From 7db430a3521ed760de56ffa12e7ec18c22faf7d8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:20:02 -0300 Subject: [PATCH] Release v3.8.14 (#3340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): open v3.8.14 development cycle Version bump 3.8.13 -> 3.8.14 (root + electron + open-sse + openapi + lockfiles). Seed the v3.8.14 changelog with the four post-tag hotfixes that shipped to Docker/Electron in v3.8.13 but missed the immutable npm 3.8.13 (#3336 SSRF / CodeQL #323, #3334/#3335/#3339 Electron packaging). i18n CHANGELOG mirrors get the in-progress placeholder section. * feat: add per-provider custom headers support for OpenAI/Anthropic-compatible nodes (#3338) Integrated into release/v3.8.14 * fix: Kiro Builder ID token import fails with Bad credentials (#3333) Integrated into release/v3.8.14 — adds Builder ID cached-creds + OIDC refresh path for Kiro token import, with regression tests (#3333). * Improve code quality: auto-pr/docstrings-1780792063 (#3337) Integrated into release/v3.8.14 — docstring for context analytics route re-export. * fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 upstream) (#3329) (#3341) NVIDIA NIM does not host minimaxai/minimax-m3 — every request returns 404 page not found, while sibling minimaxai/minimax-m2.7 on the same provider works. Advertising a model that 404s is a catalog bug; remove it from the nvidia tier (it remains on the tiers that actually serve MiniMax M3). Re-add only once NVIDIA serves it. Co-authored-by: mikmaneggahommie * fix(cli): write OpenCode config to ~/.config on all platforms incl. Windows (#3330) (#3343) resolveOpencodeConfigDir used %APPDATA% on Windows, but OpenCode reads its config from XDG ~/.config/opencode/ on every platform (on Windows: %USERPROFILE%\.config\opencode\, NOT %APPDATA%). So a Windows user who configured OpenCode via the dashboard had the file written where OpenCode never looks — it silently had no effect. Use the XDG path (XDG_CONFIG_HOME || ~/.config) unconditionally. Update the UI note + route JSDoc, and flip the three tests that encoded the old %APPDATA% behavior (t40 per-platform + card-note, cli-runtime-extended getCliConfigPaths). Co-authored-by: abdulkadirozyurt * fix(proxy): make auto-selection fallback opt-in (#3332) (#3344) selectWorkingProxyFallback (Step 11 of resolveProxyForConnection) listed ALL registry proxies, ignoring assignments and per-connection proxy_enabled, and returned the first working one with level:'autoSelect'. So a single proxy added to the registry silently became a global fallback for every connection's traffic. Gate it behind a new PROXY_AUTO_SELECT_ENABLED feature flag (default off): the fallback now no-ops unless the operator opts in. No registry proxy becomes a silent global default anymore. Co-authored-by: hertznsk * fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3328) (#3342) MiniMax M3 via the opencode provider (oc/minimax-m3-free) appeared blind: image inputs didn't reach the model, while the same model in Cline could see them. Verified empirically that MiniMax M3 on the opencode upstream IS multimodal -- a base64 image is described correctly (it returns 403 only for remote image URLs, which it doesn't accept). Root cause: OmniRoute treated MiniMax M3 as a non-vision model in two places, so when compression was active the image was replaced with a text placeholder before dispatch: - compression's modelSupportsVision() heuristic (lite.ts) only matched gpt-4/4o/claude-3/gemini/vision -- minimax was absent -> replaceImageUrls stripped the image. - the opencode minimax-m3-free catalog entry lacked supportsVision, so the combo vision-capability gate could also exclude/mishandle it. Add 'minimax-m3' to the vision heuristic and supportsVision: true to the opencode minimax-m3-free entry. TDD: a failing-then-passing test in compression/lite.test.ts proves replaceImageUrls now keeps images for minimax-m3 ids, plus a registry assertion mirroring the #2822 qwen test. Reported-by: @mikmaneggahommie * docs(i18n): translate 25 core documentation files to Indonesian (#3348) Integrated into release/v3.8.14 — Indonesian i18n docs. * fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350) Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332). * fix(provider-proxy): honor per-account proxy toggles (#3349) Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED. * fix(dashboard): remove duplicate Distribute Proxies button on provider page (#3352) * fix(providers): reduce proxy label noise (#3346) Integrated into release/v3.8.14 — reduce proxy label noise + a11y (aria-label/sr-only). * fix(duckduckgo): restore bare Response contract and rebase onto release/v3.8.14 (#3323) Integrated into release/v3.8.14 — browser-backed cookie providers (duckduckgo/claude-web) with restored executor contract + unit tests. * fix(noauth): expose only usable model aliases (#3345) Integrated into release/v3.8.14 — noauth usable-alias filtering + registry alias plumbing (veo-free). * fix(dashboard): stop infinite config-load loop on Hermes Agent detail page (#3353) * fix(electron): tree-kill the server on exit/update to release the omniroute.exe lock (#3347) (#3354) * chore(release): finalize v3.8.14 changelog + clear release-gate drift - CHANGELOG: finalize the v3.8.14 section (date, full New Features/Bug Fixes/ Maintenance coverage of all 16 cycle commits, Contributors hall of 12). - docs: document OMNIROUTE_BROWSER_POOL + WEB_COOKIE_USE_BROWSER (#3323) in .env.example + ENVIRONMENT.md; regenerate the id/llm.txt strict mirror (#3348 had translated it; llm.txt mirrors must match root). - test(proxy-fetch): #3323 made tlsClient.available a computed getter — stub it via Object.defineProperty instead of assignment (5 tests were red on the base). * fix(translator): coerce Gemini functionDeclaration parameters to an OBJECT schema (#3357) (#3360) * fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358) Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering). * docs(changelog): add #3358 Gemini textual tool-call normalization to v3.8.14 * fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361) The Analytics page discarded the server's error body on a non-OK response and rendered a generic "An error occurred", so users (and maintainers) could not see why /api/usage/analytics 500'd after an upgrade. Now the route returns the real reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy error shapes. Reported-by: @superti4r --------- Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com> Co-authored-by: Dong Mengzhe <154944819+Lang-Qiu@users.noreply.github.com> Co-authored-by: mikmaneggahommie Co-authored-by: abdulkadirozyurt Co-authored-by: hertznsk Co-authored-by: Krisna Santosa <54174372+KrisnaSantosa15@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Wilson Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Ardem2025 --- .env.example | 9 + CHANGELOG.md | 61 + docs/i18n/ar/CHANGELOG.md | 6 + docs/i18n/az/CHANGELOG.md | 6 + docs/i18n/bg/CHANGELOG.md | 6 + docs/i18n/bn/CHANGELOG.md | 6 + docs/i18n/cs/CHANGELOG.md | 6 + docs/i18n/da/CHANGELOG.md | 6 + docs/i18n/de/CHANGELOG.md | 6 + docs/i18n/es/CHANGELOG.md | 6 + docs/i18n/fa/CHANGELOG.md | 6 + docs/i18n/fi/CHANGELOG.md | 6 + docs/i18n/fr/CHANGELOG.md | 6 + docs/i18n/gu/CHANGELOG.md | 6 + docs/i18n/he/CHANGELOG.md | 6 + docs/i18n/hi/CHANGELOG.md | 6 + docs/i18n/hu/CHANGELOG.md | 6 + docs/i18n/id/CHANGELOG.md | 6 + docs/i18n/id/CODE_OF_CONDUCT.md | 135 +- docs/i18n/id/CONTRIBUTING.md | 298 ++-- docs/i18n/id/GEMINI.md | 28 +- docs/i18n/id/README.md | 1514 ++++++++--------- docs/i18n/id/SECURITY.md | 186 +- .../i18n/id/docs/architecture/ARCHITECTURE.md | 888 +++++----- .../architecture/CODEBASE_DOCUMENTATION.md | 370 ++-- .../id/docs/cloudflare-zero-trust-guide.md | 124 +- docs/i18n/id/docs/features/context-relay.md | 134 +- docs/i18n/id/docs/frameworks/A2A-SERVER.md | 68 +- docs/i18n/id/docs/frameworks/MCP-SERVER.md | 90 +- docs/i18n/id/docs/guides/FEATURES.md | 222 +-- docs/i18n/id/docs/guides/I18N.md | 374 ++-- docs/i18n/id/docs/guides/TROUBLESHOOTING.md | 340 ++-- docs/i18n/id/docs/guides/UNINSTALL.md | 80 +- docs/i18n/id/docs/guides/USER_GUIDE.md | 558 +++--- docs/i18n/id/docs/ops/COVERAGE_PLAN.md | 162 +- .../id/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md | 282 +-- docs/i18n/id/docs/ops/RELEASE_CHECKLIST.md | 44 +- docs/i18n/id/docs/ops/VM_DEPLOYMENT_GUIDE.md | 148 +- docs/i18n/id/docs/reference/API_REFERENCE.md | 372 ++-- docs/i18n/id/docs/reference/CLI-TOOLS.md | 226 +-- docs/i18n/id/docs/reference/ENVIRONMENT.md | 732 ++++---- docs/i18n/id/docs/routing/AUTO-COMBO.md | 70 +- docs/i18n/in/CHANGELOG.md | 6 + docs/i18n/it/CHANGELOG.md | 6 + docs/i18n/ja/CHANGELOG.md | 6 + docs/i18n/ko/CHANGELOG.md | 6 + docs/i18n/mr/CHANGELOG.md | 6 + docs/i18n/ms/CHANGELOG.md | 6 + docs/i18n/nl/CHANGELOG.md | 6 + docs/i18n/no/CHANGELOG.md | 6 + docs/i18n/phi/CHANGELOG.md | 6 + docs/i18n/pl/CHANGELOG.md | 6 + docs/i18n/pt-BR/CHANGELOG.md | 6 + docs/i18n/pt/CHANGELOG.md | 6 + docs/i18n/ro/CHANGELOG.md | 6 + docs/i18n/ru/CHANGELOG.md | 6 + docs/i18n/sk/CHANGELOG.md | 6 + docs/i18n/sv/CHANGELOG.md | 6 + docs/i18n/sw/CHANGELOG.md | 6 + docs/i18n/ta/CHANGELOG.md | 6 + docs/i18n/te/CHANGELOG.md | 6 + docs/i18n/th/CHANGELOG.md | 6 + docs/i18n/tr/CHANGELOG.md | 6 + docs/i18n/uk-UA/CHANGELOG.md | 6 + docs/i18n/ur/CHANGELOG.md | 6 + docs/i18n/vi/CHANGELOG.md | 6 + docs/i18n/zh-CN/CHANGELOG.md | 6 + docs/reference/ENVIRONMENT.md | 2 + docs/reference/openapi.yaml | 2 +- electron/main.js | 14 +- electron/package-lock.json | 4 +- electron/package.json | 3 +- electron/processTree.js | 62 + open-sse/config/providerRegistry.ts | 45 +- open-sse/config/registryUtils.ts | 23 +- open-sse/config/videoRegistry.ts | 14 + open-sse/executors/claude-web.ts | 530 ++++-- open-sse/executors/default.ts | 57 +- open-sse/executors/duckduckgo-web.ts | 893 ++++++++-- open-sse/handlers/chatCore.ts | 84 +- open-sse/handlers/responseSanitizer.ts | 9 +- open-sse/handlers/videoGeneration.ts | 48 + open-sse/package.json | 2 +- open-sse/services/browserBackedChat.ts | 849 +++++++++ open-sse/services/browserPool.ts | 380 +++++ open-sse/services/claudeTlsClient.ts | 2 +- open-sse/services/compression/lite.ts | 5 +- open-sse/services/proxyAutoSelector.ts | 7 +- .../helpers/geminiToolsSanitizer.ts | 35 +- .../translator/response/gemini-to-openai.ts | 114 +- open-sse/utils/proxyFallback.ts | 7 + open-sse/utils/proxyFetch.ts | 13 +- open-sse/utils/stream.ts | 141 +- open-sse/utils/textualToolCall.ts | 101 ++ open-sse/utils/tlsClient.ts | 148 +- package-lock.json | 6 +- package.json | 2 +- .../components/HermesAgentToolCard.tsx | 62 +- .../dashboard/providers/[id]/page.tsx | 85 +- .../guide-settings/[toolId]/route.ts | 6 +- src/app/api/context/analytics/route.ts | 6 +- src/app/api/oauth/kiro/import/route.ts | 2 +- src/app/api/provider-nodes/[id]/route.ts | 9 +- src/app/api/provider-nodes/route.ts | 4 +- src/app/api/providers/route.ts | 2 + src/app/api/usage/analytics/route.ts | 8 +- src/app/api/v1/models/catalog.ts | 15 +- src/i18n/messages/en.json | 2 + src/lib/db/core.ts | 42 +- src/lib/db/migrationRunner.ts | 10 +- .../095_provider_node_custom_headers.sql | 5 + src/lib/db/providers.ts | 89 +- src/lib/db/settings.ts | 70 +- src/lib/oauth/services/kiro.ts | 104 +- src/shared/components/UsageAnalytics.tsx | 3 +- src/shared/constants/cliTools.ts | 2 +- .../constants/featureFlagDefinitions.ts | 12 + src/shared/constants/modelSpecs.ts | 16 + src/shared/constants/upstreamHeaders.ts | 20 + src/shared/services/cliRuntime.ts | 13 +- src/shared/utils/fetchError.ts | 26 + src/shared/validation/schemas.ts | 22 +- src/sse/services/auth.ts | 83 +- .../auth-opencode-zen-noauth-fallback.test.ts | 23 +- tests/unit/autoRefreshDaemon.test.ts | 87 - tests/unit/browserBackedChat-matcher.test.ts | 75 + tests/unit/cli-runtime-detection.test.ts | 23 +- tests/unit/cli-runtime-extended.test.ts | 7 +- tests/unit/compression/lite.test.ts | 18 + .../custom-headers-provider-nodes.test.ts | 606 +++++++ tests/unit/duckduckgo-web-executor.test.ts | 2 +- tests/unit/electron-processtree.test.ts | 105 ++ tests/unit/executor-web-cookie-sweep.test.ts | 220 +++ tests/unit/feature-flags-settings.test.ts | 10 +- tests/unit/fetch-error-message.test.ts | 47 + .../gemini-tool-params-object-3357.test.ts | 77 + .../unit/kiro-builder-id-import-3333.test.ts | 192 +++ tests/unit/minimax-m3-model-registry.test.ts | 6 +- tests/unit/models-catalog-route.test.ts | 9 +- .../nvidia-minimax-m3-removed-3329.test.ts | 19 + ...provider-connections-ui-regression.test.ts | 66 + .../provider-registry-qwen-vision.test.ts | 13 + .../unit/proxy-autoselect-optin-3332.test.ts | 55 + tests/unit/proxy-fetch.test.ts | 12 +- tests/unit/proxy-registry.test.ts | 75 + tests/unit/registry-utils.test.ts | 3 + tests/unit/review-reviews-v3814-fixes.test.ts | 234 +++ tests/unit/stream-utils.test.ts | 151 ++ ...t40-opencode-cli-tools-integration.test.ts | 17 +- tests/unit/tlsClient-circuit-breaker.test.ts | 151 ++ .../translator-resp-gemini-to-openai.test.ts | 100 ++ tests/unit/tryBackedChat.test.ts | 187 ++ .../unit/ui/hermes-agent-config-loop.test.tsx | 118 ++ 153 files changed, 10266 insertions(+), 4523 deletions(-) create mode 100644 electron/processTree.js create mode 100644 open-sse/services/browserBackedChat.ts create mode 100644 open-sse/services/browserPool.ts create mode 100644 open-sse/utils/textualToolCall.ts create mode 100644 src/lib/db/migrations/095_provider_node_custom_headers.sql create mode 100644 src/shared/utils/fetchError.ts delete mode 100644 tests/unit/autoRefreshDaemon.test.ts create mode 100644 tests/unit/browserBackedChat-matcher.test.ts create mode 100644 tests/unit/custom-headers-provider-nodes.test.ts create mode 100644 tests/unit/electron-processtree.test.ts create mode 100644 tests/unit/executor-web-cookie-sweep.test.ts create mode 100644 tests/unit/fetch-error-message.test.ts create mode 100644 tests/unit/gemini-tool-params-object-3357.test.ts create mode 100644 tests/unit/kiro-builder-id-import-3333.test.ts create mode 100644 tests/unit/nvidia-minimax-m3-removed-3329.test.ts create mode 100644 tests/unit/provider-connections-ui-regression.test.ts create mode 100644 tests/unit/proxy-autoselect-optin-3332.test.ts create mode 100644 tests/unit/review-reviews-v3814-fixes.test.ts create mode 100644 tests/unit/tlsClient-circuit-breaker.test.ts create mode 100644 tests/unit/tryBackedChat.test.ts create mode 100644 tests/unit/ui/hermes-agent-config-loop.test.tsx diff --git a/.env.example b/.env.example index ca1fb8f6a0..d192c0a989 100644 --- a/.env.example +++ b/.env.example @@ -846,6 +846,15 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000 # OMNIROUTE_GROK_TLS_GRACE_MS=10000 +# ── Browser-backed web-cookie chat (Playwright shared pool) ── +# Used by: open-sse/services/browserPool.ts + browserBackedChat.ts. The shared +# browser pool warms a headless context for web-cookie providers (e.g. claude-web) +# that need a real browser to satisfy anti-bot challenges. Set OMNIROUTE_BROWSER_POOL=off +# to fully disable the pool; set WEB_COOKIE_USE_BROWSER=1 to opt a web-cookie chat +# request into the browser-backed path. +# OMNIROUTE_BROWSER_POOL=on +# WEB_COOKIE_USE_BROWSER=0 + # ── Circuit breaker thresholds and reset windows ── # Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts. # Defaults match historical PROVIDER_PROFILES values (post-scaling for diff --git a/CHANGELOG.md b/CHANGELOG.md index 21afca071d..93460c69ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,67 @@ --- +## [3.8.14] — 2026-06-07 + +### ✨ New Features + +- **feat(api):** per-provider **custom headers** for OpenAI/Anthropic-compatible provider nodes — attach operator-defined headers (e.g. tenant/routing headers) to upstream requests via a new `customHeaders` field on provider nodes (`custom_headers_json` column, migration 095). Hardened on merge: values/names validated through the canonical `upstreamHeadersRecordSchema` (CRLF/control-char/length/16-max) with a single shared `isForbiddenCustomHeaderName()` denylist (hop-by-hop + auth), applied case-insensitively, and honored for `anthropic-compatible-cc-*` nodes too. ([#3338](https://github.com/diegosouzapw/OmniRoute/pull/3338) — thanks @pizzav-xyz / @diegosouzapw) + +### 🔒 Security + +- **fix(security):** provider auto-sync self-fetch now uses a trusted loopback/env-pinned origin (`getModelSyncInternalBaseUrl()`) instead of `new URL(request.url).origin`, so a management-authenticated caller can no longer redirect the credential-bearing internal request to an arbitrary host via the `Host` header (CodeQL `js/request-forgery`, critical). Shipped to Docker/Electron in v3.8.13; reaches npm here (npm `3.8.13` was immutable). ([#3336](https://github.com/diegosouzapw/OmniRoute/pull/3336), CodeQL #323 — thanks @diegosouzapw) + +### 🔧 Bug Fixes + +- **fix(translator):** every Gemini/Vertex `functionDeclaration.parameters` is now coerced to an OBJECT-typed schema before cleaning. Clients like GitHub Copilot send some tools (e.g. `terminal_last_command`) whose `parameters` is present but lacks a top-level `type: "object"` (just `{ properties }`, a scalar type, or `{}`); these slipped through `buildGeminiTools`' `params || default` guard and Vertex rejected them with `[400] ... functionDeclaration parameters schema should be of type OBJECT`. Hardens every OpenAI→Gemini tool request (Vertex / antigravity / agy / gemini). (#3357 — thanks @nullbytef0x) +- **fix(gemini):** normalize Gemini/Antigravity textual `[Tool call: ...]` markers — stop suppressing **false positives** (legitimate assistant prose that merely mentions `[Tool call: terminal]`, e.g. in backticks, is preserved instead of being swallowed) and correctly buffer markers **split across streaming chunks** (`[Tool` + ` call: terminal]` + `Arguments: {...}`), flushing the text when it turns out not to be a tool call. Dedups the parsing/validation into a shared `open-sse/utils/textualToolCall.ts` (new `isValidToolCallHeaderPrefix`) and adds `gemini-2.5-flash`/`gemini-3.5-flash-low` model specs. ([#3358](https://github.com/diegosouzapw/OmniRoute/pull/3358) — thanks @Ardem2025 / @diegosouzapw) +- **fix(electron):** clicking "Exit" (or applying an update) now terminates the **whole** server process tree, not just the direct child. The embedded server runs as `omniroute.exe`-as-node (`ELECTRON_RUN_AS_NODE`) and spawns grandchildren (embedded services, MITM proxy, tunnels); on Windows `ChildProcess.kill()` only terminates the direct child, so survivors kept `omniroute.exe` locked — the process "hung in memory" after Exit and updates failed with "file in use". New `killProcessTree()` helper uses `taskkill /PID /T /F` on Windows (signal-based on POSIX); wired into `stopNextServer`, the `waitForServerExit` force-kill, and `installUpdate`. (#3347 — thanks @Flexible78) +- **fix(proxy):** proxy auto-selection is now **opt-in** (new `PROXY_AUTO_SELECT_ENABLED` flag, default off). Previously a single proxy in the registry silently became a global fallback for **all** provider connections (the Step-11 fallback listed every registry proxy, ignoring assignments and per-connection `proxy_enabled`). It now no-ops unless the operator enables the flag. (#3332 — thanks @hertznsk) +- **fix(cli):** write the OpenCode config to `~/.config/opencode/opencode.json` on **all** platforms — on Windows OmniRoute wrote to `%APPDATA%\opencode\` but OpenCode reads from `%USERPROFILE%\.config\opencode\` (XDG), so dashboard-saved config silently had no effect. (#3330 — thanks @abdulkadirozyurt) +- **fix(catalog):** remove `minimaxai/minimax-m3` from the **NVIDIA NIM** tier — NVIDIA does not host it yet, so every request 404'd (`404 page not found`), while sibling `minimax-m2.7` on the same provider works. MiniMax M3 stays available on the tiers that actually serve it. (#3329 — thanks @mikmaneggahommie) +- **fix(sse):** treat **MiniMax M3** as multimodal so the compression layer no longer strips image parts from vision requests — `lite.ts modelSupportsVision` now keeps images for `minimax-m3*` (see also the registry `supportsVision` alignment in Maintenance). ([#3328](https://github.com/diegosouzapw/OmniRoute/pull/3328) — thanks @diegosouzapw) +- **fix(oauth):** Kiro **Builder ID** token import no longer fails with "Bad credentials" — `validateImportToken` only ever tried the social-auth refresh; it now uses the cached AWS SSO `clientId`/`clientSecret` (`~/.aws/sso/cache/*.json`) and the OIDC refresh path (`authMethod: "builder-id"`), with a TDD harness. ([#3333](https://github.com/diegosouzapw/OmniRoute/pull/3333) — thanks @quanturbo / @diegosouzapw) +- **fix(provider-proxy):** honor per-account proxy toggles — a connection with `proxy_enabled = false` is no longer forced through an assigned/registry proxy. ([#3349](https://github.com/diegosouzapw/OmniRoute/pull/3349) — thanks @rdself) +- **fix(providers):** reduce proxy label noise on the provider page (clearer proxy assignment/state display). ([#3346](https://github.com/diegosouzapw/OmniRoute/pull/3346) — thanks @wilsonicdev) +- **fix(noauth):** expose only **usable** model aliases for no-auth providers, so the catalog no longer advertises aliases that can't actually be called. ([#3345](https://github.com/diegosouzapw/OmniRoute/pull/3345) — thanks @oyi77) +- **fix(duckduckgo):** restore the bare `Response` contract for the DuckDuckGo/browser-backed executor (rebased onto the cycle), fixing a wrapping-contract regression. ([#3323](https://github.com/diegosouzapw/OmniRoute/pull/3323) — thanks @oyi77 / @diegosouzapw) +- **fix(dashboard):** drop the duplicate "Distribute Proxies" button on the provider page — it rendered twice at once (provider toolbar + accounts-list header) whenever connections existed and none were selected. The toolbar button (global) and the per-tag-group buttons remain. ([#3352](https://github.com/diegosouzapw/OmniRoute/pull/3352) — thanks @diegosouzapw) +- **fix(electron):** ship `loginManager.js` in the packaged app — #3292 added it (and a `require("./loginManager")` in `main.js`) without adding it to electron-builder's `build.files`, so the packaged app crashed at startup with "Cannot find module" on the Linux/macOS smoke tests. Plus a regression test asserting every local `require("./x")` in the Electron entry points is shipped. ([#3334](https://github.com/diegosouzapw/OmniRoute/pull/3334) — thanks @diegosouzapw) +- **fix(startup):** correct the #3292 auto-refresh daemon import (`@/open-sse/...` → `@omniroute/open-sse/services/autoRefreshDaemon`); the `@/` alias maps to `src/`, so the daemon silently never ran in the built standalone (non-fatal "Cannot find module", caught at runtime). Adds a regression test banning `@/open-sse/*` imports in `src/`. ([#3335](https://github.com/diegosouzapw/OmniRoute/pull/3335) — thanks @diegosouzapw) +- **fix(electron):** wrap `autoUpdater.checkForUpdates()` so a 404/offline/rate-limited update check can no longer surface as an unhandled rejection (the `error` event still notifies the user); fixes the macOS-intel packaged-app smoke failure. ([#3339](https://github.com/diegosouzapw/OmniRoute/pull/3339) — thanks @diegosouzapw) +- **fix(dashboard):** stop the infinite render loop on `/dashboard/cli-agents/hermes-agent` — `HermesAgentToolCard` listed `currentRoles` in the config-load effect's deps while `loadCurrentConfig()` set `currentRoles` to a fresh object on every fetch, so the effect re-fired → refetched → re-set forever (the page spun and spammed `GET /api/cli-tools/hermes-agent-settings` in the console; it manifested only on the always-expanded detail page). `loadCurrentConfig` is now memoized and the batch-seed reads `currentRoles` via a functional update, so the effect runs once. Adds a jsdom regression test asserting the settings endpoint is fetched a bounded number of times. ([#3353](https://github.com/diegosouzapw/OmniRoute/pull/3353) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **fix(review):** harden the per-provider custom-headers feature surfaced by the `/review-reviews` battery — `updateProviderNode` no longer wipes stored `custom_headers_json` on a partial update that omits the field; `customHeadersSchema` reuses the canonical `upstreamHeadersRecordSchema` guards (CRLF/control-char/length/16-max) and rejects auth header names via a single shared `isForbiddenCustomHeaderName()` denylist (executor + schema no longer keep divergent copies); custom headers now reach the wire for `anthropic-compatible-cc-*` nodes and override the executor's own `Content-Type`/`Accept` case-insensitively instead of duplicating them; and `rowToCamel` normalizes a NULL `_json` column to `baseKey: null`. ([#3350](https://github.com/diegosouzapw/OmniRoute/pull/3350) — thanks @diegosouzapw) +- **fix(catalog):** flag every `minimax-m3` registry entry `supportsVision` (not just the opencode free tier) so the vision-bridge guardrail and the compression layer agree the model is multimodal on all tiers (completes #3328). (thanks @diegosouzapw) +- **fix(oauth):** Kiro Builder ID import forwards the requested `region` to the OIDC validation refresh (no longer pinned to `us-east-1`), prefers the region-matching cached SSO client registration over the first file found, and falls `expiresIn` back to 3600 on the OIDC path. (thanks @diegosouzapw) +- **fix(db):** migration `095` gains an `isSchemaAlreadyApplied` guard so a fresh DB (where `SCHEMA_SQL` already creates `custom_headers_json`) skips it cleanly instead of throwing-then-catching a duplicate-column error. (thanks @diegosouzapw) +- **test:** align stale cycle tests with shipped behavior — NVIDIA `minimaxai/minimax-m3` removal (#3329), the 29th feature flag (`PROXY_AUTO_SELECT_ENABLED`, #3332), and the OpenCode `~/.config` path on Windows (#3330). (thanks @diegosouzapw) +- **docs:** add a documentation comment to the exported `GET` handler in the context-analytics route. ([#3337](https://github.com/diegosouzapw/OmniRoute/pull/3337) — thanks @Lang-Qiu) +- **docs(i18n):** translate 25 core documentation files to Indonesian. ([#3348](https://github.com/diegosouzapw/OmniRoute/pull/3348) — thanks @KrisnaSantosa15) + +### 🙌 Contributors + +Thanks to everyone whose work landed in v3.8.14: + +| Contributor | PRs / Issues | +| --- | --- | +| [@pizzav-xyz](https://github.com/pizzav-xyz) | #3338 | +| [@quanturbo](https://github.com/quanturbo) | #3333 | +| [@oyi77](https://github.com/oyi77) | #3323, #3345 | +| [@rdself](https://github.com/rdself) | #3349 | +| [@wilsonicdev](https://github.com/wilsonicdev) | #3346 | +| [@hertznsk](https://github.com/hertznsk) | #3332 | +| [@abdulkadirozyurt](https://github.com/abdulkadirozyurt) | #3330 | +| [@mikmaneggahommie](https://github.com/mikmaneggahommie) | #3329 | +| [@Flexible78](https://github.com/Flexible78) | #3347 | +| [@Lang-Qiu](https://github.com/Lang-Qiu) | #3337 | +| [@KrisnaSantosa15](https://github.com/KrisnaSantosa15) | #3348 | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3334, #3335, #3336, #3339, #3350, #3352, #3353; review/hardening across the cycle | + +--- + ## [3.8.13] — 2026-06-06 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 4136e52273..5dd8dd41fe 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 98b9c41c1d..3a2f9f50a3 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 98b9c41c1d..3a2f9f50a3 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 9705ecb4f8..3cbd8dfec4 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index f9179b1f2a..586abb1517 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 0ff9ae4d54..6332a18c43 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 3f5c0ba412..5193d0ed52 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index add692fc28..43eeefcbf2 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index a3de3e8340..736f0c27ea 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index d886bcc0aa..3b29d496b4 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 4cb69e0825..aeeb558992 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index f272199b0c..88370b8445 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index c5dcae3884..aa836eed70 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index c8162bab02..6f6f039e90 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index a8dea7557b..760b72e550 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 6ee3a8f3be..9369d6099c 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CODE_OF_CONDUCT.md b/docs/i18n/id/CODE_OF_CONDUCT.md index 7c95a80f5d..6219f74923 100644 --- a/docs/i18n/id/CODE_OF_CONDUCT.md +++ b/docs/i18n/id/CODE_OF_CONDUCT.md @@ -1,132 +1,89 @@ -# Contributor Covenant Code of Conduct (Bahasa Indonesia) +# Kode Etik Contributor Covenant (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../CODE_OF_CONDUCT.md) · 🇸🇦 [ar](../ar/CODE_OF_CONDUCT.md) · 🇧🇬 [bg](../bg/CODE_OF_CONDUCT.md) · 🇧🇩 [bn](../bn/CODE_OF_CONDUCT.md) · 🇨🇿 [cs](../cs/CODE_OF_CONDUCT.md) · 🇩🇰 [da](../da/CODE_OF_CONDUCT.md) · 🇩🇪 [de](../de/CODE_OF_CONDUCT.md) · 🇪🇸 [es](../es/CODE_OF_CONDUCT.md) · 🇮🇷 [fa](../fa/CODE_OF_CONDUCT.md) · 🇫🇮 [fi](../fi/CODE_OF_CONDUCT.md) · 🇫🇷 [fr](../fr/CODE_OF_CONDUCT.md) · 🇮🇳 [gu](../gu/CODE_OF_CONDUCT.md) · 🇮🇱 [he](../he/CODE_OF_CONDUCT.md) · 🇮🇳 [hi](../hi/CODE_OF_CONDUCT.md) · 🇭🇺 [hu](../hu/CODE_OF_CONDUCT.md) · 🇮🇩 [id](../id/CODE_OF_CONDUCT.md) · 🇮🇹 [it](../it/CODE_OF_CONDUCT.md) · 🇯🇵 [ja](../ja/CODE_OF_CONDUCT.md) · 🇰🇷 [ko](../ko/CODE_OF_CONDUCT.md) · 🇮🇳 [mr](../mr/CODE_OF_CONDUCT.md) · 🇲🇾 [ms](../ms/CODE_OF_CONDUCT.md) · 🇳🇱 [nl](../nl/CODE_OF_CONDUCT.md) · 🇳🇴 [no](../no/CODE_OF_CONDUCT.md) · 🇵🇭 [phi](../phi/CODE_OF_CONDUCT.md) · 🇵🇱 [pl](../pl/CODE_OF_CONDUCT.md) · 🇵🇹 [pt](../pt/CODE_OF_CONDUCT.md) · 🇧🇷 [pt-BR](../pt-BR/CODE_OF_CONDUCT.md) · 🇷🇴 [ro](../ro/CODE_OF_CONDUCT.md) · 🇷🇺 [ru](../ru/CODE_OF_CONDUCT.md) · 🇸🇰 [sk](../sk/CODE_OF_CONDUCT.md) · 🇸🇪 [sv](../sv/CODE_OF_CONDUCT.md) · 🇰🇪 [sw](../sw/CODE_OF_CONDUCT.md) · 🇮🇳 [ta](../ta/CODE_OF_CONDUCT.md) · 🇮🇳 [te](../te/CODE_OF_CONDUCT.md) · 🇹🇭 [th](../th/CODE_OF_CONDUCT.md) · 🇹🇷 [tr](../tr/CODE_OF_CONDUCT.md) · 🇺🇦 [uk-UA](../uk-UA/CODE_OF_CONDUCT.md) · 🇵🇰 [ur](../ur/CODE_OF_CONDUCT.md) · 🇻🇳 [vi](../vi/CODE_OF_CONDUCT.md) · 🇨🇳 [zh-CN](../zh-CN/CODE_OF_CONDUCT.md) --- -## Our Pledge +## Ikrar Kami -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. +Kami sebagai anggota, kontributor, dan pemimpin berjanji untuk menjadikan partisipasi dalam komunitas kami sebagai pengalaman yang bebas dari pelecehan bagi semua orang, tanpa memandang usia, ukuran tubuh, disabilitas yang terlihat maupun tidak terlihat, etnisitas, karakteristik seks, identitas dan ekspresi gender, tingkat pengalaman, pendidikan, status sosial-ekonomi, kebangsaan, penampilan pribadi, ras, agama, atau identitas dan orientasi seksual. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +Kami berjanji untuk bertindak dan berinteraksi dengan cara yang berkontribusi pada komunitas yang terbuka, ramah, beragam, inklusif, dan sehat. -## Our Standards +## Standar Kami -Examples of behavior that contributes to a positive environment for our -community include: +Contoh perilaku yang berkontribusi pada lingkungan yang positif bagi komunitas kami meliputi: -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community +- Menunjukkan empati dan kebaikan terhadap orang lain +- Menghormati pendapat, sudut pandang, dan pengalaman yang berbeda +- Memberikan dan menerima umpan balik yang konstruktif dengan lapang dada +- Menerima tanggung jawab dan meminta maaf kepada mereka yang terdampak oleh kesalahan kita, serta belajar dari pengalaman tersebut +- Berfokus pada apa yang terbaik bukan hanya bagi kita sebagai individu, tetapi bagi komunitas secara keseluruhan -Examples of unacceptable behavior include: +Contoh perilaku yang tidak dapat diterima meliputi: -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- Penggunaan bahasa atau citra yang bersifat seksual, serta perhatian atau rayuan seksual dalam bentuk apa pun +- Trolling, komentar menghina atau merendahkan, serta serangan pribadi atau politis +- Pelecehan di ranah publik maupun privat +- Mempublikasikan informasi pribadi orang lain, seperti alamat fisik atau email, tanpa izin eksplisit mereka +- Perilaku lain yang secara wajar dapat dianggap tidak pantas dalam lingkungan profesional -## Enforcement Responsibilities +## Tanggung Jawab Penegakan -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Pemimpin komunitas bertanggung jawab untuk mengklarifikasi dan menegakkan standar perilaku yang dapat diterima, serta akan mengambil tindakan korektif yang tepat dan adil sebagai respons terhadap perilaku apa pun yang mereka anggap tidak pantas, mengancam, menyinggung, atau merugikan. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Pemimpin komunitas memiliki hak dan tanggung jawab untuk menghapus, mengedit, atau menolak komentar, commit, kode, suntingan wiki, isu, dan kontribusi lain yang tidak sesuai dengan Kode Etik ini, serta akan mengomunikasikan alasan keputusan moderasi bila diperlukan. -## Scope +## Cakupan -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +Kode Etik ini berlaku di semua ruang komunitas, dan juga berlaku ketika seseorang secara resmi mewakili komunitas di ruang publik. Contoh perwakilan komunitas kita meliputi penggunaan alamat email resmi, posting melalui akun media sosial resmi, atau bertindak sebagai perwakilan yang ditunjuk pada acara daring maupun luring. -## Enforcement +## Penegakan -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at +Kasus perilaku kasar, melecehkan, atau tidak dapat diterima lainnya dapat dilaporkan kepada pemimpin komunitas yang bertanggung jawab atas penegakan di . -All complaints will be reviewed and investigated promptly and fairly. +Semua pengaduan akan ditinjau dan diselidiki dengan segera dan adil. -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. +Semua pemimpin komunitas berkewajiban untuk menghormati privasi dan keamanan pelapor dari setiap insiden. -## Enforcement Guidelines +## Pedoman Penegakan -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: +Pemimpin komunitas akan mengikuti Pedoman Dampak Komunitas ini dalam menentukan konsekuensi atas setiap tindakan yang mereka anggap melanggar Kode Etik ini: -### 1. Correction +### 1. Koreksi -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. +**Dampak Komunitas**: Penggunaan bahasa yang tidak pantas atau perilaku lain yang dianggap tidak profesional atau tidak diinginkan dalam komunitas. -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. +**Konsekuensi**: Peringatan tertulis secara privat dari pemimpin komunitas, yang memberikan kejelasan mengenai sifat pelanggaran dan penjelasan mengapa perilaku tersebut tidak pantas. Permohonan maaf secara publik mungkin diminta. -### 2. Warning +### 2. Peringatan -**Community Impact**: A violation through a single incident or series -of actions. +**Dampak Komunitas**: Pelanggaran melalui satu insiden atau serangkaian tindakan. -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. +**Konsekuensi**: Peringatan dengan konsekuensi apabila perilaku tersebut berlanjut. Tidak ada interaksi dengan pihak-pihak yang terlibat, termasuk interaksi yang tidak diminta dengan mereka yang menegakkan Kode Etik, selama jangka waktu tertentu. Hal ini mencakup penghindaran interaksi di ruang komunitas maupun di saluran eksternal seperti media sosial. Melanggar ketentuan ini dapat mengakibatkan larangan sementara atau permanen. -### 3. Temporary Ban +### 3. Larangan Sementara -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. +**Dampak Komunitas**: Pelanggaran serius terhadap standar komunitas, termasuk perilaku tidak pantas yang berkelanjutan. -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. +**Konsekuensi**: Larangan sementara dari segala bentuk interaksi atau komunikasi publik dengan komunitas selama jangka waktu tertentu. Tidak ada interaksi publik maupun privat dengan pihak-pihak yang terlibat, termasuk interaksi yang tidak diminta dengan mereka yang menegakkan Kode Etik, yang diizinkan selama periode ini. Melanggar ketentuan ini dapat mengakibatkan larangan permanen. -### 4. Permanent Ban +### 4. Larangan Permanen -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. +**Dampak Komunitas**: Menunjukkan pola pelanggaran standar komunitas, termasuk perilaku tidak pantas yang berkelanjutan, pelecehan terhadap individu, atau agresi terhadap atau penghinaan kepada golongan individu tertentu. -**Consequence**: A permanent ban from any sort of public interaction within -the community. +**Konsekuensi**: Larangan permanen dari segala bentuk interaksi publik dalam komunitas. -## Attribution +## Atribusi -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at +Kode Etik ini diadaptasi dari [Contributor Covenant][homepage], +versi 2.0, tersedia di https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). +Pedoman Dampak Komunitas terinspirasi dari [tangga penegakan kode etik Mozilla](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at +Untuk jawaban atas pertanyaan umum tentang kode etik ini, lihat FAQ di +https://www.contributor-covenant.org/faq. Terjemahan tersedia di https://www.contributor-covenant.org/translations. diff --git a/docs/i18n/id/CONTRIBUTING.md b/docs/i18n/id/CONTRIBUTING.md index b1a724553a..fea9c9b7c2 100644 --- a/docs/i18n/id/CONTRIBUTING.md +++ b/docs/i18n/id/CONTRIBUTING.md @@ -1,22 +1,22 @@ -# Contributing to OmniRoute (Bahasa Indonesia) +# Berkontribusi ke OmniRoute (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../CONTRIBUTING.md) · 🇸🇦 [ar](../ar/CONTRIBUTING.md) · 🇧🇬 [bg](../bg/CONTRIBUTING.md) · 🇧🇩 [bn](../bn/CONTRIBUTING.md) · 🇨🇿 [cs](../cs/CONTRIBUTING.md) · 🇩🇰 [da](../da/CONTRIBUTING.md) · 🇩🇪 [de](../de/CONTRIBUTING.md) · 🇪🇸 [es](../es/CONTRIBUTING.md) · 🇮🇷 [fa](../fa/CONTRIBUTING.md) · 🇫🇮 [fi](../fi/CONTRIBUTING.md) · 🇫🇷 [fr](../fr/CONTRIBUTING.md) · 🇮🇳 [gu](../gu/CONTRIBUTING.md) · 🇮🇱 [he](../he/CONTRIBUTING.md) · 🇮🇳 [hi](../hi/CONTRIBUTING.md) · 🇭🇺 [hu](../hu/CONTRIBUTING.md) · 🇮🇩 [id](../id/CONTRIBUTING.md) · 🇮🇹 [it](../it/CONTRIBUTING.md) · 🇯🇵 [ja](../ja/CONTRIBUTING.md) · 🇰🇷 [ko](../ko/CONTRIBUTING.md) · 🇮🇳 [mr](../mr/CONTRIBUTING.md) · 🇲🇾 [ms](../ms/CONTRIBUTING.md) · 🇳🇱 [nl](../nl/CONTRIBUTING.md) · 🇳🇴 [no](../no/CONTRIBUTING.md) · 🇵🇭 [phi](../phi/CONTRIBUTING.md) · 🇵🇱 [pl](../pl/CONTRIBUTING.md) · 🇵🇹 [pt](../pt/CONTRIBUTING.md) · 🇧🇷 [pt-BR](../pt-BR/CONTRIBUTING.md) · 🇷🇴 [ro](../ro/CONTRIBUTING.md) · 🇷🇺 [ru](../ru/CONTRIBUTING.md) · 🇸🇰 [sk](../sk/CONTRIBUTING.md) · 🇸🇪 [sv](../sv/CONTRIBUTING.md) · 🇰🇪 [sw](../sw/CONTRIBUTING.md) · 🇮🇳 [ta](../ta/CONTRIBUTING.md) · 🇮🇳 [te](../te/CONTRIBUTING.md) · 🇹🇭 [th](../th/CONTRIBUTING.md) · 🇹🇷 [tr](../tr/CONTRIBUTING.md) · 🇺🇦 [uk-UA](../uk-UA/CONTRIBUTING.md) · 🇵🇰 [ur](../ur/CONTRIBUTING.md) · 🇻🇳 [vi](../vi/CONTRIBUTING.md) · 🇨🇳 [zh-CN](../zh-CN/CONTRIBUTING.md) --- -Thank you for your interest in contributing! This guide covers everything you need to get started. +Terima kasih atas minat Anda untuk berkontribusi! Panduan ini mencakup semua yang perlu Anda ketahui untuk memulai. --- -## Development Setup +## Pengaturan Pengembangan -### Prerequisites +### Persyaratan - **Node.js** >= 18 < 24 (recommended: 22 LTS) - **npm** 10+ - **Git** -### Clone & Install +### Kloning & Instalasi ```bash git clone https://github.com/diegosouzapw/OmniRoute.git @@ -24,7 +24,7 @@ cd OmniRoute npm install ``` -### Environment Variables +### Variabel Lingkungan ```bash # Create your .env from the template @@ -35,28 +35,28 @@ echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env echo "API_KEY_SECRET=$(openssl rand -hex 32)" >> .env ``` -Key variables for development: +Variabel-variabel utama untuk pengembangan: -| Variable | Development Default | Description | -| ---------------------- | ------------------------ | --------------------- | -| `PORT` | `20128` | Server port | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | Base URL for frontend | -| `JWT_SECRET` | (generate above) | JWT signing secret | -| `INITIAL_PASSWORD` | `CHANGEME` | First login password | -| `APP_LOG_LEVEL` | `info` | Log verbosity level | +| Variable | Development Default | Deskripsi | +| ---------------------- | ------------------------ | --------------------------------- | +| `PORT` | `20128` | Port server | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | URL dasar untuk frontend | +| `JWT_SECRET` | (generate above) | Kunci penandatanganan JWT | +| `INITIAL_PASSWORD` | `CHANGEME` | Kata sandi login pertama | +| `APP_LOG_LEVEL` | `info` | Tingkat verbositas log | -### Dashboard Settings +### Pengaturan Dashboard -The dashboard provides UI toggles for features that can also be configured via environment variables: +Dashboard menyediakan tombol UI untuk fitur-fitur yang juga dapat dikonfigurasi melalui variabel lingkungan: -| Setting Location | Toggle | Description | -| ------------------- | ------------------ | ------------------------------ | -| Settings → Advanced | Debug Mode | Enable debug request logs (UI) | -| Settings → General | Sidebar Visibility | Show/hide sidebar sections | +| Lokasi Pengaturan | Tombol | Deskripsi | +| ------------------- | ------------------ | ---------------------------------------- | +| Settings → Advanced | Debug Mode | Aktifkan log permintaan debug (UI) | +| Settings → General | Sidebar Visibility | Tampilkan/sembunyikan bagian sidebar | -These settings are stored in the database and persist across restarts, overriding env var defaults when set. +Pengaturan ini disimpan di database dan tetap ada setelah restart, menggantikan nilai default variabel lingkungan jika sudah diatur. -### Running Locally +### Menjalankan Secara Lokal ```bash # Development mode (hot reload) @@ -70,16 +70,16 @@ npm run start PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev ``` -Default URLs: +URL default: - **Dashboard**: `http://localhost:20128/dashboard` - **API**: `http://localhost:20128/v1` --- -## Git Workflow +## Alur Kerja Git -> ⚠️ **NEVER commit directly to `main`.** Always use feature branches. +> ⚠️ **JANGAN PERNAH melakukan commit langsung ke `main`.** Selalu gunakan cabang fitur. ```bash git checkout -b feat/your-feature-name @@ -89,20 +89,20 @@ git push -u origin feat/your-feature-name # Open a Pull Request on GitHub ``` -### Branch Naming +### Penamaan Cabang -| Prefix | Purpose | -| ----------- | ------------------------- | -| `feat/` | New features | -| `fix/` | Bug fixes | -| `refactor/` | Code restructuring | -| `docs/` | Documentation changes | -| `test/` | Test additions/fixes | -| `chore/` | Tooling, CI, dependencies | +| Awalan | Tujuan | +| ----------- | ------------------------------ | +| `feat/` | Fitur baru | +| `fix/` | Perbaikan bug | +| `refactor/` | Restrukturisasi kode | +| `docs/` | Perubahan dokumentasi | +| `test/` | Penambahan/perbaikan tes | +| `chore/` | Perkakas, CI, dependensi | -### Commit Messages +### Pesan Commit -Follow [Conventional Commits](https://www.conventionalcommits.org/): +Ikuti [Conventional Commits](https://www.conventionalcommits.org/): ``` feat: add circuit breaker for provider calls @@ -112,11 +112,11 @@ test: add observability unit tests refactor(db): consolidate rate limit tables ``` -Scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills`. +Cakupan: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills`. --- -## Running Tests +## Menjalankan Tes ```bash # All tests (unit + vitest + ecosystem + e2e) @@ -146,166 +146,166 @@ npm run lint npm run check ``` -Coverage notes: +Catatan cakupan: -- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**` -- Pull requests must keep the overall coverage gate at **60% or higher** for statements, lines, functions, and branches -- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must add or update automated tests in the same PR -- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run -- `npm run test:coverage:legacy` preserves the older metric for historical comparison -- See `docs/ops/COVERAGE_PLAN.md` for the phased coverage improvement roadmap +- `npm run test:coverage` mengukur cakupan kode sumber untuk rangkaian tes unit utama, mengecualikan `tests/**`, dan menyertakan `open-sse/**` +- Pull request harus menjaga batas cakupan keseluruhan di **60% atau lebih tinggi** untuk pernyataan, baris, fungsi, dan cabang +- Jika sebuah PR mengubah kode produksi di `src/`, `open-sse/`, `electron/`, atau `bin/`, PR tersebut harus menambahkan atau memperbarui tes otomatis dalam PR yang sama +- `npm run coverage:report` mencetak laporan terperinci per file dari hasil cakupan terbaru +- `npm run test:coverage:legacy` mempertahankan metrik lama untuk perbandingan historis +- Lihat `docs/ops/COVERAGE_PLAN.md` untuk peta jalan peningkatan cakupan bertahap -### Pull Request Requirements +### Persyaratan Pull Request -Before opening or merging a PR: +Sebelum membuka atau menggabungkan sebuah PR: -- Run `npm run test:unit` -- Run `npm run test:coverage` -- Ensure the coverage gate stays at **60%+** for all metrics -- Include the changed or added test files in the PR description when production code changed -- Check the SonarQube result on the PR when the project secrets are configured in CI +- Jalankan `npm run test:unit` +- Jalankan `npm run test:coverage` +- Pastikan batas cakupan tetap di **60%+** untuk semua metrik +- Sertakan file tes yang diubah atau ditambahkan dalam deskripsi PR ketika kode produksi berubah +- Periksa hasil SonarQube pada PR ketika rahasia proyek dikonfigurasi di CI -Current test status: **122 unit test files** covering: +Status tes saat ini: **122 file tes unit** yang mencakup: -- Provider translators and format conversion -- Rate limiting, circuit breaker, and resilience -- Semantic cache, idempotency, progress tracking -- Database operations and schema (21 DB modules) -- OAuth flows and authentication -- API endpoint validation (Zod v4) -- MCP server tools and scope enforcement -- Memory and Skills systems +- Penerjemah penyedia dan konversi format +- Pembatasan laju, pemutus sirkuit, dan ketahanan +- Cache semantik, idempoten, pelacakan progres +- Operasi database dan skema (21 modul DB) +- Alur OAuth dan autentikasi +- Validasi endpoint API (Zod v4) +- Alat server MCP dan penegakan cakupan +- Sistem Memory dan Skills --- -## Code Style +## Gaya Kode -- **ESLint** — Run `npm run lint` before committing -- **Prettier** — Auto-formatted via `lint-staged` on commit (2 spaces, semicolons, double quotes, 100 char width, es5 trailing commas) -- **TypeScript** — All `src/` code uses `.ts`/`.tsx`; `open-sse/` uses `.ts`/`.js`; document with TSDoc (`@param`, `@returns`, `@throws`) -- **No `eval()`** — ESLint enforces `no-eval`, `no-implied-eval`, `no-new-func` -- **Zod validation** — Use Zod v4 schemas for all API input validation -- **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE +- **ESLint** — Jalankan `npm run lint` sebelum melakukan commit +- **Prettier** — Diformat otomatis melalui `lint-staged` saat commit (2 spasi, titik koma, tanda kutip ganda, lebar 100 karakter, koma trailing es5) +- **TypeScript** — Semua kode `src/` menggunakan `.ts`/`.tsx`; `open-sse/` menggunakan `.ts`/`.js`; dokumentasi dengan TSDoc (`@param`, `@returns`, `@throws`) +- **Tanpa `eval()`** — ESLint menerapkan `no-eval`, `no-implied-eval`, `no-new-func` +- **Validasi Zod** — Gunakan skema Zod v4 untuk semua validasi input API +- **Penamaan**: File = camelCase/kebab-case, komponen = PascalCase, konstanta = UPPER_SNAKE --- -## Project Structure +## Struktur Proyek ``` src/ # TypeScript (.ts / .tsx) ├── app/ # Next.js 16 App Router -│ ├── (dashboard)/ # Dashboard pages (23 sections) -│ ├── api/ # API routes (51 directories) -│ └── login/ # Auth pages (.tsx) -├── domain/ # Policy engine (policyEngine, comboResolver, costRules, etc.) -├── lib/ # Core business logic (.ts) -│ ├── a2a/ # Agent-to-Agent v0.3 protocol server -│ ├── acp/ # Agent Communication Protocol registry -│ ├── compliance/ # Compliance policy engine -│ ├── db/ # SQLite database layer (21 modules + 16 migrations) -│ ├── memory/ # Persistent conversational memory -│ ├── oauth/ # OAuth providers, services, and utilities -│ ├── skills/ # Extensible skill framework -│ ├── usage/ # Usage tracking and cost calculation -│ └── localDb.ts # Re-export layer only — never add logic here -├── middleware/ # Request middleware (promptInjectionGuard) -├── mitm/ # MITM proxy (cert, DNS, target routing) +│ ├── (dashboard)/ # Halaman dashboard (23 bagian) +│ ├── api/ # Rute API (51 direktori) +│ └── login/ # Halaman autentikasi (.tsx) +├── domain/ # Mesin kebijakan (policyEngine, comboResolver, costRules, dll.) +├── lib/ # Logika bisnis inti (.ts) +│ ├── a2a/ # Server protokol Agent-to-Agent v0.3 +│ ├── acp/ # Registri Agent Communication Protocol +│ ├── compliance/ # Mesin kebijakan kepatuhan +│ ├── db/ # Lapisan database SQLite (21 modul + 16 migrasi) +│ ├── memory/ # Memori percakapan persisten +│ ├── oauth/ # Penyedia, layanan, dan utilitas OAuth +│ ├── skills/ # Kerangka skill yang dapat diperluas +│ ├── usage/ # Pelacakan penggunaan dan kalkulasi biaya +│ └── localDb.ts # Lapisan re-ekspor saja — jangan pernah tambahkan logika di sini +├── middleware/ # Middleware permintaan (promptInjectionGuard) +├── mitm/ # Proxy MITM (sertifikat, DNS, perutean target) ├── shared/ -│ ├── components/ # React components (.tsx) -│ ├── constants/ # Provider definitions (60+), MCP scopes, routing strategies -│ ├── utils/ # Circuit breaker, sanitizer, auth helpers -│ └── validation/ # Zod v4 schemas -└── sse/ # SSE proxy pipeline +│ ├── components/ # Komponen React (.tsx) +│ ├── constants/ # Definisi penyedia (60+), cakupan MCP, strategi perutean +│ ├── utils/ # Pemutus sirkuit, sanitizer, pembantu autentikasi +│ └── validation/ # Skema Zod v4 +└── sse/ # Pipeline proxy SSE -open-sse/ # @omniroute/open-sse workspace -├── executors/ # 14 provider-specific request executors -├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.) -├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes) -├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.) -├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama) -├── transformer/ # Responses API transformer -└── utils/ # 22 utility modules (stream, TLS, proxy, logging) +open-sse/ # Workspace @omniroute/open-sse +├── executors/ # 14 eksekutor permintaan khusus penyedia +├── handlers/ # 11 penangan permintaan (chat, responses, embeddings, images, dll.) +├── mcp-server/ # Server MCP (25 alat, 3 transport, 10 cakupan) +├── services/ # 36+ layanan (combo, autoCombo, rateLimitManager, dll.) +├── translator/ # Penerjemah format (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama) +├── transformer/ # Transformer Responses API +└── utils/ # 22 modul utilitas (stream, TLS, proxy, logging) -electron/ # Electron desktop app (cross-platform) +electron/ # Aplikasi desktop Electron (lintas platform) tests/ -├── unit/ # Node.js test runner (122 test files) -├── integration/ # Integration tests -├── e2e/ # Playwright tests -├── security/ # Security tests -├── translator/ # Translator-specific tests -└── load/ # Load tests +├── unit/ # Runner tes Node.js (122 file tes) +├── integration/ # Tes integrasi +├── e2e/ # Tes Playwright +├── security/ # Tes keamanan +├── translator/ # Tes khusus penerjemah +└── load/ # Tes beban -docs/ # Documentation -├── ARCHITECTURE.md # System architecture -├── API_REFERENCE.md # All endpoints -├── USER_GUIDE.md # Provider setup, CLI integration -├── TROUBLESHOOTING.md # Common issues -├── MCP-SERVER.md # MCP server (25 tools) -├── A2A-SERVER.md # A2A agent protocol -├── AUTO-COMBO.md # Auto-combo engine -├── CLI-TOOLS.md # CLI tools integration -├── COVERAGE_PLAN.md # Test coverage improvement plan -├── openapi.yaml # OpenAPI specification -└── adr/ # Architecture Decision Records +docs/ # Dokumentasi +├── ARCHITECTURE.md # Arsitektur sistem +├── API_REFERENCE.md # Semua endpoint +├── USER_GUIDE.md # Pengaturan penyedia, integrasi CLI +├── TROUBLESHOOTING.md # Masalah umum +├── MCP-SERVER.md # Server MCP (25 alat) +├── A2A-SERVER.md # Protokol agen A2A +├── AUTO-COMBO.md # Mesin auto-combo +├── CLI-TOOLS.md # Integrasi alat CLI +├── COVERAGE_PLAN.md # Rencana peningkatan cakupan tes +├── openapi.yaml # Spesifikasi OpenAPI +└── adr/ # Catatan Keputusan Arsitektur ``` --- -## Adding a New Provider +## Menambahkan Penyedia Baru -### Step 1: Register Provider Constants +### Langkah 1: Daftarkan Konstanta Penyedia -Add to `src/shared/constants/providers.ts` — Zod-validated at module load. +Tambahkan ke `src/shared/constants/providers.ts` — divalidasi dengan Zod saat modul dimuat. -### Step 2: Add Executor (if custom logic needed) +### Langkah 2: Tambahkan Eksekutor (jika diperlukan logika kustom) -Create executor in `open-sse/executors/your-provider.ts` extending the base executor. +Buat eksekutor di `open-sse/executors/your-provider.ts` dengan memperluas eksekutor dasar. -### Step 3: Add Translator (if non-OpenAI format) +### Langkah 3: Tambahkan Penerjemah (jika format bukan OpenAI) -Create request/response translators in `open-sse/translator/`. +Buat penerjemah permintaan/respons di `open-sse/translator/`. -### Step 4: Add OAuth Config (if OAuth-based) +### Langkah 4: Tambahkan Konfigurasi OAuth (jika berbasis OAuth) -Add OAuth credentials in `src/lib/oauth/constants/oauth.ts` and service in `src/lib/oauth/services/`. +Tambahkan kredensial OAuth di `src/lib/oauth/constants/oauth.ts` dan layanan di `src/lib/oauth/services/`. -### Step 5: Register Models +### Langkah 5: Daftarkan Model -Add model definitions in `open-sse/config/providerRegistry.ts`. +Tambahkan definisi model di `open-sse/config/providerRegistry.ts`. -### Step 6: Add Tests +### Langkah 6: Tambahkan Tes -Write unit tests in `tests/unit/` covering at minimum: +Tulis tes unit di `tests/unit/` yang mencakup minimal: -- Provider registration -- Request/response translation -- Error handling +- Pendaftaran penyedia +- Terjemahan permintaan/respons +- Penanganan kesalahan --- -## Pull Request Checklist +## Daftar Periksa Pull Request -- [ ] Tests pass (`npm test`) -- [ ] Linting passes (`npm run lint`) -- [ ] Build succeeds (`npm run build`) -- [ ] TypeScript types added for new public functions and interfaces -- [ ] No hardcoded secrets or fallback values -- [ ] All inputs validated with Zod schemas -- [ ] CHANGELOG updated (if user-facing change) -- [ ] Documentation updated (if applicable) +- [ ] Tes lulus (`npm test`) +- [ ] Linting lulus (`npm run lint`) +- [ ] Build berhasil (`npm run build`) +- [ ] Tipe TypeScript ditambahkan untuk fungsi dan antarmuka publik baru +- [ ] Tidak ada rahasia atau nilai fallback yang dikodekan secara keras +- [ ] Semua input divalidasi dengan skema Zod +- [ ] CHANGELOG diperbarui (jika ada perubahan yang terlihat pengguna) +- [ ] Dokumentasi diperbarui (jika berlaku) --- -## Releasing +## Rilis -Releases are managed via the `/generate-release` workflow. When a new GitHub Release is created, the package is **automatically published to npm** via GitHub Actions. +Rilis dikelola melalui alur kerja `/generate-release`. Ketika GitHub Release baru dibuat, paket secara **otomatis diterbitkan ke npm** melalui GitHub Actions. --- -## Getting Help +## Mendapatkan Bantuan -- **Architecture**: See [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) -- **API Reference**: See [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) -- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) -- **ADRs**: See `docs/adr/` for architectural decision records +- **Arsitektur**: Lihat [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) +- **Referensi API**: Lihat [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) +- **Masalah**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) +- **ADR**: Lihat `docs/adr/` untuk catatan keputusan arsitektur diff --git a/docs/i18n/id/GEMINI.md b/docs/i18n/id/GEMINI.md index d9706fda42..e263e872fc 100644 --- a/docs/i18n/id/GEMINI.md +++ b/docs/i18n/id/GEMINI.md @@ -1,25 +1,25 @@ -# Security and Cleanliness Rules for AI Assistants (Bahasa Indonesia) +# Aturan Keamanan dan Kebersihan untuk Asisten AI (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../GEMINI.md) · 🇸🇦 [ar](../ar/GEMINI.md) · 🇧🇬 [bg](../bg/GEMINI.md) · 🇧🇩 [bn](../bn/GEMINI.md) · 🇨🇿 [cs](../cs/GEMINI.md) · 🇩🇰 [da](../da/GEMINI.md) · 🇩🇪 [de](../de/GEMINI.md) · 🇪🇸 [es](../es/GEMINI.md) · 🇮🇷 [fa](../fa/GEMINI.md) · 🇫🇮 [fi](../fi/GEMINI.md) · 🇫🇷 [fr](../fr/GEMINI.md) · 🇮🇳 [gu](../gu/GEMINI.md) · 🇮🇱 [he](../he/GEMINI.md) · 🇮🇳 [hi](../hi/GEMINI.md) · 🇭🇺 [hu](../hu/GEMINI.md) · 🇮🇩 [id](../id/GEMINI.md) · 🇮🇹 [it](../it/GEMINI.md) · 🇯🇵 [ja](../ja/GEMINI.md) · 🇰🇷 [ko](../ko/GEMINI.md) · 🇮🇳 [mr](../mr/GEMINI.md) · 🇲🇾 [ms](../ms/GEMINI.md) · 🇳🇱 [nl](../nl/GEMINI.md) · 🇳🇴 [no](../no/GEMINI.md) · 🇵🇭 [phi](../phi/GEMINI.md) · 🇵🇱 [pl](../pl/GEMINI.md) · 🇵🇹 [pt](../pt/GEMINI.md) · 🇧🇷 [pt-BR](../pt-BR/GEMINI.md) · 🇷🇴 [ro](../ro/GEMINI.md) · 🇷🇺 [ru](../ru/GEMINI.md) · 🇸🇰 [sk](../sk/GEMINI.md) · 🇸🇪 [sv](../sv/GEMINI.md) · 🇰🇪 [sw](../sw/GEMINI.md) · 🇮🇳 [ta](../ta/GEMINI.md) · 🇮🇳 [te](../te/GEMINI.md) · 🇹🇭 [th](../th/GEMINI.md) · 🇹🇷 [tr](../tr/GEMINI.md) · 🇺🇦 [uk-UA](../uk-UA/GEMINI.md) · 🇵🇰 [ur](../ur/GEMINI.md) · 🇻🇳 [vi](../vi/GEMINI.md) · 🇨🇳 [zh-CN](../zh-CN/GEMINI.md) --- -## 1. File Placement & Organization +## 1. Penempatan & Organisasi File -- **Test Files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`). -- **Scripts and Utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside the `scripts/` directory or `scripts/scratch/` for temporary one-offs. NEVER dump loose scripts in the project root (`/`). +- **File Tes**: SEMUA uji unit, uji integrasi, uji ekosistem, atau file Vitest HARUS ditempatkan secara ketat di dalam direktori `tests/` (mis., `tests/unit/`, `tests/integration/`). JANGAN PERNAH membuat file tes di root proyek (`/`). +- **Skrip dan Utilitas**: SEMUA skrip pemeliharaan, debugging, pembuatan, atau eksperimental (`.cjs`, `.mjs`, `.js`, `.ts`) HARUS ditempatkan secara ketat di dalam direktori `scripts/` atau `scripts/scratch/` untuk keperluan sementara. JANGAN PERNAH membuang skrip bebas di root proyek (`/`). -**The Project Root MUST ONLY CONTAIN:** +**Root Proyek HANYA BOLEH BERISI:** -- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, etc.) -- Dependency files (`package.json`, `package-lock.json`) -- Documentation files (`README.md`, `CHANGELOG.md`, `AGENTS.md`) -- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`) +- File konfigurasi (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, dll.) +- File dependensi (`package.json`, `package-lock.json`) +- File dokumentasi (`README.md`, `CHANGELOG.md`, `AGENTS.md`) +- File CI/CD dan definisi pengabaian (`.gitignore`, `.dockerignore`) -When creating _any_ validation tests or one-off logic scripts, default to using `scripts/scratch/` or the `tests/unit/` directories according to your goals. Do not pollute the `/` root context. +Saat membuat _tes validasi_ atau skrip logika sekali pakai, default ke penggunaan `scripts/scratch/` atau direktori `tests/unit/` sesuai tujuan Anda. Jangan mencemari konteks root `/`. -## 2. VPS Dashboard Credentials +## 2. Kredensial Dashboard VPS -| Environment | URL | Password | -| ----------- | ------------------------- | -------- | -| Local VPS | http://192.168.0.15:20128 | 123456 | +| Lingkungan | URL | Kata Sandi | +| ---------- | ------------------------- | ---------- | +| VPS Lokal | http://192.168.0.15:20128 | 123456 | diff --git a/docs/i18n/id/README.md b/docs/i18n/id/README.md index 19247d911c..c57e14d228 100644 --- a/docs/i18n/id/README.md +++ b/docs/i18n/id/README.md @@ -1,14 +1,14 @@ -# 🚀 OmniRoute — The Free AI Gateway (Bahasa Indonesia) +# 🚀 OmniRoute — Gateway AI Gratis (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../README.md) · 🇸🇦 [ar](../ar/README.md) · 🇧🇬 [bg](../bg/README.md) · 🇧🇩 [bn](../bn/README.md) · 🇨🇿 [cs](../cs/README.md) · 🇩🇰 [da](../da/README.md) · 🇩🇪 [de](../de/README.md) · 🇪🇸 [es](../es/README.md) · 🇮🇷 [fa](../fa/README.md) · 🇫🇮 [fi](../fi/README.md) · 🇫🇷 [fr](../fr/README.md) · 🇮🇳 [gu](../gu/README.md) · 🇮🇱 [he](../he/README.md) · 🇮🇳 [hi](../hi/README.md) · 🇭🇺 [hu](../hu/README.md) · 🇮🇩 [id](../id/README.md) · 🇮🇹 [it](../it/README.md) · 🇯🇵 [ja](../ja/README.md) · 🇰🇷 [ko](../ko/README.md) · 🇮🇳 [mr](../mr/README.md) · 🇲🇾 [ms](../ms/README.md) · 🇳🇱 [nl](../nl/README.md) · 🇳🇴 [no](../no/README.md) · 🇵🇭 [phi](../phi/README.md) · 🇵🇱 [pl](../pl/README.md) · 🇵🇹 [pt](../pt/README.md) · 🇧🇷 [pt-BR](../pt-BR/README.md) · 🇷🇴 [ro](../ro/README.md) · 🇷🇺 [ru](../ru/README.md) · 🇸🇰 [sk](../sk/README.md) · 🇸🇪 [sv](../sv/README.md) · 🇰🇪 [sw](../sw/README.md) · 🇮🇳 [ta](../ta/README.md) · 🇮🇳 [te](../te/README.md) · 🇹🇭 [th](../th/README.md) · 🇹🇷 [tr](../tr/README.md) · 🇺🇦 [uk-UA](../uk-UA/README.md) · 🇵🇰 [ur](../ur/README.md) · 🇻🇳 [vi](../vi/README.md) · 🇨🇳 [zh-CN](../zh-CN/README.md) --- -### Never stop coding. Smart routing to **FREE & low-cost AI models** with automatic fallback. +### Jangan pernah berhenti ngoding. Routing cerdas ke **model AI GRATIS & berbiaya rendah** dengan fallback otomatis. -_Your universal API proxy — one endpoint, 100+ providers, zero downtime. Now with **MCP Server (25 tools)**, **A2A Protocol**, **Memory/Skills Systems** & **Electron Desktop App**._ +_Proxy API universal Anda — satu endpoint, 100+ penyedia, tanpa downtime. Kini dengan **MCP Server (25 alat)**, **Protokol A2A**, **Sistem Memori/Skill** & **Aplikasi Desktop Electron**._ -**Chat Completions • Embeddings • Image Generation • Video • Music • Audio • Reranking • **Web Search** • MCP Server • A2A Protocol • 100% TypeScript** +**Chat Completions • Embeddings • Pembuatan Gambar • Video • Musik • Audio • Reranking • **Pencarian Web** • MCP Server • Protokol A2A • 100% TypeScript** --- @@ -41,7 +41,7 @@ _Your universal API proxy — one endpoint, 100+ providers, zero downtime. Now w [![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](https://omniroute.online) [![WhatsApp](https://img.shields.io/badge/WhatsApp-Community-25D366?logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -[🌐 Website](https://omniroute.online) • [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Docs](#-documentation) • [💰 Pricing](#-pricing-at-a-glance) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) +[🌐 Website](https://omniroute.online) • [🚀 Mulai Cepat](#-quick-start) • [💡 Fitur](#-key-features) • [📖 Dokumentasi](#-documentation) • [💰 Harga](#-pricing-at-a-glance) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) @@ -49,7 +49,7 @@ _Your universal API proxy — one endpoint, 100+ providers, zero downtime. Now w --- -## 🖼️ Main Dashboard +## 🖼️ Dashboard Utama
OmniRoute Dashboard @@ -57,12 +57,12 @@ _Your universal API proxy — one endpoint, 100+ providers, zero downtime. Now w --- -## 📸 Dashboard Preview +## 📸 Pratinjau Dashboard
-Click to see dashboard screenshots +Klik untuk melihat tangkapan layar dashboard -| Page | Screenshot | +| Halaman | Tangkapan Layar | | -------------- | ------------------------------------------------- | | **Providers** | ![Providers](docs/screenshots/01-providers.png) | | **Combos** | ![Combos](docs/screenshots/02-combos.png) | @@ -78,9 +78,9 @@ _Your universal API proxy — one endpoint, 100+ providers, zero downtime. Now w --- -### 🤖 Free AI Provider for your favorite coding agents +### 🤖 Penyedia AI Gratis untuk agen coding favorit Anda -_Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway for unlimited coding._ +_Hubungkan IDE atau alat CLI berbasis AI apa pun melalui OmniRoute — gateway API gratis untuk coding tanpa batas._ @@ -159,541 +159,541 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f
-📡 All agents connect via http://localhost:20128/v1 or http://cloud.omniroute.online/v1 — one config, unlimited models and quota +📡 Semua agen terhubung melalui http://localhost:20128/v1 atau http://cloud.omniroute.online/v1 — satu konfigurasi, model dan kuota tak terbatas --- -## 🤔 Why OmniRoute? +## 🤔 Mengapa OmniRoute? -**Stop wasting money and hitting limits:** +**Berhenti membuang uang dan terus mencapai batas:** -- Subscription quota expires unused every month -- Rate limits stop you mid-coding -- Expensive APIs ($20-50/month per provider) -- Manual switching between providers +- Kuota langganan kedaluwarsa tanpa digunakan setiap bulan +- Batas rate menghentikan Anda di tengah sesi coding +- API mahal ($20-50/bulan per penyedia) +- Perpindahan manual antar penyedia -**OmniRoute solves this:** +**OmniRoute mengatasi ini:** -- ✅ **Maximize subscriptions** - Track quota, use every bit before reset -- ✅ **Auto fallback** - Subscription → API Key → Cheap → Free, zero downtime -- ✅ **Multi-account** - Round-robin between accounts per provider -- ✅ **Universal** - Works with Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, any CLI tool +- ✅ **Maksimalkan langganan** - Pantau kuota, gunakan setiap bit sebelum reset +- ✅ **Fallback otomatis** - Langganan → Kunci API → Murah → Gratis, tanpa downtime +- ✅ **Multi-akun** - Round-robin antar akun per penyedia +- ✅ **Universal** - Bekerja dengan Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenClaw, alat CLI apa pun --- -## 📧 Support +## 📧 Dukungan -> 💬 **Join our community!** [WhatsApp Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Get help, share tips, and stay updated. +> 💬 **Bergabunglah dengan komunitas kami!** [Grup WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Dapatkan bantuan, berbagi tips, dan tetap terupdate. - **Website**: [omniroute.online](https://omniroute.online) - **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) - **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) -- **WhatsApp**: [Community Group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) -- **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md), open a PR, or pick a `good first issue` +- **WhatsApp**: [Grup Komunitas](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) +- **Berkontribusi**: Lihat [CONTRIBUTING.md](CONTRIBUTING.md), buka PR, atau pilih `good first issue` -### 🐛 Reporting a Bug? +### 🐛 Melaporkan Bug? -When opening an issue, please run the system-info command and attach the generated file: +Saat membuka issue, jalankan perintah system-info dan lampirkan file yang dihasilkan: ```bash npm run system-info ``` -This generates a `system-info.txt` with your Node.js version, OmniRoute version, OS details, installed CLI tools (qoder, gemini, claude, codex, antigravity, droid, etc.), Docker/PM2 status, and system packages — everything we need to reproduce your issue quickly. Attach the file directly to your GitHub issue. +Perintah ini menghasilkan `system-info.txt` berisi versi Node.js, versi OmniRoute, detail OS, alat CLI yang terpasang (qoder, gemini, claude, codex, antigravity, droid, dll.), status Docker/PM2, dan paket sistem — semua yang dibutuhkan untuk mereproduksi masalah Anda dengan cepat. Lampirkan file tersebut langsung ke GitHub issue Anda. --- -## 🔄 How It Works +## 🔄 Cara Kerjanya ``` ┌─────────────┐ -│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ CLI Anda │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) │ Tool │ └──────┬──────┘ │ http://localhost:20128/v1 ↓ ┌─────────────────────────────────────────┐ -│ OmniRoute (Smart Router) │ -│ • Format translation (OpenAI ↔ Claude) │ -│ • Quota tracking + Embeddings + Images │ -│ • Auto token refresh │ +│ OmniRoute (Router Cerdas) │ +│ • Translasi format (OpenAI ↔ Claude) │ +│ • Pelacakan kuota + Embeddings + Gambar│ +│ • Refresh token otomatis │ └──────┬──────────────────────────────────┘ │ - ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI - │ ↓ quota exhausted - ├─→ [Tier 2: API KEY] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM, etc. - │ ↓ budget limit - ├─→ [Tier 3: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) - │ ↓ budget limit - └─→ [Tier 4: FREE] Qoder, Qwen, Kiro (unlimited) + ├─→ [Tier 1: LANGGANAN] Claude Code, Codex, Gemini CLI + │ ↓ kuota habis + ├─→ [Tier 2: KUNCI API] DeepSeek, Groq, xAI, Mistral, NVIDIA NIM, dll. + │ ↓ batas anggaran + ├─→ [Tier 3: MURAH] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ batas anggaran + └─→ [Tier 4: GRATIS] Qoder, Qwen, Kiro (tidak terbatas) -Result: Never stop coding, minimal cost +Hasil: Tidak pernah berhenti coding, biaya minimal ``` --- -## 🎯 What OmniRoute Solves — 30 Real Pain Points & Use Cases +## 🎯 Apa yang Diselesaikan OmniRoute — 30 Masalah Nyata & Kasus Penggunaan -> **Every developer using AI tools faces these problems daily.** OmniRoute was built to solve them all — from cost overruns to regional blocks, from broken OAuth flows to protocol operations and enterprise observability. +> **Setiap developer yang menggunakan alat AI menghadapi masalah ini setiap hari.** OmniRoute dibangun untuk menyelesaikannya semua — dari pembengkakan biaya hingga pemblokiran regional, dari alur OAuth yang rusak hingga operasi protokol dan observabilitas enterprise.
-💸 1. "I pay for an expensive subscription but still get interrupted by limits" +💸 1. "Saya membayar langganan mahal tapi masih terganggu oleh batas" -Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Even paying, quota has a ceiling — 5h of usage, weekly limits, or per-minute rate limits. Mid-coding session, the provider stops responding and the developer loses flow and productivity. +Developer membayar $20–200/bulan untuk Claude Pro, Codex Pro, atau GitHub Copilot. Meski sudah membayar, kuota memiliki batas — 5 jam penggunaan, batas mingguan, atau batas rate per menit. Di tengah sesi coding, penyedia berhenti merespons dan developer kehilangan fokus dan produktivitas. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Smart 4-Tier Fallback** — If subscription quota runs out, automatically redirects to API Key → Cheap → Free with zero manual intervention -- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI -- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next -- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) -- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets -- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use -- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard +- **Fallback 4-Tier Cerdas** — Jika kuota langganan habis, secara otomatis mengarahkan ke Kunci API → Murah → Gratis tanpa intervensi manual +- **Pelacakan Batas Penyedia** — Snapshot kuota yang di-cache diperbarui sesuai jadwal sisi server (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) dengan pembaruan manual tersedia di UI +- **Dukungan Multi-Akun** — Beberapa akun per penyedia dengan round-robin otomatis — saat satu habis, beralih ke berikutnya +- **Combo Kustom** — Rantai fallback yang dapat dikustomisasi dengan 13 strategi penyeimbangan (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**) +- **Pembangun Combo Terstruktur** — Buat combo langkah demi langkah dengan pemilihan penyedia + model + akun yang eksplisit, termasuk penyedia berulang dan target akun tetap +- **P2C Berbasis Kuota** — Pemilihan akun power-of-two kini mempertimbangkan kapasitas kuota, backoff, kesalahan terkini, dan penggunaan berturut-turut +- **Kuota Bisnis Codex** — Pemantauan kuota workspace Business/Team langsung di dashboard
-🔌 2. "I need to use multiple providers but each has a different API" +🔌 2. "Saya perlu menggunakan beberapa penyedia tetapi masing-masing memiliki API berbeda" -OpenAI uses one format, Claude (Anthropic) uses another, Gemini yet another. If a dev wants to test models from different providers or fallback between them, they need to reconfigure SDKs, change endpoints, deal with incompatible formats. Custom providers (FriendLI, NIM) have non-standard model endpoints. +OpenAI menggunakan satu format, Claude (Anthropic) menggunakan format lain, Gemini pun berbeda lagi. Jika seorang developer ingin menguji model dari penyedia berbeda atau melakukan fallback di antara mereka, mereka perlu mengonfigurasi ulang SDK, mengganti endpoint, dan menangani format yang tidak kompatibel. Penyedia kustom (FriendLI, NIM) memiliki endpoint model non-standar. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Unified Endpoint** — A single `http://localhost:20128/v1` serves as proxy for all 100+ providers -- **Format Translation** — Automatic and transparent: OpenAI ↔ Claude ↔ Gemini ↔ Responses API -- **Response Sanitization** — Strips non-standard fields (`x_groq`, `usage_breakdown`, `service_tier`) that break OpenAI SDK v1.83+ -- **Role Normalization** — Converts `developer` → `system` for non-OpenAI providers; `system` → `user` for GLM/ERNIE -- **Think Tag Extraction** — Extracts `` blocks from models like DeepSeek R1 into standardized `reasoning_content` -- **Structured Output for Gemini** — `json_schema` → `responseMimeType`/`responseSchema` automatic conversion -- **`stream` defaults to `false`** — Aligns with OpenAI spec, avoiding unexpected SSE in Python/Rust/Go SDKs +- **Endpoint Terpadu** — Satu `http://localhost:20128/v1` berfungsi sebagai proxy untuk semua 100+ penyedia +- **Translasi Format** — Otomatis dan transparan: OpenAI ↔ Claude ↔ Gemini ↔ Responses API +- **Sanitasi Respons** — Menghapus field non-standar (`x_groq`, `usage_breakdown`, `service_tier`) yang merusak OpenAI SDK v1.83+ +- **Normalisasi Peran** — Mengonversi `developer` → `system` untuk penyedia non-OpenAI; `system` → `user` untuk GLM/ERNIE +- **Ekstraksi Tag Think** — Mengekstrak blok `` dari model seperti DeepSeek R1 ke `reasoning_content` yang terstandarisasi +- **Output Terstruktur untuk Gemini** — Konversi otomatis `json_schema` → `responseMimeType`/`responseSchema` +- **`stream` default ke `false`** — Selaras dengan spesifikasi OpenAI, menghindari SSE tak terduga di SDK Python/Rust/Go
-🌐 3. "My AI provider blocks my region/country" +🌐 3. "Penyedia AI saya memblokir wilayah/negara saya" -Providers like OpenAI/Codex block access from certain geographic regions. Users get errors like `unsupported_country_region_territory` during OAuth and API connections. This is especially frustrating for developers from developing countries. +Penyedia seperti OpenAI/Codex memblokir akses dari wilayah geografis tertentu. Pengguna mendapat kesalahan seperti `unsupported_country_region_territory` saat OAuth dan koneksi API. Ini sangat membuat frustasi para developer dari negara berkembang. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **3-Level Proxy Config** — Configurable proxy at 3 levels: global (all traffic), per-provider (one provider only), and per-connection/key -- **Color-Coded Proxy Badges** — Visual indicators: 🟢 global proxy, 🟡 provider proxy, 🔵 connection proxy, always showing the IP -- **OAuth Token Exchange Through Proxy** — OAuth flow also goes through the proxy, solving `unsupported_country_region_territory` -- **Connection Tests via Proxy** — Connection tests use the configured proxy (no more direct bypass) -- **SOCKS5 Support** — Full SOCKS5 proxy support for outbound routing -- **TLS Fingerprint Spoofing** — Browser-like TLS fingerprint via `wreq-js` to bypass bot detection -- **🔏 CLI Fingerprint Matching** — Reorders headers and body fields to match native CLI binary signatures, drastically reducing account flagging risk. The proxy IP is preserved — you get both stealth **and** IP masking simultaneously +- **Konfigurasi Proxy 3-Level** — Proxy yang dapat dikonfigurasi di 3 level: global (semua lalu lintas), per-penyedia (hanya satu penyedia), dan per-koneksi/kunci +- **Lencana Proxy Berkode Warna** — Indikator visual: 🟢 proxy global, 🟡 proxy penyedia, 🔵 proxy koneksi, selalu menampilkan IP +- **Pertukaran Token OAuth Melalui Proxy** — Alur OAuth juga melewati proxy, menyelesaikan masalah `unsupported_country_region_territory` +- **Uji Koneksi via Proxy** — Uji koneksi menggunakan proxy yang dikonfigurasi (tidak ada lagi bypass langsung) +- **Dukungan SOCKS5** — Dukungan proxy SOCKS5 penuh untuk routing keluar +- **Spoofing Sidik Jari TLS** — Sidik jari TLS seperti browser melalui `wreq-js` untuk melewati deteksi bot +- **🔏 Pencocokan Sidik Jari CLI** — Menyusun ulang header dan field body agar sesuai dengan tanda tangan biner CLI asli, sangat mengurangi risiko pemanduan akun. IP proxy tetap dipertahankan — Anda mendapatkan kesiluman **dan** penyamaran IP secara bersamaan
-🆓 4. "I want to use AI for coding but I have no money" +🆓 4. "Saya ingin menggunakan AI untuk coding tapi tidak punya uang" -Not everyone can pay $20–200/month for AI subscriptions. Students, devs from emerging countries, hobbyists, and freelancers need access to quality models at zero cost. +Tidak semua orang bisa membayar $20–200/bulan untuk langganan AI. Pelajar, developer dari negara berkembang, penghobi, dan freelancer membutuhkan akses ke model berkualitas tanpa biaya sama sekali. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Free Tier Providers Built-in** — Native support for 100% free providers: Qoder (5 unlimited models via OAuth: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1, minimax-m2, kimi-k2), Qwen (4 unlimited models: qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next, vision-model), Kiro (Claude + AWS Builder ID for free), Gemini CLI (180K tokens/month free) -- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` with free "Light usage" tier; use `ollamacloud/` prefix -- **Free-Only Combos** — Chain `gc/gemini-3-flash → if/kimi-k2-thinking → qw/qwen3-coder-plus` = $0/month with zero downtime -- **NVIDIA NIM Free Access** — ~40 RPM dev-forever free access to 70+ models at build.nvidia.com (transitioning from credits to pure rate limits) -- **Cost Optimized Strategy** — Routing strategy that automatically chooses the cheapest available provider +- **Penyedia Tier Gratis Bawaan** — Dukungan asli untuk penyedia 100% gratis: Qoder (5 model tak terbatas via OAuth: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1, minimax-m2, kimi-k2), Qwen (4 model tak terbatas: qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next, vision-model), Kiro (Claude + AWS Builder ID gratis), Gemini CLI (180K token/bulan gratis) +- **Ollama Cloud** — Model Ollama yang di-host di cloud pada `api.ollama.com` dengan tier "Light usage" gratis; gunakan prefix `ollamacloud/` +- **Combo Hanya Gratis** — Rantai `gc/gemini-3-flash → if/kimi-k2-thinking → qw/qwen3-coder-plus` = $0/bulan tanpa downtime +- **Akses Gratis NVIDIA NIM** — ~40 RPM akses gratis selamanya untuk 70+ model di build.nvidia.com (beralih dari kredit ke batas rate murni) +- **Strategi Optimasi Biaya** — Strategi routing yang secara otomatis memilih penyedia termurah yang tersedia
-🔒 5. "I need to protect my AI gateway from unauthorized access" +🔒 5. "Saya perlu melindungi gateway AI saya dari akses tidak sah" -When exposing an AI gateway to the network (LAN, VPS, Docker), anyone with the address can consume the developer's tokens/quota. Without protection, APIs are vulnerable to misuse, prompt injection, and abuse. +Saat mengekspos gateway AI ke jaringan (LAN, VPS, Docker), siapa pun yang memiliki alamat tersebut dapat mengonsumsi token/kuota developer. Tanpa perlindungan, API rentan terhadap penyalahgunaan, injeksi prompt, dan eksploitasi. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **API Key Management** — Generation, rotation, and scoping per provider with a dedicated `/dashboard/api-manager` page -- **Model-Level Permissions** — Restrict API keys to specific models (`openai/*`, wildcard patterns), with Allow All/Restrict toggle -- **API Endpoint Protection** — Require a key for `/v1/models` and block specific providers from the listing -- **Auth Guard + CSRF Protection** — All dashboard routes protected with `withAuth` middleware + CSRF tokens -- **Rate Limiter** — Per-IP rate limiting with configurable windows -- **IP Filtering** — Allowlist/blocklist for access control -- **Prompt Injection Guard** — Sanitization against malicious prompt patterns -- **AES-256-GCM Encryption** — Credentials encrypted at rest +- **Manajemen Kunci API** — Pembuatan, rotasi, dan pembatasan lingkup per penyedia dengan halaman `/dashboard/api-manager` yang didedikasikan +- **Izin Tingkat Model** — Batasi kunci API ke model tertentu (`openai/*`, pola wildcard), dengan toggle Izinkan Semua/Batasi +- **Perlindungan Endpoint API** — Wajibkan kunci untuk `/v1/models` dan blokir penyedia tertentu dari daftar +- **Auth Guard + Perlindungan CSRF** — Semua rute dashboard dilindungi dengan middleware `withAuth` + token CSRF +- **Pembatas Rate** — Pembatasan rate per-IP dengan jendela yang dapat dikonfigurasi +- **Penyaringan IP** — Allowlist/blocklist untuk kontrol akses +- **Penjaga Injeksi Prompt** — Sanitasi terhadap pola prompt berbahaya +- **Enkripsi AES-256-GCM** — Kredensial dienkripsi saat disimpan
-🛑 6. "My provider went down and I lost my coding flow" +🛑 6. "Penyedia saya mati dan saya kehilangan alur coding" -AI providers can become unstable, return 5xx errors, or hit temporary rate limits. If a dev depends on a single provider, they're interrupted. Without circuit breakers, repeated retries can crash the application. +Penyedia AI bisa menjadi tidak stabil, mengembalikan kesalahan 5xx, atau mencapai batas rate sementara. Jika developer bergantung pada satu penyedia, mereka akan terganggu. Tanpa circuit breaker, percobaan ulang berulang dapat menyebabkan aplikasi crash. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Request Queue & Pacing** — Per-connection request buckets smooth bursts before they hit upstream rate caps -- **Connection Cooldown** — A single connection cools down after retryable failures with optional upstream `Retry-After` hints and exponential backoff -- **Provider Circuit Breaker** — The provider only trips after fallback is exhausted and the provider request still fails with provider-wide transient errors; connection-scoped `429` rate limits stay in Connection Cooldown -- **Wait For Cooldown** — The server can wait for the earliest connection cooldown to expire and retry the same client request automatically -- **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms -- **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention -- **Health Dashboard** — Uptime monitoring, provider circuit breaker states, cooldowns, cache stats, p50/p95/p99 latency +- **Antrian & Pacing Permintaan** — Bucket permintaan per-koneksi memperhalus lonjakan sebelum mencapai batas rate upstream +- **Pendinginan Koneksi** — Satu koneksi mendingin setelah kegagalan yang dapat dicoba ulang dengan petunjuk `Retry-After` upstream opsional dan backoff eksponensial +- **Circuit Breaker Penyedia** — Penyedia hanya trip setelah fallback habis dan permintaan penyedia masih gagal dengan kesalahan transien seluruh penyedia; batas rate `429` yang terikat koneksi tetap di Pendinginan Koneksi +- **Tunggu Pendinginan** — Server dapat menunggu pendinginan koneksi paling awal berakhir dan mencoba ulang permintaan klien yang sama secara otomatis +- **Anti-Thundering Herd** — Perlindungan mutex + semaphore terhadap badai percobaan ulang bersamaan +- **Rantai Fallback Combo** — Jika penyedia utama gagal, secara otomatis jatuh ke rantai berikutnya tanpa intervensi +- **Dashboard Kesehatan** — Pemantauan uptime, status circuit breaker penyedia, pendinginan, statistik cache, latensi p50/p95/p99
-🔧 7. "Configuring each AI tool is tedious and repetitive" +🔧 7. "Mengonfigurasi setiap alat AI membosankan dan berulang" -Developers use Cursor, Claude Code, Codex CLI, OpenClaw, Gemini CLI, Kilo Code... Each tool needs a different config (API endpoint, key, model). Reconfiguring when switching providers or models is a waste of time. +Developer menggunakan Cursor, Claude Code, Codex CLI, OpenClaw, Gemini CLI, Kilo Code... Setiap alat memerlukan konfigurasi berbeda (endpoint API, kunci, model). Mengonfigurasi ulang saat berganti penyedia atau model adalah pemborosan waktu. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **CLI Tools Dashboard** — Dedicated page with one-click setup for Claude Code, Codex CLI, OpenClaw, Kilo Code, Antigravity, Cline -- **GitHub Copilot Config Generator** — Generates `chatLanguageModels.json` for VS Code with bulk model selection -- **Onboarding Wizard** — Guided 4-step setup for first-time users -- **One endpoint, all models** — Configure `http://localhost:20128/v1` once, access 100+ providers +- **Dashboard Alat CLI** — Halaman khusus dengan pengaturan satu klik untuk Claude Code, Codex CLI, OpenClaw, Kilo Code, Antigravity, Cline +- **Generator Konfigurasi GitHub Copilot** — Menghasilkan `chatLanguageModels.json` untuk VS Code dengan pemilihan model massal +- **Wizard Orientasi** — Pengaturan terpandu 4 langkah untuk pengguna pertama kali +- **Satu endpoint, semua model** — Konfigurasi `http://localhost:20128/v1` sekali, akses 100+ penyedia
-🔑 8. "Managing OAuth tokens from multiple providers is hell" +🔑 8. "Mengelola token OAuth dari beberapa penyedia adalah mimpi buruk" -Claude Code, Codex, Gemini CLI, Copilot — all use OAuth 2.0 with expiring tokens. Developers need to re-authenticate constantly, deal with `client_secret is missing`, `redirect_uri_mismatch`, and failures on remote servers. OAuth on LAN/VPS is particularly problematic. +Claude Code, Codex, Gemini CLI, Copilot — semua menggunakan OAuth 2.0 dengan token yang kedaluwarsa. Developer perlu mengautentikasi ulang terus-menerus, menangani `client_secret is missing`, `redirect_uri_mismatch`, dan kegagalan di server jarak jauh. OAuth di LAN/VPS sangat bermasalah. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Auto Token Refresh** — OAuth tokens refresh in background before expiration -- **OAuth 2.0 (PKCE) Built-in** — Automatic flow for Claude Code, Codex, Gemini CLI, Copilot, Kiro, Qwen, Qoder -- **Multi-Account OAuth** — Multiple accounts per provider via JWT/ID token extraction -- **OAuth LAN/Remote Fix** — Private IP detection for `redirect_uri` + manual URL mode for remote servers -- **OAuth Behind Nginx** — Uses `window.location.origin` for reverse proxy compatibility -- **Remote OAuth Guide** — Step-by-step guide for Google Cloud credentials on VPS/Docker +- **Refresh Token Otomatis** — Token OAuth diperbarui di latar belakang sebelum kedaluwarsa +- **OAuth 2.0 (PKCE) Bawaan** — Alur otomatis untuk Claude Code, Codex, Gemini CLI, Copilot, Kiro, Qwen, Qoder +- **OAuth Multi-Akun** — Beberapa akun per penyedia melalui ekstraksi token JWT/ID +- **Perbaikan OAuth LAN/Jarak Jauh** — Deteksi IP privat untuk `redirect_uri` + mode URL manual untuk server jarak jauh +- **OAuth di Balik Nginx** — Menggunakan `window.location.origin` untuk kompatibilitas reverse proxy +- **Panduan OAuth Jarak Jauh** — Panduan langkah demi langkah untuk kredensial Google Cloud di VPS/Docker
-📊 9. "I don't know how much I'm spending or where" +📊 9. "Saya tidak tahu berapa banyak yang saya belanjakan atau di mana" -Developers use multiple paid providers but have no unified view of spending. Each provider has its own billing dashboard, but there's no consolidated view. Unexpected costs can pile up. +Developer menggunakan beberapa penyedia berbayar tetapi tidak memiliki tampilan pengeluaran yang terpadu. Setiap penyedia memiliki dashboard penagihan sendiri, tetapi tidak ada tampilan konsolidasi. Biaya tak terduga bisa menumpuk. -**How OmniRoute solves it:** +**Cara OmniRoute menyelesaikannya:** -- **Cost Analytics Dashboard** — Per-token cost tracking and budget management per provider -- **Budget Limits per Tier** — Spending ceiling per tier that triggers automatic fallback -- **Per-Model Pricing Configuration** — Configurable prices per model -- **Usage Statistics Per API Key** — Request count and last-used timestamp per key -- **Analytics Dashboard** — Stat cards, model usage chart, provider table with success rates and latency +- **Dashboard Analitik Biaya** — Pelacakan biaya per-token dan manajemen anggaran per penyedia +- **Batas Anggaran per Tier** — Batas pengeluaran per tier yang memicu fallback otomatis +- **Konfigurasi Harga Per-Model** — Harga yang dapat dikonfigurasi per model +- **Statistik Penggunaan Per Kunci API** — Jumlah permintaan dan cap waktu terakhir digunakan per kunci +- **Dashboard Analitik** — Kartu statistik, grafik penggunaan model, tabel penyedia dengan tingkat keberhasilan dan latensi
-🐛 10. "I can't diagnose errors and problems in AI calls" +🐛 10. "Saya tidak dapat mendiagnosis kesalahan dan masalah dalam panggilan AI" -When a call fails, the dev doesn't know if it was a rate limit, expired token, wrong format, or provider error. Fragmented logs across different terminals. Without observability, debugging is trial-and-error. +Saat panggilan gagal, pengembang tidak mengetahui apakah itu batas kecepatan, token kedaluwarsa, format salah, atau kesalahan penyedia. Log terfragmentasi di terminal yang berbeda. Tanpa observabilitas, debugging adalah trial-and-error. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **Unified Logs Dashboard** — 4 tabs: Request Logs, Proxy Logs, Audit Logs, Console -- **Console Log Viewer** — Real-time terminal-style viewer with color-coded levels, auto-scroll, search, filter -- **SQLite Summary Logs** — Request and proxy log indexes stay queryable across restarts without loading large payload blobs into SQLite -- **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time) -- **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing -- **File-Based Detail Artifacts** — App logs rotate by size, retention days, and archive count; detailed request/response payloads live in `DATA_DIR/call_logs/` and rotate independently of SQLite summaries -- **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage. +- **Dasbor Log Terpadu** — 4 tab: Log Permintaan, Log Proksi, Log Audit, Konsol +- **Penampil Log Konsol** — Penampil gaya terminal real-time dengan level kode warna, gulir otomatis, pencarian, filter +- **Log Ringkasan SQLite** — Indeks log permintaan dan proksi tetap dapat dikueri saat restart tanpa memuat blob payload besar ke SQLite +- **Translator Playground** — 4 mode debugging: Playground (terjemahan format), Chat Tester (pulang pergi), Test Bench (batch), Live Monitor (real-time) +- **Telemetri Permintaan** — latensi p50/p95/p99 + penelusuran X-Request-Id +- **Artefak Detail Berbasis File** — Log aplikasi dirotasi berdasarkan ukuran, hari penyimpanan, dan jumlah arsip; payload permintaan/respons terperinci ada di `DATA_DIR/call_logs/` dan diputar secara independen dari ringkasan SQLite +- **Laporan Info Sistem** — `npm run system-info` menghasilkan `system-info.txt` dengan lingkungan lengkap Anda (versi Node, versi OmniRoute, OS, alat CLI, status Docker/PM2). Lampirkan saat melaporkan masalah untuk triase instan.
-🏗️ 11. "Deploying and maintaining the gateway is complex" +🏗️ 11. "Menyebarkan dan memelihara gateway itu rumit" -Installing, configuring, and maintaining an AI proxy across different environments (local, VPS, Docker, cloud) is labor-intensive. Problems like hardcoded paths, `EACCES` on directories, port conflicts, and cross-platform builds add friction. +Menginstal, mengonfigurasi, dan memelihara proksi AI di berbagai lingkungan (lokal, VPS, Docker, cloud) membutuhkan banyak tenaga. Masalah seperti jalur hardcode, `EACCES` pada direktori, konflik port, dan pembangunan lintas platform menambah gesekan. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **npm global install** — `npm install -g omniroute && omniroute` — done -- **Docker Multi-Platform** — AMD64 + ARM64 native (Apple Silicon, AWS Graviton, Raspberry Pi) -- **Docker Compose Profiles** — `base` (no CLI tools) and `cli` (with Claude Code, Codex, OpenClaw) -- **Electron Desktop App** — Native app for Windows/macOS/Linux with system tray, auto-start, offline mode -- **Split-Port Mode** — API and Dashboard on separate ports for advanced scenarios (reverse proxy, container networking) -- **Cloud Sync** — Config synchronization across devices via Cloudflare Workers -- **DB Backups** — Automatic backup, restore, export and import of all settings, with `DISABLE_SQLITE_AUTO_BACKUP` for externally managed backups +- **instal global npm** — `npm install -g omniroute && omniroute` — selesai +- **Docker Multi-Platform** — asli AMD64 + ARM64 (Apple Silicon, AWS Graviton, Raspberry Pi) +- **Docker Compose Profiles** — `base` (tanpa alat CLI) dan `cli` (dengan Claude Code, Codex, OpenClaw) +- **Aplikasi Desktop Electron** — Aplikasi asli untuk Windows/macOS/Linux dengan baki sistem, mulai otomatis, mode offline +- **Mode Port Terpisah** — API dan Dasbor pada port terpisah untuk skenario tingkat lanjut (proksi terbalik, jaringan kontainer) +- **Cloud Sync** — Konfigurasi sinkronisasi antar perangkat melalui Cloudflare Workers +- **DB Backups** — Pencadangan otomatis, pemulihan, ekspor dan impor semua pengaturan, dengan `DISABLE_SQLITE_AUTO_BACKUP` untuk pencadangan yang dikelola secara eksternal
-🌍 12. "The interface is English-only and my team doesn't speak English" +🌍 12. "Antarmuka hanya berbahasa Inggris dan tim saya tidak bisa berbahasa Inggris" -Teams in non-English-speaking countries, especially in Latin America, Asia, and Europe, struggle with English-only interfaces. Language barriers reduce adoption and increase configuration errors. +Tim di negara-negara yang tidak berbahasa Inggris, khususnya di Amerika Latin, Asia, dan Eropa, kesulitan dengan antarmuka yang hanya berbahasa Inggris. Hambatan bahasa mengurangi adopsi dan meningkatkan kesalahan konfigurasi. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **Dashboard i18n — 30 Languages** — All 500+ keys translated including Arabic, Bulgarian, Danish, German, Spanish, Finnish, French, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Malay, Dutch, Norwegian, Polish, Portuguese (PT/BR), Romanian, Russian, Slovak, Swedish, Thai, Ukrainian, Vietnamese, Chinese, Filipino, English -- **RTL Support** — Right-to-left support for Arabic and Hebrew -- **Multi-Language READMEs** — 30 complete documentation translations -- **Language Selector** — Globe icon in header for real-time switching +- **Dasbor i18n — 30 Bahasa** — 500+ tombol diterjemahkan termasuk Arab, Bulgaria, Denmark, Jerman, Spanyol, Finlandia, Prancis, Ibrani, Hindi, Hungaria, Indonesia, Italia, Jepang, Korea, Melayu, Belanda, Norwegia, Polandia, Portugis (PT/BR), Rumania, Rusia, Slovakia, Swedia, Thailand, Ukraina, Vietnam, China, Filipina, Inggris +- **Dukungan RTL** — Dukungan kanan ke kiri untuk bahasa Arab dan Ibrani +- **README Multi-Bahasa** — 30 terjemahan dokumentasi lengkap +- **Pemilih Bahasa** — Ikon bola dunia di header untuk peralihan waktu nyata
-🔄 13. "I need more than chat — I need embeddings, images, audio" +🔄 13. "Saya memerlukan lebih dari sekedar chat — saya memerlukan embeddings, gambar, audio" -AI isn't just chat completion. Devs need to generate images, transcribe audio, create embeddings for RAG, rerank documents, and moderate content. Each API has a different endpoint and format. +AI bukan hanya penyelesaian obrolan. Pengembang perlu membuat gambar, mentranskripsikan audio, membuat penyematan untuk RAG, mengubah peringkat dokumen, dan memoderasi konten. Setiap API memiliki titik akhir dan format yang berbeda. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **Embeddings** — `/v1/embeddings` with 6 providers and 9+ models -- **Image Generation** — `/v1/images/generations` with 10 providers and 20+ models (OpenAI, xAI, Together, Fireworks, Nebius, Hyperbolic, NanoBanana, Antigravity, SD WebUI, ComfyUI) -- **Text-to-Video** — `/v1/videos/generations` — ComfyUI (AnimateDiff, SVD) and SD WebUI -- **Text-to-Music** — `/v1/music/generations` — ComfyUI (Stable Audio Open, MusicGen) -- **Audio Transcription** — `/v1/audio/transcriptions` — Whisper + Nvidia NIM, HuggingFace, Qwen3 -- **Text-to-Speech** — `/v1/audio/speech` — ElevenLabs, Nvidia NIM, HuggingFace, Coqui, Tortoise, Qwen3, **Inworld**, **Cartesia**, **PlayHT**, + existing providers -- **Moderations** — `/v1/moderations` — Content safety checks -- **Reranking** — `/v1/rerank` — Document relevance reranking -- **Responses API** — Full `/v1/responses` support for Codex +- **Sematan** — `/v1/embeddings` dengan 6 penyedia dan 9+ model +- **Pembuatan Gambar** — `/v1/images/generations` dengan 10 penyedia dan 20+ model (OpenAI, xAI, Together, Fireworks, Nebius, Hyperbolic, NanoBanana, Antigravity, SD WebUI, ComfyUI) +- **Teks-ke-Video** — `/v1/videos/generations` — ComfyUI (AnimateDiff, SVD) dan SD WebUI +- **Teks-ke-Musik** — `/v1/music/generations` — ComfyUI (Audio Terbuka Stabil, MusicGen) +- **Transkripsi Audio** — `/v1/audio/transcriptions` — Whisper + Nvidia NIM, HuggingFace, Qwen3 +- **Text-to-Speech** — `/v1/audio/speech` — ElevenLabs, Nvidia NIM, HuggingFace, Coqui, Tortoise, Qwen3, **Inworld**, **Cartesia**, **PlayHT**, + penyedia yang ada +- **Moderasi** — `/v1/moderations` — Pemeriksaan keamanan konten +- **Pemeringkatan ulang** — `/v1/rerank` — Pemeringkatan ulang relevansi dokumen +- **Respon API** — Dukungan penuh `/v1/responses` untuk Codex
-🧪 14. "I have no way to test and compare quality across models" +🧪 14. "Saya tidak punya cara untuk menguji dan membandingkan kualitas antar model" -Developers want to know which model is best for their use case — code, translation, reasoning — but comparing manually is slow. No integrated eval tools exist. +Pengembang ingin mengetahui model mana yang terbaik untuk kasus penggunaan mereka — kode, terjemahan, penalaran — tetapi membandingkan secara manual itu lambat. Tidak ada alat evaluasi terintegrasi. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **LLM Evaluations** — Golden set testing with 10 pre-loaded cases covering greetings, math, geography, code generation, JSON compliance, translation, markdown, safety refusal -- **4 Match Strategies** — `exact`, `contains`, `regex`, `custom` (JS function) -- **Translator Playground Test Bench** — Batch testing with multiple inputs and expected outputs, cross-provider comparison -- **Chat Tester** — Full round-trip with visual response rendering -- **Live Monitor** — Real-time stream of all requests flowing through the proxy +- **Evaluasi LLM** — Pengujian set emas dengan 10 kasus yang dimuat sebelumnya yang mencakup salam, matematika, geografi, pembuatan kode, kepatuhan JSON, terjemahan, penurunan harga, penolakan keamanan +- **4 Strategi Pertandingan** — `exact`, `contains`, `regex`, `custom` (fungsi JS) +- **Bangku Tes Taman Bermain Penerjemah** — Pengujian batch dengan banyak masukan dan keluaran yang diharapkan, perbandingan lintas penyedia +- **Penguji Obrolan** — Perjalanan bolak-balik penuh dengan rendering respons visual +- **Monitor Langsung** — Aliran real-time dari semua permintaan yang mengalir melalui proxy
-📈 15. "I need to scale without losing performance" +📈 15. "Saya perlu meningkatkan skala tanpa kehilangan performa" -As request volume grows, without caching the same questions generate duplicate costs. Without idempotency, duplicate requests waste processing. Per-provider rate limits must be respected. +Seiring bertambahnya volume permintaan, tanpa menyimpan pertanyaan yang sama akan menghasilkan biaya duplikat. Tanpa idempotensi, permintaan duplikat akan membuang-buang pemrosesan. Batasan tarif per penyedia harus dipatuhi. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **Semantic Cache** — Two-tier cache (signature + semantic) reduces cost and latency -- **Request Idempotency** — 5s deduplication window for identical requests -- **Rate Limit Detection** — Per-provider RPM, min gap, and max concurrent tracking -- **Request Queue & Pacing** — Configurable queue, pacing, and concurrency defaults in Settings → Resilience -- **API Key Validation Cache** — 3-tier cache for production performance -- **Health Dashboard with Telemetry** — p50/p95/p99 latency, cache stats, uptime +- **Cache Semantik** — Cache dua tingkat (tanda tangan + semantik) mengurangi biaya dan latensi +- **Request Idempoency** — Jendela deduplikasi 5 detik untuk permintaan yang identik +- **Deteksi Batas Tarif** — RPM per penyedia, selisih minimum, dan pelacakan serentak maks +- **Antrian & Kecepatan Permintaan** — Antrean, kecepatan, dan kecepatan konkurensi yang dapat dikonfigurasi secara default di Pengaturan → Ketahanan +- **Cache Validasi Kunci API** — cache 3 tingkat untuk kinerja produksi +- **Dasbor Kesehatan dengan Telemetri** — latensi p50/p95/p99, statistik cache, waktu aktif
-🤖 16. "I want to control model behavior globally" +🤖 16. "Saya ingin mengontrol perilaku model secara global" -Developers who want all responses in a specific language, with a specific tone, or want to limit reasoning tokens. Configuring this in every tool/request is impractical. +Pengembang yang menginginkan semua respons dalam bahasa tertentu, dengan nada tertentu, atau ingin membatasi token penalaran. Mengonfigurasi ini di setiap alat/permintaan tidak praktis. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **System Prompt Injection** — Global prompt applied to all requests -- **Thinking Budget Validation** — Reasoning token allocation control per request (passthrough, auto, custom, adaptive) -- **9 Routing Strategies** — Global strategies that determine how requests are distributed -- **Wildcard Router** — `provider/*` patterns route dynamically to any provider -- **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard -- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite -- **Provider Toggle** — Enable/disable all connections for a provider with one click -- **Blocked Providers** — Exclude specific providers from `/v1/models` listing +- **Injeksi Perintah Sistem** — Perintah global diterapkan ke semua permintaan +- **Validasi Anggaran Berpikir** — Kontrol alokasi token penalaran per permintaan (passthrough, otomatis, kustom, adaptif) +- **9 Strategi Perutean** — Strategi global yang menentukan cara permintaan didistribusikan +- **Wildcard Router** — pola `provider/*` merutekan secara dinamis ke penyedia mana pun +- **Combo Aktifkan/Nonaktifkan Toggle** — Beralih kombo langsung dari dasbor +- **Pengurutan Kombo Manual** — Seret kartu kombo berdasarkan pegangan dan pertahankan pesanan di SQLite +- **Toggle Penyedia** — Mengaktifkan/menonaktifkan semua koneksi untuk penyedia dengan satu klik +- **Penyedia yang Diblokir** — Kecualikan penyedia tertentu dari daftar `/v1/models`
-🧰 17. "I need MCP tools as first-class product capabilities" +🧰 17. "Saya membutuhkan alat MCP sebagai kemampuan produk kelas satu" Many AI gateways expose MCP only as a hidden implementation detail. Teams need a visible, manageable operation layer. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- MCP appears in the dashboard navigation and endpoint protocol tab -- Dedicated MCP management page with process, tools, scopes, and audit -- Built-in quick-start for `omniroute --mcp` and client onboarding +- MCP muncul di navigasi dasbor dan tab protokol titik akhir +- Halaman manajemen MCP khusus dengan proses, alat, cakupan, dan audit +- Mulai cepat bawaan untuk `omniroute --mcp` dan orientasi klien
-🧠 18. "I need A2A orchestration with sync + stream task paths" +🧠 18. "Saya memerlukan orkestrasi A2A dengan jalur tugas sinkronisasi + streaming" -Agent workflows need both direct replies and long-running streamed execution with lifecycle control. +Alur kerja agen memerlukan balasan langsung dan eksekusi streaming jangka panjang dengan kontrol siklus hidup. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- A2A JSON-RPC endpoint (`POST /a2a`) with `message/send` and `message/stream` -- SSE streaming with terminal state propagation -- Task lifecycle APIs for `tasks/get` and `tasks/cancel` +- Titik akhir A2A JSON-RPC (`POST /a2a`) dengan `message/send` dan `message/stream` +- Streaming SSE dengan propagasi status terminal +- API siklus hidup tugas untuk `tasks/get` dan `tasks/cancel`
-🛰️ 19. "I need real MCP process health, not guessed status" +🛰️ 19. "Saya membutuhkan kesehatan proses MCP yang nyata, bukan status yang dapat ditebak" -Operational teams need to know if MCP is actually alive, not just whether an API is reachable. +Tim operasional perlu mengetahui apakah MCP benar-benar aktif, bukan hanya apakah API dapat dijangkau. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Runtime heartbeat file with PID, timestamps, transport, tool count, and scope mode -- MCP status API combining heartbeat + recent activity -- UI status cards for process/uptime/heartbeat freshness +- File detak jantung runtime dengan PID, stempel waktu, transportasi, jumlah alat, dan mode cakupan +- API status MCP menggabungkan detak jantung + aktivitas terkini +- Kartu status UI untuk kesegaran proses/waktu aktif/detak jantung
-📋 20. "I need auditable MCP tool execution" +📋 20. "Saya memerlukan eksekusi alat MCP yang dapat diaudit" -When tools mutate config or trigger ops actions, teams need forensic traceability. +Saat alat mengubah konfigurasi atau memicu tindakan operasi, tim memerlukan kemampuan penelusuran forensik. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- SQLite-backed audit logging for MCP tool calls -- Filters by tool, success/failure, API key, and pagination -- Dashboard audit table + stats endpoints for automation +- Pencatatan audit yang didukung SQLite untuk panggilan alat MCP +- Filter berdasarkan alat, keberhasilan/kegagalan, kunci API, dan penomoran halaman +- Tabel audit dasbor + titik akhir statistik untuk otomatisasi
-🔐 21. "I need scoped MCP permissions per integration" +🔐 21. "Saya memerlukan izin MCP terbatas per integrasi" Different clients should have least-privilege access to tool categories. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- 10 granular MCP scopes for controlled tool access -- Scope enforcement and visibility in MCP management UI -- Safe default posture for operational tooling +- 10 cakupan MCP granular untuk akses alat terkontrol +- Penegakan cakupan dan visibilitas di UI manajemen MCP +- Postur default yang aman untuk perkakas operasional
-⚙️ 22. "I need operational controls without redeploying" +⚙️ 22. "Saya memerlukan kontrol operasional tanpa memindahkan" -Teams need quick runtime changes during incidents or cost events. +Tim memerlukan perubahan runtime yang cepat selama insiden atau peristiwa biaya. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Switch combo activation directly from MCP dashboard -- Tune queue, cooldown, breaker, and wait settings from the dedicated Resilience page -- Review live provider breaker state from the Health dashboard +- Beralih aktivasi kombo langsung dari dasbor MCP +- Sesuaikan pengaturan antrean, cooldown, pemutus, dan tunggu dari halaman Ketahanan khusus +- Tinjau status pemutus penyedia langsung dari dasbor Kesehatan
-🔄 23. "I need live A2A task lifecycle visibility and cancellation" +🔄 23. "Saya memerlukan visibilitas dan pembatalan siklus hidup tugas A2A langsung" Without lifecycle visibility, task incidents become hard to triage. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Task listing/filtering by state/skill with pagination -- Drill-down on task metadata, events, and artifacts -- Task cancellation endpoint and UI action with confirmation +- Daftar tugas/pemfilteran berdasarkan status/keterampilan dengan penomoran halaman +- Telusuri metadata tugas, peristiwa, dan artefak +- Titik akhir pembatalan tugas dan tindakan UI dengan konfirmasi
-🌊 24. "I need active stream metrics for A2A load" +🌊 24. "Saya memerlukan metrik aliran aktif untuk memuat A2A" -Streaming workflows require operational insight into concurrency and live connections. +Alur kerja streaming memerlukan wawasan operasional tentang konkurensi dan koneksi langsung. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Active stream counters integrated into A2A status -- Last task timestamp and per-state counts -- A2A dashboard cards for real-time ops monitoring +- Penghitung aliran aktif terintegrasi ke dalam status A2A +- Stempel waktu tugas terakhir dan jumlah per negara bagian +- Kartu dasbor A2A untuk pemantauan operasi waktu nyata
-🪪 25. "I need standard agent discovery for clients" +🪪 25. "Saya memerlukan penemuan agen standar untuk klien" -External clients and orchestrators need machine-readable metadata for onboarding. +Klien dan orkestra eksternal memerlukan metadata yang dapat dibaca mesin untuk orientasi. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Agent Card exposed at `/.well-known/agent.json` -- Capabilities and skills shown in management UI -- A2A status API includes discovery metadata for automation +- Kartu Agen terungkap di `/.well-known/agent.json` +- Kemampuan dan keterampilan yang ditunjukkan dalam manajemen UI +- API status A2A mencakup metadata penemuan untuk otomatisasi
-🧭 26. "I need protocol discoverability in the product UX" +🧭 26. "Saya memerlukan kemampuan protokol untuk ditemukan di UX produk" If users cannot discover protocol surfaces, adoption and support quality drop. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Consolidated **Endpoints** page with tabs for Proxy, MCP, A2A, and API Endpoints -- Inline service status toggles (Online/Offline) for MCP and A2A -- Links from overview to dedicated management tabs +- Halaman **Endpoint** terkonsolidasi dengan tab untuk Proxy, MCP, A2A, dan API Endpoints +- Pengalih status layanan inline (Online/Offline) untuk MCP dan A2A +- Tautan dari ikhtisar ke tab manajemen khusus
-🧪 27. "I need end-to-end protocol validation with real clients" +🧪 27. "Saya memerlukan validasi protokol end-to-end dengan klien nyata" Mock tests are not enough to validate protocol compatibility before release. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- E2E suite that boots app and uses real MCP SDK client transport -- A2A client tests for discovery, send, stream, get, and cancel flows -- Cross-check assertions against MCP audit and A2A tasks APIs +- Suite E2E yang mem-boot aplikasi dan menggunakan transportasi klien MCP SDK yang sebenarnya +- Klien A2A menguji penemuan, pengiriman, streaming, dapatkan, dan pembatalan aliran +- Periksa silang pernyataan terhadap audit MCP dan API tugas A2A
-📡 28. "I need unified observability across all interfaces" +📡 28. "Saya memerlukan observabilitas terpadu di semua antarmuka" Splitting observability by protocol creates blind spots and longer MTTR. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Unified dashboards/logs/analytics in one product -- Health + audit + request telemetry across OpenAI, MCP, and A2A layers -- Operational APIs for status and automation +- Dasbor/log/analitik terpadu dalam satu produk +- Kesehatan + audit + permintaan telemetri di seluruh lapisan OpenAI, MCP, dan A2A +- API Operasional untuk status dan otomatisasi
-💼 29. "I need one runtime for proxy + tools + agent orchestration" +💼 29. "Saya memerlukan satu runtime untuk proxy + alat + orkestrasi agen" -Running many separate services increases operational cost and failure modes. +Menjalankan banyak layanan terpisah akan meningkatkan biaya operasional dan mode kegagalan. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- OpenAI-compatible proxy, MCP server, and A2A server in one stack -- Shared auth, resilience, data store, and observability -- Consistent policy model across all interaction surfaces +- Proksi yang kompatibel dengan OpenAI, server MCP, dan server A2A dalam satu tumpukan +- Otentikasi bersama, ketahanan, penyimpanan data, dan kemampuan observasi +- Model kebijakan yang konsisten di seluruh platform interaksi
-🚀 30. "I need to ship agentic workflows without glue-code sprawl" +🚀 30. "Saya perlu mengirim workflow agentic tanpa tumpukan glue code" -Teams lose velocity when stitching multiple ad-hoc services and scripts. +Tim kehilangan kecepatan saat menggabungkan beberapa layanan dan skrip ad-hoc. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- Unified endpoint strategy for clients and agents -- Built-in protocol management UIs and smoke validation paths -- Production-ready foundations (security, logging, resilience, backup) +- Strategi titik akhir terpadu untuk klien dan agen +- UI manajemen protokol bawaan dan jalur validasi asap +- Fondasi siap produksi (keamanan, logging, ketahanan, cadangan)
-📚 31. "My long sessions crash with 'context_length_exceeded' limits" +📚 31. "Sesi panjang saya crash karena batas 'context_length_exceeded'" -During deep debugging, long histories with tool results quickly exceed provider token windows, causing failed requests and orphaned context. +Selama proses debug mendalam, riwayat panjang dengan hasil alat dengan cepat melampaui jendela token penyedia, menyebabkan permintaan gagal dan konteks tidak ada lagi. -**How OmniRoute solves it:** +**Bagaimana OmniRoute menyelesaikannya:** -- **Proactive Context Compression** — Evaluates token budgets before the request hits upstream and proactively prunes old conversation history with a smart binary-search mechanism. -- **Structural Integrity Guards** — Automatically tracks explicit `tool_use` definitions and ensures that if a tool input is truncated, its corresponding `tool_result` is also safely removed, preventing API validation errors. -- **Multi-Layer Dropping** — Progressively drops system messages, regular messages, and finally enforces strict length limits without breaking conversational logic. +- **Kompresi Konteks Proaktif** — Mengevaluasi anggaran token sebelum permintaan mencapai hulu dan secara proaktif memangkas riwayat percakapan lama dengan mekanisme pencarian biner yang cerdas. +- **Pengaman Integritas Struktural** — Secara otomatis melacak definisi `tool_use` yang eksplisit dan memastikan bahwa jika masukan alat terpotong, `tool_result` yang terkait juga dihapus dengan aman, sehingga mencegah kesalahan validasi API. +- **Penghapusan Multi-Lapisan** — Secara progresif menghapus pesan sistem, pesan biasa, dan akhirnya menerapkan batas panjang yang ketat tanpa merusak logika percakapan.
-### Example Playbooks (Integrated Use Cases) +### Contoh Playbook (Kasus Penggunaan Terintegrasi) **Playbook A: Maximize paid subscription + cheap backup** @@ -707,7 +707,7 @@ Monthly cost: $20 + small backup spend Outcome: higher quality, near-zero interruption ``` -**Playbook B: Zero-cost coding stack** +**Playbook B: Tumpukan coding tanpa biaya** ```txt Combo: "free-forever" @@ -719,7 +719,7 @@ Monthly cost: $0 Outcome: stable free coding workflow ``` -**Playbook C: 24/7 always-on fallback chain** +**Playbook C: Rantai fallback yang selalu aktif 24/7** ```txt Combo: "always-on" @@ -732,7 +732,7 @@ Combo: "always-on" Outcome: deep fallback depth for deadline-critical workloads ``` -**Playbook D: Agent ops with MCP + A2A** +**Playbook D: Operasi agen dengan MCP + A2A** ```txt 1) Start MCP transport (`omniroute --mcp`) for tool-driven operations @@ -743,44 +743,44 @@ Outcome: deep fallback depth for deadline-critical workloads --- -## 🆓 Start Free — Zero Configuration Cost +## 🆓 Mulai Gratis — Tanpa Biaya Konfigurasi -> Setup AI coding in minutes at **$0/month**. Connect these free accounts and use the built-in **Free Stack** combo. +> Siapkan pengkodean AI dalam hitungan menit di **$0/bulan**. Hubungkan akun gratis ini dan gunakan kombo **Free Stack** bawaan. | Step | Action | Providers Unlocked | | ---- | -------------------------------------------------- | ------------------------------------------------------------------ | | 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** | | 2 | Connect **Qoder** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** | | 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** | -| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** | -| 5 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically | +| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **Gratis 180K/bln** | +| 5 | `/dashboard/combos` → **Templat Tumpukan Gratis ($0)** | Round-robin semua penyedia gratis secara otomatis | -**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done. +**Arahkan IDE/CLI apa pun ke:** `http://localhost:20128/v1` · Kunci API: `any-string` · Selesai. -> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day), LongCat API key (50M tokens/day!), Cloudflare Workers AI (10K Neurons/day, 50+ models). +> **Cakupan ekstra opsional (juga gratis):** Kunci API Groq (gratis 30 RPM), NVIDIA NIM (gratis 40 RPM, 70+ model), Cerebras (1 juta tok/hari), kunci API LongCat (50 juta token/hari!), Cloudflare Workers AI (10 ribu neuron/hari, 50+ model). ## Mulai Cepat -### 1) Install and run +### 1) Instal dan jalankan ```bash npm install -g omniroute omniroute ``` -> **pnpm users:** Run `pnpm approve-builds -g` after install to enable native build scripts required by `better-sqlite3` and `@swc/core`: +> **pengguna pnpm:** Jalankan `pnpm approve-builds -g` setelah instalasi untuk mengaktifkan skrip build asli yang diperlukan oleh `better-sqlite3` dan `@swc/core`: > > ```bash > pnpm install -g omniroute -> pnpm approve-builds -g # Select all packages → approve +> pnpm approve-builds -g # Pilih semua paket → approve > omniroute > ``` -Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`. +Dasbor terbuka di `http://localhost:20128` dan URL dasar API adalah `http://localhost:20128/v1`. #### Arch Linux (AUR) -Arch Linux users can install the [AUR package](https://aur.archlinux.org/packages/omniroute-bin), which installs OmniRoute and provides a systemd user service: +Pengguna Arch Linux dapat menginstal [AUR package](https://aur.archlinux.org/packages/omniroute-bin), yang menginstal OmniRoute dan menyediakan layanan pengguna systemd: ```bash yay -S omniroute-bin @@ -789,9 +789,9 @@ systemctl --user enable --now omniroute.service | Command | Description | | ----------------------- | ----------------------------------------------------------- | -| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) | +| `omniroute` | Mulai server (`PORT=20128`, API dan dasbor pada port yang sama) | | `omniroute --port 3000` | Set canonical/API port to 3000 | -| `omniroute --mcp` | Start MCP server (stdio transport) | +| `omniroute --mcp` | Mulai server MCP (stdio transport) | | `omniroute --no-open` | Don't auto-open browser | | `omniroute --help` | Show help | @@ -803,63 +803,63 @@ PORT=20128 DASHBOARD_PORT=20129 omniroute # Dashboard: http://localhost:20129 ``` -### 2) Uninstalling +### 2) Menghapus Instalasi -When you no longer need OmniRoute, we provide two quick scripts for a clean removal: +Saat Anda tidak lagi memerlukan OmniRoute, kami menyediakan dua skrip cepat untuk penghapusan bersih: | Command | Action | | ------------------------ | ----------------------------------------------------------------------------------- | -| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | -| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. | +| `npm run uninstall` | Menghapus aplikasi sistem tetapi **menyimpan DB dan konfigurasi Anda** di `~/.omniroute`. | +| `npm run uninstall:full` | Menghapus aplikasi DAN secara permanen **menghapus semua konfigurasi, kunci, dan database**. | -> Note: To run these commands, navigate to the OmniRoute project folder (if you cloned it) and run them. Alternatively, if globally installed, you can simply run `npm uninstall -g omniroute`. +> Catatan: Untuk menjalankan perintah ini, navigasikan ke folder proyek OmniRoute (jika Anda mengkloningnya) dan jalankan. Alternatifnya, jika diinstal secara global, Anda cukup menjalankan `npm uninstall -g omniroute`. -### Long-Running Streaming Timeouts +### Batas Waktu Streaming yang Berlangsung Lama -For most deployments, you only need: +Untuk sebagian besar penerapan, Anda hanya memerlukan: | Variable | Default | Purpose | | ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | -| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | +| `REQUEST_TIMEOUT_MS` | `600000` | Garis dasar bersama untuk batas waktu mulai respons upstream, batas waktu Undici yang tersembunyi, permintaan sidik jari TLS, dan batas waktu permintaan/proksi jembatan API | +| `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Kesenjangan maksimum antara potongan streaming sebelum OmniRoute membatalkan aliran SSE | -Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. +Kompatibilitas mundur dipertahankan: `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, dan var batas waktu per lapisan lainnya yang ada masih berfungsi dan menggantikan garis dasar bersama. -For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. +Untuk upstream yang kompatibel dengan Kode Claude (`anthropic-compatible-cc-*`), OmniRoute juga memperoleh header `X-Stainless-Timeout` keluar dari batas waktu pengambilan yang diselesaikan sehingga batas waktu baca sisi penyedia tetap selaras dengan konfigurasi env Anda. -For third-party Claude Code-compatible reverse proxies, OmniRoute keeps the default -`anthropic-beta` set conservative and, when `Client Cache Control` is left on `Auto`, -only forwards client-provided `cache_control` markers. If the request does not include -`cache_control`, OmniRoute does not inject bridge-owned markers. +Untuk reverse proxy pihak ketiga yang kompatibel dengan Claude Code, OmniRoute tetap menggunakan default +`anthropic-beta` disetel konservatif dan, ketika `Client Cache Control` tersisa di `Auto`, +hanya meneruskan penanda `cache_control` yang disediakan klien. Jika permintaan tidak menyertakan +`cache_control`, OmniRoute tidak memasukkan penanda milik jembatan. -Advanced overrides are available if you need finer control: +Penggantian tingkat lanjut tersedia jika Anda memerlukan kontrol yang lebih baik: | Variable | Default | Purpose | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | -| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | +| `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Batas waktu mulai respons hulu digunakan hingga header respons tiba | +| `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Batas waktu Undici untuk menerima header respons upstream | | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | -| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `600000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | +| `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Batas waktu untuk permintaan sidik jari TLS yang dilakukan melalui `wreq-js` | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `600000` | Batas waktu untuk penerusan proxy `/v1` dari port API ke port dasbor | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Batas waktu permintaan masuk di server jembatan API | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Batas waktu header masuk di server jembatan API | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Batas waktu tetap hidup di server jembatan API| +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` |Batas waktu ketidakaktifan soket di server jembatan API (`0` menonaktifkannya) | -For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). +Untuk permintaan streaming, `FETCH_TIMEOUT_MS` hanya mencakup pengaturan koneksi/menunggu respons upstream pertama. Setelah aliran aktif, OmniRoute hanya akan dibatalkan pada keadaan terhenti sebenarnya (`STREAM_IDLE_TIMEOUT_MS`) atau tubuh Undici tidak aktif (`FETCH_BODY_TIMEOUT_MS`). -If you run OmniRoute behind Nginx, Caddy, Cloudflare, or another reverse proxy, make sure the proxy -timeouts are also higher than your OmniRoute stream/fetch timeouts. +Jika Anda menjalankan OmniRoute di belakang Nginx, Caddy, Cloudflare, atau proksi terbalik lainnya, pastikan proksi tersebut +waktu tunggu juga lebih tinggi daripada waktu tunggu aliran/pengambilan OmniRoute Anda. -### 2) Connect providers and create your API key +### 2) Hubungkan penyedia dan buat kunci API Anda -1. Open Dashboard → `Providers` and connect at least one provider (OAuth or API key). -2. Open Dashboard → `Endpoints` and create an API key. -3. (Optional) Open Dashboard → `Combos` and set your fallback chain. +1. Buka Dasbor → `Providers` dan sambungkan setidaknya satu penyedia (OAuth atau kunci API). +2. Buka Dasbor → `Endpoints` dan buat kunci API. +3. (Opsional) Buka Dasbor → `Combos` dan atur rantai cadangan Anda. -### 3) Point your coding tool to OmniRoute +### 3) Arahkan alat pengkodean Anda ke OmniRoute ```txt Base URL: http://localhost:20128/v1 @@ -867,22 +867,22 @@ API Key: [copy from Endpoint page] Model: if/kimi-k2-thinking (or any provider/model prefix) ``` -Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, OpenClaw, OpenCode, and OpenAI-compatible SDKs. +Bekerja dengan Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, OpenClaw, OpenCode, dan SDK yang kompatibel dengan OpenAI. -### 4) Enable and validate protocols (v2.0) +### 4) Mengaktifkan dan memvalidasi protokol (v2.0) -**MCP (for tool-driven operations):** +**MCP (untuk operasi yang digerakkan oleh alat):** ```bash omniroute --mcp ``` -Then connect your MCP client over `stdio` and test tools like: +Kemudian sambungkan klien MCP Anda melalui `stdio` dan uji alat seperti: - `omniroute_get_health` - `omniroute_list_combos` -**A2A (for agent-to-agent workflows):** +**A2A (untuk alur kerja agen-ke-agen):** ```bash curl http://localhost:20128/.well-known/agent.json @@ -894,15 +894,15 @@ curl -X POST http://localhost:20128/a2a \ -d '{"jsonrpc":"2.0","id":"quickstart","method":"message/send","params":{"skill":"quota-management","messages":[{"role":"user","content":"Give me a short quota summary."}]}}' ``` -### 5) Validate everything end-to-end (recommended) +### 5) Validasi semuanya end-to-end (direkomendasikan) ```bash npm run test:protocols:e2e ``` -This suite validates real MCP and A2A client flows against a running app. +Suite ini memvalidasi alur klien MCP dan A2A yang sebenarnya terhadap aplikasi yang sedang berjalan. -### Alternative: run from source +### Alternatif: dijalankan dari sumber ```bash cp .env.example .env @@ -913,7 +913,7 @@ PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm
Void Linux (`xbps-src` template) -For Void Linux users, you can build a native package using `xbps-src`. Save this block as `srcpkgs/omniroute/template`: +Untuk pengguna Void Linux, Anda dapat membuat paket asli menggunakan `xbps-src`. Simpan blok ini sebagai `srcpkgs/omniroute/template`: ```bash # Template file for 'omniroute' @@ -1021,7 +1021,7 @@ post_install() { ## 🐳 Docker -OmniRoute is available as a public Docker image on [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute). +OmniRoute tersedia sebagai image Docker publik di [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute). **Quick run:** @@ -1035,7 +1035,7 @@ docker run -d \ diegosouzapw/omniroute:latest ``` -**With environment file:** +**Dengan file lingkungan:** ```bash # Copy and edit .env first @@ -1051,7 +1051,7 @@ docker run -d \ diegosouzapw/omniroute:latest ``` -**Using Docker Compose:** +**Menggunakan Docker Tulis:** ```bash # Base profile (no CLI tools) @@ -1061,22 +1061,22 @@ docker compose --profile base up -d docker compose --profile cli up -d ``` -Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL. +Dukungan dasbor untuk penerapan Docker kini mencakup **Cloudflare Quick Tunnel** sekali klik di `Dashboard → Endpoints`. Yang pertama mengaktifkan pengunduhan `cloudflared` hanya bila diperlukan, memulai terowongan sementara ke titik akhir `/v1` Anda saat ini, dan menampilkan URL `https://*.trycloudflare.com/v1` yang dihasilkan langsung di bawah URL publik normal Anda. Notes: -- Quick Tunnel URLs are temporary and change after every restart. -- Quick Tunnels are not auto-restored after an OmniRoute or container restart. Re-enable them from the dashboard when needed. -- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`. -- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained container environments. Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want a different transport. -- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container. -- SQLite runs in WAL mode. `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. -- The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40` (or similar) so manual stops do not cut off shutdown cleanup. -- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one. +- URL Terowongan Cepat bersifat sementara dan berubah setelah setiap restart. +- Terowongan Cepat tidak dipulihkan secara otomatis setelah OmniRoute atau kontainer dimulai ulang. Aktifkan kembali dari dasbor bila diperlukan. +- Penginstalan terkelola saat ini mendukung Linux, macOS, dan Windows di `x64` / `arm64`. +- Terkelola Quick Tunnels default ke transportasi HTTP/2 untuk menghindari peringatan buffer UDP QUIC yang berisik di lingkungan kontainer yang terbatas. Setel `CLOUDFLARED_PROTOCOL=quic` atau `auto` jika Anda menginginkan transportasi lain. +- Gambar Docker menggabungkan akar CA sistem dan meneruskannya ke `cloudflared` yang dikelola, yang menghindari kegagalan kepercayaan TLS ketika terowongan melakukan bootstrap di dalam wadah. +- SQLite berjalan dalam mode WAL. `docker stop` harus dibiarkan selesai sehingga OmniRoute dapat memeriksa kembali perubahan terbaru ke `storage.sqlite`. +- File Compose yang dibundel sudah menetapkan masa tenggang penghentian 40 detik. Jika Anda menjalankan image secara langsung, pertahankan `--stop-timeout 40` (atau serupa) sehingga penghentian manual tidak menghentikan pembersihan pematian. +- Setel `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` jika Anda ingin OmniRoute menggunakan biner yang sudah ada alih-alih mengunduhnya. -**Using Docker Compose with Caddy (HTTPS Auto-TLS):** +**Menggunakan Docker Compose dengan Caddy (HTTPS Auto-TLS):** -OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP. +OmniRoute dapat diekspos dengan aman menggunakan penyediaan SSL otomatis Caddy. Pastikan data DNS A domain Anda mengarah ke IP server Anda. ```yaml services: @@ -1110,17 +1110,17 @@ volumes: --- -## 🖥️ Desktop App — Offline & Always-On +## 🖥️ Aplikasi Desktop — Offline & Selalu Aktif -> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux. +> 🆕 **BARU!** OmniRoute kini tersedia sebagai **aplikasi desktop asli** untuk Windows, macOS, dan Linux. -Run OmniRoute as a standalone desktop app — no terminal, no browser, no internet required for local models. The Electron-based app includes: +Jalankan OmniRoute sebagai aplikasi desktop mandiri — tanpa terminal, tanpa browser, tanpa internet untuk model lokal. Aplikasi berbasis Electron meliputi: -- 🖥️ **Native Window** — Dedicated app window with system tray integration -- 🔄 **Auto-Start** — Launch OmniRoute on system login -- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues -- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux) -- 🌐 **Offline Mode** — Works fully offline with bundled server +- 🖥️ **Jendela Asli** — Jendela aplikasi khusus dengan integrasi baki sistem +- 🔄 **Mulai Otomatis** — Luncurkan OmniRoute saat login sistem +- 🔔 **Pemberitahuan Asli** — Dapatkan peringatan jika kuota habis atau masalah penyedia +- ⚡ **Instal Sekali Klik** — NSIS (Windows), DMG (macOS), AppImage (Linux) +- 🌐 **Mode Offline** — Bekerja sepenuhnya offline dengan server yang dibundel ### Mulai Cepat @@ -1137,17 +1137,17 @@ npm run electron:build:linux # Linux (.AppImage) ### System Tray -When minimized, OmniRoute lives in your system tray with quick actions: +Saat diminimalkan, OmniRoute ada di baki sistem Anda dengan tindakan cepat: -- Open dashboard -- Change server port -- Quit application +- Buka dasbor +- Ubah port server +- Keluar dari aplikasi 📖 Full documentation: [`electron/README.md`](electron/README.md) --- -## 💰 Pricing at a Glance +## 💰 Harga Sekilas | Tier | Provider | Cost | Quota Reset | Best For | | ------------------- | --------------------------- | ------------------------- | ---------------- | --------------------------------- | @@ -1155,31 +1155,31 @@ When minimized, OmniRoute lives in your system tray with quick actions: | | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | | | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! | | | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models | +| **🔑 API KEY** | NVIDIA NIM | **GRATIS** (pengembangan selamanya) | ~40 RPM | 70+ open models | | | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest | | | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma | | | DeepSeek V3.2 | $0.27/$1.10 per 1M | None | Best price/quality reasoning | | | xAI Grok-4 Fast | **$0.20/$0.50 per 1M** 🆕 | None | Fastest + tool calling, ultralow | -| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI | -| | Mistral | Free trial + paid | Rate limited | European AI | -| | OpenRouter | Pay-per-use | None | 100+ models aggr. | +| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Penalaran andalan dari xAI | +| | Mistral | Uji coba gratis + berbayar | Rate limited | European AI | +| | OpenRouter | Bayar per penggunaan | None | 100+ models aggr. | | **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship | | | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | | | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks | | | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2.5 (Moonshot API) 🆕 | Pay-per-use | None | Direct Moonshot API access | +| | Kimi K2.5 (Moonshot API) 🆕 | Bayar per penggunaan | None | Direct Moonshot API access | | | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | | **🆓 FREE** | Qoder | **$0** | Unlimited | 5 models unlimited | | | Qwen | **$0** | Unlimited | 4 models unlimited | | | Kiro | **$0** | Unlimited | Claude Sonnet/Haiku (AWS Builder) | -| | LongCat Flash-Lite 🆕 | **$0** (50M tok/day 🔥) | 1 RPS | Largest free quota on Earth | -| | Pollinations AI 🆕 | **$0** (no key needed) | 1 req/15s | GPT-5, Claude, DeepSeek, Llama 4 | -| | Cloudflare Workers AI 🆕 | **$0** (10K Neurons/day) | ~150 resp/day | 50+ models, global edge | +| | LongCat Flash-Lite 🆕 | **$0** (50M tok/day 🔥) | 1 RPS | Kuota gratis terbesar di dunia | +| | Pollinations AI 🆕 | **$0** (tidak perlu kunci) | 1 req/15s | GPT-5, Claude, DeepSeek, Llama 4 | +| | Cloudflare Workers AI 🆕 | **$0** (10K Neurons/day) | ~150 resp/day | 50+ model, keunggulan global | | | Scaleway AI 🆕 | **$0** (1M tokens total) | Rate limited | EU/GDPR, Qwen3 235B, Llama 70B | -> 🆕 **New models added (Mar 2026):** Grok-4 Fast family at $0.20/$0.50/M (benchmarked at 1143ms — 30% faster than Gemini 2.5 Flash), GLM-5 via Z.AI with 128K output, MiniMax M2.5 reasoning, DeepSeek V3.2 updated pricing, Kimi K2.5 via Moonshot direct API. +> 🆕 **Model baru ditambahkan (Mar 2026):** Keluarga Grok-4 Fast seharga $0,20/$0,50/M (dibandingkan pada 1143ms — 30% lebih cepat dibandingkan Gemini 2.5 Flash), GLM-5 melalui Z.AI dengan output 128K, penalaran MiniMax M2.5, harga DeepSeek V3.2 yang diperbarui, Kimi K2.5 melalui API langsung Moonshot. -**💡 $0 Combo Stack — The Complete Free Setup:** +**💡 Tumpukan Kombo $0 — Penyiapan Gratis Lengkap:** ``` # 🆓 Ultimate Free Stack 2026 — 11 Providers, $0 Forever @@ -1196,17 +1196,17 @@ NVIDIA NIM (nvidia/) → 70+ open models — 40 RPM forever Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day ``` -**Zero cost. Never stops coding.** Configure this as one OmniRoute combo and all fallbacks happen automatically — no manual switching ever. +**Tanpa biaya. Jangan pernah berhenti melakukan pengkodean.** Konfigurasikan ini sebagai satu kombo OmniRoute dan semua fallback terjadi secara otomatis — tidak pernah ada peralihan manual. --- --- -## 🆓 Free Models — What You Actually Get +## 🆓 Model Gratis — Apa yang Sebenarnya Anda Dapatkan -> All models below are **100% free with zero credit card required**. OmniRoute auto-routes between them when one quota runs out — combine them all for an unbreakable $0 combo. +> Semua model di bawah **100% gratis tanpa memerlukan kartu kredit**. OmniRoute melakukan rute otomatis di antara keduanya ketika satu kuota habis — gabungkan semuanya untuk kombo $0 yang tidak dapat dipecahkan. -### 🔵 CLAUDE MODELS (via Kiro — AWS Builder ID) +### 🔵 MODEL CLAUDE (melalui Kiro — ID AWS Builder) | Model | Prefix | Limit | Rate Limit | | ------------------- | ------ | ------------- | --------------------- | @@ -1214,7 +1214,7 @@ Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day | `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap | | `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro | -### 🟢 QODER MODELS (Free PAT via qodercli) +### 🟢 MODEL QODER (PAT Gratis melalui qodercli) | Model | Prefix | Limit | Rate Limit | | ------------------ | ------ | ------------- | --------------- | @@ -1224,10 +1224,10 @@ Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day | `minimax-m2.1` | `if/` | **Unlimited** | No reported cap | | `kimi-k2` | `if/` | **Unlimited** | No reported cap | -> Recommended connection method: **Personal Access Token + `qodercli`**. Browser OAuth is -> experimental and disabled by default unless `QODER_OAUTH_*` environment variables are configured. +> Metode koneksi yang disarankan: **Token Akses Pribadi + `qodercli`**. Peramban OAuth adalah +> eksperimental dan dinonaktifkan secara default kecuali variabel lingkungan `QODER_OAUTH_*` dikonfigurasi. -### 🟡 QWEN MODELS (Device Code Auth) +### 🟡 MODEL QWEN (Otentikasi Kode Perangkat) | Model | Prefix | Limit | Rate Limit | | ------------------- | ------ | ------------- | ------------------- | @@ -1243,15 +1243,15 @@ Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day | `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset | | `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality | -### ⚫ NVIDIA NIM (Free API Key — build.nvidia.com) +### ⚫ NVIDIA NIM (Kunci API Gratis — build.nvidia.com) | Tier | Daily Limit | Rate Limit | Notes | | ---------- | ------------ | ----------- | ------------------------------------------------------ | -| Free (Dev) | No token cap | **~40 RPM** | 70+ models; transitioning to pure rate limits mid-2025 | +| Free (Dev) | No token cap | **~40 RPM** | 70+ model; transisi ke batas tarif murni pada pertengahan tahun 2025 | -Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1` +Model gratis populer: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1` -### ⚪ CEREBRAS (Free API Key — inference.cerebras.ai) +### ⚪ CEREBRAS (Kunci API Gratis — inference.cerebras.ai) | Tier | Daily Limit | Rate Limit | Notes | | ---- | ----------------- | ---------------- | ------------------------------------------- | @@ -1259,7 +1259,7 @@ Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7) Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b` -### 🔴 GROQ (Free API Key — console.groq.com) +### 🔴 GROQ (Kunci API Gratis — console.groq.com) | Tier | Daily Limit | Rate Limit | Notes | | ---- | ------------- | ---------------- | ----------------------------------------- | @@ -1267,19 +1267,19 @@ Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b` Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3` -### 🔴 LONGCAT AI (Free API Key — longcat.chat) 🆕 +### 🔴 LONGCAT AI (Kunci API Gratis — longcat.chat) 🆕 -| Model | Prefix | Daily Free Quota | Notes | +| Model | Prefix | Kuota Gratis Harian | Notes | | ----------------------------- | ------ | ----------------- | ----------------------- | -| `LongCat-Flash-Lite` | `lc/` | **50M tokens** 💥 | Largest free quota ever | +| `LongCat-Flash-Lite` | `lc/` | **50M tokens** 💥 | Kuota gratis terbesar yang pernah ada | | `LongCat-Flash-Chat` | `lc/` | 500K tokens | Multi-turn chat | | `LongCat-Flash-Thinking` | `lc/` | 500K tokens | Reasoning / CoT | | `LongCat-Flash-Thinking-2601` | `lc/` | 500K tokens | Jan 2026 version | | `LongCat-Flash-Omni-2603` | `lc/` | 500K tokens | Multimodal | -> 100% free while in public beta. Sign up at [longcat.chat](https://longcat.chat) with email or phone. Resets daily 00:00 UTC. +> 100% gratis saat dalam versi beta publik. Daftar di [longcat.chat](https://longcat.chat) dengan email atau telepon. Reset setiap hari pukul 00:00 UTC. -### 🟢 POLLINATIONS AI (No API Key Required) 🆕 +### 🟢 POLLINASI AI (Tidak Perlu Kunci API) 🆕 | Model | Prefix | Rate Limit | Provider Behind | | ---------- | ------ | ---------- | ------------------ | @@ -1290,52 +1290,52 @@ Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whis | `llama` | `pol/` | 1 req/15s | Meta Llama 4 Scout | | `mistral` | `pol/` | 1 req/15s | Mistral AI | -> ✨ **Zero friction:** No signup, no API key. Add the Pollinations provider with an empty key field and it works immediately. +> ✨ **Tanpa gesekan:** Tanpa pendaftaran, tanpa kunci API. Tambahkan penyedia Penyerbukan dengan bidang kunci kosong dan itu langsung berfungsi. -### 🟠 CLOUDFLARE WORKERS AI (Free API Key — cloudflare.com) 🆕 +### 🟠 AI CLOUDFLARE WORKERS (Kunci API Gratis — cloudflare.com) 🆕 | Tier | Daily Neurons | Equivalent Usage | Notes | | ---- | ------------- | --------------------------------------- | ----------------------- | -| Free | **10,000** | ~150 LLM resp / 500s audio / 15K embeds | Global edge, 50+ models | +| Free | **10,000** | ~150 LLM resp / 500s audio / 15K embeds | Keunggulan global, 50+ model | -Popular free models: `@cf/meta/llama-3.3-70b-instruct`, `@cf/google/gemma-3-12b-it`, `@cf/openai/whisper-large-v3-turbo` (free audio!), `@cf/qwen/qwen2.5-coder-15b-instruct` +Model gratis populer: `@cf/meta/llama-3.3-70b-instruct`, `@cf/google/gemma-3-12b-it`, `@cf/openai/whisper-large-v3-turbo` (audio gratis!), `@cf/qwen/qwen2.5-coder-15b-instruct` -> Requires API Token + Account ID from [dash.cloudflare.com](https://dash.cloudflare.com). Store Account ID in provider settings. +> Membutuhkan Token API + ID Akun dari [dash.cloudflare.com](https://dash.cloudflare.com). Simpan ID Akun di pengaturan penyedia. -### 🟣 SCALEWAY AI (1M Free Tokens — scaleway.com) 🆕 +### 🟣 SCALEWAY AI (1 Juta Token Gratis — scaleway.com) 🆕 | Tier | Free Quota | Location | Notes | | ---- | ------------- | ------------ | ----------------------------------- | | Free | **1M tokens** | 🇫🇷 Paris, EU | No credit card needed within limits | -Available free: `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!), `llama-3.1-70b-instruct`, `mistral-small-3.2-24b-instruct-2506`, `deepseek-v3-0324` +Tersedia gratis: `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!), `llama-3.1-70b-instruct`, `mistral-small-3.2-24b-instruct-2506`, `deepseek-v3-0324` -> EU/GDPR compliant. Get API key at [console.scaleway.com](https://console.scaleway.com). +> Sesuai dengan UE/GDPR. Dapatkan kunci API di [console.scaleway.com](https://console.scaleway.com). -> **💡 The Ultimate Free Stack (11 Providers, $0 Forever):** +> **💡 Tumpukan Gratis Terbaik (11 Penyedia, $0 Selamanya):** > > ``` -> Kiro (kr/) → Claude Sonnet/Haiku UNLIMITED -> Qoder (if/) → kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 UNLIMITED -> LongCat Lite (lc/) → LongCat-Flash-Lite — 50M tokens/day 🔥 -> Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — no key needed -> Qwen (qw/) → qwen3-coder models UNLIMITED -> Gemini (gemini/) → Gemini 2.5 Flash — 1,500 req/day free -> Cloudflare AI (cf/) → 50+ models — 10K Neurons/day -> Scaleway (scw/) → Qwen3 235B, Llama 70B — 1M free tokens (EU) -> Groq (groq/) → Llama/Gemma — 14.4K req/day ultra-fast -> NVIDIA NIM (nvidia/) → 70+ open models — 40 RPM forever -> Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day +> Kiro (kr/) → Claude Sonnet/Haiku TANPA BATAS +> Qoder (if/) → kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 TANPA BATAS +> LongCat Lite (lc/) → LongCat-Flash-Lite — 50 juta token/hari 🔥 +> Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — tidak perlu kunci +> Qwen (qw/) → model qwen3-coder TANPA BATAS +> Gemini (gemini/) → Gemini 2.5 Flash — 1.500 req/hari gratis +> Cloudflare AI (cf/) → 50+ model — 10 ribu Neurons/hari +> Scaleway (scw/) → Qwen3 235B, Llama 70B — 1 juta token gratis (EU) +> Groq (groq/) → Llama/Gemma — 14,4 ribu req/hari, sangat cepat +> NVIDIA NIM (nvidia/) → 70+ model terbuka — 40 RPM selamanya +> Cerebras (cerebras/) → Llama/Qwen tercepat di dunia — 1 juta tok/hari > ``` -## 🎙️ Free Transcription Combo +## 🎙️ Kombo Transkripsi Gratis -> Transcribe any audio/video for **$0** — Deepgram leads with $200 free, AssemblyAI $50 fallback, Groq Whisper as unlimited emergency backup. +> Transkripsikan audio/video apa pun seharga **$0** — Deepgram memimpin dengan $200 gratis, penggantian AssemblyAI $50, Groq Whisper sebagai cadangan darurat tanpa batas. | Provider | Free Credits | Best Model | Rate Limit | | ----------------- | ---------------------- | -------------------------------------------- | ---------------------------- | -| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits | -| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits | +| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | Tidak ada batasan RPM pada kredit gratis | +| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | Tidak ada batasan RPM pada kredit gratis | | 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) | **Suggested combo in `/dashboard/combos`:** @@ -1349,181 +1349,181 @@ Nodes: [3] groq/whisper-large-v3 → free forever, emergency fallback ``` -Then in `/dashboard/media` → **Transcription** tab: upload any audio or video file → select your combo endpoint → get transcription in supported formats. +Kemudian di tab `/dashboard/media` → **Transkripsi**: unggah file audio atau video apa pun → pilih titik akhir kombo Anda → dapatkan transkripsi dalam format yang didukung. -## 💡 Key Features +## 💡 Fitur Utama -OmniRoute v3.6 is built as an operational platform, not just a relay proxy. +OmniRoute v3.6 dibangun sebagai platform operasional, bukan hanya proxy relai. -### 🆕 New — v3.6.x Highlights (Apr 2026) +### 🆕 Baru — Sorotan v3.6.x (Apr 2026) -| Feature | What It Does | +| Feature | Apa Fungsinya | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🌐 **V1 WebSocket Bridge** | OpenAI-compatible WebSocket traffic upgraded and proxied via `/v1/ws` — full streaming over WS with session auth (API key or session cookie) | -| 🔑 **Sync Tokens & Config Bundle** | Issue/revoke sync tokens for config sync endpoints. Config bundles versioned with ETag for bandwidth-efficient polling | +| 🌐 **V1 WebSocket Bridge** | Lalu lintas WebSocket yang kompatibel dengan OpenAI ditingkatkan dan diproksi melalui `/v1/ws` — streaming penuh melalui WS dengan autentikasi sesi (kunci API atau cookie sesi) | +| 🔑 **Sync Tokens & Config Bundle** | Menerbitkan/mencabut token sinkronisasi untuk titik akhir sinkronisasi konfigurasi. Paket konfigurasi diversi dengan ETag untuk polling hemat bandwidth | | 🧠 **GLM Thinking (glmt) Preset** | GLM Thinking registered first-class: 65 536 max tokens, 24 576 thinking budget, 900s timeout, usage sync & pricing — Claude-compatible API | -| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing | -| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | -| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection | -| ⏳ **Wait For Cooldown** | Server-side chat retries when every candidate connection is cooling down; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` | -| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types | -| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging | -| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request | -| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` keeps data, `npm run uninstall:full` removes everything — clean removal for all install methods | -| 🔧 **OAuth Env Repair** | One-click "Repair env" action for OAuth providers restores missing env vars and fixes broken auth state | -| 🔒 **Graceful Electron Shutdown** | Electron `before-quit` shuts down Next.js gracefully, preventing SQLite WAL database locks on desktop close | -| 👁️ **Model Visibility Toggle** | Per-model visibility toggle (👁 icon) with search filter and active-count badge (`N/M active`) on provider pages | +| 🔢 **Hybrid Token Counting** | Menggunakan `/messages/count_tokens` sisi penyedia jika tersedia; kembali ke perkiraan — pelacakan penggunaan yang akurat tanpa menebak-nebak| +|🌱 **Model Alias Benih Otomatis** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches | +| 🛡️ **Safe Outbound Fetch** | Semua validasi penyedia dan penemuan model melalui lapisan pengambilan yang dilindungi yang memblokir URL pribadi/lokal dengan percobaan ulang, batas waktu, dan perlindungan SSRF | +| ⏳ **Tunggu Masa Tenang** | Percobaan ulang obrolan sisi server ketika setiap koneksi kandidat sedang dingin; dapat dikonfigurasi `enabled`, `maxRetries`, dan `maxRetryWaitSec` | +| 🔍 **Validasi Env Runtime** | Startup memvalidasi semua env vars dengan skema Zod - menghapus kesalahan untuk rahasia yang hilang, URL yang tidak valid, atau tipe yang salah | +| 📋 **Compliance Audit Expansion** | Log audit terstruktur dengan penomoran halaman, konteks permintaan, peristiwa autentikasi, peristiwa CRUD penyedia, dan pencatatan validasi yang diblokir SSRF | +| 🔐 **TPS Log Metric** | Modal detail log menunjukkan Token Per Second (TPS) — sekilas kinerja cepat untuk setiap permintaan | +| 🗑️ **Uninstall / Full Uninstall** | `npm run uninstall` menyimpan data, `npm run uninstall:full` menghapus semuanya — penghapusan bersih untuk semua metode instalasi | +| 🔧 **OAuth Env Repair** | Tindakan "Perbaiki env" sekali klik untuk penyedia OAuth memulihkan vars env yang hilang dan memperbaiki status autentikasi yang rusak | +| 🔒 **Pematian Elektron yang Anggun** | Electron `before-quit` dimatikan Next.js dengan baik, mencegah penguncian database SQLite WAL pada penutupan desktop | +| 👁️ **Pengalih Visibilitas Model** | Pengalih visibilitas per model (ikon 👁) dengan filter pencarian dan lencana jumlah aktif (`N/M active`) di halaman penyedia | | 📧 **Email Privacy Masking** | OAuth account emails masked (`di*****@g****.com`), full address visible on hover | -| 🔗 **Context Relay Strategy** | Combo strategy preserving session continuity via structured handoff summaries when accounts rotate mid-conversation | -| 🛡️ **Proxy Hardening** | Token health check, API key validation, and undici dispatcher all honor proxy config | +| 🔗 **Context Relay Strategy** | Strategi kombo menjaga kesinambungan sesi melalui ringkasan penyerahan terstruktur saat akun dirotasi di tengah percakapan | +| 🛡️ **Proxy Hardening** | Pemeriksaan kesehatan token, validasi kunci API, dan operator undici semuanya menghormati konfigurasi proxy | | ⚠️ **Node.js 24 Login Warning** | Login page proactively detects incompatible Node.js versions and shows a clear warning banner | | 📎 **Gemini PDF Attachments** | PDF attachments correctly routed to Gemini via `inline_data` and generic base64 detection | -| 🔒 **CodeQL Security Hardening** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | +| 🔒 **Pengerasan Keamanan CodeQL** | Resolved SSRF, insecure randomness, polynomial ReDoS, and incomplete URL sanitization alerts | -### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026) +### 🆕 Baru — Peningkatan Terinspirasi ClawRouter (Mar 2026) -| Feature | What It Does | +| Feature | Apa Fungsinya | | ------------------------------------ | ------------------------------------------------------------------------------------------- | | ⚡ **Grok-4 Fast Family** | xAI models at $0.20/$0.50/M — benchmarked 1143ms (30% faster than Gemini 2.5 Flash) | -| 🧠 **GLM-5 via Z.AI** | 128K output context, $0.5/1M — newest flagship from the GLM family | -| 🔮 **MiniMax M2.5** | Reasoning + agentic tasks at $0.30/1M — significant upgrade from M2.1 | -| 🎯 **toolCalling Flag per Model** | Per-model `toolCalling: true/false` in registry — AutoCombo skips non-tool-capable models | -| 🌍 **Multilingual Intent Detection** | PT/ZH/ES/AR keywords in AutoCombo scoring — better model selection for non-English content | -| 📊 **Benchmark-Driven Fallbacks** | Real p95 latency from live requests feeds combo scoring — AutoCombo learns from actual data | +| 🧠 **GLM-5 via Z.AI** | Konteks keluaran 128 ribu, $0,5/1 juta — andalan terbaru dari keluarga GLM | +| 🔮 **MiniMax M2.5** | Penalaran + tugas agen seharga $0,30/1 juta — peningkatan signifikan dari M2.1| +|🎯 **alat Memanggil Bendera per Model** | Per model `toolCalling: true/false` di registri — AutoCombo melewatkan model yang tidak mendukung alat | +| 🌍 **Multilingual Intent Detection** | Kata kunci PT/ZH/ES/AR dalam penilaian AutoCombo — pemilihan model yang lebih baik untuk konten non-Inggris | +| 📊 **Benchmark-Driven Fallbacks** | Latensi p95 nyata dari penilaian kombo umpan permintaan langsung — AutoCombo belajar dari data aktual | | 🔁 **Request Deduplication** | Content-hash based dedup window — multi-agent safe, prevents duplicate charges | -| 🔌 **Pluggable RouterStrategy** | Extensible `RouterStrategy` interface — add custom routing logic as plugins | +| 🔌 **Pluggable RouterStrategy** | Antarmuka `RouterStrategy` yang dapat diperluas — tambahkan logika perutean khusus sebagai plugin | -### 🚀 Previous v2.0.9+ — Playground, CLI Fingerprints & ACP +### 🚀 Sebelumnya v2.0.9+ — Playground, Fingerprint CLI & ACP -| Feature | What It Does | +| Feature | Apa Fungsinya | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🎮 **Model Playground** | Dashboard page to test any model directly — provider/model/endpoint selectors, Monaco Editor, streaming, abort, timing | -| 🔏 **CLI Fingerprint Matching** | Per-provider header/body ordering to match native CLI signatures — toggle per provider in Settings > Security. **Your proxy IP is preserved** | -| 🤝 **ACP Support (Agent Client Protocol)** | CLI agent discovery (Codex, Claude, Goose, Gemini CLI, OpenClaw + 9 more), process spawner, `/api/acp/agents` endpoint | -| 🤖 **ACP Agents Dashboard** | Debug › Agents page — grid of 14 agents with install status, version, custom agent form for any CLI tool. **OpenCode** users get a "Download opencode.json" button that auto-generates a ready-to-use config with all available models. | -| 🔧 **Custom Model `apiFormat` Routing** | Custom models with `apiFormat: "responses"` now correctly route to the Responses API translator | +| 🎮 **Model Playground** | Halaman dasbor untuk menguji model apa pun secara langsung — pemilih penyedia/model/titik akhir, Editor Monaco, streaming, batalkan, pengaturan waktu | +| 🔏 **CLI Fingerprint Matching** | Pengurutan header/isi per penyedia agar sesuai dengan tanda tangan CLI asli — alihkan per penyedia di Pengaturan > Keamanan. **IP proxy Anda dipertahankan** | +| 🤝 **Dukungan ACP (Protokol Klien Agen)** | CLI agent discovery (Codex, Claude, Goose, Gemini CLI, OpenClaw + 9 more), process spawner, `/api/acp/agents` endpoint | +| 🤖 **Dasbor Agen ACP** | Debug › Halaman agen — kisi 14 agen dengan status pemasangan, versi, formulir agen khusus untuk alat CLI apa pun. Pengguna **OpenCode** mendapatkan tombol "Unduh opencode.json" yang secara otomatis menghasilkan konfigurasi siap pakai dengan semua model yang tersedia. | +| 🔧 **Custom Model `apiFormat` Routing** | Model khusus dengan `apiFormat: "responses"` sekarang dirutekan dengan benar ke penerjemah Responses API | | 🏢 **Codex Workspace Isolation** | Multiple Codex workspaces per email — OAuth correctly separates connections by workspace ID | -| 🔄 **Electron Auto-Update** | Desktop app checks for updates + auto-install on restart | +| 🔄 **Pembaruan Otomatis Elektron** | Aplikasi desktop memeriksa pembaruan + instal otomatis saat restart | -### 🤖 Agent & Protocol Operations (v2.0) +### 🤖 Operasi Agen & Protokol (v2.0) -| Feature | What It Does | +| Feature | Apa Fungsinya | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 🔧 **MCP Server (25 tools)** | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`). 18 core + 3 memory + 4 skill tools | -| 🤝 **A2A Server (JSON-RPC + SSE)** | Agent-to-agent task execution with sync and streaming flows | -| 🧭 **Consolidated Endpoints Page** | Tabbed management page with Endpoint Proxy, MCP, A2A, and API Endpoints tabs | -| 🎚️ **Service Enable/Disable Toggles** | ON/OFF switches for MCP and A2A with settings persistence (default: OFF) | -| 🛰️ **MCP Runtime Heartbeat** | Real process status (pid, uptime, heartbeat age, transport, scope mode) | -| 📋 **MCP Audit Trail** | Filterable audit logs with success/failure and key attribution | -| 🔐 **MCP Scope Enforcement** | 10 granular scope permissions for controlled tool access | -| 📡 **A2A Task Lifecycle Management** | List/filter tasks, inspect events/artifacts, cancel running tasks | -| 📋 **Agent Card Discovery** | `/.well-known/agent.json` for client auto-discovery | -| 🧪 **Protocol E2E Test Harness** | Real MCP SDK + A2A client flows in `test:protocols:e2e` | -| ⚙️ **Operational Controls** | Switch combos, tune resilience settings, and review breaker state from dedicated Health and Settings surfaces | +| 🔧 **Server MCP (25 alat)** | IDE/agent tools via 3 transports: stdio, SSE (`/api/mcp/sse`), Streamable HTTP (`/api/mcp/stream`). 18 core + 3 memory + 4 skill tools | +| 🤝 **Server A2A (JSON-RPC + SSE)** | Eksekusi tugas agen-ke-agen dengan alur sinkronisasi dan streaming | +| 🧭 **Halaman Titik Akhir Konsolidasi** | Halaman manajemen bertab dengan tab Proksi Titik Akhir, MCP, A2A, dan Titik Akhir API| +| 🎚️ **Service Enable/Disable Toggles** |Sakelar ON/OFF untuk MCP dan A2A dengan pengaturan persistensi (default: OFF) | +| 🛰️ **Detak Jantung Waktu Proses MCP** | Real process status (pid, uptime, heartbeat age, transport, scope mode) | +| 📋 **MCP Audit Trail** | Log audit yang dapat difilter dengan keberhasilan/kegagalan dan atribusi kunci | +| 🔐 **MCP Scope Enforcement** | 10 izin cakupan terperinci untuk akses alat terkontrol | +| 📡 **Manajemen Siklus Hidup Tugas A2A** | List/filter tasks, inspect events/artifacts, cancel running tasks | +| 📋 **Agent Card Discovery** | `/.well-known/agent.json` untuk penemuan otomatis klien | +| 🧪 **Protocol E2E Test Harness** | Klien MCP SDK + A2A asli mengalir di `test:protocols:e2e` | +| ⚙️ **Operational Controls** | Ganti kombo, sesuaikan pengaturan ketahanan, dan tinjau status pemutus dari permukaan Kesehatan dan Pengaturan khusus | -### 🧠 Routing & Intelligence +### 🧠 Routing & Kecerdasan -| Feature | What It Does | +| Feature | Apa Fungsinya | | ---------------------------------- | ------------------------------------------------------------------------ | -| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free | -| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider | -| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions | -| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection | -| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry | -| 🎨 **Custom Combos** | 13 balancing strategies + fallback chain control | -| 🔗 **Context Relay** | Session continuity handoffs when account rotation happens mid-session | +| 🎯 **Pengembalian 4 Tingkat Cerdas** | Rute otomatis: Berlangganan → Kunci API → Murah → Gratis | +| 📊 **Pelacakan Kuota Waktu Nyata** | Jumlah token langsung + setel ulang hitungan mundur per penyedia | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Respons dengan konversi skema-aman | +| 👥 **Dukungan Multi-Akun** | Banyak akun per penyedia dengan pilihan cerdas | +| 🔄 **Auto Token Refresh** | Token OAuth disegarkan secara otomatis dengan percobaan ulang | +| 🎨 **Custom Combos** | 13 strategi penyeimbangan + kontrol rantai mundur | +| 🔗 **Context Relay** | Penyerahan kesinambungan sesi ketika rotasi akun terjadi di tengah sesi | | 🌐 **Wildcard Router** | `provider/*` dynamic routing | -| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits | -| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety | -| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models | -| 🧪 **Task-Aware Smart Routing** | Auto-select model by content type (coding/vision/analysis/summarization) | +| 🧠 **Thinking Budget Controls** | Batas penalaran passthrough, otomatis, kustom, dan adaptif| +| 🔀 **Model Aliases** |Alias ​​model khusus + bawaan dan keamanan migrasi | +| ⚡ **Background Degradation** | Arahkan tugas latar belakang berprioritas rendah ke model yang lebih murah | +| 🧪 **Perutean Cerdas Sadar Tugas** | Pilih model secara otomatis berdasarkan jenis konten (pengkodean/visi/analisis/ringkasan) | | 🔄 **A2A Agent Workflows** | Deterministic FSM orchestrator for stateful multi-step agent executions | | 🔀 **Adaptive Routing** | Dynamic strategy override based on token volume and prompt complexity | | 🎲 **Provider Diversity** | Shannon entropy scoring balancing auto-combo traffic distribution | | 💬 **System Prompt Injection** | Global behavior controls applied consistently | -| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows | +| 📄 **Kompatibilitas API Respons** | Dukungan penuh `/v1/responses` untuk Codex dan alur kerja agen tingkat lanjut | -### 🎵 Multi-Modal APIs +### 🎵 API Multi-Modal -| Feature | What It Does | +| Feature | Apa Fungsinya | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🖼️ **Image Generation** | `/v1/images/generations` with cloud and local backends | -| 📐 **Embeddings** | `/v1/embeddings` for search and RAG pipelines | +| 🖼️ **Image Generation** | `/v1/images/generations` dengan cloud dan backend lokal | +| 📐 **Embeddings** | `/v1/embeddings` untuk saluran pencarian dan RAG | | 🎤 **Audio Transcription** | `/v1/audio/transcriptions` — 7 providers (Deepgram Nova 3, AssemblyAI, Groq Whisper, HuggingFace, ElevenLabs, OpenAI, Azure), auto-language detection, MP4/MP3/WAV support | -| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 providers (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) with correct error messages | +| 🔊 **Text-to-Speech** | `/v1/audio/speech` — 10 penyedia (ElevenLabs, OpenAI, Deepgram, Cartesia, PlayHT, HuggingFace, Nvidia NIM, Inworld, Coqui, Tortoise) dengan pesan kesalahan yang benar | | 🎬 **Video Generation** | `/v1/videos/generations` (ComfyUI + SD WebUI workflows) | | 🎵 **Music Generation** | `/v1/music/generations` (ComfyUI workflows) | | 🛡️ **Moderations** | `/v1/moderations` safety checks | -| 🔀 **Reranking** | `/v1/rerank` for relevance scoring | +| 🔀 **Reranking** | `/v1/rerank` untuk penilaian relevansi | | 🔍 **Web Search** 🆕 | `/v1/search` — 5 providers (Serper, Brave, Perplexity, Exa, Tavily), 6,500+ free/month, auto-failover, cache | -### 🛡️ Resilience, Security & Governance +### 🛡️ Ketahanan, Keamanan & Tata Kelola -| Feature | What It Does | +| Feature | Apa Fungsinya | | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | -| 🔌 **Provider Circuit Breakers** | Provider-wide trip/recover after fallback exhaustion with configurable thresholds | -| 🔒 **Daily Quota Lock** 🆕 | Detects exhaustion signals and locks routing for the specific model until midnight | -| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format | +| 🔌 **Penyedia Pemutus Arus** | Perjalanan/pemulihan di seluruh penyedia setelah kelelahan fallback dengan ambang batas yang dapat dikonfigurasi | +| 🔒 **Kunci Kuota Harian** 🆕 | Mendeteksi sinyal kelelahan dan mengunci perutean untuk model tertentu hingga tengah malam | +| 🎯 **Model Sadar Titik Akhir** | Model khusus mendeklarasikan titik akhir + format API yang didukung | | 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events | -| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers | +| 🧠 **Semantic + Signature Cache** | Pengurangan biaya/latensi dengan dua lapisan cache | | ⚡ **Request Idempotency** | Duplicate protection window | -| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** | +| 🔒 **TLS Fingerprint Spoofing** | Sidik jari TLS seperti browser — **mengurangi deteksi bot dan penandaan akun**| | 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** | -| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments | -| 🚦 **Request Queue & Pacing** | Configurable per-connection request buckets for RPM, spacing, concurrency, and max wait | +| 🌐 **IP Filtering** |Kontrol daftar yang diizinkan/daftar blokir untuk penerapan yang terbuka | +| 🚦 **Minta Antrian & Kecepatan** | Bucket permintaan per koneksi yang dapat dikonfigurasi untuk RPM, spasi, konkurensi, dan waktu tunggu maksimal | | 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations | -| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks | -| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures | +| 📜 **Config Audit Trail** | Pelacakan perubahan berbasis diff mencegah penyimpangan operasional dengan rollback sederhana | +| ⏳ **Sinkronisasi Kesehatan Penyedia** | Proactive token expiration monitoring triggering alerts before authorization failures | | ❄️ **Connection Cooldown** | Retryable 408/429/5xx failures cool down a single connection with optional upstream hints | -| 🚪 **Auto-Disable Banned Accounts** | Permanently blocked token accounts can be disabled automatically | -| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls | -| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` | -| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog | -| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection | -| ⏳ **Wait For Cooldown** 🆕 | Auto-retry chat after connection cooldowns; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` | -| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages | -| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging | +| 🚪 **Nonaktifkan Otomatis Akun yang Diblokir** | Akun token yang diblokir secara permanen dapat dinonaktifkan secara otomatis | +| 🔑 **Manajemen Kunci API + Pelingkupan** | Mengamankan penerbitan/rotasi kunci dan kontrol model/penyedia | +| 👁️ **Pengungkapan Kunci API Cakupan** 🆕 | Ikut serta dalam pemulihan kunci API melalui `ALLOW_API_KEY_REVEAL` | +| 🛡️ **Protected `/models`** | Gerbang autentikasi opsional dan penyembunyian penyedia untuk katalog model | +| 🛡️ **Safe Outbound Fetch** 🆕 | Pengambilan yang dijaga untuk panggilan penyedia — memblokir URL pribadi/lokal, percobaan ulang, perlindungan SSRF | +| ⏳ **Tunggu Cooldown** 🆕 | Coba ulang obrolan secara otomatis setelah cooldown koneksi; dapat dikonfigurasi `enabled`, `maxRetries`, dan `maxRetryWaitSec` | +| 🔍 **Validasi Env Runtime** 🆕 | Zod-based env schema validation at startup with actionable error messages | +| 📋 **Compliance Audit v2** 🆕 | Penomoran halaman, konteks permintaan, peristiwa autentikasi, CRUD penyedia, dan logging yang diblokir SSRF | -### 📊 Observability & Analytics +### 📊 Observabilitas & Analitik -| Feature | What It Does | +| Feature | Apa Fungsinya | | -------------------------------- | ----------------------------------------------------- | -| 📝 **Request + Proxy Logging** | Full request/response and proxy logging | -| 📉 **Streamed Detailed Logs** | Reconstructs SSE payload streams cleanly into the UI | -| 🏷️ **Real-Time Model Badges** 🆕 | Live model status and daily quota countdown timers | -| 📋 **Unified Logs Dashboard** | Request, proxy, audit, and console views in one page | -| 🔍 **Request Telemetry** | p50/p95/p99 latency and request tracing | +| 📝 **Permintaan + Pencatatan Proksi** | Permintaan/respons penuh dan pencatatan proksi| +| 📉 **Streamed Detailed Logs** |Merekonstruksi aliran muatan SSE dengan rapi ke dalam UI | +| 🏷️ **Lencana Model Real-Time** 🆕 | Status model langsung dan penghitung waktu mundur kuota harian | +| 📋 **Dasbor Log Terpadu** | Tampilan permintaan, proksi, audit, dan konsol dalam satu halaman | +| 🔍 **Request Telemetry** | latensi p50/p95/p99 dan penelusuran permintaan | | 🏥 **Health Dashboard** | Uptime, breaker states, lockouts, cache stats | -| 💰 **Cost Tracking** | Budget controls and per-model pricing visibility | -| 📈 **Analytics Visualizations** | Model/provider usage insights and trend views | -| 🧪 **Evaluation Framework** | Golden set testing with configurable match strategies | -| 📡 **Live Diagnostics** 🆕 | Semantic cache bypass for accurate combo live testing | +| 💰 **Cost Tracking** | Kontrol anggaran dan visibilitas harga per model | +| 📈 **Analytics Visualizations** | Wawasan penggunaan model/penyedia dan tampilan tren | +| 🧪 **Evaluation Framework** | Pengujian set emas dengan strategi pencocokan yang dapat dikonfigurasi | +| 📡 **Live Diagnostics** 🆕 | Bypass cache semantik untuk pengujian langsung kombo yang akurat | | 🔐 **TPS Log Metric** 🆕 | Tokens Per Second badge in log details modal | ### ☁️ Deployment & Platform -| Feature | What It Does | +| Feature | Apa Fungsinya | | ------------------------------ | --------------------------------------------------------------------- | | 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments | -| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard | -| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles | -| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls | -| 🔄 **Backup/Restore** | Export/import and disaster recovery flows | -| 🧙 **Onboarding Wizard** | First-run guided setup | -| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools | -| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard | -| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security | -| 🌐 **i18n (30 languages)** | Full dashboard + docs language support with RTL coverage | -| 🧹 **Clear All Models** | One-click model list clearing in provider details | -| 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings | -| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features | -| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location | +| 🚇 **Cloudflare Tunnel** 🆕 | Integrasi Quick Tunnel sekali klik dari dasbor | +| 🔑 **Pemfilteran Model Kunci API** | Respons asli /v1/models difilter melalui peran konteks Pembawa yang ditetapkan | +| ⚡ **Smart Cache Bypass** | Heuristik TTL yang dapat dikonfigurasi dan kontrol pengambilan ulang paksa | +| 🔄 **Backup/Restore** | Arus ekspor/impor dan pemulihan bencana | +| 🧙 **Onboarding Wizard** | Penyiapan terpandu yang dijalankan pertama kali | +| 🔧 **Dasbor Alat CLI** | Pengaturan sekali klik untuk alat pengkodean populer | +| 🎮 **Model Playground** | Uji penyedia/model/titik akhir apa pun dari dasbor| +| 🔏 **CLI Fingerprint Toggle** |Pencocokan sidik jari per penyedia di Pengaturan > Keamanan | +| 🌐 **i18n (30 languages)** | Dasbor lengkap + dukungan bahasa dokumen dengan cakupan RTL | +| 🧹 **Hapus Semua Model** | Pembersihan daftar model sekali klik di detail penyedia | +| 👁️ **Sidebar Controls** 🆕 | Sembunyikan komponen dan integrasi dari Pengaturan Penampilan | +| 📋 **Issue Templates** | Templat GitHub standar untuk bug dan fitur | +| 📂 **Custom Data Directory** | `DATA_DIR` penggantian untuk lokasi penyimpanan | | 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` | -| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support | +| 🔑 **Sync Tokens & Bundle** 🆕 | Konfigurasikan token sinkronisasi + titik akhir bundel berversi dengan dukungan ETag | -### Feature Deep Dive +### Fitur Penyelaman Mendalam -#### Smart fallback with practical cost control +#### Penggantian cerdas dengan pengendalian biaya praktis ```txt Combo: "my-coding-stack" @@ -1533,42 +1533,42 @@ Combo: "my-coding-stack" 4. if/kimi-k2-thinking ``` -When quota, rate, or health fails, OmniRoute automatically moves to the next candidate without manual switching. +Ketika kuota, tarif, atau kesehatan gagal, OmniRoute secara otomatis berpindah ke kandidat berikutnya tanpa peralihan manual. -#### Protocol management that is visible and operable +#### Manajemen protokol yang terlihat dan dapat dioperasikan -- MCP + A2A are discoverable in UI and docs (not hidden) -- Protocol status APIs expose live operational data (`/api/mcp/*`, `/api/a2a/*`) -- Dashboards include actions for day-2 ops (combo toggles, breaker resets, task cancellation) +- MCP + A2A dapat ditemukan di UI dan dokumen (tidak disembunyikan) +- API status protokol memaparkan data operasional langsung (`/api/mcp/*`, `/api/a2a/*`) +- Dasbor mencakup tindakan untuk operasi hari ke-2 (pengalihan kombo, pengaturan ulang pemutus, pembatalan tugas) -#### Translator + validation workflow +#### Workflow translator + validasi -The Translator area includes: +Area Penerjemah meliputi: - **Playground**: request transformation checks - **Chat Tester**: full request/response round-trip - **Test Bench**: multiple cases in one run - **Live Monitor**: real-time traffic view -Plus protocol validation with real clients via `npm run test:protocols:e2e`. +Ditambah validasi protokol dengan klien nyata melalui `npm run test:protocols:e2e`. -> 📖 **[MCP Server README](open-sse/mcp-server/README.md)** — Tool reference, IDE configs, and client examples +> 📖 **[MCP Server README](open-sse/mcp-server/README.md)** — Referensi alat, konfigurasi IDE, dan contoh klien > -> 📖 **[A2A Server README](src/lib/a2a/README.md)** — Skills, JSON-RPC methods, streaming, and task lifecycle +> 📖 **[A2A Server README](src/lib/a2a/README.md)** — Keterampilan, metode JSON-RPC, streaming, dan siklus hidup tugas -## 🧪 Evaluations (Evals) +## 🧪 Evaluasi (Evals) -OmniRoute includes a built-in evaluation framework to test LLM response quality against a golden set. Access it via **Analytics → Evals** in the dashboard. +OmniRoute menyertakan kerangka evaluasi bawaan untuk menguji kualitas respons LLM terhadap rangkaian emas. Akses melalui **Analytics → Evals** di dasbor. -### Built-in Golden Set +### Golden Set Bawaan -The pre-loaded "OmniRoute Golden Set" contains test cases for: +"OmniRoute Golden Set" yang dimuat sebelumnya berisi kasus uji untuk: -- Greetings, math, geography, code generation -- JSON format compliance, translation, markdown generation -- Safety refusal (harmful content), counting, boolean logic +- Salam, matematika, geografi, pembuatan kode +- Kepatuhan format JSON, terjemahan, pembuatan penurunan harga +- Penolakan keamanan (konten berbahaya), penghitungan, logika boolean -### Evaluation Strategies +### Strategi Evaluasi | Strategy | Description | Example | | ---------- | ------------------------------------------------ | -------------------------------- | @@ -1579,12 +1579,12 @@ The pre-loaded "OmniRoute Golden Set" contains test cases for: --- -## 📖 Setup Guide +## 📖 Panduan Setup -### Protocol Setup (MCP + A2A) +### Pengaturan Protokol (MCP + A2A)
-🧩 MCP Setup (Model Context Protocol) +🧩 Penyiapan MCP (Protokol Konteks Model) Start MCP transport in stdio mode: @@ -1594,12 +1594,12 @@ omniroute --mcp Recommended validation flow: -1. Connect your MCP client over stdio. -2. Run `omniroute_get_health`. -3. Run `omniroute_list_combos`. -4. Open `/dashboard/mcp` to confirm heartbeat, activity, and audit. +1. Hubungkan klien MCP Anda melalui stdio. +2. Jalankan `omniroute_get_health`. +3. Jalankan `omniroute_list_combos`. +4. Buka `/dashboard/mcp` untuk mengonfirmasi detak jantung, aktivitas, dan audit. -Useful APIs for automation: +API yang berguna untuk otomatisasi: - `GET /api/mcp/status` - `GET /api/mcp/tools` @@ -1609,9 +1609,9 @@ Useful APIs for automation:
-🤝 A2A Setup (Agent2Agent) +🤝 Pengaturan A2A (Agen2Agen) -Discover the agent: +Temukan agennya: ```bash curl http://localhost:20128/.well-known/agent.json @@ -1634,14 +1634,14 @@ Manage lifecycle: Operational UI: -- `/dashboard/a2a` for task/state/stream observability and smoke actions +- `/dashboard/a2a` untuk observasi tugas/status/aliran dan tindakan asap
-🧪 End-to-end protocol validation +🧪 Validasi protokol end-to-end -Validate both protocols with real clients: +Validasi kedua protokol dengan klien nyata: ```bash npm run test:protocols:e2e @@ -1649,14 +1649,14 @@ npm run test:protocols:e2e This verifies: -- MCP SDK client connect/list/call -- A2A discovery/send/stream/get/cancel -- Cross-check data in MCP audit and A2A task management APIs +- Koneksi/daftar/panggilan klien MCP SDK +- Penemuan A2A/kirim/aliran/dapatkan/batalkan +- Periksa silang data dalam audit MCP dan API manajemen tugas A2A
-💳 Subscription Providers +💳 Penyedia Berlangganan ### Claude Code (Pro/Max) @@ -1671,7 +1671,7 @@ Models: cc/claude-haiku-4-5-20251001 ``` -**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model! +**Kiat Pro:** Gunakan Opus untuk tugas kompleks, Soneta untuk kecepatan. OmniRoute melacak kuota per model! ### OpenAI Codex (Plus/Pro) @@ -1685,24 +1685,24 @@ Models: cx/gpt-5.1-codex-max ``` -#### Codex Account Limit Management (5h + Weekly) +#### Manajemen Batas Akun Codex (5 jam + Mingguan) -Each Codex account now has policy toggles in `Dashboard -> Providers`: +Setiap akun Codex kini memiliki kebijakan yang dapat diubah di `Dashboard -> Providers`: -- `5h` (ON/OFF): enforce the 5-hour window threshold policy. -- `Weekly` (ON/OFF): enforce the weekly window threshold policy. -- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped. -- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically. -- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically. +- `5h` (ON/OFF): menerapkan kebijakan ambang jendela 5 jam. +- `Weekly` (ON/OFF): menerapkan kebijakan ambang jendela mingguan. +- Perilaku ambang batas: ketika jendela yang diaktifkan mencapai >=90% penggunaan, akun tersebut dilewati. +- Perilaku rotasi: OmniRoute merutekan ke akun Codex berikutnya yang memenuhi syarat secara otomatis. +- Perilaku reset: ketika waktu `resetAt` penyedia telah berlalu, akun akan memenuhi syarat lagi secara otomatis. Scenarios: -- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold. -- `5h OFF` + `Weekly ON`: only weekly usage can block the account. -- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account. -- `resetAt` passed: account re-enters rotation automatically (no manual re-enable). +- `5h ON` + `Weekly ON`: akun dilewati ketika salah satu jendela mencapai ambang batas. +- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang dapat memblokir akun. +- `5h ON` + `Weekly OFF` : hanya penggunaan 5 jam yang dapat memblokir akun. +- `resetAt` lolos: akun masuk kembali ke rotasi secara otomatis (tidak ada pengaktifan ulang secara manual). -### Gemini CLI (FREE 180K/month!) +### Gemini CLI (GRATIS 180K/bulan!) ```bash Dashboard → Providers → Connect Gemini CLI @@ -1714,7 +1714,7 @@ Models: gc/gemini-2.5-pro ``` -**Best Value:** Huge free tier! Use this before paid tiers. +**Nilai Terbaik:** Tingkat gratis yang sangat besar! Gunakan ini sebelum tingkatan berbayar. ### GitHub Copilot @@ -1732,90 +1732,90 @@ Models:
-🔑 API Key Providers +🔑 Penyedia Kunci API -### NVIDIA NIM (FREE developer access — 70+ models) +### NVIDIA NIM (akses pengembang GRATIS — 70+ model) -1. Sign up: [build.nvidia.com](https://build.nvidia.com) -2. Get free API key (1000 inference credits included) -3. Dashboard → Add Provider → NVIDIA NIM: - - API Key: `nvapi-your-key` +1. Daftar: [build.nvidia.com](https://build.nvidia.com) +2. Dapatkan kunci API gratis (termasuk 1000 kredit inferensi) +3. Dasbor → Tambah Penyedia → NVIDIA NIM: + - Kunci API: `nvapi-your-key` -**Models:** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct`, and 50+ more +**Model:** `nvidia/llama-3.3-70b-instruct`, `nvidia/mistral-7b-instruct`, dan 50+ lainnya -**Pro Tip:** OpenAI-compatible API — works seamlessly with OmniRoute's format translation! +**Kiat Pro:** API yang kompatibel dengan OpenAI — bekerja secara lancar dengan terjemahan format OmniRoute! ### DeepSeek -1. Sign up: [platform.deepseek.com](https://platform.deepseek.com) -2. Get API key -3. Dashboard → Add Provider → DeepSeek +1. Daftar: [platform.deepseek.com](https://platform.deepseek.com) +2. Dapatkan kunci API +3. Dasbor → Tambah Penyedia → DeepSeek **Models:** `deepseek/deepseek-chat`, `deepseek/deepseek-coder` -### Groq (Free Tier Available!) +### Groq (Tersedia Tingkat Gratis!) -1. Sign up: [console.groq.com](https://console.groq.com) -2. Get API key (free tier included) -3. Dashboard → Add Provider → Groq +1. Daftar: [console.groq.com](https://console.groq.com) +2. Dapatkan kunci API (termasuk tingkat gratis) +3. Dasbor → Tambah Penyedia → Groq **Models:** `groq/llama-3.3-70b`, `groq/mixtral-8x7b` **Pro Tip:** Ultra-fast inference — best for real-time coding! -### OpenRouter (100+ Models) +### OpenRouter (100+ Model) -1. Sign up: [openrouter.ai](https://openrouter.ai) -2. Get API key -3. Dashboard → Add Provider → OpenRouter +1. Daftar: [openrouter.ai](https://openrouter.ai) +2. Dapatkan kunci API +3. Dasbor → Tambah Penyedia → OpenRouter -**Models:** Access 100+ models from all major providers through a single API key. +**Model:** Akses 100+ model dari semua penyedia utama melalui satu kunci API. -**Dashboard behavior:** OpenRouter models are managed from **Available Models**. Manual add, import, and auto-sync all update the same list. +**Perilaku dasbor:** Model OpenRouter dikelola dari **Model yang Tersedia**. Penambahan manual, impor, dan sinkronisasi otomatis semuanya memperbarui daftar yang sama.
-💰 Cheap Providers (Backup) +💰 Penyedia Murah (Cadangan) -### GLM-4.7 (Daily reset, $0.6/1M) +### GLM-4.7 (Reset harian, $0.6/1M) -1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) -2. Get API key from Coding Plan -3. Dashboard → Add API Key: - - Provider: `glm` - - API Key: `your-key` +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Dapatkan kunci API dari Coding Plan +3. Dasbor → Tambahkan Kunci API: + - Penyedia: `glm` + - Kunci API: `your-key` **Use:** `glm/glm-4.7` -**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. +**Tips Pro:** Paket Coding menawarkan 3× kuota dengan biaya 1/7! Reset setiap hari pukul 10.00. -### MiniMax M2.1 (5h reset, $0.20/1M) +### MiniMax M2.1 (Reset 5 jam, $0.20/1M) -1. Sign up: [MiniMax](https://www.minimax.io/) -2. Get API key -3. Dashboard → Add API Key +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Dapatkan kunci API +3. Dasbor → Tambahkan Kunci API **Use:** `minimax/MiniMax-M2.1` -**Pro Tip:** Cheapest option for long context (1M tokens)! +**Kiat Pro:** Opsi termurah untuk konteks panjang (1 juta token)! -### Kimi K2 ($9/month flat) +### Kimi K2 ($9/bulan flat) -1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) -2. Get API key -3. Dashboard → Add API Key +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Dapatkan kunci API +3. Dasbor → Tambahkan Kunci API **Use:** `kimi/kimi-latest` -**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! +**Kiat Pro:** Memperbaiki $9/bulan untuk 10 juta token = biaya efektif $0,90/1 juta!
-🆓 FREE Providers (Emergency Backup) +🆓 Penyedia GRATIS (Cadangan Darurat) -### Qoder (5 FREE models via OAuth) +### Qoder (5 model GRATIS melalui OAuth) ```bash Dashboard → Connect Qoder @@ -1830,7 +1830,7 @@ Models: if/deepseek-r1 ``` -### Qwen (4 FREE models via Device Code) +### Qwen (4 model GRATIS melalui Kode Perangkat) ```bash Dashboard → Connect Qwen @@ -1842,7 +1842,7 @@ Models: qw/qwen3-coder-flash ``` -### Kiro (Claude FREE) +### Kiro (Claude GRATIS) ```bash Dashboard → Connect Kiro @@ -1857,9 +1857,9 @@ Models:
-🎨 Create Combos +🎨 Membuat Combo -### Example 1: Maximize Subscription → Cheap Backup +### Contoh 1: Maksimalkan Langganan → Cadangan Murah ``` Dashboard → Combos → Create New @@ -1873,7 +1873,7 @@ Models: Use in CLI: premium-coding ``` -### Example 2: Free-Only (Zero Cost) +### Contoh 2: Gratis Saja (Tanpa Biaya) ``` Name: free-combo @@ -1888,7 +1888,7 @@ Cost: $0 forever!
-🔧 CLI Integration +🔧 Integrasi CLI ### Cursor IDE @@ -1901,7 +1901,7 @@ Settings → Models → Advanced: ### Claude Code -Use the **CLI Tools** page in the dashboard for one-click configuration, or edit `~/.claude/settings.json` manually. +Gunakan halaman **Alat CLI** di dasbor untuk konfigurasi sekali klik, atau edit `~/.claude/settings.json` secara manual. ### Codex CLI @@ -1914,7 +1914,7 @@ codex "your prompt" ### OpenClaw -**Option 1 — Dashboard (recommended):** +**Opsi 1 — Dasbor (disarankan):** ``` Dashboard → CLI Tools → OpenClaw → Select Model → Apply @@ -1936,7 +1936,7 @@ Dashboard → CLI Tools → OpenClaw → Select Model → Apply } ``` -> **Note:** OpenClaw only works with local OmniRoute. Use `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues. +> **Catatan:** OpenClaw hanya berfungsi dengan OmniRoute lokal. Gunakan `127.0.0.1` alih-alih `localhost` untuk menghindari masalah resolusi IPv6. ### Cline / Continue / RooCode @@ -1950,7 +1950,7 @@ Settings → API Configuration: ### OpenCode -**Step 1:** Add OmniRoute as a custom provider: +**Langkah 1:** Tambahkan OmniRoute sebagai penyedia khusus: ```bash opencode @@ -1958,7 +1958,7 @@ opencode # Select "Other" → Enter ID: "omniroute" → Enter your OmniRoute API key ``` -**Step 2:** Create/edit `opencode.json` in your project root: +**Langkah 2:** Buat/edit `opencode.json` di root proyek Anda: ```json { @@ -1980,14 +1980,14 @@ opencode } ``` -**Step 3:** Select the model in OpenCode: +**Langkah 3:** Pilih model di OpenCode: ```bash /models # Select any OmniRoute model from the list ``` -> **Tip:** Add any model available in your OmniRoute `/v1/models` endpoint to the `models` section. Use the format `provider/model-id` from your OmniRoute dashboard. +> **Tips:** Tambahkan model apa pun yang tersedia di titik akhir OmniRoute `/v1/models` Anda ke bagian `models`. Gunakan format `provider/model-id` dari dasbor OmniRoute Anda.
@@ -1996,112 +1996,112 @@ opencode ## Pemecahan Masalah
-Click to expand troubleshooting guide +Klik untuk memperluas panduan pemecahan masalah -**"Language model did not provide messages"** +**"Model bahasa tidak memberikan pesan"** -- Provider quota exhausted → Check dashboard quota tracker -- Solution: Use combo fallback or switch to cheaper tier +- Kuota penyedia habis → Periksa dashboard pelacak kuota +- Solusi: Gunakan combo fallback atau beralih ke tier yang lebih murah **Rate limiting** -- Subscription quota out → Fallback to GLM/MiniMax -- Add combo: `cc/claude-opus-4-7 → glm/glm-4.7 → if/kimi-k2-thinking` +- Kuota berlangganan habis → Penggantian ke GLM/MiniMax +- Tambahkan kombo: `cc/claude-opus-4-7 → glm/glm-4.7 → if/kimi-k2-thinking` **OAuth token expired** -- Auto-refreshed by OmniRoute -- If issues persist: Dashboard → Provider → Reconnect +- Disegarkan secara otomatis oleh OmniRoute +- Jika masalah terus berlanjut: Dasbor → Penyedia → Sambungkan kembali **High costs** -- Check usage stats in Dashboard → Costs -- Switch primary model to GLM/MiniMax -- Use free tier (Gemini CLI, Qoder) for non-critical tasks +- Periksa statistik penggunaan di Dashboard → Biaya +- Ganti model utama ke GLM/MiniMax +- Gunakan tingkat gratis (Gemini CLI, Qoder) untuk tugas-tugas yang tidak penting -**Dashboard/API ports are wrong** +**Port dasbor/API salah** -- `PORT` is the canonical base port (and API port by default) -- `API_PORT` overrides only OpenAI-compatible API listener -- `DASHBOARD_PORT` overrides only dashboard/Next.js listener -- Set `NEXT_PUBLIC_BASE_URL` to your dashboard/public URL (for OAuth callbacks) +- `PORT` adalah port dasar kanonik (dan port API secara default) +- `API_PORT` hanya menimpa pendengar API yang kompatibel dengan OpenAI +- `DASHBOARD_PORT` hanya menimpa dashboard/pendengar Next.js +- Setel `NEXT_PUBLIC_BASE_URL` ke dasbor/URL publik Anda (untuk panggilan balik OAuth) **Cloud sync errors** -- Verify `BASE_URL` points to your running instance -- Verify `CLOUD_URL` points to your expected cloud endpoint -- Keep `NEXT_PUBLIC_*` values aligned with server-side values +- Verifikasi `BASE_URL` poin ke instance Anda yang sedang berjalan +- Verifikasi `CLOUD_URL` poin ke titik akhir cloud yang Anda harapkan +- Jaga agar nilai `NEXT_PUBLIC_*` selaras dengan nilai sisi server **First login not working** -- Check `INITIAL_PASSWORD` in `.env` -- If unset, fallback password is `123456` +- Periksa `INITIAL_PASSWORD` di `.env` +- Jika tidak disetel, kata sandi cadangan adalah `123456` -**No request logs** +**Tidak ada log permintaan** -- `call_logs` in SQLite stores summary metadata for the Request Logs table and analytics views -- Detailed request/response payloads are written to `DATA_DIR/call_logs/` as one JSON artifact per request -- Enable pipeline capture from Dashboard → Logs → Request Logs if you need detailed per-stage payloads -- `Export Logs` reads the artifact files on demand, while `Export All` includes the `call_logs/` directory alongside `storage.sqlite` -- Set `APP_LOG_TO_FILE=true` if you also want application console logs in `logs/application/app.log` -- Adjust `APP_LOG_MAX_FILE_SIZE`, `APP_LOG_RETENTION_DAYS`, `APP_LOG_MAX_FILES`, and `CALL_LOG_MAX_ENTRIES` as needed +- `call_logs` di SQLite menyimpan metadata ringkasan untuk tabel Log Permintaan dan tampilan analitik +- Muatan permintaan/respons terperinci ditulis ke `DATA_DIR/call_logs/` sebagai satu artefak JSON per permintaan +- Aktifkan pengambilan saluran pipa dari Dasbor → Log → Log Permintaan jika Anda memerlukan muatan per tahap yang terperinci +- `Export Logs` membaca file artefak sesuai permintaan, sementara `Export All` menyertakan direktori `call_logs/` bersama `storage.sqlite` +- Setel `APP_LOG_TO_FILE=true` jika Anda juga ingin log konsol aplikasi di `logs/application/app.log` +- Sesuaikan `APP_LOG_MAX_FILE_SIZE`, `APP_LOG_RETENTION_DAYS`, `APP_LOG_MAX_FILES`, dan `CALL_LOG_MAX_ENTRIES` sesuai kebutuhan -**Connection test shows "Invalid" for OpenAI-compatible providers** +**Tes koneksi menunjukkan "Tidak Valid" untuk penyedia yang kompatibel dengan OpenAI** -- Many providers don't expose a `/models` endpoint -- OmniRoute v1.0.6+ includes fallback validation via chat completions -- Ensure base URL includes `/v1` suffix +- Banyak penyedia tidak mengekspos titik akhir `/models` +- OmniRoute v1.0.6+ menyertakan validasi fallback melalui penyelesaian obrolan +- Pastikan URL dasar menyertakan akhiran `/v1` -### 🔐 OAuth on a Remote Server +### 🔐 OAuth di Server Jarak Jauh -> **⚠️ Important for users running OmniRoute on a VPS, Docker, or any remote server** +> **⚠️ Penting bagi pengguna yang menjalankan OmniRoute di VPS, Docker, atau server jarak jauh mana pun** -#### Why does Antigravity / Gemini CLI OAuth fail on remote servers? +#### Mengapa OAuth Antigravity / Gemini CLI gagal di server jarak jauh? -The **Antigravity** and **Gemini CLI** providers use **Google OAuth 2.0**. Google requires the `redirect_uri` in the OAuth flow to exactly match one of the pre-registered URIs in the app's Google Cloud Console. +Penyedia **Antigravitasi** dan **Gemini CLI** menggunakan **Google OAuth 2.0**. Google mewajibkan `redirect_uri` dalam alur OAuth agar sama persis dengan salah satu URI yang telah didaftarkan sebelumnya di Google Cloud Console aplikasi. -The OAuth credentials bundled in OmniRoute are registered **for `localhost` only**. When you access OmniRoute on a remote server (e.g. `https://omniroute.myserver.com`), Google rejects the authentication with: +Kredensial OAuth yang disertakan dalam OmniRoute didaftarkan **hanya untuk `localhost`**. Saat Anda mengakses OmniRoute di server jarak jauh (misalnya `https://omniroute.myserver.com`), Google menolak autentikasi dengan: ``` Error 400: redirect_uri_mismatch ``` -#### Solution: Configure your own OAuth credentials +#### Solusi: Konfigurasikan kredensial OAuth Anda sendiri -You need to create an **OAuth 2.0 Client ID** in Google Cloud Console with your server's URI. +Anda perlu membuat **ID Klien OAuth 2.0** di Google Cloud Console dengan URI server Anda. -#### Step-by-step +#### Langkah demi langkah **1. Open Google Cloud Console** Go to: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) -**2. Create a new OAuth 2.0 Client ID** +**2. Buat ID Klien OAuth 2.0 baru** -- Click **"+ Create Credentials"** → **"OAuth client ID"** -- Application type: **"Web application"** -- Name: anything you like (e.g. `OmniRoute Remote`) +- Klik **"+ Buat Kredensial"** → **"ID klien OAuth"** +- Jenis aplikasi: **"Aplikasi web"** +- Nama: apa pun yang Anda suka (mis. `OmniRoute Remote`) **3. Add Authorized Redirect URIs** -In the **"Authorized redirect URIs"** field, add: +Di kolom **"URI pengalihan resmi"**, tambahkan: ``` https://your-server.com/callback ``` -> Replace `your-server.com` with your server's domain or IP (include the port if needed, e.g. `http://45.33.32.156:20128/callback`). +> Ganti `your-server.com` dengan domain atau IP server Anda (sertakan port jika diperlukan, misalnya `http://45.33.32.156:20128/callback`). -**4. Save and copy the credentials** +**4. Simpan dan salin kredensial** -After creating, Google will show the **Client ID** and **Client Secret**. +Setelah pembuatan, Google akan menampilkan **ID Klien** dan **Rahasia Klien**. -**5. Set environment variables** +**5. Tetapkan variabel lingkungan** -In your `.env` (or Docker environment variables): +Di `.env` Anda (atau variabel lingkungan Docker): ```bash # For Antigravity: @@ -2126,23 +2126,23 @@ docker restart omniroute **7. Try connecting again** -Dashboard → Providers → Antigravity (or Gemini CLI) → OAuth +Dasbor → Penyedia → Antigravitasi (atau Gemini CLI) → OAuth Google will now redirect correctly to `https://your-server.com/callback`. --- -#### Temporary workaround (without custom credentials) +#### Solusi sementara (tanpa kredensial kustom) -If you don't want to set up your own credentials right now, you can still use the **manual URL flow**: +Jika Anda tidak ingin menyiapkan kredensial Anda sendiri saat ini, Anda masih dapat menggunakan **alur URL manual**: -1. OmniRoute opens the Google authorization URL -2. After authorizing, Google tries to redirect to `localhost` (which fails on the remote server) -3. **Copy the full URL** from your browser's address bar (even if the page doesn't load) -4. Paste that URL into the field shown in the OmniRoute connection modal -5. Click **"Connect"** +1. OmniRoute membuka URL otorisasi Google +2. Setelah otorisasi, Google mencoba mengalihkan ke `localhost` (yang gagal di server jauh) +3. **Salin URL lengkap** dari bilah alamat browser Anda (meskipun halaman tidak dimuat) +4. Tempelkan URL tersebut ke bidang yang ditampilkan di modal koneksi OmniRoute +5. Klik **"Hubungkan"** -> This works because the authorization code in the URL is valid regardless of whether the redirect page loaded. +> Ini berfungsi karena kode otorisasi di URL valid terlepas dari apakah halaman pengalihan dimuat. --- @@ -2169,11 +2169,11 @@ Você precisa criar um **OAuth 2.0 Client ID** no Google Cloud Console com a URI Abra: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) -**2. Crie um novo OAuth 2.0 Client ID** +**2. Inilah ID Klien OAuth 2.0 yang baru** -- Clique em **"+ Create Credentials"** → **"OAuth client ID"** -- Tipo de aplicativo: **"Web application"** -- Nome: escolha qualquer nome (ex: `OmniRoute Remote`) +- Klik pada **"+ Buat Kredensial"** → **"ID klien OAuth"** +- Tip aplikasi: **"Aplikasi web"** +- Nama: nama escolha qualquer (misal: `OmniRoute Remote`) **3. Adicione as Authorized Redirect URIs** @@ -2187,7 +2187,7 @@ https://seu-servidor.com/callback **4. Salve e copie as credenciais** -Após criar, o Google mostrará o **Client ID** e o **Client Secret**. +Kemudian, Google menampilkan **ID Klien** dan **Rahasia Klien**. **5. Configure as variáveis de ambiente** @@ -2216,7 +2216,7 @@ docker restart omniroute **7. Tente conectar novamente** -Dashboard → Providers → Antigravity (ou Gemini CLI) → OAuth +Dasbor → Penyedia → Antigravitasi (atau Gemini CLI) → OAuth Agora o Google redirecionará corretamente para `https://seu-servidor.com/callback` e a autenticação funcionará. @@ -2240,25 +2240,25 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
-## 🛠️ Tech Stack +## 🛠️ Stack Teknologi
-Click to expand tech stack details +Klik untuk membuka detail stack teknologi -- **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible) -- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0) -- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 -- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills -- **Schemas**: Zod (MCP tool I/O validation, API contracts) -- **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) -- **Streaming**: Server-Sent Events (SSE) -- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + MCP Scoped Authorization -- **Testing**: Node.js test runner + Vitest (900+ tests including unit, integration, E2E) -- **CI/CD**: GitHub Actions (auto npm publish + Docker Hub on release) -- **Website**: [omniroute.online](https://omniroute.online) -- **Package**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute) -- **Docker**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute) -- **Resilience**: Circuit breaker, exponential backoff, anti-thundering herd, TLS spoofing, auto-combo self-healing +- **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ **tidak didukung** — `better-sqlite3` biner asli tidak kompatibel) +- **Bahasa**: TypeScript 5.9 — **100% TypeScript** di `src/` dan `open-sse/` (nol `any` dalam modul inti sejak v2.0) +- **Kerangka Kerja**: Next.js 16 + React 19 + Tailwind CSS 4 +- **Database**: lebih baik-sqlite3 (SQLite) + LowDB (JSON legacy) — status domain, log proksi, audit MCP, keputusan perutean, memori, keterampilan +- **Skema**: Zod (validasi I/O alat MCP, kontrak API) +- **Protokol**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE) +- **Streaming**: Peristiwa Terkirim Server (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + Kunci API + Otorisasi Cakupan MCP +- **Pengujian**: Pelari pengujian Node.js + Vitest (900+ pengujian termasuk unit, integrasi, E2E) +- **CI/CD**: Tindakan GitHub (publikasi npm otomatis + Docker Hub saat dirilis) +- **Situs Web**: [omniroute.online](https://omniroute.online) +- **Paket**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute) +- **Pekerja Pelabuhan**: [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute) +- **Ketahanan**: Pemutus arus, backoff eksponensial, kawanan anti-thundering, spoofing TLS, penyembuhan diri kombo otomatis
@@ -2268,66 +2268,66 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux | Document | Description | | --------------------------------------------------------------------- | --------------------------------------------------- | -| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | -| [MCP Server](open-sse/mcp-server/README.md) | 25 MCP tools, IDE configs, Python/TS/Go clients | -| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | +| [User Guide](docs/guides/USER_GUIDE.md) | Penyedia, kombo, integrasi CLI, penerapan | +| [API Reference](docs/reference/API_REFERENCE.md) | Semua titik akhir dengan contoh | +| [MCP Server](open-sse/mcp-server/README.md) | 25 alat MCP, konfigurasi IDE, klien Python/TS/Go | +| [A2A Server](src/lib/a2a/README.md) | Protokol JSON-RPC 2.0, keterampilan, streaming, manajemen tugas | | [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | -| [Context Relay](docs/features/context-relay.md) | Session handoff strategy for account rotation | -| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Common problems and solutions | -| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture and internals | +| [Context Relay](docs/features/context-relay.md) | Strategi penyerahan sesi untuk rotasi akun | +| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Masalah umum dan solusinya | +| [Architecture](docs/architecture/ARCHITECTURE.md) | Arsitektur sistem dan internal| | [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | -| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods | -| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references | -| [Contributing](CONTRIBUTING.md) | Development setup and guidelines | +| [Uninstall Guide](docs/guides/UNINSTALL.md) |Penghapusan bersih untuk semua metode instalasi | +| [Environment Config](docs/reference/ENVIRONMENT.md) | Lengkapi `.env` variabel dan referensi | +| [Contributing](CONTRIBUTING.md) | Pengaturan dan pedoman pengembangan | | [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification | -| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices | -| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | -| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots | +| [Security Policy](SECURITY.md) | Pelaporan kerentanan dan praktik keamanan | +| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Panduan lengkap: pengaturan VM + nginx + Cloudflare | +| [Features Gallery](docs/guides/FEATURES.md) | Tur dasbor visual dengan tangkapan layar | | [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps | --- ## 🗺️ Roadmap -OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas: +OmniRoute memiliki **218+ fitur yang direncanakan** di berbagai fase pengembangan. Berikut adalah bidang-bidang utamanya: | Category | Planned Features | Highlights | | ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | -| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing | -| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping | -| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model | -| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, connection cooldowns, multi-account Codex, Copilot quota parsing | -| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API | +| 🧠 **Routing & Intelligence** | 25+ | Perutean latensi terendah, perutean berbasis tag, preflight kuota, P2C sadar kuota, perutean kombo berbasis langkah | +| 🔒 **Security & Compliance** | 20+ | Pengerasan SSRF, penyelubungan kredensial, batas tarif per titik akhir, pelingkupan kunci manajemen | +| 📊 **Observability** | 15+ | Integrasi OpenTelemetry, pemantauan kuota waktu nyata, kesehatan target kombo, pelacakan biaya per model | +| 🔄 **Provider Integrations** | 20+ | Registri model dinamis, cooldown koneksi, Codex multi-akun, penguraian kuota Salinan | +| ⚡ **Performance** | 15+ | Lapisan cache ganda, cache cepat, cache respons, streaming keepalive, API batch| | 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode | -### 🔜 Coming Soon +### 🔜 Segera Hadir -- 🔗 **OpenCode Integration** — Native provider support for the OpenCode AI coding IDE -- 🔗 **TRAE Integration** — Full support for the TRAE AI development framework -- 📦 **Batch API** — Asynchronous batch processing for bulk requests -- 🎯 **Tag-Based Routing** — Route requests based on custom tags and metadata -- 💰 **Lowest-Cost Strategy** — Automatically select the cheapest available provider +- 🔗 **Integrasi OpenCode** — Dukungan penyedia asli untuk IDE pengkodean AI OpenCode +- 🔗 **Integrasi TRAE** — Dukungan penuh untuk kerangka pengembangan AI TRAE +- 📦 **Batch API** — Pemrosesan batch asinkron untuk permintaan massal +- 🎯 **Perutean Berbasis Tag** — Merutekan permintaan berdasarkan tag dan metadata khusus +- 💰 **Strategi Biaya Terendah** — Secara otomatis memilih penyedia termurah yang tersedia -> 📝 Full feature specifications available in [`docs/new-features/`](docs/new-features/) (217 detailed specs) +> 📝 Spesifikasi fitur lengkap tersedia di [docs/new-features/](docs/new-features/) (217 spesifikasi detail) --- -## 👥 Contributors +## 👥 Kontributor [![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors) -### How to Contribute +### Cara Berkontribusi -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +1. Cabangkan repositori +2. Buat cabang fitur Anda (`git checkout -b feature/amazing-feature`) +3. Komit perubahan Anda (`git commit -m 'Add amazing feature'`) +4. Dorong ke cabang (`git push origin feature/amazing-feature`) +5. Buka Permintaan Tarik -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. +Lihat [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan detailnya. -### Releasing a New Version +### Merilis Versi Baru ```bash # Create a release — npm publish happens automatically @@ -2336,7 +2336,7 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes --- -## 📊 Star History +## 📊 Riwayat Star @@ -2356,15 +2356,15 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes -## 🙏 Acknowledgments +## 🙏 Ucapan Terima Kasih -Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — the original Go implementation that inspired this JavaScript port. +Terima kasih khusus kepada **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implementasi Go asli yang menginspirasi port JavaScript ini. --- ## Lisensi -MIT License - see [LICENSE](LICENSE) for details. +Lisensi MIT - lihat [LICENSE](LICENSE) untuk detailnya. --- diff --git a/docs/i18n/id/SECURITY.md b/docs/i18n/id/SECURITY.md index 25a0cd526a..5bbfd79e9b 100644 --- a/docs/i18n/id/SECURITY.md +++ b/docs/i18n/id/SECURITY.md @@ -1,138 +1,138 @@ -# Security Policy (Bahasa Indonesia) +# Kebijakan Keamanan (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../SECURITY.md) · 🇸🇦 [ar](../ar/SECURITY.md) · 🇧🇬 [bg](../bg/SECURITY.md) · 🇧🇩 [bn](../bn/SECURITY.md) · 🇨🇿 [cs](../cs/SECURITY.md) · 🇩🇰 [da](../da/SECURITY.md) · 🇩🇪 [de](../de/SECURITY.md) · 🇪🇸 [es](../es/SECURITY.md) · 🇮🇷 [fa](../fa/SECURITY.md) · 🇫🇮 [fi](../fi/SECURITY.md) · 🇫🇷 [fr](../fr/SECURITY.md) · 🇮🇳 [gu](../gu/SECURITY.md) · 🇮🇱 [he](../he/SECURITY.md) · 🇮🇳 [hi](../hi/SECURITY.md) · 🇭🇺 [hu](../hu/SECURITY.md) · 🇮🇩 [id](../id/SECURITY.md) · 🇮🇹 [it](../it/SECURITY.md) · 🇯🇵 [ja](../ja/SECURITY.md) · 🇰🇷 [ko](../ko/SECURITY.md) · 🇮🇳 [mr](../mr/SECURITY.md) · 🇲🇾 [ms](../ms/SECURITY.md) · 🇳🇱 [nl](../nl/SECURITY.md) · 🇳🇴 [no](../no/SECURITY.md) · 🇵🇭 [phi](../phi/SECURITY.md) · 🇵🇱 [pl](../pl/SECURITY.md) · 🇵🇹 [pt](../pt/SECURITY.md) · 🇧🇷 [pt-BR](../pt-BR/SECURITY.md) · 🇷🇴 [ro](../ro/SECURITY.md) · 🇷🇺 [ru](../ru/SECURITY.md) · 🇸🇰 [sk](../sk/SECURITY.md) · 🇸🇪 [sv](../sv/SECURITY.md) · 🇰🇪 [sw](../sw/SECURITY.md) · 🇮🇳 [ta](../ta/SECURITY.md) · 🇮🇳 [te](../te/SECURITY.md) · 🇹🇭 [th](../th/SECURITY.md) · 🇹🇷 [tr](../tr/SECURITY.md) · 🇺🇦 [uk-UA](../uk-UA/SECURITY.md) · 🇵🇰 [ur](../ur/SECURITY.md) · 🇻🇳 [vi](../vi/SECURITY.md) · 🇨🇳 [zh-CN](../zh-CN/SECURITY.md) --- -## Reporting Vulnerabilities +## Melaporkan Kerentanan -If you discover a security vulnerability in OmniRoute, please report it responsibly: +Jika Anda menemukan kerentanan keamanan di OmniRoute, harap laporkan secara bertanggung jawab: -1. **DO NOT** open a public GitHub issue -2. Use [GitHub Security Advisories](https://github.com/diegosouzapw/OmniRoute/security/advisories/new) -3. Include: description, reproduction steps, and potential impact +1. **JANGAN** membuka isu GitHub yang bersifat publik +2. Gunakan [GitHub Security Advisories](https://github.com/diegosouzapw/OmniRoute/security/advisories/new) +3. Sertakan: deskripsi, langkah-langkah reproduksi, dan potensi dampak -## Response Timeline +## Linimasa Respons -| Stage | Target | -| ------------------- | --------------------------- | -| Acknowledgment | 48 hours | -| Triage & Assessment | 5 business days | -| Patch Release | 14 business days (critical) | +| Tahap | Target | +| ----------------------- | ------------------------------- | +| Konfirmasi Penerimaan | 48 jam | +| Triase & Penilaian | 5 hari kerja | +| Rilis Patch | 14 hari kerja (kritis) | -## Supported Versions +## Versi yang Didukung -| Version | Support Status | -| ------- | -------------- | -| 3.6.x | ✅ Active | -| 3.5.x | ✅ Security | -| < 3.5.0 | ❌ Unsupported | +| Versi | Status Dukungan | +| ------- | ------------------ | +| 3.6.x | ✅ Aktif | +| 3.5.x | ✅ Keamanan | +| < 3.5.0 | ❌ Tidak Didukung | --- -## Security Architecture +## Arsitektur Keamanan -OmniRoute implements a multi-layered security model: +OmniRoute menerapkan model keamanan berlapis: ``` Request → CORS → API Key Auth → Prompt Injection Guard → Input Sanitizer → Rate Limiter → Circuit Breaker → Provider ``` -### 🔐 Authentication & Authorization +### 🔐 Autentikasi & Otorisasi -| Feature | Implementation | -| -------------------- | ---------------------------------------------------------- | -| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) | -| **API Key Auth** | HMAC-signed keys with CRC validation | -| **OAuth 2.0 + PKCE** | Secure provider auth (Claude, Codex, Gemini, Cursor, etc.) | -| **Token Refresh** | Automatic OAuth token refresh before expiry | -| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments | -| **MCP Scopes** | 10 granular scopes for MCP tool access control | +| Fitur | Implementasi | +| -------------------- | ------------------------------------------------------------------- | +| **Login Dashboard** | Autentikasi berbasis kata sandi dengan token JWT (cookie HttpOnly) | +| **Autentikasi API Key** | Kunci bertanda tangan HMAC dengan validasi CRC | +| **OAuth 2.0 + PKCE** | Autentikasi penyedia yang aman (Claude, Codex, Gemini, Cursor, dll.) | +| **Pembaruan Token** | Pembaruan token OAuth otomatis sebelum kedaluwarsa | +| **Cookie Aman** | `AUTH_COOKIE_SECURE=true` untuk lingkungan HTTPS | +| **Ruang Lingkup MCP** | 10 ruang lingkup terperinci untuk kontrol akses alat MCP | -### 🛡️ Encryption at Rest +### 🛡️ Enkripsi Data Tersimpan -All sensitive data stored in SQLite is encrypted using **AES-256-GCM** with scrypt key derivation: +Semua data sensitif yang disimpan di SQLite dienkripsi menggunakan **AES-256-GCM** dengan derivasi kunci scrypt: -- API keys, access tokens, refresh tokens, and ID tokens -- Versioned format: `enc:v1:::` -- Passthrough mode (plaintext) when `STORAGE_ENCRYPTION_KEY` is not set +- Kunci API, token akses, token penyegaran, dan token ID +- Format berversi: `enc:v1:::` +- Mode passthrough (teks biasa) ketika `STORAGE_ENCRYPTION_KEY` tidak disetel ```bash # Generate encryption key: STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32) ``` -### 🧠 Prompt Injection Guard +### 🧠 Penjaga Injeksi Prompt -Middleware that detects and blocks prompt injection attacks in LLM requests: +Middleware yang mendeteksi dan memblokir serangan injeksi prompt dalam permintaan LLM: -| Pattern Type | Severity | Example | -| ------------------- | -------- | ---------------------------------------------- | -| System Override | High | "ignore all previous instructions" | -| Role Hijack | High | "you are now DAN, you can do anything" | -| Delimiter Injection | Medium | Encoded separators to break context boundaries | -| DAN/Jailbreak | High | Known jailbreak prompt patterns | -| Instruction Leak | Medium | "show me your system prompt" | +| Jenis Pola | Tingkat Keparahan | Contoh | +| ------------------- | ----------------- | ----------------------------------------------------------- | +| Penimpaan Sistem | Tinggi | "ignore all previous instructions" | +| Pembajakan Peran | Tinggi | "you are now DAN, you can do anything" | +| Injeksi Pembatas | Sedang | Pemisah yang dikodekan untuk merusak batas konteks | +| DAN/Jailbreak | Tinggi | Pola prompt jailbreak yang telah diketahui | +| Kebocoran Instruksi | Sedang | "show me your system prompt" | -Configure via dashboard (Settings → Security) or `.env`: +Konfigurasikan melalui dashboard (Settings → Security) atau `.env`: ```env INPUT_SANITIZER_ENABLED=true INPUT_SANITIZER_MODE=block # warn | block | redact ``` -### 🔒 PII Redaction +### 🔒 Redaksi PII -Automatic detection and optional redaction of personally identifiable information: +Deteksi otomatis dan redaksi opsional informasi yang dapat mengidentifikasi pribadi: -| PII Type | Pattern | Replacement | -| ------------- | --------------------- | ------------------ | -| Email | `user@domain.com` | `[EMAIL_REDACTED]` | -| CPF (Brazil) | `123.456.789-00` | `[CPF_REDACTED]` | -| CNPJ (Brazil) | `12.345.678/0001-00` | `[CNPJ_REDACTED]` | -| Credit Card | `4111-1111-1111-1111` | `[CC_REDACTED]` | -| Phone | `+55 11 99999-9999` | `[PHONE_REDACTED]` | -| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` | +| Jenis PII | Pola | Pengganti | +| ---------------- | --------------------- | ------------------ | +| Email | `user@domain.com` | `[EMAIL_REDACTED]` | +| CPF (Brasil) | `123.456.789-00` | `[CPF_REDACTED]` | +| CNPJ (Brasil) | `12.345.678/0001-00` | `[CNPJ_REDACTED]` | +| Kartu Kredit | `4111-1111-1111-1111` | `[CC_REDACTED]` | +| Telepon | `+55 11 99999-9999` | `[PHONE_REDACTED]` | +| SSN (AS) | `123-45-6789` | `[SSN_REDACTED]` | ```env PII_REDACTION_ENABLED=true ``` -### 🌐 Network Security +### 🌐 Keamanan Jaringan -| Feature | Description | -| ------------------------ | ---------------------------------------------------------------- | -| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) | -| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard | -| **Rate Limiting** | Per-provider rate limits with automatic backoff | -| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s | -| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection | -| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures | +| Fitur | Deskripsi | +| ------------------------ | ------------------------------------------------------------------------------- | +| **CORS** | Kontrol origin yang dapat dikonfigurasi (variabel env `CORS_ORIGIN`, default `*`) | +| **Pemfilteran IP** | Daftar izin/blokir rentang IP di dashboard | +| **Pembatasan Laju** | Batas laju per-penyedia dengan backoff otomatis | +| **Anti-Thundering Herd** | Mutex + penguncian per-koneksi mencegah kegagalan 502 beruntun | +| **Sidik Jari TLS** | Spoofing sidik jari TLS menyerupai browser untuk mengurangi deteksi bot | +| **Sidik Jari CLI** | Pengurutan header/body per-penyedia agar sesuai tanda tangan CLI native | -### 🔌 Resilience & Availability +### 🔌 Ketahanan & Ketersediaan -| Feature | Description | -| ----------------------- | ------------------------------------------------------------------ | -| **Circuit Breaker** | 3-state (Closed → Open → Half-Open) per provider, SQLite-persisted | -| **Request Idempotency** | 5-second dedup window for duplicate requests | -| **Exponential Backoff** | Automatic retry with increasing delays | -| **Health Dashboard** | Real-time provider health monitoring | +| Fitur | Deskripsi | +| -------------------------- | ---------------------------------------------------------------------------- | +| **Pemutus Sirkuit** | 3 status (Closed → Open → Half-Open) per penyedia, dipersistenkan di SQLite | +| **Idempotansi Permintaan** | Jendela deduplikasi 5 detik untuk permintaan duplikat | +| **Backoff Eksponensial** | Percobaan ulang otomatis dengan penundaan yang semakin meningkat | +| **Dashboard Kesehatan** | Pemantauan kesehatan penyedia secara real-time | -### 📋 Compliance +### 📋 Kepatuhan -| Feature | Description | -| ------------------ | ----------------------------------------------------------- | -| **Log Retention** | Automatic cleanup after `CALL_LOG_RETENTION_DAYS` | -| **No-Log Opt-out** | Per API key `noLog` flag disables request logging | -| **Audit Log** | Administrative actions tracked in `audit_log` table | -| **MCP Audit** | SQLite-backed audit logging for all MCP tool calls | -| **Zod Validation** | All API inputs validated with Zod v4 schemas at module load | +| Fitur | Deskripsi | +| ---------------------- | ---------------------------------------------------------------------- | +| **Retensi Log** | Pembersihan otomatis setelah `CALL_LOG_RETENTION_DAYS` | +| **Opt-out Tanpa Log** | Tanda `noLog` per kunci API menonaktifkan pencatatan permintaan | +| **Log Audit** | Tindakan administratif dilacak di tabel `audit_log` | +| **Audit MCP** | Pencatatan audit berbasis SQLite untuk semua pemanggilan alat MCP | +| **Validasi Zod** | Semua input API divalidasi dengan skema Zod v4 saat pemuatan modul | --- -## Required Environment Variables +## Variabel Lingkungan yang Wajib Disetel -All secrets must be set before starting the server. The server will **fail fast** if they are missing or weak. +Semua rahasia harus disetel sebelum menjalankan server. Server akan **gagal cepat** jika nilainya tidak ada atau terlalu lemah. ```bash # REQUIRED — server will not start without these: @@ -143,17 +143,17 @@ API_KEY_SECRET=$(openssl rand -hex 32) # min 16 chars STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32) ``` -The server actively rejects known-weak values like `changeme`, `secret`, or `password`. +Server secara aktif menolak nilai yang diketahui lemah seperti `changeme`, `secret`, atau `password`. --- -## Docker Security +## Keamanan Docker -- Use non-root user in production -- Mount secrets as read-only volumes -- Never copy `.env` files into Docker images -- Use `.dockerignore` to exclude sensitive files -- Set `AUTH_COOKIE_SECURE=true` when behind HTTPS +- Gunakan pengguna non-root di lingkungan produksi +- Pasang rahasia sebagai volume hanya-baca +- Jangan pernah menyalin file `.env` ke dalam image Docker +- Gunakan `.dockerignore` untuk mengecualikan file sensitif +- Setel `AUTH_COOKIE_SECURE=true` saat berada di belakang HTTPS ```bash docker run -d \ @@ -170,10 +170,10 @@ docker run -d \ --- -## Dependencies +## Dependensi -- Run `npm audit` regularly -- Keep dependencies updated -- The project uses `husky` + `lint-staged` for pre-commit checks -- CI pipeline runs ESLint security rules on every push -- Provider constants validated at module load via Zod (`src/shared/validation/providerSchema.ts`) +- Jalankan `npm audit` secara berkala +- Jaga agar dependensi tetap diperbarui +- Proyek menggunakan `husky` + `lint-staged` untuk pemeriksaan pra-commit +- Pipeline CI menjalankan aturan keamanan ESLint pada setiap push +- Konstanta penyedia divalidasi saat pemuatan modul melalui Zod (`src/shared/validation/providerSchema.ts`) diff --git a/docs/i18n/id/docs/architecture/ARCHITECTURE.md b/docs/i18n/id/docs/architecture/ARCHITECTURE.md index 049bf956c5..3445d8f776 100644 --- a/docs/i18n/id/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/id/docs/architecture/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# OmniRoute Architecture (Bahasa Indonesia) +# Arsitektur OmniRoute (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/ARCHITECTURE.md) · 🇸🇦 [ar](../../ar/docs/ARCHITECTURE.md) · 🇧🇬 [bg](../../bg/docs/ARCHITECTURE.md) · 🇧🇩 [bn](../../bn/docs/ARCHITECTURE.md) · 🇨🇿 [cs](../../cs/docs/ARCHITECTURE.md) · 🇩🇰 [da](../../da/docs/ARCHITECTURE.md) · 🇩🇪 [de](../../de/docs/ARCHITECTURE.md) · 🇪🇸 [es](../../es/docs/ARCHITECTURE.md) · 🇮🇷 [fa](../../fa/docs/ARCHITECTURE.md) · 🇫🇮 [fi](../../fi/docs/ARCHITECTURE.md) · 🇫🇷 [fr](../../fr/docs/ARCHITECTURE.md) · 🇮🇳 [gu](../../gu/docs/ARCHITECTURE.md) · 🇮🇱 [he](../../he/docs/ARCHITECTURE.md) · 🇮🇳 [hi](../../hi/docs/ARCHITECTURE.md) · 🇭🇺 [hu](../../hu/docs/ARCHITECTURE.md) · 🇮🇩 [id](../../id/docs/ARCHITECTURE.md) · 🇮🇹 [it](../../it/docs/ARCHITECTURE.md) · 🇯🇵 [ja](../../ja/docs/ARCHITECTURE.md) · 🇰🇷 [ko](../../ko/docs/ARCHITECTURE.md) · 🇮🇳 [mr](../../mr/docs/ARCHITECTURE.md) · 🇲🇾 [ms](../../ms/docs/ARCHITECTURE.md) · 🇳🇱 [nl](../../nl/docs/ARCHITECTURE.md) · 🇳🇴 [no](../../no/docs/ARCHITECTURE.md) · 🇵🇭 [phi](../../phi/docs/ARCHITECTURE.md) · 🇵🇱 [pl](../../pl/docs/ARCHITECTURE.md) · 🇵🇹 [pt](../../pt/docs/ARCHITECTURE.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/ARCHITECTURE.md) · 🇷🇴 [ro](../../ro/docs/ARCHITECTURE.md) · 🇷🇺 [ru](../../ru/docs/ARCHITECTURE.md) · 🇸🇰 [sk](../../sk/docs/ARCHITECTURE.md) · 🇸🇪 [sv](../../sv/docs/ARCHITECTURE.md) · 🇰🇪 [sw](../../sw/docs/ARCHITECTURE.md) · 🇮🇳 [ta](../../ta/docs/ARCHITECTURE.md) · 🇮🇳 [te](../../te/docs/ARCHITECTURE.md) · 🇹🇭 [th](../../th/docs/ARCHITECTURE.md) · 🇹🇷 [tr](../../tr/docs/ARCHITECTURE.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/ARCHITECTURE.md) · 🇵🇰 [ur](../../ur/docs/ARCHITECTURE.md) · 🇻🇳 [vi](../../vi/docs/ARCHITECTURE.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/ARCHITECTURE.md) @@ -6,117 +6,117 @@ _Last updated: 2026-04-15_ -## Executive Summary +## Ringkasan Eksekutif -OmniRoute is a local AI routing gateway and dashboard built on Next.js. -It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic across multiple upstream providers with translation, fallback, token refresh, and usage tracking. +OmniRoute adalah gateway routing AI lokal dan dasbor yang dibangun di atas Next.js. +Sistem ini menyediakan satu endpoint yang kompatibel dengan OpenAI (`/v1/*`) dan merutekan lalu lintas ke berbagai penyedia upstream dengan kemampuan translasi, fallback, pembaruan token, dan pelacakan penggunaan. -Core capabilities: +Kemampuan inti: -- OpenAI-compatible API surface for CLI/tools (100+ providers, 16 executors) -- Request/response translation across provider formats -- Model combo fallback (multi-model sequence) -- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` -- Account-level fallback (multi-account per provider) -- Quota preflight and quota-aware P2C account selection in the main chat path -- OAuth + API-key provider connection management (13 OAuth modules) -- Embedding generation via `/v1/embeddings` (6 providers, 9 models) -- Image generation via `/v1/images/generations` (10+ providers, 20+ models) -- Audio transcription via `/v1/audio/transcriptions` (7 providers) -- Text-to-speech via `/v1/audio/speech` (10 providers) -- Video generation via `/v1/videos/generations` (ComfyUI + SD WebUI) -- Music generation via `/v1/music/generations` (ComfyUI) -- Web search via `/v1/search` (5 providers) -- Moderations via `/v1/moderations` -- Reranking via `/v1/rerank` -- Think tag parsing (`...`) for reasoning models -- Response sanitization for strict OpenAI SDK compatibility -- Role normalization (developer→system, system→user) for cross-provider compatibility -- Structured output conversion (json_schema → Gemini responseSchema) -- Local persistence for providers, keys, aliases, combos, settings, pricing (26 DB modules) -- Usage/cost tracking and request logging -- Optional cloud sync for multi-device/state sync -- IP allowlist/blocklist for API access control -- Thinking budget management (passthrough/auto/custom/adaptive) -- Global system prompt injection -- Session tracking and fingerprinting -- Per-account enhanced rate limiting with provider-specific profiles -- Circuit breaker pattern for provider resilience -- Anti-thundering herd protection with mutex locking -- Signature-based request deduplication cache -- Domain layer: cost rules, fallback policy, lockout policy -- Context Relay: session handoff summaries for account rotation continuity -- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers) -- Policy engine for centralized request evaluation (lockout → budget → fallback) -- Request telemetry with p50/p95/p99 latency aggregation -- Combo target telemetry and historical combo target health via `combo_execution_key` / `combo_step_id` -- Correlation ID (X-Request-Id) for end-to-end tracing -- Compliance audit logging with opt-out per API key -- Eval framework for LLM quality assurance -- Health dashboard with real-time provider circuit breaker status -- MCP Server (25 tools) with 3 transports (stdio/SSE/Streamable HTTP) -- A2A Server (JSON-RPC 2.0 + SSE) with skills and task lifecycle -- Memory system (extraction, injection, retrieval, summarization) -- Skills system (registry, executor, sandbox, built-in skills) -- MITM proxy with certificate management and DNS handling -- Prompt injection guard middleware -- ACP (Agent Communication Protocol) registry -- Modular OAuth providers (13 individual modules under `src/lib/oauth/providers/`) -- Uninstall/full-uninstall scripts -- OAuth environment repair action -- WebSocket bridge for OpenAI-compatible WS clients (`/v1/ws`) -- Sync token management (issue/revoke, ETag-versioned config bundle download) -- GLM Thinking (`glmt`) first-class provider preset -- Hybrid token counting (provider-side `/messages/count_tokens` with estimation fallback) -- Model alias auto-seeding (30+ cross-proxy dialect normalizations at startup) -- Safe outbound fetch with SSRF guard, private URL blocking, and configurable retry -- Cooldown-aware chat retries with configurable `requestRetry` and `maxRetryIntervalSec` -- Runtime environment validation with Zod at startup -- Compliance audit v2 with pagination, provider CRUD events, and SSRF-blocked validation logging +- Antarmuka API yang kompatibel dengan OpenAI untuk CLI/tools (100+ penyedia, 16 executor) +- Translasi permintaan/respons antar format penyedia +- Fallback combo model (urutan multi-model) +- Langkah combo terstruktur (`provider + model + connection`) dengan pengurutan runtime melalui `compositeTiers` +- Fallback di tingkat akun (multi-akun per penyedia) +- Preflight kuota dan pemilihan akun P2C yang sadar kuota pada jalur chat utama +- Manajemen koneksi penyedia OAuth + API-key (13 modul OAuth) +- Pembuatan embedding melalui `/v1/embeddings` (6 penyedia, 9 model) +- Pembuatan gambar melalui `/v1/images/generations` (10+ penyedia, 20+ model) +- Transkripsi audio melalui `/v1/audio/transcriptions` (7 penyedia) +- Text-to-speech melalui `/v1/audio/speech` (10 penyedia) +- Pembuatan video melalui `/v1/videos/generations` (ComfyUI + SD WebUI) +- Pembuatan musik melalui `/v1/music/generations` (ComfyUI) +- Pencarian web melalui `/v1/search` (5 penyedia) +- Moderasi melalui `/v1/moderations` +- Reranking melalui `/v1/rerank` +- Penguraian think tag (`...`) untuk model penalaran +- Sanitasi respons untuk kompatibilitas ketat OpenAI SDK +- Normalisasi peran (developer→system, system→user) untuk kompatibilitas lintas penyedia +- Konversi output terstruktur (json_schema → Gemini responseSchema) +- Persistensi lokal untuk penyedia, kunci, alias, combo, pengaturan, harga (26 modul DB) +- Pelacakan penggunaan/biaya dan pencatatan permintaan +- Sinkronisasi cloud opsional untuk sinkronisasi multi-perangkat/status +- Daftar izin/blokir IP untuk kontrol akses API +- Manajemen anggaran berpikir (passthrough/auto/custom/adaptive) +- Injeksi system prompt global +- Pelacakan sesi dan fingerprinting +- Pembatasan laju yang ditingkatkan per akun dengan profil khusus penyedia +- Pola circuit breaker untuk ketahanan penyedia +- Perlindungan anti-thundering herd dengan penguncian mutex +- Cache deduplikasi permintaan berbasis tanda tangan +- Lapisan domain: aturan biaya, kebijakan fallback, kebijakan lockout +- Context Relay: ringkasan handoff sesi untuk kesinambungan rotasi akun +- Persistensi status domain (cache write-through SQLite untuk fallback, anggaran, lockout, circuit breaker) +- Mesin kebijakan untuk evaluasi permintaan terpusat (lockout → budget → fallback) +- Telemetri permintaan dengan agregasi latensi p50/p95/p99 +- Telemetri target combo dan kesehatan target combo historis melalui `combo_execution_key` / `combo_step_id` +- Correlation ID (X-Request-Id) untuk pelacakan end-to-end +- Pencatatan audit kepatuhan dengan opsi keluar per API key +- Kerangka eval untuk penjaminan kualitas LLM +- Dasbor kesehatan dengan status circuit breaker penyedia secara real-time +- MCP Server (25 tools) dengan 3 transport (stdio/SSE/Streamable HTTP) +- A2A Server (JSON-RPC 2.0 + SSE) dengan skill dan siklus hidup tugas +- Sistem memori (ekstraksi, injeksi, pengambilan, perangkuman) +- Sistem skill (registry, executor, sandbox, skill bawaan) +- Proxy MITM dengan manajemen sertifikat dan penanganan DNS +- Middleware penjaga injeksi prompt +- Registry ACP (Agent Communication Protocol) +- Penyedia OAuth modular (13 modul individual di bawah `src/lib/oauth/providers/`) +- Skrip uninstall/full-uninstall +- Aksi perbaikan lingkungan OAuth +- Jembatan WebSocket untuk klien WS yang kompatibel dengan OpenAI (`/v1/ws`) +- Manajemen token sinkronisasi (penerbitan/pencabutan, unduhan bundel konfigurasi berversi ETag) +- Preset penyedia utama GLM Thinking (`glmt`) +- Penghitungan token hybrid (sisi penyedia `/messages/count_tokens` dengan fallback estimasi) +- Penyemaian alias model otomatis (30+ normalisasi dialek lintas proxy saat startup) +- Pengambilan outbound aman dengan penjaga SSRF, pemblokiran URL privat, dan percobaan ulang yang dapat dikonfigurasi +- Percobaan ulang chat yang sadar cooldown dengan `requestRetry` dan `maxRetryIntervalSec` yang dapat dikonfigurasi +- Validasi lingkungan runtime dengan Zod saat startup +- Audit kepatuhan v2 dengan paginasi, event CRUD penyedia, dan pencatatan validasi yang diblokir SSRF -Primary runtime model: +Waktu proses model utama: -- Next.js app routes under `src/app/api/*` implement both dashboard APIs and compatibility APIs -- A shared SSE/routing core in `src/sse/*` + `open-sse/*` handles provider execution, translation, streaming, fallback, and usage +- Route aplikasi Next.js di bawah `src/app/api/*` mengimplementasikan API dasbor dan API kompatibilitas +- Inti SSE/routing bersama dalam `src/sse/*` + `open-sse/*` menangani eksekusi penyedia, translasi, streaming, fallback, dan penggunaan -## Scope and Boundaries +## Ruang Lingkup dan Batasan -### In Scope +### Dalam Ruang Lingkup -- Local gateway runtime -- Dashboard management APIs -- Provider authentication and token refresh -- Request translation and SSE streaming -- Local state + usage persistence -- Optional cloud sync orchestration +- Runtime gateway lokal +- API manajemen dasbor +- Autentikasi penyedia dan pembaruan token +- Translasi permintaan dan streaming SSE +- Persistensi status lokal + penggunaan +- Orkestrasi sinkronisasi cloud opsional -### Out of Scope +### Di Luar Ruang Lingkup -- Cloud service implementation behind `NEXT_PUBLIC_CLOUD_URL` -- Provider SLA/control plane outside local process -- External CLI binaries themselves (Claude CLI, Codex CLI, etc.) +- Implementasi layanan cloud di balik `NEXT_PUBLIC_CLOUD_URL` +- SLA/control plane penyedia di luar proses lokal +- Biner CLI eksternal itu sendiri (Claude CLI, Codex CLI, dll.) -## Dashboard Surface (Current) +## Tampilan Dasbor (Saat Ini) -Main pages under `src/app/(dashboard)/dashboard/`: +Halaman utama di bawah `src/app/(dashboard)/dashboard/`: -- `/dashboard` — quick start + provider overview -- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs -- `/dashboard/providers` — provider connections and credentials -- `/dashboard/combos` — combo strategies, templates, step-based builder, model routing rules, manual persisted ordering -- `/dashboard/costs` — cost aggregation and pricing visibility -- `/dashboard/analytics` — usage analytics, evaluations, combo target health -- `/dashboard/limits` — quota/rate controls -- `/dashboard/cli-tools` — CLI onboarding, runtime detection, config generation -- `/dashboard/agents` — detected ACP agents + custom agent registration -- `/dashboard/media` — image/video/music playground -- `/dashboard/search-tools` — search provider testing and history -- `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions -- `/dashboard/logs` — request/proxy/audit/console logs -- `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.) -- `/dashboard/api-manager` — API key lifecycle and model permissions +- `/dashboard` — panduan cepat + ikhtisar penyedia +- `/dashboard/endpoint` — tab proxy endpoint + MCP + A2A + endpoint API +- `/dashboard/providers` — koneksi penyedia dan kredensial +- `/dashboard/combos` — strategi combo, template, pembuat berbasis langkah, aturan routing model, pengurutan persisten manual +- `/dashboard/costs` — agregasi biaya dan visibilitas harga +- `/dashboard/analytics` — analitik penggunaan, evaluasi, kesehatan target combo +- `/dashboard/limits` — kontrol kuota/laju +- `/dashboard/cli-tools` — orientasi CLI, deteksi runtime, pembuatan konfigurasi +- `/dashboard/agents` — agen ACP yang terdeteksi + pendaftaran agen kustom +- `/dashboard/media` — playground gambar/video/musik +- `/dashboard/search-tools` — pengujian penyedia pencarian dan riwayat +- `/dashboard/health` — uptime, circuit breaker, batas laju, sesi yang dipantau kuota +- `/dashboard/logs` — log permintaan/proxy/audit/konsol +- `/dashboard/settings` — tab pengaturan sistem (umum, routing, default combo, dll.) +- `/dashboard/api-manager` — siklus hidup API key dan izin model -## High-Level System Context +## Konteks Sistem Tingkat Tinggi ```mermaid flowchart LR @@ -164,279 +164,279 @@ flowchart LR DASH --> CLOUD ``` -## Core Runtime Components +## Komponen Runtime Inti -## 1) API and Routing Layer (Next.js App Routes) +## 1) Lapisan API dan Routing (Next.js App Routes) -Main directories: +Direktori utama: -- `src/app/api/v1/*` and `src/app/api/v1beta/*` for compatibility APIs -- `src/app/api/*` for management/configuration APIs -- Next rewrites in `next.config.mjs` map `/v1/*` to `/api/v1/*` +- `src/app/api/v1/*` dan `src/app/api/v1beta/*` untuk API kompatibilitas +- `src/app/api/*` untuk API manajemen/konfigurasi +- Selanjutnya penulisan ulang di `next.config.mjs` peta `/v1/*` menjadi `/api/v1/*` -Important compatibility routes: +Route kompatibilitas penting: - `src/app/api/v1/chat/completions/route.ts` - `src/app/api/v1/messages/route.ts` - `src/app/api/v1/responses/route.ts` -- `src/app/api/v1/models/route.ts` — includes custom models with `custom: true` -- `src/app/api/v1/embeddings/route.ts` — embedding generation (6 providers) -- `src/app/api/v1/images/generations/route.ts` — image generation (4+ providers incl. Antigravity/Nebius) +- `src/app/api/v1/models/route.ts` — termasuk model khusus dengan `custom: true` +- `src/app/api/v1/embeddings/route.ts` — generasi penyematan (6 penyedia) +- `src/app/api/v1/images/generations/route.ts` — pembuatan gambar (4+ penyedia termasuk Antigravitasi/Nebius) - `src/app/api/v1/messages/count_tokens/route.ts` -- `src/app/api/v1/providers/[provider]/chat/completions/route.ts` — dedicated per-provider chat -- `src/app/api/v1/providers/[provider]/embeddings/route.ts` — dedicated per-provider embeddings -- `src/app/api/v1/providers/[provider]/images/generations/route.ts` — dedicated per-provider images +- `src/app/api/v1/providers/[provider]/chat/completions/route.ts` — obrolan khusus per penyedia +- `src/app/api/v1/providers/[provider]/embeddings/route.ts` — penyematan khusus per penyedia +- `src/app/api/v1/providers/[provider]/images/generations/route.ts` — gambar khusus per penyedia - `src/app/api/v1beta/models/route.ts` - `src/app/api/v1beta/models/[...path]/route.ts` -Management domains: +Domain manajemen: -- Auth/settings: `src/app/api/auth/*`, `src/app/api/settings/*` -- Providers/connections: `src/app/api/providers*` -- Provider nodes: `src/app/api/provider-nodes*` -- Custom models: `src/app/api/provider-models` (GET/POST/DELETE) -- Model catalog: `src/app/api/models/route.ts` (GET) -- Proxy config: `src/app/api/settings/proxy` (GET/PUT/DELETE) + `src/app/api/settings/proxy/test` (POST) +- Auth/pengaturan: `src/app/api/auth/*`, `src/app/api/settings/*` +- Penyedia/koneksi: `src/app/api/providers*` +- Node penyedia: `src/app/api/provider-nodes*` +- Model kustom: `src/app/api/provider-models` (GET/POST/DELETE) +- Katalog model: `src/app/api/models/route.ts` (GET) +- Konfigurasi proxy: `src/app/api/settings/proxy` (GET/PUT/DELETE) + `src/app/api/settings/proxy/test` (POST) - OAuth: `src/app/api/oauth/*` -- Keys/aliases/combos/pricing: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing` -- Usage: `src/app/api/usage/*` +- Kunci/alias/combo/harga: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing` +- Penggunaan: `src/app/api/usage/*` - Sync/cloud: `src/app/api/sync/*`, `src/app/api/cloud/*` -- CLI tooling helpers: `src/app/api/cli-tools/*` -- IP filter: `src/app/api/settings/ip-filter` (GET/PUT) -- Thinking budget: `src/app/api/settings/thinking-budget` (GET/PUT) +- Pembantu alat CLI: `src/app/api/cli-tools/*` +- Filter IP: `src/app/api/settings/ip-filter` (GET/PUT) +- Anggaran berpikir: `src/app/api/settings/thinking-budget` (GET/PUT) - System prompt: `src/app/api/settings/system-prompt` (GET/PUT) -- Sessions: `src/app/api/sessions` (GET) -- Rate limits: `src/app/api/rate-limits` (GET) -- Resilience: `src/app/api/resilience` (GET/PATCH) — request queue, connection cooldown, provider breaker, wait-for-cooldown config -- Resilience reset: `src/app/api/resilience/reset` (POST) — reset provider breakers -- Cache stats: `src/app/api/cache/stats` (GET/DELETE) -- Telemetry: `src/app/api/telemetry/summary` (GET) -- Budget: `src/app/api/usage/budget` (GET/POST) -- Fallback chains: `src/app/api/fallback/chains` (GET/POST/DELETE) -- Compliance audit: `src/app/api/compliance/audit-log` (GET, with pagination + structured metadata) -- Evals: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET) -- Policies: `src/app/api/policies` (GET/POST) -- Sync tokens: `src/app/api/sync/tokens` (GET/POST), `src/app/api/sync/tokens/[id]` (GET/DELETE) -- Config bundle: `src/app/api/sync/bundle` (GET, ETag-versioned snapshot of settings/providers/combos/keys) -- WebSocket: `src/app/api/v1/ws/route.ts` — Upgrade handler for OpenAI-compatible WS clients +- Sesi: `src/app/api/sessions` (GET) +- Batas laju: `src/app/api/rate-limits` (GET) +- Ketahanan: `src/app/api/resilience` (GET/PATCH) — antrean permintaan, cooldown koneksi, breaker penyedia, konfigurasi wait-for-cooldown +- Reset ketahanan: `src/app/api/resilience/reset` (POST) — reset breaker penyedia +- Statistik cache: `src/app/api/cache/stats` (GET/DELETE) +- Telemetri: `src/app/api/telemetry/summary` (GET) +- Anggaran: `src/app/api/usage/budget` (GET/POST) +- Rantai fallback: `src/app/api/fallback/chains` (GET/POST/DELETE) +- Audit kepatuhan: `src/app/api/compliance/audit-log` (GET, dengan paginasi + metadata terstruktur) +- Eval: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET) +- Kebijakan: `src/app/api/policies` (GET/POST) +- Token sinkronisasi: `src/app/api/sync/tokens` (GET/POST), `src/app/api/sync/tokens/[id]` (GET/DELETE) +- Bundel konfigurasi: `src/app/api/sync/bundle` (GET, snapshot berversi ETag dari pengaturan/penyedia/combo/kunci) +- WebSocket: `src/app/api/v1/ws/route.ts` — handler Upgrade untuk klien WS yang kompatibel dengan OpenAI -## 2) SSE + Translation Core +## 2) Inti SSE + Translasi -Main flow modules: +Modul aliran utama: -- Entry: `src/sse/handlers/chat.ts` -- Core orchestration: `open-sse/handlers/chatCore.ts` -- Provider execution adapters: `open-sse/executors/*` -- Format detection/provider config: `open-sse/services/provider.ts` -- Model parse/resolve: `src/sse/services/model.ts`, `open-sse/services/model.ts` -- Account fallback logic: `open-sse/services/accountFallback.ts` -- Translation registry: `open-sse/translator/index.ts` -- Stream transformations: `open-sse/utils/stream.ts`, `open-sse/utils/streamHandler.ts` -- Usage extraction/normalization: `open-sse/utils/usageTracking.ts` -- Think tag parser: `open-sse/utils/thinkTagParser.ts` -- Embedding handler: `open-sse/handlers/embeddings.ts` -- Embedding provider registry: `open-sse/config/embeddingRegistry.ts` -- Image generation handler: `open-sse/handlers/imageGeneration.ts` -- Image provider registry: `open-sse/config/imageRegistry.ts` -- Response sanitization: `open-sse/handlers/responseSanitizer.ts` -- Role normalization: `open-sse/services/roleNormalizer.ts` +- Entri: `src/sse/handlers/chat.ts` +- Orkestrasi inti: `open-sse/handlers/chatCore.ts` +- Adaptor eksekusi penyedia: `open-sse/executors/*` +- Deteksi format/konfigurasi penyedia: `open-sse/services/provider.ts` +- Penguraian/resolusi model: `src/sse/services/model.ts`, `open-sse/services/model.ts` +- Logika fallback akun: `open-sse/services/accountFallback.ts` +- Registry translasi: `open-sse/translator/index.ts` +- Transformasi stream: `open-sse/utils/stream.ts`, `open-sse/utils/streamHandler.ts` +- Ekstraksi/normalisasi penggunaan: `open-sse/utils/usageTracking.ts` +- Pengurai think tag: `open-sse/utils/thinkTagParser.ts` +- Handler embedding: `open-sse/handlers/embeddings.ts` +- Registry penyedia embedding: `open-sse/config/embeddingRegistry.ts` +- Handler pembuatan gambar: `open-sse/handlers/imageGeneration.ts` +- Registry penyedia gambar: `open-sse/config/imageRegistry.ts` +- Sanitasi respons: `open-sse/handlers/responseSanitizer.ts` +- Normalisasi peran: `open-sse/services/roleNormalizer.ts` -Services (business logic): +Layanan (logika bisnis): -- Account selection/scoring: `open-sse/services/accountSelector.ts` -- Context lifecycle management: `open-sse/services/contextManager.ts` -- IP filter enforcement: `open-sse/services/ipFilter.ts` -- Session tracking: `open-sse/services/sessionManager.ts` -- Request deduplication: `open-sse/services/signatureCache.ts` -- System prompt injection: `open-sse/services/systemPrompt.ts` -- Thinking budget management: `open-sse/services/thinkingBudget.ts` -- Wildcard model routing: `open-sse/services/wildcardRouter.ts` -- Rate limit management: `open-sse/services/rateLimitManager.ts` +- Pemilihan/penilaian akun: `open-sse/services/accountSelector.ts` +- Manajemen siklus hidup konteks: `open-sse/services/contextManager.ts` +- Penegakan filter IP: `open-sse/services/ipFilter.ts` +- Pelacakan sesi: `open-sse/services/sessionManager.ts` +- Deduplikasi permintaan: `open-sse/services/signatureCache.ts` +- Injeksi system prompt: `open-sse/services/systemPrompt.ts` +- Manajemen anggaran berpikir: `open-sse/services/thinkingBudget.ts` +- Routing model wildcard: `open-sse/services/wildcardRouter.ts` +- Manajemen batas laju: `open-sse/services/rateLimitManager.ts` - Circuit breaker: `open-sse/services/circuitBreaker.ts` -- Context handoff: `open-sse/services/contextHandoff.ts` — handoff summary generation and injection for context-relay strategy -- Codex quota fetcher: `open-sse/services/codexQuotaFetcher.ts` — fetches Codex quota for context-relay handoff decisions -- Cooldown-aware retry: `src/sse/services/cooldownAwareRetry.ts` — per-model cooldown retries with configurable `requestRetry` / `maxRetryIntervalSec` -- Safe outbound fetch: `src/shared/network/safeOutboundFetch.ts` — guarded provider/model fetch with SSRF guard, private-URL blocking, retry, and timeout -- Outbound URL guard: `src/shared/network/outboundUrlGuard.ts` — validates provider URLs against private/localhost CIDR ranges -- Provider request defaults: `open-sse/services/providerRequestDefaults.ts` — provider-level `maxTokens`, `temperature`, `thinkingBudgetTokens` defaults -- GLM provider constants: `open-sse/config/glmProvider.ts` — shared GLM models, quota URLs, GLMT timeout/defaults -- Antigravity upstream: `open-sse/config/antigravityUpstream.ts` — base URL and discovery path constants -- Codex client constants: `open-sse/config/codexClient.ts` — versioned user-agent and client-version values -- Model alias seed: `src/lib/modelAliasSeed.ts` — seeds 30+ cross-proxy dialect aliases at startup +- Handoff konteks: `open-sse/services/contextHandoff.ts` — pembuatan dan injeksi ringkasan handoff untuk strategi context-relay +- Pengambil kuota Codex: `open-sse/services/codexQuotaFetcher.ts` — mengambil kuota Codex untuk keputusan handoff context-relay +- Percobaan ulang yang sadar cooldown: `src/sse/services/cooldownAwareRetry.ts` — percobaan ulang cooldown per model dengan `requestRetry` / `maxRetryIntervalSec` yang dapat dikonfigurasi +- Pengambilan outbound aman: `src/shared/network/safeOutboundFetch.ts` — pengambilan penyedia/model yang dijaga dengan penjaga SSRF, pemblokiran URL privat, percobaan ulang, dan batas waktu +- Penjaga URL outbound: `src/shared/network/outboundUrlGuard.ts` — memvalidasi URL penyedia terhadap rentang CIDR privat/localhost +- Default permintaan penyedia: `open-sse/services/providerRequestDefaults.ts` — default `maxTokens`, `temperature`, `thinkingBudgetTokens` tingkat penyedia +- Konstanta penyedia GLM: `open-sse/config/glmProvider.ts` — model GLM bersama, URL kuota, timeout/default GLMT +- Upstream Antigravity: `open-sse/config/antigravityUpstream.ts` — konstanta base URL dan jalur discovery +- Konstanta klien Codex: `open-sse/config/codexClient.ts` — nilai user-agent dan client-version berversi +- Seed alias model: `src/lib/modelAliasSeed.ts` — menyemai 30+ alias dialek lintas proxy saat startup -Domain layer modules: +Modul lapisan domain: -- Cost rules/budgets: `src/lib/domain/costRules.ts` -- Fallback policy: `src/lib/domain/fallbackPolicy.ts` -- Combo resolver: `src/lib/domain/comboResolver.ts` -- Lockout policy: `src/lib/domain/lockoutPolicy.ts` -- Policy engine: `src/domain/policyEngine.ts` — centralized lockout → budget → fallback evaluation -- Error codes catalog: `src/lib/domain/errorCodes.ts` -- Request ID: `src/lib/domain/requestId.ts` -- Fetch timeout: `src/lib/domain/fetchTimeout.ts` -- Request telemetry: `src/lib/domain/requestTelemetry.ts` -- Compliance/audit: `src/lib/domain/compliance/index.ts` -- Eval runner: `src/lib/domain/evalRunner.ts` -- Domain state persistence: `src/lib/db/domainState.ts` — SQLite CRUD for fallback chains, budgets, cost history, lockout state, circuit breakers +- Aturan biaya/anggaran: `src/lib/domain/costRules.ts` +- Kebijakan fallback: `src/lib/domain/fallbackPolicy.ts` +- Resolver combo: `src/lib/domain/comboResolver.ts` +- Kebijakan lockout: `src/lib/domain/lockoutPolicy.ts` +- Mesin kebijakan: `src/domain/policyEngine.ts` — evaluasi terpusat lockout → budget → fallback +- Katalog kode kesalahan: `src/lib/domain/errorCodes.ts` +- ID permintaan: `src/lib/domain/requestId.ts` +- Batas waktu pengambilan: `src/lib/domain/fetchTimeout.ts` +- Telemetri permintaan: `src/lib/domain/requestTelemetry.ts` +- Kepatuhan/audit: `src/lib/domain/compliance/index.ts` +- Pelari eval: `src/lib/domain/evalRunner.ts` +- Persistensi status domain: `src/lib/db/domainState.ts` — CRUD SQLite untuk rantai fallback, anggaran, riwayat biaya, status lockout, circuit breaker -OAuth provider modules (13 individual files under `src/lib/oauth/providers/`): +Modul penyedia OAuth (13 file individual di bawah `src/lib/oauth/providers/`): -- Registry index: `src/lib/oauth/providers/index.ts` -- Individual providers: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts` -- Thin wrapper: `src/lib/oauth/providers.ts` — re-exports from individual modules +- Indeks registry: `src/lib/oauth/providers/index.ts` +- Penyedia individual: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts` +- Pembungkus tipis: `src/lib/oauth/providers.ts` — re-ekspor dari modul individual -## 3) Persistence Layer +## 3) Lapisan Persistensi -Primary state DB (SQLite): +DB status utama (SQLite): -- Core infra: `src/lib/db/core.ts` (better-sqlite3, migrations, WAL) -- Re-export facade: `src/lib/localDb.ts` (thin compatibility layer for callers) -- file: `${DATA_DIR}/storage.sqlite` (or `$XDG_CONFIG_HOME/omniroute/storage.sqlite` when set, else `~/.omniroute/storage.sqlite`) -- entities (tables + KV namespaces): providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing, **customModels**, **proxyConfig**, **ipFilter**, **thinkingBudget**, **systemPrompt** +- Infrastruktur inti: `src/lib/db/core.ts` (better-sqlite3, migrasi, WAL) +- Fasad re-ekspor: `src/lib/localDb.ts` (lapisan kompatibilitas tipis untuk pemanggil) +- File: `${DATA_DIR}/storage.sqlite` (atau `$XDG_CONFIG_HOME/omniroute/storage.sqlite` jika diatur, jika tidak `~/.omniroute/storage.sqlite`) +- Entitas (tabel + namespace KV): providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing, **customModels**, **proxyConfig**, **ipFilter**, **thinkingBudget**, **systemPrompt** -Usage persistence: +Persistensi penggunaan: -- facade: `src/lib/usageDb.ts` (decomposed modules in `src/lib/usage/*`) -- SQLite tables in `storage.sqlite`: `usage_history`, `call_logs`, `proxy_logs` -- optional file artifacts remain for compatibility/debug (`${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`, `/logs/...`) -- legacy JSON files are migrated to SQLite by startup migrations when present +- Fasad: `src/lib/usageDb.ts` (modul terurai dalam `src/lib/usage/*`) +- Tabel SQLite dalam `storage.sqlite`: `usage_history`, `call_logs`, `proxy_logs` +- Artefak file opsional tetap ada untuk kompatibilitas/debug (`${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`, `/logs/...`) +- File JSON lama dimigrasikan ke SQLite oleh migrasi startup jika ada -Domain State DB (SQLite): +DB Status Domain (SQLite): -- `src/lib/db/domainState.ts` — CRUD operations for domain state -- Tables (created in `src/lib/db/core.ts`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers` -- Write-through cache pattern: in-memory Maps are authoritative at runtime; mutations are written synchronously to SQLite; state is restored from DB on cold start +- `src/lib/db/domainState.ts` — operasi CRUD untuk status domain +- Tabel (dibuat di `src/lib/db/core.ts`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers` +- Pola cache write-through: Map dalam memori bersifat otoritatif saat runtime; mutasi ditulis secara sinkron ke SQLite; status dipulihkan dari DB saat cold start -## 4) Auth + Security Surfaces +## 4) Permukaan Auth + Keamanan -- Dashboard cookie auth: `src/proxy.ts`, `src/app/api/auth/login/route.ts` -- API key generation/verification: `src/shared/utils/apiKey.ts` -- Provider secrets persisted in `providerConnections` entries -- Outbound proxy support via `open-sse/utils/proxyFetch.ts` (env vars) and `open-sse/utils/networkProxy.ts` (configurable per-provider or global) -- SSRF / outbound URL guard: `src/shared/network/outboundUrlGuard.ts` — blocks private/loopback/link-local ranges for all provider calls -- Runtime env validation: `src/lib/env/runtimeEnv.ts` — Zod schema for all environment variables, surfaced as startup errors/warnings -- Sync tokens: `src/lib/db/syncTokens.ts` — scoped tokens for config bundle download endpoints; backed by `sync_tokens` SQLite table (migration `024_create_sync_tokens.sql`) -- WebSocket handshake auth: `src/lib/ws/handshake.ts` — validates WS upgrade requests via API key or session cookie +- Auth cookie dasbor: `src/proxy.ts`, `src/app/api/auth/login/route.ts` +- Pembuatan/verifikasi API key: `src/shared/utils/apiKey.ts` +- Rahasia penyedia yang disimpan dalam entri `providerConnections` +- Dukungan proxy outbound melalui `open-sse/utils/proxyFetch.ts` (env var) dan `open-sse/utils/networkProxy.ts` (dapat dikonfigurasi per penyedia atau global) +- Penjaga SSRF/URL outbound: `src/shared/network/outboundUrlGuard.ts` — memblokir rentang privat/loopback/link-local untuk semua panggilan penyedia +- Validasi env runtime: `src/lib/env/runtimeEnv.ts` — skema Zod untuk semua variabel lingkungan, ditampilkan sebagai kesalahan/peringatan saat startup +- Token sinkronisasi: `src/lib/db/syncTokens.ts` — token bercakupan untuk endpoint unduhan bundel konfigurasi; didukung oleh tabel SQLite `sync_tokens` (migrasi `024_create_sync_tokens.sql`) +- Auth handshake WebSocket: `src/lib/ws/handshake.ts` — memvalidasi permintaan upgrade WS melalui API key atau cookie sesi -## 5) Cloud Sync +## 5) Sinkronisasi Cloud -- Scheduler init: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts`, `src/shared/services/modelSyncScheduler.ts` -- Periodic task: `src/shared/services/cloudSyncScheduler.ts` -- Periodic task: `src/shared/services/modelSyncScheduler.ts` -- Control route: `src/app/api/sync/cloud/route.ts` +- Inisialisasi penjadwal: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts`, `src/shared/services/modelSyncScheduler.ts` +- Tugas berkala: `src/shared/services/cloudSyncScheduler.ts` +- Tugas berkala: `src/shared/services/modelSyncScheduler.ts` +- Route kontrol: `src/app/api/sync/cloud/route.ts` -## Request Lifecycle (`/v1/chat/completions`) +## Siklus Hidup Permintaan (`/v1/chat/completions`) ```mermaid sequenceDiagram autonumber - participant Client as CLI/SDK Client + participant Client as Klien CLI/SDK participant Route as /api/v1/chat/completions participant Chat as src/sse/handlers/chat participant Core as open-sse/handlers/chatCore - participant Model as Model Resolver - participant Auth as Credential Selector - participant Exec as Provider Executor - participant Prov as Upstream Provider - participant Stream as Stream Translator + participant Model as Resolver Model + participant Auth as Pemilih Kredensial + participant Exec as Executor Penyedia + participant Prov as Penyedia Upstream + participant Stream as Penerjemah Stream participant Usage as usageDb Client->>Route: POST /v1/chat/completions Route->>Chat: handleChat(request) - Chat->>Model: parse/resolve model or combo + Chat->>Model: urai/resolusi model atau combo - alt Combo model - Chat->>Chat: iterate combo models (handleComboChat) + alt Model combo + Chat->>Chat: iterasi model combo (handleComboChat) end Chat->>Auth: getProviderCredentials(provider) - Auth-->>Chat: active account + tokens/api key + Auth-->>Chat: akun aktif + token/api key Chat->>Core: handleChatCore(body, modelInfo, credentials) - Core->>Core: detect source format - Core->>Core: translate request to target format + Core->>Core: deteksi format sumber + Core->>Core: terjemahkan permintaan ke format target Core->>Exec: execute(provider, transformedBody) - Exec->>Prov: upstream API call - Prov-->>Exec: SSE/JSON response - Exec-->>Core: response + metadata + Exec->>Prov: panggilan API upstream + Prov-->>Exec: respons SSE/JSON + Exec-->>Core: respons + metadata alt 401/403 Core->>Exec: refreshCredentials() - Exec-->>Core: updated tokens - Core->>Exec: retry request + Exec-->>Core: token yang diperbarui + Core->>Exec: coba ulang permintaan end - Core->>Stream: translate/normalize stream to client format - Stream-->>Client: SSE chunks / JSON response + Core->>Stream: terjemahkan/normalisasi stream ke format klien + Stream-->>Client: potongan SSE / respons JSON - Stream->>Usage: extract usage + persist history/log + Stream->>Usage: ekstrak penggunaan + simpan riwayat/log ``` -## Combo + Account Fallback Flow +## Aliran Fallback Combo + Akun ```mermaid flowchart TD - A[Incoming model string] --> B{Is combo name?} - B -- Yes --> C[Load combo models sequence] - B -- No --> D[Single model path] + A[String model masuk] --> B{Apakah nama combo?} + B -- Ya --> C[Muat urutan model combo] + B -- Tidak --> D[Jalur model tunggal] - C --> E[Try model N] - E --> F[Resolve provider/model] + C --> E[Coba model N] + E --> F[Resolusi penyedia/model] D --> F - F --> G[Select account credentials] - G --> H{Credentials available?} - H -- No --> I[Return provider unavailable] - H -- Yes --> J[Execute request] + F --> G[Pilih kredensial akun] + G --> H{Kredensial tersedia?} + H -- Tidak --> I[Kembalikan penyedia tidak tersedia] + H -- Ya --> J[Eksekusi permintaan] - J --> K{Success?} - K -- Yes --> L[Return response] - K -- No --> M{Fallback-eligible error?} + J --> K{Berhasil?} + K -- Ya --> L[Kembalikan respons] + K -- Tidak --> M{Kesalahan layak fallback?} - M -- No --> N[Return error] - M -- Yes --> O[Mark account unavailable cooldown] - O --> P{Another account for provider?} - P -- Yes --> G - P -- No --> Q{In combo with next model?} - Q -- Yes --> E - Q -- No --> R[Return all unavailable] + M -- Tidak --> N[Kembalikan kesalahan] + M -- Ya --> O[Tandai akun cooldown tidak tersedia] + O --> P{Ada akun lain untuk penyedia?} + P -- Ya --> G + P -- Tidak --> Q{Dalam combo dengan model berikutnya?} + Q -- Ya --> E + Q -- Tidak --> R[Kembalikan semua tidak tersedia] ``` -Fallback decisions are driven by `open-sse/services/accountFallback.ts` using status codes and error-message heuristics. Combo routing adds one extra guard: provider-scoped 400s such as upstream content-block and role-validation failures are treated as model-local failures so later combo targets can still run. +Keputusan fallback dikendalikan oleh `open-sse/services/accountFallback.ts` menggunakan kode status dan heuristik pesan kesalahan. Routing combo menambahkan satu penjaga ekstra: 400 yang bercakupan penyedia seperti kegagalan blokir konten upstream dan validasi peran diperlakukan sebagai kegagalan lokal model sehingga target combo berikutnya masih bisa berjalan. -## OAuth Onboarding and Token Refresh Lifecycle +## Siklus Hidup Orientasi OAuth dan Pembaruan Token ```mermaid sequenceDiagram autonumber - participant UI as Dashboard UI + participant UI as UI Dasbor participant OAuth as /api/oauth/[provider]/[action] - participant ProvAuth as Provider Auth Server + participant ProvAuth as Server Auth Penyedia participant DB as localDb participant Test as /api/providers/[id]/test - participant Exec as Provider Executor + participant Exec as Executor Penyedia - UI->>OAuth: GET authorize or device-code - OAuth->>ProvAuth: create auth/device flow - ProvAuth-->>OAuth: auth URL or device code payload - OAuth-->>UI: flow data + UI->>OAuth: GET authorize atau device-code + OAuth->>ProvAuth: buat aliran auth/device + ProvAuth-->>OAuth: URL auth atau payload kode device + OAuth-->>UI: data aliran - UI->>OAuth: POST exchange or poll - OAuth->>ProvAuth: token exchange/poll - ProvAuth-->>OAuth: access/refresh tokens + UI->>OAuth: POST exchange atau poll + OAuth->>ProvAuth: penukaran/polling token + ProvAuth-->>OAuth: token akses/penyegaran OAuth->>DB: createProviderConnection(oauth data) - OAuth-->>UI: success + connection id + OAuth-->>UI: berhasil + id koneksi UI->>Test: POST /api/providers/[id]/test - Test->>Exec: validate credentials / optional refresh - Exec-->>Test: valid or refreshed token info - Test->>DB: update status/tokens/errors - Test-->>UI: validation result + Test->>Exec: validasi kredensial / penyegaran opsional + Exec-->>Test: info token valid atau yang diperbarui + Test->>DB: perbarui status/token/kesalahan + Test-->>UI: hasil validasi ``` -Refresh during live traffic is executed inside `open-sse/handlers/chatCore.ts` via executor `refreshCredentials()`. +Pembaruan selama lalu lintas langsung dieksekusi di dalam `open-sse/handlers/chatCore.ts` melalui executor `refreshCredentials()`. -## Cloud Sync Lifecycle (Enable / Sync / Disable) +## Siklus Hidup Sinkronisasi Cloud (Aktifkan / Sinkronkan / Nonaktifkan) ```mermaid sequenceDiagram @@ -468,14 +468,14 @@ sequenceDiagram Sync-->>UI: disabled ``` -Periodic sync is triggered by `CloudSyncScheduler` when cloud is enabled. +Sinkronisasi berkala dipicu oleh `CloudSyncScheduler` saat cloud diaktifkan. -## Data Model and Storage Map +## Model Data dan Peta Penyimpanan ```mermaid erDiagram - SETTINGS ||--o{ PROVIDER_CONNECTION : controls - PROVIDER_NODE ||--o{ PROVIDER_CONNECTION : backs_compatible_provider + SETTINGS ||--o{ PROVIDER_CONNECTION : kontrol + PROVIDER_NODE ||--o{ PROVIDER_CONNECTION : penyedia_kompatibel_belakang PROVIDER_CONNECTION ||--o{ USAGE_ENTRY : emits_usage SETTINGS { @@ -573,12 +573,12 @@ erDiagram Physical storage files: -- primary runtime DB: `${DATA_DIR}/storage.sqlite` -- request log lines: `${DATA_DIR}/log.txt` (compat/debug artifact) -- structured call payload archives: `${DATA_DIR}/call_logs/` -- optional translator/request debug sessions: `/logs/...` +- DB waktu proses utama: `${DATA_DIR}/storage.sqlite` +- baris log permintaan: `${DATA_DIR}/log.txt` (artefak compat/debug) +- arsip muatan panggilan terstruktur: `${DATA_DIR}/call_logs/` +- sesi debug penerjemah/permintaan opsional: `/logs/...` -## Deployment Topology +## Topologi Deployment ```mermaid flowchart LR @@ -609,86 +609,86 @@ flowchart LR Next --> SyncCloud ``` -## Module Mapping (Decision-Critical) +## Pemetaan Modul (Kritis Keputusan) -### Route and API Modules +### Rute dan Modul API -- `src/app/api/v1/*`, `src/app/api/v1beta/*`: compatibility APIs -- `src/app/api/v1/providers/[provider]/*`: dedicated per-provider routes (chat, embeddings, images) -- `src/app/api/providers*`: provider CRUD, validation, testing -- `src/app/api/provider-nodes*`: custom compatible node management -- `src/app/api/provider-models`: custom model management (CRUD) -- `src/app/api/models/route.ts`: model catalog API (aliases + custom models) -- `src/app/api/oauth/*`: OAuth/device-code flows -- `src/app/api/keys*`: local API key lifecycle -- `src/app/api/models/alias`: alias management -- `src/app/api/combos*`: fallback combo management -- `src/app/api/pricing`: pricing overrides for cost calculation -- `src/app/api/settings/proxy`: proxy configuration (GET/PUT/DELETE) -- `src/app/api/settings/proxy/test`: outbound proxy connectivity test (POST) -- `src/app/api/usage/*`: usage and logs APIs -- `src/app/api/sync/*` + `src/app/api/cloud/*`: cloud sync and cloud-facing helpers -- `src/app/api/cli-tools/*`: local CLI config writers/checkers -- `src/app/api/settings/ip-filter`: IP allowlist/blocklist (GET/PUT) -- `src/app/api/settings/thinking-budget`: thinking token budget config (GET/PUT) -- `src/app/api/settings/system-prompt`: global system prompt (GET/PUT) -- `src/app/api/sessions`: active session listing (GET) -- `src/app/api/rate-limits`: per-account rate limit status (GET) -- `src/app/api/sync/tokens`: sync token CRUD (GET/POST) -- `src/app/api/sync/tokens/[id]`: sync token get/delete (GET/DELETE) -- `src/app/api/sync/bundle`: config bundle download (GET, ETag versioning) -- `src/app/api/v1/ws`: WebSocket upgrade handler for OpenAI-compatible WS clients +- `src/app/api/v1/*`, `src/app/api/v1beta/*`: API kompatibilitas +- `src/app/api/v1/providers/[provider]/*`: rute khusus per penyedia (obrolan, penyematan, gambar) +- `src/app/api/providers*` : penyedia CRUD, validasi, pengujian +- `src/app/api/provider-nodes*`: manajemen node khusus yang kompatibel +- `src/app/api/provider-models`: manajemen model khusus (CRUD) +- `src/app/api/models/route.ts`: API katalog model (alias + model khusus) +- `src/app/api/oauth/*`: OAuth/kode perangkat mengalir +- `src/app/api/keys*`: siklus hidup kunci API lokal +- `src/app/api/models/alias`: manajemen alias +- `src/app/api/combos*`: manajemen kombo cadangan +- `src/app/api/pricing`: penggantian harga untuk penghitungan biaya +- `src/app/api/settings/proxy`: konfigurasi proksi (GET/PUT/DELETE) +- `src/app/api/settings/proxy/test`: uji konektivitas proxy keluar (POST) +- `src/app/api/usage/*`: API penggunaan dan log +- `src/app/api/sync/*` + `src/app/api/cloud/*`: sinkronisasi cloud dan bantuan yang menghadap cloud +- `src/app/api/cli-tools/*`: penulis/pemeriksa konfigurasi CLI lokal +- `src/app/api/settings/ip-filter`: Daftar IP yang diizinkan/daftar blokir (GET/PUT) +- `src/app/api/settings/thinking-budget`: konfigurasi anggaran token pemikiran (GET/PUT) +- `src/app/api/settings/system-prompt`: perintah sistem global (GET/PUT) +- `src/app/api/sessions`: daftar sesi aktif (GET) +- `src/app/api/rate-limits`: status batas tarif per akun (GET) +- `src/app/api/sync/tokens`: token sinkronisasi CRUD (GET/POST) +- `src/app/api/sync/tokens/[id]`: dapatkan/hapus token sinkronisasi (GET/DELETE) +- `src/app/api/sync/bundle`: pengunduhan bundel konfigurasi (GET, versi ETag) +- `src/app/api/v1/ws`: Pengendali pemutakhiran WebSocket untuk klien WS yang kompatibel dengan OpenAI -### Routing and Execution Core +### Perutean dan Inti Eksekusi -- `src/sse/handlers/chat.ts`: request parse, combo handling, account selection loop -- `open-sse/handlers/chatCore.ts`: translation, executor dispatch, retry/refresh handling, stream setup -- `open-sse/executors/*`: provider-specific network and format behavior +- `src/sse/handlers/chat.ts`: penguraian permintaan, penanganan kombo, putaran pemilihan akun +- `open-sse/handlers/chatCore.ts`: terjemahan, pengiriman eksekutor, penanganan coba lagi/segarkan, pengaturan streaming +- `open-sse/executors/*`: perilaku jaringan dan format khusus penyedia -### Translation Registry and Format Converters +### Registri Terjemahan dan Pengonversi Format -- `open-sse/translator/index.ts`: translator registry and orchestration -- Request translators: `open-sse/translator/request/*` -- Response translators: `open-sse/translator/response/*` -- Format constants: `open-sse/translator/formats.ts` +- `open-sse/translator/index.ts`: registrasi dan orkestrasi penerjemah +- Permintaan penerjemah: `open-sse/translator/request/*` +- Penerjemah tanggapan: `open-sse/translator/response/*` +- Konstanta format: `open-sse/translator/formats.ts` -### Persistence +### Persistensi -- `src/lib/db/*`: persistent config/state and domain persistence on SQLite -- `src/lib/localDb.ts`: compatibility re-export for DB modules -- `src/lib/usageDb.ts`: usage history/call logs facade on top of SQLite tables +- `src/lib/db/*`: konfigurasi/status persisten dan persistensi domain di SQLite +- `src/lib/localDb.ts`: ekspor ulang kompatibilitas untuk modul DB +- `src/lib/usageDb.ts`: riwayat penggunaan/log panggilan fasad di atas tabel SQLite -## Provider Executor Coverage (Strategy Pattern) +## Cakupan Pelaksana Penyedia (Pola Strategi) -Each provider has a specialized executor extending `BaseExecutor` (in `open-sse/executors/base.ts`), which provides URL building, header construction, retry with exponential backoff, credential refresh hooks, and the `execute()` orchestration method. +Setiap penyedia memiliki pelaksana khusus yang memperluas `BaseExecutor` (dalam `open-sse/executors/base.ts`), yang menyediakan pembuatan URL, konstruksi header, percobaan ulang dengan backoff eksponensial, kait penyegaran kredensial, dan metode orkestrasi `execute()`. | Executor | Provider(s) | Special Handling | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Dynamic URL/header config per provider | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Konfigurasi URL/tajuk dinamis per penyedia | | `AntigravityExecutor` | Google Antigravity | Custom project/session IDs, Retry-After parsing | -| `CliProxyApiExecutor` | CLIProxyAPI-compatible providers | Custom auth and protocol handling | -| `CloudflareAiExecutor` | Cloudflare Workers AI | Account ID injection, Neurons-based usage tracking | +| `CliProxyApiExecutor` | Penyedia yang kompatibel dengan CLIProxyAPI | Penanganan autentikasi dan protokol khusus | +| `CloudflareAiExecutor` | Cloudflare Workers AI | Injeksi ID Akun, pelacakan penggunaan berbasis Neuron | | `CodexExecutor` | OpenAI Codex | Injects system instructions, forces reasoning effort | -| `CursorExecutor` | Cursor IDE | ConnectRPC protocol, Protobuf encoding, request signing via checksum | +| `CursorExecutor` | Cursor IDE | Protokol ConnectRPC, pengkodean Protobuf, penandatanganan permintaan melalui checksum | | `GithubExecutor` | GitHub Copilot | Copilot token refresh, VSCode-mimicking headers | | `GeminiCLIExecutor` | Gemini CLI | Google OAuth token refresh cycle | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream binary format → SSE conversion | -| `OpenCodeExecutor` | OpenCode | AI SDK compatible provider setup | -| `PollinationsExecutor` | Pollinations AI | No API key required, rate-limited requests | -| `PuterExecutor` | Puter | Browser-based provider integration | -| `QoderExecutor` | Qoder AI | PAT and OAuth support, multi-model free tier | -| `VertexExecutor` | Google Vertex AI | Service account auth, region-based endpoints | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | Format biner AWS EventStream → konversi SSE | +| `OpenCodeExecutor` | OpenCode | Penyiapan penyedia yang kompatibel dengan AI SDK | +| `PollinationsExecutor` | Pollinations AI | Tidak diperlukan kunci API, permintaan dengan tarif terbatas | +| `PuterExecutor` | Puter | Integrasi penyedia berbasis browser | +| `QoderExecutor` | Qoder AI | Dukungan PAT dan OAuth, tingkat gratis multi-model | +| `VertexExecutor` | Google Vertex AI | Otentikasi akun layanan, titik akhir berbasis wilayah | -All other providers (including custom compatible nodes) use the `DefaultExecutor`. +Semua penyedia lain (termasuk node khusus yang kompatibel) menggunakan `DefaultExecutor`. -## Provider Compatibility Matrix +## Matriks Kompatibilitas Penyedia | Provider | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API | | ---------------- | ---------------- | --------------------- | ---------------- | ---------- | ------------- | ------------------ | -| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Admin only | -| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Claude | claude | Kunci API / OAuth | ✅ | ✅ | ✅ | ⚠️ Admin only | +| Gemini | gemini | Kunci API / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | | Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Full quota API | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API kuota penuh | | OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | | Codex | openai-responses | OAuth | ✅ forced | ❌ | ✅ | ✅ Rate limits | | GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Quota snapshots | @@ -712,10 +712,10 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor | Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | | NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | | Cloudflare AI | openai | API Token + Acct ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | None (no key) | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | Tidak ada (tidak ada kunci) | ✅ | ✅ | ❌ | ❌ | | Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | | LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API Key (optional) | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | Kunci API (opsional)| ✅ | ✅ | ❌ | ❌ | | HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | | Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | | SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | @@ -723,16 +723,16 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor | Vertex AI | gemini | Service Account | ✅ | ✅ | ✅ | ⚠️ Cloud Console | | Puter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -## Format Translation Coverage +## Format Cakupan Terjemahan -Detected source formats include: +Format sumber yang terdeteksi meliputi: - `openai` - `openai-responses` - `claude` - `gemini` -Target formats include: +Format sasarannya meliputi: - OpenAI chat/Responses - Claude @@ -740,22 +740,22 @@ Target formats include: - Kiro - Cursor -Translations use **OpenAI as the hub format** — all conversions go through OpenAI as intermediate: +Penerjemahan menggunakan **OpenAI sebagai format hub** — semua konversi melalui OpenAI sebagai perantara: ``` Source Format → OpenAI (hub) → Target Format ``` -Translations are selected dynamically based on source payload shape and provider target format. +Terjemahan dipilih secara dinamis berdasarkan bentuk muatan sumber dan format target penyedia. -Additional processing layers in the translation pipeline: +Lapisan pemrosesan tambahan dalam alur terjemahan: -- **Response sanitization** — Strips non-standard fields from OpenAI-format responses (both streaming and non-streaming) to ensure strict SDK compliance -- **Role normalization** — Converts `developer` → `system` for non-OpenAI targets; merges `system` → `user` for models that reject the system role (GLM, ERNIE) -- **Think tag extraction** — Parses `...` blocks from content into `reasoning_content` field -- **Structured output** — Converts OpenAI `response_format.json_schema` to Gemini's `responseMimeType` + `responseSchema` +- **Sanitasi respons** — Menghapus kolom non-standar dari respons format OpenAI (streaming dan non-streaming) untuk memastikan kepatuhan SDK yang ketat +- **Normalisasi peran** — Mengonversi `developer` → `system` untuk target non-OpenAI; menggabungkan `system` → `user` untuk model yang menolak peran sistem (GLM, ERNIE) +- **Pikirkan ekstraksi tag** — Mengurai `...` blok dari konten ke dalam bidang `reasoning_content` +- **Output terstruktur** — Mengonversi OpenAI `response_format.json_schema` menjadi `responseMimeType` + `responseSchema` Gemini -## Supported API Endpoints +## Titik Akhir API yang Didukung | Endpoint | Format | Handler | | -------------------------------------------------- | ------------------ | ------------------------------------------------------------------- | @@ -766,126 +766,126 @@ Additional processing layers in the translation pipeline: | `GET /v1/embeddings` | Model listing | API route | | `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | | `GET /v1/images/generations` | Model listing | API route | -| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation | -| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation | -| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation | +| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Per penyedia khusus dengan validasi model | +| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Per penyedia khusus dengan validasi model | +| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Per penyedia khusus dengan validasi model | | `POST /v1/messages/count_tokens` | Claude Token Count | API route | -| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) | -| `GET /api/models/catalog` | Catalog | All models grouped by provider + type | +| `GET /v1/models` | Daftar Model OpenAI | Rute API (obrolan + penyematan + gambar + model khusus) | +| `GET /api/models/catalog` | Catalog | Semua model dikelompokkan berdasarkan penyedia + jenis | | `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route | -| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration | -| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint | -| `GET/POST/DELETE /api/provider-models` | Provider Models | Provider model metadata backing custom and managed available models | +| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Konfigurasi proksi jaringan | +| `POST /api/settings/proxy/test` | Proxy Connectivity | Titik akhir pengujian kesehatan/konektivitas proxy | +| `GET/POST/DELETE /api/provider-models` | Provider Models | Metadata model penyedia mendukung model kustom dan terkelola yang tersedia | -## Bypass Handler +## Handler Bypass -The bypass handler (`open-sse/utils/bypassHandler.ts`) intercepts known "throwaway" requests from Claude CLI — warmup pings, title extractions, and token counts — and returns a **fake response** without consuming upstream provider tokens. This is triggered only when `User-Agent` contains `claude-cli`. +Penangan bypass (`open-sse/utils/bypassHandler.ts`) mencegat permintaan "sekali pakai" yang diketahui dari Claude CLI — ping pemanasan, ekstraksi judul, dan jumlah token — dan mengembalikan **respons palsu** tanpa menggunakan token penyedia upstream. Ini dipicu hanya ketika `User-Agent` berisi `claude-cli`. -## Request Logging and Artifacts +## Minta Pencatatan dan Artefak -The older file-based request logger (`open-sse/utils/requestLogger.ts`) is retained only for -legacy compatibility. The current runtime contract uses: +Pencatat permintaan berbasis file yang lebih lama (`open-sse/utils/requestLogger.ts`) dipertahankan hanya untuk +kompatibilitas warisan. Kontrak runtime saat ini menggunakan: -- `APP_LOG_TO_FILE=true` for application and audit logs written under `/logs/` -- SQLite-backed call log records in `call_logs` -- `${DATA_DIR}/call_logs/YYYY-MM-DD/...` artifacts when the call log pipeline is enabled +- `APP_LOG_TO_FILE=true` untuk log aplikasi dan audit yang ditulis di bawah `/logs/` +- Catatan log panggilan yang didukung SQLite di `call_logs` +- `${DATA_DIR}/call_logs/YYYY-MM-DD/...` artefak saat pipa log panggilan diaktifkan -## Failure Modes and Resilience +## Mode Kegagalan dan Ketahanan -## 1) Account/Provider Availability +## 1) Ketersediaan Akun/Penyedia -- connection cooldown on retryable upstream failures -- account fallback before failing request -- combo model fallback when current model/provider path is exhausted +- cooldown koneksi pada kegagalan upstream yang dapat dicoba lagi +- penggantian akun sebelum permintaan gagal +- penggantian model kombo ketika jalur model/penyedia saat ini habis -## 2) Token Expiry +## 2) Kedaluwarsa Token -- pre-check and refresh with retry for refreshable providers -- 401/403 retry after refresh attempt in core path +- pra-periksa dan segarkan dengan coba lagi untuk penyedia yang dapat disegarkan +- 401/403 percobaan ulang setelah upaya penyegaran di jalur inti -## 3) Stream Safety +## 3) Keamanan Stream -- disconnect-aware stream controller -- translation stream with end-of-stream flush and `[DONE]` handling -- usage estimation fallback when provider usage metadata is missing +- pengontrol aliran yang sadar akan pemutusan hubungan +- aliran terjemahan dengan flush akhir aliran dan penanganan `[DONE]` +- penggantian estimasi penggunaan ketika metadata penggunaan penyedia tidak ada -## 4) Cloud Sync Degradation +## 4) Degradasi Sinkronisasi Cloud -- sync errors are surfaced but local runtime continues -- scheduler has retry-capable logic, but periodic execution currently calls single-attempt sync by default +- kesalahan sinkronisasi muncul tetapi runtime lokal terus berlanjut +- penjadwal memiliki logika yang mampu mencoba ulang, namun eksekusi berkala saat ini memanggil sinkronisasi upaya tunggal secara default -## 5) Data Integrity +## 5) Integritas Data - SQLite schema migrations and auto-upgrade hooks at startup - legacy JSON → SQLite migration compatibility path -## 6) SSRF / Outbound URL Guard +## 6) SSRF / Penjaga URL Outbound -- `src/shared/network/outboundUrlGuard.ts` blocks all private/loopback/link-local target URLs before they reach provider executors -- Provider model discovery and validation routes use `src/shared/network/safeOutboundFetch.ts` which applies the guard before every outbound request -- Guard errors surface as `URL_GUARD_BLOCKED` with HTTP 422 and are logged to the compliance audit trail via `providerAudit.ts` +- `src/shared/network/outboundUrlGuard.ts` memblokir semua URL target pribadi/loopback/link-lokal sebelum mencapai pelaksana penyedia +- Rute penemuan dan validasi model penyedia menggunakan `src/shared/network/safeOutboundFetch.ts` yang menerapkan penjagaan sebelum setiap permintaan keluar +- Kesalahan penjaga muncul sebagai `URL_GUARD_BLOCKED` dengan HTTP 422 dan dicatat ke jejak audit kepatuhan melalui `providerAudit.ts` -## Observability and Operational Signals +## Observabilitas dan Sinyal Operasional -Runtime visibility sources: +Sumber visibilitas waktu proses: -- console logs from `src/sse/utils/logger.ts` -- per-request usage aggregates in SQLite (`usage_history`, `call_logs`, `proxy_logs`) -- four-stage detailed payload captures in SQLite (`request_detail_logs`) when `settings.detailed_logs_enabled=true` -- textual request status log in `log.txt` (optional/compat) -- optional application log files under `logs/` when `APP_LOG_TO_FILE=true` -- optional request artifacts under `${DATA_DIR}/call_logs/` when the call log pipeline is enabled -- dashboard usage endpoints (`/api/usage/*`) for UI consumption +- log konsol dari `src/sse/utils/logger.ts` +- agregat penggunaan per permintaan di SQLite (`usage_history`, `call_logs`, `proxy_logs`) +- pengambilan muatan terperinci empat tahap dalam SQLite (`request_detail_logs`) ketika `settings.detailed_logs_enabled=true` +- status permintaan tekstual masuk `log.txt` (opsional/sesuai) +- file log aplikasi opsional di bawah `logs/` ketika `APP_LOG_TO_FILE=true` +- artefak permintaan opsional di bawah `${DATA_DIR}/call_logs/` ketika saluran log panggilan diaktifkan +- titik akhir penggunaan dasbor (`/api/usage/*`) untuk konsumsi UI Detailed request payload capture stores up to four JSON payload stages per routed call: -- raw request received from the client -- translated request actually sent upstream -- provider response reconstructed as JSON; streamed responses are compacted to the final summary plus stream metadata -- final client response returned by OmniRoute; streamed responses are stored in the same compact summary form +- permintaan mentah diterima dari klien +- permintaan yang diterjemahkan sebenarnya dikirim ke hulu +- respons penyedia direkonstruksi sebagai JSON; tanggapan yang dialirkan dipadatkan ke ringkasan akhir ditambah metadata aliran +- respons klien akhir yang dikembalikan oleh OmniRoute; tanggapan yang dialirkan disimpan dalam bentuk ringkasan ringkas yang sama -## Security-Sensitive Boundaries +## Batasan yang Sensitif terhadap Keamanan -- JWT secret (`JWT_SECRET`) secures dashboard session cookie verification/signing -- Initial password bootstrap (`INITIAL_PASSWORD`) should be explicitly configured for first-run provisioning -- API key HMAC secret (`API_KEY_SECRET`) secures generated local API key format -- Provider secrets (API keys/tokens) are persisted in local DB and should be protected at filesystem level -- Cloud sync endpoints rely on API key auth + machine id semantics +- Rahasia JWT (`JWT_SECRET`) mengamankan verifikasi/penandatanganan cookie sesi dasbor +- Bootstrap kata sandi awal (`INITIAL_PASSWORD`) harus dikonfigurasi secara eksplisit untuk provisi yang dijalankan pertama kali +- Rahasia HMAC kunci API (`API_KEY_SECRET`) mengamankan format kunci API lokal yang dihasilkan +- Rahasia penyedia (kunci/token API) disimpan di DB lokal dan harus dilindungi di tingkat sistem file +- Titik akhir sinkronisasi cloud mengandalkan autentikasi kunci API + semantik id mesin -## Environment and Runtime Matrix +## Matriks Lingkungan dan Runtime -Environment variables actively used by code: +Variabel lingkungan yang aktif digunakan oleh kode: -- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD` -- Storage: `DATA_DIR` -- Compatible node behavior: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` -- Optional storage base override (Linux/macOS when `DATA_DIR` unset): `XDG_CONFIG_HOME` -- Security hashing: `API_KEY_SECRET`, `MACHINE_ID_SALT` -- Logging: `APP_LOG_TO_FILE`, `APP_LOG_RETENTION_DAYS`, `CALL_LOG_RETENTION_DAYS` -- Sync/cloud URLing: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL` -- Outbound proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` and lowercase variants -- SOCKS5 feature flags: `ENABLE_SOCKS5_PROXY`, `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` -- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME` +- Aplikasi/autentikasi: `JWT_SECRET`, `INITIAL_PASSWORD` +- Penyimpanan: `DATA_DIR` +- Perilaku node yang kompatibel: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` +- Penggantian basis penyimpanan opsional (Linux/macOS ketika `DATA_DIR` tidak disetel): `XDG_CONFIG_HOME` +- Pencirian keamanan: `API_KEY_SECRET`, `MACHINE_ID_SALT` +- Pencatatan: `APP_LOG_TO_FILE`, `APP_LOG_RETENTION_DAYS`, `CALL_LOG_RETENTION_DAYS` +- URL sinkronisasi/cloud: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL` +- Proksi keluar: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` dan varian huruf kecil +- Bendera fitur SOCKS5: `ENABLE_SOCKS5_PROXY`, `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` +- Pembantu platform/runtime (bukan konfigurasi khusus aplikasi): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME` -## Known Architectural Notes +## Catatan Arsitektur yang Diketahui -1. `usageDb` and `localDb` share the same base directory policy (`DATA_DIR` -> `XDG_CONFIG_HOME/omniroute` -> `~/.omniroute`) with legacy file migration. -2. `/api/v1/route.ts` delegates to the same unified catalog builder used by `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) to avoid semantic drift. -3. Request logger writes full headers/body when enabled; treat log directory as sensitive. -4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. -5. The `open-sse/` directory is published as the `@omniroute/open-sse` **npm workspace package**. Source code imports it via `@omniroute/open-sse/...` (resolved by Next.js `transpilePackages`). File paths in this document still use the directory name `open-sse/` for consistency. -6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive analytics visualizations (model usage bar charts, provider breakdown tables with success rates). -7. E2E tests use **Playwright** (`tests/e2e/`), run via `npm run test:e2e`. Unit tests use **Node.js test runner** (`tests/unit/`), run via `npm run test:unit`. Source code under `src/` is **TypeScript** (`.ts`/`.tsx`); the `open-sse/` workspace remains JavaScript (`.js`). -8. Settings page is organized into 7 tabs: General, Appearance, AI, Security, Routing, Resilience, Advanced. The Resilience page only configures request queue, connection cooldown, provider breaker, and wait-for-cooldown behavior; live breaker runtime state is shown on the Health page. -9. **Context Relay** strategy (`context-relay`) is split across two layers: `combo.ts` decides if a handoff should be generated, `chat.ts` injects the handoff after account resolution. Handoff data lives in `context_handoffs` SQLite table. This split is intentional because only `chat.ts` knows whether the actual account changed. -10. **Proxy enforcement** is now comprehensive: `tokenHealthCheck.ts` resolves proxy per connection, `/api/providers/validate` uses `runWithProxyContext`, and `proxyFetch.ts` uses `undici.fetch()` to maintain dispatcher compatibility on Node 22. -11. **Node.js runtime policy detection**: `/api/settings/require-login` returns `nodeVersion` and `nodeCompatible` fields. The login page renders a warning banner when the runtime falls outside the supported secure Node.js lines. +1. `usageDb` dan `localDb` berbagi kebijakan direktori dasar yang sama (`DATA_DIR` -> `XDG_CONFIG_HOME/omniroute` -> `~/.omniroute`) dengan migrasi file lama. +2. `/api/v1/route.ts` mendelegasikan ke pembuat katalog terpadu yang sama yang digunakan oleh `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) untuk menghindari penyimpangan semantik. +3. Pencatat permintaan menulis header/isi lengkap saat diaktifkan; memperlakukan direktori log sebagai sensitif. +4. Perilaku cloud bergantung pada `NEXT_PUBLIC_BASE_URL` yang benar dan jangkauan titik akhir cloud. +5. Direktori `open-sse/` diterbitkan sebagai `@omniroute/open-sse` **paket ruang kerja npm**. Kode sumber mengimpornya melalui `@omniroute/open-sse/...` (diselesaikan oleh Next.js `transpilePackages`). Jalur file dalam dokumen ini masih menggunakan nama direktori `open-sse/` untuk konsistensi. +6. Bagan di dasbor menggunakan **Recharts** (berbasis SVG) untuk visualisasi analitik interaktif yang mudah diakses (diagram batang penggunaan model, tabel perincian penyedia dengan tingkat keberhasilan). +7. Tes E2E menggunakan **Playwright** (`tests/e2e/`), dijalankan melalui `npm run test:e2e`. Pengujian unit menggunakan **Node.js test runner** (`tests/unit/`), dijalankan melalui `npm run test:unit`. Kode sumber di bawah `src/` adalah **TypeScript** (`.ts`/`.tsx`); ruang kerja `open-sse/` tetap JavaScript (`.js`). +8. Halaman pengaturan disusun dalam 7 tab: Umum, Tampilan, AI, Keamanan, Perutean, Ketahanan, Lanjutan. Halaman Ketahanan hanya mengonfigurasi antrean permintaan, waktu tunggu koneksi, pemutus penyedia, dan perilaku menunggu waktu tunggu; status runtime pemutus langsung ditampilkan di halaman Kesehatan. +9. Strategi **Relai Konteks** (`context-relay`) dibagi menjadi dua lapisan: `combo.ts` memutuskan apakah handoff harus dibuat, `chat.ts` memasukkan handoff setelah resolusi akun. Data handoff ada di tabel SQLite `context_handoffs`. Pemisahan ini disengaja karena hanya `chat.ts` yang mengetahui apakah akun sebenarnya berubah. +10. **Penerapan proxy** kini komprehensif: `tokenHealthCheck.ts` menyelesaikan proxy per koneksi, `/api/providers/validate` menggunakan `runWithProxyContext`, dan `proxyFetch.ts` menggunakan `undici.fetch()` untuk menjaga kompatibilitas operator di Node 22. +11. **Deteksi kebijakan runtime Node.js**: `/api/settings/require-login` mengembalikan kolom `nodeVersion` dan `nodeCompatible`. Halaman login menampilkan spanduk peringatan ketika runtime berada di luar garis aman Node.js yang didukung. -## Operational Verification Checklist +## Daftar Periksa Verifikasi Operasional -- Build from source: `npm run build` -- Build Docker image: `docker build -t omniroute .` -- Start service and verify: +- Bangun dari sumber: `npm run build` +- Bangun gambar Docker: `docker build -t omniroute .` +- Mulai layanan dan verifikasi: - `GET /api/settings` - `GET /api/v1/models` -- CLI target base URL should be `http://:20128/v1` when `PORT=20128` +- URL dasar target CLI harus `http://:20128/v1` ketika `PORT=20128` diff --git a/docs/i18n/id/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/i18n/id/docs/architecture/CODEBASE_DOCUMENTATION.md index 2b2ed35375..13905a0259 100644 --- a/docs/i18n/id/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/i18n/id/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -4,21 +4,21 @@ --- -> A comprehensive, beginner-friendly guide to the **omniroute** multi-provider AI proxy router. +> Panduan lengkap dan ramah-pemula untuk router proxy AI multi-penyedia **omniroute**. --- -## 1. What Is omniroute? +## 1. Apa Itu omniroute? -omniroute is a **proxy router** that sits between AI clients (Claude CLI, Codex, Cursor IDE, etc.) and AI providers (Anthropic, Google, OpenAI, AWS, GitHub, etc.). It solves one big problem: +omniroute adalah sebuah **router proxy** yang berada di antara klien AI (Claude CLI, Codex, Cursor IDE, dll.) dan penyedia AI (Anthropic, Google, OpenAI, AWS, GitHub, dll.). Ia memecahkan satu masalah besar: -> **Different AI clients speak different "languages" (API formats), and different AI providers expect different "languages" too.** omniroute translates between them automatically. +> **Klien AI yang berbeda berbicara "bahasa" yang berbeda (format API), dan penyedia AI yang berbeda pun mengharapkan "bahasa" yang berbeda pula.** omniroute menerjemahkan di antara mereka secara otomatis. -Think of it like a universal translator at the United Nations — any delegate can speak any language, and the translator converts it for any other delegate. +Bayangkan seperti penerjemah universal di Perserikatan Bangsa-Bangsa — delegasi mana pun dapat berbicara dalam bahasa apa pun, dan penerjemah mengubahnya untuk delegasi lainnya. --- -## 2. Architecture Overview +## 2. Ikhtisar Arsitektur ```mermaid graph LR @@ -63,82 +63,82 @@ graph LR H -.-> G ``` -### Core Principle: Hub-and-Spoke Translation +### Prinsip Inti: Terjemahan Hub-and-Spoke -All format translation passes through **OpenAI format as the hub**: +Semua terjemahan format melewati **format OpenAI sebagai hub**: ``` Client Format → [OpenAI Hub] → Provider Format (request) Provider Format → [OpenAI Hub] → Client Format (response) ``` -This means you only need **N translators** (one per format) instead of **N²** (every pair). +Artinya Anda hanya membutuhkan **N penerjemah** (satu per format), bukan **N²** (setiap pasangan). --- -## 3. Project Structure +## 3. Struktur Proyek ``` omniroute/ -├── open-sse/ ← Core proxy library (portable, framework-agnostic) -│ ├── index.js ← Main entry point, exports everything -│ ├── config/ ← Configuration & constants -│ ├── executors/ ← Provider-specific request execution -│ ├── handlers/ ← Request handling orchestration -│ ├── services/ ← Business logic (auth, models, fallback, usage) -│ ├── translator/ ← Format translation engine -│ │ ├── request/ ← Request translators (8 files) -│ │ ├── response/ ← Response translators (7 files) -│ │ └── helpers/ ← Shared translation utilities (6 files) -│ └── utils/ ← Utility functions -├── src/ ← Application layer (Express/Worker runtime) -│ ├── app/ ← Web UI, API routes, middleware -│ ├── lib/ ← Database, auth, and shared library code -│ ├── mitm/ ← Man-in-the-middle proxy utilities -│ ├── models/ ← Database models -│ ├── shared/ ← Shared utilities (wrappers around open-sse) -│ ├── sse/ ← SSE endpoint handlers -│ └── store/ ← State management -├── data/ ← Runtime data (credentials, logs) -│ └── provider-credentials.json (external credentials override, gitignored) -└── tester/ ← Test utilities +├── open-sse/ ← Library proxy inti (portabel, framework-agnostic) +│ ├── index.js ← Titik masuk utama, mengekspor segalanya +│ ├── config/ ← Konfigurasi & konstanta +│ ├── executors/ ← Eksekusi permintaan khusus penyedia +│ ├── handlers/ ← Orkestrasi penanganan permintaan +│ ├── services/ ← Logika bisnis (auth, model, fallback, penggunaan) +│ ├── translator/ ← Mesin terjemahan format +│ │ ├── request/ ← Penerjemah permintaan (8 file) +│ │ ├── response/ ← Penerjemah respons (7 file) +│ │ └── helpers/ ← Utilitas terjemahan bersama (6 file) +│ └── utils/ ← Fungsi utilitas +├── src/ ← Lapisan aplikasi (runtime Express/Worker) +│ ├── app/ ← Antarmuka web, rute API, middleware +│ ├── lib/ ← Database, auth, dan kode library bersama +│ ├── mitm/ ← Utilitas proxy man-in-the-middle +│ ├── models/ ← Model database +│ ├── shared/ ← Utilitas bersama (wrapper open-sse) +│ ├── sse/ ← Handler endpoint SSE +│ └── store/ ← Manajemen state +├── data/ ← Data runtime (kredensial, log) +│ └── provider-credentials.json (override kredensial eksternal, diabaikan git) +└── tester/ ← Utilitas pengujian ``` --- -## 4. Module-by-Module Breakdown +## 4. Rincian Modul per Modul ### 4.1 Config (`open-sse/config/`) -The **single source of truth** for all provider configuration. +**Satu-satunya sumber kebenaran** untuk semua konfigurasi penyedia. -| File | Purpose | +| File | Tujuan | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `constants.ts` | `PROVIDERS` object with base URLs, OAuth credentials (defaults), headers, and default system prompts for every provider. Also defines `HTTP_STATUS`, `ERROR_TYPES`, `COOLDOWN_MS`, `BACKOFF_CONFIG`, and `SKIP_PATTERNS`. | -| `credentialLoader.ts` | Loads external credentials from `data/provider-credentials.json` and merges them over the hardcoded defaults in `PROVIDERS`. Keeps secrets out of source control while maintaining backwards compatibility. | -| `providerModels.ts` | Central model registry: maps provider aliases → model IDs. Functions like `getModels()`, `getProviderByAlias()`. | -| `codexInstructions.ts` | System instructions injected into Codex requests (editing constraints, sandbox rules, approval policies). | -| `defaultThinkingSignature.ts` | Default "thinking" signatures for Claude and Gemini models. | -| `ollamaModels.ts` | Schema definition for local Ollama models (name, size, family, quantization). | +| `constants.ts` | Objek `PROVIDERS` dengan URL dasar, kredensial OAuth (default), header, dan system prompt default untuk setiap penyedia. Juga mendefinisikan `HTTP_STATUS`, `ERROR_TYPES`, `COOLDOWN_MS`, `BACKOFF_CONFIG`, dan `SKIP_PATTERNS`. | +| `credentialLoader.ts` | Memuat kredensial eksternal dari `data/provider-credentials.json` dan menggabungkannya ke atas nilai default yang ter-hardcode di `PROVIDERS`. Menjaga rahasia di luar source control sambil mempertahankan kompatibilitas mundur. | +| `providerModels.ts` | Registry model terpusat: memetakan alias penyedia → ID model. Fungsi-fungsi seperti `getModels()`, `getProviderByAlias()`. | +| `codexInstructions.ts` | Instruksi sistem yang disuntikkan ke dalam permintaan Codex (batasan pengeditan, aturan sandbox, kebijakan persetujuan). | +| `defaultThinkingSignature.ts` | Tanda tangan "berpikir" default untuk model Claude dan Gemini. | +| `ollamaModels.ts` | Definisi skema untuk model Ollama lokal (nama, ukuran, keluarga, kuantisasi). | -#### Credential Loading Flow +#### Alur Pemuatan Kredensial ```mermaid flowchart TD A["App starts"] --> B["constants.ts defines PROVIDERS\nwith hardcoded defaults"] B --> C{"data/provider-credentials.json\nexists?"} C -->|Yes| D["credentialLoader reads JSON"] - C -->|No| E["Use hardcoded defaults"] + C -->|No| E["Gunakan default hardcode"] D --> F{"For each provider in JSON"} F --> G{"Provider exists\nin PROVIDERS?"} G -->|No| H["Log warning, skip"] - G -->|Yes| I{"Value is object?"} + G -->|Yes| I{"Nilai adalah objek?"} I -->|No| J["Log warning, skip"] I -->|Yes| K["Merge clientId, clientSecret,\ntokenUrl, authUrl, refreshUrl"] K --> F H --> F J --> F - F -->|Done| L["PROVIDERS ready with\nmerged credentials"] + F -->|Done| L["PROVIDERS siap dengan\nkredensial yang digabungkan"] E --> L ``` @@ -146,7 +146,7 @@ flowchart TD ### 4.2 Executors (`open-sse/executors/`) -Executors encapsulate **provider-specific logic** using the **Strategy Pattern**. Each executor overrides base methods as needed. +Executor merangkum **logika khusus penyedia** menggunakan **Strategy Pattern**. Setiap executor mengganti metode dasar sesuai kebutuhan. ```mermaid classDiagram @@ -196,32 +196,32 @@ classDiagram BaseExecutor <|-- GithubExecutor ``` -| Executor | Provider | Key Specializations | +| Executor | Penyedia | Spesialisasi Utama | | ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `base.ts` | — | Abstract base: URL building, headers, retry logic, credential refresh | -| `default.ts` | Claude, Gemini, OpenAI, GLM, Kimi, MiniMax | Generic OAuth token refresh for standard providers | -| `antigravity.ts` | Google Cloud Code | Project/session ID generation, multi-URL fallback, custom retry parsing from error messages ("reset after 2h7m23s") | -| `cursor.ts` | Cursor IDE | **Most complex**: SHA-256 checksum auth, Protobuf request encoding, binary EventStream → SSE response parsing | -| `codex.ts` | OpenAI Codex | Injects system instructions, manages thinking levels, removes unsupported parameters | -| `gemini-cli.ts` | Google Gemini CLI | Custom URL building (`streamGenerateContent`), Google OAuth token refresh | -| `github.ts` | GitHub Copilot | Dual token system (GitHub OAuth + Copilot token), VSCode header mimicking | -| `kiro.ts` | AWS CodeWhisperer | AWS EventStream binary parsing, AMZN event frames, token estimation | -| `index.ts` | — | Factory: maps provider name → executor class, with default fallback | +| `base.ts` | — | Basis abstrak: pembangunan URL, header, logika percobaan ulang, pembaruan kredensial | +| `default.ts` | Claude, Gemini, OpenAI, GLM, Kimi, MiniMax | Pembaruan token OAuth generik untuk penyedia standar | +| `antigravity.ts` | Google Cloud Code | Pembuatan ID proyek/sesi, fallback multi-URL, parsing percobaan ulang kustom dari pesan error ("reset after 2h7m23s") | +| `cursor.ts` | Cursor IDE | **Paling kompleks**: autentikasi checksum SHA-256, encoding permintaan Protobuf, parsing binary EventStream → respons SSE | +| `codex.ts` | OpenAI Codex | Menyuntikkan instruksi sistem, mengelola tingkat berpikir, menghapus parameter yang tidak didukung | +| `gemini-cli.ts` | Google Gemini CLI | Pembangunan URL kustom (`streamGenerateContent`), pembaruan token OAuth Google | +| `github.ts` | GitHub Copilot | Sistem token ganda (GitHub OAuth + token Copilot), peniruan header VSCode | +| `kiro.ts` | AWS CodeWhisperer | Parsing binary AWS EventStream, frame event AMZN, estimasi token | +| `index.ts` | — | Factory: memetakan nama penyedia → kelas executor, dengan fallback default | --- ### 4.3 Handlers (`open-sse/handlers/`) -The **orchestration layer** — coordinates translation, execution, streaming, and error handling. +**Lapisan orkestrasi** — mengoordinasikan terjemahan, eksekusi, streaming, dan penanganan error. -| File | Purpose | +| File | Tujuan | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `chatCore.ts` | **Central orchestrator** (~600 lines). Handles the complete request lifecycle: format detection → translation → executor dispatch → streaming/non-streaming response → token refresh → error handling → usage logging. | -| `responsesHandler.ts` | Adapter for OpenAI's Responses API: converts Responses format → Chat Completions → sends to `chatCore` → converts SSE back to Responses format. | -| `embeddings.ts` | Embedding generation handler: resolves embedding model → provider, dispatches to provider API, returns OpenAI-compatible embedding response. Supports 6+ providers. | -| `imageGeneration.ts` | Image generation handler: resolves image model → provider, supports OpenAI-compatible, Gemini-image (Antigravity), and fallback (Nebius) modes. Returns base64 or URL images. | +| `chatCore.ts` | **Orkestrator pusat** (~600 baris). Menangani siklus hidup permintaan secara lengkap: deteksi format → terjemahan → dispatch executor → respons streaming/non-streaming → pembaruan token → penanganan error → pencatatan penggunaan. | +| `responsesHandler.ts` | Adaptor untuk Responses API OpenAI: mengonversi format Responses → Penyelesaian Obrolan → mengirim ke `chatCore` → mengonversi SSE kembali ke format Responses. | +| `embeddings.ts` | Handler pembuatan embedding: me-resolve model embedding → penyedia, mengirim ke API penyedia, mengembalikan respons embedding yang kompatibel dengan OpenAI. Mendukung 6+ penyedia. | +| `imageGeneration.ts` | Handler pembuatan gambar: me-resolve model gambar → penyedia, mendukung mode kompatibel-OpenAI, Gemini-image (Antigravity), dan fallback (Nebius). Mengembalikan gambar base64 atau URL. | -#### Request Lifecycle (chatCore.ts) +#### Siklus Hidup Permintaan (chatCore.ts) ```mermaid sequenceDiagram @@ -262,26 +262,26 @@ sequenceDiagram ### 4.4 Services (`open-sse/services/`) -Business logic that supports the handlers and executors. +Logika bisnis yang mendukung handler dan executor. -| File | Purpose | +| File | Tujuan | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `provider.ts` | **Format detection** (`detectFormat`): analyzes request body structure to identify Claude/OpenAI/Gemini/Antigravity/Responses formats (includes `max_tokens` heuristic for Claude). Also: URL building, header building, thinking config normalization. Supports `openai-compatible-*` and `anthropic-compatible-*` dynamic providers. | -| `model.ts` | Model string parsing (`claude/model-name` → `{provider: "claude", model: "model-name"}`), alias resolution with collision detection, input sanitization (rejects path traversal/control chars), and model info resolution with async alias getter support. | -| `accountFallback.ts` | Rate-limit handling: exponential backoff (1s → 2s → 4s → max 2min), account cooldown management, error classification (which errors trigger fallback vs. not). | -| `tokenRefresh.ts` | OAuth token refresh for **every provider**: Google (Gemini, Antigravity), Claude, Codex, Qwen, Qoder, GitHub (OAuth + Copilot dual-token), Kiro (AWS SSO OIDC + Social Auth). Includes in-flight promise deduplication cache and retry with exponential backoff. | -| `combo.ts` | **Combo models**: chains of fallback models. If model A fails with a fallback-eligible error, try model B, then C, etc. Returns actual upstream status codes. | -| `usage.ts` | Fetches quota/usage data from provider APIs (GitHub Copilot quotas, Antigravity model quotas, Codex rate limits, Kiro usage breakdowns, Claude settings). | -| `accountSelector.ts` | Smart account selection with scoring algorithm: considers priority, health status, round-robin position, and cooldown state to pick the optimal account for each request. | -| `contextManager.ts` | Request context lifecycle management: creates and tracks per-request context objects with metadata (request ID, timestamps, provider info) for debugging and logging. | -| `ipFilter.ts` | IP-based access control: supports allowlist and blocklist modes. Validates client IP against configured rules before processing API requests. | -| `sessionManager.ts` | Session tracking with client fingerprinting: tracks active sessions using hashed client identifiers, monitors request counts, and provides session metrics. | -| `signatureCache.ts` | Request signature-based deduplication cache: prevents duplicate requests by caching recent request signatures and returning cached responses for identical requests within a time window. | -| `systemPrompt.ts` | Global system prompt injection: prepends or appends a configurable system prompt to all requests, with per-provider compatibility handling. | -| `thinkingBudget.ts` | Reasoning token budget management: supports passthrough, auto (strip thinking config), custom (fixed budget), and adaptive (complexity-scaled) modes for controlling thinking/reasoning tokens. | -| `wildcardRouter.ts` | Wildcard model pattern routing: resolves wildcard patterns (e.g., `*/claude-*`) to concrete provider/model pairs based on availability and priority. | +| `provider.ts` | **Deteksi format** (`detectFormat`): menganalisis struktur body permintaan untuk mengidentifikasi format Claude/OpenAI/Gemini/Antigravity/Responses (mencakup heuristik `max_tokens` untuk Claude). Juga: pembangunan URL, pembangunan header, normalisasi konfigurasi berpikir. Mendukung penyedia dinamis `openai-compatible-*` dan `anthropic-compatible-*`. | +| `model.ts` | Parsing string model (`claude/model-name` → `{provider: "claude", model: "model-name"}`), resolusi alias dengan deteksi tabrakan, sanitasi input (menolak path traversal/karakter kontrol), dan resolusi info model dengan dukungan getter alias asinkron. | +| `accountFallback.ts` | Penanganan rate-limit: backoff eksponensial (1d → 2d → 4d → maks 2min), manajemen cooldown akun, klasifikasi error (error mana yang memicu fallback vs. tidak). | +| `tokenRefresh.ts` | Pembaruan token OAuth untuk **setiap penyedia**: Google (Gemini, Antigravity), Claude, Codex, Qwen, Qoder, GitHub (OAuth + token ganda Copilot), Kiro (AWS SSO OIDC + Social Auth). Mencakup cache deduplikasi promise in-flight dan percobaan ulang dengan backoff eksponensial. | +| `combo.ts` | **Model combo**: rantai model fallback. Jika model A gagal dengan error yang memenuhi syarat fallback, coba model B, lalu C, dst. Mengembalikan kode status upstream yang sebenarnya. | +| `usage.ts` | Mengambil data kuota/penggunaan dari API penyedia (kuota GitHub Copilot, kuota model Antigravity, batas laju Codex, rincian penggunaan Kiro, pengaturan Claude). | +| `accountSelector.ts` | Pemilihan akun cerdas dengan algoritma penilaian: mempertimbangkan prioritas, status kesehatan, posisi round-robin, dan kondisi cooldown untuk memilih akun optimal setiap permintaan. | +| `contextManager.ts` | Manajemen siklus hidup konteks permintaan: membuat dan melacak objek konteks per-permintaan dengan metadata (ID permintaan, stempel waktu, info penyedia) untuk debugging dan pencatatan. | +| `ipFilter.ts` | Kontrol akses berbasis IP: mendukung mode allowlist dan blocklist. Memvalidasi IP klien terhadap aturan yang dikonfigurasi sebelum memproses permintaan API. | +| `sessionManager.ts` | Pelacakan sesi dengan fingerprinting klien: melacak sesi aktif menggunakan identifier klien yang di-hash, memantau jumlah permintaan, dan menyediakan metrik sesi. | +| `signatureCache.ts` | Cache deduplikasi berbasis tanda tangan permintaan: mencegah permintaan duplikat dengan menyimpan cache tanda tangan permintaan terbaru dan mengembalikan respons tersimpan untuk permintaan identik dalam jendela waktu tertentu. | +| `systemPrompt.ts` | Injeksi system prompt global: menambahkan di depan atau di belakang system prompt yang dapat dikonfigurasi ke semua permintaan, dengan penanganan kompatibilitas per-penyedia. | +| `thinkingBudget.ts` | Manajemen anggaran token penalaran: mendukung mode passthrough, auto (hapus konfigurasi berpikir), kustom (anggaran tetap), dan adaptif (skala kompleksitas) untuk mengendalikan token berpikir/penalaran. | +| `wildcardRouter.ts` | Routing pola model wildcard: me-resolve pola wildcard (mis., `*/claude-*`) ke pasangan penyedia/model konkret berdasarkan ketersediaan dan prioritas. | -#### Token Refresh Deduplication +#### Deduplikasi Pembaruan Token ```mermaid sequenceDiagram @@ -302,7 +302,7 @@ sequenceDiagram Cache->>Cache: Delete cache entry ``` -#### Account Fallback State Machine +#### Mesin Status Fallback Akun ```mermaid stateDiagram-v2 @@ -327,7 +327,7 @@ stateDiagram-v2 } ``` -#### Combo Model Chain +#### Model Rantai Kombo ```mermaid flowchart LR @@ -348,7 +348,7 @@ flowchart LR ### 4.5 Translator (`open-sse/translator/`) -The **format translation engine** using a self-registering plugin system. +**Mesin terjemahan format** yang menggunakan sistem plugin pendaftaran-diri. #### Arsitektur @@ -376,40 +376,40 @@ graph TD end ``` -| Directory | Files | Description | +| Direktori | File | Deskripsi | | ------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `request/` | 8 translators | Convert request bodies between formats. Each file self-registers via `register(from, to, fn)` on import. | -| `response/` | 7 translators | Convert streaming response chunks between formats. Handles SSE event types, thinking blocks, tool calls. | -| `helpers/` | 6 helpers | Shared utilities: `claudeHelper` (system prompt extraction, thinking config), `geminiHelper` (parts/contents mapping), `openaiHelper` (format filtering), `toolCallHelper` (ID generation, missing response injection), `maxTokensHelper`, `responsesApiHelper`. | -| `index.ts` | — | Translation engine: `translateRequest()`, `translateResponse()`, state management, registry. | -| `formats.ts` | — | Format constants: `OPENAI`, `CLAUDE`, `GEMINI`, `ANTIGRAVITY`, `KIRO`, `CURSOR`, `OPENAI_RESPONSES`. | +| `request/` | 8 penerjemah | Mengonversi body permintaan antar format. Setiap file mendaftar sendiri melalui `register(from, to, fn)` saat diimpor. | +| `response/` | 7 penerjemah | Mengonversi potongan respon streaming antar format. berpartisipasi tipe acara SSE, blok berpikir, panggilan alat. | +| `helpers/` | 6 pembantu | Utilitas bersama: `claudeHelper` (ekstraksi system prompt, konfigurasi berpikir), `geminiHelper` (pemetaan parts/contents), `openaiHelper` (pemfilteran format), `toolCallHelper` (pembuatan ID, injeksi respons yang hilang), `maxTokensHelper`, `responsesApiHelper`. | +| `index.ts` | — | Mesin terjemahan: `translateRequest()`, `translateResponse()`, manajemen state, registry. | +| `formats.ts` | — | Konstanta format: `OPENAI`, `CLAUDE`, `GEMINI`, `ANTIGRAVITY`, `KIRO`, `CURSOR`, `OPENAI_RESPONSES`. | -#### Key Design: Self-Registering Plugins +#### Desain Utama: Plugin Pendaftaran-Diri ```javascript -// Each translator file calls register() on import: +// Setiap file penerjemah memanggil register() saat diimpor: import { register } from "../index.js"; register("claude", "openai", translateClaudeToOpenAI); -// The index.js imports all translator files, triggering registration: -import "./request/claude-to-openai.js"; // ← self-registers +// index.js mengimpor semua file penerjemah, memicu pendaftaran: +import "./request/claude-to-openai.js"; // ← mendaftar sendiri ``` --- ### 4.6 Utils (`open-sse/utils/`) -| File | Purpose | +| File | Tujuan | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `error.ts` | Error response building (OpenAI-compatible format), upstream error parsing, Antigravity retry-time extraction from error messages, SSE error streaming. | -| `stream.ts` | **SSE Transform Stream** — the core streaming pipeline. Two modes: `TRANSLATE` (full format translation) and `PASSTHROUGH` (normalize + extract usage). Handles chunk buffering, usage estimation, content length tracking. Per-stream encoder/decoder instances avoid shared state. | -| `streamHelpers.ts` | Low-level SSE utilities: `parseSSELine` (whitespace-tolerant), `hasValuableContent` (filters empty chunks for OpenAI/Claude/Gemini), `fixInvalidId`, `formatSSE` (format-aware SSE serialization with `perf_metrics` cleanup). | -| `usageTracking.ts` | Token usage extraction from any format (Claude/OpenAI/Gemini/Responses), estimation with separate tool/message char-per-token ratios, buffer addition (2000 tokens safety margin), format-specific field filtering, console logging with ANSI colors. | -| `requestLogger.ts` | Legacy file-based request logging helper kept for compatibility. Current deployments should prefer `APP_LOG_TO_FILE` for application logs and the call log pipeline for persisted request artifacts. | -| `bypassHandler.ts` | Intercepts specific patterns from Claude CLI (title extraction, warmup, count) and returns fake responses without calling any provider. Supports both streaming and non-streaming. Intentionally limited to Claude CLI scope. | -| `networkProxy.ts` | Resolves outbound proxy URL for a given provider with precedence: provider-specific config → global config → environment variables (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`). Supports `NO_PROXY` exclusions. Caches config for 30s. | +| `error.ts` | Pembangunan respons error (format kompatibel-OpenAI), parsing error upstream, ekstraksi waktu percobaan ulang Antigravity dari pesan error, streaming error SSE. | +| `stream.ts` | **SSE Transform Stream** — inti streaming saluran pipa. Mode dua: `TRANSLATE` (terjemahan format penuh) dan `PASSTHROUGH` (normalisasi + penggunaan ekstraksi). menyertakan buffering potongan, estimasi penggunaan, pelacakan panjang konten. Encoder/decoder instance per-stream menghindari status bersama. | +| `streamHelpers.ts` | Utilitas SSE tingkat rendah: `parseSSELine` (toleran terhadap spasi), `hasValuableContent` (menyaring potongan kosong untuk OpenAI/Claude/Gemini), `fixInvalidId`, `formatSSE` (serialisasi SSE yang peka format dengan pembersihan `perf_metrics`). | +| `usageTracking.ts` | Ekstraksi penggunaan token dari format apa pun (Claude/OpenAI/Gemini/Responses), estimasi dengan rasio karakter-per-token terpisah untuk tool/pesan, penambahan buffer (margin keamanan 2000 token), pemfilteran field spesifik-format, pencatatan konsol dengan warna ANSI. | +| `requestLogger.ts` | Pembantu pencatatan permintaan berbasis file lawas yang dipertahankan untuk kompatibilitas. Deployment saat ini sebaiknya menggunakan `APP_LOG_TO_FILE` untuk log aplikasi dan pipeline log panggilan untuk artefak permintaan yang dipersistensikan. | +| `bypassHandler.ts` | Mengintersep pola tertentu dari Claude CLI (ekstraksi judul, pemanasan, penghitungan) dan mengembalikan respons palsu tanpa memanggil penyedia apa pun. Mendukung streaming maupun non-streaming. Sengaja dibatasi hanya untuk cakupan Claude CLI. | +| `networkProxy.ts` | Me-resolve URL proxy keluar untuk penyedia tertentu dengan urutan prioritas: konfigurasi spesifik-penyedia → konfigurasi global → variabel lingkungan (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`). Mendukung pengecualian `NO_PROXY`. Menyimpan cache konfigurasi selama 30d. | -#### SSE Streaming Pipeline +#### SSE Streaming Saluran Pipa ```mermaid flowchart TD @@ -431,127 +431,127 @@ flowchart TD style M fill:#9f9,stroke:#333 ``` -#### Request Logger Session Structure +#### Struktur Sesi Permintaan Logger ``` logs/ └── claude_gemini_claude-sonnet_20260208_143045/ - ├── 1_req_client.json ← Raw client request - ├── 2_req_source.json ← After initial conversion - ├── 3_req_openai.json ← OpenAI intermediate format - ├── 4_req_target.json ← Final target format - ├── 5_res_provider.txt ← Provider SSE chunks (streaming) - ├── 5_res_provider.json ← Provider response (non-streaming) - ├── 6_res_openai.txt ← OpenAI intermediate chunks - ├── 7_res_client.txt ← Client-facing SSE chunks - └── 6_error.json ← Error details (if any) + ├── 1_req_client.json ← Permintaan klien mentah + ├── 2_req_source.json ← Setelah konversi awal + ├── 3_req_openai.json ← Format perantara OpenAI + ├── 4_req_target.json ← Format target akhir + ├── 5_res_provider.txt ← Potongan SSE penyedia (streaming) + ├── 5_res_provider.json ← Respons penyedia (non-streaming) + ├── 6_res_openai.txt ← Potongan perantara OpenAI + ├── 7_res_client.txt ← Potongan SSE yang menghadap klien + └── 6_error.json ← Detail error (jika ada) ``` --- -### 4.7 Application Layer (`src/`) +### 4.7 Lapisan Aplikasi (`src/`) -| Directory | Purpose | -| ------------- | ---------------------------------------------------------------------- | -| `src/app/` | Web UI, API routes, Express middleware, OAuth callback handlers | -| `src/lib/` | Database access (`localDb.ts`, `usageDb.ts`), authentication, shared | -| `src/mitm/` | Man-in-the-middle proxy utilities for intercepting provider traffic | -| `src/models/` | Database model definitions | -| `src/shared/` | Wrappers around open-sse functions (provider, stream, error, etc.) | -| `src/sse/` | SSE endpoint handlers that wire the open-sse library to Express routes | -| `src/store/` | Application state management | +| Direktori | Tujuan | +| ------------- | ------------------------------------------------------------------------------- | +| `src/app/` | Antarmuka web, rute API, middleware Express, handler callback OAuth | +| `src/lib/` | Akses database (`localDb.ts`, `usageDb.ts`), autentikasi, kode bersama | +| `src/mitm/` | Utilitas proxy man-in-the-middle untuk mengintersep lalu lintas penyedia | +| `src/models/` | Definisi model basis data | +| `src/shared/` | Wrapper fungsi open-sse (penyedia, stream, error, dll.) | +| `src/sse/` | Handler endpoint SSE yang menghubungkan library open-sse ke rute Express | +| `src/store/` | Manajemen state aplikasi | -#### Notable API Routes +#### Rute API Penting -| Route | Methods | Purpose | -| --------------------------------------------- | --------------- | ------------------------------------------------------------------------------------- | -| `/api/provider-models` | GET/POST/DELETE | CRUD for custom models per provider | -| `/api/models/catalog` | GET | Aggregated catalog of all models (chat, embedding, image, custom) grouped by provider | -| `/api/settings/proxy` | GET/PUT/DELETE | Hierarchical outbound proxy configuration (`global/providers/combos/keys`) | -| `/api/settings/proxy/test` | POST | Validates proxy connectivity and returns public IP/latency | -| `/v1/providers/[provider]/chat/completions` | POST | Dedicated per-provider chat completions with model validation | -| `/v1/providers/[provider]/embeddings` | POST | Dedicated per-provider embeddings with model validation | -| `/v1/providers/[provider]/images/generations` | POST | Dedicated per-provider image generation with model validation | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist management | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget configuration (passthrough/auto/custom/adaptive) | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt injection for all requests | -| `/api/sessions` | GET | Active session tracking and metrics | -| `/api/rate-limits` | GET | Per-account rate limit status | +| Rute | Metode | Tujuan | +| --------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------- | +| `/api/provider-models` | GET/POST/DELETE | CRUD untuk model kustom per penyedia | +| `/api/models/catalog` | GET | Katalog gabungan semua model (chat, embedding, gambar, kustom) yang dikelompokkan per penyedia | +| `/api/settings/proxy` | GET/PUT/DELETE | Konfigurasi proxy keluar hierarkis (`global/providers/combos/keys`) | +| `/api/settings/proxy/test` | POST | Memvalidasi konektivitas proxy dan mengembalikan IP publik/latensi | +| `/v1/providers/[provider]/chat/completions` | POST | Chat completions khusus per-penyedia dengan validasi model | +| `/v1/providers/[provider]/embeddings` | POST | Embedding khusus per-penyedia dengan validasi model | +| `/v1/providers/[provider]/images/generations` | POST | Pembuatan gambar khusus per-penyedia dengan validasi model | +| `/api/settings/ip-filter` | GET/PUT | Manajemen allowlist/blocklist IP | +| `/api/settings/thinking-budget` | GET/PUT | Konfigurasi anggaran token penalaran (passthrough/auto/custom/adaptive) | +| `/api/settings/system-prompt` | GET/PUT | Injeksi system prompt global untuk semua permintaan | +| `/api/sessions` | GET | Pelacakan sesi aktif dan metrik | +| `/api/rate-limits` | GET | Status batas laju per-akun | --- -## 5. Key Design Patterns +## 5. Pola Desain Utama -### 5.1 Hub-and-Spoke Translation +### 5.1 Terjemahan Hub-and-Spoke -All formats translate through **OpenAI format as the hub**. Adding a new provider only requires writing **one pair** of translators (to/from OpenAI), not N pairs. +Semua format diterjemahkan melalui **format OpenAI sebagai hub**. Menambahkan penyedia baru hanya membutuhkan penulisan **satu pasang** penerjemah (ke/dari OpenAI), bukan N pasangan. -### 5.2 Executor Strategy Pattern +### 5.2 Strategy Pattern pada Executor -Each provider has a dedicated executor class inheriting from `BaseExecutor`. The factory in `executors/index.ts` selects the right one at runtime. +Setiap penyedia memiliki kelas executor khusus yang mewarisi dari `BaseExecutor`. Factory di `executors/index.ts` memilih yang tepat saat runtime. -### 5.3 Self-Registering Plugin System +### 5.3 Sistem Plugin Pendaftaran-Diri -Translator modules register themselves on import via `register()`. Adding a new translator is just creating a file and importing it. +Modul penerjemah mendaftarkan diri saat diimpor melalui `register()`. Menambahkan penerjemah baru cukup dengan membuat file dan mengimpornya. -### 5.4 Account Fallback with Exponential Backoff +### 5.4 Fallback Akun dengan Backoff Eksponensial -When a provider returns 429/401/500, the system can switch to the next account, applying exponential cooldowns (1s → 2s → 4s → max 2min). +Ketika penyedia mengembalikan 429/401/500, sistem dapat beralih ke akun berikutnya, menerapkan cooldown eksponensial (1d → 2d → 4d → maks 2min). -### 5.5 Combo Model Chains +### 5.5 Model Rantai Kombo -A "combo" groups multiple `provider/model` strings. If the first fails, fallback to the next automatically. +Sebuah "combo" mengelompokkan beberapa string `provider/model`. Jika yang pertama gagal, otomatis beralih ke berikutnya. -### 5.6 Stateful Streaming Translation +### 5.6 Terjemahan Streaming dengan State -Response translation maintains state across SSE chunks (thinking block tracking, tool call accumulation, content block indexing) via the `initState()` mechanism. +Terjemahan respons mempertahankan state di seluruh potongan SSE (pelacakan blok berpikir, akumulasi tool call, pengindeksan blok konten) melalui mekanisme `initState()`. -### 5.7 Usage Safety Buffer +### 5.7 Buffer Keamanan Penggunaan -A 2000-token buffer is added to reported usage to prevent clients from hitting context window limits due to overhead from system prompts and format translation. +Buffer 2000 token ditambahkan ke penggunaan yang dilaporkan untuk mencegah klien mencapai batas jendela konteks akibat overhead dari system prompt dan terjemahan format. --- -## 6. Supported Formats +## 6. Format yang Didukung -| Format | Direction | Identifier | +| Format | Arah | Identifier | | ----------------------- | --------------- | ------------------ | -| OpenAI Chat Completions | source + target | `openai` | -| OpenAI Responses API | source + target | `openai-responses` | -| Anthropic Claude | source + target | `claude` | -| Google Gemini | source + target | `gemini` | -| Google Gemini CLI | target only | `gemini-cli` | -| Antigravity | source + target | `antigravity` | -| AWS Kiro | target only | `kiro` | -| Cursor | target only | `cursor` | +| OpenAI Chat Completions | sumber + target | `openai` | +| API Respons OpenAI | sumber + target | `openai-responses` | +| Anthropic Claude | sumber + target | `claude` | +| Google Gemini | sumber + target | `gemini` | +| Google Gemini CLI | target saja | `gemini-cli` | +| Antigravity | sumber + target | `antigravity` | +| AWS Kiro | target saja | `kiro` | +| Cursor | target saja | `cursor` | --- -## 7. Supported Providers +## 7. Penyedia yang Didukung -| Provider | Auth Method | Executor | Key Notes | -| ------------------------ | ---------------------- | ----------- | --------------------------------------------- | -| Anthropic Claude | API key or OAuth | Default | Uses `x-api-key` header | -| Google Gemini | API key or OAuth | Default | Uses `x-goog-api-key` header | -| Google Gemini CLI | OAuth | GeminiCLI | Uses `streamGenerateContent` endpoint | -| Antigravity | OAuth | Antigravity | Multi-URL fallback, custom retry parsing | -| OpenAI | API key | Default | Standard Bearer auth | -| Codex | OAuth | Codex | Injects system instructions, manages thinking | -| GitHub Copilot | OAuth + Copilot token | Github | Dual token, VSCode header mimicking | -| Kiro (AWS) | AWS SSO OIDC or Social | Kiro | Binary EventStream parsing | -| Cursor IDE | Checksum auth | Cursor | Protobuf encoding, SHA-256 checksums | -| Qwen | OAuth | Default | Standard auth | -| Qoder | OAuth (Basic + Bearer) | Default | Dual auth header | -| OpenRouter | API key | Default | Standard Bearer auth | -| GLM, Kimi, MiniMax | API key | Default | Claude-compatible, use `x-api-key` | -| `openai-compatible-*` | API key | Default | Dynamic: any OpenAI-compatible endpoint | -| `anthropic-compatible-*` | API key | Default | Dynamic: any Claude-compatible endpoint | +| Penyedia | Metode Autentikasi | Executor | Catatan Utama | +| ------------------------ | ---------------------- | ----------- | ----------------------------------------------------- | +| Anthropic Claude | Kunci API atau OAuth | Default | Menggunakan header `x-api-key` | +| Google Gemini | Kunci API atau OAuth | Default | Menggunakan header `x-goog-api-key` | +| Google Gemini CLI | OAuth | GeminiCLI | Menggunakan endpoint `streamGenerateContent` | +| Antigravity | OAuth | Antigravity | Penggantian multi-URL, penguraian percobaan ulang kustom | +| OpenAI | API key | Default | Autentikasi Bearer standar | +| Codex | OAuth | Codex | Menyuntikkan instruksi sistem, mengelola berpikir | +| GitHub Copilot | OAuth + token Copilot | Github | Token ganda, peniruan header VSCode | +| Kiro (AWS) | AWS SSO OIDC atau Social | Kiro | Parsing binary EventStream | +| Cursor IDE | Autentikasi checksum | Cursor | Encoding Protobuf, checksum SHA-256 | +| Qwen | OAuth | Default | Autentikasi standar | +| Qoder | OAuth (Basic + Bearer) | Default | Autentikasi header ganda| +| OpenRouter | API key | Default | Autentikasi Bearer standar | +| GLM, Kimi, MiniMax | API key | Default | Kompatibel-Claude, menggunakan `x-api-key` | +| `openai-compatible-*` | API key | Default |Dinamis: endpoint kompatibel-OpenAI apa pun | +| `anthropic-compatible-*` | API key | Default | Dinamis: endpoint kompatibel-Claude apa pun | --- -## 8. Data Flow Summary +## 8. Ringkasan Alur Data -### Streaming Request +### Permintaan Streaming ```mermaid flowchart LR @@ -568,7 +568,7 @@ flowchart LR K --> L["logUsage()\nsaveRequestUsage()"] ``` -### Non-Streaming Request +### Permintaan Non-Streaming ```mermaid flowchart LR @@ -579,12 +579,12 @@ flowchart LR E --> F["Return JSON\nresponse"] ``` -### Bypass Flow (Claude CLI) +### Alur Bypass (Claude CLI) ```mermaid flowchart LR A["Claude CLI request"] --> B{"Match bypass\npattern?"} - B -->|"Title/Warmup/Count"| C["Generate fake\nOpenAI response"] + B -->|"Title/Warmup/Count"| C["Buat respons palsu\nOpenAI"] B -->|"No match"| D["Normal flow"] C --> E["Translate to\nsource format"] E --> F["Return without\ncalling provider"] diff --git a/docs/i18n/id/docs/cloudflare-zero-trust-guide.md b/docs/i18n/id/docs/cloudflare-zero-trust-guide.md index ff8b7f34ce..5ddfa7e4c9 100644 --- a/docs/i18n/id/docs/cloudflare-zero-trust-guide.md +++ b/docs/i18n/id/docs/cloudflare-zero-trust-guide.md @@ -1,106 +1,106 @@ -# Guia Completo: Cloudflare Tunnel & Zero Trust (Split-Port) (Bahasa Indonesia) +# Panduan Lengkap: Cloudflare Tunnel & Zero Trust (Split-Port) (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/cloudflare-zero-trust-guide.md) · 🇪🇸 [es](../../es/docs/cloudflare-zero-trust-guide.md) · 🇫🇷 [fr](../../fr/docs/cloudflare-zero-trust-guide.md) · 🇩🇪 [de](../../de/docs/cloudflare-zero-trust-guide.md) · 🇮🇹 [it](../../it/docs/cloudflare-zero-trust-guide.md) · 🇷🇺 [ru](../../ru/docs/cloudflare-zero-trust-guide.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/cloudflare-zero-trust-guide.md) · 🇯🇵 [ja](../../ja/docs/cloudflare-zero-trust-guide.md) · 🇰🇷 [ko](../../ko/docs/cloudflare-zero-trust-guide.md) · 🇸🇦 [ar](../../ar/docs/cloudflare-zero-trust-guide.md) · 🇮🇳 [hi](../../hi/docs/cloudflare-zero-trust-guide.md) · 🇮🇳 [in](../../in/docs/cloudflare-zero-trust-guide.md) · 🇹🇭 [th](../../th/docs/cloudflare-zero-trust-guide.md) · 🇻🇳 [vi](../../vi/docs/cloudflare-zero-trust-guide.md) · 🇮🇩 [id](../../id/docs/cloudflare-zero-trust-guide.md) · 🇲🇾 [ms](../../ms/docs/cloudflare-zero-trust-guide.md) · 🇳🇱 [nl](../../nl/docs/cloudflare-zero-trust-guide.md) · 🇵🇱 [pl](../../pl/docs/cloudflare-zero-trust-guide.md) · 🇸🇪 [sv](../../sv/docs/cloudflare-zero-trust-guide.md) · 🇳🇴 [no](../../no/docs/cloudflare-zero-trust-guide.md) · 🇩🇰 [da](../../da/docs/cloudflare-zero-trust-guide.md) · 🇫🇮 [fi](../../fi/docs/cloudflare-zero-trust-guide.md) · 🇵🇹 [pt](../../pt/docs/cloudflare-zero-trust-guide.md) · 🇷🇴 [ro](../../ro/docs/cloudflare-zero-trust-guide.md) · 🇭🇺 [hu](../../hu/docs/cloudflare-zero-trust-guide.md) · 🇧🇬 [bg](../../bg/docs/cloudflare-zero-trust-guide.md) · 🇸🇰 [sk](../../sk/docs/cloudflare-zero-trust-guide.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/cloudflare-zero-trust-guide.md) · 🇮🇱 [he](../../he/docs/cloudflare-zero-trust-guide.md) · 🇵🇭 [phi](../../phi/docs/cloudflare-zero-trust-guide.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/cloudflare-zero-trust-guide.md) · 🇨🇿 [cs](../../cs/docs/cloudflare-zero-trust-guide.md) · 🇹🇷 [tr](../../tr/docs/cloudflare-zero-trust-guide.md) --- -Este guia documenta o padrão ouro de infraestrutura de rede para proteger o **OmniRoute** e expor sua aplicação de forma segura para a internet, **sem abrir nenhuma porta (Zero Inbound)**. +Panduan ini mendokumentasikan standar infrastruktur jaringan terbaik untuk mengamankan **OmniRoute** dan mengekspos aplikasi Anda ke internet secara aman, **tanpa membuka satu pun port (Zero Inbound)**. -## O que foi feito na sua VM? +## Apa yang Telah Dilakukan pada VM Anda? -Nós ativamos o OmniRoute em modo **Split-Port** através do PM2: +Kami mengaktifkan OmniRoute dalam mode **Split-Port** melalui PM2: -- **Porta \`20128\`:** Roda **apenas a API** `/v1`. -- **Porta \`20129\`:** Roda **apenas o Dashboard** Administrativo visual. +- **Port \`20128\`:** Menjalankan **hanya API** `/v1`. +- **Port \`20129\`:** Menjalankan **hanya Dashboard** Administratif visual. -Além disso, o serviço interno exige \`REQUIRE_API_KEY=true\`, o que significa que nenhum agente pode consumir os endpoints da API sem enviar um "Bearer Token" legítimo gerado na aba API Keys do Painel. +Selain itu, layanan internal memerlukan `REQUIRE_API_KEY=true`, yang berarti tidak ada agen yang dapat menggunakan endpoint API tanpa mengirimkan "Bearer Token" yang sah yang dihasilkan dari tab API Keys di Panel. -Isso nos permite criar duas regras completamente independentes na rede. É aqui que entra o **Cloudflare Tunnel (cloudflared)**. +Hal ini memungkinkan kita membuat dua aturan yang sepenuhnya independen di jaringan. Di sinilah peran **Cloudflare Tunnel (cloudflared)**. --- -## 1. Como Criar o Túnel na Cloudflare +## 1. Cara Membuat Terowongan di Cloudflare -O utilitário \`cloudflared\` já está instalado na sua máquina. Siga os passos na nuvem: +Utilitas `cloudflared` sudah terpasang di mesin Anda. Ikuti langkah-langkah berikut di cloud: -1. Acesse seu painel **Cloudflare Zero Trust** (One.dash.cloudflare.com). -2. No menu à esquerda, vá em **Networks > Tunnels**. -3. Clique em **Add a Tunnel**, escolha **Cloudflared** e dê o nome \`OmniRoute-VM\`. -4. Ele vai gerar um comando na tela chamado "Install and run a connector". **Você só precisa copiar o Token (a string longa após `--token`)**. -5. Logue via SSH na sua máquina virtual (ou Terminal do Proxmox) e execute: +1. Akses panel **Cloudflare Zero Trust** Anda (One.dash.cloudflare.com). +2. Di menu sebelah kiri, pergi ke **Networks > Tunnels**. +3. Klik **Add a Tunnel**, pilih **Cloudflared**, dan beri nama `OmniRoute-VM`. +4. Sistem akan menghasilkan perintah di layar bernama "Install and run a connector". **Anda hanya perlu menyalin Token (string panjang setelah `--token`)**. +5. Masuk melalui SSH ke mesin virtual Anda (atau Terminal Proxmox) dan jalankan: \`\`\`bash - # Inicia e amarra o túnel permanentemente à sua conta - cloudflared service install SEU_TOKEN_GIGANTE_AQUI + # Memulai dan mengikat terowongan secara permanen ke akun Anda + cloudflared service install TOKEN_PANJANG_ANDA_DI_SINI \`\`\` --- -## 2. Configurando o Roteamento (Public Hostnames) +## 2. Mengonfigurasi Perutean (Public Hostnames) -Ainda na tela do Tunnel recém-criado, vá para a aba **Public Hostnames** e adicione as **duas** rotas, aproveitando a separação que fizemos: +Masih di layar Tunnel yang baru dibuat, buka tab **Public Hostnames** dan tambahkan **dua** rute, memanfaatkan pemisahan yang telah kita lakukan: -### Rota 1: API Segura (Limitada) +### Rute 1: API Aman (Terbatas) -- **Subdomain:** \`api\` -- **Domain:** \`seuglobal.com.br\` (escolha seu domínio real) -- **Service Type:** \`HTTP\` -- **URL:** \`127.0.0.1:20128\` _(Porta interna da API)_ +- **Subdomain:** `api` +- **Domain:** `domainanda.com` (pilih domain nyata Anda) +- **Service Type:** `HTTP` +- **URL:** `127.0.0.1:20128` _(Port internal API)_ -### Rota 2: Painel Zero Trust (Fechado) +### Rute 2: Panel Zero Trust (Tertutup) -- **Subdomain:** \`omniroute\` ou \`painel\` -- **Domain:** \`seuglobal.com.br\` -- **Service Type:** \`HTTP\` -- **URL:** \`127.0.0.1:20129\` _(Porta interna do App/Visual)_ +- **Subdomain:** `omniroute` atau `panel` +- **Domain:** `domainanda.com` +- **Service Type:** `HTTP` +- **URL:** `127.0.0.1:20129` _(Port internal App/Visual)_ -Neste momento, a conectividade "Física" está resolvida. Agora vamos blindar de verdade. +Pada titik ini, konektivitas "fisik" telah terselesaikan. Sekarang kita akan benar-benar mengamankannya. --- -## 3. Blindando o Painel com Zero Trust (Access) +## 3. Mengamankan Panel dengan Zero Trust (Access) -Nenhuma senha local protege melhor o seu painel do que remover totalmente o acesso a ele da internet aberta. +Tidak ada kata sandi lokal yang lebih baik dalam melindungi panel Anda selain menghapus sepenuhnya akses ke panel tersebut dari internet terbuka. -1. No painel Zero Trust, vá em **Access > Applications > Add an application**. -2. Selecione **Self-hosted**. -3. Em **Application name**, coloque \`Painel OmniRoute\`. -4. Em **Application domain**, coloque \`omniroute.seuglobal.com.br\` (O mesmo que você fez na "Rota 2"). -5. Clique em **Next**. -6. Em **Rule action**, escolha \`Allow\`. Em nome da Rule coloque \`Admin Apenas\`. -7. Em **Include**, no seletor de "Selector" escolha \`Emails\` e digite o seu email, por exemplo \`admin@spgeo.com.br\`. -8. Salve (`Add application`). +1. Di panel Zero Trust, buka **Access > Applications > Add an application**. +2. Pilih **Self-hosted**. +3. Di **Application name**, masukkan `Panel OmniRoute`. +4. Di **Application domain**, masukkan `omniroute.domainanda.com` (sama dengan yang Anda buat di "Rute 2"). +5. Klik **Next**. +6. Di **Rule action**, pilih `Allow`. Beri nama Rule `Admin Saja`. +7. Di **Include**, pada selektor "Selector" pilih `Emails` dan masukkan email Anda, misalnya `admin@domainanda.com`. +8. Simpan (`Add application`). -> **O que isso fez:** Se você tentar abrir \`omniroute.seuglobal.com.br\`, não cai mais na sua aplicação OmniRoute! Cai numa tela elegante da Cloudflare pedindo para digitar seu email. Somente se você (ou o email que você botou) for digitado lá, ele recebe no Outlook/Gmail um código de 6 dígitos temporário que libera o túnel até a porta \`20129\`. +> **Apa yang terjadi:** Jika Anda mencoba membuka `omniroute.domainanda.com`, Anda tidak akan langsung masuk ke aplikasi OmniRoute! Anda akan disambut halaman Cloudflare yang meminta Anda memasukkan email. Hanya jika email yang Anda masukkan cocok, Anda akan menerima kode sementara 6 digit melalui Outlook/Gmail yang membuka akses ke terowongan menuju port `20129`. --- -## 4. Limitando e Protegendo a API com Rate Limit (WAF) +## 4. Membatasi dan Melindungi API dengan Rate Limit (WAF) -O Dashboard do Zero Trust não se aplica à rota da API (\`api.seuglobal.com.br\`), porque é um acesso programático via ferramentas automatizadas (agentes) sem navegador. Para ele, usaremos o Firewall principal (WAF) da Cloudflare. +Dashboard Zero Trust tidak berlaku untuk rute API (`api.domainanda.com`), karena ini adalah akses terprogram melalui alat otomatis (agen) tanpa browser. Untuk ini, kita akan menggunakan Firewall utama (WAF) Cloudflare. -1. Acesse o **Painel Normal** da Cloudflare (dash.cloudflare.com) e entre no seu Domínio. -2. No menu esquerdo, vá em **Security > WAF > Rate limiting rules**. -3. Clique em **Create rule**. -4. **Name:** \`Anti-Abuso OmniRoute API\` +1. Akses **Panel Normal** Cloudflare (dash.cloudflare.com) dan masuk ke Domain Anda. +2. Di menu sebelah kiri, buka **Security > WAF > Rate limiting rules**. +3. Klik **Create rule**. +4. **Name:** `Anti-Penyalahgunaan OmniRoute API` 5. **If incoming requests match...** - - Escolha em Field: \`Hostname\` - - Operator: \`equals\` - - Value: \`api.seuglobal.com.br\` -6. Em **With the same characteristics:** Mantenha \`IP\`. -7. Nos limites (Limit): - - **When requests exceed:** \`50\` - - **Period:** \`1 minute\` -8. No final, em **Action**: \`Block\` (Bloquear) e decida se o bloqueio dura por 1 minuto ou 1 hora. + - Pilih di Field: `Hostname` + - Operator: `equals` + - Value: `api.domainanda.com` +6. Di **With the same characteristics:** Pertahankan `IP`. +7. Pada batas (Limit): + - **When requests exceed:** `50` + - **Period:** `1 minute` +8. Di bagian bawah, pada **Action**: `Block` (Blokir) dan tentukan apakah pemblokiran berlangsung 1 menit atau 1 jam. 9. **Deploy**. -> **O que isso fez:** Ninguém pode mandar mais de 50 requisições num período de 60 segundos na sua URL de API. Como você roda vários agentes e os consumos por trás já batem rate limit e já rastreiam tokens, isso é apenas uma medida na Borda da Internet (Edge Layer) que protege sua Instância On-Premises de cair por estresse térmico antes mesmo do tráfego descer pelo túnel. +> **Apa yang terjadi:** Tidak ada yang dapat mengirim lebih dari 50 permintaan dalam periode 60 detik ke URL API Anda. Karena Anda menjalankan beberapa agen dan konsumsi di belakangnya sudah mencapai batas laju serta melacak token, ini hanyalah langkah pengamanan di lapisan tepi internet (Edge Layer) yang melindungi instans On-Premises Anda dari kelebihan beban bahkan sebelum trafik melewati terowongan. --- -## Finalização +## Penutup -1. A sua VM **não possui nenhuma porta exposta** em `/etc/ufw`. -2. O OmniRoute só conversa HTTPS saindo (\`cloudflared\`) e não recebendo TCP direto do mundo. -3. Seus requets pro OpenAI são ofuscados porque configuramos eles globalmente pra passar em um Proxy SOCKS5 (A nuvem não liga pro SOCKS5 porque ela vem Inbound). -4. Seu painel web tem 2-Factor com Email. -5. Sua API está ratelimitada na borda pela Cloudflare e só trafega Bearer Tokens. +1. VM Anda **tidak memiliki port yang terbuka** di `/etc/ufw`. +2. OmniRoute hanya berkomunikasi melalui HTTPS keluar (`cloudflared`) dan tidak menerima koneksi TCP langsung dari internet. +3. Permintaan Anda ke OpenAI disamarkan karena dikonfigurasi secara global untuk melewati Proxy SOCKS5 (cloud tidak peduli dengan SOCKS5 karena trafik datang secara Inbound). +4. Panel web Anda memiliki autentikasi 2 faktor melalui Email. +5. API Anda dibatasi lajunya di tepi jaringan oleh Cloudflare dan hanya menerima lalu lintas Bearer Token. diff --git a/docs/i18n/id/docs/features/context-relay.md b/docs/i18n/id/docs/features/context-relay.md index a00f14a26c..53ef6bb299 100644 --- a/docs/i18n/id/docs/features/context-relay.md +++ b/docs/i18n/id/docs/features/context-relay.md @@ -1,65 +1,65 @@ -# Context Relay (Bahasa Indonesia) +# Context Relay 🌐 **Languages:** 🇺🇸 [English](../../../../../docs/features/context-relay.md) · 🇪🇸 [es](../../../es/docs/features/context-relay.md) · 🇫🇷 [fr](../../../fr/docs/features/context-relay.md) · 🇩🇪 [de](../../../de/docs/features/context-relay.md) · 🇮🇹 [it](../../../it/docs/features/context-relay.md) · 🇷🇺 [ru](../../../ru/docs/features/context-relay.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/features/context-relay.md) · 🇯🇵 [ja](../../../ja/docs/features/context-relay.md) · 🇰🇷 [ko](../../../ko/docs/features/context-relay.md) · 🇸🇦 [ar](../../../ar/docs/features/context-relay.md) · 🇮🇳 [hi](../../../hi/docs/features/context-relay.md) · 🇮🇳 [in](../../../in/docs/features/context-relay.md) · 🇹🇭 [th](../../../th/docs/features/context-relay.md) · 🇻🇳 [vi](../../../vi/docs/features/context-relay.md) · 🇮🇩 [id](../../../id/docs/features/context-relay.md) · 🇲🇾 [ms](../../../ms/docs/features/context-relay.md) · 🇳🇱 [nl](../../../nl/docs/features/context-relay.md) · 🇵🇱 [pl](../../../pl/docs/features/context-relay.md) · 🇸🇪 [sv](../../../sv/docs/features/context-relay.md) · 🇳🇴 [no](../../../no/docs/features/context-relay.md) · 🇩🇰 [da](../../../da/docs/features/context-relay.md) · 🇫🇮 [fi](../../../fi/docs/features/context-relay.md) · 🇵🇹 [pt](../../../pt/docs/features/context-relay.md) · 🇷🇴 [ro](../../../ro/docs/features/context-relay.md) · 🇭🇺 [hu](../../../hu/docs/features/context-relay.md) · 🇧🇬 [bg](../../../bg/docs/features/context-relay.md) · 🇸🇰 [sk](../../../sk/docs/features/context-relay.md) · 🇺🇦 [uk-UA](../../../uk-UA/docs/features/context-relay.md) · 🇮🇱 [he](../../../he/docs/features/context-relay.md) · 🇵🇭 [phi](../../../phi/docs/features/context-relay.md) · 🇧🇷 [pt-BR](../../../pt-BR/docs/features/context-relay.md) · 🇨🇿 [cs](../../../cs/docs/features/context-relay.md) · 🇹🇷 [tr](../../../tr/docs/features/context-relay.md) --- -`context-relay` is a combo strategy that keeps session continuity when the active account -rotates before the conversation is finished. +`context-relay` adalah strategi combo yang menjaga kesinambungan sesi ketika akun aktif +berputar sebelum percakapan selesai. -The current runtime behaves like priority routing for model selection, then adds a -handoff layer on top: +Runtime saat ini berperilaku seperti routing prioritas untuk pemilihan model, kemudian menambahkan +lapisan handoff di atasnya: -- before the active account is exhausted, OmniRoute generates a compact structured summary -- after authentication selects a different account for the same session, OmniRoute injects - that summary as a system message into the next request -- once the handoff is consumed successfully, it is removed from storage +- sebelum akun aktif habis, OmniRoute menghasilkan ringkasan terstruktur yang ringkas +- setelah autentikasi memilih akun berbeda untuk sesi yang sama, OmniRoute menyuntikkan + ringkasan tersebut sebagai pesan sistem ke dalam permintaan berikutnya +- setelah handoff berhasil dikonsumsi, handoff tersebut dihapus dari penyimpanan -## When To Use It +## Kapan Menggunakannya -Use `context-relay` when all of the following are true: +Gunakan `context-relay` ketika semua kondisi berikut terpenuhi: -- the combo is expected to rotate between multiple accounts of the same provider -- losing short-term conversational continuity would hurt task quality -- the provider exposes enough quota information to predict an approaching account limit +- combo diharapkan berputar di antara beberapa akun dari penyedia yang sama +- kehilangan kesinambungan percakapan jangka pendek akan mengurangi kualitas tugas +- penyedia mengekspos informasi kuota yang cukup untuk memprediksi batas akun yang akan datang -This is most useful for long-running coding or research sessions that may outlive a single -account window. +Ini paling berguna untuk sesi coding atau riset yang berjalan lama yang mungkin melampaui satu +jendela akun. -## Runtime Flow +## Alur Runtime -The current behavior is intentionally split across two runtime layers. +Perilaku saat ini secara sengaja dibagi ke dalam dua lapisan runtime. -### 0% to 84% quota used +### 0% hingga 84% kuota terpakai -No handoff is generated. Requests behave like normal priority routing. +Tidak ada handoff yang dihasilkan. Permintaan berperilaku seperti routing prioritas normal. -### 85% to 94% quota used +### 85% hingga 94% kuota terpakai -If the active provider is enabled in `handoffProviders`, OmniRoute generates a structured -handoff summary in the background before the account is fully exhausted. +Jika penyedia aktif diaktifkan di `handoffProviders`, OmniRoute menghasilkan ringkasan handoff +terstruktur di latar belakang sebelum akun habis sepenuhnya. -Important details: +Detail penting: -- the default warning threshold is `0.85` -- the hard stop for generation is `0.95` -- only one in-flight handoff generation is allowed per `sessionId + comboName` -- if an active handoff already exists for that session/combo, no duplicate summary is generated +- ambang batas peringatan default adalah `0.85` +- batas keras untuk pembuatan adalah `0.95` +- hanya satu pembuatan handoff yang sedang berjalan yang diizinkan per `sessionId + comboName` +- jika handoff aktif sudah ada untuk sesi/combo tersebut, tidak ada ringkasan duplikat yang dihasilkan -### 95% or more quota used +### 95% atau lebih kuota terpakai -No new handoff is generated. At this point the system is already in or near exhaustion and -the runtime avoids scheduling another summary request. +Tidak ada handoff baru yang dihasilkan. Pada titik ini sistem sudah berada dalam kondisi habis atau +mendekati habis dan runtime menghindari penjadwalan permintaan ringkasan lain. -### After account rotation +### Setelah rotasi akun -When the next request for the same session resolves to a different authenticated account, -OmniRoute prepends the stored handoff as a system message. Injection happens only after the -real account switch is known. +Ketika permintaan berikutnya untuk sesi yang sama menghasilkan akun terautentikasi yang berbeda, +OmniRoute menambahkan handoff yang tersimpan sebagai pesan sistem. Penyuntikan hanya terjadi setelah +pergantian akun nyata diketahui. -## Handoff Payload +## Muatan Handoff -The persisted handoff payload is stored in `context_handoffs` and includes: +Muatan handoff yang dipersistenkan disimpan di `context_handoffs` dan mencakup: - `sessionId` - `comboName` @@ -74,7 +74,7 @@ The persisted handoff payload is stored in `context_handoffs` and includes: - `generatedAt` - `expiresAt` -The summary model is instructed to return a JSON object with this structure: +Model ringkasan diperintahkan untuk mengembalikan objek JSON dengan struktur berikut: ```json { @@ -85,46 +85,46 @@ The summary model is instructed to return a JSON object with this structure: } ``` -At injection time, OmniRoute converts that payload into a `` system -message so the next account can continue with the correct local context. +Pada saat penyuntikan, OmniRoute mengonversi muatan tersebut menjadi pesan sistem `` +agar akun berikutnya dapat melanjutkan dengan konteks lokal yang benar. ## Konfigurasi -`context-relay` supports these config fields: +`context-relay` mendukung kolom konfigurasi berikut: -- `handoffThreshold`: warning threshold for summary generation, default `0.85` -- `handoffModel`: optional model override used only for summary generation -- `handoffProviders`: allowlist of providers allowed to trigger handoff generation +- `handoffThreshold`: ambang batas peringatan untuk pembuatan ringkasan, default `0.85` +- `handoffModel`: penggantian model opsional yang hanya digunakan untuk pembuatan ringkasan +- `handoffProviders`: daftar izin penyedia yang diperbolehkan memicu pembuatan handoff -Global defaults can be configured in Settings, and combo-specific values can override them -in the Combos page. +Nilai default global dapat dikonfigurasi di Pengaturan, dan nilai spesifik combo dapat menggantikannya +di halaman Combos. -## Architectural Note +## Catatan Arsitektur -The current implementation does not use a standalone `handleContextRelayCombo` handler. +Implementasi saat ini tidak menggunakan pengendali `handleContextRelayCombo` yang berdiri sendiri. -Instead: +Sebaliknya: -- `open-sse/services/combo.ts` decides whether a successful turn should generate a handoff -- `src/sse/handlers/chat.ts` injects the handoff only after authentication resolves the - actual account used for the request +- `open-sse/services/combo.ts` memutuskan apakah giliran yang berhasil harus menghasilkan handoff +- `src/sse/handlers/chat.ts` menyuntikkan handoff hanya setelah autentikasi menyelesaikan + akun aktual yang digunakan untuk permintaan -This split is intentional in the current codebase because the combo loop alone does not know -whether the request stayed on the same account or actually switched accounts. +Pemisahan ini disengaja dalam basis kode saat ini karena loop combo saja tidak mengetahui +apakah permintaan tetap pada akun yang sama atau benar-benar berpindah akun. -## Limitations +## Keterbatasan -- Effective runtime support is currently centered on `codex` quota rotation. -- `handoffProviders` is already modeled as a config surface, but real handoff generation - still depends on provider-specific quota plumbing. -- The summary is intentionally compact and recent-history based; it is not a full transcript - replay mechanism. -- Handoffs are scoped by `sessionId + comboName` and expire automatically. -- If the session does not switch accounts, the stored handoff is not injected. +- Dukungan runtime yang efektif saat ini terpusat pada rotasi kuota `codex`. +- `handoffProviders` sudah dimodelkan sebagai permukaan konfigurasi, tetapi pembuatan handoff + nyata masih bergantung pada jalur kuota spesifik penyedia. +- Ringkasan secara sengaja dibuat ringkas dan berbasis riwayat terkini; ini bukan mekanisme + pemutaran ulang transkrip penuh. +- Handoff dicakupkan oleh `sessionId + comboName` dan kedaluwarsa secara otomatis. +- Jika sesi tidak berpindah akun, handoff yang tersimpan tidak disuntikkan. -## Recommended Usage Pattern +## Pola Penggunaan yang Disarankan -- use multiple accounts from the same provider -- keep stable `sessionId` values across the session -- set `handoffThreshold` early enough to leave room for the background summary request -- treat the feature as continuity assistance, not as a replacement for persistent memory +- gunakan beberapa akun dari penyedia yang sama +- pertahankan nilai `sessionId` yang stabil sepanjang sesi +- atur `handoffThreshold` cukup awal untuk menyisakan ruang bagi permintaan ringkasan latar belakang +- perlakukan fitur ini sebagai bantuan kesinambungan, bukan sebagai pengganti memori persisten diff --git a/docs/i18n/id/docs/frameworks/A2A-SERVER.md b/docs/i18n/id/docs/frameworks/A2A-SERVER.md index 808d40683c..7a83d8d8e4 100644 --- a/docs/i18n/id/docs/frameworks/A2A-SERVER.md +++ b/docs/i18n/id/docs/frameworks/A2A-SERVER.md @@ -1,38 +1,38 @@ -# OmniRoute A2A Server Documentation (Bahasa Indonesia) +# Dokumentasi Server A2A OmniRoute (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/A2A-SERVER.md) · 🇸🇦 [ar](../../ar/docs/A2A-SERVER.md) · 🇧🇬 [bg](../../bg/docs/A2A-SERVER.md) · 🇧🇩 [bn](../../bn/docs/A2A-SERVER.md) · 🇨🇿 [cs](../../cs/docs/A2A-SERVER.md) · 🇩🇰 [da](../../da/docs/A2A-SERVER.md) · 🇩🇪 [de](../../de/docs/A2A-SERVER.md) · 🇪🇸 [es](../../es/docs/A2A-SERVER.md) · 🇮🇷 [fa](../../fa/docs/A2A-SERVER.md) · 🇫🇮 [fi](../../fi/docs/A2A-SERVER.md) · 🇫🇷 [fr](../../fr/docs/A2A-SERVER.md) · 🇮🇳 [gu](../../gu/docs/A2A-SERVER.md) · 🇮🇱 [he](../../he/docs/A2A-SERVER.md) · 🇮🇳 [hi](../../hi/docs/A2A-SERVER.md) · 🇭🇺 [hu](../../hu/docs/A2A-SERVER.md) · 🇮🇩 [id](../../id/docs/A2A-SERVER.md) · 🇮🇹 [it](../../it/docs/A2A-SERVER.md) · 🇯🇵 [ja](../../ja/docs/A2A-SERVER.md) · 🇰🇷 [ko](../../ko/docs/A2A-SERVER.md) · 🇮🇳 [mr](../../mr/docs/A2A-SERVER.md) · 🇲🇾 [ms](../../ms/docs/A2A-SERVER.md) · 🇳🇱 [nl](../../nl/docs/A2A-SERVER.md) · 🇳🇴 [no](../../no/docs/A2A-SERVER.md) · 🇵🇭 [phi](../../phi/docs/A2A-SERVER.md) · 🇵🇱 [pl](../../pl/docs/A2A-SERVER.md) · 🇵🇹 [pt](../../pt/docs/A2A-SERVER.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/A2A-SERVER.md) · 🇷🇴 [ro](../../ro/docs/A2A-SERVER.md) · 🇷🇺 [ru](../../ru/docs/A2A-SERVER.md) · 🇸🇰 [sk](../../sk/docs/A2A-SERVER.md) · 🇸🇪 [sv](../../sv/docs/A2A-SERVER.md) · 🇰🇪 [sw](../../sw/docs/A2A-SERVER.md) · 🇮🇳 [ta](../../ta/docs/A2A-SERVER.md) · 🇮🇳 [te](../../te/docs/A2A-SERVER.md) · 🇹🇭 [th](../../th/docs/A2A-SERVER.md) · 🇹🇷 [tr](../../tr/docs/A2A-SERVER.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/A2A-SERVER.md) · 🇵🇰 [ur](../../ur/docs/A2A-SERVER.md) · 🇻🇳 [vi](../../vi/docs/A2A-SERVER.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/A2A-SERVER.md) --- -> Agent-to-Agent Protocol v0.3 — OmniRoute as an intelligent routing agent +> Protokol Agent-to-Agent v0.3 — OmniRoute sebagai agen routing cerdas -## Agent Discovery +## Penemuan Agen ```bash curl http://localhost:20128/.well-known/agent.json ``` -Returns the Agent Card describing OmniRoute's capabilities, skills, and authentication requirements. +Mengembalikan Kartu Agen yang mendeskripsikan kemampuan, keterampilan, dan persyaratan autentikasi OmniRoute. --- -## Authentication +## Autentikasi -All `/a2a` requests require an API key via the `Authorization` header: +Semua permintaan `/a2a` memerlukan kunci API melalui header `Authorization`: ``` Authorization: Bearer YOUR_OMNIROUTE_API_KEY ``` -If no API key is configured on the server, authentication is bypassed. +Jika tidak ada kunci API yang dikonfigurasi di server, autentikasi akan dilewati. --- -## JSON-RPC 2.0 Methods +## Metode JSON-RPC 2.0 -### `message/send` — Synchronous Execution +### `message/send` — Eksekusi Sinkron -Sends a message to a skill and waits for the complete response. +Mengirim pesan ke sebuah keterampilan dan menunggu respons lengkap. ```bash curl -X POST http://localhost:20128/a2a \ @@ -50,7 +50,7 @@ curl -X POST http://localhost:20128/a2a \ }' ``` -**Response:** +**Respons:** ```json { @@ -71,9 +71,9 @@ curl -X POST http://localhost:20128/a2a \ } ``` -### `message/stream` — SSE Streaming +### `message/stream` — Streaming SSE -Same as `message/send` but returns Server-Sent Events for real-time streaming. +Sama seperti `message/send` tetapi mengembalikan Server-Sent Events untuk streaming secara real-time. ```bash curl -N -X POST http://localhost:20128/a2a \ @@ -90,7 +90,7 @@ curl -N -X POST http://localhost:20128/a2a \ }' ``` -**SSE Events:** +**Event SSE:** ``` data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"working"},"chunk":{"type":"text","content":"..."}}} @@ -100,7 +100,7 @@ data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","s data: {"jsonrpc":"2.0","method":"message/stream","params":{"task":{"id":"...","state":"completed"},"metadata":{...}}} ``` -### `tasks/get` — Query Task Status +### `tasks/get` — Kueri Status Tugas ```bash curl -X POST http://localhost:20128/a2a \ @@ -109,7 +109,7 @@ curl -X POST http://localhost:20128/a2a \ -d '{"jsonrpc":"2.0","id":"2","method":"tasks/get","params":{"taskId":"TASK_UUID"}}' ``` -### `tasks/cancel` — Cancel a Task +### `tasks/cancel` — Batalkan Sebuah Tugas ```bash curl -X POST http://localhost:20128/a2a \ @@ -120,16 +120,16 @@ curl -X POST http://localhost:20128/a2a \ --- -## Available Skills +## Keterampilan yang Tersedia -| Skill | Description | -| :----------------- | :------------------------------------------------------------------------------------------------------------------------------ | -| `smart-routing` | Routes prompts through OmniRoute's intelligent pipeline. Returns response with routing explanation, cost, and resilience trace. | -| `quota-management` | Answers natural-language queries about provider quotas, suggests free combos, and provides quota rankings. | +| Keterampilan | Deskripsi | +| :----------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `smart-routing` | Merutekan prompt melalui pipeline cerdas OmniRoute. Mengembalikan respons beserta penjelasan routing, biaya, dan jejak ketahanan. | +| `quota-management` | Menjawab kueri bahasa alami tentang kuota penyedia, menyarankan combo gratis, dan memberikan peringkat kuota. | --- -## Task Lifecycle +## Siklus Hidup Tugas ``` submitted → working → completed @@ -137,25 +137,25 @@ submitted → working → completed → cancelled ``` -- Tasks expire after 5 minutes (configurable) -- Terminal states: `completed`, `failed`, `cancelled` -- Event log tracks every state transition +- Tugas kedaluwarsa setelah 5 menit (dapat dikonfigurasi) +- Status terminal: `completed`, `failed`, `cancelled` +- Log event melacak setiap transisi status --- -## Error Codes +## Kode Kesalahan -| Code | Meaning | -| :----- | :----------------------------- | -| -32700 | Parse error (invalid JSON) | -| -32600 | Invalid request / Unauthorized | -| -32601 | Method or skill not found | -| -32602 | Invalid params | -| -32603 | Internal error | +| Kode | Arti | +| :----- | :-------------------------------------- | +| -32700 | Kesalahan parse (JSON tidak valid) | +| -32600 | Permintaan tidak valid / Tidak diotorisasi | +| -32601 | Metode atau keterampilan tidak ditemukan | +| -32602 | Parameter tidak valid | +| -32603 | Kesalahan internal | --- -## Integration Examples +## Contoh Integrasi ### Python (requests) diff --git a/docs/i18n/id/docs/frameworks/MCP-SERVER.md b/docs/i18n/id/docs/frameworks/MCP-SERVER.md index 33b5c59578..1af2f02093 100644 --- a/docs/i18n/id/docs/frameworks/MCP-SERVER.md +++ b/docs/i18n/id/docs/frameworks/MCP-SERVER.md @@ -1,63 +1,63 @@ -# OmniRoute MCP Server Documentation (Bahasa Indonesia) +# Dokumentasi Server MCP OmniRoute (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/MCP-SERVER.md) · 🇸🇦 [ar](../../ar/docs/MCP-SERVER.md) · 🇧🇬 [bg](../../bg/docs/MCP-SERVER.md) · 🇧🇩 [bn](../../bn/docs/MCP-SERVER.md) · 🇨🇿 [cs](../../cs/docs/MCP-SERVER.md) · 🇩🇰 [da](../../da/docs/MCP-SERVER.md) · 🇩🇪 [de](../../de/docs/MCP-SERVER.md) · 🇪🇸 [es](../../es/docs/MCP-SERVER.md) · 🇮🇷 [fa](../../fa/docs/MCP-SERVER.md) · 🇫🇮 [fi](../../fi/docs/MCP-SERVER.md) · 🇫🇷 [fr](../../fr/docs/MCP-SERVER.md) · 🇮🇳 [gu](../../gu/docs/MCP-SERVER.md) · 🇮🇱 [he](../../he/docs/MCP-SERVER.md) · 🇮🇳 [hi](../../hi/docs/MCP-SERVER.md) · 🇭🇺 [hu](../../hu/docs/MCP-SERVER.md) · 🇮🇩 [id](../../id/docs/MCP-SERVER.md) · 🇮🇹 [it](../../it/docs/MCP-SERVER.md) · 🇯🇵 [ja](../../ja/docs/MCP-SERVER.md) · 🇰🇷 [ko](../../ko/docs/MCP-SERVER.md) · 🇮🇳 [mr](../../mr/docs/MCP-SERVER.md) · 🇲🇾 [ms](../../ms/docs/MCP-SERVER.md) · 🇳🇱 [nl](../../nl/docs/MCP-SERVER.md) · 🇳🇴 [no](../../no/docs/MCP-SERVER.md) · 🇵🇭 [phi](../../phi/docs/MCP-SERVER.md) · 🇵🇱 [pl](../../pl/docs/MCP-SERVER.md) · 🇵🇹 [pt](../../pt/docs/MCP-SERVER.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/MCP-SERVER.md) · 🇷🇴 [ro](../../ro/docs/MCP-SERVER.md) · 🇷🇺 [ru](../../ru/docs/MCP-SERVER.md) · 🇸🇰 [sk](../../sk/docs/MCP-SERVER.md) · 🇸🇪 [sv](../../sv/docs/MCP-SERVER.md) · 🇰🇪 [sw](../../sw/docs/MCP-SERVER.md) · 🇮🇳 [ta](../../ta/docs/MCP-SERVER.md) · 🇮🇳 [te](../../te/docs/MCP-SERVER.md) · 🇹🇭 [th](../../th/docs/MCP-SERVER.md) · 🇹🇷 [tr](../../tr/docs/MCP-SERVER.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/MCP-SERVER.md) · 🇵🇰 [ur](../../ur/docs/MCP-SERVER.md) · 🇻🇳 [vi](../../vi/docs/MCP-SERVER.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/MCP-SERVER.md) --- -> Model Context Protocol server with 16 intelligent tools +> Server Model Context Protocol dengan 16 alat cerdas -## Instal +## Instalasi -OmniRoute MCP is built-in. Start it with: +OmniRoute MCP sudah tersedia secara bawaan. Jalankan dengan: ```bash omniroute --mcp ``` -Or via the open-sse transport: +Atau melalui transport open-sse: ```bash # HTTP streamable transport (port 20130) omniroute --dev # MCP auto-starts on /mcp endpoint ``` -## IDE Configuration +## Konfigurasi IDE -See [IDE Configs](integrations/ide-configs.md) for Antigravity, Cursor, Copilot, and Claude Desktop setup. +Lihat [Konfigurasi IDE](integrations/ide-configs.md) untuk pengaturan Antigravity, Cursor, Copilot, dan Claude Desktop. --- -## Essential Tools (8) +## Alat Esensial (8) -| Tool | Description | -| :------------------------------ | :--------------------------------------- | -| `omniroute_get_health` | Gateway health, circuit breakers, uptime | -| `omniroute_list_combos` | All configured combos with models | -| `omniroute_get_combo_metrics` | Performance metrics for a specific combo | -| `omniroute_switch_combo` | Switch active combo by ID/name | -| `omniroute_check_quota` | Quota status per provider or all | -| `omniroute_route_request` | Send a chat completion through OmniRoute | -| `omniroute_cost_report` | Cost analytics for a time period | -| `omniroute_list_models_catalog` | Full model catalog with capabilities | +| Alat | Deskripsi | +| :------------------------------ | :---------------------------------------------------------------- | +| `omniroute_get_health` | Kesehatan gateway, pemutus sirkuit, uptime | +| `omniroute_list_combos` | Semua combo yang dikonfigurasi beserta modelnya | +| `omniroute_get_combo_metrics` | Metrik performa untuk combo tertentu | +| `omniroute_switch_combo` | Ganti combo aktif berdasarkan ID/nama | +| `omniroute_check_quota` | Status kuota per penyedia atau semua penyedia | +| `omniroute_route_request` | Kirim penyelesaian chat melalui OmniRoute | +| `omniroute_cost_report` | Analitik biaya untuk periode waktu tertentu | +| `omniroute_list_models_catalog` | Katalog model lengkap beserta kemampuannya | -## Advanced Tools (8) +## Alat Lanjutan (8) -| Tool | Description | -| :--------------------------------- | :---------------------------------------------------------- | -| `omniroute_simulate_route` | Dry-run routing simulation with fallback tree | -| `omniroute_set_budget_guard` | Session budget with degrade/block/alert actions | -| `omniroute_set_resilience_profile` | Apply conservative/balanced/aggressive preset | -| `omniroute_test_combo` | Live-test all models in a combo via a real upstream request | -| `omniroute_get_provider_metrics` | Detailed metrics for one provider | -| `omniroute_best_combo_for_task` | Task-fitness recommendation with alternatives | -| `omniroute_explain_route` | Explain a past routing decision | -| `omniroute_get_session_snapshot` | Full session state: costs, tokens, errors | +| Alat | Deskripsi | +| :--------------------------------- | :--------------------------------------------------------------------- | +| `omniroute_simulate_route` | Simulasi routing percobaan dengan pohon fallback | +| `omniroute_set_budget_guard` | Anggaran sesi dengan tindakan degrade/block/alert | +| `omniroute_set_resilience_profile` | Terapkan preset conservative/balanced/aggressive | +| `omniroute_test_combo` | Uji langsung semua model dalam combo melalui permintaan upstream nyata | +| `omniroute_get_provider_metrics` | Metrik terperinci untuk satu penyedia | +| `omniroute_best_combo_for_task` | Rekomendasi kesesuaian tugas beserta alternatifnya | +| `omniroute_explain_route` | Jelaskan keputusan routing yang lalu | +| `omniroute_get_session_snapshot` | Status sesi lengkap: biaya, token, kesalahan | -## Authentication +## Autentikasi -MCP tools are authenticated via API key scopes. Each tool requires specific scopes: +Alat MCP diautentikasi melalui lingkup kunci API. Setiap alat memerlukan lingkup tertentu: -| Scope | Tools | +| Lingkup | Alat | | :------------- | :----------------------------------------------- | | `read:health` | get_health, get_provider_metrics | | `read:combos` | list_combos, get_combo_metrics | @@ -68,20 +68,20 @@ MCP tools are authenticated via API key scopes. Each tool requires specific scop | `write:config` | set_budget_guard, set_resilience_profile | | `read:models` | list_models_catalog, best_combo_for_task | -## Audit Logging +## Pencatatan Audit -Every tool call is logged to `mcp_tool_audit` with: +Setiap pemanggilan alat dicatat ke `mcp_tool_audit` dengan: -- Tool name, arguments, result -- Duration (ms), success/failure -- API key hash, timestamp +- Nama alat, argumen, hasil +- Durasi (ms), berhasil/gagal +- Hash kunci API, cap waktu -## Files +## Berkas -| File | Purpose | -| :------------------------------------------- | :------------------------------------------ | -| `open-sse/mcp-server/server.ts` | MCP server creation + 16 tool registrations | -| `open-sse/mcp-server/transport.ts` | Stdio + HTTP transport | -| `open-sse/mcp-server/auth.ts` | API key + scope validation | -| `open-sse/mcp-server/audit.ts` | Tool call audit logging | -| `open-sse/mcp-server/tools/advancedTools.ts` | 8 advanced tool handlers | +| Berkas | Tujuan | +| :------------------------------------------- | :--------------------------------------------------- | +| `open-sse/mcp-server/server.ts` | Pembuatan server MCP + pendaftaran 16 alat | +| `open-sse/mcp-server/transport.ts` | Transportasi Stdio + HTTP | +| `open-sse/mcp-server/auth.ts` | Validasi kunci API + lingkup | +| `open-sse/mcp-server/audit.ts` | Pencatatan audit pemanggilan alat | +| `open-sse/mcp-server/tools/advancedTools.ts` | 8 pengendali alat lanjutan | diff --git a/docs/i18n/id/docs/guides/FEATURES.md b/docs/i18n/id/docs/guides/FEATURES.md index 7860ca5d91..ba49bbd65f 100644 --- a/docs/i18n/id/docs/guides/FEATURES.md +++ b/docs/i18n/id/docs/guides/FEATURES.md @@ -1,270 +1,270 @@ -# OmniRoute — Dashboard Features Gallery (Bahasa Indonesia) +# OmniRoute — Galeri Fitur Dashboard (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/FEATURES.md) · 🇸🇦 [ar](../../ar/docs/FEATURES.md) · 🇧🇬 [bg](../../bg/docs/FEATURES.md) · 🇧🇩 [bn](../../bn/docs/FEATURES.md) · 🇨🇿 [cs](../../cs/docs/FEATURES.md) · 🇩🇰 [da](../../da/docs/FEATURES.md) · 🇩🇪 [de](../../de/docs/FEATURES.md) · 🇪🇸 [es](../../es/docs/FEATURES.md) · 🇮🇷 [fa](../../fa/docs/FEATURES.md) · 🇫🇮 [fi](../../fi/docs/FEATURES.md) · 🇫🇷 [fr](../../fr/docs/FEATURES.md) · 🇮🇳 [gu](../../gu/docs/FEATURES.md) · 🇮🇱 [he](../../he/docs/FEATURES.md) · 🇮🇳 [hi](../../hi/docs/FEATURES.md) · 🇭🇺 [hu](../../hu/docs/FEATURES.md) · 🇮🇩 [id](../../id/docs/FEATURES.md) · 🇮🇹 [it](../../it/docs/FEATURES.md) · 🇯🇵 [ja](../../ja/docs/FEATURES.md) · 🇰🇷 [ko](../../ko/docs/FEATURES.md) · 🇮🇳 [mr](../../mr/docs/FEATURES.md) · 🇲🇾 [ms](../../ms/docs/FEATURES.md) · 🇳🇱 [nl](../../nl/docs/FEATURES.md) · 🇳🇴 [no](../../no/docs/FEATURES.md) · 🇵🇭 [phi](../../phi/docs/FEATURES.md) · 🇵🇱 [pl](../../pl/docs/FEATURES.md) · 🇵🇹 [pt](../../pt/docs/FEATURES.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/FEATURES.md) · 🇷🇴 [ro](../../ro/docs/FEATURES.md) · 🇷🇺 [ru](../../ru/docs/FEATURES.md) · 🇸🇰 [sk](../../sk/docs/FEATURES.md) · 🇸🇪 [sv](../../sv/docs/FEATURES.md) · 🇰🇪 [sw](../../sw/docs/FEATURES.md) · 🇮🇳 [ta](../../ta/docs/FEATURES.md) · 🇮🇳 [te](../../te/docs/FEATURES.md) · 🇹🇭 [th](../../th/docs/FEATURES.md) · 🇹🇷 [tr](../../tr/docs/FEATURES.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/FEATURES.md) · 🇵🇰 [ur](../../ur/docs/FEATURES.md) · 🇻🇳 [vi](../../vi/docs/FEATURES.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/FEATURES.md) --- -Visual guide to every section of the OmniRoute dashboard. +Panduan visual untuk setiap bagian dashboard OmniRoute. --- -## 🔌 Providers +## 🔌 Penyedia -Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI), API key providers (Groq, DeepSeek, OpenRouter), and free providers (Qoder, Qwen, Kiro). Kiro accounts include credit balance tracking — remaining credits, total allowance, and renewal date visible in Dashboard → Usage. +Kelola koneksi penyedia AI: penyedia OAuth (Claude Code, Codex, Gemini CLI), penyedia kunci API (Groq, DeepSeek, OpenRouter), dan penyedia gratis (Qoder, Qwen, Kiro). Akun Kiro menyertakan pelacakan saldo kredit — sisa kredit, total tunjangan, dan tanggal pembaruan terlihat di Dashboard → Penggunaan. ![Providers Dashboard](screenshots/01-providers.png) --- -## 🎨 Combos +## 🎨 Combo -Create model routing combos with 13 strategies: priority, weighted, round-robin, random, least-used, cost-optimized, strict-random, auto, fill-first, p2c, lkgp, context-optimized, and **context-relay**. Each combo chains multiple models with automatic fallback and includes quick templates and readiness checks. +Buat combo routing model dengan 13 strategi: priority, weighted, round-robin, random, least-used, cost-optimized, strict-random, auto, fill-first, p2c, lkgp, context-optimized, dan **context-relay**. Setiap combo menghubungkan beberapa model dengan fallback otomatis dan menyertakan templat cepat serta pemeriksaan kesiapan. -Recent combo improvements: +Peningkatan combo terbaru: -- **Structured combo builder** — create each step by selecting provider, model, and exact account/connection -- **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique -- **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings -- **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps +- **Pembuat combo terstruktur** — buat setiap langkah dengan memilih penyedia, model, dan akun/koneksi yang tepat +- **Dukungan penyedia berulang** — gunakan kembali penyedia yang sama berkali-kali dalam satu combo selama tuple `(provider, model, connection)` bersifat unik +- **Kesehatan target combo** — analitik dan tampilan kesehatan kini membedakan target/langkah combo individual alih-alih menggabungkan semuanya ke dalam string model +- **Urutan tingkatan komposit** — `defaultTier -> fallbackTier` kini memengaruhi urutan eksekusi/fallback saat runtime untuk langkah combo tingkat atas ![Combos Dashboard](screenshots/02-combos.png) --- -## 📊 Analytics +## 📊 Analitik -Comprehensive usage analytics with token consumption, cost estimates, activity heatmaps, weekly distribution charts, and per-provider breakdowns. +Analitik penggunaan komprehensif dengan konsumsi token, estimasi biaya, peta panas aktivitas, grafik distribusi mingguan, dan rincian per penyedia. ![Analytics Dashboard](screenshots/03-analytics.png) --- -## 🏥 System Health +## 🏥 Kesehatan Sistem -Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, provider circuit breaker states, active quota-monitored sessions, and combo target health. +Pemantauan real-time: uptime, memori, versi, persentil latensi (p50/p95/p99), statistik cache, status circuit breaker penyedia, sesi terpantau kuota yang aktif, dan kesehatan target combo. ![Health Dashboard](screenshots/04-health.png) --- -## 🔧 Translator Playground +## 🔧 Taman Bermain Translator -Four modes for debugging API translations: **Playground** (format converter), **Chat Tester** (live requests), **Test Bench** (batch tests), and **Live Monitor** (real-time stream). +Empat mode untuk men-debug terjemahan API: **Playground** (konverter format), **Chat Tester** (permintaan langsung), **Test Bench** (pengujian batch), dan **Live Monitor** (aliran real-time). ![Translator Playground](screenshots/05-translator.png) --- -## 🎮 Model Playground _(v2.0.9+)_ +## 🎮 Taman Bermain Model _(v2.0.9+)_ -Test any model directly from the dashboard. Select provider, model, and endpoint, write prompts with Monaco Editor, stream responses in real-time, abort mid-stream, and view timing metrics. +Uji model apa pun langsung dari dashboard. Pilih penyedia, model, dan endpoint, tulis prompt dengan Monaco Editor, streaming respons secara real-time, batalkan di tengah streaming, dan lihat metrik waktu. --- -## 🎨 Themes _(v2.0.5+)_ +## 🎨 Tema _(v2.0.5+)_ -Customizable color themes for the entire dashboard. Choose from 7 preset colors (Coral, Blue, Red, Green, Violet, Orange, Cyan) or create a custom theme by picking any hex color. Supports light, dark, and system mode. +Tema warna yang dapat dikustomisasi untuk seluruh dashboard. Pilih dari 7 warna prasetel (Coral, Blue, Red, Green, Violet, Orange, Cyan) atau buat tema kustom dengan memilih warna hex apa pun. Mendukung mode terang, gelap, dan sistem. --- -## ⚙️ Settings +## ⚙️ Pengaturan -Comprehensive settings panel with tabs: +Panel pengaturan komprehensif dengan tab: -- **General** — System storage, backup management (export/import database) -- **Appearance** — Theme selector (dark/light/system), color theme presets and custom colors, health log visibility, sidebar item visibility controls -- **Security** — API endpoint protection, custom provider blocking, IP filtering, session info -- **Routing** — Model aliases, background task degradation -- **Resilience** — Rate limit persistence, circuit breaker tuning, auto-disable banned accounts, provider expiration monitoring, **Context Relay** handoff threshold and summary model configuration -- **Advanced** — Configuration overrides, configuration audit trail, fallback degradation mode +- **Umum** — Penyimpanan sistem, manajemen cadangan (ekspor/impor database) +- **Tampilan** — Pemilih tema (gelap/terang/sistem), prasetel tema warna dan warna kustom, visibilitas log kesehatan, kontrol visibilitas item bilah samping +- **Keamanan** — Perlindungan endpoint API, pemblokiran penyedia kustom, pemfilteran IP, info sesi +- **Routing** — Alias model, degradasi tugas latar belakang +- **Ketahanan** — Persistensi batas laju, penyetelan circuit breaker, nonaktifkan akun yang diblokir secara otomatis, pemantauan kedaluwarsa penyedia, ambang batas handoff **Context Relay** dan konfigurasi model ringkasan +- **Lanjutan** — Penimpaan konfigurasi, jejak audit konfigurasi, mode degradasi fallback ![Settings Dashboard](screenshots/06-settings.png) --- -## 🔧 CLI Tools +## 🔧 Alat CLI -One-click configuration for AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, Antigravity, Cline, Continue, Cursor, and Factory Droid. Features automated config apply/reset, connection profiles, and model mapping. +Konfigurasi satu klik untuk alat pengkodean AI: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, Antigravity, Cline, Continue, Cursor, dan Factory Droid. Dilengkapi penerapan/reset konfigurasi otomatis, profil koneksi, dan pemetaan model. ![CLI Tools Dashboard](screenshots/07-cli-tools.png) --- -## 🤖 CLI Agents _(v2.0.11+)_ +## 🤖 Agen CLI _(v2.0.11+)_ -Dashboard for discovering and managing CLI agents. Shows a grid of 14 built-in agents (Codex, Claude, Goose, Gemini CLI, OpenClaw, Aider, OpenCode, Cline, Qwen Code, ForgeCode, Amazon Q, Open Interpreter, Cursor CLI, Warp) with: +Dashboard untuk menemukan dan mengelola agen CLI. Menampilkan kisi 14 agen bawaan (Codex, Claude, Goose, Gemini CLI, OpenClaw, Aider, OpenCode, Cline, Qwen Code, ForgeCode, Amazon Q, Open Interpreter, Cursor CLI, Warp) dengan: -- **Installation status** — Installed / Not Found with version detection -- **Protocol badges** — stdio, HTTP, etc. -- **Custom agents** — Register any CLI tool via form (name, binary, version command, spawn args) -- **CLI Fingerprint Matching** — Per-provider toggle to match native CLI request signatures, reducing ban risk while preserving proxy IP +- **Status instalasi** — Terpasang / Tidak Ditemukan dengan deteksi versi +- **Lencana protokol** — stdio, HTTP, dll. +- **Agen kustom** — Daftarkan alat CLI apa pun melalui formulir (nama, biner, perintah versi, argumen spawn) +- **Pencocokan Sidik Jari CLI** — Sakelar per penyedia untuk mencocokkan tanda tangan permintaan CLI asli, mengurangi risiko pemblokiran sambil mempertahankan IP proxy --- ## 🔗 Context Relay _(v3.5.5+)_ -A combo strategy that preserves session continuity when account rotation happens mid-conversation. Before the active account is exhausted, OmniRoute generates a structured handoff summary in the background. After the next request resolves to a different account, the summary is injected as a system message so the new account continues with full context. +Strategi combo yang mempertahankan kesinambungan sesi saat rotasi akun terjadi di tengah percakapan. Sebelum akun aktif habis, OmniRoute menghasilkan ringkasan handoff terstruktur di latar belakang. Setelah permintaan berikutnya diarahkan ke akun berbeda, ringkasan disuntikkan sebagai pesan sistem sehingga akun baru melanjutkan dengan konteks penuh. -Configurable via combo-level or global settings: +Dapat dikonfigurasi melalui pengaturan level combo atau global: -- **Handoff Threshold** — Quota usage percentage that triggers summary generation (default 85%) -- **Max Messages For Summary** — How much recent history to condense -- **Summary Model** — Optional override model for generating the handoff summary +- **Ambang Batas Handoff** — Persentase penggunaan kuota yang memicu pembuatan ringkasan (default 85%) +- **Maks Pesan untuk Ringkasan** — Seberapa banyak riwayat terkini yang dipadatkan +- **Model Ringkasan** — Model penimpaan opsional untuk menghasilkan ringkasan handoff -Currently supports Codex account rotation. See [Context Relay documentation](features/context-relay.md). +Saat ini mendukung rotasi akun Codex. Lihat [dokumentasi Context Relay](features/context-relay.md). --- -## 🛡️ Proxy Hardening _(v3.5.5+)_ +## 🛡️ Penguatan Proxy _(v3.5.5+)_ -Comprehensive proxy configuration enforcement across the entire request pipeline: +Penegakan konfigurasi proxy komprehensif di seluruh pipeline permintaan: -- **Token Health Check** — Background OAuth refresh now resolves proxy config per connection, preventing failures in proxy-required environments -- **API Key Validation** — Provider key validation (`POST /api/providers/validate`) routes through `runWithProxyContext`, honoring provider-level and global proxy settings -- **undici Dispatcher Fix** — Proxy dispatchers use undici's own fetch implementation instead of Node's built-in fetch, resolving `invalid onRequestStart method` errors on Node.js 22 -- **Node.js Version Detection** — Login page proactively detects incompatible Node.js versions (24+) and displays a warning banner with instructions to use Node 22 LTS +- **Pemeriksaan Kesehatan Token** — Pembaruan OAuth latar belakang kini me-resolve konfigurasi proxy per koneksi, mencegah kegagalan di lingkungan yang memerlukan proxy +- **Validasi Kunci API** — Validasi kunci penyedia (`POST /api/providers/validate`) diarahkan melalui `runWithProxyContext`, menghormati pengaturan proxy level penyedia dan global +- **Perbaikan Dispatcher undici** — Dispatcher proxy menggunakan implementasi fetch milik undici sendiri alih-alih fetch bawaan Node, menyelesaikan kesalahan `invalid onRequestStart method` pada Node.js 22 +- **Deteksi Versi Node.js** — Halaman login secara proaktif mendeteksi versi Node.js yang tidak kompatibel (24+) dan menampilkan spanduk peringatan dengan instruksi untuk menggunakan Node 22 LTS --- -## 📧 Email Privacy Masking _(v3.5.6+)_ +## 📧 Penyamaran Privasi Email _(v3.5.6+)_ -OAuth account emails are now masked in the provider dashboard (e.g. `di*****@g****.com`) to prevent accidental exposure when sharing screenshots or recording demos. The full email address remains accessible via hover tooltip (`title` attribute). +Email akun OAuth kini disembunyikan di dashboard penyedia (mis. `di*****@g****.com`) untuk mencegah paparan tidak sengaja saat berbagi tangkapan layar atau merekam demo. Alamat email lengkap tetap dapat diakses melalui tooltip hover (atribut `title`). --- -## 👁️ Model Visibility Toggle _(v3.5.6+)_ +## 👁️ Sakelar Visibilitas Model _(v3.5.6+)_ -The provider page model list now includes: +Daftar model halaman penyedia kini menyertakan: -- **Real-time search/filter bar** — Quickly find specific models -- **Per-model visibility toggle** (👁 icon) — Hidden models are grayed out and excluded from the `/v1/models` catalog -- **Active-count badge** (`N/M active`) — Shows at a glance how many models are enabled vs total +- **Bilah pencarian/filter real-time** — Temukan model tertentu dengan cepat +- **Sakelar visibilitas per model** (ikon 👁) — Model yang disembunyikan diarsir dan dikecualikan dari katalog `/v1/models` +- **Lencana jumlah aktif** (`N/M active`) — Menampilkan sekilas berapa banyak model yang diaktifkan vs total --- -## 🔧 OAuth Env Repair _(v3.6.1+)_ +## 🔧 Perbaikan Env OAuth _(v3.6.1+)_ -One-click "Repair env" action for OAuth providers that restores missing environment variables and fixes broken auth state. Accessible from `Dashboard → Providers → [OAuth Provider] → Repair env`. Automatically detects and repairs: +Tindakan "Repair env" satu klik untuk penyedia OAuth yang memulihkan variabel lingkungan yang hilang dan memperbaiki status autentikasi yang rusak. Dapat diakses dari `Dashboard → Providers → [OAuth Provider] → Repair env`. Secara otomatis mendeteksi dan memperbaiki: -- Missing OAuth client credentials -- Corrupted env file entries -- Backup path sanitization +- Kredensial klien OAuth yang hilang +- Entri file env yang rusak +- Sanitasi jalur cadangan --- -## 🗑️ Uninstall / Full Uninstall _(v3.6.2+)_ +## 🗑️ Uninstall / Uninstall Penuh _(v3.6.2+)_ -Clean removal scripts for all installation methods: +Skrip penghapusan bersih untuk semua metode instalasi: -| Command | Action | -| ------------------------ | ----------------------------------------------------------------------------------- | -| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | -| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. | +| Perintah | Tindakan | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `npm run uninstall` | Menghapus aplikasi sistem tetapi **mempertahankan DB dan konfigurasi Anda** di `~/.omniroute`. | +| `npm run uninstall:full` | Menghapus aplikasi DAN secara permanen **menghapus semua konfigurasi, kunci, dan database**. | --- ## 🖼️ Media _(v2.0.3+)_ -Generate images, videos, and music from the dashboard. Supports OpenAI, xAI, Together, Hyperbolic, SD WebUI, ComfyUI, AnimateDiff, Stable Audio Open, and MusicGen. +Hasilkan gambar, video, dan musik dari dashboard. Mendukung OpenAI, xAI, Together, Hyperbolic, SD WebUI, ComfyUI, AnimateDiff, Stable Audio Open, dan MusicGen. --- -## 📝 Request Logs +## 📝 Log Permintaan -Real-time request logging with filtering by provider, model, account, and API key. Shows status codes, token usage, latency, and response details. +Pencatatan permintaan real-time dengan pemfilteran berdasarkan penyedia, model, akun, dan kunci API. Menampilkan kode status, penggunaan token, latensi, dan detail respons. ![Usage Logs](screenshots/08-usage.png) --- -## 🌐 API Endpoint +## 🌐 Endpoint API -Your unified API endpoint with capability breakdown: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, and registered API keys. Cloudflare Quick Tunnel integration and cloud proxy support for remote access. +Endpoint API terpadu Anda dengan rincian kemampuan: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, dan kunci API yang terdaftar. Integrasi Cloudflare Quick Tunnel dan dukungan proxy cloud untuk akses jarak jauh. ![Endpoint Dashboard](screenshots/09-endpoint.png) --- -## 🔑 API Key Management +## 🔑 Manajemen Kunci API -Create, scope, and revoke API keys. Each key can be restricted to specific models/providers with full access or read-only permissions. Visual key management with usage tracking. +Buat, batasi cakupan, dan cabut kunci API. Setiap kunci dapat dibatasi ke model/penyedia tertentu dengan izin akses penuh atau hanya baca. Manajemen kunci secara visual dengan pelacakan penggunaan. --- -## 📋 Audit Log +## 📋 Log Audit -Administrative action tracking with filtering by action type, actor, target, IP address, and timestamp. Full security event history. +Pelacakan tindakan administratif dengan pemfilteran berdasarkan jenis tindakan, pelaku, target, alamat IP, dan cap waktu. Riwayat kejadian keamanan lengkap. --- -## 🖥️ Desktop Application +## 🖥️ Aplikasi Desktop -Native Electron desktop app for Windows, macOS, and Linux. Run OmniRoute as a standalone application with system tray integration, offline support, auto-update, and one-click install. +Aplikasi desktop Electron asli untuk Windows, macOS, dan Linux. Jalankan OmniRoute sebagai aplikasi mandiri dengan integrasi system tray, dukungan offline, pembaruan otomatis, dan instalasi satu klik. -Key features: +Fitur utama: -- Server readiness polling (no blank screen on cold start) -- System tray with port management +- Polling kesiapan server (tidak ada layar kosong saat cold start) +- System tray dengan manajemen port - Content Security Policy -- Single-instance lock -- Auto-update on restart -- Platform-conditional UI (macOS traffic lights, Windows/Linux default titlebar) -- Hardened Electron build packaging — symlinked `node_modules` in the standalone bundle is detected and rejected before packaging, preventing runtime dependency on the build machine (v2.5.5+) -- **Graceful shutdown** — Electron `before-quit` shuts down Next.js cleanly, preventing SQLite WAL database locks (v3.6.2+) +- Kunci instans tunggal +- Pembaruan otomatis saat restart +- UI kondisional platform (lampu lalu lintas macOS, titlebar default Windows/Linux) +- Pengemasan build Electron yang diperkuat — `node_modules` yang di-symlink dalam bundel mandiri terdeteksi dan ditolak sebelum pengemasan, mencegah ketergantungan runtime pada mesin build (v2.5.5+) +- **Penutupan yang baik** — `before-quit` Electron menutup Next.js dengan bersih, mencegah kunci database SQLite WAL (v3.6.2+) -📖 See [`electron/README.md`](../electron/README.md) for full documentation. +📖 Lihat [`electron/README.md`](../electron/README.md) untuk dokumentasi lengkap. --- -## 🌐 V1 WebSocket Bridge _(v3.6.6+)_ +## 🌐 Jembatan WebSocket V1 _(v3.6.6+)_ -OmniRoute now supports **OpenAI-compatible WebSocket clients** via the `/v1/ws` upgrade endpoint. The custom `scripts/v1-ws-bridge.mjs` server wraps Next.js and upgrades WS connections to full bidirectional streaming sessions. Authentication uses the same API key or session cookie as HTTP requests. +OmniRoute kini mendukung **klien WebSocket yang kompatibel dengan OpenAI** melalui endpoint upgrade `/v1/ws`. Server `scripts/v1-ws-bridge.mjs` kustom membungkus Next.js dan mengupgrade koneksi WS menjadi sesi streaming dua arah penuh. Autentikasi menggunakan kunci API atau cookie sesi yang sama seperti permintaan HTTP. -Key behaviours: +Perilaku utama: -- WS upgrade validated by `src/lib/ws/handshake.ts` before the connection is established -- Streams terminated cleanly on session close or upstream error -- Works alongside the existing HTTP+SSE streaming path simultaneously +- Upgrade WS divalidasi oleh `src/lib/ws/handshake.ts` sebelum koneksi dibuat +- Aliran dihentikan dengan bersih saat sesi ditutup atau terjadi kesalahan upstream +- Berfungsi berdampingan dengan jalur streaming HTTP+SSE yang ada secara bersamaan --- -## 🔑 Sync Tokens & Config Bundle _(v3.6.6+)_ +## 🔑 Token Sinkronisasi & Bundel Konfigurasi _(v3.6.6+)_ -Multi-device and external operator access is now possible via **scoped sync tokens**: +Akses multi-perangkat dan operator eksternal kini dimungkinkan melalui **token sinkronisasi bercakupan**: -- **`POST /api/sync/tokens`** — Issue a new sync token (scoped, with optional expiry) -- **`DELETE /api/sync/tokens/:id`** — Revoke a token -- **`GET /api/sync/bundle`** — Download a versioned, ETag-keyed JSON snapshot of all non-sensitive settings (passwords redacted) +- **`POST /api/sync/tokens`** — Terbitkan token sinkronisasi baru (bercakupan, dengan kedaluwarsa opsional) +- **`DELETE /api/sync/tokens/:id`** — Cabut token +- **`GET /api/sync/bundle`** — Unduh snapshot JSON berversi dan berkey ETag dari semua pengaturan tidak sensitif (kata sandi disunting) -The config bundle is built by `src/lib/sync/bundle.ts`. Consumers compare the `ETag` response header to detect changes without re-downloading the full payload. +Bundel konfigurasi dibuat oleh `src/lib/sync/bundle.ts`. Konsumen membandingkan header respons `ETag` untuk mendeteksi perubahan tanpa mengunduh ulang payload penuh. --- -## 🧠 GLM Thinking Preset _(v3.6.6+)_ +## 🧠 Prasetel GLM Thinking _(v3.6.6+)_ -**GLM Thinking (`glmt`)** is now a registered first-class provider: 65 536 max output tokens, 24 576 thinking budget, 900 s default timeout, Claude-compatible API format, and shared usage sync with the GLM family. +**GLM Thinking (`glmt`)** kini merupakan penyedia kelas pertama yang terdaftar: 65 536 token output maksimum, anggaran thinking 24 576, timeout default 900 detik, format API yang kompatibel dengan Claude, dan sinkronisasi penggunaan bersama dengan keluarga GLM. -**Hybrid token counting** also lands in v3.6.6: when a Claude-compatible provider exposes `/messages/count_tokens`, OmniRoute calls it before large requests with graceful estimation fallback. +**Penghitungan token hibrida** juga hadir di v3.6.6: ketika penyedia yang kompatibel dengan Claude mengekspos `/messages/count_tokens`, OmniRoute memanggilnya sebelum permintaan besar dengan fallback estimasi yang baik. --- -## 🛡️ Safe Outbound Fetch & SSRF Guard _(v3.6.6+)_ +## 🛡️ Fetch Keluar Aman & Penjaga SSRF _(v3.6.6+)_ -All provider validation and model discovery calls now go through a two-layer outbound guard: +Semua panggilan validasi penyedia dan penemuan model kini melewati penjaga keluar dua lapis: -1. **URL guard** (`src/shared/network/outboundUrlGuard.ts`) — Blocks private/loopback/link-local IP ranges before the socket is opened. -2. **Safe fetch wrapper** (`src/shared/network/safeOutboundFetch.ts`) — Applies the URL guard, normalises timeouts, and retries transient errors with exponential backoff. +1. **Penjaga URL** (`src/shared/network/outboundUrlGuard.ts`) — Memblokir rentang IP privat/loopback/link-local sebelum soket dibuka. +2. **Pembungkus fetch aman** (`src/shared/network/safeOutboundFetch.ts`) — Menerapkan penjaga URL, menormalkan timeout, dan mencoba ulang kesalahan transien dengan backoff eksponensial. -Guard violations surface as HTTP 422 (`URL_GUARD_BLOCKED`) and are written to the compliance audit log via `providerAudit.ts`. +Pelanggaran penjaga muncul sebagai HTTP 422 (`URL_GUARD_BLOCKED`) dan ditulis ke log audit kepatuhan melalui `providerAudit.ts`. --- -## 🔄 Cooldown-Aware Retries _(v3.6.6+)_ +## 🔄 Percobaan Ulang yang Mempertimbangkan Cooldown _(v3.6.6+)_ -Chat requests now **automatically retry** when an upstream provider returns a model-scoped cooldown. Configurable via `REQUEST_RETRY` (default: 2) and `MAX_RETRY_INTERVAL_SEC` (default: 30 s). Rate-limit header learning improved across `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`, and `Retry-After` — per-model cooldown state is visible in the Resilience dashboard. +Permintaan chat kini **secara otomatis mencoba ulang** ketika penyedia upstream mengembalikan cooldown bercakupan model. Dapat dikonfigurasi melalui `REQUEST_RETRY` (default: 2) dan `MAX_RETRY_INTERVAL_SEC` (default: 30 detik). Pembelajaran header batas laju yang ditingkatkan di seluruh `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`, dan `Retry-After` — status cooldown per model terlihat di dashboard Ketahanan. --- -## 📋 Compliance Audit v2 _(v3.6.6+)_ +## 📋 Audit Kepatuhan v2 _(v3.6.6+)_ -The audit log has been expanded with cursor-based pagination, request context enrichment (request ID, user agent, IP), structured auth events, provider CRUD events with diff context, and SSRF-blocked validation logging. New events emitted by `src/lib/compliance/providerAudit.ts`. +Log audit telah diperluas dengan paginasi berbasis kursor, pengayaan konteks permintaan (ID permintaan, user agent, IP), kejadian autentikasi terstruktur, kejadian CRUD penyedia dengan konteks diff, dan pencatatan validasi yang diblokir SSRF. Kejadian baru dipancarkan oleh `src/lib/compliance/providerAudit.ts`. diff --git a/docs/i18n/id/docs/guides/I18N.md b/docs/i18n/id/docs/guides/I18N.md index 910370dcae..efd27f4fa3 100644 --- a/docs/i18n/id/docs/guides/I18N.md +++ b/docs/i18n/id/docs/guides/I18N.md @@ -1,77 +1,77 @@ -# i18n — Internationalization Guide (Bahasa Indonesia) +# i18n — Panduan Internasionalisasi (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/I18N.md) · 🇸🇦 [ar](../../ar/docs/I18N.md) · 🇧🇬 [bg](../../bg/docs/I18N.md) · 🇧🇩 [bn](../../bn/docs/I18N.md) · 🇨🇿 [cs](../../cs/docs/I18N.md) · 🇩🇰 [da](../../da/docs/I18N.md) · 🇩🇪 [de](../../de/docs/I18N.md) · 🇪🇸 [es](../../es/docs/I18N.md) · 🇮🇷 [fa](../../fa/docs/I18N.md) · 🇫🇮 [fi](../../fi/docs/I18N.md) · 🇫🇷 [fr](../../fr/docs/I18N.md) · 🇮🇳 [gu](../../gu/docs/I18N.md) · 🇮🇱 [he](../../he/docs/I18N.md) · 🇮🇳 [hi](../../hi/docs/I18N.md) · 🇭🇺 [hu](../../hu/docs/I18N.md) · 🇮🇩 [id](../../id/docs/I18N.md) · 🇮🇹 [it](../../it/docs/I18N.md) · 🇯🇵 [ja](../../ja/docs/I18N.md) · 🇰🇷 [ko](../../ko/docs/I18N.md) · 🇮🇳 [mr](../../mr/docs/I18N.md) · 🇲🇾 [ms](../../ms/docs/I18N.md) · 🇳🇱 [nl](../../nl/docs/I18N.md) · 🇳🇴 [no](../../no/docs/I18N.md) · 🇵🇭 [phi](../../phi/docs/I18N.md) · 🇵🇱 [pl](../../pl/docs/I18N.md) · 🇵🇹 [pt](../../pt/docs/I18N.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/I18N.md) · 🇷🇴 [ro](../../ro/docs/I18N.md) · 🇷🇺 [ru](../../ru/docs/I18N.md) · 🇸🇰 [sk](../../sk/docs/I18N.md) · 🇸🇪 [sv](../../sv/docs/I18N.md) · 🇰🇪 [sw](../../sw/docs/I18N.md) · 🇮🇳 [ta](../../ta/docs/I18N.md) · 🇮🇳 [te](../../te/docs/I18N.md) · 🇹🇭 [th](../../th/docs/I18N.md) · 🇹🇷 [tr](../../tr/docs/I18N.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/I18N.md) · 🇵🇰 [ur](../../ur/docs/I18N.md) · 🇻🇳 [vi](../../vi/docs/I18N.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/I18N.md) --- -OmniRoute supports **30 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. +OmniRoute mendukung **30 bahasa** dengan terjemahan UI dashboard penuh, dokumentasi yang diterjemahkan, dan dukungan RTL untuk bahasa Arab dan Ibrani. -## Quick Reference +## Referensi Cepat -| Task | Command | -| ---------------------- | --------------------------------------------------------------------------------------- | -| Generate translations | `node scripts/i18n/generate-multilang.mjs messages` | -| Translate docs (LLM) | `python3 scripts/i18n_autotranslate.py --api-url --api-key --model ` | -| Validate a locale | `python3 scripts/validate_translation.py quick -l cs` | -| Check code keys | `python3 scripts/check_translations.py` | -| Generate QA report | `node scripts/i18n/generate-qa-checklist.mjs` | -| Visual QA (Playwright) | `node scripts/i18n/run-visual-qa.mjs` | +| Tugas | Perintah | +| ------------------------------ | --------------------------------------------------------------------------------------- | +| Hasilkan terjemahan | `node scripts/i18n/generate-multilang.mjs messages` | +| Terjemahkan dokumentasi (LLM) | `python3 scripts/i18n_autotranslate.py --api-url --api-key --model ` | +| Validasi lokal | `python3 scripts/validate_translation.py quick -l cs` | +| Periksa kunci kode | `python3 scripts/check_translations.py` | +| Hasilkan laporan QA | `node scripts/i18n/generate-qa-checklist.mjs` | +| QA Visual (Playwright) | `node scripts/i18n/run-visual-qa.mjs` | ## Arsitektur -### Source of Truth +### Sumber Kebenaran -- **UI strings**: `src/i18n/messages/en.json` (English source, ~2800 keys) -- **Locale files**: `src/i18n/messages/{locale}.json` (30 translations) -- **Framework**: `next-intl` with cookie-based locale resolution -- **Config**: `src/i18n/config.ts` — defines all 30 locales, language names, flags +- **String UI**: `src/i18n/messages/en.json` (sumber bahasa Inggris, ~2800 kunci) +- **File lokal**: `src/i18n/messages/{locale}.json` (30 terjemahan) +- **Framework**: `next-intl` dengan resolusi lokal berbasis cookie +- **Konfigurasi**: `src/i18n/config.ts` — mendefinisikan semua 30 lokal, nama bahasa, bendera -### Runtime Flow +### Alur Runtime -1. User selects language → `NEXT_LOCALE` cookie set -2. `src/i18n/request.ts` resolves locale: cookie → `Accept-Language` header → fallback `en` -3. Dynamic import loads `messages/{locale}.json` -4. Components use `useTranslations("namespace")` and `t("key")` +1. Pengguna memilih bahasa → cookie `NEXT_LOCALE` disetel +2. `src/i18n/request.ts` menyelesaikan lokal: cookie → header `Accept-Language` → fallback `en` +3. Import dinamis memuat `messages/{locale}.json` +4. Komponen menggunakan `useTranslations("namespace")` dan `t("key")` -### Supported Locales +### Lokal yang Didukung -| Code | Language | RTL | Google Translate Code | +| Kode | Bahasa | RTL | Kode Google Translate | | ------- | -------------------- | --- | --------------------- | -| `ar` | العربية | Yes | `ar` | -| `bg` | Български | No | `bg` | -| `cs` | Čeština | No | `cs` | -| `da` | Dansk | No | `da` | -| `de` | Deutsch | No | `de` | -| `es` | Español | No | `es` | -| `fi` | Suomi | No | `fi` | -| `fr` | Français | No | `fr` | -| `he` | עברית | Yes | `iw` | -| `hi` | हिन्दी | No | `hi` | -| `hu` | Magyar | No | `hu` | -| `id` | Bahasa Indonesia | No | `id` | -| `it` | Italiano | No | `it` | -| `ja` | 日本語 | No | `ja` | -| `ko` | 한국어 | No | `ko` | -| `ms` | Bahasa Melayu | No | `ms` | -| `nl` | Nederlands | No | `nl` | -| `no` | Norsk | No | `no` | -| `phi` | Filipino | No | `tl` | -| `pl` | Polski | No | `pl` | -| `pt` | Português (Portugal) | No | `pt` | -| `pt-BR` | Português (Brasil) | No | `pt` | -| `ro` | Română | No | `ro` | -| `ru` | Русский | No | `ru` | -| `sk` | Slovenčina | No | `sk` | -| `sv` | Svenska | No | `sv` | -| `th` | ไทย | No | `th` | -| `tr` | Türkçe | No | `tr` | -| `uk-UA` | Українська | No | `uk` | -| `vi` | Tiếng Việt | No | `vi` | -| `zh-CN` | 中文 (简体) | No | `zh-CN` | +| `ar` | العربية | Ya | `ar` | +| `bg` | Български | Tidak | `bg` | +| `cs` | Čeština | Tidak | `cs` | +| `da` | Dansk | Tidak | `da` | +| `de` | Deutsch | Tidak | `de` | +| `es` | Español | Tidak | `es` | +| `fi` | Suomi | Tidak | `fi` | +| `fr` | Français | Tidak | `fr` | +| `he` | עברית | Ya | `iw` | +| `hi` | हिन्दी | Tidak | `hi` | +| `hu` | Magyar | Tidak | `hu` | +| `id` | Bahasa Indonesia | Tidak | `id` | +| `it` | Italiano | Tidak | `it` | +| `ja` | 日本語 | Tidak | `ja` | +| `ko` | 한국어 | Tidak | `ko` | +| `ms` | Bahasa Melayu | Tidak | `ms` | +| `nl` | Nederlands | Tidak | `nl` | +| `no` | Norsk | Tidak | `no` | +| `phi` | Filipino | Tidak | `tl` | +| `pl` | Polski | Tidak | `pl` | +| `pt` | Português (Portugal) | Tidak | `pt` | +| `pt-BR` | Português (Brasil) | Tidak | `pt` | +| `ro` | Română | Tidak | `ro` | +| `ru` | Русский | Tidak | `ru` | +| `sk` | Slovenčina | Tidak | `sk` | +| `sv` | Svenska | Tidak | `sv` | +| `th` | ไทย | Tidak | `th` | +| `tr` | Türkçe | Tidak | `tr` | +| `uk-UA` | Українська | Tidak | `uk` | +| `vi` | Tiếng Việt | Tidak | `vi` | +| `zh-CN` | 中文 (简体) | Tidak | `zh-CN` | -## Adding a New Language +## Menambahkan Bahasa Baru -### 1. Register the Locale +### 1. Daftarkan Lokal Edit `src/i18n/config.ts`: @@ -82,9 +82,9 @@ Edit `src/i18n/config.ts`: { code: "xx", label: "XX", name: "Language Name", flag: "🏳️" }, ``` -### 2. Add to Generator +### 2. Tambahkan ke Generator -Edit `scripts/i18n/generate-multilang.mjs` — add entry to `LOCALE_SPECS`: +Edit `scripts/i18n/generate-multilang.mjs` — tambahkan entri ke `LOCALE_SPECS`: ```js { @@ -98,70 +98,70 @@ Edit `scripts/i18n/generate-multilang.mjs` — add entry to `LOCALE_SPECS`: }, ``` -### 3. Generate Initial Translation +### 3. Hasilkan Terjemahan Awal ```bash node scripts/i18n/generate-multilang.mjs messages ``` -This creates `src/i18n/messages/xx.json` auto-translated from `en.json` via Google Translate. +Perintah ini membuat `src/i18n/messages/xx.json` yang diterjemahkan secara otomatis dari `en.json` melalui Google Translate. -### 4. Review & Fix Auto-Translations +### 4. Tinjau & Perbaiki Terjemahan Otomatis -Auto-translations are a starting point. Review manually for: +Terjemahan otomatis hanyalah titik awal. Tinjau secara manual untuk: -- Technical accuracy -- Context-appropriate terminology -- Proper handling of placeholders (`{count}`, `{value}`, etc.) +- Ketepatan teknis +- Terminologi yang sesuai konteks +- Penanganan placeholder yang benar (`{count}`, `{value}`, dll.) -### 5. Validate +### 5. Validasi ```bash python3 scripts/validate_translation.py quick -l xx python3 scripts/validate_translation.py diff common -l xx ``` -### 6. Generate Translated Documentation +### 6. Hasilkan Dokumentasi yang Diterjemahkan ```bash node scripts/i18n/generate-multilang.mjs docs ``` -## Auto-Translation Pipeline +## Pipeline Terjemahan Otomatis ### generate-multilang.mjs (Google Translate) -**Primary auto-translation engine** — uses Google Translate free API to generate translations for UI strings, READMEs, and documentation. +**Mesin terjemahan otomatis utama** — menggunakan API gratis Google Translate untuk menghasilkan terjemahan string UI, README, dan dokumentasi. ```bash node scripts/i18n/generate-multilang.mjs [messages|readme|docs|all] ``` -| Mode | What it does | +| Mode | Yang dilakukan | | ---------- | ----------------------------------------------------------------------------- | -| `messages` | Translates missing keys in `src/i18n/messages/{locale}.json` from `en.json` | -| `readme` | Translates `README.md` into all locales as `README.{code}.md` in project root | -| `docs` | Translates `DOC_SOURCE_FILES` into `docs/i18n/{locale}/{docName}` | -| `all` | Runs all three modes | +| `messages` | Menerjemahkan kunci yang hilang di `src/i18n/messages/{locale}.json` dari `en.json` | +| `readme` | Menerjemahkan `README.md` ke semua lokal sebagai `README.{code}.md` di root proyek | +| `docs` | Menerjemahkan `DOC_SOURCE_FILES` ke `docs/i18n/{locale}/{docName}` | +| `all` | Menjalankan ketiga mode sekaligus | -**Features:** +**Fitur:** -- **Text protection**: Masks code blocks (` ``` `), inline code (`` ` ``), markdown links/images (`[text](url)`), HTML tags, tables, and ICU placeholders (`{count}`, `{value}`, `{total}`, etc.) before translation, then restores them -- **Chunked batching**: Joins multiple strings with `__OMNIROUTE_I18N_SEPARATOR__` delimiters to minimize API calls (max 1800 chars per request) -- **In-memory cache**: Avoids redundant API calls for repeated strings within a session -- **Retry logic**: Exponential backoff (up to 5 attempts with 300ms × attempt delay) for 429/5xx errors -- **Timeout**: 20 seconds per request -- **Skip existing**: If target file already exists, it is NOT overwritten +- **Perlindungan teks**: Menyembunyikan blok kode (` ``` `), kode inline (`` ` ``), tautan/gambar markdown (`[text](url)`), tag HTML, tabel, dan placeholder ICU (`{count}`, `{value}`, `{total}`, dll.) sebelum penerjemahan, lalu memulihkannya +- **Pemrosesan bertahap**: Menggabungkan beberapa string dengan pemisah `__OMNIROUTE_I18N_SEPARATOR__` untuk meminimalkan panggilan API (maks 1800 karakter per permintaan) +- **Cache dalam memori**: Menghindari panggilan API yang redundan untuk string yang berulang dalam satu sesi +- **Logika percobaan ulang**: Backoff eksponensial (hingga 5 percobaan dengan penundaan 300ms × percobaan) untuk error 429/5xx +- **Batas waktu**: 20 detik per permintaan +- **Lewati yang sudah ada**: Jika file target sudah ada, file tersebut TIDAK akan ditimpa -**Important behaviors:** +**Perilaku penting:** -- `docs/i18n/README.md` is **regenerated** each run — it's an auto-generated index of all docs -- Root `README.{code}.md` files are only created if they don't exist (skips locales in `EXISTING_README_CODES`) -- Language bars (`🌐 **Languages:** ...`) are automatically inserted/updated in all translated docs +- `docs/i18n/README.md` **dihasilkan ulang** setiap kali dijalankan — ini adalah indeks dokumentasi yang dibuat otomatis +- File `README.{code}.md` di root hanya dibuat jika belum ada (melewati lokal yang ada di `EXISTING_README_CODES`) +- Bilah bahasa (`🌐 **Languages:** ...`) disisipkan/diperbarui secara otomatis di semua dokumen yang diterjemahkan -### i18n_autotranslate.py (LLM-based) +### i18n_autotranslate.py (berbasis LLM) -**Secondary translator** — uses any OpenAI-compatible LLM API (including OmniRoute itself) to translate existing `docs/i18n/` markdown files. Best for polishing or re-translating docs with better quality than Google Translate. +**Penerjemah sekunder** — menggunakan API LLM yang kompatibel dengan OpenAI (termasuk OmniRoute sendiri) untuk menerjemahkan file markdown `docs/i18n/` yang sudah ada. Paling baik digunakan untuk menyempurnakan atau menerjemahkan ulang dokumen dengan kualitas yang lebih baik dari Google Translate. ```bash python3 scripts/i18n_autotranslate.py \ @@ -170,18 +170,18 @@ python3 scripts/i18n_autotranslate.py \ --model gpt-4o ``` -**Features:** +**Fitur:** -- Scans `docs/i18n/` markdown files for English paragraphs -- Skips code blocks, tables, and already-translated content -- Sends paragraphs to LLM with technical translation system prompt -- Supports all 30 languages +- Memindai file markdown `docs/i18n/` untuk paragraf berbahasa Inggris +- Melewati blok kode, tabel, dan konten yang sudah diterjemahkan +- Mengirimkan paragraf ke LLM dengan prompt sistem penerjemahan teknis +- Mendukung semua 30 bahasa -## Validation & QA +## Validasi & QA ### validate_translation.py -**Translation validator** — compares any locale JSON against `en.json` and reports issues. +**Validator terjemahan** — membandingkan JSON lokal mana pun dengan `en.json` dan melaporkan masalah yang ditemukan. ```bash # Quick check (counts only) @@ -205,26 +205,26 @@ python3 scripts/validate_translation.py md -l cs > report.md python3 scripts/validate_translation.py -l cs ``` -**Detects:** +**Yang dideteksi:** -- **Missing keys** — keys in `en.json` but not in locale file -- **Extra keys** — keys in locale file but not in `en.json` -- **Untranslated keys** — keys where locale value equals English source (excluding allowlist) -- **Placeholder mismatches** — ICU placeholders that don't match between source and translation +- **Kunci yang hilang** — kunci yang ada di `en.json` tetapi tidak ada di file lokal +- **Kunci tambahan** — kunci yang ada di file lokal tetapi tidak ada di `en.json` +- **Kunci yang belum diterjemahkan** — kunci yang nilai lokalnya sama dengan sumber bahasa Inggris (tidak termasuk daftar yang diizinkan) +- **Ketidaksesuaian placeholder** — placeholder ICU yang tidak cocok antara sumber dan terjemahan -**Exit codes:** -| Code | Meaning | -|------|---------| +**Kode keluar:** +| Kode | Makna | +|------|-------| | 0 | OK | -| 1 | Generic error | -| 2 | Missing strings (hard error) | -| 3 | Untranslated warning (soft) | +| 1 | Error umum | +| 2 | String yang hilang (error kritis) | +| 3 | Peringatan belum diterjemahkan (lunak) | -**Environment:** Set `TRANSLATION_LANG=cs` or use `-l cs` flag. +**Lingkungan:** Setel `TRANSLATION_LANG=cs` atau gunakan flag `-l cs`. ### check_translations.py -**Code-to-JSON key checker** — scans `src/**/*.tsx` and `src/**/*.ts` for `useTranslations()` calls and verifies all referenced keys exist in `en.json`. +**Pemeriksa kunci kode ke JSON** — memindai `src/**/*.tsx` dan `src/**/*.ts` untuk panggilan `useTranslations()` dan memverifikasi bahwa semua kunci yang direferensikan ada di `en.json`. ```bash # Basic check @@ -239,25 +239,25 @@ python3 scripts/check_translations.py --fix ### generate-qa-checklist.mjs -**Static analysis QA** — scans Next.js page files for i18n risk metrics and generates a Markdown report. +**QA analisis statis** — memindai file halaman Next.js untuk metrik risiko i18n dan menghasilkan laporan Markdown. ```bash node scripts/i18n/generate-qa-checklist.mjs ``` -**Checks:** +**Yang diperiksa:** -- Fixed-width class usage (overflow risk) -- Directional left/right classes (RTL risk) -- Clipping-prone patterns -- Locale parity (missing/extra keys vs `en.json`) -- README language selector bars in priority locales (`es`, `fr`, `de`, `ja`, `ar`) +- Penggunaan kelas lebar tetap (risiko overflow) +- Kelas arah kiri/kanan (risiko RTL) +- Pola rentan terpotong +- Paritas lokal (kunci yang hilang/tambahan vs `en.json`) +- Bilah pemilih bahasa README di lokal prioritas (`es`, `fr`, `de`, `ja`, `ar`) -**Output:** `docs/reports/i18n-qa-checklist-{date}.md` +**Keluaran:** `docs/reports/i18n-qa-checklist-{date}.md` ### run-visual-qa.mjs -**Visual QA via Playwright** — takes screenshots of all dashboard routes in multiple locales and viewports, then evaluates page health. +**QA visual melalui Playwright** — mengambil tangkapan layar semua rute dashboard dalam beberapa lokal dan viewport, lalu mengevaluasi kesehatan halaman. ```bash # Default: es, fr, de, ja, ar on localhost:20128 @@ -270,21 +270,21 @@ QA_BASE_URL=http://staging.example.com QA_LOCALES=de,fr node scripts/i18n/run-vi QA_ROUTES=/dashboard/settings,/dashboard/providers node scripts/i18n/run-visual-qa.mjs ``` -**Detects:** +**Yang dideteksi:** -- Text overflow -- Element clipping -- RTL layout mismatches +- Teks yang meluap (text overflow) +- Elemen yang terpotong +- Ketidaksesuaian tata letak RTL -**Output:** `docs/reports/i18n-visual-qa-{date}.md` + JSON report +**Keluaran:** `docs/reports/i18n-visual-qa-{date}.md` + laporan JSON -## Managing Untranslatable Keys +## Mengelola Kunci yang Tidak Dapat Diterjemahkan ### untranslatable-keys.json **File:** `scripts/i18n/untranslatable-keys.json` -Allowlist of keys that should remain identical to English source. Used by `validate_translation.py` to avoid false-positive "untranslated" warnings. +Daftar yang diizinkan berisi kunci yang harus tetap identik dengan sumber bahasa Inggris. Digunakan oleh `validate_translation.py` untuk menghindari peringatan "belum diterjemahkan" yang merupakan positif palsu. ```json { @@ -298,26 +298,26 @@ Allowlist of keys that should remain identical to English source. Used by `valid } ``` -**What belongs here:** +**Yang termasuk di sini:** -- Brand/product names: `landing.brandName`, `common.social-github` -- Technical terms/acronyms: `health.cpu`, `mcpDashboard.pid`, `settings.ai` -- ICU/format strings: `apiManager.modelsCount`, `health.millisecondsShort` -- Placeholder values: `providers.openaiBaseUrlPlaceholder`, `cliTools.baseUrlPlaceholder` -- Protocol names: `common.http`, `common.oauth`, `providers.oauth2Label` -- Navigation sections: `sidebar.primarySection`, `sidebar.cliSection` +- Nama merek/produk: `landing.brandName`, `common.social-github` +- Istilah teknis/akronim: `health.cpu`, `mcpDashboard.pid`, `settings.ai` +- String ICU/format: `apiManager.modelsCount`, `health.millisecondsShort` +- Nilai placeholder: `providers.openaiBaseUrlPlaceholder`, `cliTools.baseUrlPlaceholder` +- Nama protokol: `common.http`, `common.oauth`, `providers.oauth2Label` +- Bagian navigasi: `sidebar.primarySection`, `sidebar.cliSection` -**To add a key:** Edit the `keys` array in `scripts/i18n/untranslatable-keys.json` and re-run validation. +**Untuk menambahkan kunci:** Edit array `keys` di `scripts/i18n/untranslatable-keys.json` dan jalankan ulang validasi. -## CI Integration +## Integrasi CI ### GitHub Actions (`.github/workflows/ci.yml`) -The CI pipeline validates all locales on every push and PR: +Pipeline CI memvalidasi semua lokal pada setiap push dan PR: -1. **`i18n-matrix` job** — dynamically discovers all locale files (excluding `en.json`) -2. **`i18n` job** — runs `validate_translation.py quick -l ''` for each locale in parallel -3. **`ci-summary` job** — aggregates results into a dashboard summary +1. **Job `i18n-matrix`** — secara dinamis menemukan semua file lokal (tidak termasuk `en.json`) +2. **Job `i18n`** — menjalankan `validate_translation.py quick -l ''` untuk setiap lokal secara paralel +3. **Job `ci-summary`** — mengagregasi hasil menjadi ringkasan dashboard ```yaml # i18n-matrix: discovers languages @@ -327,7 +327,7 @@ LANGS=$(ls src/i18n/messages/*.json | xargs -n1 basename | sed 's/.json$//' | gr python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}' ``` -**Dashboard output:** +**Keluaran dashboard:** ``` ## 🌍 Translations @@ -339,63 +339,63 @@ python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}' ✅ All translations complete ``` -## File Structure +## Struktur File ``` src/i18n/ -├── config.ts # Locale definitions (30 locales, RTL config) -├── request.ts # Runtime locale resolution +├── config.ts # Definisi lokal (30 lokal, konfigurasi RTL) +├── request.ts # Resolusi lokal saat runtime └── messages/ - ├── en.json # Source of truth (~2800 keys) - ├── cs.json # Czech translation - ├── de.json # German translation - └── ... # 30 locale files total + ├── en.json # Sumber kebenaran (~2800 kunci) + ├── cs.json # Terjemahan bahasa Ceko + ├── de.json # Terjemahan bahasa Jerman + └── ... # Total 30 file lokal scripts/ ├── i18n/ -│ ├── generate-multilang.mjs # Auto-translation engine (Google Translate, 888 lines) -│ ├── generate-qa-checklist.mjs # Static analysis QA -│ ├── run-visual-qa.mjs # Playwright visual QA -│ └── untranslatable-keys.json # Allowlist for validation (236 keys) -├── validate_translation.py # Translation validator -├── check_translations.py # Code-to-JSON key checker -└── i18n_autotranslate.py # LLM-based doc translator +│ ├── generate-multilang.mjs # Mesin terjemahan otomatis (Google Translate, 888 baris) +│ ├── generate-qa-checklist.mjs # QA analisis statis +│ ├── run-visual-qa.mjs # QA visual Playwright +│ └── untranslatable-keys.json # Daftar yang diizinkan untuk validasi (236 kunci) +├── validate_translation.py # Validator terjemahan +├── check_translations.py # Pemeriksa kunci kode ke JSON +└── i18n_autotranslate.py # Penerjemah dokumentasi berbasis LLM .github/workflows/ -└── ci.yml # i18n validation in CI matrix +└── ci.yml # Validasi i18n dalam matriks CI docs/ -├── I18N.md # This file — i18n toolchain documentation +├── I18N.md # File ini — dokumentasi toolchain i18n ├── i18n/ -│ ├── README.md # Auto-generated language index -│ ├── cs/ # Czech docs +│ ├── README.md # Indeks bahasa yang dibuat otomatis +│ ├── cs/ # Dokumentasi bahasa Ceko │ │ └── docs/ -│ │ ├── I18N.md # Czech translation of this file +│ │ ├── I18N.md # Terjemahan bahasa Ceko dari file ini │ │ └── ... -│ ├── de/ # German docs -│ └── ... # 30 locale directories +│ ├── de/ # Dokumentasi bahasa Jerman +│ └── ... # 30 direktori lokal └── reports/ - ├── i18n-qa-checklist-*.md # Static analysis reports - └── i18n-visual-qa-*.md # Visual QA reports + ├── i18n-qa-checklist-*.md # Laporan analisis statis + └── i18n-visual-qa-*.md # Laporan QA visual ``` -## Best Practices +## Praktik Terbaik -### When Editing Translations +### Saat Mengedit Terjemahan -1. **Always edit `en.json` first** — it's the source of truth -2. **Run `generate-multilang.mjs messages`** to propagate new keys to all locales -3. **Review auto-translations** — Google Translate is a starting point, not final -4. **Validate before committing** — `python3 scripts/validate_translation.py quick -l ` -5. **Update `untranslatable-keys.json`** if a key should remain in English +1. **Selalu edit `en.json` terlebih dahulu** — itulah sumber kebenaran +2. **Jalankan `generate-multilang.mjs messages`** untuk menyebarkan kunci baru ke semua lokal +3. **Tinjau terjemahan otomatis** — Google Translate hanyalah titik awal, bukan hasil akhir +4. **Validasi sebelum melakukan commit** — `python3 scripts/validate_translation.py quick -l ` +5. **Perbarui `untranslatable-keys.json`** jika sebuah kunci harus tetap dalam bahasa Inggris -### Placeholder Safety +### Keamanan Placeholder -- ICU placeholders (`{count}`, `{value}`, `{total}`, `{seconds}`) must be preserved exactly -- Plural formats (`{count, plural, one {# model} other {# models}}`) must maintain structure -- The validator detects placeholder mismatches automatically +- Placeholder ICU (`{count}`, `{value}`, `{total}`, `{seconds}`) harus dipertahankan persis sama +- Format jamak (`{count, plural, one {# model} other {# models}}`) harus mempertahankan strukturnya +- Validator mendeteksi ketidaksesuaian placeholder secara otomatis -### Adding New Translation Keys in Code +### Menambahkan Kunci Terjemahan Baru dalam Kode ```tsx // Use namespaced keys @@ -406,33 +406,33 @@ t("cacheSettings"); // maps to settings.cacheSettings in JSON python3 scripts/check_translations.py --verbose ``` -### RTL Considerations +### Pertimbangan RTL -- Arabic (`ar`) and Hebrew (`he`) are RTL locales -- Avoid hardcoded `left`/`right` CSS — use `start`/`end` logical properties -- Visual QA catches RTL layout mismatches via `run-visual-qa.mjs` +- Bahasa Arab (`ar`) dan Ibrani (`he`) adalah lokal RTL +- Hindari `left`/`right` CSS yang dikodekan keras — gunakan properti logis `start`/`end` +- QA visual menangkap ketidaksesuaian tata letak RTL melalui `run-visual-qa.mjs` -## Known Issues & History +## Masalah yang Diketahui & Riwayat -### `in.json` → `hi.json` Fix +### Perbaikan `in.json` → `hi.json` -The generator originally used `code: "in"` (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 `hi`. This created an orphaned `in.json` duplicate of `hi.json`. Fixed by changing `code: "in"` to `code: "hi"` in `generate-multilang.mjs` and removing the orphaned file. +Generator awalnya menggunakan `code: "in"` (kode Google Translate yang sudah usang) untuk bahasa Hindi, bukan ISO 639-1 yang benar yaitu `hi`. Hal ini menciptakan duplikat `in.json` yang tidak terhubung dari `hi.json`. Diperbaiki dengan mengubah `code: "in"` menjadi `code: "hi"` di `generate-multilang.mjs` dan menghapus file yang tidak terhubung tersebut. -### `docs/i18n/README.md` Is Auto-Generated +### `docs/i18n/README.md` Dibuat Secara Otomatis -The `docs/i18n/README.md` file is completely regenerated by `generate-multilang.mjs docs`. Any manual edits will be lost. Use `docs/guides/I18N.md` (this file) for hand-written documentation that should persist. +File `docs/i18n/README.md` sepenuhnya dihasilkan ulang oleh `generate-multilang.mjs docs`. Setiap pengeditan manual akan hilang. Gunakan `docs/guides/I18N.md` (file ini) untuk dokumentasi yang ditulis tangan yang harus tetap ada. -### External Untranslatable Keys List +### Daftar Kunci yang Tidak Dapat Diterjemahkan Eksternal -The `untranslatable-keys.json` allowlist was moved from an inline Python set in `validate_translation.py` to an external JSON file for easier maintenance. The validator loads it at runtime. +Daftar yang diizinkan `untranslatable-keys.json` dipindahkan dari set Python inline di `validate_translation.py` ke file JSON eksternal agar lebih mudah dipelihara. Validator memuatnya saat runtime. -### `generate-multilang.mjs` Hindi Code Fix +### Perbaikan Kode Hindi di `generate-multilang.mjs` -The generator originally used `code: "in"` (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 `hi`. This was introduced in upstream commit `952b0b22c` by `diegosouzapw`. Fixed by changing `code: "in"` to `code: "hi"` in the `LOCALE_SPECS` array and removing the orphaned `in.json` file. +Generator awalnya menggunakan `code: "in"` (kode Google Translate yang sudah usang) untuk bahasa Hindi, bukan ISO 639-1 yang benar yaitu `hi`. Ini diperkenalkan dalam commit upstream `952b0b22c` oleh `diegosouzapw`. Diperbaiki dengan mengubah `code: "in"` menjadi `code: "hi"` di array `LOCALE_SPECS` dan menghapus file `in.json` yang tidak terhubung. -### `validate_translation.py` Ignored Count Output +### Keluaran Jumlah Kunci yang Diabaikan di `validate_translation.py` -The `quick` check now displays the count of ignored keys from `untranslatable-keys.json`: +Pemeriksaan `quick` kini menampilkan jumlah kunci yang diabaikan dari `untranslatable-keys.json`: ``` Missing: 0 diff --git a/docs/i18n/id/docs/guides/TROUBLESHOOTING.md b/docs/i18n/id/docs/guides/TROUBLESHOOTING.md index ae8156e5ef..73fe032261 100644 --- a/docs/i18n/id/docs/guides/TROUBLESHOOTING.md +++ b/docs/i18n/id/docs/guides/TROUBLESHOOTING.md @@ -1,72 +1,72 @@ -# Troubleshooting (Bahasa Indonesia) +# Pemecahan Masalah (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/TROUBLESHOOTING.md) · 🇸🇦 [ar](../../ar/docs/TROUBLESHOOTING.md) · 🇧🇬 [bg](../../bg/docs/TROUBLESHOOTING.md) · 🇧🇩 [bn](../../bn/docs/TROUBLESHOOTING.md) · 🇨🇿 [cs](../../cs/docs/TROUBLESHOOTING.md) · 🇩🇰 [da](../../da/docs/TROUBLESHOOTING.md) · 🇩🇪 [de](../../de/docs/TROUBLESHOOTING.md) · 🇪🇸 [es](../../es/docs/TROUBLESHOOTING.md) · 🇮🇷 [fa](../../fa/docs/TROUBLESHOOTING.md) · 🇫🇮 [fi](../../fi/docs/TROUBLESHOOTING.md) · 🇫🇷 [fr](../../fr/docs/TROUBLESHOOTING.md) · 🇮🇳 [gu](../../gu/docs/TROUBLESHOOTING.md) · 🇮🇱 [he](../../he/docs/TROUBLESHOOTING.md) · 🇮🇳 [hi](../../hi/docs/TROUBLESHOOTING.md) · 🇭🇺 [hu](../../hu/docs/TROUBLESHOOTING.md) · 🇮🇩 [id](../../id/docs/TROUBLESHOOTING.md) · 🇮🇹 [it](../../it/docs/TROUBLESHOOTING.md) · 🇯🇵 [ja](../../ja/docs/TROUBLESHOOTING.md) · 🇰🇷 [ko](../../ko/docs/TROUBLESHOOTING.md) · 🇮🇳 [mr](../../mr/docs/TROUBLESHOOTING.md) · 🇲🇾 [ms](../../ms/docs/TROUBLESHOOTING.md) · 🇳🇱 [nl](../../nl/docs/TROUBLESHOOTING.md) · 🇳🇴 [no](../../no/docs/TROUBLESHOOTING.md) · 🇵🇭 [phi](../../phi/docs/TROUBLESHOOTING.md) · 🇵🇱 [pl](../../pl/docs/TROUBLESHOOTING.md) · 🇵🇹 [pt](../../pt/docs/TROUBLESHOOTING.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/TROUBLESHOOTING.md) · 🇷🇴 [ro](../../ro/docs/TROUBLESHOOTING.md) · 🇷🇺 [ru](../../ru/docs/TROUBLESHOOTING.md) · 🇸🇰 [sk](../../sk/docs/TROUBLESHOOTING.md) · 🇸🇪 [sv](../../sv/docs/TROUBLESHOOTING.md) · 🇰🇪 [sw](../../sw/docs/TROUBLESHOOTING.md) · 🇮🇳 [ta](../../ta/docs/TROUBLESHOOTING.md) · 🇮🇳 [te](../../te/docs/TROUBLESHOOTING.md) · 🇹🇭 [th](../../th/docs/TROUBLESHOOTING.md) · 🇹🇷 [tr](../../tr/docs/TROUBLESHOOTING.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/TROUBLESHOOTING.md) · 🇵🇰 [ur](../../ur/docs/TROUBLESHOOTING.md) · 🇻🇳 [vi](../../vi/docs/TROUBLESHOOTING.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/TROUBLESHOOTING.md) --- -Common problems and solutions for OmniRoute. +Masalah umum dan solusinya untuk OmniRoute. --- -## Quick Fixes +## Perbaikan Cepat -| Problem | Solution | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| First login not working | Set `INITIAL_PASSWORD` in `.env` (no hardcoded default) | -| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | -| No logs written to disk | Set `APP_LOG_TO_FILE=true` and verify call log capture is enabled | -| EACCES: permission denied | Set `DATA_DIR=/path/to/writable/dir` to override `~/.omniroute` | -| Routing strategy not saving | Update to v1.4.11+ (Zod schema fix for settings persistence) | -| Login crash / blank page | Check Node.js version — see [Node.js Compatibility](#nodejs-compatibility) below | -| `dlopen` / `slice is not valid mach-o file` (macOS) | Run `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — see [macOS native module rebuild](#macos-native-module-rebuild) below | -| Proxy "fetch failed" | Ensure proxy config is set at the correct level — see [Proxy Issues](#proxy-issues) below | +| Masalah | Solusi | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Login pertama tidak berfungsi | Atur `INITIAL_PASSWORD` di `.env` (tidak ada nilai default yang dikodekan langsung) | +| Dashboard terbuka di port yang salah | Atur `PORT=20128` dan `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | +| Tidak ada log yang ditulis ke disk | Atur `APP_LOG_TO_FILE=true` dan pastikan pengambilan log panggilan diaktifkan | +| EACCES: permission denied | Atur `DATA_DIR=/path/to/writable/dir` untuk mengganti `~/.omniroute` | +| Strategi routing tidak tersimpan | Perbarui ke v1.4.11+ (perbaikan skema Zod untuk persistensi pengaturan) | +| Login crash / halaman kosong | Periksa versi Node.js — lihat [Kompatibilitas Node.js](#nodejs-compatibility) di bawah | +| `dlopen` / `slice is not valid mach-o file` (macOS) | Jalankan `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — lihat [Pembangunan ulang modul native macOS](#macos-native-module-rebuild) di bawah | +| Proxy "fetch failed" | Pastikan konfigurasi proxy diatur pada tingkat yang tepat — lihat [Masalah Proxy](#proxy-issues) di bawah | --- -## Node.js Compatibility +## Kompatibilitas Node.js -### Login page crashes or shows "Module self-registration" error +### Halaman login crash atau menampilkan error "Module self-registration" -**Cause:** You are running a Node.js version outside OmniRoute's approved secure runtime floor. The most common case is running an older Node 20, 22, or 24 patch level that falls below the patched security floor OmniRoute requires. +**Penyebab:** Anda menjalankan versi Node.js di luar batas runtime aman yang disetujui OmniRoute. Kasus paling umum adalah menjalankan Node 20, 22, atau 24 versi patch lama yang berada di bawah batas keamanan yang diperlukan OmniRoute. -**Symptoms:** +**Gejala:** -- Login page shows a blank screen or a server error -- Console shows `Error: Module did not self-register` or similar native binding errors -- The login page shows an **orange warning banner** with your Node version if the runtime is outside the supported secure policy +- Halaman login menampilkan layar kosong atau error server +- Konsol menampilkan `Error: Module did not self-register` atau error binding native serupa +- Halaman login menampilkan **banner peringatan oranye** dengan versi Node Anda jika runtime berada di luar kebijakan keamanan yang didukung -**Fix:** +**Solusi:** -1. Install a supported Node.js LTS release (recommended: Node.js 24.x): +1. Instal rilis Node.js LTS yang didukung (disarankan: Node.js 24.x): ```bash nvm install 24 nvm use 24 ``` -2. Verify your version: `node --version` should show `v24.0.0` or newer on the 24.x LTS line -3. Reinstall OmniRoute: `npm install -g omniroute` -4. Restart: `omniroute` +2. Verifikasi versi Anda: `node --version` seharusnya menampilkan `v24.0.0` atau lebih baru pada lini LTS 24.x +3. Instal ulang OmniRoute: `npm install -g omniroute` +4. Mulai ulang: `omniroute` -> **Supported secure versions:** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`. Node.js 24.x LTS (Krypton) is fully supported. +> **Versi aman yang didukung:** `>=20.20.2 <21`, `>=22.22.2 <23`, atau `>=24.0.0 <25`. Node.js 24.x LTS (Krypton) sepenuhnya didukung. ### macOS: `dlopen` / "slice is not valid mach-o file" -**Cause:** After a global `npm install -g omniroute`, the `better-sqlite3` native binary inside the package may have been compiled for a different architecture or Node.js ABI than what is running locally. This is common on macOS (both Apple Silicon and Intel) when the pre-built binary does not match your environment. +**Penyebab:** Setelah `npm install -g omniroute` secara global, biner native `better-sqlite3` di dalam paket mungkin telah dikompilasi untuk arsitektur atau ABI Node.js yang berbeda dari yang berjalan secara lokal. Hal ini umum terjadi di macOS (baik Apple Silicon maupun Intel) ketika biner yang sudah dibangun tidak cocok dengan lingkungan Anda. -**Symptoms:** +**Gejala:** -- Server fails immediately on startup with a `dlopen` error -- Error contains `slice is not valid mach-o file` -- Full example: +- Server gagal langsung saat startup dengan error `dlopen` +- Error berisi `slice is not valid mach-o file` +- Contoh lengkap: ``` dlopen(/Users//.nvm/versions/node/v24.14.1/lib/node_modules/omniroute/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node, 0x0001): tried: '...' (slice is not valid mach-o file) ``` -**Fix — rebuild for your local environment (no Node.js downgrade required):** +**Solusi — bangun ulang untuk lingkungan lokal Anda (tidak perlu downgrade Node.js):** ```bash cd $(npm root -g)/omniroute/app @@ -74,98 +74,98 @@ npm rebuild better-sqlite3 omniroute ``` -> **Note:** This recompiles the native binding against your local Node.js version and CPU architecture, resolving the binary mismatch. The officially supported range is **`>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`** (`engines` field in `package.json`). Node.js 24.x LTS (Krypton) is fully supported with `better-sqlite3` v12.x. +> **Catatan:** Perintah ini mengompilasi ulang binding native terhadap versi Node.js dan arsitektur CPU lokal Anda, mengatasi ketidakcocokan biner. Rentang yang resmi didukung adalah **`>=20.20.2 <21`, `>=22.22.2 <23`, atau `>=24.0.0 <25`** (kolom `engines` di `package.json`). Node.js 24.x LTS (Krypton) sepenuhnya didukung dengan `better-sqlite3` v12.x. --- -## Proxy Issues +## Masalah Proxy -### Provider validation shows "fetch failed" +### Validasi penyedia menampilkan "fetch failed" -**Cause:** The API key validation endpoint (`POST /api/providers/validate`) was previously bypassing proxy configuration, causing failures in environments that require proxy routing. +**Penyebab:** Endpoint validasi API key (`POST /api/providers/validate`) sebelumnya mengabaikan konfigurasi proxy, menyebabkan kegagalan di lingkungan yang memerlukan routing melalui proxy. -**Fix (v3.5.5+):** This is now fixed. Provider validation routes through `runWithProxyContext`, honoring provider-level and global proxy settings automatically. +**Solusi (v3.5.5+):** Masalah ini sudah diperbaiki. Validasi penyedia sekarang melewati `runWithProxyContext`, mengikuti pengaturan proxy pada tingkat penyedia dan global secara otomatis. -### Token health check fails with "fetch failed" +### Pemeriksaan kesehatan token gagal dengan "fetch failed" -**Cause:** Background OAuth token refresh was not resolving proxy configuration per connection. +**Penyebab:** Pembaruan token OAuth di latar belakang tidak menyelesaikan konfigurasi proxy per koneksi. -**Fix (v3.5.5+):** The token health check scheduler now resolves proxy config per connection before attempting refresh. Update to v3.5.5+. +**Solusi (v3.5.5+):** Penjadwal pemeriksaan kesehatan token sekarang menyelesaikan konfigurasi proxy per koneksi sebelum mencoba pembaruan. Perbarui ke v3.5.5+. -### SOCKS5 proxy returns "invalid onRequestStart method" +### Proxy SOCKS5 mengembalikan "invalid onRequestStart method" -**Cause:** On Node.js 22, the undici@8 dispatcher is incompatible with Node's built-in `fetch()` implementation. +**Penyebab:** Pada Node.js 22, dispatcher undici@8 tidak kompatibel dengan implementasi `fetch()` bawaan Node. -**Fix (v3.5.5+):** OmniRoute now uses undici's own `fetch()` function when a proxy dispatcher is active, ensuring consistent behavior. Update to v3.5.5+. +**Solusi (v3.5.5+):** OmniRoute sekarang menggunakan fungsi `fetch()` milik undici sendiri ketika dispatcher proxy aktif, memastikan perilaku yang konsisten. Perbarui ke v3.5.5+. --- -## Provider Issues +## Masalah Penyedia ### "Language model did not provide messages" -**Cause:** Provider quota exhausted. +**Penyebab:** Kuota penyedia habis. -**Fix:** +**Solusi:** -1. Check dashboard quota tracker -2. Use a combo with fallback tiers -3. Switch to cheaper/free tier +1. Periksa pelacak kuota di dashboard +2. Gunakan combo dengan tier fallback +3. Beralih ke tier yang lebih murah/gratis -### Rate Limiting +### Pembatasan Laju (Rate Limiting) -**Cause:** Subscription quota exhausted. +**Penyebab:** Kuota langganan habis. -**Fix:** +**Solusi:** -- Add fallback: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking` -- Use GLM/MiniMax as cheap backup +- Tambahkan fallback: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking` +- Gunakan GLM/MiniMax sebagai cadangan murah -### OAuth Token Expired +### Token OAuth Kedaluwarsa -OmniRoute auto-refreshes tokens. If issues persist: +OmniRoute memperbarui token secara otomatis. Jika masalah berlanjut: -1. Dashboard → Provider → Reconnect -2. Delete and re-add the provider connection +1. Dashboard → Penyedia → Sambungkan Ulang +2. Hapus dan tambahkan ulang koneksi penyedia --- -## Cloud Issues +## Masalah Cloud -### Cloud Sync Errors +### Error Sinkronisasi Cloud -1. Verify `BASE_URL` points to your running instance (e.g., `http://localhost:20128`) -2. Verify `CLOUD_URL` points to your cloud endpoint (e.g., `https://omniroute.dev`) -3. Keep `NEXT_PUBLIC_*` values aligned with server-side values +1. Pastikan `BASE_URL` mengarah ke instans yang sedang berjalan (misalnya, `http://localhost:20128`) +2. Pastikan `CLOUD_URL` mengarah ke endpoint cloud Anda (misalnya, `https://omniroute.dev`) +3. Jaga agar nilai `NEXT_PUBLIC_*` selaras dengan nilai sisi server -### Cloud `stream=false` Returns 500 +### Cloud `stream=false` Mengembalikan 500 -**Symptom:** `Unexpected token 'd'...` on cloud endpoint for non-streaming calls. +**Gejala:** `Unexpected token 'd'...` pada endpoint cloud untuk panggilan non-streaming. -**Cause:** Upstream returns SSE payload while client expects JSON. +**Penyebab:** Upstream mengembalikan payload SSE sementara klien mengharapkan JSON. -**Workaround:** Use `stream=true` for cloud direct calls. Local runtime includes SSE→JSON fallback. +**Solusi Sementara:** Gunakan `stream=true` untuk panggilan langsung ke cloud. Runtime lokal sudah menyertakan fallback SSE→JSON. -### Cloud Says Connected but "Invalid API key" +### Cloud Menunjukkan Terhubung tetapi "Invalid API key" -1. Create a fresh key from local dashboard (`/api/keys`) -2. Run cloud sync: Enable Cloud → Sync Now -3. Old/non-synced keys can still return `401` on cloud +1. Buat kunci baru dari dashboard lokal (`/api/keys`) +2. Jalankan sinkronisasi cloud: Aktifkan Cloud → Sinkronkan Sekarang +3. Kunci lama/yang tidak tersinkronisasi masih dapat mengembalikan `401` di cloud --- -## Docker Issues +## Masalah Docker -### CLI Tool Shows Not Installed +### Alat CLI Menampilkan Belum Terinstal -1. Check runtime fields: `curl http://localhost:20128/api/cli-tools/runtime/codex | jq` -2. For portable mode: use image target `runner-cli` (bundled CLIs) -3. For host mount mode: set `CLI_EXTRA_PATHS` and mount host bin directory as read-only -4. If `installed=true` and `runnable=false`: binary was found but failed healthcheck +1. Periksa kolom runtime: `curl http://localhost:20128/api/cli-tools/runtime/codex | jq` +2. Untuk mode portabel: gunakan target image `runner-cli` (CLI yang sudah dibundel) +3. Untuk mode mount host: atur `CLI_EXTRA_PATHS` dan mount direktori bin host sebagai read-only +4. Jika `installed=true` dan `runnable=false`: biner ditemukan tetapi gagal healthcheck -### Quick Runtime Validation +### Validasi Runtime Cepat ```bash curl -s http://localhost:20128/api/cli-tools/codex-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' @@ -175,26 +175,26 @@ curl -s http://localhost:20128/api/cli-tools/openclaw-settings | jq '{installed, --- -## Cost Issues +## Masalah Biaya -### High Costs +### Biaya Tinggi -1. Check usage stats in Dashboard → Usage -2. Switch primary model to GLM/MiniMax -3. Use free tier (Gemini CLI, Qoder) for non-critical tasks -4. Set cost budgets per API key: Dashboard → API Keys → Budget +1. Periksa statistik penggunaan di Dashboard → Penggunaan +2. Beralih model utama ke GLM/MiniMax +3. Gunakan tier gratis (Gemini CLI, Qoder) untuk tugas yang tidak kritis +4. Atur anggaran biaya per API key: Dashboard → API Keys → Anggaran --- ## Debugging -### Enable Log Files +### Aktifkan File Log -Set `APP_LOG_TO_FILE=true` in your `.env` file. Application logs are written under `logs/`. -Request artifacts are stored under `${DATA_DIR}/call_logs/` when the call log pipeline is -enabled in settings. +Atur `APP_LOG_TO_FILE=true` di file `.env` Anda. Log aplikasi ditulis di bawah `logs/`. +Artefak permintaan disimpan di bawah `${DATA_DIR}/call_logs/` ketika pipeline log panggilan +diaktifkan di pengaturan. -### Check Provider Health +### Periksa Kesehatan Penyedia ```bash # Health dashboard @@ -204,138 +204,138 @@ http://localhost:20128/dashboard/health curl http://localhost:20128/api/monitoring/health ``` -### Runtime Storage +### Penyimpanan Runtime -- Main state: `${DATA_DIR}/storage.sqlite` (providers, combos, aliases, keys, settings) -- Usage: SQLite tables in `storage.sqlite` (`usage_history`, `call_logs`, `proxy_logs`) + optional `${DATA_DIR}/call_logs/` -- Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) -- Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled +- Status utama: `${DATA_DIR}/storage.sqlite` (penyedia, combo, alias, kunci, pengaturan) +- Penggunaan: tabel SQLite di `storage.sqlite` (`usage_history`, `call_logs`, `proxy_logs`) + opsional `${DATA_DIR}/call_logs/` +- Log aplikasi: `/logs/...` (ketika `APP_LOG_TO_FILE=true`) +- Artefak log panggilan: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` ketika pipeline log panggilan diaktifkan --- -## Circuit Breaker Issues +## Masalah Circuit Breaker -### Provider stuck in OPEN state +### Penyedia terjebak dalam status OPEN -When a provider's circuit breaker is OPEN, requests are blocked until the cooldown expires. +Ketika circuit breaker penyedia dalam status OPEN, permintaan diblokir hingga cooldown berakhir. -**Fix:** +**Solusi:** -1. Go to **Dashboard → Settings → Resilience** -2. Check the circuit breaker card for the affected provider -3. Click **Reset All** to clear all breakers, or wait for the cooldown to expire -4. Verify the provider is actually available before resetting +1. Buka **Dashboard → Settings → Resilience** +2. Periksa kartu circuit breaker untuk penyedia yang terdampak +3. Klik **Reset All** untuk menghapus semua breaker, atau tunggu hingga cooldown berakhir +4. Pastikan penyedia benar-benar tersedia sebelum melakukan reset -### Provider keeps tripping the circuit breaker +### Penyedia terus memicu circuit breaker -If a provider repeatedly enters OPEN state: +Jika penyedia berulang kali masuk ke status OPEN: -1. Check **Dashboard → Health → Provider Health** for the failure pattern -2. Go to **Settings → Resilience → Provider Profiles** and increase the failure threshold -3. Check if the provider has changed API limits or requires re-authentication -4. Review latency telemetry — high latency may cause timeout-based failures +1. Periksa **Dashboard → Health → Provider Health** untuk pola kegagalan +2. Buka **Settings → Resilience → Provider Profiles** dan tingkatkan ambang batas kegagalan +3. Periksa apakah penyedia telah mengubah batas API atau memerlukan autentikasi ulang +4. Tinjau telemetri latensi — latensi tinggi dapat menyebabkan kegagalan berbasis timeout --- -## Audio Transcription Issues +## Masalah Transkripsi Audio -### "Unsupported model" error +### Error "Unsupported model" -- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best` -- Verify the provider is connected in **Dashboard → Providers** +- Pastikan Anda menggunakan awalan yang tepat: `deepgram/nova-3` atau `assemblyai/best` +- Pastikan penyedia terhubung di **Dashboard → Providers** -### Transcription returns empty or fails +### Transkripsi mengembalikan hasil kosong atau gagal -- Check supported audio formats: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm` -- Verify file size is within provider limits (typically < 25MB) -- Check provider API key validity in the provider card +- Periksa format audio yang didukung: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm` +- Pastikan ukuran file berada dalam batas penyedia (biasanya < 25MB) +- Periksa validitas API key penyedia di kartu penyedia --- -## Translator Debugging +## Debugging Translator -Use **Dashboard → Translator** to debug format translation issues: +Gunakan **Dashboard → Translator** untuk melakukan debug masalah terjemahan format: -| Mode | When to Use | -| ---------------- | -------------------------------------------------------------------------------------------- | -| **Playground** | Compare input/output formats side by side — paste a failing request to see how it translates | -| **Chat Tester** | Send live messages and inspect the full request/response payload including headers | -| **Test Bench** | Run batch tests across format combinations to find which translations are broken | -| **Live Monitor** | Watch real-time request flow to catch intermittent translation issues | +| Mode | Kapan Digunakan | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| **Playground** | Bandingkan format input/output berdampingan — tempel permintaan yang gagal untuk melihat cara terjemahannya | +| **Chat Tester** | Kirim pesan langsung dan periksa payload permintaan/respons lengkap termasuk header | +| **Test Bench** | Jalankan pengujian batch di berbagai kombinasi format untuk menemukan terjemahan mana yang rusak | +| **Live Monitor** | Pantau aliran permintaan secara real-time untuk menangkap masalah terjemahan yang intermiten | -### Common format issues +### Masalah format yang umum -- **Thinking tags not appearing** — Check if the target provider supports thinking and the thinking budget setting -- **Tool calls dropping** — Some format translations may strip unsupported fields; verify in Playground mode -- **System prompt missing** — Claude and Gemini handle system prompts differently; check translation output -- **SDK returns raw string instead of object** — Fixed in v1.1.0: response sanitizer now strips non-standard fields (`x_groq`, `usage_breakdown`, etc.) that cause OpenAI SDK Pydantic validation failures -- **GLM/ERNIE rejects `system` role** — Fixed in v1.1.0: role normalizer automatically merges system messages into user messages for incompatible models -- **`developer` role not recognized** — Fixed in v1.1.0: automatically converted to `system` for non-OpenAI providers -- **`json_schema` not working with Gemini** — Fixed in v1.1.0: `response_format` is now converted to Gemini's `responseMimeType` + `responseSchema` +- **Tag thinking tidak muncul** — Periksa apakah penyedia target mendukung thinking dan pengaturan anggaran thinking +- **Tool call hilang** — Beberapa terjemahan format mungkin menghapus kolom yang tidak didukung; verifikasi di mode Playground +- **System prompt hilang** — Claude dan Gemini menangani system prompt secara berbeda; periksa output terjemahan +- **SDK mengembalikan string mentah alih-alih objek** — Diperbaiki di v1.1.0: sanitizer respons sekarang menghapus kolom non-standar (`x_groq`, `usage_breakdown`, dll.) yang menyebabkan kegagalan validasi Pydantic SDK OpenAI +- **GLM/ERNIE menolak role `system`** — Diperbaiki di v1.1.0: normalizer role secara otomatis menggabungkan pesan sistem ke dalam pesan pengguna untuk model yang tidak kompatibel +- **Role `developer` tidak dikenali** — Diperbaiki di v1.1.0: secara otomatis dikonversi ke `system` untuk penyedia non-OpenAI +- **`json_schema` tidak berfungsi dengan Gemini** — Diperbaiki di v1.1.0: `response_format` sekarang dikonversi ke `responseMimeType` + `responseSchema` milik Gemini --- -## Resilience Settings +## Pengaturan Resiliensi -### Auto rate-limit not triggering +### Auto rate-limit tidak terpicu -- Auto rate-limit only applies to API key providers (not OAuth/subscription) -- Verify **Settings → Resilience → Provider Profiles** has auto-rate-limit enabled -- Check if the provider returns `429` status codes or `Retry-After` headers +- Auto rate-limit hanya berlaku untuk penyedia dengan API key (bukan OAuth/langganan) +- Pastikan **Settings → Resilience → Provider Profiles** telah mengaktifkan auto rate-limit +- Periksa apakah penyedia mengembalikan kode status `429` atau header `Retry-After` -### Tuning exponential backoff +### Menyetel exponential backoff -Provider profiles support these settings: +Profil penyedia mendukung pengaturan berikut: -- **Base delay** — Initial wait time after first failure (default: 1s) -- **Max delay** — Maximum wait time cap (default: 30s) -- **Multiplier** — How much to increase delay per consecutive failure (default: 2x) +- **Base delay** — Waktu tunggu awal setelah kegagalan pertama (default: 1s) +- **Max delay** — Batas maksimum waktu tunggu (default: 30s) +- **Multiplier** — Seberapa banyak penundaan ditingkatkan per kegagalan berturut-turut (default: 2x) ### Anti-thundering herd -When many concurrent requests hit a rate-limited provider, OmniRoute uses mutex + auto rate-limiting to serialize requests and prevent cascading failures. This is automatic for API key providers. +Ketika banyak permintaan bersamaan mengenai penyedia yang dibatasi lajunya, OmniRoute menggunakan mutex + auto rate-limiting untuk membuat serialisasi permintaan dan mencegah kegagalan berantai. Ini berjalan otomatis untuk penyedia dengan API key. --- -## Optional RAG / LLM failure taxonomy (16 problems) +## Taksonomi Kegagalan RAG / LLM Opsional (16 masalah) -Some OmniRoute users place the gateway in front of RAG or agent stacks. In those setups it is common to see a strange pattern: OmniRoute looks healthy (providers up, routing profiles ok, no rate limit alerts) but the final answer is still wrong. +Beberapa pengguna OmniRoute menempatkan gateway di depan tumpukan RAG atau agen. Dalam pengaturan tersebut, umum terjadi pola yang aneh: OmniRoute terlihat sehat (penyedia aktif, profil routing baik, tidak ada peringatan batas laju) tetapi jawaban akhir masih salah. -In practice these incidents usually come from the downstream RAG pipeline, not from the gateway itself. +Dalam praktiknya, insiden ini biasanya berasal dari pipeline RAG downstream, bukan dari gateway itu sendiri. -If you want a shared vocabulary to describe those failures you can use the WFGY ProblemMap, an external MIT license text resource that defines sixteen recurring RAG / LLM failure patterns. At a high level it covers: +Jika Anda menginginkan kosakata bersama untuk mendeskripsikan kegagalan tersebut, Anda dapat menggunakan WFGY ProblemMap, sebuah sumber daya teks berlisensi MIT eksternal yang mendefinisikan enam belas pola kegagalan RAG / LLM yang berulang. Secara garis besar, ini mencakup: -- retrieval drift and broken context boundaries -- empty or stale indexes and vector stores -- embedding versus semantic mismatch -- prompt assembly and context window issues -- logic collapse and overconfident answers -- long chain and agent coordination failures -- multi agent memory and role drift -- deployment and bootstrap ordering problems +- pergeseran retrieval dan batas konteks yang rusak +- indeks dan vector store yang kosong atau sudah usang +- ketidakcocokan embedding versus semantik +- masalah perakitan prompt dan jendela konteks +- keruntuhan logika dan jawaban yang terlalu percaya diri +- kegagalan rantai panjang dan koordinasi agen +- memori multi-agen dan pergeseran peran +- masalah urutan deployment dan bootstrap -The idea is simple: +Idenya sederhana: -1. When you investigate a bad response, capture: - - user task and request - - route or provider combo in OmniRoute - - any RAG context used downstream (retrieved documents, tool calls, etc) -2. Map the incident to one or two WFGY ProblemMap numbers (`No.1` … `No.16`). -3. Store the number in your own dashboard, runbook, or incident tracker next to the OmniRoute logs. -4. Use the corresponding WFGY page to decide whether you need to change your RAG stack, retriever, or routing strategy. +1. Saat Anda menyelidiki respons yang buruk, kumpulkan: + - tugas pengguna dan permintaan + - route atau combo penyedia di OmniRoute + - konteks RAG apa pun yang digunakan di downstream (dokumen yang diambil, tool call, dll.) +2. Petakan insiden ke satu atau dua nomor WFGY ProblemMap (`No.1` … `No.16`). +3. Simpan nomornya di dashboard, runbook, atau pelacak insiden Anda sendiri di samping log OmniRoute. +4. Gunakan halaman WFGY yang sesuai untuk memutuskan apakah Anda perlu mengubah tumpukan RAG, retriever, atau strategi routing Anda. -Full text and concrete recipes live here (MIT license, text only): +Teks lengkap dan resep konkret tersedia di sini (lisensi MIT, hanya teks): [WFGY ProblemMap README](https://github.com/onestardao/WFGY/blob/main/ProblemMap/README.md) -You can ignore this section if you do not run RAG or agent pipelines behind OmniRoute. +Anda dapat mengabaikan bagian ini jika Anda tidak menjalankan pipeline RAG atau agen di belakang OmniRoute. --- -## Still Stuck? +## Masih Terjebak? - **GitHub Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) -- **Architecture**: See [`docs/architecture/ARCHITECTURE.md`](ARCHITECTURE.md) for internal details -- **API Reference**: See [`docs/reference/API_REFERENCE.md`](API_REFERENCE.md) for all endpoints -- **Health Dashboard**: Check **Dashboard → Health** for real-time system status -- **Translator**: Use **Dashboard → Translator** to debug format issues +- **Arsitektur**: Lihat [`docs/architecture/ARCHITECTURE.md`](ARCHITECTURE.md) untuk detail internal +- **Referensi API**: Lihat [`docs/reference/API_REFERENCE.md`](API_REFERENCE.md) untuk semua endpoint +- **Health Dashboard**: Periksa **Dashboard → Health** untuk status sistem secara real-time +- **Translator**: Gunakan **Dashboard → Translator** untuk melakukan debug masalah format diff --git a/docs/i18n/id/docs/guides/UNINSTALL.md b/docs/i18n/id/docs/guides/UNINSTALL.md index 55c4e16c95..862d3662a2 100644 --- a/docs/i18n/id/docs/guides/UNINSTALL.md +++ b/docs/i18n/id/docs/guides/UNINSTALL.md @@ -1,46 +1,46 @@ -# OmniRoute — Uninstall Guide (Bahasa Indonesia) +# OmniRoute — Panduan Mencopot Pemasangan (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/UNINSTALL.md) · 🇸🇦 [ar](../../ar/docs/UNINSTALL.md) · 🇧🇬 [bg](../../bg/docs/UNINSTALL.md) · 🇧🇩 [bn](../../bn/docs/UNINSTALL.md) · 🇨🇿 [cs](../../cs/docs/UNINSTALL.md) · 🇩🇰 [da](../../da/docs/UNINSTALL.md) · 🇩🇪 [de](../../de/docs/UNINSTALL.md) · 🇪🇸 [es](../../es/docs/UNINSTALL.md) · 🇮🇷 [fa](../../fa/docs/UNINSTALL.md) · 🇫🇮 [fi](../../fi/docs/UNINSTALL.md) · 🇫🇷 [fr](../../fr/docs/UNINSTALL.md) · 🇮🇳 [gu](../../gu/docs/UNINSTALL.md) · 🇮🇱 [he](../../he/docs/UNINSTALL.md) · 🇮🇳 [hi](../../hi/docs/UNINSTALL.md) · 🇭🇺 [hu](../../hu/docs/UNINSTALL.md) · 🇮🇩 [id](../../id/docs/UNINSTALL.md) · 🇮🇹 [it](../../it/docs/UNINSTALL.md) · 🇯🇵 [ja](../../ja/docs/UNINSTALL.md) · 🇰🇷 [ko](../../ko/docs/UNINSTALL.md) · 🇮🇳 [mr](../../mr/docs/UNINSTALL.md) · 🇲🇾 [ms](../../ms/docs/UNINSTALL.md) · 🇳🇱 [nl](../../nl/docs/UNINSTALL.md) · 🇳🇴 [no](../../no/docs/UNINSTALL.md) · 🇵🇭 [phi](../../phi/docs/UNINSTALL.md) · 🇵🇱 [pl](../../pl/docs/UNINSTALL.md) · 🇵🇹 [pt](../../pt/docs/UNINSTALL.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/UNINSTALL.md) · 🇷🇴 [ro](../../ro/docs/UNINSTALL.md) · 🇷🇺 [ru](../../ru/docs/UNINSTALL.md) · 🇸🇰 [sk](../../sk/docs/UNINSTALL.md) · 🇸🇪 [sv](../../sv/docs/UNINSTALL.md) · 🇰🇪 [sw](../../sw/docs/UNINSTALL.md) · 🇮🇳 [ta](../../ta/docs/UNINSTALL.md) · 🇮🇳 [te](../../te/docs/UNINSTALL.md) · 🇹🇭 [th](../../th/docs/UNINSTALL.md) · 🇹🇷 [tr](../../tr/docs/UNINSTALL.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/UNINSTALL.md) · 🇵🇰 [ur](../../ur/docs/UNINSTALL.md) · 🇻🇳 [vi](../../vi/docs/UNINSTALL.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/UNINSTALL.md) --- -This guide covers how to cleanly remove OmniRoute from your system. +Panduan ini menjelaskan cara mencopot pemasangan OmniRoute dari sistem Anda secara bersih. --- -## Quick Uninstall (v3.6.2+) +## Mencopot Pemasangan dengan Cepat (v3.6.2+) -OmniRoute provides two built-in scripts for clean removal: +OmniRoute menyediakan dua skrip bawaan untuk penghapusan yang bersih: -### Keep Your Data +### Pertahankan Data Anda ```bash npm run uninstall ``` -This removes the OmniRoute application but **preserves** your database, configurations, API keys, and provider settings in `~/.omniroute/`. Use this if you plan to reinstall later and want to keep your setup. +Perintah ini menghapus aplikasi OmniRoute tetapi **mempertahankan** basis data, konfigurasi, kunci API, dan pengaturan penyedia Anda di `~/.omniroute/`. Gunakan ini jika Anda berencana memasang ulang nanti dan ingin menyimpan pengaturan yang ada. -### Full Removal +### Penghapusan Penuh ```bash npm run uninstall:full ``` -This removes the application **and permanently erases** all data: +Perintah ini menghapus aplikasi **dan menghapus secara permanen** semua data: -- Database (`storage.sqlite`) -- Provider configurations and API keys -- Backup files -- Log files -- All files in the `~/.omniroute/` directory +- Basis data (`storage.sqlite`) +- Konfigurasi penyedia dan kunci API +- Berkas cadangan +- Berkas log +- Semua berkas di direktori `~/.omniroute/` -> ⚠️ **Warning:** `npm run uninstall:full` is irreversible. All your provider connections, combos, API keys, and usage history will be permanently deleted. +> ⚠️ **Peringatan:** `npm run uninstall:full` tidak dapat dibatalkan. Semua koneksi penyedia, combo, kunci API, dan riwayat penggunaan Anda akan dihapus secara permanen. --- -## Manual Uninstall +## Mencopot Pemasangan Secara Manual -### NPM Global Install +### Instalasi Global NPM ```bash # Remove the global package @@ -50,7 +50,7 @@ npm uninstall -g omniroute rm -rf ~/.omniroute ``` -### pnpm Global Install +### Instalasi Global pnpm ```bash pnpm uninstall -g omniroute @@ -81,24 +81,24 @@ docker compose down docker compose down -v ``` -### Electron Desktop App +### Aplikasi Desktop Electron **Windows:** -- Open `Settings → Apps → OmniRoute → Uninstall` -- Or run the NSIS uninstaller from the install directory +- Buka `Settings → Apps → OmniRoute → Uninstall` +- Atau jalankan uninstaller NSIS dari direktori instalasi **macOS:** -- Drag `OmniRoute.app` from `/Applications` to Trash -- Remove data: `rm -rf ~/Library/Application Support/omniroute` +- Seret `OmniRoute.app` dari `/Applications` ke Trash +- Hapus data: `rm -rf ~/Library/Application Support/omniroute` **Linux:** -- Remove the AppImage file -- Remove data: `rm -rf ~/.omniroute` +- Hapus berkas AppImage +- Hapus data: `rm -rf ~/.omniroute` -### Source Install (git clone) +### Instalasi dari Sumber (git clone) ```bash # Remove the cloned directory @@ -110,11 +110,11 @@ rm -rf ~/.omniroute --- -## Data Directories +## Direktori Data -OmniRoute stores data in the following locations by default: +OmniRoute menyimpan data di lokasi-lokasi berikut secara default: -| Platform | Default Path | Override | +| Platform | Jalur Default | Pengganti | | ------------- | ----------------------------- | ------------------------- | | Linux | `~/.omniroute/` | `DATA_DIR` env var | | macOS | `~/.omniroute/` | `DATA_DIR` env var | @@ -122,22 +122,22 @@ OmniRoute stores data in the following locations by default: | Docker | `/app/data/` (mounted volume) | `DATA_DIR` env var | | XDG-compliant | `$XDG_CONFIG_HOME/omniroute/` | `XDG_CONFIG_HOME` env var | -### Files in the data directory +### Berkas di dalam direktori data -| File/Directory | Description | -| -------------------- | ------------------------------------------------- | -| `storage.sqlite` | Main database (providers, combos, settings, keys) | -| `storage.sqlite-wal` | SQLite write-ahead log (temporary) | -| `storage.sqlite-shm` | SQLite shared memory (temporary) | -| `call_logs/` | Request payload archives | -| `backups/` | Automatic database backups | -| `log.txt` | Legacy request log (optional) | +| Berkas/Direktori | Deskripsi | +| -------------------- | ------------------------------------------------------------ | +| `storage.sqlite` | Basis data utama (penyedia, combo, pengaturan, kunci) | +| `storage.sqlite-wal` | Write-ahead log SQLite (sementara) | +| `storage.sqlite-shm` | Shared memory SQLite (sementara) | +| `call_logs/` | Arsip payload permintaan | +| `backups/` | Cadangan basis data otomatis | +| `log.txt` | Log permintaan lama (opsional) | --- -## Verify Complete Removal +## Verifikasi Penghapusan Lengkap -After uninstalling, verify there are no remaining files: +Setelah mencopot pemasangan, verifikasi bahwa tidak ada berkas yang tersisa: ```bash # Check for global npm package @@ -150,7 +150,7 @@ ls -la ~/.omniroute/ 2>/dev/null pgrep -f omniroute ``` -If any process is still running, stop it: +Jika ada proses yang masih berjalan, hentikan dengan perintah berikut: ```bash pkill -f omniroute diff --git a/docs/i18n/id/docs/guides/USER_GUIDE.md b/docs/i18n/id/docs/guides/USER_GUIDE.md index 1e4dc9b26f..e857206971 100644 --- a/docs/i18n/id/docs/guides/USER_GUIDE.md +++ b/docs/i18n/id/docs/guides/USER_GUIDE.md @@ -1,119 +1,119 @@ -# User Guide (Bahasa Indonesia) +# Panduan Pengguna (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/USER_GUIDE.md) · 🇸🇦 [ar](../../ar/docs/USER_GUIDE.md) · 🇧🇬 [bg](../../bg/docs/USER_GUIDE.md) · 🇧🇩 [bn](../../bn/docs/USER_GUIDE.md) · 🇨🇿 [cs](../../cs/docs/USER_GUIDE.md) · 🇩🇰 [da](../../da/docs/USER_GUIDE.md) · 🇩🇪 [de](../../de/docs/USER_GUIDE.md) · 🇪🇸 [es](../../es/docs/USER_GUIDE.md) · 🇮🇷 [fa](../../fa/docs/USER_GUIDE.md) · 🇫🇮 [fi](../../fi/docs/USER_GUIDE.md) · 🇫🇷 [fr](../../fr/docs/USER_GUIDE.md) · 🇮🇳 [gu](../../gu/docs/USER_GUIDE.md) · 🇮🇱 [he](../../he/docs/USER_GUIDE.md) · 🇮🇳 [hi](../../hi/docs/USER_GUIDE.md) · 🇭🇺 [hu](../../hu/docs/USER_GUIDE.md) · 🇮🇩 [id](../../id/docs/USER_GUIDE.md) · 🇮🇹 [it](../../it/docs/USER_GUIDE.md) · 🇯🇵 [ja](../../ja/docs/USER_GUIDE.md) · 🇰🇷 [ko](../../ko/docs/USER_GUIDE.md) · 🇮🇳 [mr](../../mr/docs/USER_GUIDE.md) · 🇲🇾 [ms](../../ms/docs/USER_GUIDE.md) · 🇳🇱 [nl](../../nl/docs/USER_GUIDE.md) · 🇳🇴 [no](../../no/docs/USER_GUIDE.md) · 🇵🇭 [phi](../../phi/docs/USER_GUIDE.md) · 🇵🇱 [pl](../../pl/docs/USER_GUIDE.md) · 🇵🇹 [pt](../../pt/docs/USER_GUIDE.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/USER_GUIDE.md) · 🇷🇴 [ro](../../ro/docs/USER_GUIDE.md) · 🇷🇺 [ru](../../ru/docs/USER_GUIDE.md) · 🇸🇰 [sk](../../sk/docs/USER_GUIDE.md) · 🇸🇪 [sv](../../sv/docs/USER_GUIDE.md) · 🇰🇪 [sw](../../sw/docs/USER_GUIDE.md) · 🇮🇳 [ta](../../ta/docs/USER_GUIDE.md) · 🇮🇳 [te](../../te/docs/USER_GUIDE.md) · 🇹🇭 [th](../../th/docs/USER_GUIDE.md) · 🇹🇷 [tr](../../tr/docs/USER_GUIDE.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/USER_GUIDE.md) · 🇵🇰 [ur](../../ur/docs/USER_GUIDE.md) · 🇻🇳 [vi](../../vi/docs/USER_GUIDE.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/USER_GUIDE.md) --- -Complete guide for configuring providers, creating combos, integrating CLI tools, and deploying OmniRoute. +Panduan lengkap untuk mengonfigurasi penyedia, membuat combo, mengintegrasikan alat CLI, dan menerapkan OmniRoute. --- -## Table of Contents +## Daftar Isi -- [Pricing at a Glance](#-pricing-at-a-glance) -- [Use Cases](#-use-cases) -- [Provider Setup](#-provider-setup) -- [CLI Integration](#-cli-integration) -- [Deployment](#-deployment) -- [Available Models](#-available-models) -- [Advanced Features](#-advanced-features) +- [Harga Sekilas](#-harga-sekilas) +- [Kasus Penggunaan](#-kasus-penggunaan) +- [Pengaturan Penyedia](#-pengaturan-penyedia) +- [Integrasi CLI](#-integrasi-cli) +- [Penerapan](#penerapan) +- [Model yang Tersedia](#-model-yang-tersedia) +- [Fitur Lanjutan](#-fitur-lanjutan) --- -## 💰 Pricing at a Glance +## 💰 Harga Sekilas -| Tier | Provider | Cost | Quota Reset | Best For | -| ------------------- | ----------------- | ----------- | ---------------- | -------------------- | -| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed | -| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | -| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! | -| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| **🔑 API KEY** | DeepSeek | Pay per use | None | Cheap reasoning | -| | Groq | Pay per use | None | Ultra-fast inference | -| | xAI (Grok) | Pay per use | None | Grok 4 reasoning | -| | Mistral | Pay per use | None | EU-hosted models | -| | Perplexity | Pay per use | None | Search-augmented | -| | Together AI | Pay per use | None | Open-source models | -| | Fireworks AI | Pay per use | None | Fast FLUX images | -| | Cerebras | Pay per use | None | Wafer-scale speed | -| | Cohere | Pay per use | None | Command R+ RAG | -| | NVIDIA NIM | Pay per use | None | Enterprise models | -| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | -| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **🆓 FREE** | Qoder | $0 | Unlimited | 8 models free | -| | Qwen | $0 | Unlimited | 3 models free | -| | Kiro | $0 | Unlimited | Claude free | +| Tingkatan | Penyedia | Biaya | Reset Kuota | Terbaik Untuk | +| ------------------- | ----------------- | ----------- | ---------------- | -------------------------- | +| **💳 LANGGANAN** | Claude Code (Pro) | $20/bln | 5j + mingguan | Sudah berlangganan | +| | Codex (Plus/Pro) | $20-200/bln | 5j + mingguan | Pengguna OpenAI | +| | Gemini CLI | **GRATIS** | 180K/bln + 1K/hr | Semua orang! | +| | GitHub Copilot | $10-19/bln | Bulanan | Pengguna GitHub | +| **🔑 KUNCI API** | DeepSeek | Bayar pakai | Tidak ada | Penalaran murah | +| | Groq | Bayar pakai | Tidak ada | Inferensi sangat cepat | +| | xAI (Grok) | Bayar pakai | Tidak ada | Penalaran Grok 4 | +| | Mistral | Bayar pakai | Tidak ada |Model berbasis EU | +| | Perplexity | Bayar pakai | Tidak ada | Dilengkapi pencarian | +| | Together AI | Bayar pakai | Tidak ada | Model sumber terbuka | +| | Fireworks AI | Bayar pakai | Tidak ada | Gambar FLUX cepat | +| | Cerebras | Bayar pakai | Tidak ada | Kecepatan skala wafer | +| | Cohere | Bayar pakai | Tidak ada | RAG Command R+ | +| | NVIDIA NIM | Bayar pakai | Tidak ada | Model enterprise | +| **💰 MURAH** | GLM-4.7 | $0.6/1M | Harian pukul 10 | Cadangan hemat | +| | MiniMax M2.1 | $0.2/1M | Bergulir 5 jam | Pilihan termurah | +| | Kimi K2 | $9/bln flat | 10M token/bln | Biaya yang dapat diprediksi| +| **🆓 GRATIS** | Qoder | $0 | Tidak terbatas | 8 model gratis | +| | Qwen | $0 | Tidak terbatas | 3 model gratis | +| | Kiro | $0 | Tidak terbatas | Claude gratis | -**💡 Pro Tip:** Start with Gemini CLI (180K free/month) + Qoder (unlimited free) combo = $0 cost! +**💡 Tips Pro:** Mulai dengan combo Gemini CLI (180K gratis/bulan) + Qoder (gratis tanpa batas) = biaya $0! --- -## 🎯 Use Cases +## 🎯 Kasus Penggunaan -### Case 1: "I have Claude Pro subscription" +### Kasus 1: "Saya punya langganan Claude Pro" -**Problem:** Quota expires unused, rate limits during heavy coding +**Masalah:** Kuota habis tidak terpakai, batas kecepatan saat coding intensif ``` Combo: "maximize-claude" - 1. cc/claude-opus-4-7 (use subscription fully) - 2. glm/glm-4.7 (cheap backup when quota out) - 3. if/kimi-k2-thinking (free emergency fallback) + 1. cc/claude-opus-4-7 (gunakan langganan sepenuhnya) + 2. glm/glm-4.7 (cadangan murah saat kuota habis) + 3. if/kimi-k2-thinking (fallback darurat gratis) -Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total -vs. $20 + hitting limits = frustration +Biaya bulanan: $20 (langganan) + ~$5 (cadangan) = total $25 +vs. $20 + terkena batas = frustrasi ``` -### Case 2: "I want zero cost" +### Kasus 2: "Saya ingin biaya nol" -**Problem:** Can't afford subscriptions, need reliable AI coding +**Masalah:** Tidak mampu berlangganan, butuh AI coding yang andal ``` Combo: "free-forever" - 1. gc/gemini-3-flash (180K free/month) - 2. if/kimi-k2-thinking (unlimited free) - 3. qw/qwen3-coder-plus (unlimited free) + 1. gc/gemini-3-flash (180K gratis/bulan) + 2. if/kimi-k2-thinking (gratis tanpa batas) + 3. qw/qwen3-coder-plus (gratis tanpa batas) -Monthly cost: $0 -Quality: Production-ready models +Biaya bulanan: $0 +Kualitas: Model siap produksi ``` -### Case 3: "I need 24/7 coding, no interruptions" +### Kasus 3: "Saya butuh coding 24/7, tanpa gangguan" -**Problem:** Deadlines, can't afford downtime +**Masalah:** Tenggat waktu, tidak boleh ada downtime ``` Combo: "always-on" - 1. cc/claude-opus-4-7 (best quality) - 2. cx/gpt-5.2-codex (second subscription) - 3. glm/glm-4.7 (cheap, resets daily) - 4. minimax/MiniMax-M2.1 (cheapest, 5h reset) - 5. if/kimi-k2-thinking (free unlimited) + 1. cc/claude-opus-4-7 (kualitas terbaik) + 2. cx/gpt-5.2-codex (langganan kedua) + 3. glm/glm-4.7 (murah, reset harian) + 4. minimax/MiniMax-M2.1 (termurah, reset 5 jam) + 5. if/kimi-k2-thinking (gratis tanpa batas) -Result: 5 layers of fallback = zero downtime -Monthly cost: $20-200 (subscriptions) + $10-20 (backup) +Hasil: 5 lapis fallback = nol downtime +Biaya bulanan: $20-200 (langganan) + $10-20 (cadangan) ``` -### Case 4: "I want FREE AI in OpenClaw" +### Kasus 4: "Saya ingin AI GRATIS di OpenClaw" -**Problem:** Need AI assistant in messaging apps, completely free +**Masalah:** Perlu asisten AI di aplikasi pesan, sepenuhnya gratis ``` Combo: "openclaw-free" - 1. if/glm-4.7 (unlimited free) - 2. if/minimax-m2.1 (unlimited free) - 3. if/kimi-k2-thinking (unlimited free) + 1. if/glm-4.7 (gratis tanpa batas) + 2. if/minimax-m2.1 (gratis tanpa batas) + 3. if/kimi-k2-thinking (gratis tanpa batas) -Monthly cost: $0 -Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +Biaya bulanan: $0 +Akses melalui: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... ``` --- -## 📖 Provider Setup +## 📖 Pengaturan Penyedia -### 🔐 Subscription Providers +### 🔐 Penyedia Berlangganan #### Claude Code (Pro/Max) @@ -128,7 +128,7 @@ Models: cc/claude-haiku-4-5-20251001 ``` -**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model! +**Tips Pro:** Gunakan Opus untuk tugas kompleks, Sonnet untuk kecepatan. OmniRoute melacak kuota per model! #### OpenAI Codex (Plus/Pro) @@ -142,7 +142,7 @@ Models: cx/gpt-5.1-codex-max ``` -#### Gemini CLI (FREE 180K/month!) +#### Gemini CLI (GRATIS 180K/bulan!) ```bash Dashboard → Providers → Connect Gemini CLI @@ -154,7 +154,7 @@ Models: gc/gemini-2.5-pro ``` -**Best Value:** Huge free tier! Use this before paid tiers. +**Nilai Terbaik:** Tingkat gratis yang sangat besar! Gunakan ini sebelum tingkatan berbayar. #### GitHub Copilot @@ -169,33 +169,33 @@ Models: gh/gemini-3.1-pro-preview ``` -### 💰 Cheap Providers +### 💰 Penyedia Murah -#### GLM-4.7 (Daily reset, $0.6/1M) +#### GLM-4.7 (Reset harian, $0.6/1M) -1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) -2. Get API key from Coding Plan -3. Dashboard → Add API Key: Provider: `glm`, API Key: `your-key` +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Dapatkan kunci API dari Coding Plan +3. Dasbor → Tambahkan Kunci API: Penyedia: `glm`, Kunci API: `your-key` -**Use:** `glm/glm-4.7` — **Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. +**Gunakan:** `glm/glm-4.7` — **Tips Pro:** Coding Plan menawarkan kuota 3× dengan biaya 1/7! Reset setiap hari pukul 10:00. -#### MiniMax M2.1 (5h reset, $0.20/1M) +#### MiniMax M2.1 (Reset 5 jam, $0.20/1M) -1. Sign up: [MiniMax](https://www.minimax.io/) -2. Get API key → Dashboard → Add API Key +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Dapatkan kunci API → Dasbor → Tambahkan Kunci API -**Use:** `minimax/MiniMax-M2.1` — **Pro Tip:** Cheapest option for long context (1M tokens)! +**Gunakan:** `minimax/MiniMax-M2.1` — **Tips Pro:** Pilihan termurah untuk konteks panjang (1M token)! -#### Kimi K2 ($9/month flat) +#### Kimi K2 ($9/bulan flat) -1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) -2. Get API key → Dashboard → Add API Key +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Dapatkan kunci API → Dasbor → Tambahkan Kunci API -**Use:** `kimi/kimi-latest` — **Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! +**Gunakan:** `kimi/kimi-latest` — **Tips Pro:** Tetap $9/bulan untuk 10M token = biaya efektif $0.90/1M! -### 🆓 FREE Providers +### 🆓 Penyedia GRATIS -#### Qoder (8 FREE models) +#### Qoder (8 model GRATIS) ```bash Dashboard → Connect Qoder → OAuth login → Unlimited usage @@ -203,7 +203,7 @@ Dashboard → Connect Qoder → OAuth login → Unlimited usage Models: if/kimi-k2-thinking, if/qwen3-coder-plus, if/glm-4.7, if/minimax-m2, if/deepseek-r1 ``` -#### Qwen (3 FREE models) +#### Qwen (3 model GRATIS) ```bash Dashboard → Connect Qwen → Device code auth → Unlimited usage @@ -211,7 +211,7 @@ Dashboard → Connect Qwen → Device code auth → Unlimited usage Models: qw/qwen3-coder-plus, qw/qwen3-coder-flash ``` -#### Kiro (Claude FREE) +#### Kiro (Claude GRATIS) ```bash Dashboard → Connect Kiro → AWS Builder ID or Google/GitHub → Unlimited @@ -221,46 +221,46 @@ Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5 --- -## 🎨 Combos +## 🎨 Combo -You can reorder combo cards directly in **Dashboard → Combos** by dragging the handle on each card. The order is stored in SQLite and restored on reload. +Anda dapat mengurutkan ulang kartu combo langsung di **Dashboard → Combos** dengan menyeret gagang pada setiap kartu. Urutan disimpan di SQLite dan dipulihkan saat dimuat ulang. -### Example 1: Maximize Subscription → Cheap Backup +### Contoh 1: Maksimalkan Langganan → Cadangan Murah ``` Dashboard → Combos → Create New Name: premium-coding Models: - 1. cc/claude-opus-4-7 (Subscription primary) - 2. glm/glm-4.7 (Cheap backup, $0.6/1M) - 3. minimax/MiniMax-M2.1 (Cheapest fallback, $0.20/1M) + 1. cc/claude-opus-4-7 (Langganan utama) + 2. glm/glm-4.7 (Cadangan murah, $0.6/1M) + 3. minimax/MiniMax-M2.1 (Fallback termurah, $0.20/1M) Use in CLI: premium-coding ``` -### Example 2: Free-Only (Zero Cost) +### Contoh 2: Hanya Gratis (Biaya Nol) ``` Name: free-combo Models: - 1. gc/gemini-3-flash-preview (180K free/month) - 2. if/kimi-k2-thinking (unlimited) - 3. qw/qwen3-coder-plus (unlimited) + 1. gc/gemini-3-flash-preview (180K gratis/bulan) + 2. if/kimi-k2-thinking (tanpa batas) + 3. qw/qwen3-coder-plus (tanpa batas) -Cost: $0 forever! +Cost: $0 selamanya! ``` --- -## 🔧 CLI Integration +## 🔧 Integrasi CLI ### Cursor IDE ``` Settings → Models → Advanced: OpenAI API Base URL: http://localhost:20128/v1 - OpenAI API Key: [from omniroute dashboard] + OpenAI API Key: [dari dasbor omniroute] Model: cc/claude-opus-4-7 ``` @@ -307,14 +307,14 @@ Edit `~/.openclaw/openclaw.json`: } ``` -**Or use Dashboard:** CLI Tools → OpenClaw → Auto-config +**Atau gunakan Dasbor:** CLI Tools → OpenClaw → Auto-config ### Cline / Continue / RooCode ``` Provider: OpenAI Compatible Base URL: http://localhost:20128/v1 -API Key: [from dashboard] +API Key: [dari dasbor] Model: cc/claude-opus-4-7 ``` @@ -322,7 +322,7 @@ Model: cc/claude-opus-4-7 ## Penerapan -### Global npm install (Recommended) +### Instalasi npm Global (Direkomendasikan) ```bash npm install -g omniroute @@ -339,20 +339,20 @@ omniroute omniroute --port 3000 ``` -The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`. +CLI secara otomatis memuat `.env` dari `~/.omniroute/.env` atau `./.env`. -### Uninstalling +### Menghapus Instalasi -When you no longer need OmniRoute, we provide two quick scripts for a clean removal: +Saat Anda tidak lagi memerlukan OmniRoute, kami menyediakan dua skrip cepat untuk penghapusan bersih: -| Command | Action | -| ------------------------ | ----------------------------------------------------------------------------------- | -| `npm run uninstall` | Removes the system app but **keeps your DB and configurations** in `~/.omniroute`. | -| `npm run uninstall:full` | Removes the app AND permanently **erases all configurations, keys, and databases**. | +| Perintah | Tindakan | +| ------------------------ | -------------------------------------------------------------------------------------------------- | +| `npm run uninstall` | Menghapus aplikasi dari sistem tetapi **menyimpan DB dan konfigurasi** di `~/.omniroute`. | +| `npm run uninstall:full` | Menghapus aplikasi DAN secara permanen **menghapus semua konfigurasi, kunci, dan basis data**. | -> Note: To run these commands, navigate to the OmniRoute project folder (if you cloned it) and run them. Alternatively, if globally installed, you can simply run `npm uninstall -g omniroute`. +> Catatan: Untuk menjalankan perintah ini, navigasikan ke folder proyek OmniRoute (jika Anda telah meng-clone-nya) dan jalankan. Atau, jika diinstal secara global, Anda cukup menjalankan `npm uninstall -g omniroute`. -### VPS Deployment +### Penerapan VPS ```bash git clone https://github.com/diegosouzapw/OmniRoute.git @@ -371,9 +371,9 @@ npm run start # Or: pm2 start npm --name omniroute -- start ``` -### PM2 Deployment (Low Memory) +### Penerapan PM2 (Memori Rendah) -For servers with limited RAM, use the memory limit option: +Untuk server dengan RAM terbatas, gunakan opsi batas memori: ```bash # With 512MB limit (default) @@ -386,7 +386,7 @@ OMNIROUTE_MEMORY_MB=512 pm2 start npm --name omniroute -- start pm2 start ecosystem.config.js ``` -Create `ecosystem.config.js`: +Buat `ecosystem.config.js`: ```javascript module.exports = { @@ -418,14 +418,14 @@ docker build -t omniroute:cli . docker run -d --name omniroute -p 20128:20128 --env-file ./.env -v omniroute-data:/app/data omniroute:cli ``` -For host-integrated mode with CLI binaries, see the Docker section in the main docs. +Untuk mode integrasi host dengan binari CLI, lihat bagian Docker di dokumentasi utama. ### Void Linux (xbps-src) -Void Linux users can package and install OmniRoute natively using the `xbps-src` cross-compilation framework. This automates the Node.js standalone build along with the required `better-sqlite3` native bindings. +Pengguna Void Linux dapat mengemas dan menginstal OmniRoute secara native menggunakan framework kompilasi silang `xbps-src`. Ini mengotomasi build standalone Node.js beserta binding native `better-sqlite3` yang diperlukan.
-View xbps-src template +Lihat template xbps-src ```bash # Template file for 'omniroute' @@ -520,45 +520,45 @@ post_install() {
-### Environment Variables +### Variabel Lingkungan -| Variable | Default | Description | -| --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) | -| `INITIAL_PASSWORD` | `123456` | First login password | -| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) | -| `PORT` | framework default | Service port (`20128` in examples) | -| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | -| `NODE_ENV` | runtime default | Set `production` for deploy | -| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL | -| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL | -| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | -| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` | -| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | Server-side refresh cadence for cached Provider Limits data; UI refresh buttons still trigger manual sync | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | Disable automatic SQLite snapshots before writes/import/restore; manual backups still work | -| `APP_LOG_TO_FILE` | `true` | Enables application and audit log output to disk | -| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) | -| `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download | -| `CLOUDFLARED_PROTOCOL` | `http2` | Transport for managed Quick Tunnels (`http2`, `quic`, or `auto`) | -| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB | -| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries | +| Variabel | Default | Deskripsi | +| --------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | `omniroute-default-secret-change-me` | Rahasia penandatanganan JWT (**ubah di produksi**) | +| `INITIAL_PASSWORD` | `123456` | Kata sandi login pertama | +| `DATA_DIR` | `~/.omniroute` | Direktori data (db, penggunaan, log) | +| `PORT` | default framework | Port layanan (`20128` dalam contoh) | +| `HOSTNAME` | default framework | Host bind (Docker default ke `0.0.0.0`) | +| `NODE_ENV` | default runtime | Atur `production` untuk penerapan | +| `BASE_URL` | `http://localhost:20128` | URL berbasis sisi server internal | +| `CLOUD_URL` | `https://omniroute.dev` | Cloud sinkronisasi titik akhir berbasis URL | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Rahasia HMAC untuk kunci API yang dihasilkan | +| `REQUIRE_API_KEY` | `false` | Wajibkan kunci API Bearer di `/v1/*` | +| `ALLOW_API_KEY_REVEAL` | `false` | Izinkan Api Manager menyalin kunci API lengkap sesuai permintaan | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | Frekuensi refresh sisi server untuk data Provider Limits yang di-cache; tombol refresh UI tetap memicu sinkronisasi manual | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | Nonaktifkan snapshot SQLite otomatis sebelum tulis/impor/pemulihan; backup manual tetap berfungsi | +| `APP_LOG_TO_FILE` | `true` | Mengaktifkan output log aplikasi dan audit ke disk | +| `AUTH_COOKIE_SECURE` | `false` | Paksa cookie auth `Secure` (di belakang reverse proxy HTTPS) | +| `CLOUDFLARED_BIN` | tidak diatur | Gunakan binari `cloudflared` yang sudah ada alih-alih unduhan terkelola | +| `CLOUDFLARED_PROTOCOL` | `http2` | Transport untuk Quick Tunnel terkelola (`http2`, `quic`, atau `auto`) | +| `OMNIROUTE_MEMORY_MB` | `512` | Batas heap Node.js dalam MB | +| `PROMPT_CACHE_MAX_SIZE` | `50` | Entri cache prompt maksimum | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Entri cache semantik maksimum | -For the full environment variable reference, see the [README](../README.md). +Untuk referensi variabel lingkungan lengkap, lihat [README](../README.md). --- -## 📊 Available Models +## 📊 Model yang Tersedia
-View all available models +Lihat semua model yang tersedia **Claude Code (`cc/`)** — Pro/Max: `cc/claude-opus-4-7`, `cc/claude-sonnet-4-5-20250929`, `cc/claude-haiku-4-5-20251001` **Codex (`cx/`)** — Plus/Pro: `cx/gpt-5.2-codex`, `cx/gpt-5.1-codex-max` -**Gemini CLI (`gc/`)** — FREE: `gc/gemini-3-flash-preview`, `gc/gemini-2.5-pro` +**Gemini CLI (`gc/`)** — GRATIS: `gc/gemini-3-flash-preview`, `gc/gemini-2.5-pro` **GitHub Copilot (`gh/`)**: `gh/gpt-5`, `gh/claude-4.5-sonnet` @@ -566,11 +566,11 @@ For the full environment variable reference, see the [README](../README.md). **MiniMax (`minimax/`)** — $0.2/1M: `minimax/MiniMax-M2.1` -**Qoder (`if/`)** — FREE: `if/kimi-k2-thinking`, `if/qwen3-coder-plus`, `if/deepseek-r1` +**Qoder (`if/`)** — GRATIS: `if/kimi-k2-thinking`, `if/qwen3-coder-plus`, `if/deepseek-r1` -**Qwen (`qw/`)** — FREE: `qw/qwen3-coder-plus`, `qw/qwen3-coder-flash` +**Qwen (`qw/`)** — GRATIS: `qw/qwen3-coder-plus`, `qw/qwen3-coder-flash` -**Kiro (`kr/`)** — FREE: `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5` +**Kiro (`kr/`)** — GRATIS: `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5` **DeepSeek (`ds/`)**: `ds/deepseek-chat`, `ds/deepseek-reasoner` @@ -596,11 +596,11 @@ For the full environment variable reference, see the [README](../README.md). --- -## 🧩 Advanced Features +## 🧩 Fitur Lanjutan -### Custom Models +### Model Kustom -Add any model ID to any provider without waiting for an app update: +Tambahkan ID model apa pun ke penyedia mana pun tanpa menunggu pembaruan aplikasi: ```bash # Via API @@ -612,16 +612,16 @@ curl -X POST http://localhost:20128/api/provider-models \ # Remove: curl -X DELETE "http://localhost:20128/api/provider-models?provider=openai&model=gpt-4.5-preview" ``` -Or use Dashboard: **Providers → [Provider] → Custom Models**. +Atau gunakan Dasbor: **Penyedia → [Penyedia] → Model Khusus**. -Notes: +Catatan: -- OpenRouter and OpenAI/Anthropic-compatible providers are managed from **Available Models** only. Manual add, import, and auto-sync all land in the same available-model list, so there is no separate Custom Models section for those providers. -- The **Custom Models** section is intended for providers that do not expose managed available-model imports. +- Penyedia yang kompatibel dengan OpenRouter dan OpenAI/Anthropic dikelola hanya melalui **Available Models**. Penambahan manual, impor, dan auto-sync semuanya masuk ke daftar model yang sama, sehingga tidak ada bagian Custom Models terpisah untuk penyedia tersebut. +- Bagian **Custom Models** ditujukan untuk penyedia yang tidak mengekspos impor model yang terkelola. -### Dedicated Provider Routes +### Rute Penyedia Khusus -Route requests directly to a specific provider with model validation: +Arahkan permintaan langsung ke penyedia tertentu dengan validasi model: ```bash POST http://localhost:20128/v1/providers/openai/chat/completions @@ -629,9 +629,9 @@ POST http://localhost:20128/v1/providers/openai/embeddings POST http://localhost:20128/v1/providers/fireworks/images/generations ``` -The provider prefix is auto-added if missing. Mismatched models return `400`. +Awalan penyedia ditambahkan otomatis jika tidak ada. Model yang tidak cocok mengembalikan `400`. -### Network Proxy Configuration +### Konfigurasi Proxy Jaringan ```bash # Set global proxy @@ -647,103 +647,103 @@ curl -X POST http://localhost:20128/api/settings/proxy/test \ -d '{"proxy":{"type":"socks5","host":"proxy.example.com","port":"1080"}}' ``` -**Precedence:** Key-specific → Combo-specific → Provider-specific → Global → Environment. +**Urutan Prioritas:** Spesifik-kunci → Spesifik-combo → Spesifik-penyedia → Global → Lingkungan. -### Model Catalog API +### API Katalog Model ```bash curl http://localhost:20128/api/models/catalog ``` -Returns models grouped by provider with types (`chat`, `embedding`, `image`). +Mengembalikan model yang dikelompokkan berdasarkan penyedia dengan tipe (`chat`, `embedding`, `image`). -### Cloud Sync +### Sinkronisasi Cloud -- Sync providers, combos, and settings across devices -- Automatic background sync with timeout + fail-fast -- Prefer server-side `BASE_URL`/`CLOUD_URL` in production +- Sinkronkan penyedia, combo, dan pengaturan di semua perangkat +- Sinkronisasi latar belakang otomatis dengan timeout + gagal cepat +- Gunakan `BASE_URL`/`CLOUD_URL` sisi server di produksi ### Cloudflare Quick Tunnel -- Available in **Dashboard → Endpoints** for Docker and other self-hosted deployments -- Creates a temporary `https://*.trycloudflare.com` URL that forwards to your current OpenAI-compatible `/v1` endpoint -- First enable installs `cloudflared` only when needed; later restarts reuse the same managed binary -- Quick Tunnels are not auto-restored after an OmniRoute or container restart; re-enable them from the dashboard when needed -- Tunnel URLs are ephemeral and change every time you stop/start the tunnel -- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained containers -- Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want to override the managed transport choice -- Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download +- Tersedia di **Dashboard → Endpoints** untuk penerapan Docker dan self-hosted lainnya +- Membuat URL `https://*.trycloudflare.com` sementara yang diteruskan ke endpoint `/v1` Anda yang kompatibel dengan OpenAI +- Aktifkan pertama kali untuk menginstal `cloudflared` hanya saat diperlukan; restart berikutnya menggunakan kembali binari terkelola yang sama +- Quick Tunnel tidak dipulihkan otomatis setelah OmniRoute atau container di-restart; aktifkan kembali dari dasbor bila diperlukan +- URL tunnel bersifat sementara dan berubah setiap kali Anda menghentikan/memulai tunnel +- Managed Quick Tunnel secara default menggunakan transport HTTP/2 untuk menghindari peringatan buffer UDP QUIC yang mengganggu di container terbatas +- Atur `CLOUDFLARED_PROTOCOL=quic` atau `auto` jika ingin mengubah pilihan transport terkelola +- Atur `CLOUDFLARED_BIN` jika ingin menggunakan binari `cloudflared` yang sudah terinstal alih-alih unduhan terkelola -### LLM Gateway Intelligence (Phase 9) +### Kecerdasan LLM Gateway (Fase 9) -- **Semantic Cache** — Auto-caches non-streaming, temperature=0 responses (bypass with `X-OmniRoute-No-Cache: true`) -- **Request Idempotency** — Deduplicates requests within 5s via `Idempotency-Key` or `X-Request-Id` header -- **Progress Tracking** — Opt-in SSE `event: progress` events via `X-OmniRoute-Progress: true` header +- **Cache Semantik** — Otomatis menyimpan respons non-streaming, temperature=0 (lewati dengan `X-OmniRoute-No-Cache: true`) +- **Idempotensitas Permintaan** — Mendeduplikasi permintaan dalam 5 detik melalui header `Idempotency-Key` atau `X-Request-Id` +- **Pelacakan Progres** — Event SSE `event: progress` yang bisa diaktifkan melalui header `X-OmniRoute-Progress: true` --- ### Translator Playground -Access via **Dashboard → Translator**. Debug and visualize how OmniRoute translates API requests between providers. +Akses melalui **Dashboard → Translator**. Debug dan visualisasikan bagaimana OmniRoute menerjemahkan permintaan API antar penyedia. -| Mode | Purpose | -| ---------------- | -------------------------------------------------------------------------------------- | -| **Playground** | Select source/target formats, paste a request, and see the translated output instantly | -| **Chat Tester** | Send live chat messages through the proxy and inspect the full request/response cycle | -| **Test Bench** | Run batch tests across multiple format combinations to verify translation correctness | -| **Live Monitor** | Watch real-time translations as requests flow through the proxy | +| Mode | Tujuan | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| **Playground** | Pilih format sumber/target, tempel permintaan, dan lihat hasil terjemahan secara instan | +| **Chat Tester** | Kirim pesan chat langsung melalui proxy dan periksa siklus permintaan/respons lengkap | +| **Test Bench** | Jalankan pengujian batch di berbagai kombinasi format untuk memverifikasi kebenaran terjemahan | +| **Live Monitor** | Amati terjemahan real-time saat permintaan mengalir melalui proxy | -**Use cases:** +**Kasus penggunaan:** -- Debug why a specific client/provider combination fails -- Verify that thinking tags, tool calls, and system prompts translate correctly -- Compare format differences between OpenAI, Claude, Gemini, and Responses API formats +- Debug mengapa kombinasi klien/penyedia tertentu gagal +- Verifikasi bahwa tag thinking, pemanggilan tool, dan system prompt diterjemahkan dengan benar +- Bandingkan perbedaan format antara OpenAI, Claude, Gemini, dan format Responses API --- -### Routing Strategies +### Strategi Routing -Configure via **Dashboard → Settings → Routing**. +Konfigurasikan melalui **Dasbor → Pengaturan → Perutean**. | Strategy | Description | | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| **Fill First** | Uses accounts in priority order — primary account handles all requests until unavailable | -| **Round Robin** | Cycles through all accounts with a configurable sticky limit (default: 3 calls per account) | -| **P2C (Power of Two Choices)** | Picks 2 random accounts and routes to the healthier one — balances load with awareness of health | -| **Random** | Randomly selects an account for each request using Fisher-Yates shuffle | -| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly | -| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers | +| **Fill First** | Menggunakan akun dalam urutan prioritas — akun utama menangani semua permintaan hingga tidak tersedia | +| **Round Robin** | Menggilir semua akun dengan batas melekat yang dapat dikonfigurasi (default: 3 panggilan per akun) | +| **P2C (Power of Two Choices)** | Pilih 2 akun acak dan rute ke akun yang lebih sehat — menyeimbangkan beban dengan kesadaran akan kesehatan | +| **Random** | Memilih akun secara acak untuk setiap permintaan menggunakan pengacakan Fisher-Yates | +| **Least Used** | Merutekan ke akun dengan stempel waktu `lastUsedAt` terlama, mendistribusikan lalu lintas secara merata | +| **Cost Optimized** | Merutekan ke akun dengan nilai prioritas terendah, mengoptimalkan penyedia berbiaya terendah | -#### External Sticky Session Header +#### Header Sesi Lengket Eksternal -For external session affinity (for example, Claude Code/Codex agents behind reverse proxies), send: +Untuk afinitas sesi eksternal (misalnya, agen Claude Code/Codex di belakang proxy terbalik), kirim: ```http X-Session-Id: your-session-key ``` -OmniRoute also accepts `x_session_id` and returns the effective session key in `X-OmniRoute-Session-Id`. +OmniRoute juga menerima `x_session_id` dan mengembalikan kunci sesi efektif di `X-OmniRoute-Session-Id`. -If you use Nginx and send underscore-form headers, enable: +Jika Anda menggunakan Nginx dan mengirim header berbentuk garis bawah, aktifkan: ```nginx underscores_in_headers on; ``` -#### Wildcard Model Aliases +#### Model Alias Wildcard -Create wildcard patterns to remap model names: +Buat pola wildcard untuk memetakan ulang nama model: ``` Pattern: claude-sonnet-* → Target: cc/claude-sonnet-4-5-20250929 Pattern: gpt-* → Target: gh/gpt-5.1-codex ``` -Wildcards support `*` (any characters) and `?` (single character). +Wildcard mendukung `*` (karakter apa saja) dan `?` (karakter tunggal). #### Fallback Chains -Define global fallback chains that apply across all requests: +Tentukan rantai fallback global yang berlaku di semua permintaan: ``` Chain: production-fallback @@ -754,50 +754,50 @@ Chain: production-fallback --- -### Resilience & Circuit Breakers +### Ketahanan & Pemutus Sirkuit -Configure via **Dashboard → Settings → Resilience**. +Konfigurasikan melalui **Dasbor → Pengaturan → Ketahanan**. -OmniRoute implements provider-level resilience with five components: +OmniRoute mengimplementasikan ketahanan tingkat penyedia dengan lima komponen: -1. **Request Queue & Pacing** — System-level request shaping: - - **Requests Per Minute (RPM)** — Maximum requests per minute per account - - **Min Time Between Requests** — Minimum gap in milliseconds between requests - - **Max Concurrent Requests** — Maximum simultaneous requests per account +1. **Antrian & Kecepatan Permintaan** — Pembentukan permintaan tingkat sistem: + - **Permintaan Per Menit (RPM)** — Permintaan maksimum per menit per akun + - **Waktu Minimum Antar Permintaan** — Kesenjangan minimum dalam milidetik antar permintaan + - **Permintaan Bersamaan Maksimum** — Permintaan simultan maksimum per akun -2. **Connection Cooldown** — Per-auth-type configuration for a single connection after retryable failures: - - **Base Cooldown** — Default cooldown window for retryable upstream failures - - **Use Upstream Retry Hints** — Honors authoritative `Retry-After` or reset hints when provided - - **Max Backoff Steps** — Maximum exponential backoff level for repeated failures +2. **Cooldown Koneksi** — Konfigurasi tipe per autentikasi untuk satu koneksi setelah kegagalan yang dapat dicoba lagi: + - **Cooldown Dasar** — Jendela cooldown default untuk kegagalan upstream yang dapat dicoba ulang + - **Gunakan Petunjuk Coba Ulang Hulu** — Ikuti `Retry-After` resmi atau petunjuk setel ulang bila diberikan + - **Langkah Backoff Maks** — Tingkat backoff eksponensial maksimum untuk kegagalan berulang -3. **Provider Circuit Breaker** — Tracks end-to-end provider failures and automatically opens the breaker when the configured threshold is reached: - - **Failure Threshold** — Consecutive provider failures before opening the breaker - - **Reset Timeout** — Time window before the provider is tested again - - **CLOSED** (Healthy) — Requests flow normally - - **OPEN** — Provider is temporarily blocked after repeated failures - - **HALF_OPEN** — Testing if provider has recovered +3. **Pemutus Sirkuit Penyedia** — Melacak kegagalan penyedia ujung ke ujung dan secara otomatis membuka pemutus ketika ambang batas yang dikonfigurasi tercapai: + - **Ambang Kegagalan** — Kegagalan penyedia berturut-turut sebelum membuka pemutus + - **Reset Timeout** — Jangka waktu sebelum penyedia diuji lagi + - **TUTUP** (Sehat) — Permintaan mengalir normal + - **BUKA** — Penyedia diblokir sementara setelah kegagalan berulang kali + - **HALF_OPEN** — Menguji apakah penyedia telah pulih - Connection-scoped `429` rate limits stay in **Connection Cooldown** and do not count toward the provider breaker. + Batas kecepatan `429` cakupan koneksi tetap dalam **Cooldown Koneksi** dan tidak diperhitungkan dalam pemutus penyedia. - The provider breaker runtime state is shown on **Dashboard → Health** only. + Status waktu proses pemutus penyedia hanya ditampilkan di **Dasbor → Kesehatan**. -4. **Wait For Cooldown** — If every candidate connection is already cooling down, OmniRoute can wait for the earliest cooldown and retry the same client request automatically. +4. **Tunggu Cooldown** — Jika setiap kandidat koneksi sudah cooldown, OmniRoute dapat menunggu cooldown paling awal dan mencoba kembali permintaan klien yang sama secara otomatis. -5. **Rate Limit Auto-Detection** — When upstream providers return explicit wait windows, those hints override the local connection cooldown when the setting is enabled. +5. **Deteksi Otomatis Batas Kecepatan** — Saat penyedia upstream mengembalikan jendela tunggu eksplisit, petunjuk tersebut akan menggantikan jeda pakai koneksi lokal saat pengaturan diaktifkan. -**Pro Tip:** Use the **Health** page to inspect and reset live provider breakers after an outage. The Resilience page only changes configuration. +**Kiat Pro:** Gunakan laman **Kesehatan** untuk memeriksa dan menyetel ulang pemutus penyedia langsung setelah pemadaman. Halaman Ketahanan hanya mengubah konfigurasi. --- -### Database Export / Import +### Ekspor/Impor Basis Data -Manage database backups in **Dashboard → Settings → System & Storage**. +Kelola cadangan basis data di **Dasbor → Pengaturan → Sistem & Penyimpanan**. | Action | Description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| **Export Database** | Downloads the current SQLite database as a `.sqlite` file | -| **Export All (.tar.gz)** | Downloads a full backup archive including: database, settings, combos, provider connections (no credentials), API key metadata | -| **Import Database** | Upload a `.sqlite` file to replace the current database. A pre-import backup is automatically created unless `DISABLE_SQLITE_AUTO_BACKUP=true` | +| **Export Database** | Mengunduh database SQLite saat ini sebagai file `.sqlite` | +| **Ekspor Semua (.tar.gz)** | Mengunduh arsip cadangan lengkap termasuk: basis data, pengaturan, kombo, koneksi penyedia (tanpa kredensial), metadata kunci API | +| **Import Database** | Unggah file `.sqlite` untuk menggantikan database saat ini. Cadangan pra-impor dibuat secara otomatis kecuali `DISABLE_SQLITE_AUTO_BACKUP=true` | ```bash # API: Export database @@ -811,39 +811,39 @@ curl -X POST http://localhost:20128/api/db-backups/import \ -F "file=@backup.sqlite" ``` -**Import Validation:** The imported file is validated for integrity (SQLite pragma check), required tables (`provider_connections`, `provider_nodes`, `combos`, `api_keys`), and size (max 100MB). +**Validasi Impor:** File yang diimpor divalidasi integritasnya (pemeriksaan pragma SQLite), tabel yang diperlukan (`provider_connections`, `provider_nodes`, `combos`, `api_keys`), dan ukuran (maks 100MB). **Use Cases:** -- Migrate OmniRoute between machines -- Create external backups for disaster recovery -- Share configurations between team members (export all → share archive) +- Migrasi OmniRoute antar mesin +- Buat cadangan eksternal untuk pemulihan bencana +- Bagikan konfigurasi antar anggota tim (ekspor semua → bagikan arsip) --- -### Settings Dashboard +### Dashboard Pengaturan -The settings page is organized into 6 tabs for easy navigation: +Halaman pengaturan disusun menjadi 6 tab untuk memudahkan navigasi: | Tab | Contents | | -------------- | -------------------------------------------------------------------------------------------- | | **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility | -| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking | +| **Security** |Pengaturan Login/Kata Sandi, Kontrol Akses IP, autentikasi API untuk `/models`, dan Pemblokiran Penyedia | | **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults | -| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior | +| **Resilience** | Antrean permintaan, waktu tunggu koneksi, konfigurasi pemutus penyedia, dan perilaku menunggu waktu tunggu | | **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats | -| **Advanced** | Global proxy configuration (HTTP/SOCKS5) | +| **Advanced** | Konfigurasi proksi global (HTTP/SOCKS5) | --- -### Costs & Budget Management +### Biaya & Manajemen Anggaran -Access via **Dashboard → Costs**. +Akses melalui **Dasbor → Biaya**. | Tab | Purpose | | ----------- | ---------------------------------------------------------------------------------------- | -| **Budget** | Set spending limits per API key with daily/weekly/monthly budgets and real-time tracking | -| **Pricing** | View and edit model pricing entries — cost per 1K input/output tokens per provider | +| **Budget** | Tetapkan batas pengeluaran per kunci API dengan anggaran harian/mingguan/bulanan dan pelacakan waktu nyata | +| **Pricing** | Lihat dan edit entri harga model — biaya per 1K token input/output per penyedia | ```bash # API: Set a budget @@ -855,13 +855,13 @@ curl -X POST http://localhost:20128/api/usage/budget \ curl http://localhost:20128/api/usage/budget ``` -**Cost Tracking:** Every request logs token usage and calculates cost using the pricing table. View breakdowns in **Dashboard → Usage** by provider, model, and API key. +**Pelacakan Biaya:** Setiap permintaan mencatat penggunaan token dan menghitung biaya menggunakan tabel harga. Lihat pengelompokan di **Dasbor → Penggunaan** menurut penyedia, model, dan kunci API. --- -### Audio Transcription +### Transkripsi Audio -OmniRoute supports audio transcription via the OpenAI-compatible endpoint: +OmniRoute mendukung transkripsi audio melalui titik akhir yang kompatibel dengan OpenAI: ```bash POST /v1/audio/transcriptions @@ -875,49 +875,49 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ -F "model=deepgram/nova-3" ``` -Available providers: **Deepgram** (`deepgram/`), **AssemblyAI** (`assemblyai/`). +Penyedia yang tersedia: **Deepgram** (`deepgram/`), **AssemblyAI** (`assemblyai/`). -Supported audio formats: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`. +Format audio yang didukung: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`. --- -### Combo Balancing Strategies +### Strategi Penyeimbangan Kombo -Configure per-combo balancing in **Dashboard → Combos → Create/Edit → Strategy**. +Konfigurasikan penyeimbangan per kombo di **Dasbor → Kombo → Buat/Edit → Strategi**. | Strategy | Description | | ------------------ | ------------------------------------------------------------------------ | -| **Round-Robin** | Rotates through models sequentially | -| **Priority** | Always tries the first model; falls back only on error | -| **Random** | Picks a random model from the combo for each request | -| **Weighted** | Routes proportionally based on assigned weights per model | -| **Least-Used** | Routes to the model with the fewest recent requests (uses combo metrics) | -| **Cost-Optimized** | Routes to the cheapest available model (uses pricing table) | +| **Round-Robin** | Berputar melalui model secara berurutan | +| **Priority** | Selalu mencoba model pertama; jatuh kembali hanya karena kesalahan | +| **Random** | Memilih model acak dari kombo untuk setiap permintaan | +| **Weighted** | Rute secara proporsional berdasarkan bobot yang ditetapkan per model | +| **Least-Used** | Merutekan ke model dengan permintaan terkini paling sedikit (menggunakan metrik kombo) | +| **Cost-Optimized** | Rute ke model termurah yang tersedia (menggunakan tabel harga) | -Global combo defaults can be set in **Dashboard → Settings → Routing → Combo Defaults**. +Default kombo global dapat diatur di **Dasbor → Pengaturan → Perutean → Default Kombo**. --- -### Health Dashboard +### Dashboard Kesehatan -Access via **Dashboard → Health**. Real-time system health overview with 6 cards: +Akses melalui **Dasbor → Kesehatan**. Ikhtisar kesehatan sistem real-time dengan 6 kartu: -| Card | What It Shows | +| Card | Apa yang Ditunjukkannya | | --------------------- | ----------------------------------------------------------- | | **System Status** | Uptime, version, memory usage, data directory | -| **Provider Health** | Global provider circuit breaker runtime state | -| **Rate Limits** | Active connection cooldowns per account with remaining time | -| **Active Lockouts** | Active model-scoped lockouts and temporary exclusions | -| **Signature Cache** | Deduplication cache stats (active keys, hit rate) | -| **Latency Telemetry** | p50/p95/p99 latency aggregation per provider | +| **Provider Health** | Status runtime pemutus sirkuit penyedia global | +| **Rate Limits** | Cooldown koneksi aktif per akun dengan sisa waktu | +| **Active Lockouts** | Penguncian cakupan model aktif dan pengecualian sementara | +| **Signature Cache** | Statistik cache deduplikasi (kunci aktif, tingkat hit) | +| **Latency Telemetry** | agregasi latensi p50/p95/p99 per penyedia | -**Pro Tip:** The Health page auto-refreshes every 10 seconds. Use the circuit breaker card to identify which providers are experiencing issues. +**Tips Pro:** Halaman Kesehatan disegarkan secara otomatis setiap 10 detik. Gunakan kartu pemutus sirkuit untuk mengidentifikasi penyedia mana yang mengalami masalah. --- -## 🖥️ Desktop Application (Electron) +## 🖥️ Aplikasi Desktop (Elektron) -OmniRoute is available as a native desktop application for Windows, macOS, and Linux. +OmniRoute tersedia sebagai aplikasi desktop asli untuk Windows, macOS, dan Linux. ### Instal @@ -933,7 +933,7 @@ npm run dev npm start ``` -### Building Installers +### Membuat Installer ```bash cd electron @@ -945,18 +945,18 @@ npm run build:linux # Linux (.AppImage) Output → `electron/dist-electron/` -### Key Features +### Fitur Utama | Feature | Description | | --------------------------- | ---------------------------------------------------- | -| **Server Readiness** | Polls server before showing window (no blank screen) | +| **Server Readiness** |Server jajak pendapat sebelum menampilkan jendela (tidak ada layar kosong) | | **System Tray** | Minimize to tray, change port, quit from tray menu | -| **Port Management** | Change server port from tray (auto-restarts server) | -| **Content Security Policy** | Restrictive CSP via session headers | -| **Single Instance** | Only one app instance can run at a time | -| **Offline Mode** | Bundled Next.js server works without internet | +| **Port Management** | Ubah port server dari baki (server restart otomatis) | +| **Kebijakan Keamanan Konten** | CSP terbatas melalui header sesi | +| **Single Instance** | Hanya satu instance aplikasi yang dapat berjalan dalam satu waktu | +| **Offline Mode** | Server Next.js yang dibundel berfungsi tanpa internet| -### Environment Variables +### Variabel Lingkungan | Variable | Default | Description | | --------------------- | ------- | -------------------------------- | diff --git a/docs/i18n/id/docs/ops/COVERAGE_PLAN.md b/docs/i18n/id/docs/ops/COVERAGE_PLAN.md index 3437f09f0f..45dd535d43 100644 --- a/docs/i18n/id/docs/ops/COVERAGE_PLAN.md +++ b/docs/i18n/id/docs/ops/COVERAGE_PLAN.md @@ -1,158 +1,158 @@ -# Test Coverage Plan (Bahasa Indonesia) +# Rencana Cakupan Pengujian (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/COVERAGE_PLAN.md) · 🇸🇦 [ar](../../ar/docs/COVERAGE_PLAN.md) · 🇧🇬 [bg](../../bg/docs/COVERAGE_PLAN.md) · 🇧🇩 [bn](../../bn/docs/COVERAGE_PLAN.md) · 🇨🇿 [cs](../../cs/docs/COVERAGE_PLAN.md) · 🇩🇰 [da](../../da/docs/COVERAGE_PLAN.md) · 🇩🇪 [de](../../de/docs/COVERAGE_PLAN.md) · 🇪🇸 [es](../../es/docs/COVERAGE_PLAN.md) · 🇮🇷 [fa](../../fa/docs/COVERAGE_PLAN.md) · 🇫🇮 [fi](../../fi/docs/COVERAGE_PLAN.md) · 🇫🇷 [fr](../../fr/docs/COVERAGE_PLAN.md) · 🇮🇳 [gu](../../gu/docs/COVERAGE_PLAN.md) · 🇮🇱 [he](../../he/docs/COVERAGE_PLAN.md) · 🇮🇳 [hi](../../hi/docs/COVERAGE_PLAN.md) · 🇭🇺 [hu](../../hu/docs/COVERAGE_PLAN.md) · 🇮🇩 [id](../../id/docs/COVERAGE_PLAN.md) · 🇮🇹 [it](../../it/docs/COVERAGE_PLAN.md) · 🇯🇵 [ja](../../ja/docs/COVERAGE_PLAN.md) · 🇰🇷 [ko](../../ko/docs/COVERAGE_PLAN.md) · 🇮🇳 [mr](../../mr/docs/COVERAGE_PLAN.md) · 🇲🇾 [ms](../../ms/docs/COVERAGE_PLAN.md) · 🇳🇱 [nl](../../nl/docs/COVERAGE_PLAN.md) · 🇳🇴 [no](../../no/docs/COVERAGE_PLAN.md) · 🇵🇭 [phi](../../phi/docs/COVERAGE_PLAN.md) · 🇵🇱 [pl](../../pl/docs/COVERAGE_PLAN.md) · 🇵🇹 [pt](../../pt/docs/COVERAGE_PLAN.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/COVERAGE_PLAN.md) · 🇷🇴 [ro](../../ro/docs/COVERAGE_PLAN.md) · 🇷🇺 [ru](../../ru/docs/COVERAGE_PLAN.md) · 🇸🇰 [sk](../../sk/docs/COVERAGE_PLAN.md) · 🇸🇪 [sv](../../sv/docs/COVERAGE_PLAN.md) · 🇰🇪 [sw](../../sw/docs/COVERAGE_PLAN.md) · 🇮🇳 [ta](../../ta/docs/COVERAGE_PLAN.md) · 🇮🇳 [te](../../te/docs/COVERAGE_PLAN.md) · 🇹🇭 [th](../../th/docs/COVERAGE_PLAN.md) · 🇹🇷 [tr](../../tr/docs/COVERAGE_PLAN.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/COVERAGE_PLAN.md) · 🇵🇰 [ur](../../ur/docs/COVERAGE_PLAN.md) · 🇻🇳 [vi](../../vi/docs/COVERAGE_PLAN.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/COVERAGE_PLAN.md) --- -Last updated: 2026-03-28 +Terakhir diperbarui: 2026-03-28 ## Baseline -There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful. +Ada beberapa angka cakupan tergantung pada cara laporan dihitung. Untuk keperluan perencanaan, hanya satu yang berguna. -| Metric | Scope | Statements / Lines | Branches | Functions | Notes | -| -------------------- | ----------------------------------------------------- | -----------------: | -------: | --------: | --------------------------------------------------- | -| Legacy | Old `npm run test:coverage` | 79.42% | 75.15% | 67.94% | Inflated: counts test files and excludes `open-sse` | -| Diagnostic | Source-only, excluding tests and excluding `open-sse` | 68.16% | 63.55% | 64.06% | Useful only to isolate `src/**` | -| Recommended baseline | Source-only, excluding tests and including `open-sse` | 56.95% | 66.05% | 57.80% | This is the project-wide baseline to improve | +| Metrik | Ruang Lingkup | Pernyataan / Garis | Cabang | Fungsi | Catatan | +| -------------------- | -------------------------------------------------------------------- | -----------------: | -------: | --------: | ------------------------------------------------------------------------ | +| Lama | `npm run test:coverage` lama | 79.42% | 75.15% | 67.94% | Diperbesar: menghitung file pengujian dan mengecualikan `open-sse` | +| Diagnostik | Hanya sumber, mengecualikan pengujian dan mengecualikan `open-sse` | 68.16% | 63.55% | 64.06% | Berguna hanya untuk mengisolasi `src/**` | +| Baseline yang disarankan | Hanya sumber, mengecualikan pengujian dan menyertakan `open-sse` | 56.95% | 66.05% | 57.80% | Ini adalah baseline seluruh proyek yang perlu ditingkatkan | -The recommended baseline is the number to optimize against. +Baseline yang disarankan adalah angka yang perlu dioptimalkan. -## Rules +## Aturan -- Coverage targets apply to source files, not to `tests/**`. -- `open-sse/**` is part of the product and must remain in scope. -- New code should not reduce coverage in touched areas. -- Prefer testing behavior and branch outcomes over implementation details. -- Prefer temp SQLite databases and small fixtures over broad mocks for `src/lib/db/**`. +- Target cakupan berlaku untuk file sumber, bukan untuk `tests/**`. +- `open-sse/**` adalah bagian dari produk dan harus tetap dalam cakupan. +- Kode baru tidak boleh mengurangi cakupan di area yang disentuh. +- Utamakan pengujian perilaku dan hasil cabang dibandingkan detail implementasi. +- Utamakan database SQLite sementara dan fixture kecil dibandingkan mock yang luas untuk `src/lib/db/**`. -## Current command set +## Kumpulan perintah saat ini - `npm run test:coverage` - - Main source coverage gate for the unit test suite - - Generates `text-summary`, `html`, `json-summary`, and `lcov` + - Gerbang cakupan sumber utama untuk suite pengujian unit + - Menghasilkan `text-summary`, `html`, `json-summary`, dan `lcov` - `npm run coverage:report` - - Detailed file-by-file report from the latest run + - Laporan per-file terperinci dari jalankan terakhir - `npm run test:coverage:legacy` - - Historical comparison only + - Hanya untuk perbandingan historis -## Milestones +## Tonggak Pencapaian -| Phase | Target | Focus | -| ------- | ---------------------: | ------------------------------------------------- | -| Phase 1 | 60% statements / lines | Quick wins and low-risk utility coverage | -| Phase 2 | 65% statements / lines | DB and route foundations | -| Phase 3 | 70% statements / lines | Provider validation and usage analytics | -| Phase 4 | 75% statements / lines | `open-sse` translators and helpers | -| Phase 5 | 80% statements / lines | `open-sse` handlers and executor branches | -| Phase 6 | 85% statements / lines | Harder edge cases, branch debt, regression suites | -| Phase 7 | 90% statements / lines | Final sweep, gap closure, strict ratchet | +| Fase | Target | Fokus | +| ------- | ----------------------: | ------------------------------------------------------------------ | +| Fase 1 | 60% pernyataan / garis | Kemenangan cepat dan cakupan utilitas berisiko rendah | +| Fase 2 | 65% pernyataan / garis | Fondasi DB dan rute | +| Fase 3 | 70% pernyataan / garis | Validasi penyedia dan analitik penggunaan | +| Fase 4 | 75% pernyataan / garis | Penerjemah dan pembantu `open-sse` | +| Fase 5 | 80% pernyataan / garis | Handler dan cabang eksekutor `open-sse` | +| Fase 6 | 85% pernyataan / garis | Kasus tepi yang lebih sulit, utang cabang, suite regresi | +| Fase 7 | 90% pernyataan / garis | Pemeriksaan akhir, penutupan celah, ratchet ketat | -Branches and functions should ratchet upward with each phase, but the primary hard target is statements / lines. +Cabang dan fungsi harus meningkat secara bertahap di setiap fase, tetapi target keras utama adalah pernyataan / garis. -## Priority hotspots +## Titik panas prioritas -These files or areas offer the best return for the next phases: +File atau area berikut menawarkan keuntungan terbaik untuk fase selanjutnya: 1. `open-sse/handlers` - - `chatCore.ts` at 7.57% - - Overall directory at 29.07% + - `chatCore.ts` pada 7.57% + - Direktori keseluruhan pada 29.07% 2. `open-sse/translator/request` - - Overall directory at 36.39% - - Many translators are still near single-digit coverage + - Direktori keseluruhan pada 36.39% + - Banyak penerjemah masih mendekati cakupan satu digit 3. `open-sse/translator/response` - - Overall directory at 8.07% + - Direktori keseluruhan pada 8.07% 4. `open-sse/executors` - - Overall directory at 36.62% + - Direktori keseluruhan pada 36.62% 5. `src/lib/db` - - `models.ts` at 20.66% - - `registeredKeys.ts` at 34.46% - - `modelComboMappings.ts` at 36.25% - - `settings.ts` at 46.40% - - `webhooks.ts` at 33.33% + - `models.ts` pada 20.66% + - `registeredKeys.ts` pada 34.46% + - `modelComboMappings.ts` pada 36.25% + - `settings.ts` pada 46.40% + - `webhooks.ts` pada 33.33% 6. `src/lib/usage` - - `usageHistory.ts` at 21.12% - - `usageStats.ts` at 9.56% - - `costCalculator.ts` at 30.00% + - `usageHistory.ts` pada 21.12% + - `usageStats.ts` pada 9.56% + - `costCalculator.ts` pada 30.00% 7. `src/lib/providers` - - `validation.ts` at 41.16% -8. Low-risk utility and API files for early gains + - `validation.ts` pada 41.16% +8. File utilitas dan API berisiko rendah untuk keuntungan awal - `src/shared/utils/upstreamError.ts` - `src/shared/utils/apiAuth.ts` - `src/lib/api/errorResponse.ts` - `src/app/api/settings/require-login/route.ts` - `src/app/api/providers/[id]/models/route.ts` -## Execution checklist +## Daftar periksa eksekusi -### Phase 1: 56.95% -> 60% +### Fase 1: 56.95% -> 60% -- [x] Fix coverage metric so it reflects source code instead of test files -- [x] Keep a legacy coverage script for comparison -- [x] Record the baseline and hotspots in-repo -- [ ] Add focused tests for low-risk utilities: +- [x] Perbaiki metrik cakupan agar mencerminkan kode sumber, bukan file pengujian +- [x] Simpan skrip cakupan lama untuk perbandingan +- [x] Catat baseline dan titik panas di dalam repo +- [ ] Tambahkan pengujian terfokus untuk utilitas berisiko rendah: - `src/shared/utils/upstreamError.ts` - `src/shared/utils/fetchTimeout.ts` - `src/lib/api/errorResponse.ts` - `src/shared/utils/apiAuth.ts` - `src/lib/display/names.ts` -- [ ] Add route tests for: +- [ ] Tambahkan pengujian rute untuk: - `src/app/api/settings/require-login/route.ts` - `src/app/api/providers/[id]/models/route.ts` -### Phase 2: 60% -> 65% +### Fase 2: 60% -> 65% -- [ ] Add DB-backed tests for: +- [ ] Tambahkan pengujian berbasis DB untuk: - `src/lib/db/modelComboMappings.ts` - `src/lib/db/settings.ts` - `src/lib/db/registeredKeys.ts` -- [ ] Cover branch behavior in: +- [ ] Cakup perilaku cabang dalam: - `src/lib/providers/validation.ts` - `src/app/api/v1/embeddings/route.ts` - `src/app/api/v1/moderations/route.ts` -### Phase 3: 65% -> 70% +### Fase 3: 65% -> 70% -- [ ] Add usage analytics tests for: +- [ ] Tambahkan pengujian analitik penggunaan untuk: - `src/lib/usage/usageHistory.ts` - `src/lib/usage/usageStats.ts` - `src/lib/usage/costCalculator.ts` -- [ ] Expand route coverage for proxy management and settings branches +- [ ] Perluas cakupan rute untuk manajemen proxy dan cabang pengaturan -### Phase 4: 70% -> 75% +### Fase 4: 70% -> 75% -- [ ] Cover translator helpers and central translation paths: +- [ ] Cakup pembantu penerjemah dan jalur penerjemahan sentral: - `open-sse/translator/index.ts` - `open-sse/translator/helpers/*` - `open-sse/translator/request/*` - `open-sse/translator/response/*` -### Phase 5: 75% -> 80% +### Fase 5: 75% -> 80% -- [ ] Add handler-level tests for: +- [ ] Tambahkan pengujian tingkat handler untuk: - `open-sse/handlers/chatCore.ts` - `open-sse/handlers/responsesHandler.js` - `open-sse/handlers/imageGeneration.js` - `open-sse/handlers/embeddings.js` -- [ ] Add executor branch coverage for provider-specific auth, retries, and endpoint overrides +- [ ] Tambahkan cakupan cabang eksekutor untuk autentikasi spesifik penyedia, percobaan ulang, dan penggantian endpoint -### Phase 6: 80% -> 85% +### Fase 6: 80% -> 85% -- [ ] Merge more edge-case suites into the main coverage path -- [ ] Increase function coverage for DB modules with weak constructor/helper coverage -- [ ] Close branch gaps in `settings.ts`, `registeredKeys.ts`, `validation.ts`, and translator helpers +- [ ] Gabungkan lebih banyak suite kasus tepi ke dalam jalur cakupan utama +- [ ] Tingkatkan cakupan fungsi untuk modul DB dengan cakupan konstruktor/pembantu yang lemah +- [ ] Tutup celah cabang dalam `settings.ts`, `registeredKeys.ts`, `validation.ts`, dan pembantu penerjemah -### Phase 7: 85% -> 90% +### Fase 7: 85% -> 90% -- [ ] Treat the remaining low-coverage files as blockers -- [ ] Add regression tests for every uncovered production bug fixed during the push to 90% -- [ ] Raise the coverage gate in CI only after the local baseline is stable for at least two consecutive runs +- [ ] Perlakukan file dengan cakupan rendah yang tersisa sebagai pemblokir +- [ ] Tambahkan pengujian regresi untuk setiap bug produksi yang belum tercakup yang diperbaiki selama pendakian ke 90% +- [ ] Naikkan gerbang cakupan di CI hanya setelah baseline lokal stabil selama setidaknya dua jalankan berurutan -## Ratchet policy +## Kebijakan ratchet -Update `npm run test:coverage` thresholds only after the project actually exceeds the next milestone with a comfortable buffer. +Perbarui ambang batas `npm run test:coverage` hanya setelah proyek benar-benar melampaui tonggak berikutnya dengan buffer yang nyaman. -Recommended ratchet sequence: +Urutan ratchet yang disarankan: 1. 55/60/55 2. 60/62/58 @@ -163,8 +163,8 @@ Recommended ratchet sequence: 7. 85/80/84 8. 90/85/88 -Order is `statements-lines / branches / functions`. +Urutan adalah `pernyataan-garis / cabang / fungsi`. -## Known gap +## Celah yang diketahui -The current coverage command measures the main Node unit suite and includes source reached from it, including `open-sse`. It does not yet merge Vitest coverage into a single unified report. That merge is worth doing later, but it is not a blocker for starting the 60% -> 80% climb. +Perintah cakupan saat ini mengukur suite unit Node utama dan menyertakan sumber yang dapat dijangkau darinya, termasuk `open-sse`. Perintah ini belum menggabungkan cakupan Vitest ke dalam satu laporan terpadu. Penggabungan tersebut layak dilakukan nanti, tetapi bukan pemblokir untuk memulai pendakian 60% -> 80%. diff --git a/docs/i18n/id/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md b/docs/i18n/id/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md index 2ce5572b98..b485b842bc 100644 --- a/docs/i18n/id/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md +++ b/docs/i18n/id/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md @@ -1,32 +1,32 @@ -# OmniRoute Fly.io 部署指南 (Bahasa Indonesia) +# Panduan Deployment OmniRoute di Fly.io 🌐 **Languages:** 🇺🇸 [English](../../../../docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇸🇦 [ar](../../ar/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇧🇬 [bg](../../bg/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇧🇩 [bn](../../bn/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇨🇿 [cs](../../cs/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇩🇰 [da](../../da/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇩🇪 [de](../../de/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇪🇸 [es](../../es/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇷 [fa](../../fa/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇫🇮 [fi](../../fi/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇫🇷 [fr](../../fr/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇳 [gu](../../gu/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇱 [he](../../he/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇳 [hi](../../hi/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇭🇺 [hu](../../hu/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇩 [id](../../id/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇹 [it](../../it/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇯🇵 [ja](../../ja/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇰🇷 [ko](../../ko/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇳 [mr](../../mr/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇲🇾 [ms](../../ms/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇳🇱 [nl](../../nl/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇳🇴 [no](../../no/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇵🇭 [phi](../../phi/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇵🇱 [pl](../../pl/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇵🇹 [pt](../../pt/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇷🇴 [ro](../../ro/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇷🇺 [ru](../../ru/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇸🇰 [sk](../../sk/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇸🇪 [sv](../../sv/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇰🇪 [sw](../../sw/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇳 [ta](../../ta/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇮🇳 [te](../../te/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇹🇭 [th](../../th/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇹🇷 [tr](../../tr/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇵🇰 [ur](../../ur/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇻🇳 [vi](../../vi/docs/FLY_IO_DEPLOYMENT_GUIDE.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/FLY_IO_DEPLOYMENT_GUIDE.md) --- -本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: +Dokumen ini menjelaskan metode deployment OmniRoute di Fly.io yang telah terbukti berhasil, mencakup dua skenario utama: -- 首次把当前项目部署到 Fly.io -- 后续代码更新后继续发布 -- 新项目参考同样流程部署 +- Deployment pertama kali proyek saat ini ke Fly.io +- Publikasi setelah pembaruan kode berikutnya +- Referensi alur deployment yang sama untuk proyek baru -本文基于当前项目已经验证通过的配置整理,应用名为 `omniroute`。 +Dokumen ini disusun berdasarkan konfigurasi yang telah diverifikasi pada proyek saat ini, dengan nama aplikasi `omniroute`. --- -## 1. 部署目标 +## 1. Target Deployment -- 平台:Fly.io -- 部署方式:本地 `flyctl` 直接发布 -- 运行方式:使用仓库内现有 `Dockerfile` 和 `fly.toml` -- 数据持久化:Fly Volume 挂载到 `/data` -- 访问地址:`https://omniroute.fly.dev/` +- Platform: Fly.io +- Metode deployment: Publikasi langsung dari lokal menggunakan `flyctl` +- Cara menjalankan: Menggunakan `Dockerfile` dan `fly.toml` yang sudah ada di repositori +- Persistensi data: Fly Volume yang dipasang ke `/data` +- Alamat akses: `https://omniroute.fly.dev/` --- -## 2. 当前项目关键配置 +## 2. Konfigurasi Kunci Proyek Saat Ini -当前仓库中的 `fly.toml` 已确认包含以下关键项: +`fly.toml` di repositori saat ini telah dikonfirmasi mengandung item-item kunci berikut: ```toml app = 'omniroute' @@ -49,33 +49,33 @@ primary_region = 'sin' BIND = "0.0.0.0" ``` -说明: +Keterangan: -- `app = 'omniroute'` 决定实际部署到哪个 Fly 应用 -- `destination = '/data'` 决定持久卷挂载目录 -- 本项目必须让 `DATA_DIR=/data`,否则数据库和密钥会写到容器临时目录 +- `app = 'omniroute'` menentukan aplikasi Fly mana yang menjadi target deployment +- `destination = '/data'` menentukan direktori pemasangan volume persisten +- Proyek ini mengharuskan `DATA_DIR=/data` disetel; jika tidak, database dan kunci rahasia akan ditulis ke direktori sementara kontainer --- -## 3. 必备工具 +## 3. Alat yang Diperlukan -### 3.1 安装 Fly CLI +### 3.1 Instalasi Fly CLI -Windows PowerShell: +Windows PowerShell: ```powershell pwsh -Command "iwr https://fly.io/install.ps1 -useb | iex" ``` -如果安装脚本在当前环境失败,也可以手动下载 `flyctl` 二进制并放到 `PATH` 中。 +Jika skrip instalasi gagal di lingkungan saat ini, Anda juga dapat mengunduh biner `flyctl` secara manual dan menempatkannya di `PATH`. -### 3.2 登录 Fly 账号 +### 3.2 Login ke Akun Fly ```powershell flyctl auth login ``` -### 3.3 检查登录状态 +### 3.3 Periksa Status Login ```powershell flyctl auth whoami @@ -84,45 +84,45 @@ flyctl version --- -## 4. 首次部署当前项目 +## 4. Deployment Pertama Kali untuk Proyek Saat Ini -### 4.1 获取代码并进入目录 +### 4.1 Ambil Kode dan Masuk ke Direktori ```powershell git clone https://github.com/diegosouzapw/OmniRoute.git cd OmniRoute ``` -### 4.2 确认应用名 +### 4.2 Konfirmasi Nama Aplikasi -打开 `fly.toml`,重点看这一行: +Buka `fly.toml` dan perhatikan baris berikut: ```toml app = 'omniroute' ``` -如果你准备部署到自己的新应用,可改成全局唯一名称,例如: +Jika Anda berencana melakukan deployment ke aplikasi baru milik sendiri, ubah menjadi nama yang unik secara global, misalnya: ```toml app = 'omniroute-yourname' ``` -注意: +Catatan: -- 控制台里要看的是与 `fly.toml` 里 `app` 一致的应用 -- 以前如果用过别的名字,例如 `oroute`,不要和 `omniroute` 混淆 +- Di konsol, pastikan Anda melihat aplikasi yang sesuai dengan nilai `app` di `fly.toml` +- Jika sebelumnya Anda menggunakan nama lain seperti `oroute`, jangan sampai tertukar dengan `omniroute` -### 4.3 创建应用 +### 4.3 Buat Aplikasi -如果该应用尚不存在: +Jika aplikasi belum ada: ```powershell flyctl apps create omniroute ``` -如果你已经改成别的应用名,把 `omniroute` 替换成你的名字。 +Jika Anda sudah mengganti nama aplikasi, ganti `omniroute` dengan nama Anda. -### 4.4 首次部署 +### 4.4 Deployment Pertama ```powershell flyctl deploy @@ -130,13 +130,13 @@ flyctl deploy --- -## 5. 必配参数 +## 5. Parameter yang Wajib Dikonfigurasi -本项目在 Fly.io 上建议至少配置以下参数。 +Berikut adalah parameter minimum yang direkomendasikan untuk proyek ini di Fly.io. -### 5.1 已验证使用的参数 +### 5.1 Parameter yang Telah Diverifikasi -这些参数已经在当前 `omniroute` 应用上实际部署: +Parameter-parameter berikut telah digunakan secara nyata pada aplikasi `omniroute` saat ini: - `API_KEY_SECRET` - `DATA_DIR` @@ -145,58 +145,58 @@ flyctl deploy - `NEXT_PUBLIC_BASE_URL` - `STORAGE_ENCRYPTION_KEY` -### 5.2 关于 `INITIAL_PASSWORD` +### 5.2 Tentang `INITIAL_PASSWORD` -当前项目没有设置 `INITIAL_PASSWORD`,因为本次部署按需求不使用它。 +Proyek saat ini tidak menyetel `INITIAL_PASSWORD` karena deployment ini tidak memerlukannya. -如果不设置: +Jika tidak disetel: -- 启动日志会提示默认密码是 `CHANGEME` -- 部署后应尽快在系统设置中修改登录密码 +- Log startup akan menampilkan bahwa kata sandi default adalah `CHANGEME` +- Setelah deployment, segera ubah kata sandi login di pengaturan sistem -如果你希望无人值守初始化后台密码,也可以后续补: +Jika Anda ingin menginisialisasi kata sandi panel admin secara otomatis, Anda dapat menambahkannya nanti: - `INITIAL_PASSWORD` --- -## 6. 推荐参数说明 +## 6. Penjelasan Parameter yang Direkomendasikan -### 6.1 Secrets 中设置 +### 6.1 Konfigurasi di Secrets -建议放入 Fly Secrets: +Parameter yang disarankan untuk disimpan sebagai Fly Secrets: -| 变量名 | 是否推荐 | 说明 | -| ------------------------ | -------- | ------------------------------ | -| `API_KEY_SECRET` | 必需 | API Key 生成与校验使用 | -| `JWT_SECRET` | 必需 | 登录态和 JWT 签名使用 | -| `STORAGE_ENCRYPTION_KEY` | 强烈推荐 | 加密存储敏感连接信息 | -| `MACHINE_ID_SALT` | 推荐 | 生成稳定机器标识 | -| `INITIAL_PASSWORD` | 可选 | 首次部署时直接指定后台初始密码 | -| OAuth/API 私密凭证 | 按需 | 各类外部平台鉴权配置 | +| Nama Variabel | Direkomendasikan | Keterangan | +| ------------------------ | ---------------- | ----------------------------------------------- | +| `API_KEY_SECRET` | Wajib | Digunakan untuk pembuatan dan validasi API Key | +| `JWT_SECRET` | Wajib | Digunakan untuk sesi login dan tanda tangan JWT | +| `STORAGE_ENCRYPTION_KEY` | Sangat Direkomendasikan | Mengenkripsi informasi koneksi sensitif | +| `MACHINE_ID_SALT` | Direkomendasikan | Menghasilkan identifikasi mesin yang stabil | +| `INITIAL_PASSWORD` | Opsional | Menentukan kata sandi awal panel admin saat deployment pertama | +| Kredensial OAuth/API | Sesuai kebutuhan | Konfigurasi autentikasi untuk berbagai platform eksternal | -### 6.2 当前项目推荐值 +### 6.2 Nilai yang Direkomendasikan untuk Proyek Saat Ini -| 变量名 | 推荐值 | -| ---------------------- | --------------------------- | -| `DATA_DIR` | `/data` | -| `NEXT_PUBLIC_BASE_URL` | `https://omniroute.fly.dev` | +| Nama Variabel | Nilai yang Direkomendasikan | +| ---------------------- | ------------------------------ | +| `DATA_DIR` | `/data` | +| `NEXT_PUBLIC_BASE_URL` | `https://omniroute.fly.dev` | -说明: +Keterangan: -- `DATA_DIR=/data` 非常关键,必须与 Fly Volume 挂载点一致 -- `NEXT_PUBLIC_BASE_URL` 用于调度器和前端回调等场景 +- `DATA_DIR=/data` sangat krusial dan harus sesuai dengan titik pemasangan Fly Volume +- `NEXT_PUBLIC_BASE_URL` digunakan dalam skenario seperti penjadwal dan callback frontend --- -## 7. 一键设置参数 +## 7. Penyetelan Parameter Sekaligus -下面命令会生成安全随机值,并把当前项目需要的参数一次性写入 Fly Secrets。 +Perintah berikut akan menghasilkan nilai acak yang aman dan menulis semua parameter yang dibutuhkan proyek saat ini ke Fly Secrets dalam satu langkah. -说明: +Keterangan: -- 不包含 `INITIAL_PASSWORD` -- 适用于当前项目 `omniroute` +- Tidak menyertakan `INITIAL_PASSWORD` +- Berlaku untuk proyek saat ini yaitu `omniroute` ```powershell $apiKeySecret = [Convert]::ToHexString((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })).ToLower() @@ -214,79 +214,79 @@ flyctl secrets set ` -a omniroute ``` -如果你还要加初始密码: +Jika Anda juga ingin menambahkan kata sandi awal: ```powershell -flyctl secrets set INITIAL_PASSWORD=你的强密码 -a omniroute +flyctl secrets set INITIAL_PASSWORD=kata-sandi-kuat-anda -a omniroute ``` --- -## 8. 查看当前参数 +## 8. Melihat Parameter Saat Ini ```powershell flyctl secrets list -a omniroute ``` -如果控制台 `Secrets` 页面没有显示你期待的变量,先检查: +Jika halaman `Secrets` di konsol tidak menampilkan variabel yang Anda harapkan, periksa terlebih dahulu: -- 看的应用是不是 `omniroute` -- `fly.toml` 的 `app` 是否和控制台应用一致 +- Apakah aplikasi yang sedang dilihat adalah `omniroute` +- Apakah nilai `app` di `fly.toml` sudah sesuai dengan aplikasi di konsol --- -## 9. 后续更新发布 +## 9. Pembaruan dan Publikasi Selanjutnya -代码有更新后,发布步骤很简单: +Setelah ada pembaruan kode, langkah publikasinya sangat sederhana: ```powershell git pull flyctl deploy ``` -如果只更新参数,不改代码: +Jika hanya memperbarui parameter tanpa mengubah kode: ```powershell flyctl secrets set KEY=value -a omniroute ``` -Fly 会自动滚动更新机器。 +Fly akan melakukan pembaruan mesin secara otomatis dengan rolling update. -### 9.1 跟踪原仓库更新并保留 fork 的 `fly.toml` +### 9.1 Melacak Pembaruan Repositori Asal dan Mempertahankan `fly.toml` dari Fork -如果当前仓库是 fork,并且你要同步上游 `https://github.com/diegosouzapw/OmniRoute` 的更新,推荐按下面流程执行。 +Jika repositori saat ini adalah fork dan Anda ingin menyinkronkan pembaruan dari upstream `https://github.com/diegosouzapw/OmniRoute`, ikuti alur berikut. -先确认远程: +Pertama, konfirmasi remote yang ada: ```powershell git remote -v ``` -应至少包含: +Harus mengandung setidaknya: -- `origin` 指向你自己的 fork -- `upstream` 指向原仓库 +- `origin` yang mengarah ke fork milik Anda +- `upstream` yang mengarah ke repositori asal -如果没有 `upstream`,先添加: +Jika belum ada `upstream`, tambahkan terlebih dahulu: ```powershell git remote add upstream https://github.com/diegosouzapw/OmniRoute.git ``` -同步上游前,先抓取最新提交和标签: +Sebelum menyinkronkan upstream, ambil commit dan tag terbaru: ```powershell git fetch upstream --tags ``` -查看当前版本和上游标签: +Lihat versi saat ini dan tag upstream: ```powershell git describe --tags --always git show --no-patch --oneline v3.4.7 ``` -如果你想合并上游最新 `main`,并强制保留 fork 当前的 `fly.toml`,可按下面流程执行: +Jika Anda ingin menggabungkan `main` upstream terbaru sambil mempertahankan `fly.toml` fork saat ini secara paksa, ikuti alur berikut: ```powershell git merge upstream/main @@ -296,52 +296,52 @@ git commit -m "chore(deploy): keep fork fly.toml" git push origin main ``` -说明: +Keterangan: -- `git merge upstream/main` 用于同步原仓库最新代码 -- `git checkout HEAD~1 -- fly.toml` 用于恢复合并前你 fork 自己的 `fly.toml` -- 如果上游没有改 `fly.toml`,这一步不会带来额外差异 -- 如果上游改了 `fly.toml`,这一步能确保 Fly 应用名、挂载卷、区域等 fork 自定义部署配置不被覆盖 +- `git merge upstream/main` digunakan untuk menyinkronkan kode terbaru dari repositori asal +- `git checkout HEAD~1 -- fly.toml` digunakan untuk memulihkan `fly.toml` fork Anda sebelum penggabungan +- Jika upstream tidak mengubah `fly.toml`, langkah ini tidak akan menimbulkan perbedaan tambahan +- Jika upstream mengubah `fly.toml`, langkah ini memastikan konfigurasi deployment kustom fork Anda seperti nama aplikasi Fly, volume pemasangan, dan region tidak tertimpa -如果你明确只想对齐某个发布标签,例如 `v3.4.7`,也可以先确认标签是否已经包含在 `upstream/main`: +Jika Anda hanya ingin menyejajarkan dengan tag rilis tertentu, misalnya `v3.4.7`, konfirmasi terlebih dahulu apakah tag tersebut sudah tercakup dalam `upstream/main`: ```powershell git merge-base --is-ancestor v3.4.7 upstream/main ``` -返回成功表示 `upstream/main` 已经包含该版本,直接合并 `upstream/main` 即可。 +Jika berhasil, berarti `upstream/main` sudah mengandung versi tersebut dan Anda dapat langsung menggabungkan `upstream/main`. -### 9.2 同步上游后的标准发布顺序 +### 9.2 Urutan Publikasi Standar Setelah Sinkronisasi Upstream -同步原仓库完成后,推荐按下面顺序发布: +Setelah selesai menyinkronkan repositori asal, ikuti urutan publikasi berikut: 1. `git fetch upstream --tags` 2. `git merge upstream/main` -3. 恢复 fork 的 `fly.toml` +3. Pulihkan `fly.toml` dari fork 4. `git push origin main` 5. `flyctl deploy` 6. `flyctl status -a omniroute` 7. `flyctl logs --no-tail -a omniroute` -这就是当前项目升级到 `v3.4.7` 时使用的实际流程。 +Inilah alur yang digunakan saat proyek ini diperbarui ke `v3.4.7`. --- -## 10. 发布后检查 +## 10. Pemeriksaan Setelah Deployment -### 10.1 查看应用状态 +### 10.1 Lihat Status Aplikasi ```powershell flyctl status -a omniroute ``` -### 10.2 查看启动日志 +### 10.2 Lihat Log Startup ```powershell flyctl logs --no-tail -a omniroute ``` -### 10.3 检查网站可访问 +### 10.3 Periksa Aksesibilitas Situs ```powershell try { @@ -355,82 +355,82 @@ try { } ``` -返回 `200` 说明站点已正常响应。 +Jika mengembalikan `200`, berarti situs sudah merespons dengan normal. --- -## 11. 成功标志 +## 11. Indikator Keberhasilan -部署成功后,日志里应看到类似内容: +Setelah deployment berhasil, Anda seharusnya melihat konten seperti berikut di log: ```text [bootstrap] Secrets persisted to: /data/server.env [DB] SQLite database ready: /data/storage.sqlite ``` -这两个点很关键: +Dua poin ini sangat penting: -- `/data/server.env` 说明运行时密钥落到了持久卷 -- `/data/storage.sqlite` 说明数据库写入持久卷 +- `/data/server.env` menunjukkan bahwa kunci rahasia runtime telah tersimpan ke volume persisten +- `/data/storage.sqlite` menunjukkan bahwa database telah ditulis ke volume persisten -如果你看到的是 `/app/data/...`,说明 `DATA_DIR` 没配对,需要立即修正。 +Jika yang Anda lihat adalah `/app/data/...`, berarti `DATA_DIR` tidak dikonfigurasi dengan benar dan harus segera diperbaiki. --- -## 12. 常见问题 +## 12. Masalah Umum -### 12.1 `Secrets` 页面是空的 +### 12.1 Halaman `Secrets` Kosong -通常有两种原因: +Biasanya ada dua penyebab: -- 你还没执行 `flyctl secrets set` -- 你打开的是另一个应用,例如 `oroute`,不是 `omniroute` +- Anda belum menjalankan `flyctl secrets set` +- Anda membuka aplikasi yang salah, misalnya `oroute`, bukan `omniroute` -### 12.2 `flyctl deploy` 报 `app not found` +### 12.2 `flyctl deploy` Melaporkan `app not found` -先创建应用: +Buat aplikasinya terlebih dahulu: ```powershell flyctl apps create omniroute ``` -### 12.3 `fly.toml` 解析失败 +### 12.3 Parsing `fly.toml` Gagal -重点检查: +Periksa secara khusus: -- 注释里是否有乱码字符 -- TOML 引号和缩进是否正确 +- Apakah ada karakter tidak valid di dalam komentar +- Apakah tanda kutip dan indentasi TOML sudah benar -### 12.4 数据没有持久化 +### 12.4 Data Tidak Tersimpan Secara Persisten -检查以下两点: +Periksa dua hal berikut: -- `fly.toml` 中是否存在 `destination = '/data'` -- `DATA_DIR` 是否设置为 `/data` +- Apakah `destination = '/data'` ada di `fly.toml` +- Apakah `DATA_DIR` sudah disetel ke `/data` -### 12.5 不设置 `INITIAL_PASSWORD` 是否能跑 +### 12.5 Apakah Bisa Berjalan Tanpa Menyetel `INITIAL_PASSWORD` -可以运行,但会回退到默认 `CHANGEME`。生产环境建议尽快修改后台密码。 +Bisa berjalan, tetapi akan menggunakan kata sandi default `CHANGEME`. Untuk lingkungan produksi, disarankan untuk segera mengubah kata sandi panel admin. --- -## 13. 新项目复用建议 +## 13. Rekomendasi untuk Penggunaan Ulang pada Proyek Baru -如果以后是新项目照着这份文档部署,最少改这几项: +Jika di kemudian hari Anda melakukan deployment proyek baru mengikuti panduan ini, setidaknya ubah item-item berikut: -1. 修改 `fly.toml` 里的 `app` -2. 修改 `NEXT_PUBLIC_BASE_URL` -3. 保持 `DATA_DIR=/data` -4. 重新生成 `API_KEY_SECRET`、`JWT_SECRET`、`MACHINE_ID_SALT`、`STORAGE_ENCRYPTION_KEY` -5. 首次部署后检查日志是否写入 `/data` +1. Ubah nilai `app` di `fly.toml` +2. Ubah `NEXT_PUBLIC_BASE_URL` +3. Pertahankan `DATA_DIR=/data` +4. Buat ulang `API_KEY_SECRET`, `JWT_SECRET`, `MACHINE_ID_SALT`, dan `STORAGE_ENCRYPTION_KEY` +5. Setelah deployment pertama, periksa log apakah data sudah ditulis ke `/data` -不要直接复用旧项目的密钥。 +Jangan gunakan ulang kunci dari proyek lama. --- -## 14. 当前项目的最小发布清单 +## 14. Daftar Periksa Publikasi Minimal untuk Proyek Saat Ini -当前项目后续最常用的命令如下: +Berikut adalah perintah yang paling sering digunakan untuk proyek saat ini: ```powershell flyctl auth whoami @@ -440,13 +440,13 @@ flyctl deploy flyctl logs --no-tail -a omniroute ``` -如果只是正常发版,核心就是: +Untuk publikasi rutin biasa, perintah intinya adalah: ```powershell flyctl deploy ``` -如果是新环境首次部署,核心就是: +Untuk deployment pertama di lingkungan baru, langkah intinya adalah: 1. `flyctl auth login` 2. `flyctl apps create omniroute` diff --git a/docs/i18n/id/docs/ops/RELEASE_CHECKLIST.md b/docs/i18n/id/docs/ops/RELEASE_CHECKLIST.md index f57f20d897..d9356baaf9 100644 --- a/docs/i18n/id/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/i18n/id/docs/ops/RELEASE_CHECKLIST.md @@ -1,44 +1,44 @@ -# Release Checklist (Bahasa Indonesia) +# Daftar Periksa Rilis (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/RELEASE_CHECKLIST.md) · 🇸🇦 [ar](../../ar/docs/RELEASE_CHECKLIST.md) · 🇧🇬 [bg](../../bg/docs/RELEASE_CHECKLIST.md) · 🇧🇩 [bn](../../bn/docs/RELEASE_CHECKLIST.md) · 🇨🇿 [cs](../../cs/docs/RELEASE_CHECKLIST.md) · 🇩🇰 [da](../../da/docs/RELEASE_CHECKLIST.md) · 🇩🇪 [de](../../de/docs/RELEASE_CHECKLIST.md) · 🇪🇸 [es](../../es/docs/RELEASE_CHECKLIST.md) · 🇮🇷 [fa](../../fa/docs/RELEASE_CHECKLIST.md) · 🇫🇮 [fi](../../fi/docs/RELEASE_CHECKLIST.md) · 🇫🇷 [fr](../../fr/docs/RELEASE_CHECKLIST.md) · 🇮🇳 [gu](../../gu/docs/RELEASE_CHECKLIST.md) · 🇮🇱 [he](../../he/docs/RELEASE_CHECKLIST.md) · 🇮🇳 [hi](../../hi/docs/RELEASE_CHECKLIST.md) · 🇭🇺 [hu](../../hu/docs/RELEASE_CHECKLIST.md) · 🇮🇩 [id](../../id/docs/RELEASE_CHECKLIST.md) · 🇮🇹 [it](../../it/docs/RELEASE_CHECKLIST.md) · 🇯🇵 [ja](../../ja/docs/RELEASE_CHECKLIST.md) · 🇰🇷 [ko](../../ko/docs/RELEASE_CHECKLIST.md) · 🇮🇳 [mr](../../mr/docs/RELEASE_CHECKLIST.md) · 🇲🇾 [ms](../../ms/docs/RELEASE_CHECKLIST.md) · 🇳🇱 [nl](../../nl/docs/RELEASE_CHECKLIST.md) · 🇳🇴 [no](../../no/docs/RELEASE_CHECKLIST.md) · 🇵🇭 [phi](../../phi/docs/RELEASE_CHECKLIST.md) · 🇵🇱 [pl](../../pl/docs/RELEASE_CHECKLIST.md) · 🇵🇹 [pt](../../pt/docs/RELEASE_CHECKLIST.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/RELEASE_CHECKLIST.md) · 🇷🇴 [ro](../../ro/docs/RELEASE_CHECKLIST.md) · 🇷🇺 [ru](../../ru/docs/RELEASE_CHECKLIST.md) · 🇸🇰 [sk](../../sk/docs/RELEASE_CHECKLIST.md) · 🇸🇪 [sv](../../sv/docs/RELEASE_CHECKLIST.md) · 🇰🇪 [sw](../../sw/docs/RELEASE_CHECKLIST.md) · 🇮🇳 [ta](../../ta/docs/RELEASE_CHECKLIST.md) · 🇮🇳 [te](../../te/docs/RELEASE_CHECKLIST.md) · 🇹🇭 [th](../../th/docs/RELEASE_CHECKLIST.md) · 🇹🇷 [tr](../../tr/docs/RELEASE_CHECKLIST.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/RELEASE_CHECKLIST.md) · 🇵🇰 [ur](../../ur/docs/RELEASE_CHECKLIST.md) · 🇻🇳 [vi](../../vi/docs/RELEASE_CHECKLIST.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/RELEASE_CHECKLIST.md) --- -Use this checklist before tagging or publishing a new OmniRoute release. +Gunakan daftar periksa ini sebelum memberi tag atau menerbitkan rilis OmniRoute baru. -## Version and Changelog +## Versi dan Changelog -1. Bump `package.json` version (`x.y.z`) in the release branch. -2. Move release notes from `## [Unreleased]` in `CHANGELOG.md` to a dated section: +1. Naikkan versi `package.json` (`x.y.z`) di cabang rilis. +2. Pindahkan catatan rilis dari `## [Unreleased]` di `CHANGELOG.md` ke bagian bertanggal: - `## [x.y.z] — YYYY-MM-DD` -3. Keep `## [Unreleased]` as the first changelog section for upcoming work. -4. Ensure the latest semver section in `CHANGELOG.md` equals `package.json` version. +3. Pertahankan `## [Unreleased]` sebagai bagian changelog pertama untuk pekerjaan mendatang. +4. Pastikan bagian semver terbaru di `CHANGELOG.md` sama dengan versi `package.json`. -## API Docs +## Dokumentasi API -1. Update `docs/reference/openapi.yaml`: - - `info.version` must equal `package.json` version. -2. Validate endpoint examples if API contracts changed. +1. Perbarui `docs/reference/openapi.yaml`: + - `info.version` harus sama dengan versi `package.json`. +2. Validasi contoh endpoint jika kontrak API berubah. -## Runtime Docs +## Dokumentasi Runtime -1. Review `docs/architecture/ARCHITECTURE.md` for storage/runtime drift. -2. Review `docs/guides/TROUBLESHOOTING.md` for env var and operational drift. -3. Verify the release/runtime Node.js version still satisfies the supported secure floor: - - `>=20.20.2 <21` or `>=22.22.2 <23` +1. Tinjau `docs/architecture/ARCHITECTURE.md` untuk penyimpangan storage/runtime. +2. Tinjau `docs/guides/TROUBLESHOOTING.md` untuk penyimpangan variabel env dan operasional. +3. Verifikasi bahwa versi Node.js rilis/runtime masih memenuhi batas aman yang didukung: + - `>=20.20.2 <21` atau `>=22.22.2 <23` - `npm run check:node-runtime` -4. Validate the npm publish artifact after building the standalone package: +4. Validasi artefak penerbitan npm setelah membangun paket standalone: - `npm run build:cli` - `npm run check:pack-artifact` - - confirm no `app.__qa_backup`, `scripts/scratch`, `package-lock.json`, or other local residue -5. Update localized docs if source docs changed significantly. + - konfirmasi tidak ada `app.__qa_backup`, `scripts/scratch`, `package-lock.json`, atau residu lokal lainnya +5. Perbarui dokumentasi terlokalisasi jika dokumentasi sumber berubah secara signifikan. -## Automated Check +## Pemeriksaan Otomatis -Run the sync guard locally before opening PR: +Jalankan penjaga sinkronisasi secara lokal sebelum membuka PR: ```bash npm run check:docs-sync ``` -CI also runs this check in `.github/workflows/ci.yml` (lint job). +CI juga menjalankan pemeriksaan ini di `.github/workflows/ci.yml` (pekerjaan lint). diff --git a/docs/i18n/id/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/i18n/id/docs/ops/VM_DEPLOYMENT_GUIDE.md index 9a39f6c618..a74f496cc8 100644 --- a/docs/i18n/id/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/i18n/id/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -1,52 +1,52 @@ -# OmniRoute — Deployment Guide on VM with Cloudflare (Bahasa Indonesia) +# OmniRoute — Panduan Deployment di VM dengan Cloudflare (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/VM_DEPLOYMENT_GUIDE.md) · 🇸🇦 [ar](../../ar/docs/VM_DEPLOYMENT_GUIDE.md) · 🇧🇬 [bg](../../bg/docs/VM_DEPLOYMENT_GUIDE.md) · 🇧🇩 [bn](../../bn/docs/VM_DEPLOYMENT_GUIDE.md) · 🇨🇿 [cs](../../cs/docs/VM_DEPLOYMENT_GUIDE.md) · 🇩🇰 [da](../../da/docs/VM_DEPLOYMENT_GUIDE.md) · 🇩🇪 [de](../../de/docs/VM_DEPLOYMENT_GUIDE.md) · 🇪🇸 [es](../../es/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇷 [fa](../../fa/docs/VM_DEPLOYMENT_GUIDE.md) · 🇫🇮 [fi](../../fi/docs/VM_DEPLOYMENT_GUIDE.md) · 🇫🇷 [fr](../../fr/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇳 [gu](../../gu/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇱 [he](../../he/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇳 [hi](../../hi/docs/VM_DEPLOYMENT_GUIDE.md) · 🇭🇺 [hu](../../hu/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇩 [id](../../id/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇹 [it](../../it/docs/VM_DEPLOYMENT_GUIDE.md) · 🇯🇵 [ja](../../ja/docs/VM_DEPLOYMENT_GUIDE.md) · 🇰🇷 [ko](../../ko/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇳 [mr](../../mr/docs/VM_DEPLOYMENT_GUIDE.md) · 🇲🇾 [ms](../../ms/docs/VM_DEPLOYMENT_GUIDE.md) · 🇳🇱 [nl](../../nl/docs/VM_DEPLOYMENT_GUIDE.md) · 🇳🇴 [no](../../no/docs/VM_DEPLOYMENT_GUIDE.md) · 🇵🇭 [phi](../../phi/docs/VM_DEPLOYMENT_GUIDE.md) · 🇵🇱 [pl](../../pl/docs/VM_DEPLOYMENT_GUIDE.md) · 🇵🇹 [pt](../../pt/docs/VM_DEPLOYMENT_GUIDE.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/VM_DEPLOYMENT_GUIDE.md) · 🇷🇴 [ro](../../ro/docs/VM_DEPLOYMENT_GUIDE.md) · 🇷🇺 [ru](../../ru/docs/VM_DEPLOYMENT_GUIDE.md) · 🇸🇰 [sk](../../sk/docs/VM_DEPLOYMENT_GUIDE.md) · 🇸🇪 [sv](../../sv/docs/VM_DEPLOYMENT_GUIDE.md) · 🇰🇪 [sw](../../sw/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇳 [ta](../../ta/docs/VM_DEPLOYMENT_GUIDE.md) · 🇮🇳 [te](../../te/docs/VM_DEPLOYMENT_GUIDE.md) · 🇹🇭 [th](../../th/docs/VM_DEPLOYMENT_GUIDE.md) · 🇹🇷 [tr](../../tr/docs/VM_DEPLOYMENT_GUIDE.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/VM_DEPLOYMENT_GUIDE.md) · 🇵🇰 [ur](../../ur/docs/VM_DEPLOYMENT_GUIDE.md) · 🇻🇳 [vi](../../vi/docs/VM_DEPLOYMENT_GUIDE.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/VM_DEPLOYMENT_GUIDE.md) --- -Complete guide to install and configure OmniRoute on a VM (VPS) with domain managed via Cloudflare. +Panduan lengkap untuk menginstal dan mengkonfigurasi OmniRoute pada sebuah VM (VPS) dengan domain yang dikelola melalui Cloudflare. --- -## Prerequisites +## Prasyarat -| Item | Minimum | Recommended | +| Item | Minimum | Direkomendasikan | | ---------- | ------------------------ | ---------------- | | **CPU** | 1 vCPU | 2 vCPU | | **RAM** | 1 GB | 2 GB | | **Disk** | 10 GB SSD | 25 GB SSD | | **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | -| **Domain** | Registered on Cloudflare | — | +| **Domain** | Terdaftar di Cloudflare | — | | **Docker** | Docker Engine 24+ | Docker 27+ | -**Tested providers**: Akamai (Linode), DigitalOcean, Vultr, Hetzner, AWS Lightsail. +**Provider yang telah diuji**: Akamai (Linode), DigitalOcean, Vultr, Hetzner, AWS Lightsail. --- -## 1. Configure the VM +## 1. Konfigurasi VM -### 1.1 Create the instance +### 1.1 Buat instans -On your preferred VPS provider: +Pada provider VPS pilihan Anda: -- Choose Ubuntu 24.04 LTS -- Select the minimum plan (1 vCPU / 1 GB RAM) -- Set a strong root password or configure SSH key -- Note the **public IP** (e.g., `203.0.113.10`) +- Pilih Ubuntu 24.04 LTS +- Pilih paket minimum (1 vCPU / 1 GB RAM) +- Tetapkan kata sandi root yang kuat atau konfigurasikan SSH key +- Catat **IP publik** (misalnya, `203.0.113.10`) -### 1.2 Connect via SSH +### 1.2 Hubungkan melalui SSH ```bash ssh root@203.0.113.10 ``` -### 1.3 Update the system +### 1.3 Perbarui sistem ```bash apt update && apt upgrade -y ``` -### 1.4 Install Docker +### 1.4 Instal Docker ```bash # Install dependencies @@ -56,18 +56,18 @@ apt install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $ (. /etc/os-release && echo “$VERSION_CODENAME”) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $ (. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null apt update apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin ``` -### 1.5 Install nginx +### 1.5 Instal nginx ```bash apt install -y nginx ``` -### 1.6 Configure Firewall (UFW) +### 1.6 Konfigurasi Firewall (UFW) ```bash ufw default deny incoming @@ -78,22 +78,22 @@ ufw allow 443/tcp # HTTPS ufw enable ``` -> **Tip**: For maximum security, restrict ports 80 and 443 to Cloudflare IPs only. See the [Advanced Security](#advanced-security) section. +> **Tips**: Untuk keamanan maksimal, batasi port 80 dan 443 hanya untuk IP Cloudflare. Lihat bagian [Keamanan Lanjutan](#keamanan-lanjutan). --- -## 2. Install OmniRoute +## 2. Instal OmniRoute -### 2.1 Create configuration directory +### 2.1 Buat direktori konfigurasi ```bash mkdir -p /opt/omniroute ``` -### 2.2 Create environment variables file +### 2.2 Buat file variabel lingkungan ```bash -cat > /opt/omniroute/.env << ‘EOF’ +cat > /opt/omniroute/.env << 'EOF' # === Security === JWT_SECRET=CHANGE-TO-A-UNIQUE-64-CHAR-SECRET-KEY INITIAL_PASSWORD=YourSecurePassword123! @@ -122,9 +122,9 @@ NEXT_PUBLIC_BASE_URL=https://llms.seudominio.com EOF ``` -> ⚠️ **IMPORTANT**: Generate unique secret keys! Use `openssl rand -hex 32` for each key. +> ⚠️ **PENTING**: Buat secret key yang unik! Gunakan `openssl rand -hex 32` untuk setiap key. -### 2.3 Start the container +### 2.3 Jalankan container ```bash docker pull diegosouzapw/omniroute:latest @@ -138,27 +138,27 @@ docker run -d \ diegosouzapw/omniroute:latest ``` -### 2.4 Verify that it is running +### 2.4 Verifikasi bahwa container berjalan ```bash docker ps | grep omniroute docker logs omniroute --tail 20 ``` -It should display: `[DB] SQLite database ready` and `listening on port 20128`. +Seharusnya menampilkan: `[DB] SQLite database ready` dan `listening on port 20128`. --- -## 3. Configure nginx (Reverse Proxy) +## 3. Konfigurasi nginx (Reverse Proxy) -### 3.1 Generate SSL certificate (Cloudflare Origin) +### 3.1 Buat sertifikat SSL (Cloudflare Origin) -In the Cloudflare dashboard: +Di dasbor Cloudflare: -1. Go to **SSL/TLS → Origin Server** -2. Click **Create Certificate** -3. Keep the defaults (15 years, \*.yourdomain.com) -4. Copy the **Origin Certificate** and the **Private Key** +1. Buka **SSL/TLS → Origin Server** +2. Klik **Create Certificate** +3. Biarkan pengaturan default (15 tahun, \*.yourdomain.com) +4. Salin **Origin Certificate** dan **Private Key** ```bash mkdir -p /etc/nginx/ssl @@ -172,10 +172,10 @@ nano /etc/nginx/ssl/origin.key chmod 600 /etc/nginx/ssl/origin.key ``` -### 3.2 Nginx Configuration +### 3.2 Konfigurasi Nginx ```bash -cat > /etc/nginx/sites-available/omniroute << ‘NGINX’ +cat > /etc/nginx/sites-available/omniroute << 'NGINX' # Default server — blocks direct access via IP server { listen 80 default_server; @@ -210,7 +210,7 @@ server { # WebSocket support proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection “upgrade”; + proxy_set_header Connection "upgrade"; # SSE (Server-Sent Events) — streaming AI responses proxy_buffering off; @@ -230,11 +230,11 @@ server { NGINX ``` -Keep reverse-proxy stream timeouts aligned with your OmniRoute timeout env vars. If you raise -`FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS`, raise `proxy_read_timeout` / `proxy_send_timeout` -above the same threshold. +Jaga agar timeout stream reverse-proxy tetap selaras dengan variabel lingkungan timeout OmniRoute Anda. Jika Anda menaikkan +`FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS`, naikkan juga `proxy_read_timeout` / `proxy_send_timeout` +di atas nilai yang sama. -### 3.3 Enable and Test +### 3.3 Aktifkan dan Uji ```bash # Remove default configuration @@ -249,29 +249,29 @@ nginx -t && systemctl reload nginx --- -## 4. Configure Cloudflare DNS +## 4. Konfigurasi DNS Cloudflare -### 4.1 Add DNS record +### 4.1 Tambahkan rekaman DNS -In the Cloudflare dashboard → DNS: +Di dasbor Cloudflare → DNS: -| Type | Name | Content | Proxy | -| ---- | ------ | ---------------------- | ---------- | -| A | `llms` | `203.0.113.10` (VM IP) | ✅ Proxied | +| Tipe | Nama | Konten | Proxy | +| ---- | ------ | ----------------------- | ----------- | +| A | `llms` | `203.0.113.10` (IP VM) | ✅ Proxied | -### 4.2 Configure SSL +### 4.2 Konfigurasi SSL -Under **SSL/TLS → Overview**: +Di bawah **SSL/TLS → Overview**: - Mode: **Full (Strict)** -Under **SSL/TLS → Edge Certificates**: +Di bawah **SSL/TLS → Edge Certificates**: - Always Use HTTPS: ✅ On - Minimum TLS Version: TLS 1.2 - Automatic HTTPS Rewrites: ✅ On -### 4.3 Testing +### 4.3 Pengujian ```bash curl -sI https://llms.seudominio.com/health @@ -280,9 +280,9 @@ curl -sI https://llms.seudominio.com/health --- -## 5. Operations and Maintenance +## 5. Operasi dan Pemeliharaan -### Upgrade to a new version +### Upgrade ke versi baru ```bash docker pull diegosouzapw/omniroute:latest @@ -294,14 +294,14 @@ docker run -d --name omniroute --restart unless-stopped \ diegosouzapw/omniroute:latest ``` -### View logs +### Lihat log ```bash docker logs -f omniroute # Real-time stream docker logs omniroute --tail 50 # Last 50 lines ``` -### Manual database backup +### Pencadangan database secara manual ```bash # Copy data from the volume to the host @@ -312,23 +312,23 @@ docker run --rm -v omniroute-data:/data -v $(pwd):/backup \ alpine tar czf /backup/omniroute-data-$(date +%F).tar.gz /data ``` -### Restore from backup +### Pulihkan dari cadangan ```bash docker stop omniroute docker run --rm -v omniroute-data:/data -v $(pwd):/backup \ - alpine sh -c “rm -rf /data/* && tar xzf /backup/omniroute-data-YYYY-MM-DD.tar.gz -C /” + alpine sh -c "rm -rf /data/* && tar xzf /backup/omniroute-data-YYYY-MM-DD.tar.gz -C /" docker start omniroute ``` --- -## 6. Advanced Security +## 6. Keamanan Lanjutan -### Restrict nginx to Cloudflare IPs +### Batasi nginx ke IP Cloudflare ```bash -cat > /etc/nginx/cloudflare-ips.conf << ‘CF’ +cat > /etc/nginx/cloudflare-ips.conf << 'CF' # Cloudflare IPv4 ranges — update periodically # https://www.cloudflare.com/ips-v4/ set_real_ip_from 173.245.48.0/20; @@ -350,13 +350,13 @@ real_ip_header CF-Connecting-IP; CF ``` -Add the following to `nginx.conf` inside the `http {}` block: +Tambahkan baris berikut ke `nginx.conf` di dalam blok `http {}`: ```nginx include /etc/nginx/cloudflare-ips.conf; ``` -### Install fail2ban +### Instal fail2ban ```bash apt install -y fail2ban @@ -367,7 +367,7 @@ systemctl start fail2ban fail2ban-client status sshd ``` -### Block direct access to the Docker port +### Blokir akses langsung ke port Docker ```bash # Prevent direct external access to port 20128 @@ -381,9 +381,9 @@ netfilter-persistent save --- -## 7. Deploy to Cloudflare Workers (Optional) +## 7. Deploy ke Cloudflare Workers (Opsional) -For remote access via Cloudflare Workers (without exposing the VM directly): +Untuk akses jarak jauh melalui Cloudflare Workers (tanpa mengekspos VM secara langsung): ```bash # In the local repository @@ -393,15 +393,15 @@ npx wrangler login npx wrangler deploy ``` -See the full documentation at [omnirouteCloud/README.md](../omnirouteCloud/README.md). +Lihat dokumentasi lengkap di [omnirouteCloud/README.md](../omnirouteCloud/README.md). --- -## Port Summary +## Ringkasan Port -| Port | Service | Access | -| ----- | ----------- | -------------------------- | -| 22 | SSH | Public (with fail2ban) | -| 80 | nginx HTTP | Redirect → HTTPS | -| 443 | nginx HTTPS | Via Cloudflare Proxy | -| 20128 | OmniRoute | Localhost only (via nginx) | +| Port | Layanan | Akses | +| ----- | ----------- | ------------------------------- | +| 22 | SSH | Publik (dengan fail2ban) | +| 80 | nginx HTTP | Redirect → HTTPS | +| 443 | nginx HTTPS | Melalui Cloudflare Proxy | +| 20128 | OmniRoute | Hanya localhost (melalui nginx) | diff --git a/docs/i18n/id/docs/reference/API_REFERENCE.md b/docs/i18n/id/docs/reference/API_REFERENCE.md index 595d5731fb..404af7c351 100644 --- a/docs/i18n/id/docs/reference/API_REFERENCE.md +++ b/docs/i18n/id/docs/reference/API_REFERENCE.md @@ -1,24 +1,24 @@ -# API Reference (Bahasa Indonesia) +# Referensi API (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/API_REFERENCE.md) · 🇸🇦 [ar](../../ar/docs/API_REFERENCE.md) · 🇧🇬 [bg](../../bg/docs/API_REFERENCE.md) · 🇧🇩 [bn](../../bn/docs/API_REFERENCE.md) · 🇨🇿 [cs](../../cs/docs/API_REFERENCE.md) · 🇩🇰 [da](../../da/docs/API_REFERENCE.md) · 🇩🇪 [de](../../de/docs/API_REFERENCE.md) · 🇪🇸 [es](../../es/docs/API_REFERENCE.md) · 🇮🇷 [fa](../../fa/docs/API_REFERENCE.md) · 🇫🇮 [fi](../../fi/docs/API_REFERENCE.md) · 🇫🇷 [fr](../../fr/docs/API_REFERENCE.md) · 🇮🇳 [gu](../../gu/docs/API_REFERENCE.md) · 🇮🇱 [he](../../he/docs/API_REFERENCE.md) · 🇮🇳 [hi](../../hi/docs/API_REFERENCE.md) · 🇭🇺 [hu](../../hu/docs/API_REFERENCE.md) · 🇮🇩 [id](../../id/docs/API_REFERENCE.md) · 🇮🇹 [it](../../it/docs/API_REFERENCE.md) · 🇯🇵 [ja](../../ja/docs/API_REFERENCE.md) · 🇰🇷 [ko](../../ko/docs/API_REFERENCE.md) · 🇮🇳 [mr](../../mr/docs/API_REFERENCE.md) · 🇲🇾 [ms](../../ms/docs/API_REFERENCE.md) · 🇳🇱 [nl](../../nl/docs/API_REFERENCE.md) · 🇳🇴 [no](../../no/docs/API_REFERENCE.md) · 🇵🇭 [phi](../../phi/docs/API_REFERENCE.md) · 🇵🇱 [pl](../../pl/docs/API_REFERENCE.md) · 🇵🇹 [pt](../../pt/docs/API_REFERENCE.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/API_REFERENCE.md) · 🇷🇴 [ro](../../ro/docs/API_REFERENCE.md) · 🇷🇺 [ru](../../ru/docs/API_REFERENCE.md) · 🇸🇰 [sk](../../sk/docs/API_REFERENCE.md) · 🇸🇪 [sv](../../sv/docs/API_REFERENCE.md) · 🇰🇪 [sw](../../sw/docs/API_REFERENCE.md) · 🇮🇳 [ta](../../ta/docs/API_REFERENCE.md) · 🇮🇳 [te](../../te/docs/API_REFERENCE.md) · 🇹🇭 [th](../../th/docs/API_REFERENCE.md) · 🇹🇷 [tr](../../tr/docs/API_REFERENCE.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/API_REFERENCE.md) · 🇵🇰 [ur](../../ur/docs/API_REFERENCE.md) · 🇻🇳 [vi](../../vi/docs/API_REFERENCE.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/API_REFERENCE.md) --- -Complete reference for all OmniRoute API endpoints. +Referensi lengkap untuk semua titik akhir API OmniRoute. --- -## Table of Contents +## Daftar Isi - [Chat Completions](#chat-completions) - [Embeddings](#embeddings) -- [Image Generation](#image-generation) -- [List Models](#list-models) -- [Compatibility Endpoints](#compatibility-endpoints) -- [Semantic Cache](#semantic-cache) -- [Dashboard & Management](#dashboard--management) -- [Request Processing](#request-processing) -- [Authentication](#authentication) +- [Pembuatan Gambar](#image-generation) +- [Daftar Model](#list-models) +- [Titik Akhir Kompatibilitas](#compatibility-endpoints) +- [Cache Semantik](#semantic-cache) +- [Dasbor & Manajemen](#dashboard--management) +- [Pemrosesan Permintaan](#request-processing) +- [Otentikasi](#authentication) --- @@ -38,22 +38,22 @@ Content-Type: application/json } ``` -### Custom Headers +### Header Kustom -| Header | Direction | Description | -| ------------------------ | --------- | ------------------------------------------------ | -| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache | -| `X-OmniRoute-Progress` | Request | Set to `true` for progress events | -| `X-Session-Id` | Request | Sticky session key for external session affinity | -| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) | -| `Idempotency-Key` | Request | Dedup key (5s window) | -| `X-Request-Id` | Request | Alternative dedup key | -| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) | -| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated | -| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on | -| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute | +| Header | Arah | Deskripsi | +| ------------------------ | --------- | ------------------------------------------------------------ | +| `X-OmniRoute-No-Cache` | Permintaan | Atur ke `true` untuk melewati cache | +| `X-OmniRoute-Progress` | Permintaan | Atur ke `true` untuk event progres | +| `X-Session-Id` | Permintaan | Kunci sesi tetap untuk afinitas sesi eksternal | +| `x_session_id` | Permintaan | Varian garis bawah juga diterima (HTTP langsung) | +| `Idempotency-Key` | Permintaan | Kunci deduplikasi (jendela 5 detik) | +| `X-Request-Id` | Permintaan | Kunci deduplikasi alternatif | +| `X-OmniRoute-Cache` | Respons | `HIT` atau `MISS` (non-streaming) | +| `X-OmniRoute-Idempotent` | Respons | `true` jika dideduplikasi | +| `X-OmniRoute-Progress` | Respons | `enabled` jika pelacakan progres aktif | +| `X-OmniRoute-Session-Id` | Respons | ID sesi efektif yang digunakan OmniRoute | -> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`. +> Catatan Nginx: jika Anda mengandalkan header bergaris bawah (misalnya `x_session_id`), aktifkan `underscores_in_headers on;`. --- @@ -70,7 +70,7 @@ Content-Type: application/json } ``` -Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**, **GitHub Models**. +Penyedia yang tersedia: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**, **GitHub Models**. ```bash # List all embedding models @@ -93,7 +93,7 @@ Content-Type: application/json } ``` -Available providers: OpenAI (GPT Image 2), xAI (Grok Image), Together AI (FLUX), Fireworks AI, Nebius (FLUX), Hyperbolic, NanoBanana, **OpenRouter**, SD WebUI (local), ComfyUI (local). +Penyedia yang tersedia: OpenAI (GPT Image 2), xAI (Grok Image), Together AI (FLUX), Fireworks AI, Nebius (FLUX), Hyperbolic, NanoBanana, **OpenRouter**, SD WebUI (lokal), ComfyUI (lokal). ```bash # List all image models @@ -102,7 +102,7 @@ GET /v1/images/generations --- -## List Models +## Daftar Model ```bash GET /v1/models @@ -113,9 +113,9 @@ Authorization: Bearer your-api-key --- -## Compatibility Endpoints +## Titik Akhir Kompatibilitas -| Method | Path | Format | +| Metode | Path | Format | | ------ | --------------------------- | ---------------------- | | POST | `/v1/chat/completions` | OpenAI | | POST | `/v1/messages` | Anthropic | @@ -128,7 +128,7 @@ Authorization: Bearer your-api-key | POST | `/v1beta/models/{...path}` | Gemini generateContent | | POST | `/v1/api/chat` | Ollama | -### Dedicated Provider Routes +### Rute Penyedia Khusus ```bash POST /v1/providers/{provider}/chat/completions @@ -136,11 +136,11 @@ POST /v1/providers/{provider}/embeddings POST /v1/providers/{provider}/images/generations ``` -The provider prefix is auto-added if missing. Mismatched models return `400`. +Prefiks penyedia ditambahkan secara otomatis jika tidak ada. Model yang tidak cocok mengembalikan `400`. --- -## Semantic Cache +## Cache Semantik ```bash # Get cache stats @@ -150,7 +150,7 @@ GET /api/cache/stats DELETE /api/cache/stats ``` -Response example: +Contoh respons: ```json { @@ -169,171 +169,171 @@ Response example: --- -## Dashboard & Management +## Dasbor & Manajemen -### Authentication +### Otentikasi -| Endpoint | Method | Description | -| ----------------------------- | ------- | --------------------- | -| `/api/auth/login` | POST | Login | -| `/api/auth/logout` | POST | Logout | -| `/api/settings/require-login` | GET/PUT | Toggle login required | +| Titik Akhir | Metode | Deskripsi | +| ----------------------------- | ------- | ---------------------------------- | +| `/api/auth/login` | POST | Masuk | +| `/api/auth/logout` | POST | Keluar | +| `/api/settings/require-login` | GET/PUT | Aktifkan/nonaktifkan wajib login | -### Provider Management +### Manajemen Penyedia -| Endpoint | Method | Description | -| ---------------------------- | --------------------- | ---------------------------------------------- | -| `/api/providers` | GET/POST | List / create providers | -| `/api/providers/[id]` | GET/PUT/DELETE | Manage a provider | -| `/api/providers/[id]/test` | POST | Test provider connection | -| `/api/providers/[id]/models` | GET | List provider models | -| `/api/providers/validate` | POST | Validate provider config | -| `/api/provider-nodes*` | Various | Provider node management | -| `/api/provider-models` | GET/POST/PATCH/DELETE | Custom models (add, update, hide/show, delete) | +| Titik Akhir | Metode | Deskripsi | +| ---------------------------- | --------------------- | ---------------------------------------------------------- | +| `/api/providers` | GET/POST | Daftar / buat penyedia | +| `/api/providers/[id]` | GET/PUT/DELETE | Kelola penyedia | +| `/api/providers/[id]/test` | POST | Uji koneksi penyedia | +| `/api/providers/[id]/models` | GET | Daftar model penyedia | +| `/api/providers/validate` | POST | Validasi konfigurasi penyedia | +| `/api/provider-nodes*` | Berbagai | Manajemen simpul penyedia | +| `/api/provider-models` | GET/POST/PATCH/DELETE | Model kustom (tambah, perbarui, sembunyikan/tampilkan, hapus) | -### OAuth Flows +### Alur OAuth -| Endpoint | Method | Description | -| -------------------------------- | ------- | ----------------------- | -| `/api/oauth/[provider]/[action]` | Various | Provider-specific OAuth | +| Titik Akhir | Metode | Deskripsi | +| -------------------------------- | -------- | ----------------------------- | +| `/api/oauth/[provider]/[action]` | Berbagai | OAuth khusus penyedia | -### Routing & Config +### Perutean & Konfigurasi -| Endpoint | Method | Description | -| --------------------- | -------- | ----------------------------- | -| `/api/models/alias` | GET/POST | Model aliases | -| `/api/models/catalog` | GET | All models by provider + type | -| `/api/combos*` | Various | Combo management | -| `/api/keys*` | Various | API key management | -| `/api/pricing` | GET | Model pricing | +| Titik Akhir | Metode | Deskripsi | +| --------------------- | -------- | ----------------------------------- | +| `/api/models/alias` | GET/POST | Alias model | +| `/api/models/catalog` | GET | Semua model berdasarkan penyedia + tipe | +| `/api/combos*` | Berbagai | Manajemen combo | +| `/api/keys*` | Berbagai | Manajemen kunci API | +| `/api/pricing` | GET | Harga model | -### Usage & Analytics +### Penggunaan & Analitik -| Endpoint | Method | Description | -| --------------------------- | ------ | -------------------- | -| `/api/usage/history` | GET | Usage history | -| `/api/usage/logs` | GET | Usage logs | -| `/api/usage/request-logs` | GET | Request-level logs | -| `/api/usage/[connectionId]` | GET | Per-connection usage | +| Titik Akhir | Metode | Deskripsi | +| --------------------------- | ------ | ------------------------------ | +| `/api/usage/history` | GET | Riwayat penggunaan | +| `/api/usage/logs` | GET | Log penggunaan | +| `/api/usage/request-logs` | GET | Log tingkat permintaan | +| `/api/usage/[connectionId]` | GET | Penggunaan per koneksi | -### Settings +### Pengaturan -| Endpoint | Method | Description | -| ------------------------------- | ------------- | ---------------------- | -| `/api/settings` | GET/PUT/PATCH | General settings | -| `/api/settings/proxy` | GET/PUT | Network proxy config | -| `/api/settings/proxy/test` | POST | Test proxy connection | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| Titik Akhir | Metode | Deskripsi | +| ------------------------------- | ------------- | ------------------------------- | +| `/api/settings` | GET/PUT/PATCH | Pengaturan umum | +| `/api/settings/proxy` | GET/PUT | Konfigurasi proksi jaringan | +| `/api/settings/proxy/test` | POST | Uji koneksi proksi | +| `/api/settings/ip-filter` | GET/PUT | Daftar izin/blokir IP | +| `/api/settings/thinking-budget` | GET/PUT | Anggaran token penalaran | +| `/api/settings/system-prompt` | GET/PUT | Prompt sistem global | -### Monitoring +### Pemantauan -| Endpoint | Method | Description | -| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- | -| `/api/sessions` | GET | Active session tracking | -| `/api/rate-limits` | GET | Per-account rate limits | -| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) | -| `/api/cache/stats` | GET/DELETE | Cache stats / clear | +| Titik Akhir | Metode | Deskripsi | +| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| `/api/sessions` | GET | Pelacakan sesi aktif | +| `/api/rate-limits` | GET | Batas laju per akun | +| `/api/monitoring/health` | GET | Pemeriksaan kesehatan + ringkasan penyedia (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) | +| `/api/cache/stats` | GET/DELETE | Statistik cache / hapus | -### Backup & Export/Import +### Cadangan & Ekspor/Impor -| Endpoint | Method | Description | -| --------------------------- | ------ | --------------------------------------- | -| `/api/db-backups` | GET | List available backups | -| `/api/db-backups` | PUT | Create a manual backup | -| `/api/db-backups` | POST | Restore from a specific backup | -| `/api/db-backups/export` | GET | Download database as .sqlite file | -| `/api/db-backups/import` | POST | Upload .sqlite file to replace database | -| `/api/db-backups/exportAll` | GET | Download full backup as .tar.gz archive | +| Titik Akhir | Metode | Deskripsi | +| --------------------------- | ------ | ------------------------------------------------ | +| `/api/db-backups` | GET | Daftar cadangan yang tersedia | +| `/api/db-backups` | PUT | Buat cadangan manual | +| `/api/db-backups` | POST | Pulihkan dari cadangan tertentu | +| `/api/db-backups/export` | GET | Unduh database sebagai file .sqlite | +| `/api/db-backups/import` | POST | Unggah file .sqlite untuk mengganti database | +| `/api/db-backups/exportAll` | GET | Unduh cadangan lengkap sebagai arsip .tar.gz | -### Cloud Sync +### Sinkronisasi Cloud -| Endpoint | Method | Description | -| ---------------------- | ------- | --------------------- | -| `/api/sync/cloud` | Various | Cloud sync operations | -| `/api/sync/initialize` | POST | Initialize sync | -| `/api/cloud/*` | Various | Cloud management | +| Titik Akhir | Metode | Deskripsi | +| ---------------------- | -------- | ------------------------------ | +| `/api/sync/cloud` | Berbagai | Operasi sinkronisasi cloud | +| `/api/sync/initialize` | POST | Inisialisasi sinkronisasi | +| `/api/cloud/*` | Berbagai | Manajemen cloud | -### Tunnels +### Terowongan -| Endpoint | Method | Description | -| -------------------------- | ------ | ----------------------------------------------------------------------- | -| `/api/tunnels/cloudflared` | GET | Read Cloudflare Quick Tunnel install/runtime status for the dashboard | -| `/api/tunnels/cloudflared` | POST | Enable or disable the Cloudflare Quick Tunnel (`action=enable/disable`) | +| Titik Akhir | Metode | Deskripsi | +| -------------------------- | ------ | ----------------------------------------------------------------------------------------------- | +| `/api/tunnels/cloudflared` | GET | Baca status instalasi/runtime Cloudflare Quick Tunnel untuk dasbor | +| `/api/tunnels/cloudflared` | POST | Aktifkan atau nonaktifkan Cloudflare Quick Tunnel (`action=enable/disable`) | -### CLI Tools +### Alat CLI -| Endpoint | Method | Description | -| ---------------------------------- | ------ | ------------------- | -| `/api/cli-tools/claude-settings` | GET | Claude CLI status | -| `/api/cli-tools/codex-settings` | GET | Codex CLI status | -| `/api/cli-tools/droid-settings` | GET | Droid CLI status | -| `/api/cli-tools/openclaw-settings` | GET | OpenClaw CLI status | -| `/api/cli-tools/runtime/[toolId]` | GET | Generic CLI runtime | +| Titik Akhir | Metode | Deskripsi | +| ---------------------------------- | ------ | ---------------------- | +| `/api/cli-tools/claude-settings` | GET | Status CLI Claude | +| `/api/cli-tools/codex-settings` | GET | Status CLI Codex | +| `/api/cli-tools/droid-settings` | GET | Status CLI Droid | +| `/api/cli-tools/openclaw-settings` | GET | Status CLI OpenClaw | +| `/api/cli-tools/runtime/[toolId]` | GET | Runtime CLI generik | -CLI responses include: `installed`, `runnable`, `command`, `commandPath`, `runtimeMode`, `reason`. +Respons CLI mencakup: `installed`, `runnable`, `command`, `commandPath`, `runtimeMode`, `reason`. -### ACP Agents +### Agen ACP -| Endpoint | Method | Description | -| ----------------- | ------ | -------------------------------------------------------- | -| `/api/acp/agents` | GET | List all detected agents (built-in + custom) with status | -| `/api/acp/agents` | POST | Add custom agent or refresh detection cache | -| `/api/acp/agents` | DELETE | Remove a custom agent by `id` query param | +| Titik Akhir | Metode | Deskripsi | +| ----------------- | ------ | ------------------------------------------------------------------ | +| `/api/acp/agents` | GET | Daftar semua agen yang terdeteksi (bawaan + kustom) beserta status | +| `/api/acp/agents` | POST | Tambah agen kustom atau segarkan cache deteksi | +| `/api/acp/agents` | DELETE | Hapus agen kustom berdasarkan parameter kueri `id` | -GET response includes `agents[]` (id, name, binary, version, installed, protocol, isCustom) and `summary` (total, installed, notFound, builtIn, custom). +Respons GET mencakup `agents[]` (id, name, binary, version, installed, protocol, isCustom) dan `summary` (total, installed, notFound, builtIn, custom). -### Resilience & Rate Limits +### Ketahanan & Batas Laju -| Endpoint | Method | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------------------- | -| `/api/resilience` | GET/PATCH | Get/update request queue, connection cooldown, provider breaker, and wait settings | -| `/api/resilience/reset` | POST | Reset provider circuit breakers | -| `/api/rate-limits` | GET | Per-account rate limit status | -| `/api/rate-limit` | GET | Global rate limit configuration | +| Titik Akhir | Metode | Deskripsi | +| ----------------------- | --------- | -------------------------------------------------------------------------------------------------- | +| `/api/resilience` | GET/PATCH | Ambil/perbarui antrean permintaan, cooldown koneksi, pemutus sirkuit penyedia, dan pengaturan tunggu | +| `/api/resilience/reset` | POST | Reset pemutus sirkuit penyedia | +| `/api/rate-limits` | GET | Status batas laju per akun | +| `/api/rate-limit` | GET | Konfigurasi batas laju global | -### Evals +### Eval -| Endpoint | Method | Description | -| ------------ | -------- | --------------------------------- | -| `/api/evals` | GET/POST | List eval suites / run evaluation | +| Titik Akhir | Metode | Deskripsi | +| ------------ | -------- | -------------------------------------- | +| `/api/evals` | GET/POST | Daftar suite eval / jalankan evaluasi | -### Policies +### Kebijakan -| Endpoint | Method | Description | -| --------------- | --------------- | ----------------------- | -| `/api/policies` | GET/POST/DELETE | Manage routing policies | +| Titik Akhir | Metode | Deskripsi | +| --------------- | --------------- | ------------------------------ | +| `/api/policies` | GET/POST/DELETE | Kelola kebijakan perutean | -### Compliance +### Kepatuhan -| Endpoint | Method | Description | -| --------------------------- | ------ | ----------------------------- | -| `/api/compliance/audit-log` | GET | Compliance audit log (last N) | +| Titik Akhir | Metode | Deskripsi | +| --------------------------- | ------ | ----------------------------------- | +| `/api/compliance/audit-log` | GET | Log audit kepatuhan (N terakhir) | -### v1beta (Gemini-Compatible) +### v1beta (Kompatibel dengan Gemini) -| Endpoint | Method | Description | -| -------------------------- | ------ | --------------------------------- | -| `/v1beta/models` | GET | List models in Gemini format | -| `/v1beta/models/{...path}` | POST | Gemini `generateContent` endpoint | +| Titik Akhir | Metode | Deskripsi | +| -------------------------- | ------ | ----------------------------------- | +| `/v1beta/models` | GET | Daftar model dalam format Gemini | +| `/v1beta/models/{...path}` | POST | Titik akhir `generateContent` Gemini | -These endpoints mirror Gemini's API format for clients that expect native Gemini SDK compatibility. +Titik akhir ini mencerminkan format API Gemini untuk klien yang mengharapkan kompatibilitas SDK Gemini asli. -### Internal / System APIs +### API Internal / Sistem -| Endpoint | Method | Description | -| ------------------------ | ------ | ---------------------------------------------------- | -| `/api/init` | GET | Application initialization check (used on first run) | -| `/api/tags` | GET | Ollama-compatible model tags (for Ollama clients) | -| `/api/restart` | POST | Trigger graceful server restart | -| `/api/shutdown` | POST | Trigger graceful server shutdown | -| `/api/system/env/repair` | POST | Repair OAuth provider environment variables | -| `/api/system-info` | GET | Generate system diagnostics report | +| Titik Akhir | Metode | Deskripsi | +| ------------------------ | ------ | ---------------------------------------------------------------- | +| `/api/init` | GET | Pemeriksaan inisialisasi aplikasi (digunakan saat pertama kali) | +| `/api/tags` | GET | Tag model kompatibel Ollama (untuk klien Ollama) | +| `/api/restart` | POST | Picu restart server secara halus | +| `/api/shutdown` | POST | Picu penghentian server secara halus | +| `/api/system/env/repair` | POST | Perbaiki variabel lingkungan penyedia OAuth | +| `/api/system-info` | GET | Buat laporan diagnostik sistem | -> **Note:** These endpoints are used internally by the system or for Ollama client compatibility. They are not typically called by end users. +> **Catatan:** Titik akhir ini digunakan secara internal oleh sistem atau untuk kompatibilitas klien Ollama. Biasanya tidak dipanggil langsung oleh pengguna akhir. -### OAuth Environment Repair _(v3.6.1+)_ +### Perbaikan Lingkungan OAuth _(v3.6.1+)_ ```bash POST /api/system/env/repair @@ -344,7 +344,7 @@ Content-Type: application/json } ``` -Repairs missing or corrupted OAuth environment variables for a specific provider. Returns: +Memperbaiki variabel lingkungan OAuth yang hilang atau rusak untuk penyedia tertentu. Mengembalikan: ```json { @@ -356,7 +356,7 @@ Repairs missing or corrupted OAuth environment variables for a specific provider --- -## Audio Transcription +## Transkripsi Audio ```bash POST /v1/audio/transcriptions @@ -364,9 +364,9 @@ Authorization: Bearer your-api-key Content-Type: multipart/form-data ``` -Transcribe audio files using Deepgram or AssemblyAI. +Transkripsi file audio menggunakan Deepgram atau AssemblyAI. -**Request:** +**Permintaan:** ```bash curl -X POST http://localhost:20128/v1/audio/transcriptions \ @@ -375,7 +375,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ -F "model=deepgram/nova-3" ``` -**Response:** +**Respons:** ```json { @@ -386,15 +386,15 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ } ``` -**Supported providers:** `deepgram/nova-3`, `assemblyai/best`. +**Penyedia yang didukung:** `deepgram/nova-3`, `assemblyai/best`. -**Supported formats:** `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`. +**Format yang didukung:** `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`. --- -## Ollama Compatibility +## Kompatibilitas Ollama -For clients that use Ollama's API format: +Untuk klien yang menggunakan format API Ollama: ```bash # Chat endpoint (Ollama format) @@ -404,18 +404,18 @@ POST /v1/api/chat GET /api/tags ``` -Requests are automatically translated between Ollama and internal formats. +Permintaan diterjemahkan secara otomatis antara format Ollama dan format internal. --- -## Telemetry +## Telemetri ```bash # Get latency telemetry summary (p50/p95/p99 per provider) GET /api/telemetry/summary ``` -**Response:** +**Respons:** ```json { @@ -428,7 +428,7 @@ GET /api/telemetry/summary --- -## Budget +## Anggaran ```bash # Get budget status for all API keys @@ -445,25 +445,25 @@ Content-Type: application/json } ``` -## Request Processing +## Pemrosesan Permintaan -1. Client sends request to `/v1/*` -2. Route handler calls `handleChat`, `handleEmbedding`, `handleAudioTranscription`, or `handleImageGeneration` -3. Model is resolved (direct provider/model or alias/combo) -4. Credentials selected from local DB with account availability filtering -5. For chat: `handleChatCore` — format detection, translation, cache check, idempotency check -6. Provider executor sends upstream request -7. Response translated back to client format (chat) or returned as-is (embeddings/images/audio) -8. Usage/logging recorded -9. Fallback applies on errors according to combo rules +1. Klien mengirim permintaan ke `/v1/*` +2. Handler rute memanggil `handleChat`, `handleEmbedding`, `handleAudioTranscription`, atau `handleImageGeneration` +3. Model diselesaikan (penyedia/model langsung, alias, atau combo) +4. Kredensial dipilih dari DB lokal dengan penyaringan ketersediaan akun +5. Untuk chat: `handleChatCore` — deteksi format, translasi, pemeriksaan cache, pemeriksaan idempoten +6. Eksekutor penyedia mengirim permintaan upstream +7. Respons diterjemahkan kembali ke format klien (chat) atau dikembalikan apa adanya (embeddings/gambar/audio) +8. Penggunaan/logging dicatat +9. Fallback diterapkan pada error sesuai aturan combo -Full architecture reference: [`ARCHITECTURE.md`](ARCHITECTURE.md) +Referensi arsitektur lengkap: [`ARCHITECTURE.md`](ARCHITECTURE.md) --- -## Authentication +## Otentikasi -- Dashboard routes (`/dashboard/*`) use `auth_token` cookie -- Login uses saved password hash; fallback to `INITIAL_PASSWORD` -- `requireLogin` toggleable via `/api/settings/require-login` -- `/v1/*` routes optionally require Bearer API key when `REQUIRE_API_KEY=true` +- Rute dasbor (`/dashboard/*`) menggunakan cookie `auth_token` +- Login menggunakan hash kata sandi yang tersimpan; fallback ke `INITIAL_PASSWORD` +- `requireLogin` dapat diaktifkan/nonaktifkan melalui `/api/settings/require-login` +- Rute `/v1/*` secara opsional memerlukan kunci API Bearer saat `REQUIRE_API_KEY=true` diff --git a/docs/i18n/id/docs/reference/CLI-TOOLS.md b/docs/i18n/id/docs/reference/CLI-TOOLS.md index bb3b80fb0f..126a51864e 100644 --- a/docs/i18n/id/docs/reference/CLI-TOOLS.md +++ b/docs/i18n/id/docs/reference/CLI-TOOLS.md @@ -1,86 +1,86 @@ -# CLI Tools Setup Guide — OmniRoute (Bahasa Indonesia) +# Panduan Pengaturan Alat CLI — OmniRoute (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/CLI-TOOLS.md) · 🇸🇦 [ar](../../ar/docs/CLI-TOOLS.md) · 🇧🇬 [bg](../../bg/docs/CLI-TOOLS.md) · 🇧🇩 [bn](../../bn/docs/CLI-TOOLS.md) · 🇨🇿 [cs](../../cs/docs/CLI-TOOLS.md) · 🇩🇰 [da](../../da/docs/CLI-TOOLS.md) · 🇩🇪 [de](../../de/docs/CLI-TOOLS.md) · 🇪🇸 [es](../../es/docs/CLI-TOOLS.md) · 🇮🇷 [fa](../../fa/docs/CLI-TOOLS.md) · 🇫🇮 [fi](../../fi/docs/CLI-TOOLS.md) · 🇫🇷 [fr](../../fr/docs/CLI-TOOLS.md) · 🇮🇳 [gu](../../gu/docs/CLI-TOOLS.md) · 🇮🇱 [he](../../he/docs/CLI-TOOLS.md) · 🇮🇳 [hi](../../hi/docs/CLI-TOOLS.md) · 🇭🇺 [hu](../../hu/docs/CLI-TOOLS.md) · 🇮🇩 [id](../../id/docs/CLI-TOOLS.md) · 🇮🇹 [it](../../it/docs/CLI-TOOLS.md) · 🇯🇵 [ja](../../ja/docs/CLI-TOOLS.md) · 🇰🇷 [ko](../../ko/docs/CLI-TOOLS.md) · 🇮🇳 [mr](../../mr/docs/CLI-TOOLS.md) · 🇲🇾 [ms](../../ms/docs/CLI-TOOLS.md) · 🇳🇱 [nl](../../nl/docs/CLI-TOOLS.md) · 🇳🇴 [no](../../no/docs/CLI-TOOLS.md) · 🇵🇭 [phi](../../phi/docs/CLI-TOOLS.md) · 🇵🇱 [pl](../../pl/docs/CLI-TOOLS.md) · 🇵🇹 [pt](../../pt/docs/CLI-TOOLS.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/CLI-TOOLS.md) · 🇷🇴 [ro](../../ro/docs/CLI-TOOLS.md) · 🇷🇺 [ru](../../ru/docs/CLI-TOOLS.md) · 🇸🇰 [sk](../../sk/docs/CLI-TOOLS.md) · 🇸🇪 [sv](../../sv/docs/CLI-TOOLS.md) · 🇰🇪 [sw](../../sw/docs/CLI-TOOLS.md) · 🇮🇳 [ta](../../ta/docs/CLI-TOOLS.md) · 🇮🇳 [te](../../te/docs/CLI-TOOLS.md) · 🇹🇭 [th](../../th/docs/CLI-TOOLS.md) · 🇹🇷 [tr](../../tr/docs/CLI-TOOLS.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/CLI-TOOLS.md) · 🇵🇰 [ur](../../ur/docs/CLI-TOOLS.md) · 🇻🇳 [vi](../../vi/docs/CLI-TOOLS.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/CLI-TOOLS.md) --- -This guide explains how to install and configure all supported AI coding CLI tools -to use **OmniRoute** as the unified backend, giving you centralized key management, -cost tracking, model switching, and request logging across every tool. +Panduan ini menjelaskan cara menginstal dan mengonfigurasi semua alat CLI coding AI yang didukung +untuk menggunakan **OmniRoute** sebagai backend terpadu, memberikan manajemen kunci terpusat, +pelacakan biaya, pergantian model, dan pencatatan permintaan di semua alat. --- -## How It Works +## Cara Kerjanya ``` Claude / Codex / OpenCode / Cline / KiloCode / Continue / Kiro / Cursor / Copilot │ - ▼ (all point to OmniRoute) + ▼ (semua mengarah ke OmniRoute) http://YOUR_SERVER:20128/v1 │ - ▼ (OmniRoute routes to the right provider) + ▼ (OmniRoute meneruskan ke penyedia yang tepat) Anthropic / OpenAI / Gemini / DeepSeek / Groq / Mistral / ... ``` -**Benefits:** +**Manfaat:** -- One API key to manage all tools -- Cost tracking across all CLIs in the dashboard -- Model switching without reconfiguring every tool -- Works locally and on remote servers (VPS) +- Satu API key untuk mengelola semua alat +- Pelacakan biaya di semua CLI melalui dashboard +- Pergantian model tanpa mengonfigurasi ulang setiap alat +- Berjalan secara lokal maupun di server jarak jauh (VPS) --- -## Supported Tools (Dashboard Source of Truth) +## Alat yang Didukung (Sumber Kebenaran Dashboard) -The dashboard cards in `/dashboard/cli-tools` are generated from `src/shared/constants/cliTools.ts`. -Current list (v3.0.0-rc.16): +Kartu dashboard di `/dashboard/cli-tools` dibuat dari `src/shared/constants/cliTools.ts`. +Daftar saat ini (v3.0.0-rc.16): -| Tool | ID | Command | Setup Mode | Install Method | -| ------------------ | ------------- | ---------- | ---------- | -------------- | -| **Claude Code** | `claude` | `claude` | env | npm | -| **OpenAI Codex** | `codex` | `codex` | custom | npm | -| **Factory Droid** | `droid` | `droid` | custom | bundled/CLI | -| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI | -| **Cursor** | `cursor` | app | guide | desktop app | -| **Cline** | `cline` | `cline` | custom | npm | -| **Kilo Code** | `kilo` | `kilocode` | custom | npm | -| **Continue** | `continue` | extension | guide | VS Code | -| **Antigravity** | `antigravity` | internal | mitm | OmniRoute | -| **GitHub Copilot** | `copilot` | extension | custom | VS Code | -| **OpenCode** | `opencode` | `opencode` | guide | npm | -| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI | -| **Qwen Code** | `qwen` | `qwen` | custom | npm | +| Alat | ID | Perintah | Mode Pengaturan | Metode Instalasi | +| ------------------ | ------------- | ---------- | --------------- | ---------------- | +| **Claude Code** | `claude` | `claude` | env | npm | +| **OpenAI Codex** | `codex` | `codex` | custom | npm | +| **Factory Droid** | `droid` | `droid` | custom | bundled/CLI | +| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI | +| **Cursor** | `cursor` | app | guide | desktop app | +| **Cline** | `cline` | `cline` | custom | npm | +| **Kilo Code** | `kilo` | `kilocode` | custom | npm | +| **Continue** | `continue` | extension | guide | VS Code | +| **Antigravity** | `antigravity` | internal | mitm | OmniRoute | +| **GitHub Copilot** | `copilot` | extension | custom | VS Code | +| **OpenCode** | `opencode` | `opencode` | guide | npm | +| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI | +| **Qwen Code** | `qwen` | `qwen` | custom | npm | -### CLI fingerprint sync (Agents + Settings) +### Sinkronisasi fingerprint CLI (Agents + Pengaturan) -`/dashboard/agents` and `Settings > CLI Fingerprint` use `src/shared/constants/cliCompatProviders.ts`. -This keeps provider IDs aligned with CLI cards and legacy IDs. +`/dashboard/agents` dan `Settings > CLI Fingerprint` menggunakan `src/shared/constants/cliCompatProviders.ts`. +Ini menjaga ID penyedia tetap selaras dengan kartu CLI dan ID lama. -| CLI ID | Fingerprint Provider ID | +| CLI ID | ID Penyedia Fingerprint | | ---------------------------------------------------------------------------------------------------- | ----------------------- | | `kilo` | `kilocode` | | `copilot` | `github` | | `claude` / `codex` / `antigravity` / `kiro` / `cursor` / `cline` / `opencode` / `droid` / `openclaw` | same ID | -Legacy IDs still accepted for compatibility: `copilot`, `kimi-coding`, `qwen`. +ID lama yang masih diterima untuk kompatibilitas: `copilot`, `kimi-coding`, `qwen`. --- -## Step 1 — Get an OmniRoute API Key +## Langkah 1 — Dapatkan API Key OmniRoute -1. Open the OmniRoute dashboard → **API Manager** (`/dashboard/api-manager`) -2. Click **Create API Key** -3. Give it a name (e.g. `cli-tools`) and select all permissions -4. Copy the key — you'll need it for every CLI below +1. Buka dashboard OmniRoute → **API Manager** (`/dashboard/api-manager`) +2. Klik **Create API Key** +3. Beri nama (misalnya `cli-tools`) dan pilih semua izin +4. Salin kunci tersebut — Anda akan membutuhkannya untuk setiap CLI di bawah -> Your key looks like: `sk-xxxxxxxxxxxxxxxx-xxxxxxxxx` +> Kunci Anda terlihat seperti: `sk-xxxxxxxxxxxxxxxx-xxxxxxxxx` --- -## Step 2 — Install CLI Tools +## Langkah 2 — Instal Alat CLI -All npm-based tools require Node.js 18+: +Semua alat berbasis npm memerlukan Node.js 18+: ```bash # Claude Code (Anthropic) @@ -104,7 +104,7 @@ curl -fsSL https://cli.kiro.dev/install | bash export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc ``` -**Verify:** +**Verifikasi:** ```bash claude --version # 2.x.x @@ -117,9 +117,9 @@ kiro-cli --version # 1.x.x --- -## Step 3 — Set Global Environment Variables +## Langkah 3 — Tetapkan Variabel Lingkungan Global -Add to `~/.bashrc` (or `~/.zshrc`), then run `source ~/.bashrc`: +Tambahkan ke `~/.bashrc` (atau `~/.zshrc`), lalu jalankan `source ~/.bashrc`: ```bash # OmniRoute Universal Endpoint @@ -131,20 +131,20 @@ export GEMINI_BASE_URL="http://localhost:20128/v1" export GEMINI_API_KEY="sk-your-omniroute-key" ``` -> For a **remote server** replace `localhost:20128` with the server IP or domain, -> e.g. `http://192.168.0.15:20128`. +> Untuk **server jarak jauh**, ganti `localhost:20128` dengan IP atau domain server, +> misalnya `http://192.168.0.15:20128`. --- -## Step 4 — Configure Each Tool +## Langkah 4 — Konfigurasi Setiap Alat ### Claude Code ```bash -# Via CLI: +# Melalui CLI: claude config set --global api-base-url http://localhost:20128/v1 -# Or create ~/.claude/settings.json: +# Atau buat ~/.claude/settings.json: mkdir -p ~/.claude && cat > ~/.claude/settings.json << EOF { "apiBaseUrl": "http://localhost:20128/v1", @@ -153,7 +153,7 @@ mkdir -p ~/.claude && cat > ~/.claude/settings.json << EOF EOF ``` -**Test:** `claude "say hello"` +**Uji:** `claude "say hello"` --- @@ -167,7 +167,7 @@ apiBaseUrl: http://localhost:20128/v1 EOF ``` -**Test:** `codex "what is 2+2?"` +**Uji:** `codex "what is 2+2?"` --- @@ -181,13 +181,13 @@ api_key = "sk-your-omniroute-key" EOF ``` -**Test:** `opencode` +**Uji:** `opencode` --- -### Cline (CLI or VS Code) +### Cline (CLI atau VS Code) -**CLI mode:** +**Mode CLI:** ```bash mkdir -p ~/.cline/data && cat > ~/.cline/data/globalState.json << EOF @@ -199,22 +199,22 @@ mkdir -p ~/.cline/data && cat > ~/.cline/data/globalState.json << EOF EOF ``` -**VS Code mode:** -Cline extension settings → API Provider: `OpenAI Compatible` → Base URL: `http://localhost:20128/v1` +**Mode VS Code:** +Pengaturan ekstensi Cline → API Provider: `OpenAI Compatible` → Base URL: `http://localhost:20128/v1` -Or use the OmniRoute dashboard → **CLI Tools → Cline → Apply Config**. +Atau gunakan dashboard OmniRoute → **CLI Tools → Cline → Apply Config**. --- -### KiloCode (CLI or VS Code) +### KiloCode (CLI atau VS Code) -**CLI mode:** +**Mode CLI:** ```bash kilocode --api-base http://localhost:20128/v1 --api-key sk-your-omniroute-key ``` -**VS Code settings:** +**Pengaturan VS Code:** ```json { @@ -223,11 +223,11 @@ kilocode --api-base http://localhost:20128/v1 --api-key sk-your-omniroute-key } ``` -Or use the OmniRoute dashboard → **CLI Tools → KiloCode → Apply Config**. +Atau gunakan dashboard OmniRoute → **CLI Tools → KiloCode → Apply Config**. --- -### Continue (VS Code Extension) +### Continue (Ekstensi VS Code) Edit `~/.continue/config.yaml`: @@ -241,18 +241,18 @@ models: default: true ``` -Restart VS Code after editing. +Mulai ulang VS Code setelah mengedit. --- ### Kiro CLI (Amazon) ```bash -# Login to your AWS/Kiro account: +# Login ke akun AWS/Kiro Anda: kiro-cli login -# The CLI uses its own auth — OmniRoute is not needed as backend for Kiro CLI itself. -# Use kiro-cli alongside OmniRoute for other tools. +# CLI ini menggunakan autentikasinya sendiri — OmniRoute tidak diperlukan sebagai backend untuk Kiro CLI itu sendiri. +# Gunakan kiro-cli bersama OmniRoute untuk alat lainnya. kiro-cli status ``` @@ -260,9 +260,9 @@ kiro-cli status ### Qwen Code (Alibaba) -Qwen Code supports OpenAI-compatible API endpoints via environment variables or `settings.json`. +Qwen Code mendukung endpoint API yang kompatibel dengan OpenAI melalui variabel lingkungan atau `settings.json`. -**Option 1: Environment variables (`~/.qwen/.env`)** +**Opsi 1: Variabel lingkungan (`~/.qwen/.env`)** ```bash mkdir -p ~/.qwen && cat > ~/.qwen/.env << EOF @@ -272,7 +272,7 @@ OPENAI_MODEL="auto" EOF ``` -**Option 2: `settings.json` with model providers** +**Opsi 2: `settings.json` dengan penyedia model** ```json // ~/.qwen/settings.json @@ -294,7 +294,7 @@ EOF } ``` -**Option 3: Inline CLI flags** +**Opsi 3: Flag CLI langsung** ```bash OPENAI_BASE_URL="http://localhost:20128/v1" \ @@ -303,76 +303,76 @@ OPENAI_MODEL="auto" \ qwen ``` -> For a **remote server** replace `localhost:20128` with the server IP or domain. +> Untuk **server jarak jauh**, ganti `localhost:20128` dengan IP atau domain server. -**Test:** `qwen "say hello"` +**Uji:** `qwen "say hello"` -### Cursor (Desktop App) +### Cursor (Aplikasi Desktop) -> **Note:** Cursor routes requests through its cloud. For OmniRoute integration, -> enable **Cloud Endpoint** in OmniRoute Settings and use your public domain URL. +> **Catatan:** Cursor merutekan permintaan melalui cloudnya sendiri. Untuk integrasi OmniRoute, +> aktifkan **Cloud Endpoint** di Pengaturan OmniRoute dan gunakan URL domain publik Anda. -Via GUI: **Settings → Models → OpenAI API Key** +Melalui GUI: **Settings → Models → OpenAI API Key** - Base URL: `https://your-domain.com/v1` -- API Key: your OmniRoute key +- API Key: kunci OmniRoute Anda --- -## Dashboard Auto-Configuration +## Konfigurasi Otomatis Dashboard -The OmniRoute dashboard automates configuration for most tools: +Dashboard OmniRoute mengotomatiskan konfigurasi untuk sebagian besar alat: -1. Go to `http://localhost:20128/dashboard/cli-tools` -2. Expand any tool card -3. Select your API key from the dropdown -4. Click **Apply Config** (if tool is detected as installed) -5. Or copy the generated config snippet manually +1. Buka `http://localhost:20128/dashboard/cli-tools` +2. Perluas kartu alat mana pun +3. Pilih API key Anda dari menu tarik-turun +4. Klik **Apply Config** (jika alat terdeteksi telah terinstal) +5. Atau salin cuplikan konfigurasi yang dihasilkan secara manual --- -## Built-in Agents: Droid & OpenClaw +## Agen Bawaan: Droid & OpenClaw -**Droid** and **OpenClaw** are AI agents built directly into OmniRoute — no installation needed. -They run as internal routes and use OmniRoute's model routing automatically. +**Droid** dan **OpenClaw** adalah agen AI yang dibangun langsung ke dalam OmniRoute — tidak perlu instalasi. +Keduanya berjalan sebagai rute internal dan menggunakan perutean model OmniRoute secara otomatis. -- Access: `http://localhost:20128/dashboard/agents` -- Configure: same combos and providers as all other tools -- No API key or CLI install required +- Akses: `http://localhost:20128/dashboard/agents` +- Konfigurasi: combo dan penyedia yang sama seperti semua alat lainnya +- Tidak memerlukan API key atau instalasi CLI --- -## Available API Endpoints +## Endpoint API yang Tersedia -| Endpoint | Description | Use For | -| -------------------------- | ----------------------------- | --------------------------- | -| `/v1/chat/completions` | Standard chat (all providers) | All modern tools | -| `/v1/responses` | Responses API (OpenAI format) | Codex, agentic workflows | -| `/v1/completions` | Legacy text completions | Older tools using `prompt:` | -| `/v1/embeddings` | Text embeddings | RAG, search | -| `/v1/images/generations` | Image generation | GPT-Image, Flux, etc. | -| `/v1/audio/speech` | Text-to-speech | ElevenLabs, OpenAI TTS | -| `/v1/audio/transcriptions` | Speech-to-text | Deepgram, AssemblyAI | +| Endpoint | Deskripsi | Digunakan Untuk | +| -------------------------- | --------------------------------- | --------------------------- | +| `/v1/chat/completions` | Chat standar (semua penyedia) | Semua alat modern | +| `/v1/responses` | Responses API (format OpenAI) | Codex, alur kerja agentik | +| `/v1/completions` | Penyelesaian teks lama | Alat lama yang menggunakan `prompt:` | +| `/v1/embeddings` | Embedding teks | RAG, pencarian | +| `/v1/images/generations` | Pembuatan gambar | GPT-Image, Flux, dll. | +| `/v1/audio/speech` | Teks ke ucapan | ElevenLabs, OpenAI TTS | +| `/v1/audio/transcriptions` | Ucapan ke teks | Deepgram, AssemblyAI | --- ## Pemecahan Masalah -| Error | Cause | Fix | -| ------------------------- | ----------------------- | ------------------------------------------ | -| `Connection refused` | OmniRoute not running | `pm2 start omniroute` | -| `401 Unauthorized` | Wrong API key | Check in `/dashboard/api-manager` | -| `No combo configured` | No active routing combo | Set up in `/dashboard/combos` | -| `invalid model` | Model not in catalog | Use `auto` or check `/dashboard/providers` | -| CLI shows "not installed" | Binary not in PATH | Check `which ` | -| `kiro-cli: not found` | Not in PATH | `export PATH="$HOME/.local/bin:$PATH"` | +| Error | Penyebab | Solusi | +| ------------------------- | --------------------------------- | ------------------------------------------ | +| `Connection refused` | OmniRoute tidak berjalan | `pm2 start omniroute` | +| `401 Unauthorized` | API key salah | Periksa di `/dashboard/api-manager` | +| `No combo configured` | Tidak ada combo perutean aktif | Atur di `/dashboard/combos` | +| `invalid model` | Model tidak ada dalam katalog | Gunakan `auto` atau periksa `/dashboard/providers` | +| CLI menampilkan "not installed" | Biner tidak ada di PATH | Periksa `which ` | +| `kiro-cli: not found` | Tidak ada di PATH | `export PATH="$HOME/.local/bin:$PATH"` | --- -## Quick Setup Script (One Command) +## Skrip Pengaturan Cepat (Satu Perintah) ```bash -# Install all CLIs and configure for OmniRoute (replace with your key and server URL) +# Instal semua CLI dan konfigurasi untuk OmniRoute (ganti dengan kunci dan URL server Anda) OMNIROUTE_URL="http://localhost:20128/v1" OMNIROUTE_KEY="sk-your-omniroute-key" @@ -381,7 +381,7 @@ npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocod # Kiro CLI apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install | bash -# Write configs +# Tulis konfigurasi mkdir -p ~/.claude ~/.codex ~/.config/opencode ~/.continue cat > ~/.claude/settings.json <<< "{\"apiBaseUrl\":\"$OMNIROUTE_URL\",\"apiKey\":\"$OMNIROUTE_KEY\"}" diff --git a/docs/i18n/id/docs/reference/ENVIRONMENT.md b/docs/i18n/id/docs/reference/ENVIRONMENT.md index dec684ad6d..a789977767 100644 --- a/docs/i18n/id/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/id/docs/reference/ENVIRONMENT.md @@ -1,55 +1,55 @@ -# Environment Variables Reference (Bahasa Indonesia) +# Referensi Variabel Lingkungan (Bahasa Indonesia) 🌐 **Languages:** 🇺🇸 [English](../../../../docs/ENVIRONMENT.md) · 🇸🇦 [ar](../../ar/docs/ENVIRONMENT.md) · 🇧🇬 [bg](../../bg/docs/ENVIRONMENT.md) · 🇧🇩 [bn](../../bn/docs/ENVIRONMENT.md) · 🇨🇿 [cs](../../cs/docs/ENVIRONMENT.md) · 🇩🇰 [da](../../da/docs/ENVIRONMENT.md) · 🇩🇪 [de](../../de/docs/ENVIRONMENT.md) · 🇪🇸 [es](../../es/docs/ENVIRONMENT.md) · 🇮🇷 [fa](../../fa/docs/ENVIRONMENT.md) · 🇫🇮 [fi](../../fi/docs/ENVIRONMENT.md) · 🇫🇷 [fr](../../fr/docs/ENVIRONMENT.md) · 🇮🇳 [gu](../../gu/docs/ENVIRONMENT.md) · 🇮🇱 [he](../../he/docs/ENVIRONMENT.md) · 🇮🇳 [hi](../../hi/docs/ENVIRONMENT.md) · 🇭🇺 [hu](../../hu/docs/ENVIRONMENT.md) · 🇮🇩 [id](../../id/docs/ENVIRONMENT.md) · 🇮🇹 [it](../../it/docs/ENVIRONMENT.md) · 🇯🇵 [ja](../../ja/docs/ENVIRONMENT.md) · 🇰🇷 [ko](../../ko/docs/ENVIRONMENT.md) · 🇮🇳 [mr](../../mr/docs/ENVIRONMENT.md) · 🇲🇾 [ms](../../ms/docs/ENVIRONMENT.md) · 🇳🇱 [nl](../../nl/docs/ENVIRONMENT.md) · 🇳🇴 [no](../../no/docs/ENVIRONMENT.md) · 🇵🇭 [phi](../../phi/docs/ENVIRONMENT.md) · 🇵🇱 [pl](../../pl/docs/ENVIRONMENT.md) · 🇵🇹 [pt](../../pt/docs/ENVIRONMENT.md) · 🇧🇷 [pt-BR](../../pt-BR/docs/ENVIRONMENT.md) · 🇷🇴 [ro](../../ro/docs/ENVIRONMENT.md) · 🇷🇺 [ru](../../ru/docs/ENVIRONMENT.md) · 🇸🇰 [sk](../../sk/docs/ENVIRONMENT.md) · 🇸🇪 [sv](../../sv/docs/ENVIRONMENT.md) · 🇰🇪 [sw](../../sw/docs/ENVIRONMENT.md) · 🇮🇳 [ta](../../ta/docs/ENVIRONMENT.md) · 🇮🇳 [te](../../te/docs/ENVIRONMENT.md) · 🇹🇭 [th](../../th/docs/ENVIRONMENT.md) · 🇹🇷 [tr](../../tr/docs/ENVIRONMENT.md) · 🇺🇦 [uk-UA](../../uk-UA/docs/ENVIRONMENT.md) · 🇵🇰 [ur](../../ur/docs/ENVIRONMENT.md) · 🇻🇳 [vi](../../vi/docs/ENVIRONMENT.md) · 🇨🇳 [zh-CN](../../zh-CN/docs/ENVIRONMENT.md) --- -> Complete reference for every environment variable recognized by OmniRoute. -> For a quick-start template, see [`.env.example`](../.env.example). +> Referensi lengkap untuk setiap variabel lingkungan yang dikenali oleh OmniRoute. +> Untuk template pengaturan cepat, lihat [`.env.example`](../.env.example). --- -## Table of Contents +## Daftar Isi -- [1. Required Secrets](#1-required-secrets) -- [2. Storage & Database](#2-storage--database) -- [3. Network & Ports](#3-network--ports) -- [4. Security & Authentication](#4-security--authentication) -- [5. Input Sanitization & PII Protection](#5-input-sanitization--pii-protection) -- [6. Tool & Routing Policies](#6-tool--routing-policies) -- [7. URLs & Cloud Sync](#7-urls--cloud-sync) -- [8. Outbound Proxy](#8-outbound-proxy) -- [9. CLI Tool Integration](#9-cli-tool-integration) -- [10. Internal Agent & MCP Integrations](#10-internal-agent--mcp-integrations) -- [11. OAuth Provider Credentials](#11-oauth-provider-credentials) -- [12. Provider User-Agent Overrides](#12-provider-user-agent-overrides) -- [13. CLI Fingerprint Compatibility](#13-cli-fingerprint-compatibility) -- [14. API Key Providers](#14-api-key-providers) -- [15. Timeout Settings](#15-timeout-settings) +- [1. Rahasia yang Wajib Ada](#1-required-secrets) +- [2. Penyimpanan & Database](#2-storage--database) +- [3. Jaringan & Port](#3-network--ports) +- [4. Keamanan & Autentikasi](#4-security--authentication) +- [5. Sanitasi Input & Perlindungan PII](#5-input-sanitization--pii-protection) +- [6. Kebijakan Alat & Routing](#6-tool--routing-policies) +- [7. URL & Sinkronisasi Cloud](#7-urls--cloud-sync) +- [8. Proxy Keluar](#8-outbound-proxy) +- [9. Integrasi Alat CLI](#9-cli-tool-integration) +- [10. Agen Internal & Integrasi MCP](#10-internal-agent--mcp-integrations) +- [11. Kredensial Provider OAuth](#11-oauth-provider-credentials) +- [12. Override User-Agent Provider](#12-provider-user-agent-overrides) +- [13. Kompatibilitas Fingerprint CLI](#13-cli-fingerprint-compatibility) +- [14. Provider Kunci API](#14-api-key-providers) +- [15. Pengaturan Batas Waktu](#15-timeout-settings) - [16. Logging](#16-logging) -- [17. Memory Optimization](#17-memory-optimization) -- [18. Pricing Sync](#18-pricing-sync) -- [19. Model Sync (Dev)](#19-model-sync-dev) -- [20. Provider-Specific Settings](#20-provider-specific-settings) -- [21. Proxy Health](#21-proxy-health) +- [17. Optimasi Memori](#17-memory-optimization) +- [18. Sinkronisasi Harga](#18-pricing-sync) +- [19. Sinkronisasi Model (Dev)](#19-model-sync-dev) +- [20. Pengaturan Spesifik Provider](#20-provider-specific-settings) +- [21. Kesehatan Proxy](#21-proxy-health) - [22. Debugging](#22-debugging) -- [23. GitHub Integration](#23-github-integration) -- [Deployment Scenarios](#deployment-scenarios) -- [Audit: Removed / Dead Variables](#audit-removed--dead-variables) +- [23. Integrasi GitHub](#23-github-integration) +- [Skenario Deployment](#deployment-scenarios) +- [Audit: Variabel yang Dihapus / Tidak Aktif](#audit-removed--dead-variables) --- -## 1. Required Secrets +## 1. Rahasia yang Wajib Ada -These **must** be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. +Variabel-variabel ini **harus** diatur sebelum menjalankan aplikasi pertama kali. Tanpa variabel ini, aplikasi akan menolak untuk berjalan atau beroperasi dengan pengaturan default yang tidak aman. -| Variable | Required | Default | Source File | Description | -| ------------------ | -------- | -------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `JWT_SECRET` | **Yes** | _(none)_ | `src/lib/auth` | Signs/verifies all dashboard session cookies (JWT). Generate with `openssl rand -base64 48`. | -| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. | -| `INITIAL_PASSWORD` | **Yes** | `123456` | Bootstrap script | Sets the initial admin dashboard password. **Change before first use.** After login, change via Dashboard → Settings → Security. | +| Variable | Wajib | Default | Source File | Deskripsi | +| ------------------ | -------- | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | **Ya** | _(none)_ | `src/lib/auth` | Menandatangani/memverifikasi semua cookie sesi dashboard (JWT). Buat dengan `openssl rand -base64 48`. | +| `API_KEY_SECRET` | **Ya** | _(none)_ | `src/lib/db/apiKeys.ts` | Kunci enkripsi AES untuk nilai kunci API yang disimpan di SQLite. Buat dengan `openssl rand -hex 32`. | +| `INITIAL_PASSWORD` | **Ya** | `123456` | Bootstrap script | Mengatur kata sandi awal admin dashboard. **Ubah sebelum pertama kali digunakan.** Setelah login, ubah melalui Dashboard → Settings → Security. | -### Generation Commands +### Perintah Pembuatan ```bash # Generate all three secrets at once: @@ -59,200 +59,200 @@ echo "INITIAL_PASSWORD=$(openssl rand -base64 16)" ``` > [!CAUTION] -> Never commit `.env` files with real secrets to version control. The `.gitignore` already excludes `.env`, but verify before pushing. +> Jangan pernah melakukan commit file `.env` yang berisi rahasia nyata ke version control. `.gitignore` sudah mengecualikan `.env`, namun verifikasi sebelum melakukan push. --- -## 2. Storage & Database +## 2. Penyimpanan & Database -OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These variables control data location, encryption, and lifecycle. +OmniRoute menggunakan **SQLite** (melalui `better-sqlite3`) untuk semua persistensi data. Variabel-variabel ini mengontrol lokasi data, enkripsi, dan siklus hidup data. -| Variable | Default | Source File | Description | -| -------------------------------- | -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. | -| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | -| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | -| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | -| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | +| Variable | Default | Source File | Deskripsi | +| -------------------------------- | -------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Direktori utama untuk DB SQLite, cadangan, dan file data. Override untuk volume Docker atau path khusus. | +| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | Kunci AES untuk enkripsi penuh database SQLite saat disimpan. Buat dengan `openssl rand -hex 32`. | +| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/bootstrap-env.mjs`, `electron/main.js` | Label versi untuk kunci enkripsi. Naikkan nilainya saat melakukan rotasi kunci agar mendukung dekripsi cadangan lama. | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | Saat bernilai `true`, melewati pencadangan database otomatis yang berjalan sebelum migrasi pada setiap startup. | +| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Alias legacy** untuk `STORAGE_ENCRYPTION_KEY`. Diterima sebagai fallback ketika variabel utama tidak ada. | +| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Alias legacy** (bentuk yang dikodekan Base64) diterima sebagai fallback. Didekode secara otomatis sebelum digunakan. | -### Scenarios +### Skenario -| Scenario | Configuration | -| --------------------- | -------------------------------------------------------------------------------- | -| **Local development** | Leave all defaults. DB lives at `~/.omniroute/omniroute.db`. | -| **Docker** | `DATA_DIR=/data` + mount a volume at `/data`. | -| **Encrypted at rest** | Set `STORAGE_ENCRYPTION_KEY` + keep backups of the key! Losing it = losing data. | -| **CI/Testing** | `DATA_DIR=/tmp/omniroute-test` — ephemeral, no encryption needed. | +| Skenario | Konfigurasi | +| -------------------------- | ------------------------------------------------------------------------------------------------ | +| **Pengembangan lokal** | Biarkan semua nilai default. DB berada di `~/.omniroute/omniroute.db`. | +| **Docker** | `DATA_DIR=/data` + mount volume di `/data`. | +| **Terenkripsi saat simpan**| Set `STORAGE_ENCRYPTION_KEY` + simpan cadangan kuncinya! Kehilangan kunci = kehilangan data. | +| **CI/Testing** | `DATA_DIR=/tmp/omniroute-test` — bersifat sementara, tidak perlu enkripsi. | --- -## 3. Network & Ports +## 3. Jaringan & Port -| Variable | Default | Source File | Description | -| --------------------- | ------------ | -------------------------- | -------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| Variable | Default | Source File | Deskripsi | +| --------------------- | ------------ | -------------------------- | ----------------------------------------------------------------------------------------------------- | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Port utama untuk Dashboard UI dan endpoint API (mode port tunggal). | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Jika diatur, menyajikan API proxy `/v1/*` pada port terpisah ini. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Alamat bind untuk port API. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Jika diatur, menyajikan Dashboard UI pada port terpisah ini. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Port yang dipublikasikan di sisi host untuk Dashboard dalam mode produksi Docker. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Port yang dipublikasikan di sisi host untuk API dalam mode produksi Docker. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Mengambil prioritas di atas `PORT` saat berjalan di dalam Electron atau wrapper lainnya. | +| `NODE_ENV` | `production` | Next.js core | Mengontrol verbositas logging, caching, ekspos detail error, dan optimasi Next.js. | -### Port Modes +### Mode Port ``` -┌─────────────────────────── Single Port (default) ──────────────────────────┐ +┌─────────────────────────── Port Tunggal (default) ─────────────────────────┐ │ PORT=20128 │ │ → Dashboard: http://localhost:20128 │ │ → API: http://localhost:20128/v1/chat/completions │ └─────────────────────────────────────────────────────────────────────────────┘ -┌─────────────────────────── Split Ports ─────────────────────────────────────┐ +┌─────────────────────────── Port Terpisah ───────────────────────────────────┐ │ DASHBOARD_PORT=20128 │ │ API_PORT=20129 │ │ API_HOST=0.0.0.0 │ │ → Dashboard: http://localhost:20128 │ │ → API: http://0.0.0.0:20129/v1/chat/completions │ -│ Use case: Expose API to LAN while restricting Dashboard to localhost. │ +│ Kasus penggunaan: Ekspos API ke LAN sambil membatasi Dashboard ke localhost.│ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────── Docker Production ──────────────────────────────┐ │ PROD_DASHBOARD_PORT=443 PROD_API_PORT=8443 │ -│ → Maps container ports to host ports in docker-compose.prod.yml. │ +│ → Memetakan port kontainer ke port host di docker-compose.prod.yml. │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- -## 4. Security & Authentication +## 4. Keamanan & Autentikasi -| Variable | Default | Source File | Description | -| ----------------------------- | --------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | -| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | -| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | -| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | -| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | -| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | +| Variable | Default | Source File | Deskripsi | +| ----------------------------- | --------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt yang digabungkan dengan pengenal perangkat keras untuk fingerprinting mesin. Ubah per-deployment untuk isolasi. | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Mengatur flag `Secure` pada cookie sesi. **Harus bernilai `true`** saat berjalan di balik HTTPS. | +| `REQUIRE_API_KEY` | `false` | API middleware | Saat bernilai `true`, semua permintaan proxy `/v1/*` harus menyertakan kunci API yang valid. | +| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Memungkinkan pengungkapan nilai kunci API penuh di Dashboard UI. Berisiko pada instansi bersama. | +| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | ID kunci API yang dipisahkan koma yang melewati pencatatan permintaan (kepatuhan GDPR). | +| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Ukuran body permintaan maksimum yang diizinkan. Menolak payload yang melebihi batas ini. | +| `CORS_ORIGIN` | `*` | Next.js middleware | Nilai CORS `Access-Control-Allow-Origin`. Batasi untuk produksi. | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Memblokir panggilan provider yang menarget rentang IP privat/loopback/link-local. Nonaktifkan hanya di lingkungan pengujian terisolasi. | -### Hardening Checklist +### Daftar Periksa Penguatan Keamanan ```bash -# Production security minimum: -AUTH_COOKIE_SECURE=true # Requires HTTPS -REQUIRE_API_KEY=true # Authenticate all proxy calls -ALLOW_API_KEY_REVEAL=false # Never expose keys in UI +# Minimum keamanan produksi: +AUTH_COOKIE_SECURE=true # Memerlukan HTTPS +REQUIRE_API_KEY=true # Autentikasi semua panggilan proxy +ALLOW_API_KEY_REVEAL=false # Jangan pernah ekspos kunci di UI CORS_ORIGIN=https://your.domain.com -MAX_BODY_SIZE_BYTES=5242880 # 5 MB limit +MAX_BODY_SIZE_BYTES=5242880 # Batas 5 MB ``` --- -## 5. Input Sanitization & PII Protection +## 5. Sanitasi Input & Perlindungan PII -OmniRoute provides a two-layer defense: request-side injection scanning and response-side PII stripping. +OmniRoute menyediakan pertahanan dua lapis: pemindaian injeksi di sisi permintaan dan penghapusan PII di sisi respons. -### Request-Side: Prompt Injection Guard +### Sisi Permintaan: Penjaga Injeksi Prompt -| Variable | Default | Source File | Description | -| ------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- | -| `INPUT_SANITIZER_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Enable scanning of incoming messages for prompt injection patterns. | -| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = log only, `block` = reject request with 400, `redact` = strip suspicious patterns. | -| `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Legacy alias for `INPUT_SANITIZER_MODE` — same behavior. | -| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Detect PII (emails, phones, SSNs) in incoming requests. | +| Variable | Default | Source File | Deskripsi | +| ------------------------- | --------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `INPUT_SANITIZER_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Aktifkan pemindaian pesan masuk untuk pola injeksi prompt. | +| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = hanya log, `block` = tolak permintaan dengan 400, `redact` = hapus pola mencurigakan. | +| `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Alias legacy untuk `INPUT_SANITIZER_MODE` — perilaku sama. | +| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Deteksi PII (email, telepon, SSN) dalam permintaan masuk. | -### Response-Side: PII Sanitizer +### Sisi Respons: Sanitizer PII -| Variable | Default | Source File | Description | -| -------------------------------- | -------- | ------------------------- | ----------------------------------------------------------------------- | -| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | Scan LLM responses for leaked PII before returning to client. | -| `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = mask PII, `warn` = log only, `block` = drop entire response. | +| Variable | Default | Source File | Deskripsi | +| -------------------------------- | -------- | ------------------------- | -------------------------------------------------------------------------------------------- | +| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | Pindai respons LLM untuk PII yang bocor sebelum dikembalikan ke klien. | +| `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = sembunyikan PII, `warn` = hanya log, `block` = buang seluruh respons. | -### Scenarios +### Skenario -| Scenario | Configuration | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` | -| **Monitoring only** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — logs but never blocks | -| **Personal use** | Leave all disabled — zero overhead | +| Skenario | Konfigurasi | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| **Kepatuhan enterprise** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` | +| **Hanya pemantauan** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — mencatat log namun tidak pernah memblokir | +| **Penggunaan pribadi** | Biarkan semua dinonaktifkan — tanpa overhead | --- -## 6. Tool & Routing Policies +## 6. Kebijakan Alat & Routing -| Variable | Default | Source File | Description | -| ------------------ | ---------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Controls LLM tool/function-calling access. `allowlist` = only listed tools, `denylist` = all except listed, `disabled` = no restrictions. | +| Variable | Default | Source File | Deskripsi | +| ------------------ | ---------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Mengontrol akses pemanggilan alat/fungsi LLM. `allowlist` = hanya alat yang terdaftar, `denylist` = semua kecuali yang terdaftar, `disabled` = tanpa batasan. | --- -## 7. URLs & Cloud Sync +## 7. URL & Sinkronisasi Cloud -| Variable | Default | Source File | Description | -| ----------------------- | ------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | Server-side URL for internal sync jobs to call `/api/sync/cloud`. | -| `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | Cloud relay endpoint URL (premium feature). | -| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | HTTP timeout for cloud sync requests. | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links. **Must match your public URL behind reverse proxy.** | -| `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. | -| `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. | +| Variable | Default | Source File | Deskripsi | +| ----------------------- | ------------------------ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | URL sisi server untuk pekerjaan sinkronisasi internal memanggil `/api/sync/cloud`. | +| `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | URL endpoint relay cloud (fitur premium). | +| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | Batas waktu HTTP untuk permintaan sinkronisasi cloud. | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | URL publik untuk redirect_uri OAuth, tautan Dashboard. **Harus cocok dengan URL publik Anda di balik reverse proxy.** | +| `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Cerminan sisi klien dari `CLOUD_URL`. | +| `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Fallback legacy untuk `NEXT_PUBLIC_BASE_URL`. | > [!IMPORTANT] -> When deploying behind a reverse proxy (nginx, Caddy), `NEXT_PUBLIC_BASE_URL` **must** be set to your public URL (e.g., `https://omniroute.example.com`). Without this, OAuth callbacks will fail because the redirect_uri won't match. +> Saat melakukan deployment di balik reverse proxy (nginx, Caddy), `NEXT_PUBLIC_BASE_URL` **harus** diatur ke URL publik Anda (misalnya, `https://omniroute.example.com`). Tanpa ini, callback OAuth akan gagal karena redirect_uri tidak akan cocok. --- -## 8. Outbound Proxy +## 8. Proxy Keluar -Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress control, geo-routing, or IP masking. +Arahkan panggilan provider LLM upstream melalui proxy HTTP atau SOCKS5 untuk kontrol egress, geo-routing, atau penyembunyian IP. -| Variable | Default | Source File | Description | -| --------------------------------- | --------- | -------------------- | ----------------------------------------------------------------------------------- | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. | -| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | -| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | -| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | -| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | -| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | -| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | +| Variable | Default | Source File | Deskripsi | +| --------------------------------- | --------- | -------------------- | ---------------------------------------------------------------------------------------------------------- | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Aktifkan agen proxy SOCKS5 untuk panggilan upstream. | +| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Kesadaran sisi klien tentang ketersediaan SOCKS5. | +| `HTTP_PROXY` | _(unset)_ | Node.js standard | Proxy HTTP untuk panggilan upstream. | +| `HTTPS_PROXY` | _(unset)_ | Node.js standard | Proxy HTTPS untuk panggilan upstream. | +| `ALL_PROXY` | _(unset)_ | Node.js standard | Proxy universal (mendukung `socks5://`). | +| `NO_PROXY` | _(unset)_ | Node.js standard | Nama host/IP yang dipisahkan koma untuk melewati proxy. | +| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Memalsukan fingerprint TLS menggunakan wreq-js (meniru Chrome 124). Mengatasi pemblokiran JA3/JA4. | -### Scenarios +### Skenario -| Scenario | Configuration | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | -| **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | -| **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) | +| Skenario | Konfigurasi | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **SOCKS5 melalui tunnel SSH** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | +| **Proxy HTTP korporat** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | +| **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — memerlukan `wreq-js` (sudah disertakan) | --- -## 9. CLI Tool Integration +## 9. Integrasi Alat CLI -Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, etc.). +Mengontrol bagaimana OmniRoute menemukan dan menjalankan sidecar CLI (Claude Code, Codex, dll.). -| Variable | Default | Source File | Description | -| ------------------------- | ---------- | ----------------------------------- | -------------------------------------------------------------------------- | -| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. | -| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). | -| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). | -| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). | -| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. | -| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. | -| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Custom path to Droid CLI binary. | -| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Custom path to OpenClaw CLI binary. | -| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Custom path to Cursor agent binary. | -| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Custom path to Cline CLI binary. | -| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. | -| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | +| Variable | Default | Source File | Deskripsi | +| ------------------------- | ---------- | ----------------------------------- | ------------------------------------------------------------------------------------------------ | +| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = cari di PATH sistem; `manual` = gunakan hanya path eksplisit. | +| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Entri PATH tambahan untuk penemuan biner CLI (dipisahkan titik dua). | +| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override direktori home untuk membaca konfigurasi CLI (`~/.claude`, `~/.codex`). | +| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Izinkan OmniRoute menulis file konfigurasi CLI (penyegaran token, data sesi). | +| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Claude. | +| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Codex. | +| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Droid. | +| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI OpenClaw. | +| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner agen Cursor. | +| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Cline. | +| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Continue. | +| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Path kustom ke biner CLI Qoder. | -### Docker Example +### Contoh Docker ```bash -# Mount host binaries into the container and tell OmniRoute where they are: +# Mount biner host ke kontainer dan beri tahu OmniRoute lokasinya: CLI_EXTRA_PATHS=/host-cli/bin CLI_CONFIG_HOME=/root CLI_ALLOW_CONFIG_WRITES=true @@ -261,139 +261,139 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude --- -## 10. Internal Agent & MCP Integrations +## 10. Agen Internal & Integrasi MCP -| Variable | Default | Source File | Description | -| --------------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. | -| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | -| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | -| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | -| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | -| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | -| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | -| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | -| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | -| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | +| Variable | Default | Source File | Deskripsi | +| --------------------------------------- | ---------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_BASE_URL` | deteksi otomatis | `open-sse/mcp-server/server.ts` | URL eksplisit agar alat MCP/A2A dapat menjangkau OmniRoute. Menimpa deteksi otomatis localhost. | +| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | Kunci API untuk panggilan alat MCP internal dan skill A2A. | +| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | ID kunci untuk atribusi log audit MCP. | +| `ROUTER_API_KEY` | _(unset)_ | Legacy | Alias legacy untuk `OMNIROUTE_API_KEY`. | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Terapkan kontrol akses berbasis scope pada panggilan alat MCP. | +| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Scope yang dipisahkan koma: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | +| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Interval sinkronisasi katalog model dalam jam. | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Interval polling batas rate dan kuota provider. | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Nonaktifkan semua layanan latar belakang (sinkronisasi, harga, pembaruan model). Berguna untuk CI/pengujian. | +| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Diatur ke `true` oleh skrip bootstrap setelah pengaturan awal. Mengontrol visibilitas wizard pengaturan. | +| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: izinkan body permintaan untuk menimpa field proyek Antigravity. | -### OAuth CLI Bridge (Internal) +### Jembatan CLI OAuth (Internal) -| Variable | Default | Source File | Description | -| ------------------- | ----------- | ------------------------------- | ----------------------------------------- | -| `OMNIROUTE_SERVER` | auto-detect | `src/lib/oauth/config/index.ts` | Server URL for CLI↔OmniRoute auth bridge. | -| `OMNIROUTE_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Auth token for CLI bridge. | -| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | User ID for CLI bridge sessions. | -| `SERVER_URL` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_SERVER`. | -| `CLI_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_TOKEN`. | -| `CLI_USER_ID` | _(unset)_ | `src/lib/oauth/config/index.ts` | Legacy alias for `OMNIROUTE_USER_ID`. | +| Variable | Default | Source File | Deskripsi | +| ------------------- | ---------------- | ------------------------------- | ------------------------------------------------ | +| `OMNIROUTE_SERVER` | deteksi otomatis | `src/lib/oauth/config/index.ts` | URL server untuk jembatan autentikasi CLI↔OmniRoute. | +| `OMNIROUTE_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Token autentikasi untuk jembatan CLI. | +| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | ID pengguna untuk sesi jembatan CLI. | +| `SERVER_URL` | _(unset)_ | `src/lib/oauth/config/index.ts` | Alias legacy untuk `OMNIROUTE_SERVER`. | +| `CLI_TOKEN` | _(unset)_ | `src/lib/oauth/config/index.ts` | Alias legacy untuk `OMNIROUTE_TOKEN`. | +| `CLI_USER_ID` | _(unset)_ | `src/lib/oauth/config/index.ts` | Alias legacy untuk `OMNIROUTE_USER_ID`. | --- -## 11. OAuth Provider Credentials +## 11. Kredensial Provider OAuth -Built-in credentials for **localhost development**. For remote deployments, register your own at each provider's developer console. +Kredensial bawaan untuk **pengembangan localhost**. Untuk deployment jarak jauh, daftarkan milik Anda sendiri di konsol pengembang masing-masing provider. -| Variable | Provider | Notes | -| --------------------------------- | ----------------------- | --------------------------------------------------------------------------------- | -| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | Public client — no secret needed. | -| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | Override redirect URI. Default: `https://platform.claude.com/oauth/code/callback` | -| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | Public client. | -| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | Requires matching `_SECRET`. | -| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | -| `GEMINI_CLI_OAUTH_CLIENT_ID` | Gemini CLI | Usually same as Gemini. | -| `GEMINI_CLI_OAUTH_CLIENT_SECRET` | Gemini CLI | — | -| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | Public client. | -| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | Public client. | -| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | Requires matching `_SECRET`. | -| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | -| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. | -| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | -| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | Set to enable Qoder OAuth. | -| `QODER_OAUTH_TOKEN_URL` | Qoder | — | -| `QODER_OAUTH_USERINFO_URL` | Qoder | — | -| `QODER_OAUTH_CLIENT_ID` | Qoder | — | -| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). | -| `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. | -| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. | +| Variable | Provider | Catatan | +| --------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------- | +| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | Klien publik — tidak perlu secret. | +| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | Timpa redirect URI. Default: `https://platform.claude.com/oauth/code/callback` | +| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | Klien publik. | +| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | Memerlukan `_SECRET` yang sesuai. | +| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | +| `GEMINI_CLI_OAUTH_CLIENT_ID` | Gemini CLI | Biasanya sama dengan Gemini. | +| `GEMINI_CLI_OAUTH_CLIENT_SECRET` | Gemini CLI | — | +| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | Klien publik. | +| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | Klien publik. | +| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | Memerlukan `_SECRET` yang sesuai. | +| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | +| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Klien publik. | +| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | +| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | Atur untuk mengaktifkan OAuth Qoder. | +| `QODER_OAUTH_TOKEN_URL` | Qoder | — | +| `QODER_OAUTH_USERINFO_URL` | Qoder | — | +| `QODER_OAUTH_CLIENT_ID` | Qoder | — | +| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Fallback kunci API langsung (melewati OAuth). | +| `QODER_CLI_WORKSPACE` | Qoder | ID workspace untuk CLI Qoder. | +| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias untuk `QODER_CLI_WORKSPACE`. | > [!WARNING] -> **Google OAuth** (Antigravity, Gemini CLI) credentials **only work on localhost**. For remote servers: +> Kredensial **Google OAuth** (Antigravity, Gemini CLI) **hanya berfungsi di localhost**. Untuk server jarak jauh: > -> 1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials) -> 2. Create an OAuth 2.0 Client ID (type: "Web application") -> 3. Add your server URL as Authorized redirect URI -> 4. Replace the credential values in `.env`. +> 1. Buka [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials) +> 2. Buat OAuth 2.0 Client ID (tipe: "Web application") +> 3. Tambahkan URL server Anda sebagai Authorized redirect URI +> 4. Ganti nilai kredensial di `.env`. --- -## 12. Provider User-Agent Overrides +## 12. Override User-Agent Provider -Override the `User-Agent` header sent to each upstream provider. This is dynamically resolved at runtime by the executor base class: +Menimpa header `User-Agent` yang dikirim ke setiap provider upstream. Ini diselesaikan secara dinamis saat runtime oleh kelas dasar executor: ``` process.env[`${PROVIDER_ID}_USER_AGENT`] ``` -> **Source:** `open-sse/executors/base.ts` → `buildHeaders()` +> **Sumber:** `open-sse/executors/base.ts` → `buildHeaders()` -| Variable | Default Value | When to Update | -| ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | -| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | -| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | -| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | -| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | -| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | -| `QWEN_USER_AGENT` | `QwenCode/0.15.11 (linux; x64)` | When Qwen Code updates | -| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | When Cursor updates | -| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | When Google API client updates | +| Variable | Nilai Default | Kapan Diperbarui | +| ------------------------ | --------------------------------------------- | ---------------------------------------------------------------------- | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | Saat Anthropic merilis versi CLI baru | +| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | Saat OpenAI memperbarui CLI Codex | +| `CODEX_CLIENT_VERSION` | `0.131.0` | Override versi klien Codex secara independen dari string UA penuh | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | Saat GitHub Copilot Chat diperbarui | +| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | Saat Antigravity IDE diperbarui | +| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | Saat Kiro IDE diperbarui | +| `QODER_USER_AGENT` | `Qoder-Cli` | Saat CLI Qoder diperbarui | +| `QWEN_USER_AGENT` | `QwenCode/0.15.11 (linux; x64)` | Saat Qwen Code diperbarui | +| `CURSOR_USER_AGENT` | `connect-es/1.6.1` | Saat Cursor diperbarui | +| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | Saat klien API Google diperbarui | > [!TIP] -> You can add User-Agent overrides for **any** provider using the pattern `{PROVIDER_ID}_USER_AGENT`. The executor dynamically constructs the env var name. +> Anda dapat menambahkan override User-Agent untuk provider **mana pun** menggunakan pola `{PROVIDER_ID}_USER_AGENT`. Executor secara dinamis membangun nama variabel lingkungan. --- -## 13. CLI Fingerprint Compatibility +## 13. Kompatibilitas Fingerprint CLI -When enabled, OmniRoute reorders HTTP headers and JSON body fields to match the exact signature of official CLI tools. This reduces the risk of account flagging while preserving your proxy IP. +Saat diaktifkan, OmniRoute mengatur ulang urutan header HTTP dan field body JSON agar cocok dengan tanda tangan persis dari alat CLI resmi. Hal ini mengurangi risiko pemblokiran akun sambil mempertahankan IP proxy Anda. -**Source:** `open-sse/config/cliFingerprints.ts`, `open-sse/executors/base.ts` +**Sumber:** `open-sse/config/cliFingerprints.ts`, `open-sse/executors/base.ts` ### Per-Provider -| Variable | Effect | -| -------------------------- | --------------------------------------- | -| `CLI_COMPAT_CODEX=1` | Mimics Codex CLI request signature | -| `CLI_COMPAT_CLAUDE=1` | Mimics Claude Code request signature | -| `CLI_COMPAT_GITHUB=1` | Mimics GitHub Copilot request signature | -| `CLI_COMPAT_ANTIGRAVITY=1` | Mimics Antigravity request signature | -| `CLI_COMPAT_KIRO=1` | Mimics Kiro IDE request signature | -| `CLI_COMPAT_CURSOR=1` | Mimics Cursor request signature | -| `CLI_COMPAT_KIMI_CODING=1` | Mimics Kimi Coding request signature | -| `CLI_COMPAT_KILOCODE=1` | Mimics Kilo Code request signature | -| `CLI_COMPAT_CLINE=1` | Mimics Cline request signature | -| `CLI_COMPAT_QWEN=1` | Mimics Qwen Code request signature | +| Variable | Efek | +| -------------------------- | ------------------------------------------------- | +| `CLI_COMPAT_CODEX=1` | Meniru tanda tangan permintaan CLI Codex | +| `CLI_COMPAT_CLAUDE=1` | Meniru tanda tangan permintaan Claude Code | +| `CLI_COMPAT_GITHUB=1` | Meniru tanda tangan permintaan GitHub Copilot | +| `CLI_COMPAT_ANTIGRAVITY=1` | Meniru tanda tangan permintaan Antigravity | +| `CLI_COMPAT_KIRO=1` | Meniru tanda tangan permintaan Kiro IDE | +| `CLI_COMPAT_CURSOR=1` | Meniru tanda tangan permintaan Cursor | +| `CLI_COMPAT_KIMI_CODING=1` | Meniru tanda tangan permintaan Kimi Coding | +| `CLI_COMPAT_KILOCODE=1` | Meniru tanda tangan permintaan Kilo Code | +| `CLI_COMPAT_CLINE=1` | Meniru tanda tangan permintaan Cline | +| `CLI_COMPAT_QWEN=1` | Meniru tanda tangan permintaan Qwen Code | ### Global -| Variable | Effect | -| ------------------ | --------------------------------------------------------------- | -| `CLI_COMPAT_ALL=1` | Enable fingerprint compatibility for **all** providers at once. | +| Variable | Efek | +| ------------------ | ------------------------------------------------------------------------ | +| `CLI_COMPAT_ALL=1` | Aktifkan kompatibilitas fingerprint untuk **semua** provider sekaligus. | > [!NOTE] -> This feature works alongside the User-Agent overrides (§12). The fingerprint system handles header ordering and body field ordering, while User-Agent overrides handle the specific UA string. Both can be enabled independently. +> Fitur ini bekerja berdampingan dengan override User-Agent (§12). Sistem fingerprint menangani urutan header dan urutan field body, sementara override User-Agent menangani string UA tertentu. Keduanya dapat diaktifkan secara independen. --- -## 14. API Key Providers +## 14. Provider Kunci API -API keys for providers that use direct authentication. **Preferred setup:** Dashboard → Providers → Add API Key. +Kunci API untuk provider yang menggunakan autentikasi langsung. **Pengaturan yang disarankan:** Dashboard → Providers → Add API Key. -Setting via environment variables is an alternative for Docker or headless deployments. +Pengaturan melalui variabel lingkungan adalah alternatif untuk deployment Docker atau tanpa antarmuka grafis. -Recognized pattern: `{PROVIDER_ID}_API_KEY` +Pola yang dikenali: `{PROVIDER_ID}_API_KEY` | Variable | Provider | | -------------------- | ------------------- | @@ -410,15 +410,15 @@ Recognized pattern: `{PROVIDER_ID}_API_KEY` | `NEBIUS_API_KEY` | Nebius (embeddings) | > [!TIP] -> Keys set via the Dashboard are stored encrypted in SQLite and take precedence over environment variables. +> Kunci yang diatur melalui Dashboard disimpan terenkripsi di SQLite dan mengambil prioritas di atas variabel lingkungan. --- -## 15. Timeout Settings +## 15. Pengaturan Batas Waktu -All values are in **milliseconds**. Centralized resolution in `src/shared/utils/runtimeTimeouts.ts`. +Semua nilai dalam satuan **milidetik**. Penyelesaian terpusat di `src/shared/utils/runtimeTimeouts.ts`. -### Timeout Hierarchy +### Hierarki Batas Waktu ``` REQUEST_TIMEOUT_MS (global override) @@ -436,69 +436,69 @@ REQUEST_TIMEOUT_MS (global override) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (default: 0 = disabled) ``` -| Variable | Default | Description | -| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | -| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `600000` | Proxy hop timeout for `/v1` bridge requests. | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `600000` | Overall server request timeout for the bridge. | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | +| Variable | Default | Deskripsi | +| ---------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(unset)_ | Pintasan global — menimpa default `FETCH_TIMEOUT_MS` maupun `STREAM_IDLE_TIMEOUT_MS`. | +| `FETCH_TIMEOUT_MS` | `600000` | Total batas waktu permintaan HTTP untuk panggilan provider upstream. | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Keheningan maksimum antar chunk SSE sebelum dibatalkan. Model extended-thinking jarang berhenti lebih dari 90 detik. | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Waktu untuk menerima header respons. | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Waktu untuk menerima body respons penuh. | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Batas waktu pembentukan koneksi TCP. | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Batas waktu idle socket keep-alive. | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Batas waktu proxy fingerprint TLS (wreq-js). | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `600000` | Batas waktu hop proxy untuk permintaan jembatan `/v1`. | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `600000` | Batas waktu permintaan server keseluruhan untuk jembatan. | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Waktu untuk mengirim header respons melalui jembatan. | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Batas waktu idle keep-alive jembatan. | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Batas waktu socket mentah (0 = dinonaktifkan). | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | Periode grace pada SIGTERM/SIGINT sebelum force-exit. | -### Scenarios +### Skenario -| Scenario | Configuration | -| -------------------------------- | ------------------------------------------------------ | -| **Long-running code generation** | `REQUEST_TIMEOUT_MS=900000` (15 min) | -| **Fast-fail for production API** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | -| **Extended thinking models** | `STREAM_IDLE_TIMEOUT_MS=300000` (5 min between chunks) | +| Skenario | Konfigurasi | +| ----------------------------------------- | --------------------------------------------------------- | +| **Pembuatan kode berjalan lama** | `REQUEST_TIMEOUT_MS=900000` (15 menit) | +| **Fast-fail untuk API produksi** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | +| **Model extended thinking** | `STREAM_IDLE_TIMEOUT_MS=300000` (5 menit antar chunk) | --- ## 16. Logging -The logging system writes to both stdout and rotated log files. All configuration is read by `src/lib/logEnv.ts`. +Sistem logging menulis ke stdout dan file log yang dirotasi. Semua konfigurasi dibaca oleh `src/lib/logEnv.ts`. -| Variable | Default | Description | -| --------------------------- | -------------------------- | ---------------------------------------------------------------------------- | -| `APP_LOG_LEVEL` | `info` | Minimum log level: `debug`, `info`, `warn`, `error`. | -| `APP_LOG_FORMAT` | `text` | Output format: `text` (human-readable) or `json` (structured). | -| `APP_LOG_TO_FILE` | `true` | Write logs to file alongside stdout. | -| `APP_LOG_FILE_PATH` | `logs/application/app.log` | Log file path (relative to project root or `DATA_DIR`). | -| `APP_LOG_MAX_FILE_SIZE` | `50M` | Max file size before rotation. Accepts: `50M`, `1G`, `512K`, or plain bytes. | -| `APP_LOG_RETENTION_DAYS` | `7` | Days to keep rotated application log files. | -| `APP_LOG_MAX_FILES` | `20` | Maximum rotated log file backups. | -| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. | -| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. | -| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | -| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. | +| Variable | Default | Deskripsi | +| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------- | +| `APP_LOG_LEVEL` | `info` | Level log minimum: `debug`, `info`, `warn`, `error`. | +| `APP_LOG_FORMAT` | `text` | Format output: `text` (mudah dibaca manusia) atau `json` (terstruktur). | +| `APP_LOG_TO_FILE` | `true` | Tulis log ke file bersama stdout. | +| `APP_LOG_FILE_PATH` | `logs/application/app.log` | Path file log (relatif terhadap root proyek atau `DATA_DIR`). | +| `APP_LOG_MAX_FILE_SIZE` | `50M` | Ukuran file maksimum sebelum rotasi. Menerima: `50M`, `1G`, `512K`, atau byte biasa. | +| `APP_LOG_RETENTION_DAYS` | `7` | Hari untuk menyimpan file log aplikasi yang telah dirotasi. | +| `APP_LOG_MAX_FILES` | `20` | Maksimum cadangan file log yang telah dirotasi. | +| `CALL_LOG_RETENTION_DAYS` | `7` | Hari untuk menyimpan entri log permintaan/panggilan di database. | +| `CALL_LOG_MAX_ENTRIES` | `10000` | Maksimum entri log panggilan dalam buffer in-memory. | +| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Maksimum baris dalam tabel SQLite `call_logs` sebelum dipangkas. | +| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Maksimum baris dalam tabel SQLite `proxy_logs` sebelum dipangkas. | --- -## 17. Memory Optimization +## 17. Optimasi Memori -| Variable | Default | Description | -| -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | -| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | -| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | -| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max cached temperature=0 responses. | -| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Max total semantic cache size. | -| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 min) | Semantic cache entry TTL. | -| `STREAM_HISTORY_MAX` | `50` | Max recent stream events in the Dashboard live view buffer. | -| `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. | -| `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. | +| Variable | Default | Deskripsi | +| -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `OMNIROUTE_MEMORY_MB` | `512` | Batas heap V8 saat runtime. Docker standalone dan `omniroute serve` menggunakannya untuk mengatur `--max-old-space-size`. | +| `PROMPT_CACHE_MAX_SIZE` | `50` | Maksimum entri prompt sistem yang dicache. | +| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Ukuran total cache prompt maksimum. | +| `PROMPT_CACHE_TTL_MS` | `300000` (5 menit) | TTL entri cache prompt. | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Maksimum respons temperature=0 yang dicache. | +| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Ukuran total cache semantik maksimum. | +| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 menit) | TTL entri cache semantik. | +| `STREAM_HISTORY_MAX` | `50` | Maksimum event stream terbaru dalam buffer tampilan langsung Dashboard. | +| `CONTEXT_LENGTH_DEFAULT` | `128000` | Panjang konteks maksimum fallback global untuk model tanpa konfigurasi eksplisit. | +| `USAGE_TOKEN_BUFFER` | `100` | Cadangan token ekstra yang disisihkan saat melacak kuota penggunaan. | -### Low-RAM Docker Example +### Contoh Docker RAM Rendah ```bash OMNIROUTE_MEMORY_MB=128 @@ -511,85 +511,85 @@ STREAM_HISTORY_MAX=10 --- -## 18. Pricing Sync +## 18. Sinkronisasi Harga -Automatic model pricing data synchronization from external sources. +Sinkronisasi data harga model secara otomatis dari sumber eksternal. -| Variable | Default | Source File | Description | -| ----------------------- | ------------- | ------------------------ | ----------------------------- | -| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | Opt-in periodic pricing sync. | -| `PRICING_SYNC_INTERVAL` | `86400` (24h) | `src/lib/pricingSync.ts` | Sync interval in seconds. | -| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | Comma-separated data sources. | +| Variable | Default | Source File | Deskripsi | +| ----------------------- | ------------- | ------------------------ | ------------------------------------------------- | +| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | Opt-in sinkronisasi harga berkala. | +| `PRICING_SYNC_INTERVAL` | `86400` (24h) | `src/lib/pricingSync.ts` | Interval sinkronisasi dalam detik. | +| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | Sumber data yang dipisahkan koma. | --- -## 19. Model Sync (Dev) +## 19. Sinkronisasi Model (Dev) -| Variable | Default | Source File | Description | -| -------------------------- | ------------- | -------------------------- | -------------------------------------------------------- | -| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | +| Variable | Default | Source File | Deskripsi | +| -------------------------- | ------------- | -------------------------- | ----------------------------------------------------------------------- | +| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Interval sinkronisasi katalog model saat pengembangan dalam detik. | --- -## 20. Provider-Specific Settings +## 20. Pengaturan Spesifik Provider -| Variable | Default | Source File | Description | -| ----------------------------------------- | ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- | -| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | -| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | -| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | -| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | -| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | -| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | -| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | -| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Enable experimental Claude Code compatible provider endpoint. | -| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | -| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | -| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | -| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | +| Variable | Default | Source File | Deskripsi | +| ----------------------------------------- | --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | TTL cache katalog model OpenRouter. | +| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Waktu tunggu maksimum untuk pekerjaan pembuatan gambar NanoBanana. | +| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | Frekuensi polling pekerjaan NanoBanana. | +| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | ID akun untuk Cloudflare Workers AI. | +| `CLOUDFLARED_BIN` | deteksi otomatis | `src/lib/cloudflaredTunnel.ts` | Path kustom ke biner `cloudflared`. | +| `SEARCH_CACHE_TTL_MS` | `300000` (5 menit) | `open-sse/services/searchCache.ts` | TTL untuk caching respons API pencarian (Perplexity, Brave, dll.). | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Izinkan beberapa koneksi simultan per provider yang kompatibel dengan OpenAI. | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Aktifkan endpoint provider eksperimental yang kompatibel dengan Claude Code. | +| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | Host jembatan CLIProxyAPI (integrasi legacy). | +| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | Port jembatan CLIProxyAPI. | +| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | Direktori konfigurasi CLIProxyAPI. | +| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Nama host tambahan yang dipisahkan koma yang diperlakukan sebagai "lokal" (nama layanan Docker, dll.). | --- -## 21. Proxy Health +## 21. Kesehatan Proxy -| Variable | Default | Source File | Description | -| ---------------------------- | ---------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | -| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | -| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | +| Variable | Default | Source File | Deskripsi | +| ---------------------------- | ------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Batas waktu pemeriksaan kesehatan fast-fail. | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | TTL cache hasil pemeriksaan kesehatan. | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 menit) | `open-sse/services/rateLimitManager.ts` | Waktu tunggu maksimum pada respons 429 sebelum menggagalkan permintaan. | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Jumlah percobaan ulang otomatis pada respons cooldown berbasis model sebelum mengembalikan error ke klien. | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Interval backoff maksimum (detik) antar percobaan ulang cooldown. Dibatasi oleh nilai ini terlepas dari `Retry-After` upstream. | --- ## 22. Debugging > [!CAUTION] -> These variables produce **verbose output** and may leak sensitive data. **Never enable in production.** +> Variabel-variabel ini menghasilkan **output yang sangat detail** dan dapat membocorkan data sensitif. **Jangan pernah aktifkan di lingkungan produksi.** -| Variable | Default | Source File | Description | -| -------------------------------- | --------- | ----------------------------------------- | -------------------------------------------------------------- | -| `CURSOR_PROTOBUF_DEBUG` | _(unset)_ | `open-sse/utils/cursorProtobuf.ts` | Set `1` to dump Cursor protobuf decode/encode details. | -| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Set `1` to dump raw Cursor SSE stream data. | -| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. | -| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). | +| Variable | Default | Source File | Deskripsi | +| -------------------------------- | --------- | ----------------------------------------- | ---------------------------------------------------------------------------------- | +| `CURSOR_PROTOBUF_DEBUG` | _(unset)_ | `open-sse/utils/cursorProtobuf.ts` | Atur ke `1` untuk membuang detail decode/encode protobuf Cursor. | +| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Atur ke `1` untuk membuang data stream SSE Cursor mentah. | +| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Atur ke `true` untuk mencatat log detail translasi SSE→JSON Responses API. | +| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Atur ke `true` untuk mengaktifkan mode pengujian E2E (autentikasi santai, test hook). | --- -## 23. GitHub Integration +## 23. Integrasi GitHub -Allow users to report issues directly from the Dashboard. +Memungkinkan pengguna melaporkan masalah langsung dari Dashboard. -| Variable | Default | Source File | Description | -| --------------------- | --------- | --------------------------------------- | ------------------------------------------------------- | -| `GITHUB_ISSUES_REPO` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | Repository in `owner/repo` format. | -| `GITHUB_ISSUES_TOKEN` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | GitHub Personal Access Token with `issues:write` scope. | +| Variable | Default | Source File | Deskripsi | +| --------------------- | --------- | --------------------------------------- | -------------------------------------------------------------------- | +| `GITHUB_ISSUES_REPO` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | Repositori dalam format `owner/repo`. | +| `GITHUB_ISSUES_TOKEN` | _(unset)_ | `src/app/api/v1/issues/report/route.ts` | GitHub Personal Access Token dengan scope `issues:write`. | --- -## Deployment Scenarios +## Skenario Deployment -### Minimal Local Development +### Pengembangan Lokal Minimal ```bash JWT_SECRET=$(openssl rand -base64 48) @@ -629,7 +629,7 @@ OMNIROUTE_DISABLE_BACKGROUND_SERVICES=true APP_LOG_TO_FILE=false ``` -### VPS with Reverse Proxy (nginx + Cloudflare) +### VPS dengan Reverse Proxy (nginx + Cloudflare) ```bash JWT_SECRET= @@ -647,23 +647,23 @@ CLI_COMPAT_ALL=1 --- -## Audit: Removed / Dead Variables +## Audit: Variabel yang Dihapus / Tidak Aktif -The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed: +Variabel-variabel berikut muncul di versi sebelumnya dari `.env.example` tetapi **tidak memiliki referensi runtime** di basis kode saat ini. Variabel-variabel ini telah dihapus: -| Variable | Reason | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER=sqlite` | Never read by any source file. SQLite is the only supported driver — no selection needed. | -| `INSTANCE_NAME=omniroute` | Present in old docs/env templates but unused at runtime. May return in a future multi-instance feature. | -| `SQLITE_MAX_SIZE_MB=2048` | Not referenced in source code. Database size is not artificially limited. | -| `SQLITE_CLEAN_LEGACY_FILES=true` | Not referenced in source code. Legacy cleanup was likely removed. | -| `CLI_ROO_BIN` | Not registered in `src/shared/services/cliRuntime.ts`. | -| `CLI_KIMI_CODING_BIN` | Not registered in `src/shared/services/cliRuntime.ts` (Kimi Coding uses OAuth, not a CLI binary). | -| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | Not referenced anywhere in source code. | +| Variable | Alasan | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER=sqlite` | Tidak pernah dibaca oleh file sumber mana pun. SQLite adalah satu-satunya driver yang didukung — tidak diperlukan pemilihan. | +| `INSTANCE_NAME=omniroute` | Ada di template docs/env lama tetapi tidak digunakan saat runtime. Mungkin kembali dalam fitur multi-instansi di masa depan. | +| `SQLITE_MAX_SIZE_MB=2048` | Tidak dirujuk dalam kode sumber. Ukuran database tidak dibatasi secara artifisial. | +| `SQLITE_CLEAN_LEGACY_FILES=true` | Tidak dirujuk dalam kode sumber. Pembersihan legacy kemungkinan telah dihapus. | +| `CLI_ROO_BIN` | Tidak terdaftar di `src/shared/services/cliRuntime.ts`. | +| `CLI_KIMI_CODING_BIN` | Tidak terdaftar di `src/shared/services/cliRuntime.ts` (Kimi Coding menggunakan OAuth, bukan biner CLI). | +| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | Tidak dirujuk di mana pun dalam kode sumber. | -### Default Value Corrections +### Koreksi Nilai Default -| Variable | Old `.env.example` Value | Actual Code Default | Fixed | -| ------------------------- | ------------------------ | ------------------- | ------------------------------------------------------ | -| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | -| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | +| Variable | Nilai `.env.example` Lama | Default Kode Aktual | Diperbaiki | +| ------------------------- | ------------------------- | ------------------- | ------------------------------------------------------------------ | +| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Nilai yang menyesatkan dihapus; `7` didokumentasikan sebagai default | +| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Nilai yang menyesatkan dihapus; `7` didokumentasikan sebagai default | diff --git a/docs/i18n/id/docs/routing/AUTO-COMBO.md b/docs/i18n/id/docs/routing/AUTO-COMBO.md index 3e4e5d2501..19e3292fcf 100644 --- a/docs/i18n/id/docs/routing/AUTO-COMBO.md +++ b/docs/i18n/id/docs/routing/AUTO-COMBO.md @@ -4,40 +4,40 @@ --- -> Self-managing model chains with adaptive scoring +> Rantai model yang mengelola diri sendiri dengan penilaian adaptif -## How It Works +## Cara Kerjanya -The Auto-Combo Engine dynamically selects the best provider/model for each request using a **6-factor scoring function**: +Auto-Combo Engine secara dinamis memilih penyedia/model terbaik untuk setiap permintaan menggunakan **fungsi penilaian 6 faktor**: -| Factor | Weight | Description | -| :--------- | :----- | :---------------------------------------------- | -| Quota | 0.20 | Remaining capacity [0..1] | -| Health | 0.25 | Circuit breaker: CLOSED=1.0, HALF=0.5, OPEN=0.0 | -| CostInv | 0.20 | Inverse cost (cheaper = higher score) | -| LatencyInv | 0.15 | Inverse p95 latency (faster = higher) | -| TaskFit | 0.10 | Model × task type fitness score | -| Stability | 0.10 | Low variance in latency/errors | +| Faktor | Bobot | Deskripsi | +| :--------- | :---- | :----------------------------------------------------- | +| Quota | 0.20 | Kapasitas tersisa [0..1] | +| Health | 0.25 | Circuit breaker: CLOSED=1.0, HALF=0.5, OPEN=0.0 | +| CostInv | 0.20 | Biaya invers (lebih murah = skor lebih tinggi) | +| LatencyInv | 0.15 | Latensi p95 invers (lebih cepat = lebih tinggi) | +| TaskFit | 0.10 | Skor kesesuaian model × tipe tugas | +| Stability | 0.10 | Variansi rendah dalam latensi/kesalahan | -## Mode Packs +## Paket Mode -| Pack | Focus | Key Weight | +| Paket | Fokus | Bobot Utama | | :---------------------- | :----------- | :--------------- | -| 🚀 **Ship Fast** | Speed | latencyInv: 0.35 | -| 💰 **Cost Saver** | Economy | costInv: 0.40 | -| 🎯 **Quality First** | Best model | taskFit: 0.40 | -| 📡 **Offline Friendly** | Availability | quota: 0.40 | +| 🚀 **Ship Fast** | Kecepatan | latencyInv: 0.35 | +| 💰 **Cost Saver** | Ekonomi | costInv: 0.40 | +| 🎯 **Quality First** | Model terbaik | taskFit: 0.40 | +| 📡 **Offline Friendly** | Ketersediaan | quota: 0.40 | -## Self-Healing +## Pemulihan Mandiri -- **Temporary exclusion**: Score < 0.2 → excluded for 5 min (progressive backoff, max 30 min) -- **Circuit breaker awareness**: OPEN → auto-excluded; HALF_OPEN → probe requests -- **Incident mode**: >50% OPEN → disable exploration, maximize stability -- **Cooldown recovery**: After exclusion, first request is a "probe" with reduced timeout +- **Pengecualian sementara**: Skor < 0.2 → dikecualikan selama 5 menit (backoff progresif, maks 30 menit) +- **Kesadaran circuit breaker**: OPEN → dikecualikan otomatis; HALF_OPEN → permintaan probe +- **Mode insiden**: >50% OPEN → nonaktifkan eksplorasi, maksimalkan stabilitas +- **Pemulihan cooldown**: Setelah pengecualian, permintaan pertama adalah "probe" dengan timeout yang dikurangi -## Bandit Exploration +## Eksplorasi Bandit -5% of requests (configurable) are routed to random providers for exploration. Disabled in incident mode. +5% permintaan (dapat dikonfigurasi) diarahkan ke penyedia acak untuk eksplorasi. Dinonaktifkan dalam mode insiden. ## API @@ -51,17 +51,17 @@ curl -X POST http://localhost:20128/api/combos/auto \ curl http://localhost:20128/api/combos/auto ``` -## Task Fitness +## Kesesuaian Tugas -30+ models scored across 6 task types (`coding`, `review`, `planning`, `analysis`, `debugging`, `documentation`). Supports wildcard patterns (e.g., `*-coder` → high coding score). +30+ model dinilai di 6 tipe tugas (`coding`, `review`, `planning`, `analysis`, `debugging`, `documentation`). Mendukung pola wildcard (mis., `*-coder` → skor coding tinggi). -## Files +## Berkas -| File | Purpose | -| :------------------------------------------- | :------------------------------------ | -| `open-sse/services/autoCombo/scoring.ts` | Scoring function & pool normalization | -| `open-sse/services/autoCombo/taskFitness.ts` | Model × task fitness lookup | -| `open-sse/services/autoCombo/engine.ts` | Selection logic, bandit, budget cap | -| `open-sse/services/autoCombo/selfHealing.ts` | Exclusion, probes, incident mode | -| `open-sse/services/autoCombo/modePacks.ts` | 4 weight profiles | -| `src/app/api/combos/auto/route.ts` | REST API | +| Berkas | Tujuan | +| :------------------------------------------- | :-------------------------------------------- | +| `open-sse/services/autoCombo/scoring.ts` | Fungsi penilaian & normalisasi pool | +| `open-sse/services/autoCombo/taskFitness.ts` | Pencarian kesesuaian model × tugas | +| `open-sse/services/autoCombo/engine.ts` | Logika pemilihan, bandit, batas anggaran | +| `open-sse/services/autoCombo/selfHealing.ts` | Pengecualian, probe, mode insiden | +| `open-sse/services/autoCombo/modePacks.ts` | 4 profil bobot | +| `src/app/api/combos/auto/route.ts` | REST API | diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 2a4911b70a..83cf25ea31 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index b9c02cd599..dd4135aada 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 4650931ed4..b38dc049e5 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 0446104f61..0b61bf14bb 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 54cacc8796..0681156d8a 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 85167f999f..11ac167ad1 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index a9e80a1414..6985e75741 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index d64394d6b8..19c9e7b52b 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index cdf41a6a49..85ae63914c 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 2ac00641cc..051f54b5ca 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 9b9c89f1de..5e9b90fbbc 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 710739ebfa..2583c59cf9 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index a6e5789f59..76f57f888a 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index ef78374cdd..bf75ad18bc 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 6b948f30ea..414b73d58e 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index b3501f977c..63d1802112 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 16d5e5eb69..d6fa461ffc 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index e251de7ce6..1d788413ee 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 446a26df3f..ce9b424345 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index ee410061e0..3953f1f554 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 0d164c8f08..356d79df2f 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index a92d030ab7..dff1d717c8 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 2ff61461be..4c14b5d7b5 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 13c1a60f3a..0ac017bc39 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 93a2b91674..471ff4484b 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.14] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.13] — Unreleased _Development cycle in progress._ diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f87d64bd29..7fae6bd3a1 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -562,6 +562,8 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | | `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | +| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or `REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 2fdccc1bd8..176f8ea5bb 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.13 + version: 3.8.14 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/main.js b/electron/main.js index b6e8f99514..a439157eb4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -34,6 +34,7 @@ const fs = require("fs"); const { autoUpdater } = require("electron-updater"); const { hasEncryptedCredentials } = require("./sqlite-inspection"); const { loginManager } = require("./loginManager"); +const { killProcessTree } = require("./processTree"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -200,7 +201,9 @@ async function waitForServerExit(proc, timeoutMs = 5000) { new Promise((r) => setTimeout(() => { try { - proc.kill("SIGKILL"); + // #3347: force-kill the whole tree (Windows leaves grandchildren alive on a + // bare SIGKILL of the direct child, keeping omniroute.exe locked). + killProcessTree(proc, { signal: "SIGKILL" }); } catch { /* already dead */ } @@ -286,7 +289,9 @@ async function downloadUpdate() { function installUpdate() { if (nextServer) { - nextServer.kill("SIGTERM"); + // #3347: tree-kill before quitAndInstall — a surviving server child (and its + // grandchildren) keeps omniroute.exe locked and the updater fails with "file in use". + killProcessTree(nextServer, { signal: "SIGTERM" }); nextServer = null; } autoUpdater.quitAndInstall(); @@ -666,7 +671,10 @@ function startNextServer() { function stopNextServer() { if (nextServer) { - nextServer.kill("SIGTERM"); + // #3347: kill the whole tree, not just the direct child. On Windows the server + // (omniroute.exe-as-node) spawns grandchildren that a bare SIGTERM leaves alive, + // holding a lock on omniroute.exe and blocking updates. + killProcessTree(nextServer, { signal: "SIGTERM" }); nextServer = null; } } diff --git a/electron/package-lock.json b/electron/package-lock.json index 6926f65b66..f93404345a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.13", + "version": "3.8.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.13", + "version": "3.8.14", "license": "MIT", "dependencies": { "electron-updater": "^6.8.8" diff --git a/electron/package.json b/electron/package.json index 423e639bc0..d86270d603 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.13", + "version": "3.8.14", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { @@ -52,6 +52,7 @@ "main.js", "preload.js", "loginManager.js", + "processTree.js", "sqlite-inspection.js", "package.json", "node_modules/**/*" diff --git a/electron/processTree.js b/electron/processTree.js new file mode 100644 index 0000000000..2f895d33e8 --- /dev/null +++ b/electron/processTree.js @@ -0,0 +1,62 @@ +"use strict"; + +// Cross-platform "kill the whole process tree" helper (#3347). +// +// The embedded server is spawned via process.execPath (= omniroute.exe) with +// ELECTRON_RUN_AS_NODE=1, and it in turn spawns grandchildren (embedded services, +// MITM proxy, tunnels — several also omniroute.exe-as-node). On Windows, Node's +// ChildProcess.kill()/SIGTERM/SIGKILL only terminate the DIRECT child via +// TerminateProcess — they do NOT walk the tree. Surviving grandchildren keep a lock +// on omniroute.exe, so the process "hangs in memory" after Exit and updates fail with +// "file in use". Windows needs `taskkill /PID /T /F` (the /T flag terminates the +// process AND its descendants). POSIX keeps signal-based termination, which propagates. + +const { spawn } = require("child_process"); + +/** + * Terminate a child process and all of its descendants. + * @param {{ pid?: number, kill?: (signal?: string) => void } | null | undefined} proc + * @param {{ platform?: string, signal?: string, spawnFn?: typeof spawn }} [options] + */ +function killProcessTree(proc, options = {}) { + if (!proc || proc.pid == null) return; + const platform = options.platform || process.platform; + const signal = options.signal || "SIGTERM"; + + if (platform === "win32") { + const spawnFn = options.spawnFn || spawn; + try { + // Array args + no shell → the pid (an integer we own) is never interpolated into a + // shell command string (Hard Rule #13). /T walks the tree, /F forces termination. + const killer = spawnFn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], { + windowsHide: true, + }); + if (killer && typeof killer.on === "function") { + killer.on("error", () => { + try { + proc.kill(signal); + } catch { + /* already dead */ + } + }); + } + } catch { + // taskkill unavailable (rare) — fall back to the direct kill. + try { + proc.kill(signal); + } catch { + /* already dead */ + } + } + return; + } + + // POSIX: signals propagate to the process group of a normally-spawned child. + try { + proc.kill(signal); + } catch { + /* already dead */ + } +} + +module.exports = { killProcessTree }; diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index bef0b02233..91b3e959c4 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1035,7 +1035,12 @@ const _REGISTRY_EAGER: Record = { { id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" }, - { id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses", supportsXHighEffort: true }, + { + id: "gpt-5.4", + name: "GPT-5.4", + targetFormat: "openai-responses", + supportsXHighEffort: true, + }, { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "claude-haiku-4.5", @@ -1224,7 +1229,7 @@ const _REGISTRY_EAGER: Record = { { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, // #3110: MiniMax M3 via Trae - { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "minimax-m2.7", name: "MiniMax M2.7" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "gpt-5.4", name: "GPT 5.4" }, @@ -1412,7 +1417,14 @@ const _REGISTRY_EAGER: Record = { }, { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true }, // #3110: MiniMax M3 free tier via OpenCode - { id: "minimax-m3-free", name: "MiniMax M3 Free", contextLength: 1048576 }, + // #3328: MiniMax M3 is multimodal (verified: describes base64 images via the + // opencode upstream) — flag it so vision requests aren't gated/stripped. + { + id: "minimax-m3-free", + name: "MiniMax M3 Free", + contextLength: 1048576, + supportsVision: true, + }, { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 }, { id: "ling-2.6-1t-free", name: "Ling 2.6 Free", contextLength: 262000 }, { @@ -1453,7 +1465,13 @@ const _REGISTRY_EAGER: Record = { { id: "mimo-v2-pro", name: "MiMo-V2-Pro" }, { id: "mimo-v2-omni", name: "MiMo-V2-Omni" }, // #3110: MiniMax M3 via OpenCode Go tier - { id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude", contextLength: 1048576 }, + { + id: "minimax-m3", + name: "MiniMax M3", + targetFormat: "claude", + contextLength: 1048576, + supportsVision: true, + }, { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, // Issue #2292: Qwen models on opencode-go reject oa-compat format @@ -1539,7 +1557,7 @@ const _REGISTRY_EAGER: Record = { // ── MiniMax ──────────────────────────────────────────────── // #3110: MiniMax M3 — frontier coding model with 1M context - { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "minimax-m2.5", name: "MiniMax M2.5" }, { id: "minimax-m2.7", name: "MiniMax M2.7" }, @@ -2222,7 +2240,7 @@ const _REGISTRY_EAGER: Record = { models: [ // T12/T28: MiniMax default upgraded from M2.5 to M2.7 // #3110: MiniMax M3 — frontier coding model with 1M context - { id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, @@ -2245,7 +2263,7 @@ const _REGISTRY_EAGER: Record = { models: [ // Keep parity with minimax to ensure model discovery works for minimax-cn connections. // #3110: MiniMax M3 — frontier coding model with 1M context - { id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, @@ -2704,7 +2722,7 @@ const _REGISTRY_EAGER: Record = { { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" }, { id: "mimo-v2.5", name: "MiMo-V2.5" }, // #3110: MiniMax M3 via OpenCode Zen - { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "minimax-m2.7", name: "MiniMax M2.7" }, { id: "minimax-m2.5", name: "MiniMax M2.5" }, { id: "llama-4-maverick", name: "Llama 4 Maverick" }, @@ -3177,7 +3195,7 @@ const _REGISTRY_EAGER: Record = { { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "glm-5.1", name: "GLM 5.1" }, // #3110: MiniMax M3 via Ollama - { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, { id: "minimax-m2.7", name: "MiniMax M2.7" }, { id: "gemma4:31b", name: "Gemma 4 31B" }, { id: "nemotron-3-super", name: "NVIDIA Nemotron 3 Super" }, @@ -3223,8 +3241,9 @@ const _REGISTRY_EAGER: Record = { authHeader: "bearer", models: [ { id: "z-ai/glm-5.1", name: "GLM 5.1" }, - // #3110: MiniMax M3 via NVIDIA - { id: "minimaxai/minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + // #3329: minimaxai/minimax-m3 removed — NVIDIA NIM does not host it yet + // (every request 404s), while minimax-m2.7 on the same provider works. + // Re-add only once NVIDIA actually serves it. { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, { id: "google/gemma-4-31b-it", name: "Gemma 4 31B" }, { id: "mistralai/mistral-small-4-119b-2603", name: "Mistral Small 4 2603" }, @@ -4330,9 +4349,7 @@ const _REGISTRY_EAGER: Record = { baseUrls: ["https://amelia.chipotle.com"], authType: "none", authHeader: "none", - models: [ - { id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" }, - ], + models: [{ id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" }], passthroughModels: true, }, }; diff --git a/open-sse/config/registryUtils.ts b/open-sse/config/registryUtils.ts index 6ca45f1da6..55dc5b2c48 100644 --- a/open-sse/config/registryUtils.ts +++ b/open-sse/config/registryUtils.ts @@ -14,6 +14,7 @@ export interface BaseModel { export interface BaseProvider { id: string; + alias?: string; baseUrl: string; authType: string; // "apikey" | "oauth" | "none" authHeader: string; // "bearer" | "key" | "token" | "xi-api-key" | "x-api-key" | "none" @@ -32,10 +33,13 @@ export function parseModelFromRegistry

( if (!modelStr) return { provider: null, model: null }; // Try each provider prefix - for (const [providerId] of Object.entries(registry)) { + for (const [providerId, config] of Object.entries(registry)) { if (modelStr.startsWith(providerId + "/")) { return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; } + if (config.alias && modelStr.startsWith(config.alias + "/")) { + return { provider: providerId, model: modelStr.slice(config.alias.length + 1) }; + } } // No provider prefix — try to find the model in every provider @@ -62,12 +66,17 @@ export function getAllModelsFromRegistry

( for (const [providerId, config] of Object.entries(registry)) { const extraFields = extra ? extra(providerId, config) : {}; for (const model of config.models) { - models.push({ - id: `${providerId}/${model.id}`, - name: model.name, - provider: providerId, - ...extraFields, - }); + const entries = [providerId, config.alias].filter( + (prefix): prefix is string => typeof prefix === "string" && prefix.length > 0 + ); + for (const prefix of entries) { + models.push({ + id: `${prefix}/${model.id}`, + name: model.name, + provider: providerId, + ...extraFields, + }); + } } } diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index b876bb0b6e..6e65e5adfb 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -16,6 +16,7 @@ interface VideoModel { interface VideoProvider { id: string; + alias?: string; baseUrl: string; statusUrl?: string; authType: string; @@ -140,6 +141,19 @@ export const VIDEO_PROVIDERS: Record = { models: [{ id: "animatediff-webui", name: "AnimateDiff (WebUI)" }], }, + "veoaifree-web": { + id: "veoaifree-web", + alias: "veo-free", + baseUrl: "https://veoaifree.com/wp-admin/admin-ajax.php", + authType: "none", + authHeader: "none", + format: "veoaifree-web", + models: [ + { id: "veo", name: "VEO 3.1" }, + { id: "seedance", name: "Seedance" }, + ], + }, + runwayml: { id: "runwayml", baseUrl: "https://api.dev.runwayml.com/v1", diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index dc35c0c8d4..28122331e9 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -26,17 +26,67 @@ import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { tryBackedChat } from "../services/browserBackedChat.ts"; // ─── Constants ────────────────────────────────────────────────────────────── const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; const CLAUDE_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; // Session cookie constants const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; +/** + * Read the Claude Web session cookie from the credentials object. + * + * Lookup order (most-specific first): + * 1. `credentials.cookie` — direct field (programmatic / older callers) + * 2. `credentials.apiKey` — what the dashboard form posts (the + * connection row's `api_key` column stores the cookie string after + * encryption; `getProviderCredentials()` decrypts and surfaces it + * back as `apiKey`) + * 3. `credentials.providerSpecificData.cookie` — escape hatch for + * callers that route the cookie through the per-provider metadata + * + * Without the apiKey fallback, the executor could never see a real + * cookie through the standard /api/v1/chat/completions path — the + * dashboard posts the cookie in the connection's `apiKey` field, but + * this executor was historically reading `cookie` only. + */ +function readClaudeWebCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const c = credentials as Record; + const direct = typeof c.cookie === "string" ? c.cookie : ""; + if (direct.trim()) return direct; + const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; + if (apiKey.trim()) return apiKey; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = (psd as Record).cookie; + if (typeof nested === "string" && nested.trim()) return nested; + } + return ""; +} + +/** + * Read the optional Claude Web device ID from the credentials object. + * Mirrors `readClaudeWebCookie` so callers can use the same priority + * chain (direct → apiKey → providerSpecificData). + */ +function readClaudeWebDeviceId(credentials: unknown): string | undefined { + if (!credentials || typeof credentials !== "object") return undefined; + const c = credentials as Record; + if (typeof c.deviceId === "string" && c.deviceId.trim()) return c.deviceId; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = (psd as Record).deviceId; + if (typeof nested === "string" && nested.trim()) return nested; + } + return undefined; +} + // Default model when not specified const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; @@ -72,9 +122,11 @@ interface ClaudeWebRequestPayload { assistant_message_uuid: string; }; attachments: unknown[]; + effort: string; files: unknown[]; sync_sources: unknown[]; rendering_mode: string; + thinking_mode: string; create_conversation_params: { name: string; model: string; @@ -83,6 +135,7 @@ interface ClaudeWebRequestPayload { compass_mode: unknown; is_temporary: boolean; enabled_imagine: boolean; + tool_search_mode: string; }; } @@ -110,12 +163,17 @@ interface ClaudeWebStreamChunk { function getBrowserHeaders(deviceId?: string): Record { const headers: Record = { Accept: "text/event-stream", + "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", "Cache-Control": "no-cache", "Content-Type": "application/json", Origin: "https://claude.ai", Pragma: "no-cache", + Priority: "u=1, i", Referer: "https://claude.ai/new", + "Sec-Ch-Ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Linux"', "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", @@ -229,6 +287,14 @@ function getDefaultTools(): ClaudeWebRequestPayload["tools"] { type: "repl_v0", name: "repl", }, + { type: "widget", name: "weather_fetch" }, + { type: "widget", name: "recipe_display_v0" }, + { type: "widget", name: "places_map_display_v0" }, + { type: "widget", name: "message_compose_v1" }, + { type: "widget", name: "ask_user_input_v0" }, + { type: "widget", name: "recommend_claude_apps" }, + { type: "widget", name: "places_search" }, + { type: "widget", name: "fetch_sports_data" }, ]; } @@ -280,9 +346,11 @@ function transformToClaude(body: Record, model: string): Claude tools: getDefaultTools(), turn_message_uuids: generateMessageUUIDs(), attachments: [], + effort: "low", files: [], sync_sources: [], rendering_mode: "messages", + thinking_mode: "off", create_conversation_params: { name: "", model: model || DEFAULT_CLAUDE_MODEL, @@ -291,6 +359,7 @@ function transformToClaude(body: Record, model: string): Claude compass_mode: null, is_temporary: false, enabled_imagine: true, + tool_search_mode: "auto", }, }; } @@ -385,6 +454,180 @@ async function getOrganizationId( } } +function shouldUseBrowserBacked(): 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"; +} + +function extractLastUserText(body: Record): string { + const messages = Array.isArray(body.messages) + ? (body.messages as Array>) + : []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + const content = messages[i].content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c) => (typeof c === "object" && c && "text" in c ? String(c.text) : "")) + .filter(Boolean) + .join("\n"); + } + } + } + return "Reply with OK"; +} + +/** + * Read Claude Web SSE chunks from an upstream response body and pipe them + * through transformFromClaude to produce OpenAI chat.completion.chunk SSE. + * + * The upstream body may arrive as a ReadableStream directly (tlsBody) or + * via upstreamResp.body (browser-backed path, where the Response type is + * accurate). tlsBody takes priority when non-null. + * + * Returns a Response whose body is the transformed SSE stream. The client + * receives standard OpenAI-format streaming chunks. + */ +async function buildClaudeStreamingResponse( + upstreamResp: Response, + model: string, + log: + | { warn?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } + | null + | undefined, + tlsBody: ReadableStream | null | undefined +): Promise { + const src = tlsBody ?? upstreamResp.body; + if (!src) { + return new Response( + JSON.stringify({ + error: { + message: "No upstream response body available", + type: "upstream_error", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + } + + let finished = false; + + const transformed = new ReadableStream({ + async start(controller) { + const reader = src!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // SSE lines are separated by \n or \r\n; Claude sends + // data: {...}\n\n (double newline separating events). + const lines = buffer.split("\n"); + // Keep the last potentially-incomplete line in the buffer. + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data: ")) continue; + + const jsonStr = trimmed.slice(6); // strip "data: " + try { + const parsed = JSON.parse(jsonStr) as Record; + + // Content block delta — contains the actual text. + if (parsed.type === "content_block_delta") { + const delta = parsed.delta as Record | undefined; + const text = delta?.text as string | undefined; + if (text) { + const chunk = transformFromClaude(text, model); + const out = `data: ${JSON.stringify(chunk)}\n\n`; + controller.enqueue(new TextEncoder().encode(out)); + } + } + // message_stop — final event from Claude. + else if (parsed.type === "message_stop") { + const chunk = transformFromClaude("", model, "end_turn"); + const out = `data: ${JSON.stringify(chunk)}\n\n`; + controller.enqueue(new TextEncoder().encode(out)); + finished = true; + } + // message_delta — may carry a stop_reason. + else if (parsed.type === "message_delta") { + const delta = parsed.delta as Record | undefined; + const stopReason = delta?.stop_reason as string | undefined; + if (stopReason) { + const chunk = transformFromClaude("", model, stopReason); + const out = `data: ${JSON.stringify(chunk)}\n\n`; + controller.enqueue(new TextEncoder().encode(out)); + } + } + } catch { + // Skip lines that aren't valid JSON (metadata, ping, etc.) + } + } + } + + // Flush remaining buffer. + if (buffer.trim()) { + const trimmed = buffer.trim(); + if (trimmed.startsWith("data: ")) { + const jsonStr = trimmed.slice(6); + try { + const parsed = JSON.parse(jsonStr) as Record; + if (parsed.type === "content_block_delta") { + const delta = parsed.delta as Record | undefined; + const text = delta?.text as string | undefined; + if (text) { + const chunk = transformFromClaude(text, model); + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`) + ); + } + } + } catch { + /* skip */ + } + } + } + + // Send terminal DONE marker if we haven't sent a message_stop. + if (!finished) { + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + } + } catch (err) { + log?.error?.("CLAUDE-WEB-STREAM", `Stream error: ${String(err)}`); + controller.error(err); + } finally { + try { + reader.releaseLock(); + } catch { + /* ok */ + } + try { + controller.close(); + } catch {} + } + }, + }); + + return new Response(transformed, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + // ─── Main Executor Class ──────────────────────────────────────────────────── export class ClaudeWebExecutor extends BaseExecutor { @@ -402,13 +645,15 @@ export class ClaudeWebExecutor extends BaseExecutor { signal?: AbortSignal ): Promise { try { - const rawCookie = String((credentials as any)?.cookie || ""); + const rawCookie = readClaudeWebCookie(credentials); if (!rawCookie.trim()) { return false; } - const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { allowAutoSolve: false }); - const deviceId = (credentials as any)?.deviceId as string | undefined; + const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { + allowAutoSolve: false, + }); + const deviceId = readClaudeWebDeviceId(credentials); return await verifyCookieValidity(cookieHeader, deviceId, signal); } catch (error) { @@ -446,7 +691,7 @@ export class ClaudeWebExecutor extends BaseExecutor { }; } - const rawCookie = String((credentials as any)?.cookie || ""); + const rawCookie = readClaudeWebCookie(credentials); if (!rawCookie.trim()) { const errorResp = new Response( JSON.stringify({ @@ -473,7 +718,7 @@ export class ClaudeWebExecutor extends BaseExecutor { allowAutoSolve: true, log, }); - const deviceId = (credentials as any)?.deviceId as string | undefined; + const deviceId = readClaudeWebDeviceId(credentials); // Transform request to Claude format let claudePayload: ClaudeWebRequestPayload; @@ -520,15 +765,82 @@ export class ClaudeWebExecutor extends BaseExecutor { conversationId = randomUUID(); } + // Prepare browser-emulated headers (used by both paths) + const headers = getBrowserHeaders(deviceId); + + // Browser-backed path: opt-in via OMNIROUTE_BROWSER_POOL=on or + // WEB_COOKIE_USE_BROWSER=1. Routes the chat through a shared + // Playwright/Cloakbrowser page with the user's session cookies + // injected, so Claude's Cloudflare Turnstile / session fingerprint + // checks are satisfied by a real browser context. This is the + // only way to get HTTP 200 from a sandbox/VPS IP where the + // pasted cf_clearance is bound to a different fingerprint and + // Cloudflare refuses the request. The browser's fetch hits the + // /completion endpoint directly (the conversation id is created + // by the page itself), so the placeholder in the matcher is + // harmless. + if (shouldUseBrowserBacked()) { + const userText = extractLastUserText(bodyObj); + const completionUrl = orgId + ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/PLACEHOLDER/completion` + : `${CLAUDE_WEB_API_BASE}/chat_conversations/PLACEHOLDER/completion`; + const result = await tryBackedChat({ + poolKey: "claude-web", + chatPageUrl: "https://claude.ai/new", + chatUrl: completionUrl, + chatUrlMatchDomain: "claude.ai", + cookieString: rawCookie, + cookieDomain: ".claude.ai", + userMessage: userText, + inputSelector: "div[contenteditable='true']", + postSubmitWaitMs: 15000, + signal: signal ?? null, + }); + if (result.status > 0) { + // Wrap captured SSE body as a Response so the existing + // stream parser (transformFromClaude) can be reused. + const upstreamResp = new Response(result.body, { + status: result.status, + headers: { + "Content-Type": result.contentType || "text/event-stream", + }, + }); + // Reuse the streaming transformer from below. + return { + response: await buildClaudeStreamingResponse(upstreamResp, model, log, null), + url: completionUrl, + headers, + transformedBody: claudePayload, + }; + } + const errorResp = new Response( + JSON.stringify({ + error: { + message: `Claude Web browser-backed chat captured no upstream response (timing: ${JSON.stringify( + result.timing + )})`, + type: "upstream_error", + }, + }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + return { + response: errorResp, + url: completionUrl, + headers, + transformedBody: claudePayload, + }; + } + // Build completion URL const completionUrl = orgId && conversationId ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/${conversationId}/completion` : `${CLAUDE_WEB_API_BASE}/chat_conversations/new/completion`; - // Prepare headers - const headers = getBrowserHeaders(deviceId); - // Prepare request const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; @@ -597,12 +909,101 @@ export class ClaudeWebExecutor extends BaseExecutor { }; } - const errorText = fetchResponse.text || ""; + // Read the upstream body. `tlsFetchClaude` writes non-SSE error + // bodies (Cloudflare challenge pages, HTML rate-limit responses, + // Anthropic JSON errors) into `body`, not `text` — `text` is only + // populated for non-streaming requests. Reading the wrong field + // here produced the cryptic "[403]: Claude Web API error: " with + // no diagnostic info. The earlier code did exactly that and made + // 403 from Cloudflare look indistinguishable from a real Claude + // rejection. + // + // `body` may be a ReadableStream (SSE path) even when status is + // non-2xx — drain a small prefix so we can classify the failure + // (Cloudflare challenge vs upstream JSON) without buffering the + // whole response. If the read fails or times out, fall back to an + // empty string and let the generic error message stand. + let errorText = ""; + let cfMitigated: string | null = null; + try { + if (fetchResponse.body) { + const reader = fetchResponse.body.getReader(); + const decoder = new TextDecoder(); + const chunks: Uint8Array[] = []; + let total = 0; + const maxBytes = 2048; // enough to identify a Cloudflare challenge + while (total < maxBytes) { + const { value, done } = await reader.read(); + if (done || !value) break; + chunks.push(value); + total += value.byteLength; + } + // Release the reader so the underlying tls-client connection + // can be reused for the next request. Without cancel() the + // connection can hang on a still-open stream. + try { + reader.releaseLock(); + } catch { + /* already released */ + } + if (chunks.length === 1) { + errorText = decoder.decode(chunks[0]); + } else { + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + errorText = decoder.decode(combined); + } + } else if (fetchResponse.text) { + errorText = fetchResponse.text; + } + } catch { + errorText = ""; + } + + // Surface Cloudflare challenge pages as a distinct, actionable + // error so dashboards / logs don't show an empty "Claude Web API + // error:" body. The most common cause is a sandbox / data-center + // IP that Cloudflare has flagged; the cf_clearance cookie bound + // to a different IP won't pass the challenge. + cfMitigated = fetchResponse.headers.get("cf-mitigated"); + const isCloudflareChallenge = + fetchResponse.status === 403 && + (cfMitigated === "challenge" || + /\s*Just a moment/i.test(errorText) || + /<title>\s*Attention Required/i.test(errorText)); + + let errorMessage: string; + if (isCloudflareChallenge) { + errorMessage = + "Claude Web returned a Cloudflare bot-management challenge " + + `(cf-mitigated=${cfMitigated ?? "challenge"}). ` + + "The sandbox / VPS IP appears to be flagged; the cf_clearance " + + "cookie pasted from a residential IP won't pass. Probe from a " + + "residential network, or use the official Anthropic API " + + "(provider: 'claude') instead."; + } else { + // Trim the body to keep the error message compact but still + // useful — most real Claude errors are short JSON; Cloudflare + // HTML bodies are caught above. + const trimmed = errorText.trim().slice(0, 500); + errorMessage = trimmed + ? `Claude Web API error (${fetchResponse.status}): ${trimmed}` + : `Claude Web API error (${fetchResponse.status}) with no response body`; + } + const errorResp = new Response( JSON.stringify({ error: { - message: `Claude Web API error: ${errorText}`, - type: "api_error", + message: errorMessage, + type: isCloudflareChallenge ? "cloudflare_challenge" : "api_error", + code: isCloudflareChallenge + ? "cf_mitigated_challenge" + : `HTTP_${fetchResponse.status}`, + ...(cfMitigated ? { cfMitigated } : {}), }, }), { @@ -619,110 +1020,9 @@ export class ClaudeWebExecutor extends BaseExecutor { }; } - // Stream the response - const responseStream = new ReadableStream( - { - async start(controller) { - try { - const reader = fetchResponse.body?.getReader(); - if (!reader) { - controller.error(new Error("No response body")); - return; - } - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed === "[DONE]") continue; - - if (trimmed.startsWith("data: ")) { - const jsonStr = trimmed.slice(6); // Remove "data: " prefix - try { - const chunk = JSON.parse(jsonStr) as ClaudeWebStreamChunk; - - // Extract completion text from various possible formats - let completionText = ""; - if (chunk.completion) { - completionText = chunk.completion; - } else if (chunk.delta?.text) { - completionText = chunk.delta.text; - } - - if (completionText) { - const openaiChunk = transformFromClaude( - completionText, - model, - chunk.stop_reason - ); - const sseContent = `data: ${JSON.stringify(openaiChunk)}\n\n`; - controller.enqueue(new TextEncoder().encode(sseContent)); - } - } catch (parseError) { - log?.warn?.( - "CLAUDE-WEB", - `Failed to parse stream chunk: ${JSON.stringify({ line: trimmed })}` - ); - } - } - } - } - - // Finish the stream - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - logprobs: null, - }, - ], - }; - controller.enqueue( - new TextEncoder().encode(`data: ${JSON.stringify(finalChunk)}\n\n`) - ); - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); - controller.close(); - } catch (error) { - log?.error?.( - "CLAUDE-WEB", - `Stream error: ${error instanceof Error ? error.message : String(error)}` - ); - controller.error(error); - } - }, - }, - { highWaterMark: 16384 } - ); - - const finalResponse = new Response(responseStream, { - status: 200, - statusText: "OK", - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - + // Stream the response (shared with the browser-backed path). return { - response: finalResponse, + response: await buildClaudeStreamingResponse(fetchResponse, model, log, fetchResponse.body), url: completionUrl, headers, transformedBody: claudePayload, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index d7f114570d..1058b6054a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -29,9 +29,52 @@ import { buildOciChatUrl } from "../config/oci.ts"; import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts"; import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; +import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import type { PoolConfig } from "../services/sessionPool/types.ts"; +/** + * Apply operator-configured per-provider custom headers onto an outgoing header + * map. Defense-in-depth on top of the Zod `customHeadersSchema`: + * - skip hop-by-hop/framing AND auth header names (canonical denylist, so a row + * written before the schema tightening still can't override credential auth); + * - skip control-char (CR/LF/NUL) names/values before they reach undici; + * - assign case-insensitively, replacing any existing same-named header (e.g. + * the executor's own Content-Type/Accept) instead of emitting a duplicate. + * Used for every *-compatible node, INCLUDING anthropic-compatible-cc-* (whose + * header builder returns early, so custom headers must be merged in explicitly). + */ +function applyCustomHeaders(headers: Record<string, string>, rawCustomHeaders: unknown): void { + let customHeaders: Record<string, unknown> | null = null; + if ( + rawCustomHeaders && + typeof rawCustomHeaders === "object" && + !Array.isArray(rawCustomHeaders) + ) { + customHeaders = rawCustomHeaders as Record<string, unknown>; + } else if (typeof rawCustomHeaders === "string") { + try { + const parsed = JSON.parse(rawCustomHeaders); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + customHeaders = parsed as Record<string, unknown>; + } + } catch { + /* ignore invalid JSON */ + } + } + if (!customHeaders) return; + for (const [k, v] of Object.entries(customHeaders)) { + if (typeof k !== "string" || typeof v !== "string") continue; + if (isForbiddenCustomHeaderName(k)) continue; + if (/[\r\n\0]/.test(k) || /[\r\n]/.test(v)) continue; + const lower = k.toLowerCase(); + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === lower) delete headers[existing]; + } + headers[k] = v; + } +} + function normalizeBaseUrl(baseUrl) { return (baseUrl || "").trim().replace(/\/$/, ""); } @@ -375,11 +418,15 @@ export class DefaultExecutor extends BaseExecutor { break; default: if (isClaudeCodeCompatible(this.provider)) { - return buildClaudeCodeCompatibleHeaders( + const ccHeaders = buildClaudeCodeCompatibleHeaders( effectiveKey || credentials.accessToken || "", stream, credentials?.providerSpecificData?.ccSessionId ); + // CC nodes are also anthropic-compatible-*, so honor operator custom + // headers here (the early return skips the shared block below). + applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders); + return ccHeaders; } if (this.provider?.startsWith?.("anthropic-compatible-")) { if (effectiveKey) { @@ -419,6 +466,14 @@ export class DefaultExecutor extends BaseExecutor { } } + const isCompatibleProvider = + this.provider?.startsWith?.("openai-compatible-") || + this.provider?.startsWith?.("anthropic-compatible-"); + + if (isCompatibleProvider) { + applyCustomHeaders(headers, credentials.providerSpecificData?.customHeaders); + } + // Forward client request metadata headers (from OpenCode or similar clients) // Allowlist-based: only specific x-opencode-* headers and User-Agent are forwarded if (clientHeaders) { diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 0986753db3..ea2fcc034e 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -1,21 +1,390 @@ +import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; +import vm from "node:vm"; +import { parseFragment, serialize } from "parse5"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import type { Session } from "../services/sessionPool/session.ts"; +import { tryBackedChat } from "../services/browserBackedChat.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; export const DUCKDUCKGO_BASE = "https://duckduckgo.com"; -const STATUS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/status`; -const CHAT_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/chat`; +const DUCKAI_BASE = "https://duck.ai"; +const AUTH_TOKEN_URL = `${DUCKAI_BASE}/duckchat/v1/auth/token`; +const COUNTRY_URL = `${DUCKAI_BASE}/country.json`; +const STATUS_URL = `${DUCKAI_BASE}/duckchat/v1/status`; +const CHAT_URL = `${DUCKAI_BASE}/duckchat/v1/chat`; +const DEFAULT_FE_VERSION = "serp_20260424_180649_ET-0bdc33b2a02ebf8f235def65d887787f694720a1"; +const FE_VERSION_PATTERN = /serp_\d{8}_\d{6}_[A-Z]{2}-[0-9a-f]{40}/; +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; const FAKE_HEADERS: Record<string, string> = { Accept: "*/*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", - Origin: DUCKDUCKGO_BASE, - Referer: `${DUCKDUCKGO_BASE}/`, - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Cache-Control": "no-cache", + Origin: DUCKAI_BASE, + Pragma: "no-cache", + Referer: `${DUCKAI_BASE}/`, + Priority: "u=1, i", + "Sec-Ch-Ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Linux"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": DEFAULT_USER_AGENT, }; +const SEEDED_COOKIES: ReadonlyArray<readonly [string, string]> = [ + ["5", "1"], + ["ah", "wt-wt"], + ["dcs", "1"], + ["dcm", "3"], + ["isRecentChatOn", "1"], +]; + +function shouldUseBrowserBacked(): 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"; +} + +interface DuckDuckGoVqdHeaders { + vqd4: string | null; + vqdHash1: string | null; +} + +interface DuckDuckGoAuthHeaders { + vqd4: string | null; + vqdHash1: string | null; +} + +interface DuckDuckGoModelCapabilities { + reasoningEffort: string | null; +} + +type DuckDuckGoChallengeResult = { + client_hashes?: unknown; + [key: string]: unknown; +}; + +let durablePublicKey: JsonWebKey | null = null; + +const CHALLENGE_STUBS = String.raw` +var __ua = __DDG_REAL_UA__; +var __HTML_LOOKUP = __DDG_HTML_LOOKUP__; +function __makeHtmlElement(tag) { + var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' }; + var el = { + tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1, + children: [], childNodes: [], classList: [], dataset: {}, + offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1, + getBoundingClientRect: function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }, + setAttribute: function(){}, removeAttribute: function(){}, + getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; }, + hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; }, + addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; }, + querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; }, + cloneNode: function(){ return __makeHtmlElement(tag); } + }; + Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true }); + Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true }); + Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + '</' + tag + '>'; }, enumerable: true }); + Object.defineProperty(el, 'srcdoc', { get: function(){ return state._srcdoc||''; }, set: function(v){ state._srcdoc = String(v); }, enumerable: true }); + Object.defineProperty(el, 'contentWindow', { get: function(){ var w = {}; w.document = __ifDoc; w.Proxy = Proxy; w.self = w; w.top = w; w.parent = w; w.window = w; return w; }, enumerable: true }); + Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true }); + return el; +} +function __mkObj(name, base) { + base = base || {}; + return new Proxy(base, { + get: function(t, k) { + if (k in t) return t[k]; + if (k === Symbol.toPrimitive) return function(){ return ''; }; + if (k === Symbol.iterator) return undefined; + if (k === 'then' || k === 'catch' || k === 'finally') return undefined; + if (k === 'constructor') return Object; + if (k === 'toString' || k === 'valueOf') return function(){ return '[object ' + name + ']'; }; + if (k === 'length') return 0; + if (k === 'nodeType') return 1; + if (k === 'tagName' || k === 'nodeName') return 'DIV'; + if (k === 'innerHTML' || k === 'outerHTML' || k === 'textContent' || k === 'innerText' || k === 'value') return ''; + if (k === 'children' || k === 'childNodes' || k === 'classList') return []; + // Real numeric layout values for the DDG challenge DOM probes. + if (k === 'offsetWidth' || k === 'offsetHeight' || k === 'clientWidth' || k === 'clientHeight' || k === 'scrollHeight' || k === 'scrollWidth') return 1; + if (k === 'getBoundingClientRect') return function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }; + if (typeof k === 'string' && (k.indexOf('get') === 0 || k.indexOf('query') === 0 || k.indexOf('find') === 0)) return function(){ return k === 'querySelectorAll' || k === 'getElementsByTagName' || k === 'getElementsByClassName' ? [] : null; }; + return function(){ return __mkObj(name + '.' + String(k)); }; + }, + has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; } + }); +} +function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } +function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } +var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' }); +var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' }); +var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' }); +var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); + var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } }); +window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; +var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history; +var __R = null, __E = null; +function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } +var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); +var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); }; +var getComputedStyle = __getComputedStyle; +`; + +function extractDuckDuckGoContent(data: unknown): string { + if (!data || typeof data !== "object") return ""; + const record = data as Record<string, unknown>; + const content = record.content; + if (typeof content === "string") return content; + const message = record.message; + if (typeof message === "string") return message; + return ""; +} + +function parseDuckDuckGoDataLine(line: string): unknown | null { + if (!line.startsWith("data: ")) return null; + try { + return JSON.parse(line.slice(6)); + } catch (error) { + void error; + return null; + } +} + +function parseDuckDuckGoError(body: string): { type?: unknown; overrideCode?: unknown } | null { + try { + return JSON.parse(body) as { type?: unknown; overrideCode?: unknown }; + } catch (error) { + void error; + return null; + } +} + +function splitSetCookieHeader(header: string): string[] { + const cookies: string[] = []; + let start = 0; + for (let index = 0; index < header.length; index++) { + if (header[index] !== ",") continue; + const rest = header.slice(index + 1); + if (/^\s*[^=;\s]+\s*=/.test(rest)) { + cookies.push(header.slice(start, index).trim()); + start = index + 1; + } + } + cookies.push(header.slice(start).trim()); + return cookies.filter(Boolean); +} + +function collectSetCookieHeaders(headers: Headers): string[] { + const getSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie; + if (typeof getSetCookie === "function") return getSetCookie.call(headers); + const combined = headers.get("set-cookie"); + return combined ? splitSetCookieHeader(combined) : []; +} + +function applySetCookie(cookieJar: Map<string, string>, setCookie: string): void { + const pair = setCookie.split(";", 1)[0]?.trim(); + if (!pair) return; + const separator = pair.indexOf("="); + if (separator <= 0) return; + cookieJar.set(pair.slice(0, separator), pair.slice(separator + 1)); +} + +function serializeCookieJar(cookieJar: Map<string, string>): string { + return Array.from(cookieJar.entries()) + .map(([name, value]) => `${name}=${value}`) + .join("; "); +} + +function mergeHeadersCaseInsensitive( + ...sources: Array<Record<string, string> | undefined> +): Record<string, string> { + const merged: Record<string, string> = {}; + const canonicalNames = new Map<string, string>(); + for (const source of sources) { + if (!source) continue; + for (const [name, value] of Object.entries(source)) { + const lowerName = name.toLowerCase(); + const previousName = canonicalNames.get(lowerName); + if (previousName) delete merged[previousName]; + canonicalNames.set(lowerName, name); + merged[name] = value; + } + } + return merged; +} + +function normalizeDuckDuckGoModel(model: string | undefined): string { + if (!model) return "gpt-4o-mini"; + const clean = model.startsWith("duckduckgo-web/") ? model.slice("duckduckgo-web/".length) : model; + if (clean === "claude-3-5-haiku-20241022") return "claude-haiku-4-5"; + if (clean === "llama-4-scout") return "meta-llama/Llama-4-Scout-17B-16E-Instruct"; + if (clean === "mistral-small-2501") return "mistral-small-2603"; + if (clean === "gpt-oss-120b") return "tinfoil/gpt-oss-120b"; + return clean; +} + +function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities { + if (model === "gpt-5-mini") return { reasoningEffort: "minimal" }; + if (model === "claude-haiku-4-5") return { reasoningEffort: "low" }; + if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" }; + return { reasoningEffort: null }; +} + +function countHtmlElements(node: unknown): number { + if (!node || typeof node !== "object") return 0; + const record = node as { nodeName?: string; childNodes?: unknown[] }; + const own = record.nodeName && record.nodeName !== "#document-fragment" ? 1 : 0; + let childCount = 0; + for (const child of record.childNodes ?? []) { + childCount += countHtmlElements(child); + } + return own + childCount; +} + +function buildHtmlLookup(js: string): Record<string, { html: string; count: number }> { + const lookup: Record<string, { html: string; count: number }> = {}; + const seen = new Set<string>(); + const pattern = /(['"])(<[^'"]{1,400}?)\1/g; + for (const match of js.matchAll(pattern)) { + const html = match[2]; + if (seen.has(html)) continue; + seen.add(html); + const fragment = parseFragment(html); + lookup[html] = { + html: serialize(fragment), + count: Math.max(0, countHtmlElements(fragment) - 1), + }; + } + return lookup; +} + +function sha256Base64(value: string): string { + return createHash("sha256").update(value, "utf8").digest("base64"); +} + +async function solveDuckDuckGoChallenge(challenge: string, userAgent: string): Promise<string> { + // SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext. + // The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout + // to limit DoS risk. This is intentional for the DDG challenge solver to work. + const js = Buffer.from(challenge, "base64").toString("utf8"); + const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(userAgent)).replace( + "__DDG_HTML_LOOKUP__", + JSON.stringify(buildHtmlLookup(js)) + ); + const context = vm.createContext({}); + vm.runInContext(stubs, context, { timeout: 5000 }); + const result = (await vm.runInContext(js, context, { + timeout: 5000, + })) as DuckDuckGoChallengeResult; + const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : []; + if (clientHashes.length === 0) + throw new Error("DuckDuckGo challenge returned empty client_hashes"); + clientHashes[0] = userAgent; + result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash))); + return Buffer.from(JSON.stringify(result), "utf8").toString("base64"); +} + +function makeDuckDuckGoFeSignals(): string { + const start = Date.now() - 3000; + let delta = 80 + Math.floor(Math.random() * 101); + const events: Array<Record<string, unknown>> = [{ name: "onboarding_impression_1", delta }]; + delta += 120 + Math.floor(Math.random() * 141); + events.push({ name: "onboarding_impression_2", delta }); + delta += 200 + Math.floor(Math.random() * 301); + events.push({ name: "startNewChat", delta }); + const keyEvents = 6 + Math.floor(Math.random() * 13); + for (let i = 0; i < keyEvents; i++) { + delta += 40 + Math.floor(Math.random() * 141); + events.push({ name: "user_input", delta }); + } + delta += 120 + Math.floor(Math.random() * 231); + events.push({ name: "user_submit", delta }); + const payload = { + start, + events, + end: Math.max(delta + 20 + Math.floor(Math.random() * 71), 3000), + }; + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} + +function extractDuckDuckGoFeVersion(html: string): string | null { + return html.match(FE_VERSION_PATTERN)?.[0] ?? null; +} + +function getDurablePublicKey(): JsonWebKey { + if (!durablePublicKey) { + const { publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicExponent: 0x10001, + }); + durablePublicKey = { + ...publicKey.export({ format: "jwk" }), + alg: "RSA-OAEP-256", + ext: true, + key_ops: ["encrypt"], + use: "enc", + }; + } + return durablePublicKey; +} + +function buildDuckDuckGoPayload( + model: string, + messages: Array<Record<string, unknown>>, + canUseTools = true +): Record<string, unknown> { + const capabilities = getDuckDuckGoModelCapabilities(model); + const payload: Record<string, unknown> = { + model, + metadata: { + toolChoice: { + NewsSearch: false, + VideosSearch: false, + LocalSearch: false, + WeatherForecast: false, + }, + }, + messages, + canUseTools, + ...(capabilities.reasoningEffort ? { reasoningEffort: capabilities.reasoningEffort } : {}), + canUseApproxLocation: null, + canDelegateImageGeneration: null, + durableStream: { + messageId: randomUUID(), + conversationId: randomUUID(), + publicKey: getDurablePublicKey(), + }, + }; + return payload; +} + +function normalizeDuckDuckGoError(status: number, body: string): string { + const parsed = parseDuckDuckGoError(body); + if (parsed) { + const type = typeof parsed.type === "string" ? parsed.type : ""; + const overrideCode = typeof parsed.overrideCode === "string" ? parsed.overrideCode : ""; + if (type === "ERR_CHALLENGE" || type === "ERR_BN_LIMIT") { + const codeSuffix = overrideCode ? ` (${overrideCode})` : ""; + return ( + `DuckDuckGo AI Chat anti-abuse challenge failed: ${type}${codeSuffix}. ` + + "Retry later or from a less rate-limited IP; DuckDuckGo is rejecting this anonymous session." + ); + } + if (type) return `DuckDuckGo AI Chat error: ${type}`; + } + + return `DuckDuckGo AI Chat returned HTTP ${status}`; +} + /** * DuckDuckGoWebExecutor handles anonymous, free access to DuckDuckGo AI Chat. * @@ -41,7 +410,49 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { super("duckduckgo-web", { baseUrl: DUCKDUCKGO_BASE }); } - async testConnection(credentials: Record<string, unknown>, signal?: AbortSignal): Promise<boolean> { + private warmed = false; + private seeded = false; + private feVersion = DEFAULT_FE_VERSION; + private pendingVqdHash1: string | null = null; + private readonly cookieJar = new Map<string, string>(); + + private buildRequestHeaders(extra: Record<string, string> = {}): Record<string, string> { + const headers = { ...FAKE_HEADERS, ...extra }; + const cookie = serializeCookieJar(this.cookieJar); + return cookie ? { ...headers, Cookie: cookie } : headers; + } + + private rememberResponseCookies(response: Response): void { + for (const cookie of collectSetCookieHeaders(response.headers)) { + applySetCookie(this.cookieJar, cookie); + } + } + + private seedBrowserCookies(): void { + for (const [name, value] of SEEDED_COOKIES) { + if (!this.cookieJar.has(name)) this.cookieJar.set(name, value); + } + } + + private async warmFetch( + url: string, + headers: Record<string, string>, + signal: AbortSignal + ): Promise<Response | null> { + try { + const response = await fetch(url, { headers, signal }); + this.rememberResponseCookies(response); + return response; + } catch (error) { + void error; + return null; + } + } + + async testConnection( + _credentials: Record<string, unknown>, + signal?: AbortSignal + ): Promise<boolean> { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -52,42 +463,95 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { const resp = await fetch(STATUS_URL, { method: "GET", - headers: { ...FAKE_HEADERS, Accept: "text/event-stream" }, + headers: this.buildRequestHeaders({ + Accept: "*/*", + "Cache-Control": "no-store", + "x-vqd-accept": "1", + }), signal: mergedSignal, }); + this.rememberResponseCookies(resp); clearTimeout(timeout); - return resp.ok && resp.headers.get("x-vqd-hash-1") !== null; + return ( + resp.ok && + (resp.headers.get("x-vqd-4") !== null || resp.headers.get("x-vqd-hash-1") !== null) + ); } catch { return false; } } - async execute(input: ExecuteInput) { - const { model, body, stream, signal } = input; + async execute(input: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record<string, string>; + transformedBody: unknown; + }> { + const { model, body, stream, signal, upstreamExtraHeaders } = input; + const upstreamModel = normalizeDuckDuckGoModel(model); const bodyObj = (body || {}) as Record<string, unknown>; - const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; + const rawMessages = Array.isArray((body as { messages?: unknown[] } | null)?.messages) + ? ((body as { messages: unknown[] }).messages as Array<Record<string, unknown>>) + : []; + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages + ); + const messages = effectiveMessages as Array<Record<string, unknown>>; + const isStreaming = stream !== false; + const upstreamHeaders = upstreamExtraHeaders || {}; - if (signal?.aborted) { - return new Response( - JSON.stringify({ error: { message: "Request cancelled" } }), - { status: 499, headers: { "Content-Type": "application/json" } } - ); + const errorResponse = (status: number, message: string): Response => + new Response(JSON.stringify({ error: { message } }), { + status, + headers: { "Content-Type": "application/json" }, + }); + + if (messages.length === 0) { + return errorResponse(400, "No messages provided"); } - if (!messages || messages.length === 0) { - return new Response( - JSON.stringify({ error: { message: "No messages provided" } }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); + // Browser-backed path: opt-in via OMNIROUTE_BROWSER_POOL=on or + // WEB_COOKIE_USE_BROWSER=1. Routes the chat through a shared + // Playwright/Cloakbrowser page so DDG's VQD challenge is solved by + // a real browser. Latency is dominated by page navigation + AI wait + // (~10-25s), but it's the only way to get HTTP 200 from this + // environment once the Node vm solver hits its anti-bot ceiling. + if (shouldUseBrowserBacked()) { + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const userText = extractDuckDuckGoContent(lastUser ?? { content: "" }); + const result = await tryBackedChat({ + poolKey: "duckduckgo-web", + chatPageUrl: "https://duck.ai/chat", + chatUrl: CHAT_URL, + chatUrlMatchDomain: "duck.ai", + userMessage: userText || "Reply with OK", + inputSelector: "textarea", + submitButtonSelector: "button[aria-label='Ask']", + signal: signal ?? null, + postSubmitWaitMs: 15000, + }); + if (result.status > 0) { + // Wrap the captured body as a Response so processResponse + // (already a streaming/non-streaming transformer) can be + // reused unchanged. + const upstreamResp = new Response(result.body, { + status: result.status, + headers: { + "Content-Type": result.contentType || "text/event-stream", + }, + }); + return await this.processResponse(upstreamResp, isStreaming, hasTools, requestedTools); + } + // status 0 means no response captured (selector/navigation error). + return errorResponse(502, "Browser-backed chat captured no upstream response"); } - const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages); - // Acquire session from pool for fingerprint rotation const pool = this.getPool(); - let session; + let session: Session | null; try { session = pool ? await pool.acquireBlocking(10_000) : null; } catch { @@ -102,84 +566,83 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { ? AbortSignal.any([signal, controller.signal]) : controller.signal; + const sendChat = async (vqdHeaders: DuckDuckGoAuthHeaders): Promise<Response> => { + const payload = buildDuckDuckGoPayload(upstreamModel, messages); + const response = await fetch(CHAT_URL, { + method: "POST", + headers: mergeHeadersCaseInsensitive( + sessionHeaders, + this.buildRequestHeaders(), + upstreamHeaders, + { + Accept: "text/event-stream", + "Content-Type": "application/json", + "x-ddg-journey-id": randomUUID().replaceAll("-", ""), + "x-fe-signals": makeDuckDuckGoFeSignals(), + "x-fe-version": this.feVersion, + ...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}), + ...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}), + } + ), + body: JSON.stringify(payload), + signal: mergedSignal, + }); + this.rememberResponseCookies(response); + this.rememberChallengeHeader(response); + return response; + }; + if (mergedSignal.aborted) { clearTimeout(timeout); - return new Response( - JSON.stringify({ error: { message: "Request cancelled" } }), - { status: 499, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(499, "Request cancelled"); } - const vqdToken = await this.acquireVqdHash(mergedSignal); - if (!vqdToken) { + await this.warmSession(mergedSignal); + await this.seedChallengeChain(upstreamModel, mergedSignal); + const vqdHeaders = await this.acquireAuthHeaders(mergedSignal); + if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { clearTimeout(timeout); - return new Response( - JSON.stringify({ error: { message: "Failed to acquire VQD token" } }), - { status: 503, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(503, "Failed to acquire VQD token"); } - const chatResponse = await fetch(CHAT_URL, { - method: "POST", - headers: { - ...FAKE_HEADERS, - ...sessionHeaders, - "Content-Type": "application/json", - "x-vqd-hash-1": vqdToken, - }, - body: JSON.stringify({ - model, - messages: effectiveMessages, - stream: stream !== false, - }), - signal: mergedSignal, - }); + let chatResponse = await sendChat(vqdHeaders); + + if (chatResponse.status === 418) { + this.pendingVqdHash1 = null; + const freshVqd = await this.acquireAuthHeaders(mergedSignal); + if (freshVqd.vqd4 || freshVqd.vqdHash1) { + chatResponse = await sendChat(freshVqd); + } + } clearTimeout(timeout); if (chatResponse.status === 429) { if (pool && session) pool.reportCooldown(session); - return new Response( - JSON.stringify({ error: { message: "DuckDuckGo rate limited" } }), - { status: 429, headers: { "Content-Type": "application/json" } } - ); + return await this.processResponse(chatResponse, isStreaming, hasTools, requestedTools); } if (chatResponse.status === 401 || chatResponse.status === 403) { - const newVqd = await this.acquireVqdHash(mergedSignal); - if (newVqd) { - const retryResponse = await fetch(CHAT_URL, { - method: "POST", - headers: { - ...FAKE_HEADERS, - "Content-Type": "application/json", - "x-vqd-hash-1": newVqd, - }, - body: JSON.stringify({ - model, - messages: effectiveMessages, - stream: stream !== false, - }), - signal: mergedSignal, - }); - - return this.processResponse(retryResponse, stream !== false, hasTools, requestedTools); + this.pendingVqdHash1 = null; + const freshVqd = await this.acquireAuthHeaders(mergedSignal); + if (freshVqd.vqd4 || freshVqd.vqdHash1) { + const retryResponse = await sendChat(freshVqd); + return await this.processResponse(retryResponse, isStreaming, hasTools, requestedTools); } - return new Response( - JSON.stringify({ error: { message: "Service unavailable" } }), - { status: 503, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(503, "Service unavailable"); } if (chatResponse.status >= 500) { if (pool && session) pool.reportDead(session); - return new Response( - JSON.stringify({ error: { message: "Upstream error" } }), - { status: 502, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(502, "Upstream error"); } - const result = this.processResponse(chatResponse, stream !== false, hasTools, requestedTools); + const result = await this.processResponse( + chatResponse, + isStreaming, + hasTools, + requestedTools + ); // Report pool status based on response if (pool && session) { @@ -199,57 +662,179 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } if (error instanceof DOMException && error.name === "AbortError") { - return new Response( - JSON.stringify({ error: { message: "Request cancelled" } }), - { status: 499, headers: { "Content-Type": "application/json" } } - ); + return errorResponse(499, "Request cancelled"); } - return new Response( - JSON.stringify({ error: { message: error instanceof Error ? error.message : "Unknown error" } }), - { status: 500, headers: { "Content-Type": "application/json" } } + return errorResponse( + 500, + sanitizeErrorMessage(error instanceof Error ? error.message : "Unknown error") ); } finally { session?.release(); } } - private async acquireVqdHash(signal: AbortSignal): Promise<string | null> { + private async acquireVqdHeaders(signal: AbortSignal): Promise<DuckDuckGoVqdHeaders> { try { if (signal.aborted) throw new DOMException("Aborted", "AbortError"); - + const resp = await fetch(STATUS_URL, { method: "GET", - headers: { ...FAKE_HEADERS, Accept: "text/event-stream" }, + headers: this.buildRequestHeaders({ + Accept: "*/*", + "Cache-Control": "no-store", + "x-vqd-accept": "1", + }), signal, }); + this.rememberResponseCookies(resp); - if (!resp.ok) return null; - return resp.headers.get("x-vqd-hash-1"); + if (!resp.ok) return { vqd4: null, vqdHash1: null }; + return { + vqd4: resp.headers.get("x-vqd-4"), + vqdHash1: resp.headers.get("x-vqd-hash-1"), + }; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { throw error; } - return null; + return { vqd4: null, vqdHash1: null }; } } - private async processResponse(response: Response, streaming: boolean, hasTools?: boolean, requestedTools?: unknown): Promise<Response> { - if (!response.ok) { - const body = await response.text(); - return new Response(body, { - status: response.status, - headers: { "Content-Type": "application/json" }, - }); + private async acquireAuthHeaders(signal: AbortSignal): Promise<DuckDuckGoAuthHeaders> { + if (this.pendingVqdHash1) { + const challenge = this.pendingVqdHash1; + this.pendingVqdHash1 = null; + try { + return { + vqd4: null, + vqdHash1: await solveDuckDuckGoChallenge(challenge, FAKE_HEADERS["User-Agent"]), + }; + } catch (error) { + void error; + } } - if (streaming && !hasTools) { - const reader = response.body?.getReader(); - if (!reader) { - return new Response( - JSON.stringify({ error: { message: "No response body" } }), - { status: 500, headers: { "Content-Type": "application/json" } } - ); + const headers = await this.acquireVqdHeaders(signal); + if (headers.vqdHash1) { + try { + return { + vqd4: headers.vqd4, + vqdHash1: await solveDuckDuckGoChallenge(headers.vqdHash1, FAKE_HEADERS["User-Agent"]), + }; + } catch (error) { + void error; + return headers; + } + } + return headers; + } + + private rememberChallengeHeader(response: Response): void { + const nextHash = response.headers.get("x-vqd-hash-1"); + if (nextHash) this.pendingVqdHash1 = nextHash; + } + + private async warmSession(signal: AbortSignal): Promise<void> { + if (this.warmed || signal.aborted) return; + this.warmed = true; + this.seedBrowserCookies(); + const duckAiResponse = await this.warmFetch( + `${DUCKAI_BASE}/`, + this.buildRequestHeaders({ + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Upgrade-Insecure-Requests": "1", + }), + signal + ); + if (duckAiResponse) { + try { + const duckAiHtml = await duckAiResponse.clone().text(); + const feVersion = extractDuckDuckGoFeVersion(duckAiHtml); + if (feVersion) this.feVersion = feVersion; + } catch (error) { + void error; + } + } + await this.warmFetch(COUNTRY_URL, this.buildRequestHeaders({ Accept: "*/*" }), signal); + await this.warmFetch(AUTH_TOKEN_URL, this.buildRequestHeaders({ Accept: "*/*" }), signal); + await this.warmFetch( + `${DUCKDUCKGO_BASE}/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1`, + this.buildRequestHeaders({ + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Origin: DUCKDUCKGO_BASE, + Referer: `${DUCKDUCKGO_BASE}/`, + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Upgrade-Insecure-Requests": "1", + }), + signal + ); + } + + private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> { + if (this.seeded || signal.aborted) return; + this.seeded = true; + const seedMessages = [{ role: "user", content: "hi" }]; + const previousPending = this.pendingVqdHash1; + try { + const vqdHeaders = await this.acquireAuthHeaders(signal); + if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { + this.pendingVqdHash1 = previousPending; + return; + } + const response = await fetch(CHAT_URL, { + method: "POST", + headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), { + Accept: "text/event-stream", + "Content-Type": "application/json", + "x-ddg-journey-id": randomUUID().replaceAll("-", ""), + "x-fe-signals": makeDuckDuckGoFeSignals(), + "x-fe-version": this.feVersion, + ...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}), + ...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}), + }), + body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)), + signal, + }); + this.rememberResponseCookies(response); + if (response.ok) this.rememberChallengeHeader(response); + else this.pendingVqdHash1 = previousPending; + await response.body?.cancel().catch(() => {}); + } catch (error) { + void error; + this.pendingVqdHash1 = previousPending; + } + } + + private async processResponse( + response: Response, + streaming: boolean, + hasTools?: boolean, + requestedTools?: unknown + ): Promise<Response> { + if (!response.ok) { + const body = await response.text(); + return new Response( + JSON.stringify({ error: { message: normalizeDuckDuckGoError(response.status, body) } }), + { + status: response.status, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (streaming) { + if (!response.body) { + return new Response(JSON.stringify({ error: { message: "No response body" } }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } const transformStream = new TransformStream({ @@ -264,34 +849,25 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { continue; } - try { - if (line.startsWith("data: ")) { - const jsonStr = line.slice(6); - const data = JSON.parse(jsonStr); - - if (data.content) { - const openaiFormat = { - choices: [ - { - delta: { content: data.content }, - index: 0, - }, - ], - }; - const encoded = new TextEncoder().encode( - `data: ${JSON.stringify(openaiFormat)}\n\n` - ); - controller.enqueue(encoded); - } - } - } catch { - continue; + const data = parseDuckDuckGoDataLine(line); + const content = extractDuckDuckGoContent(data); + if (content) { + const openaiFormat = { + choices: [ + { + delta: { content }, + index: 0, + }, + ], + }; + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(openaiFormat)}\n\n`); + controller.enqueue(encoded); } } }, }); - const transformedBody = reader.pipeThrough(transformStream); + const transformedBody = response.body.pipeThrough(transformStream); return new Response(transformedBody, { headers: { "Content-Type": "text/event-stream" }, }); @@ -303,37 +879,32 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { for (const line of lines) { if (!line.trim() || line === "[DONE]") continue; - try { - if (line.startsWith("data: ")) { - const jsonStr = line.slice(6); - const data = JSON.parse(jsonStr); - if (data.content) { - fullContent += data.content; + fullContent += extractDuckDuckGoContent(parseDuckDuckGoDataLine(line)); + } + + const openaiResponse = hasTools + ? (() => { + const { content, toolCalls, finishReason } = buildToolAwareResult( + fullContent, + requestedTools, + "ddg" + ); + const message: Record<string, unknown> = { role: "assistant", content }; + if (toolCalls) { + message.tool_calls = toolCalls; + message.content = null; } - } - } catch { - continue; - } - } - - if (hasTools) { - const { content, toolCalls, finishReason } = buildToolAwareResult(fullContent, requestedTools, "ddg"); - const message: Record<string, unknown> = { role: "assistant", content }; - if (toolCalls) { message.tool_calls = toolCalls; message.content = null; } - return new Response(JSON.stringify({ choices: [{ index: 0, message, finish_reason: finishReason }] }), { - headers: { "Content-Type": "application/json" }, - }); - } - - const openaiResponse = { - choices: [ - { - message: { content: fullContent, role: "assistant" }, - index: 0, - finish_reason: "stop", - }, - ], - }; + return { choices: [{ index: 0, message, finish_reason: finishReason }] }; + })() + : { + choices: [ + { + message: { content: fullContent, role: "assistant" }, + index: 0, + finish_reason: "stop", + }, + ], + }; return new Response(JSON.stringify(openaiResponse), { headers: { "Content-Type": "application/json" }, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b8832a8b32..461ef128e1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -892,6 +892,27 @@ function getExecutorTimeoutMs(executor: unknown): number { } } +function normalizeExecutorResult( + result: + | Response + | { + response: Response; + url?: string; + headers?: Record<string, string>; + transformedBody?: unknown; + } +): { response: Response; url: string; headers: Record<string, string>; transformedBody: unknown } { + if (result instanceof Response) { + return { response: result, url: "", headers: {}, transformedBody: null }; + } + return { + response: result.response, + url: result.url || "", + headers: result.headers || {}, + transformedBody: result.transformedBody ?? null, + }; +} + async function executeWithUpstreamStartTimeout<T>({ executor, provider, @@ -1559,7 +1580,9 @@ export async function handleChatCore({ ), }; } - } catch { /* memoryUsage() never throws */ } + } catch { + /* memoryUsage() never throws */ + } // apiFormat is an optional custom-model marker injected by getModelInfo for // providers whose models can route to /chat/completions or /responses @@ -1598,8 +1621,7 @@ export async function handleChatCore({ comboName: comboName || undefined, }); }); - const traceEnabled = - process.env.OMNIRROUTE_TRACE === "true" || process.env.DEBUG === "true"; + const traceEnabled = process.env.OMNIRROUTE_TRACE === "true" || process.env.DEBUG === "true"; const trace = (label: string, extra?: Record<string, unknown>) => { if (!traceEnabled) return; const elapsed = Date.now() - startTime; @@ -1775,10 +1797,7 @@ export async function handleChatCore({ } }; - const persistCodexQuotaState = async ( - headers: Record<string, string> | null, - status = 0 - ) => { + const persistCodexQuotaState = async (headers: Record<string, string> | null, status = 0) => { if (provider !== "codex" || !connectionId || !headers) return; try { @@ -3627,14 +3646,19 @@ export async function handleChatCore({ let fallbackCodes: number[] = [429, 500, 502, 503, 504]; try { const allSettings = await getCachedSettings(); - if (typeof allSettings.cliproxyapi_fallback_codes === "string" && allSettings.cliproxyapi_fallback_codes.trim()) { + if ( + typeof allSettings.cliproxyapi_fallback_codes === "string" && + allSettings.cliproxyapi_fallback_codes.trim() + ) { const parsed = allSettings.cliproxyapi_fallback_codes .split(",") .map((s: string) => parseInt(s.trim(), 10)) .filter((n: number) => !isNaN(n)); if (parsed.length > 0) fallbackCodes = parsed; } - } catch { /* use defaults */ } + } catch { + /* use defaults */ + } const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0; const wrapper = Object.create(nativeExec); @@ -3710,10 +3734,10 @@ export async function handleChatCore({ if (decision.retryAfterSeconds) { headers["Retry-After"] = String(decision.retryAfterSeconds); } - return new Response( - JSON.stringify(buildErrorBody(429, decision.reason)), - { status: 429, headers } - ); + return new Response(JSON.stringify(buildErrorBody(429, decision.reason)), { + status: 429, + headers, + }); } if (decision.kind === "allow" && decision.deprioritize) { @@ -3961,13 +3985,7 @@ export async function handleChatCore({ stage: "sending_to_provider", }); const execCreds = getExecutionCredentials(); - const res = await executeWithUpstreamStartTimeout<{ - response: Response; - url: string; - headers: Record<string, string>; - transformedBody: unknown; - _executionCredentials?: unknown; - }>({ + const rawExecutorResult = await executeWithUpstreamStartTimeout<any>({ executor, provider, model: modelToCall, @@ -3988,6 +4006,7 @@ export async function handleChatCore({ skipUpstreamRetry, }), }); + const res = normalizeExecutorResult(rawExecutorResult); trace("post_executor", { status: res?.response?.status }); updatePendingRequest(model, provider, connectionId, { stage: "provider_response_started", @@ -4243,7 +4262,11 @@ export async function handleChatCore({ // ── Tier 2: Authoritative per-model/provider token-limit check (provider now resolved) ── if (apiKeyInfo?.id) { try { - const tokenBreach = checkTokenLimits(apiKeyInfo.id, provider || undefined, model || undefined); + const tokenBreach = checkTokenLimits( + apiKeyInfo.id, + provider || undefined, + model || undefined + ); if (tokenBreach) { const scopeLabel = tokenBreach.scopeType === "global" @@ -4311,7 +4334,11 @@ export async function handleChatCore({ // Store rate-limit headers for quota saturation signals try { const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals"); - storeRateLimitHeaders(connectionId, provider, providerResponse.headers as Record<string, string>); + storeRateLimitHeaders( + connectionId, + provider, + providerResponse.headers as Record<string, string> + ); } catch { // fail-open: saturation signal is best-effort } @@ -5244,7 +5271,8 @@ export async function handleChatCore({ if (apiKeyInfo?.id) { try { const billable = computeBillableTokens(usage); - if (billable > 0) recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); + if (billable > 0) + recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); } catch { // never block the response on counter recording } @@ -5467,8 +5495,8 @@ export async function handleChatCore({ cost: { tokens: usage && typeof usage === "object" - ? ((usage as Record<string, unknown>).prompt_tokens as number ?? 0) + - ((usage as Record<string, unknown>).completion_tokens as number ?? 0) + ? (((usage as Record<string, unknown>).prompt_tokens as number) ?? 0) + + (((usage as Record<string, unknown>).completion_tokens as number) ?? 0) : 0, usd: estimatedCost > 0 ? estimatedCost : 0, requests: 1, @@ -5701,7 +5729,8 @@ export async function handleChatCore({ if (apiKeyInfo?.id && streamStatus === 200) { try { const billable = computeBillableTokens(streamUsage); - if (billable > 0) recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); + if (billable > 0) + recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); } catch { // never block the stream on counter recording } @@ -5743,8 +5772,7 @@ export async function handleChatCore({ provider: provider ?? "unknown", cost: { tokens: su - ? (Number(su.prompt_tokens ?? 0) || 0) + - (Number(su.completion_tokens ?? 0) || 0) + ? (Number(su.prompt_tokens ?? 0) || 0) + (Number(su.completion_tokens ?? 0) || 0) : 0, usd: 0, // estimatedCost resolved async above; omit to avoid dependency requests: 1, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 80dec34e6e..3f4d3849c3 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -110,7 +110,8 @@ function findBalancedJsonEnd(text: string, startIndex: number): number { function stripInternalToolEnvelopeText(content: string): string { let sanitized = stripZeroWidthText(content); - const markerRegex = /to=(?:functions\.[A-Za-z0-9_.-]+|multi_tool_use\.[A-Za-z0-9_.-]+|[A-Za-z_][A-Za-z0-9_]*)/g; + const markerRegex = + /to=(?:functions\.[A-Za-z0-9_.-]+|multi_tool_use\.[A-Za-z0-9_.-]+|[A-Za-z_][A-Za-z0-9_]*)/g; while (true) { const match = markerRegex.exec(sanitized); @@ -170,7 +171,9 @@ function parseTextualToolCallContent(content: unknown): { name: string; args: un } function containsTextualToolCallContent(content: unknown): boolean { - return typeof content === "string" && stripInternalToolEnvelopeText(content).includes("[Tool call:"); + return ( + typeof content === "string" && stripInternalToolEnvelopeText(content).includes("[Tool call:") + ); } function hasVisibleMessageContent(content: unknown): boolean { @@ -642,7 +645,7 @@ function sanitizeResponsesStreamingOutputItem(item: unknown): JsonRecord | null text: collapseExcessiveNewlines(toString(partRecord.text) || ""), }; }) - .filter((part): part is NonNullable<typeof part> => part !== null) + .filter((part) => part !== null) : []; return { diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 103b364215..65890fdd04 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -17,6 +17,7 @@ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; import { kieExecutor } from "../executors/kie.ts"; +import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { buildRunwayApiUrl, @@ -74,6 +75,11 @@ export async function handleVideoGeneration({ body, credentials, log }) { if (providerConfig.format === "haiper-video") { return handleHaiperVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } + + if (providerConfig.format === "veoaifree-web") { + return handleVeoAiFreeVideoGeneration({ model, provider, body, credentials, log }); + } + if (providerConfig.format === "leonardo-video") { return handleLeonardoVideoGeneration({ model, @@ -96,6 +102,48 @@ export async function handleVideoGeneration({ body, credentials, log }) { * Handle ComfyUI video generation * Submits an AnimateDiff or SVD workflow, polls for completion, fetches output video */ +async function handleVeoAiFreeVideoGeneration({ model, provider, body, credentials, log }) { + const executor = getExecutor(provider); + if (!executor) { + return { success: false, status: 400, error: `Unknown video provider: ${provider}` }; + } + + const prompt = String(body.prompt ?? ""); + const systemParts = []; + if (body.size) systemParts.push(`aspect_ratio: ${body.size}`); + if (body.aspect_ratio) systemParts.push(`aspect_ratio: ${body.aspect_ratio}`); + + const response = await executor.execute({ + model, + body: { + ...body, + model: `${provider}/${model}`, + messages: [ + ...(systemParts.length > 0 ? [{ role: "system", content: systemParts.join("\n") }] : []), + { role: "user", content: prompt }, + ], + }, + stream: false, + credentials: credentials || { connectionId: "noauth" }, + signal: null, + log, + }); + + const upstreamResponse = response instanceof Response ? response : response.response; + if (!upstreamResponse.ok) { + return { + success: false, + status: upstreamResponse.status || 502, + error: await upstreamResponse.text().catch(() => "Video provider error"), + }; + } + + return { + success: true, + data: await upstreamResponse.json(), + }; +} + async function handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }) { const startTime = Date.now(); const [width, height] = (body.size || "512x512").split("x").map(Number); diff --git a/open-sse/package.json b/open-sse/package.json index 793134af7a..ace6702703 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.13", + "version": "3.8.14", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts new file mode 100644 index 0000000000..96d4f949f3 --- /dev/null +++ b/open-sse/services/browserBackedChat.ts @@ -0,0 +1,849 @@ +/** + * browserBackedChat.ts — Provider-agnostic browser-backed chat helper. + * + * 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 Node Response. + * + * Used by duckduckgo-web and claude-web executors when + * OMNIROUTE_BROWSER_POOL=on (or WEB_COOKIE_USE_BROWSER=1) is set and + * the user wants guaranteed live working from this environment, even at + * the cost of 5-15s of browser navigation overhead per request. + * + * The browser solves the provider's challenge natively (VQD, Cloudflare + * Turnstile, etc.) by computing real DOM measurement values. The + * Node-side challenge solver in duckduckgo-web.ts still runs as a + * first-line best-effort; this module is the fallback. + */ + +import { Buffer } from "node:buffer"; +import { + acquireBrowserContext, + openPage, + readPageResponseBody, + shutdownPool, + type PooledContext, +} from "./browserPool.ts"; +import tlsClient from "../utils/tlsClient.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +// Safety constants +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB +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 +const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30s base cooldown +const CIRCUIT_MAX_COOLDOWN_MS = 600_000; // 10 min max cooldown + +// 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>(); + +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; +} + +function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void { + cookieCache.set(domain, { + cookieString, + expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS), + domain, + }); +} + +// Dedup pending cookie refreshes per pool key +const pendingRefreshes = new Map<string, Promise<string | null>>(); + +// Test-only injection point. Tests call __setBrowserBackedChatOverrideForTesting() +// to replace the real browser-backed chat with a mock; production never touches this. +let testOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = + null; + +let httpOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = + null; + +export function __setBrowserBackedChatOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +export function __resetBrowserBackedChatOverrideForTesting(): void { + testOverride = null; + cookieCache.clear(); +} + +export function __setHttpBackedChatOverrideForTesting(fn: typeof httpOverride): void { + httpOverride = fn; +} + +export function __resetHttpBackedChatOverrideForTesting(): void { + httpOverride = null; + cookieCache.clear(); +} + +// Helper to make Playwright waitForTimeout abortable via AbortSignal +function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError")); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") throw err; + }); +} + +export interface BrowserBackedChatRequest { + /** + * Pool key — typically a provider id like "duckduckgo-web" or + * "claude-web", optionally suffixed by user/account id if cookies + * differ. + */ + poolKey: string; + /** + * Chat URL the page should submit to. The page's `fetch` will hit + * this URL when the user clicks Send, and we capture the response. + */ + chatUrl: string; + /** + * Chat page URL to navigate to before typing. The page must already + * have its chat UI rendered for the input/button selectors to work. + */ + chatPageUrl: string; + /** + * The text the user wants to send. Combined with the model message + * prefix (e.g. "Reply with exactly: ...") so the user message is the + * literal text typed into the chat box. + */ + userMessage: string; + /** + * Cookie string (raw) to inject into the browser context. Used by + * Claude web (cookies from `docs/CLAUDE_COOKIE.md` or similar). + * For DDG this is empty — the browser is anonymous. + */ + cookieString?: string | null; + /** + * Cookie domain. Used together with cookieString. + */ + cookieDomain?: string; + /** + * Domain for the page's `fetch` to identify which path on the + * upstream is the chat endpoint. e.g. "duckduckgo.com" for DDG, + * "claude.ai" for Claude. + */ + 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. DDG uses `textarea` with the "Ask + * anything privately" placeholder; Claude uses a contenteditable + * div. Override per provider. + */ + inputSelector: string; + /** + * Selector for the submit button. If the page exposes one, click + * it. Otherwise the helper falls back to pressing Enter in the + * input. + */ + 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 when true. When false, a + * fresh context is opened each time (slower but bypasses + * per-context rate limits). Default true. + */ + reuseContext?: boolean; +} + +export interface BrowserBackedChatResult { + status: number; + contentType: string | null; + body: Buffer; + isStealth: boolean; + timing: { + acquireContextMs: number; + navigateMs: number; + submitMs: number; + captureResponseMs: number; + totalMs: number; + }; +} + +async function settlePoolKey( + requestedKey: string, + reuseContext: boolean +): Promise<{ key: string; acquired: boolean }> { + if (reuseContext) return { key: requestedKey, acquired: true }; + // Use a unique key per non-reuse call so the pool always creates a + // fresh context. Slower but isolates state. + return { + key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + acquired: false, + }; +} + +// Match by stable path prefix and stable trailing suffix, allowing a +// dynamic id segment between them. e.g. for Claude: +// configured: "/api/organizations/{orgId}/chat_conversations/PLACEHOLDER/completion" +// observed: "/api/organizations/{orgId}/chat_conversations/{convId}/completion" +// -> prefix "/api/organizations/{orgId}/chat_conversations" must match, +// suffix "completion" must match, dynamic segment in between is +// ignored. +export 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; + // All segments except the PLACEHOLDER segment must match. + 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; +} + +export async function browserBackedChat( + req: BrowserBackedChatRequest +): Promise<BrowserBackedChatResult> { + if (testOverride) return testOverride(req); + 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, + signal: signal ?? undefined, + }); + await waitWithSignal(2500, signal); + const navigateMs = Date.now() - tNavStart; + + const inputLocator = page.locator(inputSelector).first(); + await inputLocator.waitFor({ state: "visible", timeout: 10000, signal: signal ?? undefined }); + await inputLocator.fill(userMessage); + await waitWithSignal(800, signal); + + const tSubmitStart = Date.now(); + const responsePromise = page.waitForResponse( + (r) => + r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl), + { timeout: 30000 } + ); + + // Wire signal to responsePromise via Promise.race + 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) { + // Wait for the upstream SSE to finish streaming + 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.alloc(0); + if (response) { + const captured = await readPageResponseBody(response); + // OOM guard: reject responses larger than MAX_RESPONSE_BYTES + 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 { + status = captured.status; + contentType = captured.headers["content-type"] || null; + body = captured.body; + } + } + + 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); + // Emit a structured JSON error so the executor can wrap it. + 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) { + // Non-reused contexts are uniquely keyed. Close the page's context + // so we don't leak Chromium resources for one-shot calls. + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + } + } +} + +/** + * httpBackedChat — Lightweight HTTP-backed alternative to browserBackedChat. + * + * Same interface, zero browser overhead. Uses tlsClient (Chrome 124 TLS + * fingerprint) to make direct HTTP POST requests to the provider's chat + * endpoint with browser-emulated headers. + * + * ~0.5-2s per request vs 10-25s for Playwright. The trade-off: the HTTP + * path may be blocked by advanced anti-bot challenges (VQD, Turnstile) + * that only a real browser can solve. When httpBackedChat fails, callers + * should fall back to browserBackedChat. + * + * Supported providers: + * - duckduckgo-web: POST to duckduckgo.com/duckchat/v1/chat + * - claude-web: POST to claude.ai API completion endpoint + */ +export async function httpBackedChat( + req: BrowserBackedChatRequest +): Promise<BrowserBackedChatResult> { + if (httpOverride) return httpOverride(req); + const t0 = Date.now(); + + const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req; + + // Build browser-emulated headers + const headers: Record<string, string> = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + Accept: "text/event-stream, application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/json", + Origin: + chatUrlMatchDomain === "duckduckgo.com" + ? "https://duckduckgo.com" + : `https://${chatUrlMatchDomain}`, + Referer: + chatUrlMatchDomain === "duckduckgo.com" + ? "https://duckduckgo.com/" + : `https://${chatUrlMatchDomain}/`, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + Priority: "u=1, i", + }; + + // Inject cookie if provided + if (cookieString) { + headers["Cookie"] = cookieString; + } + + // Build provider-specific request body + let body: string; + const parsedUrl = new URL(chatUrl); + if (parsedUrl.hostname.includes("duckduckgo")) { + body = JSON.stringify({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: userMessage }], + }); + } else { + // Default: send as OpenAI-style or as raw text based on endpoint + body = JSON.stringify({ + messages: [{ role: "user", content: userMessage }], + }); + } + + try { + const fetchStart = Date.now(); + + if (!tlsClient.available) { + return { + status: 501, + contentType: "application/json", + body: Buffer.from( + JSON.stringify({ + error: { + message: "httpBackedChat unavailable: wreq-js (TLS client) not installed", + type: "configuration_error", + }, + }) + ), + isStealth: false, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: Date.now() - t0, + captureResponseMs: 0, + totalMs: Date.now() - t0, + }, + }; + } + + const response = await tlsClient.fetch(chatUrl, { + method: "POST", + headers, + body, + signal: signal ?? undefined, + }); + + const fetchMs = Date.now() - fetchStart; + + // OOM guard: check content-length before reading body + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = parseInt(contentLengthHeader, 10); + if (contentLength > MAX_RESPONSE_BYTES) { + throw new Error("Response too large"); + } + } + + const responseBody = Buffer.from(await response.text()); + const responseStatus = response.status; + const contentType = response.headers.get("content-type") || "text/event-stream"; + + return { + status: responseStatus, + contentType, + body: responseBody, + isStealth: true, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: fetchMs, + captureResponseMs: 0, + totalMs: Date.now() - t0, + }, + }; + } catch (err) { + // Let AbortError propagate — tryBackedChat handles it, returns 504 + if (err instanceof DOMException && err.name === "AbortError") throw err; + const msg = err instanceof Error ? err.message : String(err); + const body = Buffer.from( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(`httpBackedChat failed: ${msg}`), + type: "upstream_error", + }, + }) + ); + return { + status: 502, + contentType: "application/json", + body, + isStealth: true, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: Date.now() - t0, + }, + }; + } +} + +/** + * 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. + */ +async function doCookieRefreshOnContext( + pooled: import("./browserPool.ts").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, + signal: signal ?? undefined, + }); + 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(() => {}); + } +} + +/** + * refreshCookiesViaBrowser — Launch a stealth browser to solve the provider's + * anti-bot challenge and extract fresh session cookies. + * + * Features: + * - Cookie cache: skip browser launch if we have fresh cached cookies + * - Dedup: concurrent calls for the same poolKey share one browser launch + * - Polling: returns as soon as cookies appear (avg 1-3s vs fixed 5s) + */ +async function refreshCookiesViaBrowser( + poolKey: string, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise<string | null> { + if (httpOverride !== null) return null; + + // Check cookie cache first — avoids browser launch entirely + const cached = getCachedCookies(cookieDomain); + if (cached) return cached; + + // Dedup concurrent refreshes for the same pool key + const pending = pendingRefreshes.get(poolKey); + if (pending) return pending; + + const promise = doRefresh(poolKey, chatPageUrl, cookieDomain, signal); + pendingRefreshes.set(poolKey, promise); + promise.finally(() => pendingRefreshes.delete(poolKey)); + return promise; +} + +async function doRefresh( + poolKey: string, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise<string | null> { + const { key } = await settlePoolKey(poolKey, true); + let pooled: import("./browserPool.ts").PooledContext; + try { + pooled = await acquireBrowserContext(key, { + cookieDomain, + cookieString: null, + warmupUrl: chatPageUrl, + }); + } catch { + return null; + } + + const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal); + + // Cache for subsequent calls + if (result) setCachedCookies(cookieDomain, result); + + return result; +} + +/** + * startBrowserWarmup — Start acquireBrowserContext in parallel with + * httpBackedChat. If httpBackedChat succeeds, the context stays in the + * pool for the next request (saves ~2-3s on the first challenge hit). + * If a challenge is detected, the browser is already partially ready. + */ +async function startBrowserWarmup( + req: BrowserBackedChatRequest +): Promise<import("./browserPool.ts").PooledContext | null> { + if (!req.cookieDomain || httpOverride !== null) return null; + const flag = process.env.OMNIROUTE_BROWSER_POOL; + if (flag === "off" || flag === "0" || flag === "false") return null; + try { + const { key } = await settlePoolKey(req.poolKey, true); + return await acquireBrowserContext(key, { + cookieDomain: req.cookieDomain, + cookieString: null, + // No warmupUrl — if httpBackedChat succeeds, the 1.5s warmup wait + // would be wasted. Navigating fresh in doCookieRefreshOnContext + // is fast once the browser context already exists. + }); + } catch { + return null; + } +} + +/** + * getFreshCookiesWithWarmup — Try the pre-warmed context first, then + * fall through to refreshCookiesViaBrowser if unavailable. + */ +async function getFreshCookiesWithWarmup( + poolKey: string, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null, + warmupPromise: Promise<import("./browserPool.ts").PooledContext | null> | null +): Promise<string | null> { + if (warmupPromise) { + try { + const pooled = await warmupPromise; + if (pooled) { + const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal); + if (result) { + setCachedCookies(cookieDomain, result); + return result; + } + } + } catch { + // Warmup failed — fall through to fresh refresh + } + } + return refreshCookiesViaBrowser(poolKey, chatPageUrl, cookieDomain, signal); +} + +function isChallengeResponse(status: number): boolean { + return status >= 400 && status !== 501; +} + +/** + * tryBackedChat — Combined fast-then-slow chat executor. + * + * Strategy: + * 1. httpBackedChat (fast TLS, ~0.5-2s) + parallel browser warmup + * 2. Cookie cache check (0ms) — skip browser if cookies still fresh + * 3. refreshCookiesViaBrowser (~1-5s) with polling + dedup — Opens a + * Playwright page, polls for cookies, caches the result + * 4. httpBackedChat retry (fast, ~0.5-2s) — Retries with fresh cookies + * 5. browserBackedChat (slow, ~10-25s) — Full chat through browser + * + * Returns the first successful (2xx) response, or the last error. + * Skips browser steps when OMNIROUTE_BROWSER_POOL=off. + */ +export async function tryBackedChat( + req: BrowserBackedChatRequest +): Promise<BrowserBackedChatResult> { + const abortController = req.signal ? null : new AbortController(); + const effectiveSignal = req.signal ?? abortController?.signal ?? null; + + if (abortController) { + setTimeout(() => abortController.abort(), 45000); + } + + // Parallel browser warmup: start acquireBrowserContext while + // httpBackedChat is in flight. If httpBackedChat succeeds, the + // warmup context stays in the pool for the next request. If it + // fails with a challenge, the browser is already partially ready, + // saving ~2-3s on the cookie refresh path. + const warmupPromise = startBrowserWarmup(req); + + try { + const fast = await httpBackedChat({ ...req, signal: effectiveSignal ?? undefined }); + if (fast.status >= 200 && fast.status < 300) return fast; + + if (!isChallengeResponse(fast.status)) return fast; + + let freshCookie: string | null = null; + if (req.cookieDomain) { + // Cookie cache check — skips browser launch on repeat challenges + freshCookie = getCachedCookies(req.cookieDomain); + if (freshCookie) { + const retry = await httpBackedChat({ + ...req, + cookieString: freshCookie, + signal: effectiveSignal ?? undefined, + }); + if (retry.status >= 200 && retry.status < 300) return retry; + // Cache is stale — fall through to fresh browser refresh + freshCookie = null; + } + + if (!freshCookie) { + // Use pre-warmed context if available, otherwise fresh refresh + freshCookie = await getFreshCookiesWithWarmup( + req.poolKey, + req.chatPageUrl, + req.cookieDomain, + effectiveSignal, + warmupPromise + ); + + if (freshCookie) { + const retry = await httpBackedChat({ + ...req, + cookieString: freshCookie, + signal: effectiveSignal ?? undefined, + }); + if (retry.status >= 200 && retry.status < 300) return retry; + } + } + } + + const slowReq = freshCookie + ? { ...req, cookieString: freshCookie, signal: effectiveSignal ?? undefined } + : { ...req, signal: effectiveSignal ?? undefined }; + const slow = await browserBackedChat(slowReq); + if (slow.status >= 200 && slow.status < 300) return slow; + return slow; + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + return { + status: 504, + contentType: "application/json", + body: Buffer.from( + JSON.stringify({ + error: { + message: "tryBackedChat timed out", + type: "timeout_error", + }, + }) + ), + isStealth: false, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: 0, + }, + }; + } + throw err; + } +} + +export { shutdownPool }; diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts new file mode 100644 index 0000000000..9523edc912 --- /dev/null +++ b/open-sse/services/browserPool.ts @@ -0,0 +1,380 @@ +/** + * 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"; + +type Browser = import("playwright").Browser; +type BrowserContext = import("playwright").BrowserContext; +type Page = import("playwright").Page; + +export interface BrowserPoolContextOptions { + cookieDomain: string; + cookieString?: string | null; + warmupUrl?: string | null; + userAgent?: string; + locale?: string; + timezone?: string; + preferCloakbrowser?: boolean; +} + +export interface PooledContext { + id: string; + context: BrowserContext; + warmupPage: Page | null; + lastUsed: number; + isStealth: boolean; +} + +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; +} + +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/146.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, +}; + +async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> { + if (state.cloakLaunchResolved) return state.cloakLaunch; + state.cloakLaunchResolved = true; + try { + const mod = (await import("cloakbrowser")) 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); + 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; + return browser; + })(); + try { + return await state.launching; + } catch (err) { + state.launching = null; + 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"; + }>; +} + +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(); + 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 = await 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 }, + }); + + 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.lastActivity = Date.now(); + resetIdleTimer(); + startEvictTimer(); + return pooled; + })(); + + state.pendingContexts.set(key, createPromise); + createPromise + .then(() => state.pendingContexts.delete(key)) + .catch(() => state.pendingContexts.delete(key)); + + 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); + 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> { + 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; +} + +export 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, + }; +} + +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) }; +} diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index 65d4fc29c2..c061ec4c2d 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto"; let clientPromise: Promise<unknown> | null = null; let exitHookInstalled = false; -const CLAUDE_PROFILE = "chrome_124"; // matches the Chrome 124 UA we send +const CLAUDE_PROFILE = "chrome_146"; // matches the Vivaldi/Chrome 146 UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS || "", 10) || 60_000; // Grace period added to the binding's wire-level timeout before our JS-level diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index fba65badbe..41906fc2cb 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -59,7 +59,10 @@ function modelSupportsVision(model: string): boolean { normalized.includes("gpt-4") || normalized.includes("4o") || normalized.includes("claude-3") || - normalized.includes("gemini") + normalized.includes("gemini") || + // #3328: MiniMax M3 is multimodal — verified it describes images via the opencode + // upstream. Without this, compression strips the image and the model goes "blind". + normalized.includes("minimax-m3") ); } diff --git a/open-sse/services/proxyAutoSelector.ts b/open-sse/services/proxyAutoSelector.ts index 3082905096..1838bdd3bf 100644 --- a/open-sse/services/proxyAutoSelector.ts +++ b/open-sse/services/proxyAutoSelector.ts @@ -10,7 +10,11 @@ * handled by the in-memory cache in proxyFallback.ts. */ -import { findWorkingProxy, clearProxyFallbackCache } from "@omniroute/open-sse/utils/proxyFallback.ts"; +import { + findWorkingProxy, + clearProxyFallbackCache, +} from "@omniroute/open-sse/utils/proxyFallback.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; // --------------------------------------------------------------------------- // Public API @@ -31,6 +35,7 @@ import { findWorkingProxy, clearProxyFallbackCache } from "@omniroute/open-sse/u * @returns A working proxy URL, or null if none was found. */ export async function selectProxyForValidation(targetUrl: string): Promise<string | null> { + if (!isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) return null; if (!targetUrl) return null; let hostname: string; diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index 3ed55b880a..4f1ae35e01 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -25,6 +25,29 @@ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Gemini/Vertex requires every functionDeclaration.parameters to be an OBJECT-typed schema + * (#3357: "functionDeclaration parameters schema should be of type OBJECT"). Some clients + * (e.g. GitHub Copilot's `terminal_last_command`) send a `parameters` that is present but + * lacks a top-level `type: "object"` — just `{ properties }`, a scalar/array type, or `{}`. + * Coerce the parameters root to an object schema before it is cleaned; a falsy/non-record + * schema becomes an empty object schema. Only the top level is touched — nested property + * schemas are left to cleanJSONSchemaForAntigravity. + */ +function toGeminiParametersSchema(raw: unknown): Record<string, unknown> { + if (!isRecord(raw)) { + return { type: "object", properties: {} }; + } + if (raw.type === "object") { + return raw; + } + return { + ...raw, + type: "object", + properties: isRecord(raw.properties) ? raw.properties : {}, + }; +} + function normalizeGeminiToolName( name: string, options: GeminiToolSanitizationOptions = {} @@ -174,9 +197,7 @@ export function buildGeminiTools( functionDeclarations.push({ name: sanitizeGeminiToolName(fn.name, options), description: typeof fn.description === "string" ? fn.description : "", - parameters: cleanJSONSchemaForAntigravity( - fn.parameters || { type: "object", properties: {} } - ), + parameters: cleanJSONSchemaForAntigravity(toGeminiParametersSchema(fn.parameters)), }); } continue; @@ -186,9 +207,7 @@ export function buildGeminiTools( functionDeclarations.push({ name: sanitizeGeminiToolName(rawTool.name, options), description: typeof rawTool.description === "string" ? rawTool.description : "", - parameters: cleanJSONSchemaForAntigravity( - rawTool.input_schema || { type: "object", properties: {} } - ), + parameters: cleanJSONSchemaForAntigravity(toGeminiParametersSchema(rawTool.input_schema)), }); continue; } @@ -202,9 +221,7 @@ export function buildGeminiTools( functionDeclarations.push({ name: sanitizeGeminiToolName(fn.name, options), description: typeof fn.description === "string" ? fn.description : "", - parameters: cleanJSONSchemaForAntigravity( - fn.parameters || { type: "object", properties: {} } - ), + parameters: cleanJSONSchemaForAntigravity(toGeminiParametersSchema(fn.parameters)), }); } } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 636f319ba5..946f96f062 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,6 +4,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; +import { parseTextualToolCallCandidate } from "../../utils/textualToolCall.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -13,6 +14,7 @@ type GeminiToOpenAIState = { signatureNamespace?: string | null; toolCalls: Map<number, unknown>; toolNameMap?: Map<string, string>; + textualToolCallBuffer?: string; }; type GeminiFunctionCallPart = { @@ -34,36 +36,6 @@ function normalizeToolCallArgs(args: unknown): unknown { } } -function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null { - if (typeof text !== "string") return null; - - // Gemini/Antigravity sometimes imitates the request-side fallback with small - // variations, e.g. a leading "(empty)" marker or zero-width chars inserted - // into argument strings. Normalize those variants before parsing so the - // response is still surfaced as a structured OpenAI tool call. - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - const match = normalized.match( - /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ - ); - if (!match) return null; - const name = match[1]?.trim(); - const rawArgs = match[2]?.trim(); - if (!name || !rawArgs) return null; - try { - let args = JSON.parse(rawArgs); - if (typeof args === "string") { - const trimmed = args.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - args = JSON.parse(trimmed); - } - } - if (args && typeof args === "object" && !Array.isArray(args)) { - return { name, args }; - } - } catch {} - return null; -} - function containsTextualToolCallMarker(text: unknown): boolean { return ( typeof text === "string" && text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:") @@ -253,26 +225,44 @@ export function geminiToOpenAIResponse(chunk, state) { // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. if (part.text !== undefined && part.text !== "") { - const textualToolCall = parseTextualToolCall(part.text); - if (textualToolCall) { - emitFunctionCallPart( - { - functionCall: { - name: textualToolCall.name, - args: textualToolCall.args, + const accumulated = (state.textualToolCallBuffer || "") + part.text; + const candidate = parseTextualToolCallCandidate(accumulated); + + if (candidate) { + if (candidate.kind === "complete") { + emitFunctionCallPart( + { + functionCall: { + name: candidate.name, + args: candidate.args, + }, }, - }, - state, - results - ); + state, + results + ); + state.textualToolCallBuffer = ""; + } else { + state.textualToolCallBuffer = accumulated; + } continue; } - // Never leak a malformed textual pseudo tool-call to clients. If the - // model emits the marker but the arguments are not parseable yet/at all, - // suppress the text; the final finish reason remains `stop` unless a - // structured tool call was emitted elsewhere. - if (containsTextualToolCallMarker(part.text)) { + if (state.textualToolCallBuffer) { + const flushedText = state.textualToolCallBuffer + part.text; + state.textualToolCallBuffer = ""; + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { content: flushedText }, + finish_reason: null, + }, + ], + }); continue; } @@ -407,6 +397,38 @@ export function geminiToOpenAIResponse(chunk, state) { // Finish reason - include usage in final chunk if (candidate.finishReason) { + if (state.textualToolCallBuffer) { + const remainingText = state.textualToolCallBuffer; + state.textualToolCallBuffer = ""; + const textualToolCall = parseTextualToolCallCandidate(remainingText); + if (textualToolCall && textualToolCall.kind === "complete") { + emitFunctionCallPart( + { + functionCall: { + name: textualToolCall.name, + args: textualToolCall.args, + }, + }, + state, + results + ); + } else if (!containsTextualToolCallMarker(remainingText)) { + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { content: remainingText }, + finish_reason: null, + }, + ], + }); + } + } + let finishReason = candidate.finishReason.toLowerCase(); if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts index 8e5b246072..165ce37854 100644 --- a/open-sse/utils/proxyFallback.ts +++ b/open-sse/utils/proxyFallback.ts @@ -10,6 +10,7 @@ import { fetch as undiciFetch } from "undici"; import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts"; import { resolveProxyForScopeFromRegistry, listProxies, listOneproxyProxies } from "@/lib/localDb"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; // --------------------------------------------------------------------------- // Types @@ -347,6 +348,12 @@ export async function selectWorkingProxyFallback( levelId: string | null; source: string; } | null> { + // #3332: auto-selection is opt-in. Without this gate, any single proxy in the + // registry silently becomes a global fallback for ALL connections (ignoring + // assignments / per-connection proxy_enabled). Default OFF — only run when the + // operator explicitly enables PROXY_AUTO_SELECT_ENABLED. + if (!isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) return null; + const candidates = await getProxyCandidates(); if (candidates.length === 0) return null; diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 3b3e7501a1..5865ab676b 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -12,6 +12,7 @@ import { } from "./proxyDispatcher.ts"; import tlsClient from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { findWorkingProxy } from "./proxyFallback.ts"; function isTlsFingerprintEnabled() { @@ -122,7 +123,10 @@ function noProxyMatch(targetUrl) { } function isLocalAddress(hostname: string): boolean { - const host = hostname.replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/i, ""); + const host = hostname + .replace(/^\[/, "") + .replace(/\]$/, "") + .replace(/^::ffff:/i, ""); if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host === "::1") { return true; } @@ -338,7 +342,7 @@ async function patchedFetch( continue; } // All attempts exhausted — try proxy fallback before native fetch - if (source === "direct") { + if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) { let targetHostname = ""; try { targetHostname = new URL(targetUrl).hostname; @@ -346,10 +350,7 @@ async function patchedFetch( // ignore } if (targetHostname) { - const fallbackProxyUrl = await findWorkingProxy( - targetHostname, - targetUrl - ); + const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl); if (fallbackProxyUrl) { try { const dispatcher = createProxyDispatcher(fallbackProxyUrl); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 0cb878a3c9..c502c62c0a 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -30,6 +30,7 @@ import { extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; import { buildErrorBody } from "./error.ts"; +import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./textualToolCall.ts"; import { recordToolLatency } from "../services/toolLatencyTracker.ts"; import { generateSessionId, @@ -188,57 +189,6 @@ function appendBoundedText(current: string, next: string): string { return combined.slice(-STREAM_SUMMARY_TEXT_LIMIT); } -function stripZeroWidth(value: unknown): unknown { - if (typeof value === "string") { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); - } - if (Array.isArray(value)) { - return value.map((item) => stripZeroWidth(item)); - } - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record<string, unknown>).map(([key, item]) => [ - key, - stripZeroWidth(item), - ]) - ); - } - return value; -} - -function parseTextualToolCallCandidate( - text: unknown -): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { - if (typeof text !== "string") return null; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - const toolCallIndex = normalized.lastIndexOf("[Tool call:"); - if (toolCallIndex < 0) return null; - const candidate = normalized.slice(toolCallIndex); - const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); - if (!headerMatch) return { kind: "partial" }; - const name = headerMatch[1]?.trim(); - const rawArgs = candidate.slice(headerMatch[0].length).trim(); - if (!name || !rawArgs) return { kind: "partial" }; - const decoders = [ - (value: string) => value, - (value: string) => { - if (value.startsWith('"') && value.endsWith('"')) { - const decoded = JSON.parse(value); - return typeof decoded === "string" ? decoded : value; - } - return value; - }, - ]; - for (const decode of decoders) { - try { - const decoded = decode(rawArgs); - const parsed = JSON.parse(decoded); - return { kind: "complete", name, args: stripZeroWidth(parsed) }; - } catch {} - } - return { kind: "partial" }; -} - function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null { const candidate = parseTextualToolCallCandidate(text); return candidate?.kind === "complete" ? { name: candidate.name, args: candidate.args } : null; @@ -248,9 +198,33 @@ function containsTextualToolCallCandidate(text: unknown): boolean { return parseTextualToolCallCandidate(text) !== null; } -function containsMalformedTextualToolCall(text: unknown): boolean { +function containsMalformedTextualToolCall( + text: unknown, + allowedToolNames?: Set<string> | null +): boolean { if (typeof text !== "string") return false; - return text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:"); + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + + let searchIdx = 0; + while (true) { + const idx = normalized.indexOf("[Tool call:", searchIdx); + if (idx === -1) break; + + const candidate = normalized.slice(idx); + if (isValidToolCallHeaderPrefix(candidate)) { + const parsed = parseTextualToolCallFromContent(candidate); + if (parsed) { + if (allowedToolNames?.size && !allowedToolNames.has(parsed.name)) { + return true; + } + } else { + return true; + } + } + + searchIdx = idx + 1; + } + return false; } function extractAllowedToolNames(body: unknown): Set<string> | null { @@ -903,6 +877,10 @@ export function createSSEStream(options: StreamOptions = {}) { textualToolCallConverted = true; delta.content = ""; } else { + if (passthroughBufferedTextualToolCallContent) { + delta.content = passthroughBufferedTextualToolCallContent + incomingContent; + textualToolCallConverted = true; + } passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, passthroughBufferedTextualToolCallContent + incomingContent @@ -1399,6 +1377,11 @@ export function createSSEStream(options: StreamOptions = {}) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } else { + if (passthroughBufferedTextualToolCallContent) { + parsed.delta = passthroughBufferedTextualToolCallContent + incomingDelta; + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, passthroughBufferedTextualToolCallContent + incomingDelta @@ -2088,6 +2071,54 @@ export function createSSEStream(options: StreamOptions = {}) { } clearPendingPassthroughEvent(); + if ( + passthroughBufferedTextualToolCallContent && + !passthroughBufferedTextualToolCallContent.includes("Arguments:") + ) { + let flushOutput = ""; + if (clientExpectsResponsesStream) { + const syntheticChunk = { + type: "response.output_text.delta", + delta: passthroughBufferedTextualToolCallContent, + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } else if (clientExpectsClaudeStream) { + const syntheticChunk = { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: passthroughBufferedTextualToolCallContent, + }, + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } else { + const syntheticChunk = { + id: passthroughResponsesId || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", + choices: [ + { + index: 0, + delta: { + content: passthroughBufferedTextualToolCallContent, + }, + finish_reason: null, + }, + ], + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } + reqLogger?.appendConvertedChunk?.(flushOutput); + controller.enqueue(encoder.encode(flushOutput)); + passthroughAccumulatedContent = appendBoundedText( + passthroughAccumulatedContent, + passthroughBufferedTextualToolCallContent + ); + passthroughBufferedTextualToolCallContent = ""; + } + // Estimate usage if provider didn't return valid usage if (!hasValidUsage(usage) && totalContentLength > 0) { usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); @@ -2141,7 +2172,7 @@ export function createSSEStream(options: StreamOptions = {}) { ) { passthroughHasToolCalls = true; content = ""; - } else if (containsMalformedTextualToolCall(content)) { + } else if (containsMalformedTextualToolCall(content, allowedToolNames)) { content = ""; } const message: Record<string, unknown> = { @@ -2392,7 +2423,7 @@ export function createSSEStream(options: StreamOptions = {}) { }, }); content = ""; - } else if (containsMalformedTextualToolCall(content)) { + } else if (containsMalformedTextualToolCall(content, allowedToolNames)) { content = ""; } const message: Record<string, unknown> = { diff --git a/open-sse/utils/textualToolCall.ts b/open-sse/utils/textualToolCall.ts new file mode 100644 index 0000000000..160f93e3d6 --- /dev/null +++ b/open-sse/utils/textualToolCall.ts @@ -0,0 +1,101 @@ +export function stripZeroWidth(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + } + if (Array.isArray(value)) { + return value.map((item) => stripZeroWidth(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record<string, unknown>).map(([key, item]) => [ + key, + stripZeroWidth(item), + ]) + ); + } + return value; +} + +export function isValidToolCallHeaderPrefix(candidate: string): boolean { + if (!candidate.startsWith("[Tool call:")) return false; + + const bracketIndex = candidate.indexOf("]"); + if (bracketIndex === -1) { + const namePart = candidate.slice("[Tool call:".length); + if (namePart.includes("\n") || namePart.includes("[")) return false; + return true; + } + + const namePart = candidate.slice("[Tool call:".length, bracketIndex); + if (namePart.includes("\n") || namePart.trim().length === 0) return false; + + const afterBracket = candidate.slice(bracketIndex + 1); + const leadingWhitespaceMatch = afterBracket.match(/^[\s\r\n]*/); + const leadingWhitespace = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : ""; + const textAfterWhitespace = afterBracket.slice(leadingWhitespace.length); + + if (textAfterWhitespace.length === 0) { + return true; + } + + if (!leadingWhitespace.includes("\n")) { + return false; + } + + const expectedText = "Arguments:"; + if (expectedText.startsWith(textAfterWhitespace)) { + return true; + } + + if (textAfterWhitespace.startsWith(expectedText)) { + return true; + } + + return false; +} + +export function parseTextualToolCallCandidate( + text: unknown +): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { + if (typeof text !== "string") return null; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const toolCallIndex = normalized.lastIndexOf("[Tool call:"); + if (toolCallIndex < 0) { + const lastBracket = normalized.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { + return { kind: "partial" }; + } + const lastParen = normalized.lastIndexOf("("); + if (lastParen !== -1 && "(empty)[Tool call:".startsWith(normalized.slice(lastParen))) { + return { kind: "partial" }; + } + return null; + } + const candidate = normalized.slice(toolCallIndex); + if (!isValidToolCallHeaderPrefix(candidate)) { + return null; + } + const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); + if (!headerMatch) return { kind: "partial" }; + const name = headerMatch[1]?.trim(); + const rawArgs = candidate.slice(headerMatch[0].length).trim(); + if (!name || !rawArgs) return { kind: "partial" }; + const decoders = [ + (value: string) => value, + (value: string) => { + if (value.startsWith('"') && value.endsWith('"')) { + const decoded = JSON.parse(value); + return typeof decoded === "string" ? decoded : value; + } + return value; + }, + ]; + for (const decode of decoders) { + try { + const decoded = decode(rawArgs); + const parsed = JSON.parse(decoded); + return { kind: "complete", name, args: stripZeroWidth(parsed) }; + } catch {} + } + return { kind: "partial" }; +} diff --git a/open-sse/utils/tlsClient.ts b/open-sse/utils/tlsClient.ts index 9e93c3cf71..c2b41521d0 100644 --- a/open-sse/utils/tlsClient.ts +++ b/open-sse/utils/tlsClient.ts @@ -71,13 +71,79 @@ function normalizeHeaders(headers: HeadersInit | undefined): Record<string, stri */ class TlsClient { session: WreqSession | null = null; - available: boolean; + + private _libraryAvailable: boolean; + private failureCount: number = 0; + private maxFailures: number = 3; + private baseCooldownMs: number = 30_000; + private cooldownMs: number = 30_000; + private cooldownMultiplier: number = 1; + private readonly MAX_COOLDOWN_MS = 600_000; // 10 min + private circuitOpenUntil: number = 0; + private circuitTripped: boolean = false; constructor() { - this.available = !!createSession; + this._libraryAvailable = !!createSession; + } + + get available(): boolean { + if (!this._libraryAvailable) return false; + if (!this.circuitTripped) return true; + return Date.now() >= this.circuitOpenUntil; + } + + private recordFailure(): void { + this.failureCount++; + if (this.failureCount >= this.maxFailures) { + this.circuitOpenUntil = Date.now() + this.cooldownMs; + this.circuitTripped = true; + // Close the stale session so the next half-open retry creates a + // fresh one instead of reusing a broken connection. + if (this.session) { + Promise.resolve(this.session.close()).catch(() => {}); + this.session = null; + } + console.warn( + `[TlsClient] Circuit opened after ${this.failureCount} consecutive failures, cooling down for ${this.cooldownMs}ms` + ); + // Double cooldown for the next trip: 30s → 60s → 120s → ... → 10 min max + this.escalateCooldown(); + } + } + + private recordSuccess(): void { + this.failureCount = 0; + if (this.circuitTripped) { + this.cooldownMultiplier = 1; + this.cooldownMs = this.baseCooldownMs; + console.log("[TlsClient] Circuit closed (success after cooldown)"); + this.circuitTripped = false; + } + } + + private escalateCooldown(): void { + this.cooldownMultiplier = Math.min(this.cooldownMultiplier * 2, 20); + this.cooldownMs = Math.min(this.baseCooldownMs * this.cooldownMultiplier, this.MAX_COOLDOWN_MS); + } + + private checkCircuit(): boolean { + if (!this.circuitTripped) return true; + + if (Date.now() >= this.circuitOpenUntil) { + console.log("[TlsClient] Half-open: retrying after cooldown"); + // Don't call recordSuccess() here — that would reset failureCount. + // Instead, let the fetch() call succeed or fail naturally. + // If it succeeds, recordSuccess() in fetch() handles cleanup. + // If it fails, recordFailure() finds failureCount still >= maxFailures + // and re-opens with escalated cooldown. + return true; + } + + return false; } async getSession() { + if (!this.checkCircuit()) return null; if (!this.available) return null; if (this.session) return this.session; const createSessionFn = createSession; @@ -103,29 +169,42 @@ class TlsClient { * wreq-js Response is already fetch-compatible (headers, text(), json(), clone(), body). */ async fetch(url: string, options: FetchOptions = {}) { - const session = await this.getSession(); - if (!session) throw new Error("wreq-js not available"); - const { timeoutMs } = getTlsClientTimeoutConfig(process.env, (message) => { - console.warn(`[TlsClient] ${message}`); - }); - - const method = (options.method || "GET").toUpperCase(); - - const wreqOptions: Record<string, unknown> = { - method, - headers: normalizeHeaders(options.headers), - body: options.body, - redirect: options.redirect === "manual" ? "manual" : "follow", - timeout: timeoutMs, - }; - - // Pass signal through if available - if (options.signal) { - wreqOptions.signal = options.signal; + if (!this.checkCircuit()) { + throw new Error("wreq-js circuit open — skipping TLS request"); } - const response = await session.fetch(url, wreqOptions); - return response; + try { + const session = await this.getSession(); + if (!session) throw new Error("wreq-js not available"); + const { timeoutMs } = getTlsClientTimeoutConfig(process.env, (message) => { + console.warn(`[TlsClient] ${message}`); + }); + + const method = (options.method || "GET").toUpperCase(); + + const wreqOptions: Record<string, unknown> = { + method, + headers: normalizeHeaders(options.headers), + body: options.body, + redirect: options.redirect === "manual" ? "manual" : "follow", + timeout: timeoutMs, + }; + + if (options.signal) { + wreqOptions.signal = options.signal; + } + + const response = await session.fetch(url, wreqOptions); + this.recordSuccess(); + return response; + } catch (err) { + const isAbort = + err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted")); + if (!isAbort) { + this.recordFailure(); + } + throw err; + } } async exit() { @@ -134,6 +213,29 @@ class TlsClient { this.session = null; } } + + resetCircuit(): void { + this.failureCount = 0; + this.circuitTripped = false; + this.circuitOpenUntil = 0; + } + + getCircuitState(): { + available: boolean; + circuitTripped: boolean; + failureCount: number; + circuitOpenUntil: number; + coolDownRemainingMs: number; + } { + return { + available: this.available, + circuitTripped: this.circuitTripped, + failureCount: this.failureCount, + circuitOpenUntil: this.circuitOpenUntil, + coolDownRemainingMs: + this.circuitOpenUntil > 0 ? Math.max(0, this.circuitOpenUntil - Date.now()) : 0, + }; + } } const tlsClient = new TlsClient(); diff --git a/package-lock.json b/package-lock.json index 53977e033e..e44c0f47aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.13", + "version": "3.8.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.13", + "version": "3.8.14", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21467,7 +21467,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.13" + "version": "3.8.14" } } } diff --git a/package.json b/package.json index 89dad24a8c..45d9f2b0a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.13", + "version": "3.8.14", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx index f72b609b69..c7d356ee36 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { Card, Button, ModelSelectModal } from "@/shared/components"; interface Role { @@ -63,34 +63,7 @@ export default function HermesAgentToolCard({ return `${minutes}m`; } - useEffect(() => { - if (isExpanded) { - // Phase 3: Seed from detector snapshot (batchStatus) for instant UI - if ( - !seededFromBatchRef.current && - Object.keys(currentRoles).length === 0 && - batchStatus?.hermesAgentRoles - ) { - const seeded: Record<string, any> = {}; - Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => { - seeded[role] = { - model: info.model, - provider: info.provider, - }; - }); - setCurrentRoles(seeded); - seededFromBatchRef.current = true; - } - loadCurrentConfig(); - } else { - // Reset seed flag when collapsed so it can seed again on next expand - seededFromBatchRef.current = false; - setPreviewYaml(null); - setFirstSetupAt(null); - } - }, [isExpanded, batchStatus, currentRoles]); - - const loadCurrentConfig = async () => { + const loadCurrentConfig = useCallback(async () => { setIsLoading(true); try { const res = await fetch("/api/cli-tools/hermes-agent-settings"); @@ -107,7 +80,36 @@ export default function HermesAgentToolCard({ } finally { setIsLoading(false); } - }; + }, []); + + useEffect(() => { + if (!isExpanded) { + // Reset seed flag when collapsed so it can seed again on next expand + seededFromBatchRef.current = false; + setPreviewYaml(null); + setFirstSetupAt(null); + return; + } + // Phase 3: Seed from detector snapshot (batchStatus) for instant UI — once per expand. + // NOTE: currentRoles is intentionally NOT a dependency. loadCurrentConfig() below sets + // currentRoles to a fresh object on every fetch; if currentRoles were a dep, the effect + // would re-fire → refetch → setCurrentRoles → re-fire … an infinite loop. On the detail + // page isExpanded is hardcoded true, so that loop spun forever (the "loading forever" + + // console spam of /api/cli-tools/hermes-agent-settings). We read currentRoles only via a + // functional update so the emptiness guard sees the latest value without subscribing to it. + if (!seededFromBatchRef.current && batchStatus?.hermesAgentRoles) { + seededFromBatchRef.current = true; + setCurrentRoles((prev) => { + if (Object.keys(prev).length > 0) return prev; + const seeded: Record<string, any> = {}; + Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => { + seeded[role] = { model: info.model, provider: info.provider }; + }); + return seeded; + }); + } + loadCurrentConfig(); + }, [isExpanded, batchStatus, loadCurrentConfig]); const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => { setSelections((prev) => ({ ...prev, [roleId]: { model, provider } })); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 9dfff75e0b..cb0f1984cf 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -186,6 +186,27 @@ function providerText( return fallback; } +function providerCountText( + t: ProviderMessageTranslator, + key: string, + count: number, + singularFallback: string, + pluralFallback: string +): string { + return providerText(t, key, count === 1 ? singularFallback : pluralFallback, { count }); +} + +function readBooleanToggle(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } + return fallback; +} + function getWebSessionCredentialLabel( t: ProviderMessageTranslator, requirement: WebSessionCredentialRequirement, @@ -4768,8 +4789,20 @@ export default function ProviderDetailPage() { /> <span className="text-sm font-medium text-text-muted"> {selectedIds.size > 0 - ? t("selectedCount", { count: selectedIds.size }) - : t("accountsCount", { count: connections.length })} + ? providerCountText( + t, + "selectedCount", + selectedIds.size, + "{count} selected", + "{count} selected" + ) + : providerCountText( + t, + "accountsCount", + connections.length, + "{count} account", + "{count} accounts" + )} </span> </label> @@ -4877,9 +4910,9 @@ export default function ProviderDetailPage() { hasProxy={!!connProxyMap[conn.id]?.proxy} proxySource={connProxyMap[conn.id]?.level || null} proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={conn.proxyEnabled !== false} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} - perKeyProxyEnabled={conn.perKeyProxyEnabled === true} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> ))} @@ -4917,23 +4950,27 @@ export default function ProviderDetailPage() { /> <span className="text-sm font-medium text-text-muted"> {selectedIds.size > 0 - ? t("selectedCount", { count: selectedIds.size }) - : t("accountsCount", { count: connections.length })} + ? providerCountText( + t, + "selectedCount", + selectedIds.size, + "{count} selected", + "{count} selected" + ) + : providerCountText( + t, + "accountsCount", + connections.length, + "{count} account", + "{count} accounts" + )} </span> </label> <div className="flex flex-wrap items-center justify-end gap-2"> - {selectedIds.size === 0 && connections.length > 0 && ( - <Button - variant="secondary" - size="sm" - icon="shield" - loading={distributingProxies} - onClick={() => handleDistributeProxies()} - > - {t("distributeProxies")} - </Button> - )} + {/* Distribute Proxies lives in the provider toolbar (top action bar); + removed the duplicate here that rendered simultaneously when nothing + was selected. Per-tag groups keep their own scoped button. */} {bulkActions} </div> </div> @@ -5080,9 +5117,9 @@ export default function ProviderDetailPage() { hasProxy={!!connProxyMap[conn.id]?.proxy} proxySource={connProxyMap[conn.id]?.level || null} proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={conn.proxyEnabled !== false} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} - perKeyProxyEnabled={conn.perKeyProxyEnabled === true} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} /> ))} @@ -7919,6 +7956,7 @@ function ConnectionRow({ <span className="text-text-muted/30 select-none">|</span> <button onClick={() => onToggleProxyEnabled(!proxyEnabled)} + aria-label={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${ proxyEnabled ? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25" @@ -7927,7 +7965,7 @@ function ConnectionRow({ title={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")} > <span className="material-symbols-outlined text-[13px]">vpn_lock</span> - {proxyEnabled ? t("proxyOn") : t("proxyOff")} + {proxyEnabled ? <span className="sr-only">{t("proxyOn")}</span> : t("proxyOff")} </button> </> )} @@ -7936,6 +7974,7 @@ function ConnectionRow({ <span className="text-text-muted/30 select-none">|</span> <button onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)} + aria-label={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${ perKeyProxyEnabled ? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25" @@ -7944,7 +7983,11 @@ function ConnectionRow({ title={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")} > <span className="material-symbols-outlined text-[13px]">key</span> - {perKeyProxyEnabled ? t("perKeyProxyOn") : t("perKeyProxyOff")} + {perKeyProxyEnabled ? ( + t("perKeyProxyOn") + ) : ( + <span className="sr-only">{t("perKeyProxyOff")}</span> + )} </button> </> )} diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index cfc3e16db5..ac8fd1a84a 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -159,9 +159,9 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) { } /** - * Save OpenCode config to: - * - Linux/macOS: ~/.config/opencode/opencode.json (XDG_CONFIG_HOME aware) - * - Windows: %APPDATA%/opencode/opencode.json + * Save OpenCode config to ~/.config/opencode/opencode.json on ALL platforms + * (XDG_CONFIG_HOME aware). OpenCode uses XDG `~/.config` even on Windows + * (%USERPROFILE%\.config), NOT %APPDATA% (#3330). * * (#524) OpenCode was silently failing because this handler was missing. */ diff --git a/src/app/api/context/analytics/route.ts b/src/app/api/context/analytics/route.ts index 5415ae4948..ed28419a2a 100644 --- a/src/app/api/context/analytics/route.ts +++ b/src/app/api/context/analytics/route.ts @@ -1 +1,5 @@ -export { GET } from "@/app/api/analytics/compression/route"; +/** + * Re-exports the GET function from the analytics/compression route for the context analytics endpoint. + * This is used to provide compression analytics data for the context analysis. + */ +export { GET } from "@/app/api/analytics/compression/route"; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index ec61572fb5..49c701a9bc 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -71,7 +71,7 @@ export async function POST(request: Request) { email: email || null, providerSpecificData: { profileArn: tokenData.profileArn, - authMethod: "imported", + authMethod: tokenData.authMethod || "imported", provider: "Imported", ...(tokenData.clientId ? { diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index b11daa306e..feba366e20 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -33,7 +33,7 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { // PUT /api/provider-nodes/[id] - Update provider node export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { - let rawBody; + let rawBody: unknown; try { rawBody = await request.json(); } catch { @@ -54,7 +54,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, chatPath, modelsPath } = validation.data; + const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders } = + validation.data; const node: any = await getProviderNodeById(id); if (!node) { @@ -89,6 +90,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: baseUrl: sanitizedBaseUrl, chatPath: chatPath || null, modelsPath: isClaudeCodeCompatibleProvider(id) ? null : modelsPath || null, + customHeaders: customHeaders || null, }; if (node.type === "openai-compatible") { @@ -110,6 +112,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: baseUrl: sanitizedBaseUrl, nodeName: updated.name, chatPath: updated.chatPath || undefined, + customHeaders: updated.customHeaders || undefined, } as JsonRecord; if (updated.modelsPath) { providerSpecificData.modelsPath = updated.modelsPath; @@ -136,7 +139,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } // DELETE /api/provider-nodes/[id] - Delete provider node and its connections -export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; const node = await getProviderNodeById(id); diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index b58f159d27..9eefbbcebe 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -68,7 +68,7 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath } = + const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath, customHeaders } = validation.data; // Determine type @@ -84,6 +84,7 @@ export async function POST(request) { name: name.trim(), chatPath: chatPath || null, modelsPath: modelsPath || null, + customHeaders: customHeaders || null, }); return NextResponse.json({ node }, { status: 201 }); } @@ -110,6 +111,7 @@ export async function POST(request) { name: name.trim(), chatPath: chatPath || null, modelsPath: compatMode === "cc" ? null : modelsPath || null, + customHeaders: customHeaders || null, }); return NextResponse.json({ node }, { status: 201 }); } diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index e732508444..98bbeacc1f 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -126,6 +126,7 @@ export async function POST(request: Request) { nodeName: node.name, ...(node.chatPath ? { chatPath: node.chatPath } : {}), ...(node.modelsPath ? { modelsPath: node.modelsPath } : {}), + ...(node.customHeaders ? { customHeaders: node.customHeaders } : {}), }; } else if (isAnthropicCompatibleProvider(provider)) { const node: any = await getProviderNodeById(provider); @@ -150,6 +151,7 @@ export async function POST(request: Request) { nodeName: node.name, ...(node.chatPath ? { chatPath: node.chatPath } : {}), ...(node.modelsPath ? { modelsPath: node.modelsPath } : {}), + ...(node.customHeaders ? { customHeaders: node.customHeaders } : {}), }; } diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 540f1f93d7..50036d299f 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1321,6 +1321,12 @@ export async function GET(request: Request) { return NextResponse.json(analytics); } catch (error) { console.error("Error computing analytics:", error); - return NextResponse.json({ error: "Failed to compute analytics" }, { status: 500 }); + // Surface the real (sanitized) reason so the dashboard can show it instead of a + // generic placeholder (#3356). buildErrorBody strips stacks/absolute paths. + const { buildErrorBody } = await import("@omniroute/open-sse/utils/error"); + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json(buildErrorBody(500, message || "Failed to compute analytics"), { + status: 500, + }); } } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 9e1d36ca16..928d52cc5c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -427,13 +427,21 @@ export async function getUnifiedModelsResponse( return providerModels.find((model) => model?.id === modelId) || null; }; + const prefixRoutesToProvider = (prefix: string, providerId: string) => { + const parsed = parseModel(`${prefix}/__omniroute_probe__`); + return parsed.provider === providerId; + }; + const getProviderPrefixes = (providerId: string, rawProvider: string) => { const prefixes = new Set<string>([providerId, rawProvider, providerIdToAlias[providerId]]); for (const [alias, mappedProviderId] of Object.entries(aliasToProviderId)) { if (mappedProviderId === providerId) prefixes.add(alias); } return [...prefixes].filter( - (prefix): prefix is string => typeof prefix === "string" && prefix.length > 0 + (prefix): prefix is string => + typeof prefix === "string" && + prefix.length > 0 && + prefixRoutesToProvider(prefix, providerId) ); }; @@ -724,7 +732,10 @@ export async function getUnifiedModelsResponse( // Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model). // This improves compatibility for clients that expect full provider names. - if (canonicalProviderId !== alias) { + if ( + canonicalProviderId !== alias && + prefixRoutesToProvider(canonicalProviderId, canonicalProviderId) + ) { const providerIdModel = `${canonicalProviderId}/${model.id}`; const providerVisionFields = getVisionCapabilityFields(providerIdModel) || getVisionCapabilityFields(model.id); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f677d1f660..3c04b237ba 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3870,6 +3870,8 @@ "testAll": "Test All", "distributeProxies": "Distribute Proxies", "distributing": "Distributing...", + "selectedCount": "{count, plural, one {# selected} other {# selected}}", + "accountsCount": "{count, plural, one {# account} other {# accounts}}", "testAllOAuth": "Test all OAuth connections", "testAllFree": "Test all Free connections", "testAllApiKey": "Test all API Key connections", diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index a365c6ff74..53bf4aa35f 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -203,6 +203,8 @@ const SCHEMA_SQL = ` last_used_at TEXT, "group" TEXT, max_concurrent INTEGER, + proxy_enabled INTEGER NOT NULL DEFAULT 1, + per_key_proxy_enabled INTEGER NOT NULL DEFAULT 0, quota_window_thresholds_json TEXT, rate_limit_overrides_json TEXT, created_at TEXT NOT NULL, @@ -219,6 +221,9 @@ const SCHEMA_SQL = ` prefix TEXT, api_type TEXT, base_url TEXT, + chat_path TEXT, + models_path TEXT, + custom_headers_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -452,7 +457,12 @@ export function rowToCamel(row: unknown): JsonRecord | null { const result: JsonRecord = {}; for (const [k, v] of Object.entries(row as JsonRecord)) { const camelKey = toCamelCase(k); - if (camelKey === "isActive" || camelKey === "rateLimitProtection") { + if ( + camelKey === "isActive" || + camelKey === "rateLimitProtection" || + camelKey === "proxyEnabled" || + camelKey === "perKeyProxyEnabled" + ) { result[camelKey] = v === 1 || v === true; } else if (camelKey === "providerSpecificData" && typeof v === "string") { try { @@ -460,15 +470,21 @@ export function rowToCamel(row: unknown): JsonRecord | null { } catch { result[camelKey] = v; } - } else if (camelKey.endsWith("Json") && typeof v === "string") { + } else if (camelKey.endsWith("Json")) { // Convention: any column with a `_json` suffix is JSON-encoded TEXT. // Surface the parsed object under the friendlier name (key minus the // "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds. + // A NULL/absent column normalizes to `baseKey: null` (not the suffixed + // key) so read and write paths expose a consistent shape. const baseKey = camelKey.slice(0, -"Json".length); - try { - result[baseKey] = JSON.parse(v); - } catch { - result[baseKey] = null; + if (typeof v === "string") { + try { + result[baseKey] = JSON.parse(v); + } catch { + result[baseKey] = null; + } + } else { + result[baseKey] = v == null ? null : v; } } else { result[camelKey] = v; @@ -537,6 +553,18 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE provider_connections ADD COLUMN max_concurrent INTEGER"); console.log("[DB] Added provider_connections.max_concurrent column"); } + if (!columnNames.has("proxy_enabled")) { + db.exec( + "ALTER TABLE provider_connections ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 1" + ); + console.log("[DB] Added provider_connections.proxy_enabled column"); + } + if (!columnNames.has("per_key_proxy_enabled")) { + db.exec( + "ALTER TABLE provider_connections ADD COLUMN per_key_proxy_enabled INTEGER NOT NULL DEFAULT 0" + ); + console.log("[DB] Added provider_connections.per_key_proxy_enabled column"); + } if (!columnNames.has("quota_window_thresholds_json")) { db.exec("ALTER TABLE provider_connections ADD COLUMN quota_window_thresholds_json TEXT"); console.log("[DB] Added provider_connections.quota_window_thresholds_json column"); @@ -1270,7 +1298,7 @@ export function getDbInstance(): SqliteDatabase { ) { throw e; } - preservedCriticalState = captureCriticalDbState(sqliteFile); + preservedCriticalState = captureCriticalDbState(sqliteFile); // SAFETY: Never delete the database — rename to backup so data can be recovered. // The old code would silently destroy all user data on any probe failure. diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 4aa8e1d345..f5e874f8a7 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -399,6 +399,8 @@ function isSchemaAlreadyApplied( switch (migration.version) { case "003": return hasColumn(db, "provider_nodes", "chat_path"); + case "095": + return hasColumn(db, "provider_nodes", "custom_headers_json"); case "005": return hasColumn(db, "combos", "system_message"); case "007": @@ -910,7 +912,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean } } return isMissing; }); - const deferredUnsupported = pending.filter((migration) => isDeferredUnsupportedMigration(db, migration)); + const deferredUnsupported = pending.filter((migration) => + isDeferredUnsupportedMigration(db, migration) + ); const actionablePending = pending.filter( (migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version) ); @@ -920,7 +924,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean } } if (deferredUnsupported.length > 0) { - const summary = deferredUnsupported.map((migration) => `${migration.version}_${migration.name}`).join(", "); + const summary = deferredUnsupported + .map((migration) => `${migration.version}_${migration.name}`) + .join(", "); console.warn( `[Migration] Deferring optional FTS5 migrations on driver ${db.driver}: ${summary}. ` + `Memory search will fall back until a SQLite driver with FTS5 support is available.` diff --git a/src/lib/db/migrations/095_provider_node_custom_headers.sql b/src/lib/db/migrations/095_provider_node_custom_headers.sql new file mode 100644 index 0000000000..b2952eafb2 --- /dev/null +++ b/src/lib/db/migrations/095_provider_node_custom_headers.sql @@ -0,0 +1,5 @@ +-- Add custom_headers_json column to provider_nodes +-- Stores JSON object of custom HTTP headers to send with requests to this provider +-- NULL = no custom headers (backward compatible) +-- Column uses _json suffix so rowToCamel auto-parses it +ALTER TABLE provider_nodes ADD COLUMN custom_headers_json TEXT; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index d593e492be..d328c41dc9 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -12,6 +12,7 @@ import { } from "./encryption"; import { invalidateDbCache } from "./readCache"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; +import { bumpProxyConfigGeneration } from "./settings"; type JsonRecord = Record<string, unknown>; @@ -73,6 +74,17 @@ function withNullableRateLimitOverrides( }; } +function normalizeBooleanColumn(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } + return fallback; +} + // Sanitize the per-connection rate limit overrides map: keep only known // fields with valid numeric values. Called once at each write-path boundary. function sanitizeRateLimitOverrides(value: unknown): Record<string, number> | null { @@ -316,6 +328,8 @@ export async function createProviderConnection(data: JsonRecord) { isActive: data.isActive !== undefined ? data.isActive : true, createdAt: now, updatedAt: now, + proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true), + perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false), }; // Optional fields @@ -372,9 +386,7 @@ export async function createProviderConnection(data: JsonRecord) { // Same sanitization for rateLimitOverrides — keep in-memory representation // in sync with what gets persisted. if ("rateLimitOverrides" in connection) { - connection.rateLimitOverrides = sanitizeRateLimitOverrides( - connection.rateLimitOverrides - ); + connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides); } _insertConnectionRow(db, encryptConnectionFields({ ...connection })); @@ -462,8 +474,8 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) { lastUsedAt: conn.lastUsedAt || null, group: conn.group || null, maxConcurrent: conn.maxConcurrent ?? null, - proxyEnabled: conn.proxyEnabled ?? 1, - perKeyProxyEnabled: conn.perKeyProxyEnabled ?? 0, + proxyEnabled: normalizeBooleanColumn(conn.proxyEnabled, true) ? 1 : 0, + perKeyProxyEnabled: normalizeBooleanColumn(conn.perKeyProxyEnabled, false) ? 1 : 0, quotaWindowThresholdsJson: serializeQuotaWindowThresholds(conn.quotaWindowThresholds), rateLimitOverridesJson: serializeRateLimitOverrides(conn.rateLimitOverrides), createdAt: conn.createdAt, @@ -541,18 +553,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { group: data.group || null, maxConcurrent: data.maxConcurrent ?? null, quotaWindowThresholdsJson: serializeQuotaWindowThresholds(data.quotaWindowThresholds), - proxyEnabled: - typeof data.proxyEnabled === "boolean" - ? data.proxyEnabled - ? 1 - : 0 - : (data.proxyEnabled ?? 1), - perKeyProxyEnabled: - typeof data.perKeyProxyEnabled === "boolean" - ? data.perKeyProxyEnabled - ? 1 - : 0 - : (data.perKeyProxyEnabled ?? 0), + proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true) ? 1 : 0, + perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false) ? 1 : 0, rateLimitOverridesJson: serializeRateLimitOverrides(data.rateLimitOverrides), updatedAt: now, }); @@ -586,6 +588,7 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache + bumpProxyConfigGeneration(); if (data.priority !== undefined) { const existingRecord = toRecord(existing); @@ -612,6 +615,7 @@ export async function deleteProviderConnection(id: string) { db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?").run(id); db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); + bumpProxyConfigGeneration(); const existingRecord = toRecord(existing); const providerId = typeof existingRecord.provider === "string" @@ -783,6 +787,8 @@ export async function createProviderNode(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); + const customHeadersJson = data.customHeaders ? JSON.stringify(data.customHeaders) : null; + const node = { id: data.id || uuidv4(), type: data.type, @@ -792,19 +798,32 @@ export async function createProviderNode(data: JsonRecord) { baseUrl: data.baseUrl || null, chatPath: data.chatPath || null, modelsPath: data.modelsPath || null, + customHeadersJson, createdAt: now, updatedAt: now, }; db.prepare( ` - INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, created_at, updated_at) - VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @createdAt, @updatedAt) + INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, custom_headers_json, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @customHeadersJson, @createdAt, @updatedAt) ` ).run(node); backupDbFile("pre-write"); - return node; + + const result: JsonRecord = { ...node }; + if (customHeadersJson) { + try { + result.customHeaders = JSON.parse(customHeadersJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; } export async function updateProviderNode(id: string, data: JsonRecord) { @@ -818,11 +837,23 @@ export async function updateProviderNode(id: string, data: JsonRecord) { updatedAt: new Date().toISOString(), }; + if (data.customHeaders !== undefined) { + merged["customHeadersJson"] = data.customHeaders ? JSON.stringify(data.customHeaders) : null; + } else { + // Partial update that omits customHeaders must PRESERVE the stored value. + // rowToCamel surfaces the column under `customHeaders` (suffix stripped), + // never `customHeadersJson`, so read the raw stored JSON from `existing` + // directly instead of relying on the (absent) merged key — otherwise the + // UPDATE would bind null and silently wipe the saved headers. + const existingJson = (existing as JsonRecord).custom_headers_json; + merged["customHeadersJson"] = typeof existingJson === "string" ? existingJson : null; + } + db.prepare( ` UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath, - models_path = @modelsPath, updated_at = @updatedAt + models_path = @modelsPath, custom_headers_json = @customHeadersJson, updated_at = @updatedAt WHERE id = @id ` ).run({ @@ -834,11 +865,25 @@ export async function updateProviderNode(id: string, data: JsonRecord) { baseUrl: merged["baseUrl"] || null, chatPath: merged["chatPath"] || null, modelsPath: merged["modelsPath"] || null, + customHeadersJson: merged["customHeadersJson"] || null, updatedAt: merged["updatedAt"], }); backupDbFile("pre-write"); - return merged; + + const result: JsonRecord = { ...merged }; + const storedJson = merged["customHeadersJson"] as string | null; + if (storedJson) { + try { + result.customHeaders = JSON.parse(storedJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; } export async function deleteProviderNode(id: string) { diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index ff5c5f06c8..6aab3d1bfd 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -644,6 +644,33 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: return result; } + let connectionRecord: JsonRecord | null = null; + let connectionProvider: string | null = null; + let connectionProxyEnabled = true; + let connectionPerKeyProxyEnabled = false; + + const row = db + .prepare( + "SELECT provider, proxy_enabled, per_key_proxy_enabled FROM provider_connections WHERE id = ?" + ) + .get(connectionId); + if (row) { + connectionRecord = toRecord(row); + connectionProvider = + typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; + connectionProxyEnabled = connectionRecord.proxy_enabled !== 0; + connectionPerKeyProxyEnabled = connectionRecord.per_key_proxy_enabled === 1; + } + + // A connection-level Proxy Off is explicit: it must bypass every stored proxy + // source for this connection, including account, provider, global, and automatic + // fallback candidates from the proxy pool. + if (connectionRecord && !connectionProxyEnabled) { + const result: ProxyResolutionResult = { proxy: null, level: "direct", levelId: null }; + cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + return result; + } + // Step 1.5: Check global perKeyProxyEnabled setting let globalPerKeyProxyEnabled = false; try { @@ -664,17 +691,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: // Step 2: API key-level proxy (only if per-key proxy is enabled globally or per-connection) if (apiKeyId) { // Check if per-key proxy is allowed: globally OR per-connection - let perKeyEnabled = globalPerKeyProxyEnabled; - if (!perKeyEnabled && connectionId) { - try { - const perKeyConn = db - .prepare("SELECT per_key_proxy_enabled FROM provider_connections WHERE id = ?") - .get(connectionId) as { per_key_proxy_enabled?: number } | undefined; - perKeyEnabled = perKeyConn?.per_key_proxy_enabled === 1; - } catch { - // Fall through - } - } + const perKeyEnabled = globalPerKeyProxyEnabled || connectionPerKeyProxyEnabled; if (perKeyEnabled) { try { @@ -726,21 +743,14 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: return result; } - // Step 5: Look up the connection's provider and check proxy_enabled - const connection = db - .prepare("SELECT provider, proxy_enabled FROM provider_connections WHERE id = ?") - .get(connectionId); - - if (connection) { - const connectionRecord = toRecord(connection); - const provider = - typeof connectionRecord.provider === "string" ? connectionRecord.provider : null; - // proxy_enabled defaults to 0 (false) when the column is NULL (pre-migration) - const connProxyEnabled = connectionRecord.proxy_enabled === 1; - + // Step 5: Use the connection's provider for provider/combo scoped proxies. + if (connectionRecord) { // Step 6: Provider-level registry (only if proxy_enabled) - if (provider && connProxyEnabled) { - const registryProvider = await resolveProxyForScopeFromRegistry("provider", provider); + if (connectionProvider && connectionProxyEnabled) { + const registryProvider = await resolveProxyForScopeFromRegistry( + "provider", + connectionProvider + ); if (registryProvider?.proxy) { cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryProvider); return registryProvider; @@ -748,7 +758,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: } // Step 7: Legacy combo-level (only if proxy_enabled) - if (connProxyEnabled && config.combos && Object.keys(config.combos).length > 0) { + if (connectionProxyEnabled && config.combos && Object.keys(config.combos).length > 0) { const combos = db.prepare("SELECT id, data FROM combos").all(); for (const comboRow of combos) { const comboRecord = toRecord(comboRow); @@ -760,7 +770,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: const combo = toRecord(JSON.parse(comboRaw)); const comboModels = Array.isArray(combo.models) ? combo.models : []; const usesProvider = comboModels.some( - (entry) => getComboModelProvider(entry) === provider + (entry) => getComboModelProvider(entry) === connectionProvider ); if (usesProvider) { const result = { proxy: config.combos[comboId], level: "combo", levelId: comboId }; @@ -775,11 +785,11 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: } // Step 8: Legacy provider-level (only if proxy_enabled) - if (provider && connProxyEnabled && config.providers?.[provider]) { + if (connectionProvider && connectionProxyEnabled && config.providers?.[connectionProvider]) { const result = { - proxy: config.providers[provider], + proxy: config.providers[connectionProvider], level: "provider", - levelId: provider, + levelId: connectionProvider, }; cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); return result; diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index 6ec3afa87b..e3ca235de3 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -226,7 +226,7 @@ export class KiroService { return { accessToken: retryData.accessToken, refreshToken: retryData.refreshToken || refreshToken, - expiresIn: retryData.expiresIn, + expiresIn: retryData.expiresIn || 3600, _newClientId: newReg.clientId, _newClientSecret: newReg.clientSecret, _newClientSecretExpiresAt: newReg.clientSecretExpiresAt, @@ -246,9 +246,13 @@ export class KiroService { const data = await response.json(); return { + // Builder ID / IDC OIDC refresh: no profileArn (the social path supplies + // one; Builder ID connections legitimately have none). expiresIn falls + // back to 3600 so the import route never computes Date(NaN) if upstream + // omits it (the social path already guards the same way). accessToken: data.accessToken, refreshToken: data.refreshToken || refreshToken, - expiresIn: data.expiresIn, + expiresIn: data.expiresIn || 3600, }; } @@ -279,9 +283,9 @@ export class KiroService { /** * Validate and import refresh token. - * Registers a dedicated OIDC client so this connection has an isolated refresh session. - * If registerClient() fails (network issue, OIDC service down), the import still - * succeeds and the connection falls back to the shared social-auth refresh path. + * First attempts to validate using cached AWS SSO client credentials (Builder ID path). + * If that fails or no cached credentials exist, registers a dedicated OIDC client. + * If registerClient() also fails, the import falls back to the shared social-auth refresh path. */ async validateImportToken(refreshToken: string, region: string = "us-east-1") { // Validate token format @@ -289,7 +293,38 @@ export class KiroService { throw new Error("Invalid token format. Token should start with aorAAAAAG..."); } - // Try to refresh to validate + // Try to read cached clientId/clientSecret from AWS SSO cache (Builder ID tokens) + const cachedClient = await this.readCachedClientCredentials(region); + + // Attempt 1: Try Builder ID refresh using cached credentials + if (cachedClient) { + try { + const result = await this.refreshToken(refreshToken, { + clientId: cachedClient.clientId, + clientSecret: cachedClient.clientSecret, + authMethod: "builder-id", + // Forward the requested region so a non-us-east-1 Builder ID validates + // against the right OIDC endpoint instead of defaulting to us-east-1. + region, + }); + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken || refreshToken, + // profileArn is intentionally absent for Builder ID (OIDC) imports — + // only the social-auth path returns one, and Builder ID connections + // don't require it. The Kiro executor adds profileArn conditionally. + profileArn: result.profileArn, + expiresIn: result.expiresIn, + authMethod: "builder-id", + clientId: cachedClient.clientId, + clientSecret: cachedClient.clientSecret, + }; + } catch { + // Cached credentials didn't work, fall through + } + } + + // Attempt 2: Try social auth refresh let result: Awaited<ReturnType<typeof this.refreshToken>>; try { result = await this.refreshToken(refreshToken); @@ -321,6 +356,63 @@ export class KiroService { }; } + /** + * Read clientId/clientSecret from AWS SSO cache (for Builder ID tokens). + * The cache is located at ~/.aws/sso/cache/ and contains JSON files from + * the OIDC client registration step of the device code flow. + */ + private async readCachedClientCredentials( + region?: string + ): Promise<{ clientId: string; clientSecret: string } | null> { + try { + const { readdir, readFile } = await import("fs/promises"); + const { homedir } = await import("os"); + const { join } = await import("path"); + const cachePath = join(homedir(), ".aws", "sso", "cache"); + const files = await readdir(cachePath); + + const candidates: { + clientId: string; + clientSecret: string; + region?: string; + expiresAt?: string; + }[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const content = await readFile(join(cachePath, file), "utf-8"); + const data = JSON.parse(content); + if (data.clientId && data.clientSecret) { + candidates.push({ + clientId: data.clientId, + clientSecret: data.clientSecret, + region: data.region, + expiresAt: data.clientSecretExpiresAt || data.expiresAt, + }); + } + } catch { + continue; + } + } + if (candidates.length === 0) return null; + + // A host can cache OIDC client registrations for several SSO sessions; + // adopting the wrong pair makes the Builder ID refresh fail. Prefer a + // registration whose region matches the requested import region, then — + // among the candidates — the one with the latest secret expiry, instead + // of blindly taking the first file readdir happens to return. + const byLatestExpiry = (a: { expiresAt?: string }, b: { expiresAt?: string }): number => + String(b.expiresAt || "").localeCompare(String(a.expiresAt || "")); + const matching = region ? candidates.filter((c) => c.region === region) : []; + const ordered = (matching.length > 0 ? matching : candidates).slice().sort(byLatestExpiry); + const best = ordered[0]; + return { clientId: best.clientId, clientSecret: best.clientSecret }; + } catch { + // Cache not available + } + return null; + } + /** * Fetch user email from access token (optional, for display) */ diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 508b6502c6..c9282d03cc 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Card from "./Card"; import { CardSkeleton } from "./Loading"; import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting"; +import { readFetchErrorMessage } from "@/shared/utils/fetchError"; import { StatCard, CompactStatGrid, @@ -59,7 +60,7 @@ export default function UsageAnalytics() { params.set("apiKeyIds", selectedApiKeys.join(",")); } const res = await fetch(`/api/usage/analytics?${params.toString()}`); - if (!res.ok) throw new Error(tCommon("error")); + if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error"))); const data = await res.json(); setAnalytics(data); setError(null); diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index feb478ecd4..f039bc4083 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -273,7 +273,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = { notes: [ { type: "warning", - text: "Config path: Linux/macOS ~/.config/opencode/opencode.json • Windows %APPDATA%\\\\opencode\\\\opencode.json", + text: "Config path: ~/.config/opencode/opencode.json on all platforms (Windows: %USERPROFILE%\\\\.config\\\\opencode\\\\opencode.json)", }, { type: "warning", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index f7d273c42e..faf5427307 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -117,6 +117,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "PROXY_AUTO_SELECT_ENABLED", + label: "Proxy Auto-Selection Fallback", + description: + "When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default — otherwise any single registry proxy becomes a global fallback for all traffic (#3332).", + descriptionI18nKey: "settings.featureFlags.proxyAutoSelectEnabled", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index b921808d95..ea36a217a2 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -69,6 +69,22 @@ export const MODEL_SPECS: Record<string, ModelSpec> = { aliases: ["openai/gpt-4o"], }, + // ── Gemini 2.5 and 3.5 Flash series ────────────────────────────── + "gemini-2.5-flash": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: false, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.5-flash-low": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: false, + supportsTools: true, + supportsVision: true, + }, + // ── Gemini 3 Flash series ─────────────────────────────────────── "gemini-3-flash": { maxOutputTokens: 65536, diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts index b57f34f398..ba990dfd87 100644 --- a/src/shared/constants/upstreamHeaders.ts +++ b/src/shared/constants/upstreamHeaders.ts @@ -20,3 +20,23 @@ const FORBIDDEN = new Set( export function isForbiddenUpstreamHeaderName(name: string): boolean { return FORBIDDEN.has(String(name).trim().toLowerCase()); } + +/** + * Auth headers that must come from the connection's credentials, never from + * operator-set per-provider custom headers. Kept here as the single source of + * truth so the Zod `customHeadersSchema` (schemas.ts) and the executor's + * apply-loop (open-sse/executors/default.ts) cannot drift apart. + */ +const FORBIDDEN_AUTH = new Set( + ["authorization", "x-api-key", "x-goog-api-key", "api-key"].map((s) => s.toLowerCase()) +); + +/** + * Forbidden for operator-supplied custom headers: the hop-by-hop/framing set + * PLUS auth headers (owned by the credential layer). Use this in both the + * validation schema and the executor so there is one canonical denylist. + */ +export function isForbiddenCustomHeaderName(name: string): boolean { + const n = String(name).trim().toLowerCase(); + return isForbiddenUpstreamHeaderName(n) || FORBIDDEN_AUTH.has(n); +} diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 9ca853eaa7..2041071bc6 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -932,16 +932,15 @@ export const getCliConfigHome = () => { }; export const resolveOpencodeConfigDir = ( - platform = process.platform, + _platform = process.platform, env: NodeJS.ProcessEnv = process.env, homeDir = os.homedir() ) => { - const isWin = platform === "win32"; - if (isWin) { - const appData = String(env.APPDATA || "").trim(); - return appData || path.join(homeDir, "AppData", "Roaming"); - } - + // #3330: OpenCode reads its config from XDG `~/.config/opencode/` on ALL + // platforms — including Windows, where it uses `%USERPROFILE%\.config`, NOT + // `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode + // never looks, so dashboard-saved config silently had no effect. `_platform` + // is kept in the signature for call-site/test compatibility. const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim(); return xdgConfigHome || path.join(homeDir, ".config"); }; diff --git a/src/shared/utils/fetchError.ts b/src/shared/utils/fetchError.ts new file mode 100644 index 0000000000..60c4f7810c --- /dev/null +++ b/src/shared/utils/fetchError.ts @@ -0,0 +1,26 @@ +/** + * Extract a human-readable message from a failed `fetch` Response body. + * + * Handles both response shapes OmniRoute routes emit: + * - OpenAI-style `{ error: { message, type, code } }` (from `buildErrorBody`) + * - legacy `{ error: "..." }` string bodies + * + * The server already sanitizes these messages (stack traces / absolute paths + * stripped via `sanitizeErrorMessage`), so surfacing them in the UI is safe. + * Falls back to `fallback` when the body is absent, unparseable, or carries no + * usable message. Never throws -- safe to call directly inside a fetch guard. + */ +export async function readFetchErrorMessage(res: Response, fallback: string): Promise<string> { + try { + const body = (await res.json()) as unknown; + const err = (body as { error?: unknown } | null)?.error; + if (typeof err === "string" && err.trim()) return err.trim(); + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message.trim(); + } + } catch { + // Non-JSON body (e.g. an HTML 500 page) or a read failure -> use the fallback. + } + return fallback; +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index c9e2b0609a..df33ca90c2 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -8,7 +8,10 @@ import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/c import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; -import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; +import { + isForbiddenUpstreamHeaderName, + isForbiddenCustomHeaderName, +} from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; function isHttpUrl(value: string): boolean { @@ -1917,6 +1920,21 @@ export const updateKeyPermissionsSchema = z } }); +// Reuse the canonical upstream-headers record schema (control-char / whitespace +// / ":" / 128-name / 4096-value / max-16 guards) so per-provider custom headers +// inherit the same hardening as `modelCompat.upstreamHeaders` — then additionally +// reject auth header names (the credential layer owns those; the executor drops +// them at send time, so reject up front for an actionable error instead of a +// silent no-op). Single denylist source: isForbiddenCustomHeaderName. +const customHeadersSchema = upstreamHeadersRecordSchema + .refine((rec) => !Object.keys(rec).some((k) => isForbiddenCustomHeaderName(k)), { + message: + "Custom headers cannot include hop-by-hop, framing, or auth headers " + + "(authorization / x-api-key / x-goog-api-key / api-key)", + }) + .nullable() + .optional(); + export const createProviderNodeSchema = z .object({ name: z.string().trim().min(1, "Name is required"), @@ -1936,6 +1954,7 @@ export const createProviderNodeSchema = z compatMode: z.enum(["cc"]).optional(), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + customHeaders: customHeadersSchema, }) .superRefine((value, ctx) => { const nodeType = value.type || "openai-compatible"; @@ -1964,6 +1983,7 @@ export const updateProviderNodeSchema = z.object({ baseUrl: z.string().trim().min(1, "Base URL is required"), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + customHeaders: customHeadersSchema, }); export const providerNodeValidateSchema = z.object({ diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 620e35a412..7dc74cb87b 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -724,6 +724,46 @@ async function selectSessionAffinityConnection( */ const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth"; +function buildSyntheticNoAuthCredentials(): { + apiKey: null; + accessToken: null; + refreshToken: null; + expiresAt: null; + projectId: null; + copilotToken: null; + providerSpecificData: Record<string, never>; + connectionId: typeof SYNTHETIC_NOAUTH_CONNECTION_ID; + testStatus: "active"; + lastError: null; + lastErrorType: null; + lastErrorSource: null; + errorCode: null; + rateLimitedUntil: null; + maxConcurrent: null; + allRateLimited?: never; + allExpired?: never; + retryAfter?: never; + retryAfterHuman?: never; +} { + return { + apiKey: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + projectId: null, + copilotToken: null, + providerSpecificData: {}, + connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, + testStatus: "active", + lastError: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + maxConcurrent: null, + }; +} + function normalizeExcludedConnectionIds( excludeConnectionId: string | null, extraExcludedConnectionIds: string[] | null | undefined @@ -903,23 +943,7 @@ export async function getProviderCredentials( if (excludedForNoAuth.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { return null; } - return { - apiKey: null, - accessToken: null, - refreshToken: null, - expiresAt: null, - projectId: null, - copilotToken: null, - providerSpecificData: {}, - connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, - testStatus: "active", - lastError: null, - lastErrorType: null, - lastErrorSource: null, - errorCode: null, - rateLimitedUntil: null, - maxConcurrent: null, - }; + return buildSyntheticNoAuthCredentials(); } const allowSuppressedConnections = options.allowSuppressedConnections === true; @@ -1031,23 +1055,7 @@ export async function getProviderCredentials( if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { return null; } - return { - apiKey: null, - accessToken: null, - refreshToken: null, - expiresAt: null, - projectId: null, - copilotToken: null, - providerSpecificData: {}, - connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, - testStatus: "active", - lastError: null, - lastErrorType: null, - lastErrorSource: null, - errorCode: null, - rateLimitedUntil: null, - maxConcurrent: null, - }; + return buildSyntheticNoAuthCredentials(); } log.warn("AUTH", `No credentials for ${provider}`); return null; @@ -1218,6 +1226,13 @@ export async function getProviderCredentials( cooldownModel: allBlockedByModelCooldown ? requestedModel : null, }; } + if (resolvedId === "opencode-zen") { + if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { + return null; + } + return buildSyntheticNoAuthCredentials(); + } + log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); return null; } diff --git a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts index fd9054d686..453a31b67a 100644 --- a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts +++ b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts @@ -19,6 +19,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); +const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); test.after(() => { core.resetDbInstance(); @@ -33,11 +34,7 @@ test("#2962 opencode-zen with no connection falls back to anonymous no-auth cred "noauth", "should be synthetic no-auth credentials" ); - assert.equal( - (creds as { apiKey?: unknown }).apiKey, - null, - "anonymous access carries no api key" - ); + assert.equal((creds as { apiKey?: unknown }).apiKey, null, "anonymous access carries no api key"); }); test("#2962 a normal api-key provider with no connection still returns null (no over-broadening)", async () => { @@ -46,3 +43,19 @@ test("#2962 a normal api-key provider with no connection still returns null (no const connectionId = (creds as { connectionId?: string } | null)?.connectionId; assert.notEqual(connectionId, "noauth", "openai must not get anonymous no-auth credentials"); }); + +test("#2962 opencode-zen falls back to no-auth when saved key rows are unusable", async () => { + await createProviderConnection({ + provider: "opencode-zen", + authType: "apikey", + name: "expired-test-key", + apiKey: "oa_test_expired", + isActive: true, + testStatus: "expired", + }); + + const creds = await getProviderCredentials("opencode-zen"); + assert.ok(creds, "opencode-zen should still resolve to anonymous no-auth credentials"); + assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); + assert.equal((creds as { apiKey?: unknown }).apiKey, null); +}); diff --git a/tests/unit/autoRefreshDaemon.test.ts b/tests/unit/autoRefreshDaemon.test.ts deleted file mode 100644 index 3ea6cd2a48..0000000000 --- a/tests/unit/autoRefreshDaemon.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Tests for AutoRefreshDaemon - */ - -import assert from "node:assert/strict"; -import { describe, it, before, after, beforeEach } from "node:test"; -import { autoRefreshDaemon, type DaemonStatus } from "../../open-sse/services/autoRefreshDaemon"; - -describe("AutoRefreshDaemon", () => { - beforeEach(() => { - autoRefreshDaemon.clearExpired(); - autoRefreshDaemon.stop(); - }); - - describe("start / stop", () => { - it("should start and stop cleanly", () => { - let status = autoRefreshDaemon.getStatus(); - assert.equal(status.running, false); - - autoRefreshDaemon.start(); - status = autoRefreshDaemon.getStatus(); - assert.equal(status.running, true); - - autoRefreshDaemon.stop(); - status = autoRefreshDaemon.getStatus(); - assert.equal(status.running, false); - }); - - it("should be idempotent on start", () => { - autoRefreshDaemon.start(); - autoRefreshDaemon.start(); // second call should noop - assert.equal(autoRefreshDaemon.getStatus().running, true); - autoRefreshDaemon.stop(); - }); - - it("should be idempotent on stop", () => { - autoRefreshDaemon.stop(); // not running — noop - autoRefreshDaemon.start(); - autoRefreshDaemon.stop(); - autoRefreshDaemon.stop(); // already stopped — noop - assert.equal(autoRefreshDaemon.getStatus().running, false); - }); - }); - - describe("registerCredential / unregisterCredential", () => { - it("should register and track a credential", () => { - autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123"); - const status = autoRefreshDaemon.getStatus(); - assert.equal(status.checkedProviderCount, 1); - }); - - it("should unregister and stop tracking", () => { - autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123"); - assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 1); - - autoRefreshDaemon.unregisterCredential("test-provider"); - assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 0); - }); - }); - - describe("clearExpired", () => { - it("should clear expired list", () => { - // Force-expire by registering and running check against a provider that - // won't resolve — expired list stays empty since network errors are non-fatal - autoRefreshDaemon.registerCredential("fake-nonexistent", "test-value"); - assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0); - - autoRefreshDaemon.clearExpired(); - assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0); - }); - }); - - describe("getStatus", () => { - it("should return current daemon state", () => { - autoRefreshDaemon.registerCredential("provider-a", "val-a"); - autoRefreshDaemon.start(); - const status = autoRefreshDaemon.getStatus(); - - assert.ok(typeof status.running === "boolean"); - assert.ok(typeof status.checkedProviderCount === "number"); - assert.ok(Array.isArray(status.expiredCredentials)); - assert.ok(typeof status.lastRun === "number" || status.lastRun === null); - - autoRefreshDaemon.stop(); - }); - }); -}); diff --git a/tests/unit/browserBackedChat-matcher.test.ts b/tests/unit/browserBackedChat-matcher.test.ts new file mode 100644 index 0000000000..9e0056b4ac --- /dev/null +++ b/tests/unit/browserBackedChat-matcher.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { chatUrlMatcher } from "../../open-sse/services/browserBackedChat.ts"; + +describe("chatUrlMatcher", () => { + it("matches exact URL", () => { + assert.equal( + chatUrlMatcher( + "https://claude.ai/api/org/chat_conv/abc/completion", + "claude.ai", + "https://claude.ai/api/org/chat_conv/abc/completion" + ), + true + ); + }); + + it("matches with dynamic conversation id", () => { + assert.equal( + chatUrlMatcher( + "https://claude.ai/api/organizations/o1/chat_conversations/2ae3a64b-7e38-4e80-9d0d-5717dc425a4b/completion", + "claude.ai", + "https://claude.ai/api/organizations/o1/chat_conversations/PLACEHOLDER/completion" + ), + true + ); + }); + + it("matches DDG chat URL exactly", () => { + assert.equal( + chatUrlMatcher( + "https://duck.ai/duckchat/v1/chat", + "duck.ai", + "https://duck.ai/duckchat/v1/chat" + ), + true + ); + }); + + it("rejects when domain differs", () => { + assert.equal( + chatUrlMatcher("https://attacker.com/api/x", "claude.ai", "https://claude.ai/api/x"), + false + ); + }); + + it("rejects when path length differs", () => { + assert.equal( + chatUrlMatcher( + "https://claude.ai/api/chat/x", + "claude.ai", + "https://claude.ai/api/chat/x/y/z" + ), + false + ); + }); + + it("rejects when more than one segment differs", () => { + assert.equal( + chatUrlMatcher("https://claude.ai/api/A/B/C/D", "claude.ai", "https://claude.ai/api/A/X/Y/D"), + false + ); + }); + + it("rejects when subdomain is a prefix but not the configured matchDomain", () => { + assert.equal( + chatUrlMatcher( + "https://claude.ai.attacker.com/api/x", + "claude.ai", + "https://claude.ai/api/x" + ), + false + ); + }); +}); diff --git a/tests/unit/cli-runtime-detection.test.ts b/tests/unit/cli-runtime-detection.test.ts index 55a8fa7279..4306ebdf34 100644 --- a/tests/unit/cli-runtime-detection.test.ts +++ b/tests/unit/cli-runtime-detection.test.ts @@ -231,23 +231,28 @@ describe("resolveOpencodeConfigPath — cross-platform", () => { assert.equal(result, path.join("/home/dev", ".config", "opencode", "opencode.json")); }); - it("should resolve on Windows with APPDATA", () => { + it("should resolve on Windows under ~/.config (XDG, NOT %APPDATA% — #3330)", () => { + // #3330: OpenCode reads its config from ~/.config/opencode on every + // platform, including Windows (%USERPROFILE%\.config). %APPDATA% is ignored. const result = resolveOpencodeConfigPathFn( "win32", { APPDATA: "C:\\Users\\dev\\AppData\\Roaming" }, "C:\\Users\\dev" ); - assert.equal( - result, - path.join("C:\\Users\\dev\\AppData\\Roaming", "opencode", "opencode.json") - ); + assert.equal(result, path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json")); }); - it("should fallback to home/AppData/Roaming on Windows without APPDATA", () => { + it("should resolve on Windows under ~/.config without APPDATA (#3330)", () => { const result = resolveOpencodeConfigPathFn("win32", {}, "C:\\Users\\dev"); - assert.equal( - result, - path.join("C:\\Users\\dev", "AppData", "Roaming", "opencode", "opencode.json") + assert.equal(result, path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json")); + }); + + it("should honor XDG_CONFIG_HOME on Windows too (#3330)", () => { + const result = resolveOpencodeConfigPathFn( + "win32", + { XDG_CONFIG_HOME: "D:\\xdg" }, + "C:\\Users\\dev" ); + assert.equal(result, path.join("D:\\xdg", "opencode", "opencode.json")); }); }); diff --git a/tests/unit/cli-runtime-extended.test.ts b/tests/unit/cli-runtime-extended.test.ts index cceca1a3aa..d81af103a0 100644 --- a/tests/unit/cli-runtime-extended.test.ts +++ b/tests/unit/cli-runtime-extended.test.ts @@ -85,10 +85,9 @@ test("CLI config helpers enforce safe config homes and expose per-tool config pa assert.equal(cliRuntime.getCliConfigPaths("unknown"), null); process.env.XDG_CONFIG_HOME = path.join(homeDir, ".config-test"); - const expectedOpencodeRoot = - process.platform === "win32" - ? process.env.APPDATA || path.join(homeDir, "AppData", "Roaming") - : process.env.XDG_CONFIG_HOME; + // #3330: OpenCode uses XDG (`~/.config` / $XDG_CONFIG_HOME) on every platform, + // including Windows — no %APPDATA% special-case. + const expectedOpencodeRoot = process.env.XDG_CONFIG_HOME; assert.deepEqual(cliRuntime.getCliConfigPaths("opencode"), { config: path.join(expectedOpencodeRoot, "opencode", "opencode.json"), }); diff --git a/tests/unit/compression/lite.test.ts b/tests/unit/compression/lite.test.ts index 6070f7ea2d..26707ab938 100644 --- a/tests/unit/compression/lite.test.ts +++ b/tests/unit/compression/lite.test.ts @@ -167,6 +167,24 @@ describe("replaceImageUrls", () => { assert.equal(result.applied, false); }); + // #3328: MiniMax M3 is multimodal (verified: it describes a base64 image via the + // opencode upstream). It must not be treated as a non-vision model, or compression + // strips the image and the model goes "blind". + it("keeps images for MiniMax M3 (incl. provider-prefixed / free ids)", () => { + const mkBody = () => ({ + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBOR" } }], + }, + ], + }); + for (const id of ["minimax-m3", "minimax-m3-free", "oc/minimax-m3-free"]) { + const result = replaceImageUrls(mkBody(), id); + assert.equal(result.applied, false, `expected images kept for ${id}`); + } + }); + it("skips non-image content", () => { const body = { messages: [{ role: "user", content: "just text" }], diff --git a/tests/unit/custom-headers-provider-nodes.test.ts b/tests/unit/custom-headers-provider-nodes.test.ts new file mode 100644 index 0000000000..17646b37f9 --- /dev/null +++ b/tests/unit/custom-headers-provider-nodes.test.ts @@ -0,0 +1,606 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-custom-headers-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts"); +const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts"); +const { createProviderNodeSchema, updateProviderNodeSchema } = + await import("../../src/shared/validation/schemas.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body: Record<string, unknown>) { + return new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeUpdateRequest(id: string, body: Record<string, unknown>) { + return new Request(`http://localhost/api/provider-nodes/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("createProviderNodeSchema accepts valid customHeaders as record of strings", () => { + const validInputs = [ + { name: "Test", prefix: "test", apiType: "chat", customHeaders: { "X-Custom-1": "value1" } }, + { + name: "Test", + prefix: "test", + apiType: "chat", + customHeaders: { "X-Header": "value", "X-Another": "value2" }, + }, + { name: "Test", prefix: "test", apiType: "chat", customHeaders: {} }, + { name: "Test", prefix: "test", apiType: "chat" }, + ]; + + for (const input of validInputs) { + const result = createProviderNodeSchema.safeParse(input); + assert.equal(result.success, true, `Should accept: ${JSON.stringify(input)}`); + } +}); + +test("createProviderNodeSchema rejects customHeaders with non-string values", () => { + const invalidInputs = [ + { customHeaders: { "X-Custom": 123 } }, + { customHeaders: { "X-Custom": null } }, + { customHeaders: { "X-Custom": true } }, + { customHeaders: { "X-Custom": ["array"] } }, + { customHeaders: { "X-Custom": { nested: "object" } } }, + ]; + + for (const input of invalidInputs) { + const result = createProviderNodeSchema.safeParse(input); + assert.equal(result.success, false, `Should reject: ${JSON.stringify(input)}`); + } +}); + +test("createProviderNodeSchema rejects forbidden hop-by-hop headers", () => { + const forbiddenHeaders = [ + "host", + "connection", + "content-length", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "te", + "trailer", + "upgrade", + ]; + + for (const header of forbiddenHeaders) { + const result = createProviderNodeSchema.safeParse({ + customHeaders: { [header]: "value" }, + }); + assert.equal(result.success, false, `Should reject forbidden header: ${header}`); + } + + const result = createProviderNodeSchema.safeParse({ + customHeaders: { HOST: "evil", "Content-Length": "999" }, + }); + assert.equal(result.success, false, "Should reject case-insensitive forbidden headers"); +}); + +test("updateProviderNodeSchema accepts valid customHeaders", () => { + const validInputs = [ + { customHeaders: { "X-Updated": "new-value" } }, + { customHeaders: { "X-A": "v1", "X-B": "v2" } }, + { customHeaders: null }, + {}, + ]; + + for (const input of validInputs) { + const result = updateProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + baseUrl: "https://test.com", + ...input, + }); + assert.equal(result.success, true, `Should accept: ${JSON.stringify(input)}`); + } +}); + +test("updateProviderNodeSchema rejects forbidden headers", () => { + const result = updateProviderNodeSchema.safeParse({ + name: "Test", + prefix: "test", + baseUrl: "https://test.com", + customHeaders: { host: "evil.com" }, + }); + assert.equal(result.success, false); +}); + +test("provider nodes route creates OpenAI-compatible nodes with customHeaders", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + name: "Custom Headers Node", + prefix: "custom-headers", + apiType: "chat", + baseUrl: "https://custom.example.com/v1", + customHeaders: { + "X-Custom-Auth": "auth-token-123", + "X-Request-ID": "req-abc", + }, + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`)); + assert.deepEqual(body.node.customHeaders, { + "X-Custom-Auth": "auth-token-123", + "X-Request-ID": "req-abc", + }); +}); + +test("provider nodes route creates nodes without customHeaders (null)", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + name: "No Custom Headers", + prefix: "no-custom", + apiType: "chat", + baseUrl: "https://nocustom.example.com/v1", + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 201); + assert.equal(body.node.customHeaders, null); +}); + +test("provider nodes route update modifies customHeaders", async () => { + const createResponse = await providerNodesRoute.POST( + makeRequest({ + name: "Original Node", + prefix: "original", + apiType: "chat", + baseUrl: "https://original.example.com/v1", + customHeaders: { "X-Original": "original-value" }, + }) + ); + const created = (await createResponse.json()) as any; + const nodeId = created.node.id; + + const updateBody = { + name: "Updated Node", + prefix: "updated", + apiType: "chat", + baseUrl: "https://updated.example.com/v1", + customHeaders: { "X-Updated": "updated-value", "X-New": "new-header" }, + }; + const updateResponse = await providerNodesIdRoute.PUT(makeUpdateRequest(nodeId, updateBody), { + params: Promise.resolve({ id: nodeId }), + }); + const updated = (await updateResponse.json()) as any; + + assert.equal(updateResponse.status, 200); + assert.deepEqual(updated.node.customHeaders, { + "X-Updated": "updated-value", + "X-New": "new-header", + }); +}); + +test("provider nodes route update can clear customHeaders by passing null", async () => { + const createResponse = await providerNodesRoute.POST( + makeRequest({ + name: "Node With Headers", + prefix: "with-headers", + apiType: "chat", + baseUrl: "https://withheaders.example.com/v1", + customHeaders: { "X-Keep": "keep-value" }, + }) + ); + const created = (await createResponse.json()) as any; + const nodeId = created.node.id; + + const clearBody = { + name: "Node Without Headers", + prefix: "no-headers", + apiType: "chat", + baseUrl: "https://noclear.example.com/v1", + customHeaders: null, + }; + const updateResponse = await providerNodesIdRoute.PUT(makeUpdateRequest(nodeId, clearBody), { + params: Promise.resolve({ id: nodeId }), + }); + const updated = (await updateResponse.json()) as any; + + assert.equal(updateResponse.status, 200); + assert.equal(updated.node.customHeaders, null); +}); + +test("DefaultExecutor.buildHeaders applies customHeaders from providerSpecificData", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "X-Custom-Auth": "custom-auth-value", + "X-Request-ID": "request-123", + "X-Custom-Header": "extra-value", + }, + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["X-Custom-Auth"], "custom-auth-value"); + assert.equal(headers["X-Request-ID"], "request-123"); + assert.equal(headers["X-Custom-Header"], "extra-value"); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers["Authorization"], "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders does NOT override auth headers with customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + Authorization: "Bearer fake-token", + "x-api-key": "fake-key", + "X-Custom": "custom-value", + }, + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers.Authorization, "Bearer real-key"); + assert.equal(headers["x-api-key"], undefined); + assert.equal(headers["X-Custom"], "custom-value"); +}); + +test("DefaultExecutor.buildHeaders blocks forbidden hop-by-hop headers from customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + host: "evil.com", + "content-length": "999", + connection: "close", + "X-Legitimate": "good-header", + }, + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers.host, undefined); + assert.equal(headers["content-length"], undefined); + assert.equal(headers.connection, undefined); + assert.equal(headers["X-Legitimate"], "good-header"); +}); + +test("DefaultExecutor.buildHeaders handles string customHeaders (JSON parsed)", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: JSON.stringify({ "X-From-String": "parsed-value" }), + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["X-From-String"], "parsed-value"); +}); + +test("DefaultExecutor.buildHeaders handles invalid JSON in string customHeaders gracefully", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: "not valid json {", + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["X-From-String"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders handles array customHeaders gracefully", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: ["array", "not", "valid"], + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["X-From-String"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.buildHeaders handles null/undefined/empty customHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const nullHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: { customHeaders: null } }, + true + ) as Record<string, string>; + const undefinedHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: {} }, + true + ) as Record<string, string>; + const emptyHeaders = executor.buildHeaders( + { apiKey: "key", providerSpecificData: { customHeaders: {} } }, + true + ) as Record<string, string>; + + for (const headers of [nullHeaders, undefinedHeaders, emptyHeaders]) { + assert.equal(headers["X-Anything"], undefined); + assert.equal(headers.Authorization, "Bearer key"); + } +}); + +test("DefaultExecutor.buildHeaders customHeaders are case-sensitive for header names", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { + "x-lower": "lower-value", + "X-Upper": "upper-value", + "X-Mixed": "mixed-value", + }, + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["x-lower"], "lower-value"); + assert.equal(headers["X-Upper"], "upper-value"); + assert.equal(headers["X-Mixed"], "mixed-value"); +}); + +test("DefaultExecutor.buildHeaders for non-openai-compatible providers ignores customHeaders", () => { + const executor = new DefaultExecutor("openai"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + customHeaders: { + "X-Custom": "should-be-ignored", + }, + }, + }, + true + ) as Record<string, string>; + + assert.equal(headers["X-Custom"], undefined); + assert.equal(headers.Authorization, "Bearer test-key"); +}); + +test("DefaultExecutor.execute sends customHeaders in the actual HTTP request", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record<string, string> = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record<string, string>) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + "X-Request-ID": "test-req-123", + "X-Trace-Id": "trace-abc", + }, + }, + }, + }); + + assert.equal(capturedHeaders["X-Request-ID"], "test-req-123"); + assert.equal(capturedHeaders["X-Trace-Id"], "trace-abc"); + assert.equal(capturedHeaders["Authorization"], "Bearer test-key"); + assert.equal(capturedHeaders["Content-Type"], "application/json"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DefaultExecutor.execute does NOT send forbidden headers from customHeaders in HTTP request", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record<string, string> = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record<string, string>) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + host: "evil.com", + "content-length": "9999", + "X-Legitimate": "good", + }, + }, + }, + }); + + assert.equal(capturedHeaders.host, undefined); + assert.equal(capturedHeaders["content-length"], undefined); + assert.equal(capturedHeaders["X-Legitimate"], "good"); + assert.equal(capturedHeaders.Authorization, "Bearer test-key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DefaultExecutor.execute does NOT allow customHeaders to override Authorization", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record<string, string> = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record<string, string>) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: { + Authorization: "Bearer forged-key", + }, + }, + }, + }); + + assert.equal(capturedHeaders.Authorization, "Bearer real-key"); + assert.notEqual(capturedHeaders.Authorization, "Bearer forged-key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("db: createProviderNode and getProviderNodeById handle customHeaders as JSON", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-custom-headers-db", + type: "openai-compatible", + name: "DB Custom Headers Test", + prefix: "db-custom", + apiType: "chat", + baseUrl: "https://db.example.com/v1", + customHeaders: { "X-DB-Header": "db-value", "X-Another": "another" }, + }); + + assert.deepEqual(node.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" }); + + const retrieved = await providersDb.getProviderNodeById( + "openai-compatible-chat-custom-headers-db" + ); + assert.deepEqual(retrieved.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" }); +}); + +test("db: updateProviderNode modifies customHeaders", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-update-custom", + type: "openai-compatible", + name: "Update Custom Test", + prefix: "update-custom", + apiType: "chat", + baseUrl: "https://update.example.com/v1", + customHeaders: { "X-Initial": "initial-value" }, + }); + + assert.deepEqual(node.customHeaders, { "X-Initial": "initial-value" }); + + const updated = await providersDb.updateProviderNode("openai-compatible-chat-update-custom", { + customHeaders: { "X-Updated": "updated-value", "X-New-Header": "new" }, + }); + + assert.deepEqual(updated.customHeaders, { "X-Updated": "updated-value", "X-New-Header": "new" }); + + const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-update-custom"); + assert.deepEqual(retrieved.customHeaders, { + "X-Updated": "updated-value", + "X-New-Header": "new", + }); +}); + +test("db: updateProviderNode can clear customHeaders by passing null", async () => { + const node = await providersDb.createProviderNode({ + id: "openai-compatible-chat-clear-custom", + type: "openai-compatible", + name: "Clear Custom Test", + prefix: "clear-custom", + apiType: "chat", + baseUrl: "https://clear.example.com/v1", + customHeaders: { "X-ToClear": "clear-me" }, + }); + + assert.deepEqual(node.customHeaders, { "X-ToClear": "clear-me" }); + + const updated = await providersDb.updateProviderNode("openai-compatible-chat-clear-custom", { + customHeaders: null, + }); + + assert.equal(updated.customHeaders, null); +}); diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index 81f2f4140e..c340e03421 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -113,7 +113,7 @@ describe("DuckDuckGoWebExecutor", () => { const response = await executor.execute({ model: "gpt-4o-mini", - messages: [{ role: "user", content: "test" }], + body: { messages: [{ role: "user", content: "test" }] }, stream: false, signal: controller.signal, } as any); diff --git a/tests/unit/electron-processtree.test.ts b/tests/unit/electron-processtree.test.ts new file mode 100644 index 0000000000..bd5e341693 --- /dev/null +++ b/tests/unit/electron-processtree.test.ts @@ -0,0 +1,105 @@ +/** + * Regression test for #3347 — Electron "Exit" leaves a process in memory that locks + * omniroute.exe on Windows. + * + * The embedded server is spawned via process.execPath (= omniroute.exe) with + * ELECTRON_RUN_AS_NODE=1. On Windows, ChildProcess.kill()/SIGTERM/SIGKILL terminate ONLY + * the direct child — NOT its descendants — so server-spawned grandchildren (embedded + * services, MITM proxy, tunnels, several also omniroute.exe-as-node) survive and keep the + * .exe locked, blocking updates. killProcessTree() must use `taskkill /PID <pid> /T /F` + * (the /T flag walks the tree) on win32, and signal-based kill on POSIX (where signals + * propagate). This test pins that platform branch, plus a static guard that main.js routes + * the server shutdown through killProcessTree (not a raw nextServer.kill). + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { killProcessTree } = require("../../electron/processTree.js"); + +describe("killProcessTree (#3347)", () => { + it("win32: kills the whole tree via `taskkill /PID <pid> /T /F` (not proc.kill)", () => { + const spawnCalls: Array<{ cmd: string; args: string[] }> = []; + let procKillCalled = false; + const proc = { + pid: 1234, + kill: () => { + procKillCalled = true; + }, + }; + const spawnFn = (cmd: string, args: string[]) => { + spawnCalls.push({ cmd, args }); + return { on: () => {} }; + }; + + killProcessTree(proc, { platform: "win32", signal: "SIGTERM", spawnFn }); + + assert.equal(spawnCalls.length, 1, "expected exactly one taskkill spawn"); + assert.equal(spawnCalls[0].cmd, "taskkill"); + assert.deepEqual(spawnCalls[0].args, ["/PID", "1234", "/T", "/F"]); + assert.equal(procKillCalled, false, "must NOT fall back to proc.kill when taskkill spawns"); + }); + + it("posix: uses signal-based proc.kill (signals propagate), never taskkill", () => { + let killedWith: string | null = null; + let spawned = false; + const proc = { + pid: 4321, + kill: (sig: string) => { + killedWith = sig; + }, + }; + const spawnFn = () => { + spawned = true; + return { on: () => {} }; + }; + + killProcessTree(proc, { platform: "linux", signal: "SIGTERM", spawnFn }); + + assert.equal(killedWith, "SIGTERM"); + assert.equal(spawned, false, "must not spawn taskkill on POSIX"); + }); + + it("win32 fallback: taskkill spawn throwing falls back to proc.kill", () => { + let killedWith: string | null = null; + const proc = { + pid: 99, + kill: (sig: string) => { + killedWith = sig; + }, + }; + const spawnFn = () => { + throw new Error("taskkill not found"); + }; + + killProcessTree(proc, { platform: "win32", signal: "SIGKILL", spawnFn }); + + assert.equal(killedWith, "SIGKILL", "fallback to proc.kill when taskkill is unavailable"); + }); + + it("no-op on null/pid-less process (does not throw)", () => { + assert.doesNotThrow(() => killProcessTree(null, { platform: "win32" })); + assert.doesNotThrow(() => killProcessTree({ pid: undefined }, { platform: "win32" })); + }); +}); + +describe("Electron main.js server shutdown routes through killProcessTree (#3347)", () => { + const main = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + it("requires the processTree helper", () => { + assert.match(main, /require\(["']\.\/processTree["']\)/); + }); + + it("does not kill the server child with a raw signal kill (must use the tree-kill)", () => { + // The two shutdown call sites (stopNextServer + waitForServerExit) must not use a bare + // `nextServer.kill(` / `proc.kill("SIGKILL")` on the server proc anymore. + assert.doesNotMatch(main, /nextServer\.kill\(/); + assert.ok( + /killProcessTree\s*\(/.test(main), + "main.js must call killProcessTree() for server shutdown" + ); + }); +}); diff --git a/tests/unit/executor-web-cookie-sweep.test.ts b/tests/unit/executor-web-cookie-sweep.test.ts new file mode 100644 index 0000000000..49050e4a8d --- /dev/null +++ b/tests/unit/executor-web-cookie-sweep.test.ts @@ -0,0 +1,220 @@ +/** + * Web-Cookie + NoAuth executor wrapper contract sweep. + * + * Why this file exists + * -------------------- + * `open-sse/handlers/chatCore.ts` reads `res.response.status`, `res.response.headers`, + * and uses `res.url` / `res.transformedBody` to classify upstream responses + * (see chatCore.ts:3937–4486, BaseExecutor.execute() at base.ts:1146). + * + * An executor that returns a raw `Response` instead of the wrapper shape + * `{response, url, headers, transformedBody}` causes `res.response.status` to + * throw `Cannot read properties of undefined (reading 'status')`. That JS + * TypeError was then surfaced as a 502 via `formatProviderError` in + * `open-sse/utils/error.ts:496`, and showed up to the client as + * `[502]: Cannot read properties of undefined (reading 'status')`. + * + * The duckduckgo-web executor was the first known case. To prevent any + * future executor from regressing on the same contract, this sweep test + * imports every executor in `WEB_COOKIE_PROVIDERS` + `NOAUTH_PROVIDERS` + * (20 web-cookie + 2 noauth = 22 total), calls `execute()` with a minimal + * but valid input, and asserts the wrapper shape. Tests use the + * pre-aborted signal path or empty-creds path so no real upstream call + * is needed. + * + * If this file ever flags a missing executor, the fix is in the executor + * — the contract is the executor's responsibility. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getExecutor } from "../../open-sse/executors/index.ts"; +import { + WEB_COOKIE_PROVIDERS, + NOAUTH_PROVIDERS, +} from "../../src/shared/constants/providers.ts"; + +type WebCookieId = keyof typeof WEB_COOKIE_PROVIDERS; +type NoauthId = keyof typeof NOAUTH_PROVIDERS; + +const WEB_COOKIE_IDS = Object.keys(WEB_COOKIE_PROVIDERS) as WebCookieId[]; +const NOAUTH_IDS = Object.keys(NOAUTH_PROVIDERS) as NoauthId[]; + +/** + * Per-provider fake-credential strings that pass the executor's own + * input-validation gate without making a real upstream call succeed. + * Each executor parses a different cookie/header — the goal is only + * to short-circuit the network call with a synthetic 401/403/4xx/5xx, + * not to actually authenticate. + */ +const FAKE_CREDS: Record<string, string> = { + "chatgpt-web": "__Secure-next-auth.session-token=fake-audit-sweep", + "grok-web": "sso=fake-audit-sweep", + "gemini-web": "__Secure-1PSID=fake-audit-sweep", + "perplexity-web": "__Secure-next-auth.session-token=fake-audit-sweep", + "blackbox-web": "__Secure-authjs.session-token=fake-audit-sweep", + "muse-spark-web": "ecto_1_sess=fake-audit-sweep", + "claude-web": "sessionKey=fake-audit-sweep", + "deepseek-web": "userToken=fake-audit-sweep", + "copilot-web": "fake-audit-sweep", + "t3-web": "fake-audit-sweep", + "inner-ai": "fake-audit-sweep user@example.com", + "adapta-web": "__client=fake-audit-sweep", + huggingchat: "hf-chat=fake-audit-sweep", + phind: "fake-audit-sweep", + "poe-web": "p-b=fake-audit-sweep", + "venice-web": "fake-audit-sweep", + "v0-vercel-web": "fake-audit-sweep", + "kimi-web": "fake-audit-sweep", + "doubao-web": "fake-audit-sweep", + "qwen-web": "fake-audit-sweep", + "duckduckgo-web": "", + "veoaifree-web": "", +}; + +const VALID_BODY = { + model: "test", + messages: [{ role: "user", content: "ping" }], +}; + +/** + * Asserts that `result` has the executor wrapper contract shape: + * { response: Response, url: string, headers: object, transformedBody: unknown } + * + * The contract is what `open-sse/handlers/chatCore.ts` and + * `BaseExecutor.execute()` (open-sse/executors/base.ts:1146) depend on. + */ +function assertExecutorWrapperShape( + result: unknown, + provider: string +): asserts result is { + response: Response; + url: string; + headers: Record<string, unknown>; + transformedBody: unknown; +} { + assert.ok( + result && typeof result === "object", + `[${provider}] execute() must return an object, not ${typeof result}` + ); + const r = result as Record<string, unknown>; + assert.ok( + r.response instanceof Response, + `[${provider}] result.response must be a Response (got ${typeof r.response})` + ); + assert.equal( + typeof r.url, + "string", + `[${provider}] result.url must be a string` + ); + assert.ok( + r.headers && typeof r.headers === "object", + `[${provider}] result.headers must be an object` + ); + // transformedBody may be null/undefined/object; just check it doesn't + // throw when accessed. + void r.transformedBody; + // Critical: r.response.status must be reachable without throwing + // — this is the exact property read that the duckduckgo-web bug + // (#3106) crashed on. + const status = (r.response as Response).status; + assert.ok( + Number.isInteger(status) && status >= 100 && status < 600, + `[${provider}] result.response.status must be a valid HTTP status, got ${status}` + ); +} + +describe("web-cookie + noauth executor wrapper contract sweep", () => { + describe("WEB_COOKIE_PROVIDERS (20)", () => { + for (const providerId of WEB_COOKIE_IDS) { + it(`${providerId} executor returns wrapper shape`, async () => { + const executor = getExecutor(providerId); + assert.ok(executor, `[${providerId}] getExecutor must return an executor`); + + const result = await executor.execute({ + model: providerId, + body: VALID_BODY, + stream: false, + credentials: { apiKey: FAKE_CREDS[providerId] ?? "fake" }, + signal: null, + } as never); + + assertExecutorWrapperShape(result, providerId); + + // Result should never be a JS TypeError. Real executor returns + // a proper Response with a JSON error body for invalid creds. + // If a regression introduces a raw Response return, the shape + // assertion above will fail. + const body = await result.response.text(); + // Most executors return JSON error bodies for invalid creds. + // We don't require JSON, but we DO require the body to be a + // non-empty string (not the literal "[object Response]" or + // a TypeError stack trace). + assert.ok( + body.length > 0, + `[${providerId}] response body must be non-empty` + ); + // And it must NOT be the duckduckgo-web regression signature. + assert.doesNotMatch( + body, + /Cannot read properties of undefined \(reading 'status'\)/, + `[${providerId}] must not surface the chatCore-side TypeError` + ); + }); + } + }); + + describe("NOAUTH_PROVIDERS (4 total; 2 require cookie='') ", () => { + // Only noauth providers that should be probed without creds: + // duckduckgo-web and veoaifree-web. opencode/notice have dedicated + // executor tests already (executor-opencode.test.ts / executor-notice.test.ts). + const TARGETS = NOAUTH_IDS.filter( + (id) => id === "duckduckgo-web" || id === "veoaifree-web" + ); + + for (const providerId of TARGETS) { + it(`${providerId} noauth executor returns wrapper shape`, async () => { + const executor = getExecutor(providerId); + assert.ok(executor, `[${providerId}] getExecutor must return an executor`); + + // Use a pre-aborted signal so the executor short-circuits via + // its AbortError path before any real network call. + const controller = new AbortController(); + controller.abort(); + + const result = await executor.execute({ + model: providerId, + body: VALID_BODY, + stream: false, + credentials: { apiKey: "" }, + signal: controller.signal, + } as never); + + // duckduckgo-web may legitimately short-circuit with a bare + // 499 Response on a pre-aborted signal; chatCore's + // normalizeExecutorResult already accepts both shapes. Only + // insist on the full wrapper for executors that are expected + // to produce one. + if (result instanceof Response) { + assert.ok( + result.status >= 100 && result.status < 600, + `[${providerId}] bare Response must have a valid HTTP status, got ${result.status}` + ); + } else { + assertExecutorWrapperShape(result, providerId); + } + const body = await ( + result instanceof Response ? result : result.response + ).text(); + assert.ok( + body.length > 0, + `[${providerId}] response body must be non-empty` + ); + assert.doesNotMatch( + body, + /Cannot read properties of undefined \(reading 'status'\)/, + `[${providerId}] must not surface the chatCore-side TypeError` + ); + }); + } + }); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 8511980d95..cc4cc4f563 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -31,13 +31,13 @@ const { // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 28 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 28); + it("has exactly 29 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 29); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 28); + assert.strictEqual(new Set(keys).size, 29); }); it("has valid categories for all flags", () => { @@ -222,9 +222,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 28 flags", () => { + it("returns all 29 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 28); + assert.strictEqual(all.length, 29); }); it("marks DB-overridden flags with source 'db'", () => { diff --git a/tests/unit/fetch-error-message.test.ts b/tests/unit/fetch-error-message.test.ts new file mode 100644 index 0000000000..37618f29da --- /dev/null +++ b/tests/unit/fetch-error-message.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readFetchErrorMessage } from "../../src/shared/utils/fetchError.ts"; + +const FALLBACK = "An error occurred"; + +function jsonResponse(body: unknown, status = 500) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// #3356: the Analytics page rendered a generic "An error occurred" because it +// discarded the server's error body. The page must surface the real (already +// sanitized server-side) message instead. + +test("reads the OpenAI-style { error: { message } } shape from buildErrorBody", async () => { + const res = jsonResponse({ error: { message: "Failed to compute analytics", type: "api_error" } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "Failed to compute analytics"); +}); + +test("reads the legacy { error: '...' } string shape", async () => { + const res = jsonResponse({ error: "no such column: combo_name" }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "no such column: combo_name"); +}); + +test("trims surrounding whitespace from the extracted message", async () => { + const res = jsonResponse({ error: { message: " boom " } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "boom"); +}); + +test("falls back when the error message is blank", async () => { + const res = jsonResponse({ error: { message: " " } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +}); + +test("falls back when there is no error field", async () => { + const res = jsonResponse({ data: 1 }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +}); + +test("falls back on a non-JSON body without throwing", async () => { + const res = new Response("<html>500 Internal Server Error</html>", { status: 500 }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +}); diff --git a/tests/unit/gemini-tool-params-object-3357.test.ts b/tests/unit/gemini-tool-params-object-3357.test.ts new file mode 100644 index 0000000000..e556d64297 --- /dev/null +++ b/tests/unit/gemini-tool-params-object-3357.test.ts @@ -0,0 +1,77 @@ +/** + * Regression test for #3357 — Vertex AI function-calling fails with + * "functionDeclaration parameters schema should be of type OBJECT". + * + * Gemini/Vertex requires every functionDeclaration.parameters to be an OBJECT-typed + * schema. GitHub Copilot sends some tools (e.g. terminal_last_command) whose `parameters` + * is present but lacks a top-level `type: "object"` (just `{ properties }`, a scalar type, + * or `{}`). buildGeminiTools() must coerce the function-parameters root to an object schema + * before cleaning, otherwise the typeless schema reaches Vertex and 400s. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts"; + +function paramsOf(tools: ReturnType<typeof buildGeminiTools>): any { + return (tools as any)?.[0]?.functionDeclarations?.[0]?.parameters; +} + +describe("buildGeminiTools — function parameters must be an OBJECT schema (#3357)", () => { + it("coerces a typeless parameters schema ({properties} only) to type:object, keeping props", () => { + const tools = buildGeminiTools([ + { + type: "function", + function: { + name: "terminal_last_command", + parameters: { properties: { cmd: { type: "string" } } }, + }, + }, + ]); + const params = paramsOf(tools); + assert.equal(params.type, "object"); + assert.ok(params.properties && params.properties.cmd, "original properties preserved"); + assert.equal(params.properties.cmd.type, "string"); + }); + + it("coerces a scalar-typed parameters schema to type:object", () => { + const tools = buildGeminiTools([ + { type: "function", function: { name: "weird_tool", parameters: { type: "string" } } }, + ]); + assert.equal(paramsOf(tools).type, "object"); + }); + + it("coerces an empty parameters object to type:object", () => { + const tools = buildGeminiTools([ + { type: "function", function: { name: "no_args", parameters: {} } }, + ]); + assert.equal(paramsOf(tools).type, "object"); + }); + + it("defaults missing parameters to a type:object schema", () => { + const tools = buildGeminiTools([ + { type: "function", function: { name: "bare" } }, + ]); + assert.equal(paramsOf(tools).type, "object"); + }); + + it("leaves an already-valid object parameters schema as type:object with its props", () => { + const tools = buildGeminiTools([ + { + type: "function", + function: { + name: "search", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + }, + ]); + const params = paramsOf(tools); + assert.equal(params.type, "object"); + assert.equal(params.properties.query.type, "string"); + assert.deepEqual(params.required, ["query"]); + }); +}); diff --git a/tests/unit/kiro-builder-id-import-3333.test.ts b/tests/unit/kiro-builder-id-import-3333.test.ts new file mode 100644 index 0000000000..f9ba09772f --- /dev/null +++ b/tests/unit/kiro-builder-id-import-3333.test.ts @@ -0,0 +1,192 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression guard for #3333 — Kiro Builder ID token import failed with "Bad +// credentials" because validateImportToken only ever tried the social-auth +// refresh path. Builder ID tokens need the cached AWS SSO clientId/clientSecret +// (~/.aws/sso/cache/*.json) and the OIDC refresh path (authMethod: "builder-id"). + +const { KiroService } = await import("../../src/lib/oauth/services/kiro.ts"); + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_FETCH = globalThis.fetch; +let tmpHome: string; + +function makeFakeSsoCache(home: string, creds: { clientId: string; clientSecret: string } | null) { + const cacheDir = path.join(home, ".aws", "sso", "cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + if (creds) { + // The OIDC client-registration cache entry also carries unrelated keys. + fs.writeFileSync( + path.join(cacheDir, "client-reg.json"), + JSON.stringify({ ...creds, region: "us-east-1", expiresAt: "2099-01-01T00:00:00Z" }) + ); + } +} + +test.beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3333-")); + process.env.HOME = tmpHome; +}); + +test.afterEach(() => { + process.env.HOME = ORIGINAL_HOME; + globalThis.fetch = ORIGINAL_FETCH; + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +test("validateImportToken uses cached Builder ID client creds + OIDC refresh path", async () => { + makeFakeSsoCache(tmpHome, { clientId: "cid-cached", clientSecret: "secret-cached" }); + + const calledEndpoints: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + calledEndpoints.push(u); + // Builder ID refresh hits the AWS OIDC token endpoint. + if (u.includes("oidc.") && u.endsWith("/token")) { + return new Response( + JSON.stringify({ + accessToken: "access-builder-id", + refreshToken: "refreshed-token", + expiresIn: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }) as typeof fetch; + + const svc = new KiroService(); + const result = await svc.validateImportToken("aorAAAAAGtoken-builder-id"); + + assert.equal(result.authMethod, "builder-id"); + assert.equal(result.accessToken, "access-builder-id"); + assert.equal(result.clientId, "cid-cached"); + assert.equal(result.clientSecret, "secret-cached"); + // Must have gone through the OIDC token endpoint, NOT the social-auth service. + assert.ok( + calledEndpoints.some((u) => u.includes("oidc.") && u.endsWith("/token")), + "expected OIDC token endpoint to be used for Builder ID refresh" + ); +}); + +test("validateImportToken falls back to social auth when no cached creds exist", async () => { + // No ~/.aws/sso/cache → readCachedClientCredentials() returns null. + makeFakeSsoCache(tmpHome, null); + + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + // Social-auth refresh endpoint. + if (u.includes("auth.desktop.kiro.dev")) { + return new Response( + JSON.stringify({ + accessToken: "access-social", + refreshToken: "social-rt", + expiresIn: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // registerClient() for the isolated OIDC client (may fail gracefully). + if (u.includes("oidc.") && u.endsWith("/client/register")) { + return new Response(JSON.stringify({ clientId: "reg-cid", clientSecret: "reg-secret" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch to ${u}`); + }) as typeof fetch; + + const svc = new KiroService(); + const result = await svc.validateImportToken("aorAAAAAGtoken-social"); + + assert.equal(result.authMethod, "imported"); + assert.equal(result.accessToken, "access-social"); +}); + +// LEDGER-6 (/review-reviews v3.8.14): the Builder ID validation refresh must +// forward the requested region to the OIDC endpoint, not default to us-east-1. +test("validateImportToken forwards the region to the OIDC endpoint (LEDGER-6)", async () => { + makeFakeSsoCache(tmpHome, { clientId: "cid", clientSecret: "secret" }); + const calledEndpoints: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + calledEndpoints.push(u); + if (u.includes("oidc.") && u.endsWith("/token")) { + return new Response( + JSON.stringify({ accessToken: "a", refreshToken: "r", expiresIn: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }) as typeof fetch; + + const svc = new KiroService(); + await svc.validateImportToken("aorAAAAAGtoken", "eu-west-1"); + assert.ok( + calledEndpoints.some((u) => u.includes("oidc.eu-west-1.amazonaws.com/token")), + `expected the eu-west-1 OIDC endpoint, got: ${calledEndpoints.join(", ")}` + ); + assert.ok( + !calledEndpoints.some((u) => u.includes("oidc.us-east-1.amazonaws.com/token")), + "must not fall back to us-east-1 when a region was requested" + ); +}); + +// LEDGER-8 (/review-reviews v3.8.14): with multiple cached SSO client +// registrations, the one whose region matches the import must be chosen rather +// than whichever readdir returns first. +test("validateImportToken prefers the region-matching cached client (LEDGER-8)", async () => { + const cacheDir = path.join(tmpHome, ".aws", "sso", "cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + // "a-" sorts first so naive first-match would pick the wrong (us-east-1) pair. + fs.writeFileSync( + path.join(cacheDir, "a-useast.json"), + JSON.stringify({ + clientId: "cid-useast", + clientSecret: "secret-useast", + region: "us-east-1", + expiresAt: "2099-01-01T00:00:00Z", + }) + ); + fs.writeFileSync( + path.join(cacheDir, "z-euwest.json"), + JSON.stringify({ + clientId: "cid-euwest", + clientSecret: "secret-euwest", + region: "eu-west-1", + expiresAt: "2099-01-01T00:00:00Z", + }) + ); + + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + if (u.includes("oidc.") && u.endsWith("/token")) { + return new Response( + JSON.stringify({ accessToken: "a", refreshToken: "r", expiresIn: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }) as typeof fetch; + + const svc = new KiroService(); + const result = await svc.validateImportToken("aorAAAAAGtoken", "eu-west-1"); + assert.equal(result.clientId, "cid-euwest", "must pick the eu-west-1 registration"); + assert.equal(result.clientSecret, "secret-euwest"); +}); + +test("validateImportToken rejects malformed refresh tokens before touching the cache", async () => { + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + const svc = new KiroService(); + await assert.rejects(() => svc.validateImportToken("not-a-valid-token"), /Invalid token format/); + assert.equal(fetched, false, "must not attempt any network call for a malformed token"); +}); diff --git a/tests/unit/minimax-m3-model-registry.test.ts b/tests/unit/minimax-m3-model-registry.test.ts index ddc49fdd62..820cbc77ca 100644 --- a/tests/unit/minimax-m3-model-registry.test.ts +++ b/tests/unit/minimax-m3-model-registry.test.ts @@ -75,12 +75,10 @@ describe("MiniMax M3 model registration (#3110)", () => { assert.equal(m3.contextLength, 1_048_576); }); - it("nvidia provider has minimaxai/minimax-m3 with 1M context", () => { + it("nvidia provider does NOT list minimaxai/minimax-m3 (removed in #3329 — 404 upstream)", () => { const entry = REGISTRY.nvidia; assert.ok(entry, "nvidia registry entry must exist"); const m3 = entry.models.find((m) => m.id === "minimaxai/minimax-m3"); - assert.ok(m3, "minimaxai/minimax-m3 must be in nvidia models"); - assert.equal(m3.name, "MiniMax M3"); - assert.equal(m3.contextLength, 1_048_576); + assert.equal(m3, undefined, "NVIDIA NIM does not host minimaxai/minimax-m3 (see #3329)"); }); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 8d13a7f010..3d392c7d4a 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1434,7 +1434,12 @@ test("v1 models catalog includes noAuth provider models when no DB connections e // opencode (noAuth) models must surface even with zero connection rows. // The registry defines models under alias "oc" (e.g. "oc/big-pickle"). assert.ok( - ids.some((id) => id.startsWith("oc/") || id.startsWith("opencode/")), - `Expected at least one oc/* or opencode/* model in /v1/models but got none. IDs sample: ${ids.slice(0, 10).join(", ")}` + ids.some((id) => id.startsWith("oc/")), + `Expected at least one oc/* model in /v1/models but got none. IDs sample: ${ids.slice(0, 10).join(", ")}` + ); + assert.equal( + ids.some((id) => id.startsWith("opencode/")), + false, + "catalog must not return opencode/* noAuth aliases because opencode/ routes to opencode-zen" ); }); diff --git a/tests/unit/nvidia-minimax-m3-removed-3329.test.ts b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts new file mode 100644 index 0000000000..77e0ce4822 --- /dev/null +++ b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); + +// #3329: `minimaxai/minimax-m3` was registered in the nvidia (NVIDIA NIM) tier, +// but NVIDIA NIM does not host it — every request returns `404 page not found`, +// while sibling models on the same provider (e.g. `minimaxai/minimax-m2.7`) +// work. Advertising a model that 404s is a catalog bug; it is removed from the +// nvidia tier until NVIDIA actually serves it. It remains on the tiers that do +// (minimax / minimax-cn / opencode / etc.). +test("nvidia tier does not advertise minimaxai/minimax-m3 (404 upstream) (#3329)", () => { + const nvidia = getRegistryEntry("nvidia"); + assert.ok(nvidia, "nvidia registry entry must exist"); + const ids = (nvidia.models ?? []).map((m) => m.id); + assert.ok(!ids.includes("minimaxai/minimax-m3"), "minimaxai/minimax-m3 must not be in nvidia"); + // sanity: the working sibling stays listed + assert.ok(ids.includes("minimaxai/minimax-m2.7"), "minimaxai/minimax-m2.7 stays available"); +}); diff --git a/tests/unit/provider-connections-ui-regression.test.ts b/tests/unit/provider-connections-ui-regression.test.ts new file mode 100644 index 0000000000..94d4800918 --- /dev/null +++ b/tests/unit/provider-connections-ui-regression.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROVIDER_PAGE = path.resolve( + __dirname, + "../../src/app/(dashboard)/dashboard/providers/[id]/page.tsx", +); +const EN_MESSAGES = path.resolve(__dirname, "../../src/i18n/messages/en.json"); + +const providerPageSrc = readFileSync(PROVIDER_PAGE, "utf-8"); +const enMessages = JSON.parse(readFileSync(EN_MESSAGES, "utf-8")); + +describe("provider connections UI regression", () => { + it("keeps English provider count messages available for the provider detail header", () => { + assert.equal( + enMessages.providers?.selectedCount, + "{count, plural, one {# selected} other {# selected}}", + ); + assert.equal( + enMessages.providers?.accountsCount, + "{count, plural, one {# account} other {# accounts}}", + ); + }); + + it("uses defensive provider count labels instead of leaking raw i18n keys", () => { + assert.match(providerPageSrc, /function\s+providerCountText\s*\(/); + assert.match(providerPageSrc, /providerCountText\([\s\S]*"selectedCount"[\s\S]*"\{count\} selected"/); + assert.match(providerPageSrc, /providerCountText\([\s\S]*"accountsCount"[\s\S]*"\{count\} account"[\s\S]*"\{count\} accounts"/); + assert.doesNotMatch( + providerPageSrc, + /\?\s*t\("selectedCount",\s*\{\s*count:\s*selectedIds\.size\s*\}\)\s*:\s*t\("accountsCount",\s*\{\s*count:\s*connections\.length\s*\}\)/, + ); + }); + + it("keeps proxy toggle text accessible without repeating active/default labels visually", () => { + assert.ok( + providerPageSrc.includes( + 'aria-label={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}', + ), + ); + assert.ok( + providerPageSrc.includes( + 'aria-label={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}', + ), + ); + assert.ok( + providerPageSrc.includes( + '<span className="sr-only">{t("proxyOn")}</span>', + ), + ); + assert.ok( + providerPageSrc.includes( + '<span className="sr-only">{t("perKeyProxyOff")}</span>', + ), + ); + assert.doesNotMatch(providerPageSrc, /\{proxyEnabled \? t\("proxyOn"\) : t\("proxyOff"\)\}/); + assert.doesNotMatch( + providerPageSrc, + /\{perKeyProxyEnabled \? t\("perKeyProxyOn"\) : t\("perKeyProxyOff"\)\}/, + ); + }); +}); diff --git a/tests/unit/provider-registry-qwen-vision.test.ts b/tests/unit/provider-registry-qwen-vision.test.ts index dccc025902..9382027aed 100644 --- a/tests/unit/provider-registry-qwen-vision.test.ts +++ b/tests/unit/provider-registry-qwen-vision.test.ts @@ -63,6 +63,19 @@ test("#2822 opencode-go/qwen3.6-plus deve ter supportsVision !== true", () => { ); }); +// #3328 — o oposto do #2822: MiniMax M3 (opencode) É multimodal (verificado +// empiricamente: descreve imagens base64 via o upstream opencode). Deve ter +// supportsVision: true para não ser barrado/strippado em requests com imagem. +test("#3328 opencode/minimax-m3-free deve ter supportsVision: true", () => { + const model = getModel("opencode", "minimax-m3-free"); + assert.ok(model, "minimax-m3-free deve estar registrado em opencode"); + assert.strictEqual( + model.supportsVision, + true, + "opencode/minimax-m3-free é multimodal — supportsVision deve ser true" + ); +}); + test("#2822 opencode-go/qwen3.5-plus deve ter supportsVision !== true", () => { const model = getModel("opencode-go", "qwen3.5-plus"); assert.ok(model, "qwen3.5-plus deve estar registrado em opencode-go"); diff --git a/tests/unit/proxy-autoselect-optin-3332.test.ts b/tests/unit/proxy-autoselect-optin-3332.test.ts new file mode 100644 index 0000000000..35f58c77fe --- /dev/null +++ b/tests/unit/proxy-autoselect-optin-3332.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-3332-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { FEATURE_FLAG_DEFINITIONS } = await import( + "../../src/shared/constants/featureFlagDefinitions.ts" +); +const { isFeatureFlagEnabled } = await import("../../src/shared/utils/featureFlags.ts"); +const { selectWorkingProxyFallback } = await import("../../open-sse/utils/proxyFallback.ts"); + +// #3332: a single proxy in the registry was silently applied to ALL connections +// via the auto-selection fallback. The fix makes auto-selection opt-in behind +// PROXY_AUTO_SELECT_ENABLED, default OFF — so no registry proxy becomes a global +// default unless the operator explicitly turns it on. + +test("PROXY_AUTO_SELECT_ENABLED exists and defaults to off (opt-in) (#3332)", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "PROXY_AUTO_SELECT_ENABLED"); + assert.ok(def, "PROXY_AUTO_SELECT_ENABLED flag must be defined"); + assert.equal(def.defaultValue, "false", "auto-selection must be opt-in (default off)"); + delete process.env.PROXY_AUTO_SELECT_ENABLED; + assert.equal(isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED"), false); +}); + +test("selectWorkingProxyFallback short-circuits to null when the flag is off, even with a candidate", async () => { + delete process.env.PROXY_AUTO_SELECT_ENABLED; + const prevAllProxy = process.env.ALL_PROXY; + // A candidate exists (env proxy) — yet auto-selection must NOT run while off. + process.env.ALL_PROXY = "http://127.0.0.1:1"; + try { + const result = await selectWorkingProxyFallback("conn-1"); + assert.equal(result, null, "no auto-selected proxy while the flag is off"); + } finally { + if (prevAllProxy === undefined) delete process.env.ALL_PROXY; + else process.env.ALL_PROXY = prevAllProxy; + } +}); + +test.after(() => { + try { + core.resetDbInstance?.(); + } catch { + /* ignore */ + } + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); diff --git a/tests/unit/proxy-fetch.test.ts b/tests/unit/proxy-fetch.test.ts index 5b175f76ea..85b6290da6 100644 --- a/tests/unit/proxy-fetch.test.ts +++ b/tests/unit/proxy-fetch.test.ts @@ -58,11 +58,17 @@ async function withHttpServer(handler, fn) { } } -const originalTlsAvailable = tlsClient.available; const originalTlsFetch = tlsClient.fetch.bind(tlsClient); +// #3323 made `tlsClient.available` a computed getter (library availability + +// circuit breaker), so it can no longer be assigned. Tests stub it by shadowing +// the prototype getter with an own data property, then `delete` to restore. +function setTlsAvailable(value: boolean) { + Object.defineProperty(tlsClient, "available", { value, configurable: true, writable: true }); +} + test.afterEach(() => { - tlsClient.available = originalTlsAvailable; + delete (tlsClient as unknown as { available?: boolean }).available; tlsClient.fetch = originalTlsFetch; }); @@ -120,7 +126,7 @@ test("proxy fetch uses TLS fingerprint transport when enabled and available", as NO_PROXY: undefined, }, async () => { - tlsClient.available = true; + setTlsAvailable(true); tlsClient.fetch = async (url, options = {}) => { assert.equal(url, "https://omniroute.example.test/hello"); assert.equal(options.method, "POST"); diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 451312cb67..9dcec7d88b 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -363,6 +363,81 @@ test("resolveProxyForConnection falls through when apiKey has no proxy_id", asyn assert.equal((resolved as any).proxy.host, "account-fallthrough.local"); }); +test("connection proxy toggle gates account assignments and invalidates cached resolutions", async () => { + await resetStorage(); + + const directConnection = await providersDb.createProviderConnection({ + provider: "proxy-toggle-test-provider", + authType: "apikey", + name: "Direct Account", + apiKey: "sk-direct-account", + }); + const proxiedConnection = await providersDb.createProviderConnection({ + provider: "proxy-toggle-test-provider", + authType: "apikey", + name: "Proxied Account", + apiKey: "sk-proxied-account", + }); + + const poolProxy = await proxiesDb.createProxy({ + name: "Pool Proxy", + type: "http", + host: "pool-proxy.local", + port: 8080, + }); + await proxiesDb.assignProxyToScope("account", (proxiedConnection as any).id, poolProxy.id); + + const directResolved = await settingsDb.resolveProxyForConnection((directConnection as any).id); + assert.equal(directResolved.level, "direct"); + assert.equal(directResolved.proxy, null); + + const proxiedResolved = await settingsDb.resolveProxyForConnection((proxiedConnection as any).id); + assert.equal(proxiedResolved.level, "account"); + assert.equal((proxiedResolved.proxy as any).host, "pool-proxy.local"); + + const disabled = await providersDb.updateProviderConnection((proxiedConnection as any).id, { + proxyEnabled: false, + }); + assert.equal((disabled as any).proxyEnabled, false); + + const disabledResolved = await settingsDb.resolveProxyForConnection( + (proxiedConnection as any).id + ); + assert.equal(disabledResolved.level, "direct"); + assert.equal(disabledResolved.proxy, null); + + const enabled = await providersDb.updateProviderConnection((proxiedConnection as any).id, { + proxyEnabled: true, + }); + assert.equal((enabled as any).proxyEnabled, true); + + const enabledResolved = await settingsDb.resolveProxyForConnection((proxiedConnection as any).id); + assert.equal(enabledResolved.level, "account"); + assert.equal((enabledResolved.proxy as any).host, "pool-proxy.local"); +}); + +test("provider connection proxy toggle fields round-trip as booleans", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Boolean Toggle Account", + apiKey: "sk-toggle-roundtrip", + }); + + const updated = await providersDb.updateProviderConnection((connection as any).id, { + proxyEnabled: false, + perKeyProxyEnabled: true, + }); + const fetched = await providersDb.getProviderConnectionById((connection as any).id); + + assert.equal((updated as any).proxyEnabled, false); + assert.equal((updated as any).perKeyProxyEnabled, true); + assert.equal((fetched as any).proxyEnabled, false); + assert.equal((fetched as any).perKeyProxyEnabled, true); +}); + test("createProxyRegistrySchema accepts type:vercel and source:vercel-relay (schema gap-06)", async () => { // Note: We validate the schema directly using the worktree's absolute path because // tests run with CWD=/OmniRoute, so `@/` aliases resolve to the main branch's src/. diff --git a/tests/unit/registry-utils.test.ts b/tests/unit/registry-utils.test.ts index 781cf8d332..4f97bdd41d 100644 --- a/tests/unit/registry-utils.test.ts +++ b/tests/unit/registry-utils.test.ts @@ -194,6 +194,7 @@ test("parseVideoModel: works via video registry", async () => { const { parseVideoModel } = await import("../../open-sse/config/videoRegistry.ts"); const result = parseVideoModel("comfyui/animatediff"); assert.deepEqual(result, { provider: "comfyui", model: "animatediff" }); + assert.deepEqual(parseVideoModel("veo-free/veo"), { provider: "veoaifree-web", model: "veo" }); }); test("parseMusicModel: works via music registry", async () => { @@ -210,6 +211,8 @@ test("getAllVideoModels: returns video models with provider prefix", async () => assert.ok(models.some((m) => m.id === "kie/sora-2-pro-image-to-video")); assert.ok(models.some((m) => m.id === "comfyui/animatediff")); assert.ok(models.some((m) => m.id === "runwayml/gen4.5")); + assert.ok(models.some((m) => m.id === "veoaifree-web/veo")); + assert.ok(models.some((m) => m.id === "veo-free/veo")); }); test("getAllMusicModels: returns music models with provider prefix", async () => { diff --git a/tests/unit/review-reviews-v3814-fixes.test.ts b/tests/unit/review-reviews-v3814-fixes.test.ts new file mode 100644 index 0000000000..9e701e9eae --- /dev/null +++ b/tests/unit/review-reviews-v3814-fixes.test.ts @@ -0,0 +1,234 @@ +/** + * Regression guards for the /review-reviews battery findings (v3.8.14). + * Each test maps to a LEDGER-* item from the consolidated review. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-review-reviews-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { createProviderNodeSchema } = await import("../../src/shared/validation/schemas.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── LEDGER-1: updateProviderNode must preserve custom headers on partial update ── +test("LEDGER-1: updateProviderNode preserves customHeaders when the field is omitted", async () => { + const created = await providersDb.createProviderNode({ + type: "openai-compatible", + name: "node-a", + prefix: "nodea", + apiType: "chat", + baseUrl: "https://proxy.example.com/v1", + customHeaders: { "X-Tenant": "acme", "X-Env": "prod" }, + }); + // Partial update that does NOT resend customHeaders (only renames the node). + const updated = await providersDb.updateProviderNode(created.id as string, { name: "node-a2" }); + assert.deepEqual( + updated?.customHeaders, + { "X-Tenant": "acme", "X-Env": "prod" }, + "omitting customHeaders on update must NOT wipe stored headers" + ); + // And reading it back confirms persistence. + const fetched = await providersDb.getProviderNodeById(created.id as string); + assert.deepEqual(fetched?.customHeaders, { "X-Tenant": "acme", "X-Env": "prod" }); +}); + +test("LEDGER-1: updateProviderNode still clears headers when explicitly set to null", async () => { + const created = await providersDb.createProviderNode({ + type: "openai-compatible", + name: "node-b", + prefix: "nodeb", + apiType: "chat", + baseUrl: "https://proxy.example.com/v1", + customHeaders: { "X-Tenant": "acme" }, + }); + const updated = await providersDb.updateProviderNode(created.id as string, { + customHeaders: null, + }); + assert.equal(updated?.customHeaders, null, "explicit null must clear headers"); +}); + +// ── LEDGER-2 / LEDGER-3: schema reuses canonical guards + rejects auth headers ── +test("LEDGER-2: customHeaders rejects CRLF / control-char values", () => { + const bad = createProviderNodeSchema.safeParse({ + type: "openai-compatible", + name: "n", + prefix: "n", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: { "X-Inject": "foo\r\nX-Evil: bar" }, + }); + assert.equal(bad.success, false, "CRLF in a header value must be rejected at the schema"); +}); + +test("LEDGER-2: customHeaders rejects control-char / whitespace / ':' in names", () => { + for (const badName of ["X Bad", "X:Bad", "X\r\nBad"]) { + const res = createProviderNodeSchema.safeParse({ + type: "openai-compatible", + name: "n", + prefix: "n", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: { [badName]: "v" }, + }); + assert.equal(res.success, false, `header name "${badName}" must be rejected`); + } +}); + +test("LEDGER-2: customHeaders rejects more than 16 entries", () => { + const many: Record<string, string> = {}; + for (let i = 0; i < 17; i++) many[`X-H${i}`] = "v"; + const res = createProviderNodeSchema.safeParse({ + type: "openai-compatible", + name: "n", + prefix: "n", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: many, + }); + assert.equal(res.success, false, "more than 16 custom headers must be rejected"); +}); + +test("LEDGER-3: customHeaders rejects auth header names at the schema boundary", () => { + for (const authName of ["Authorization", "x-api-key", "X-Goog-Api-Key", "api-key"]) { + const res = createProviderNodeSchema.safeParse({ + type: "openai-compatible", + name: "n", + prefix: "n", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: { [authName]: "Bearer x" }, + }); + assert.equal(res.success, false, `auth header "${authName}" must be rejected (no silent drop)`); + } +}); + +test("LEDGER-2: valid custom headers still pass", () => { + const res = createProviderNodeSchema.safeParse({ + type: "openai-compatible", + name: "n", + prefix: "n", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: { "X-Tenant": "acme", "X-Trace-Id": "abc-123" }, + }); + assert.equal(res.success, true, res.success ? "" : JSON.stringify(res.error?.issues)); +}); + +// ── LEDGER-4: every minimax-m3 registry entry is flagged multimodal ── +test("LEDGER-4: all minimax-m3 registry entries set supportsVision (matches lite.ts)", () => { + const entries: { id: string; supportsVision?: boolean }[] = []; + for (const provider of Object.values( + REGISTRY as Record<string, { models?: { id: string; supportsVision?: boolean }[] }> + )) { + for (const m of provider.models || []) { + if (/minimax-m3/i.test(m.id)) entries.push(m); + } + } + assert.ok(entries.length >= 6, `expected several minimax-m3 entries, got ${entries.length}`); + const unflagged = entries.filter((m) => m.supportsVision !== true).map((m) => m.id); + assert.deepEqual( + unflagged, + [], + `these minimax-m3 entries miss supportsVision: ${unflagged.join(", ")}` + ); +}); + +// ── LEDGER-5: anthropic-compatible-cc-* nodes honor custom headers ── +test("LEDGER-5: custom headers reach the wire for anthropic-compatible-cc-* nodes", () => { + const executor = new DefaultExecutor("anthropic-compatible-cc-test"); + const headers = executor.buildHeaders( + { + accessToken: "tok", + providerSpecificData: { customHeaders: { "X-CC-Custom": "yes" } }, + }, + false + ) as Record<string, string>; + assert.equal(headers["X-CC-Custom"], "yes", "CC node must apply operator custom headers"); +}); + +// ── LEDGER-10: case-insensitive override — no duplicate Content-Type/Accept ── +test("LEDGER-10: a custom content-type overrides (not duplicates) the executor's Content-Type", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const headers = executor.buildHeaders( + { + apiKey: "k", + providerSpecificData: { + baseUrl: "https://x/v1", + customHeaders: { "content-type": "application/custom" }, + }, + }, + false + ) as Record<string, string>; + const ctKeys = Object.keys(headers).filter((k) => k.toLowerCase() === "content-type"); + assert.equal( + ctKeys.length, + 1, + `exactly one content-type header expected, got ${ctKeys.join(", ")}` + ); + assert.equal(headers[ctKeys[0]], "application/custom"); +}); + +test("LEDGER-3: executor still drops auth/forbidden custom headers (defense-in-depth)", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const headers = executor.buildHeaders( + { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://x/v1", + customHeaders: { Authorization: "Bearer evil", Host: "evil.example", "X-Ok": "ok" }, + }, + }, + false + ) as Record<string, string>; + assert.equal(headers["X-Ok"], "ok"); + assert.notEqual( + headers["Authorization"], + "Bearer evil", + "auth must not be overridden by a custom header" + ); + assert.ok(!Object.keys(headers).some((k) => k.toLowerCase() === "host")); +}); + +// ── LEDGER-9: rowToCamel normalizes a NULL _json column to the base key ── +test("LEDGER-9: rowToCamel surfaces a NULL _json column under the base key as null", () => { + const row = { id: "x", custom_headers_json: null, name: "n" }; + const camel = core.rowToCamel(row); + assert.equal(camel?.customHeaders, null, "NULL _json column should be customHeaders: null"); + assert.ok( + !("customHeadersJson" in (camel as object)), + "the suffixed key must not leak on the null path" + ); +}); + +// ── LEDGER-11: a node created on a fresh DB round-trips the custom_headers_json column ── +test("LEDGER-11: fresh-DB migration leaves provider_nodes.custom_headers_json usable", async () => { + const created = await providersDb.createProviderNode({ + type: "openai-compatible", + name: "fresh", + prefix: "fresh", + apiType: "chat", + baseUrl: "https://x/v1", + customHeaders: { "X-A": "1" }, + }); + const fetched = await providersDb.getProviderNodeById(created.id as string); + assert.deepEqual(fetched?.customHeaders, { "X-A": "1" }); +}); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index f8ab86f723..cddeab67b9 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -232,6 +232,69 @@ test("createSSEStream passthrough converts split textual tool-call content at co assert.doesNotMatch(text, /\[Tool call: terminal\]/); }); +test("createSSEStream passthrough handles textual tool-call content split inside the prefix [Tool call: across chunks", async () => { + let onCompletePayload = null; + const splitToolArgs = JSON.stringify({ + command: "whoami", + }); + const chunks = ["[Tool", " call: terminal]\n", `Arguments: ${splitToolArgs}`]; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { content: chunks[1] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { content: chunks[2] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "antigravity", + model: "antigravity/gemini-3.5-flash-low", + body: { messages: [{ role: "user", content: "inspect db" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + assert.doesNotMatch(text, /"content":"\[Tool/); + assert.doesNotMatch(text, /"content":" call:/); + assert.match(text, /"tool_calls":\[/); + assert.match(text, /"name":"terminal"/); + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.content, null); + assert.equal(choice.message.tool_calls[0].function.name, "terminal"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { + command: "whoami", + }); +}); + test("createSSEStream passthrough buffers fragmented textual tool-call JSON before emitting", async () => { let onCompletePayload = null; const text = await readTransformed( @@ -1658,3 +1721,91 @@ test("createSSEStream passthrough logs empty response after tool_calls completio // Content should be null (empty) since no text was generated assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); }); + +test("createSSEStream passthrough does not swallow false positive textual tool call", async () => { + let onCompletePayload = null; + const sentence = "Checking: [Tool call: terminal] was executed successfully."; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { role: "assistant", content: sentence } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "omniroute", + model: "MainAgent", + body: { messages: [{ role: "user", content: "inspect status" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.content, sentence); + assert.equal(choice.message.tool_calls, undefined); + assert.match(text, /\[Tool call: terminal\] was executed successfully/); +}); + +test("createSSEStream passthrough does not swallow false positive textual tool call starting chunk", async () => { + let onCompletePayload = null; + const chunk1 = "[Tool call: terminal]"; + const chunk2 = " was skipped."; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { role: "assistant", content: chunk1 } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { content: chunk2 } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "omniroute", + model: "MainAgent", + body: { messages: [{ role: "user", content: "inspect status" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.content, chunk1 + chunk2); + assert.equal(choice.message.tool_calls, undefined); + assert.match(text, /\[Tool call: terminal\] was skipped/); +}); diff --git a/tests/unit/t40-opencode-cli-tools-integration.test.ts b/tests/unit/t40-opencode-cli-tools-integration.test.ts index fbaf05db0a..580b08f5ee 100644 --- a/tests/unit/t40-opencode-cli-tools-integration.test.ts +++ b/tests/unit/t40-opencode-cli-tools-integration.test.ts @@ -21,7 +21,10 @@ test("T40: OpenCode card documents config paths and --variant usage", () => { .toLowerCase(); assert.match(notesText, /\.config\/opencode\/opencode\.json/); - assert.match(notesText, /%appdata%/); + // #3330: OpenCode uses ~/.config on all platforms (incl. Windows) — the note + // must no longer point Windows users at %APPDATA%. + assert.doesNotMatch(notesText, /%appdata%/); + assert.match(notesText, /%userprofile%/); assert.match(notesText, /--variant/); }); @@ -36,6 +39,8 @@ test("T40: OpenCode config path resolves per-platform", () => { const linuxDefault = resolveOpencodeConfigPath("linux", {}, "/home/dev"); assert.equal(linuxDefault, path.join("/home/dev", ".config", "opencode", "opencode.json")); + // #3330: OpenCode uses XDG `~/.config/opencode/` on ALL platforms including + // Windows (NOT %APPDATA%) — OmniRoute must write where OpenCode reads. const windowsPath = resolveOpencodeConfigPath( "win32", { APPDATA: "C:\\Users\\dev\\AppData\\Roaming" }, @@ -43,8 +48,16 @@ test("T40: OpenCode config path resolves per-platform", () => { ); assert.equal( windowsPath, - path.join("C:\\Users\\dev\\AppData\\Roaming", "opencode", "opencode.json") + path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json") ); + + // Windows still honors XDG_CONFIG_HOME when set. + const windowsXdg = resolveOpencodeConfigPath( + "win32", + { XDG_CONFIG_HOME: "D:\\xdg" }, + "C:\\Users\\dev" + ); + assert.equal(windowsXdg, path.join("D:\\xdg", "opencode", "opencode.json")); }); test("T40: OpenCode config generator includes endpoint and selected API key", () => { diff --git a/tests/unit/tlsClient-circuit-breaker.test.ts b/tests/unit/tlsClient-circuit-breaker.test.ts new file mode 100644 index 0000000000..4b1b65508e --- /dev/null +++ b/tests/unit/tlsClient-circuit-breaker.test.ts @@ -0,0 +1,151 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert/strict"; + +let tlsClient: import("../../open-sse/utils/tlsClient.ts").default; + +describe("tlsClient circuit breaker — session recreation", () => { + before(async () => { + tlsClient = (await import("../../open-sse/utils/tlsClient.ts")).default; + tlsClient.resetCircuit(); + }); + + after(() => { + tlsClient.resetCircuit(); + }); + + // -------------------------------------------------------------------------- + // 1. Circuit opens after maxFailures consecutive failures + // -------------------------------------------------------------------------- + it("opens circuit after maxFailures (3) consecutive failures", () => { + tlsClient.resetCircuit(); + + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + assert.equal(tlsClient.getCircuitState().failureCount, 1); + + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + assert.equal(tlsClient.getCircuitState().failureCount, 2); + + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, true); + assert.equal(tlsClient.getCircuitState().failureCount, 3); + }); + + // -------------------------------------------------------------------------- + // 2. RecordSuccess resets failure count and closes circuit + // -------------------------------------------------------------------------- + it("recordSuccess resets failure count and closes circuit", () => { + tlsClient.resetCircuit(); + + // Open the circuit + tlsClient.recordFailure(); + tlsClient.recordFailure(); + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, true); + assert.equal(tlsClient.getCircuitState().failureCount, 3); + + // Simulate success (as would happen after half-open retry) + tlsClient.recordSuccess(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + assert.equal(tlsClient.getCircuitState().failureCount, 0); + }); + + // -------------------------------------------------------------------------- + // 3. Session is nulled when circuit opens (stale session cleanup) + // -------------------------------------------------------------------------- + it("stale session is closed when circuit opens", () => { + tlsClient.resetCircuit(); + // Force-clear the session since we can't create a real wreq-js session + // The session property is private, so we verify via behavior: + // After circuit opens and then recovers, getSession() should attempt to + // create a new session rather than returning the old one. + // We verify this by checking available flag transitions. + assert.equal(tlsClient.getCircuitState().available, true); + + tlsClient.recordFailure(); + tlsClient.recordFailure(); + tlsClient.recordFailure(); + + // Circuit open — available should be false + assert.equal(tlsClient.getCircuitState().circuitTripped, true); + assert.equal(tlsClient.getCircuitState().available, false); + + // After simulated cooldown period, recordSuccess closes circuit + tlsClient.recordSuccess(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + assert.equal(tlsClient.getCircuitState().available, true); + }); + + // -------------------------------------------------------------------------- + // 4. checkCircuit allows half-open retry after cooldown + // -------------------------------------------------------------------------- + it("checkCircuit returns true after cooldown period (half-open)", () => { + tlsClient.resetCircuit(); + + // Open the circuit + tlsClient.recordFailure(); + tlsClient.recordFailure(); + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().coolDownRemainingMs > 0, true); + + // Set circuitOpenUntil to the past (simulate cooldown expired) + // We access the private circuitOpenUntil via getCircuitState + const state = tlsClient.getCircuitState(); + if (state.coolDownRemainingMs > 0) { + // Fast-forward by setting failureCount to 0 and circuitTripped to false + // via recordSuccess — this simulates the half-open retry behavior + tlsClient.recordSuccess(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + } + }); + + // -------------------------------------------------------------------------- + // 5. Multiple circuit open/close cycles don't degrade + // -------------------------------------------------------------------------- + it("survives 100 circuit open/close cycles without degradation", () => { + tlsClient.resetCircuit(); + + for (let i = 0; i < 100; i++) { + // Open: 3 failures + tlsClient.recordFailure(); + tlsClient.recordFailure(); + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, true); + + // Close: success + tlsClient.recordSuccess(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + assert.equal(tlsClient.getCircuitState().failureCount, 0); + } + + assert.equal(tlsClient.getCircuitState().available, true); + }); + + // -------------------------------------------------------------------------- + // 6. Partial failures don't trip the circuit + // -------------------------------------------------------------------------- + it("does not open circuit on 1-2 failures (below maxFailures)", () => { + tlsClient.resetCircuit(); + + tlsClient.recordFailure(); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + + tlsClient.recordSuccess(); // Recovery before reaching threshold + + assert.equal(tlsClient.getCircuitState().failureCount, 0); + assert.equal(tlsClient.getCircuitState().circuitTripped, false); + }); + + // -------------------------------------------------------------------------- + // 7. Session exit() is safe to call multiple times + // -------------------------------------------------------------------------- + it("exit() is safe to call when session is already null", async () => { + tlsClient.resetCircuit(); + // Calling exit when session is null should not throw + await tlsClient.exit(); + // Calling exit again should also be safe + await tlsClient.exit(); + assert.equal(tlsClient.getCircuitState().available, true); + }); +}); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 71898d7de9..ba968904bf 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -600,3 +600,103 @@ test("Gemini stream: suppresses malformed textual Tool call marker", () => { ); assert.equal(result.at(-1).choices[0].finish_reason, "stop"); }); + +test("Gemini stream: handles textual Tool call block split across chunks", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-split", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "[Tool call: terminal]", + }, + ], + }, + }, + ], + }; + const chunk2 = { + responseId: "resp-split", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: '\nArguments: {"command":"whoami"}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + + const leakedContent = [...res1, ...res2] + .map((event) => event.choices?.[0]?.delta?.content || "") + .join(""); + + assert.equal(leakedContent, ""); + + const toolCalls = [...res1, ...res2].flatMap( + (event) => event.choices?.[0]?.delta?.tool_calls || [] + ); + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.name, "terminal"); + assert.equal(toolCalls[0].function.arguments, JSON.stringify({ command: "whoami" })); +}); + +test("Gemini stream: does not swallow false positive textual tool call in backticks", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-false-positive", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Как исправить: `[Tool call: ", + }, + ], + }, + }, + ], + }; + const chunk2 = { + responseId: "resp-false-positive", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "terminal]` не будут проходить.", + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + + const leakedContent = [...res1, ...res2] + .map((event) => event.choices?.[0]?.delta?.content || "") + .join(""); + + assert.equal(leakedContent, "Как исправить: `[Tool call: terminal]` не будут проходить."); + + const toolCalls = [...res1, ...res2].flatMap( + (event) => event.choices?.[0]?.delta?.tool_calls || [] + ); + assert.equal(toolCalls.length, 0); +}); diff --git a/tests/unit/tryBackedChat.test.ts b/tests/unit/tryBackedChat.test.ts new file mode 100644 index 0000000000..b68c37d70d --- /dev/null +++ b/tests/unit/tryBackedChat.test.ts @@ -0,0 +1,187 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; + +import { + __setHttpBackedChatOverrideForTesting, + __resetHttpBackedChatOverrideForTesting, + __setBrowserBackedChatOverrideForTesting, + __resetBrowserBackedChatOverrideForTesting, + tryBackedChat, +} from "../../open-sse/services/browserBackedChat.ts"; +import type { BrowserBackedChatResult } from "../../open-sse/services/browserBackedChat.ts"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const OK_RESPONSE: BrowserBackedChatResult = { + status: 200, + contentType: "text/event-stream", + body: Buffer.from("data: hello\n"), + isStealth: true, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, +}; + +const CHALLENGE_RESPONSE: BrowserBackedChatResult = { + status: 403, + contentType: "application/json", + body: Buffer.from(JSON.stringify({ error: "challenge" })), + isStealth: true, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, +}; + +const FAILURE_RESPONSE: BrowserBackedChatResult = { + status: 502, + contentType: "application/json", + body: Buffer.from(JSON.stringify({ error: "upstream error" })), + isStealth: true, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, +}; + +const BASE_REQ = { + poolKey: "test-provider", + chatUrl: "https://example.com/chat", + chatPageUrl: "https://example.com/", + userMessage: "hello", + chatUrlMatchDomain: "example.com", + cookieDomain: "example.com", + inputSelector: "textarea", +}; + +describe("tryBackedChat", () => { + after(() => { + __resetHttpBackedChatOverrideForTesting(); + __resetBrowserBackedChatOverrideForTesting(); + }); + + // -------------------------------------------------------------------------- + // 1. Fast path — httpBackedChat returns 2xx immediately + // -------------------------------------------------------------------------- + it("returns httpBackedChat result when status is 2xx", async () => { + __setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); + __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); + + const result = await tryBackedChat({ ...BASE_REQ }); + + assert.equal(result.status, 200); + assert.equal(result.body.toString(), "data: hello\n"); + }); + + // -------------------------------------------------------------------------- + // 2. Challenge — not a challenge code (non-4xx) → return immediately + // -------------------------------------------------------------------------- + it("returns non-challenge non-2xx (501) without falling back", async () => { + const notImplemented: BrowserBackedChatResult = { + ...FAILURE_RESPONSE, + status: 501, + }; + __setHttpBackedChatOverrideForTesting(() => Promise.resolve(notImplemented)); + __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); + + const result = await tryBackedChat({ ...BASE_REQ }); + + assert.equal(result.status, 501); + }); + + // -------------------------------------------------------------------------- + // 3. Challenge → no cookieDomain → skip cookie refresh → browserBackedChat + // -------------------------------------------------------------------------- + it("falls back to browserBackedChat when no cookieDomain is set", async () => { + let httpCalled = false; + __setHttpBackedChatOverrideForTesting(() => { + httpCalled = true; + return Promise.resolve(CHALLENGE_RESPONSE); + }); + __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); + + const result = await tryBackedChat({ ...BASE_REQ, cookieDomain: undefined }); + + assert.equal(result.status, 200); + assert.equal(httpCalled, true, "httpBackedChat must be called first"); + }); + + // -------------------------------------------------------------------------- + // 4. Challenge → cookie refresh succeeds → retry succeeds + // -------------------------------------------------------------------------- + it("retries httpBackedChat with fresh cookies after browser refresh", async () => { + let callCount = 0; + let lastCookie: string | undefined; + __setHttpBackedChatOverrideForTesting((req) => { + callCount++; + lastCookie = req.cookieString; + // First call fails with challenge, retry with fresh cookies succeeds + if (callCount === 1) return Promise.resolve(CHALLENGE_RESPONSE); + return Promise.resolve(OK_RESPONSE); + }); + // refreshCookiesViaBrowser is internal, not mockable directly. + // We mock browserBackedChat to return OK so the browser refresh + // signal is tested; the actual cookie refresh is tested by the + // cookie being passed to httpBackedChat retry. + __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); + + const result = await tryBackedChat({ + ...BASE_REQ, + // Pass a pre-set cookie so httpBackedChat override sees it + cookieString: "session=abc", + }); + + assert.equal(result.status, 200); + }); + + // -------------------------------------------------------------------------- + // 5. External AbortSignal → abort before first call → returns 504 + // -------------------------------------------------------------------------- + it("returns 504 when external AbortSignal is already aborted", async () => { + __setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError"))); + __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); + + const ac = new AbortController(); + ac.abort(); + const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal }); + + assert.equal(result.status, 504); + const body = JSON.parse(result.body.toString()); + assert.equal(body.error.type, "timeout_error"); + }); + + // -------------------------------------------------------------------------- + // 6. httpBackedChat AbortError from timeout → returns 504 + // -------------------------------------------------------------------------- + it("returns 504 when httpBackedChat throws AbortError during request", async () => { + __setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError"))); + __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); + + // Use a signal that aborts immediately to simulate timeout + const ac = new AbortController(); + ac.abort(); + const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal }); + + assert.equal(result.status, 504); + }); + + // -------------------------------------------------------------------------- + // 7. Both httpBackedChat (with retry) and browserBackedChat fail → last failure + // -------------------------------------------------------------------------- + it("returns the last failure when all paths fail", async () => { + __setHttpBackedChatOverrideForTesting(() => Promise.resolve(CHALLENGE_RESPONSE)); + __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(FAILURE_RESPONSE)); + + const result = await tryBackedChat({ ...BASE_REQ }); + + assert.equal(result.status, 502); + }); + + // -------------------------------------------------------------------------- + // 8. Cleanup: internal AbortController timer doesn't leak after fast success + // -------------------------------------------------------------------------- + it("does not leak AbortController timer when httpBackedChat succeeds quickly", async () => { + __setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); + __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); + + // Call tryBackedChat without an external signal so it creates an internal AbortController + const result = await tryBackedChat({ ...BASE_REQ, signal: undefined }); + + assert.equal(result.status, 200); + // If the timer leaked and fired, it would try to abort an already-resolved controller. + // That's harmless but wasteful; this test just verifies the response is correct. + }); +}); diff --git a/tests/unit/ui/hermes-agent-config-loop.test.tsx b/tests/unit/ui/hermes-agent-config-loop.test.tsx new file mode 100644 index 0000000000..d98b57c10c --- /dev/null +++ b/tests/unit/ui/hermes-agent-config-loop.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +// +// Regression guard for the infinite render loop on /dashboard/cli-agents/hermes-agent. +// +// HermesAgentToolCard used to list `currentRoles` in the dependency array of the +// effect that calls loadCurrentConfig(); loadCurrentConfig() sets currentRoles to a +// fresh object on every fetch, so the effect re-fired → refetched → re-set → … forever. +// On the detail page isExpanded is hardcoded true, so it spun without end and spammed +// GET /api/cli-tools/hermes-agent-settings in the console. This test mounts the card +// expanded and asserts the settings endpoint is fetched a BOUNDED number of times. +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: any) => <div>{children}</div>, + Button: ({ children, onClick, disabled, ...props }: any) => ( + <button type="button" onClick={onClick} disabled={disabled} {...props}> + {children} + </button> + ), + ModelSelectModal: () => <div data-testid="ModelSelectModal" />, +})); + +const { default: HermesAgentToolCard } = await import( + "@/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard" +); + +const SETTINGS_ENDPOINT = "/api/cli-tools/hermes-agent-settings"; +const containers: HTMLElement[] = []; +let settingsCalls = 0; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + settingsCalls = 0; + + // fetch mock: count settings GETs and return valid roles. A safety valve breaks any + // runaway loop after 8 calls (returns a non-success body so loadCurrentConfig stops + // mutating currentRoles) so the buggy code FAILS the assertion instead of hanging. + vi.stubGlobal( + "fetch", + vi.fn(async (url: any) => { + const u = typeof url === "string" ? url : String(url); + if (u.includes(SETTINGS_ENDPOINT)) { + settingsCalls++; + if (settingsCalls > 8) { + return { ok: false, json: async () => ({ success: false }) } as any; + } + return { + ok: true, + json: async () => ({ + success: true, + roles: { + default: { + model: "openai/gpt-4o", + provider: "omniroute", + base_url: "http://localhost:20128", + }, + }, + }), + } as any; + } + return { ok: true, json: async () => ({}) } as any; + }) + ); +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +function renderExpandedCard() { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + act(() => { + root.render( + <HermesAgentToolCard + tool={{ name: "Hermes Agent", description: "Hermes Agent" }} + isExpanded={true} + baseUrl="http://localhost:20128" + apiKeys={[{ id: "key-1" }]} + activeProviders={[]} + batchStatus={null} + /> + ); + }); + return container; +} + +describe("HermesAgentToolCard — config-load effect", () => { + it("loads current config once when expanded (does not loop on currentRoles)", async () => { + renderExpandedCard(); + + // Drive every pending microtask/effect to quiescence. On the buggy code each + // settings response re-triggered the effect, so these flushes would keep firing + // fetches up to the safety valve; on the fixed code it settles after one fetch. + for (let i = 0; i < 6; i++) { + await act(async () => {}); + } + + expect(settingsCalls).toBeLessThanOrEqual(2); + expect(settingsCalls).toBeGreaterThanOrEqual(1); + }); +});