mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
5 Commits
fix/agentr
...
fix/i18n-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2925fde9f | ||
|
|
9a64e0e17d | ||
|
|
e2eba05e5c | ||
|
|
9e628e73fa | ||
|
|
9061d7c70d |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -39,17 +39,6 @@ updates:
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# ioredis is a SOFT/optional dependency loaded through a dynamic import
|
||||
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
|
||||
# so a breaking major never fails at build or typecheck time: the only consumers
|
||||
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
|
||||
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
|
||||
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
|
||||
# green and only surface at runtime for operators running distributed quota — the
|
||||
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
|
||||
# bumps; majors here need their own PR and a deliberate migration review.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
|
||||
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -155,13 +155,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.5.2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.5.2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -255,13 +255,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.5.2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.5.2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -390,7 +390,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -17,13 +17,6 @@
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
|
||||
# merge_protections_settings — the rules-based queue action / autoqueue path is
|
||||
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- label = queue
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
@@ -41,26 +34,14 @@ queue_rules:
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
|
||||
# continue-on-error by design, and its GH-hosted Turbopack build hangs
|
||||
# recurrently mid-"Creating an optimized production build" (100% failure rate
|
||||
# across every sampled PR since the job was added 2026-07-27, always killed by
|
||||
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
|
||||
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
|
||||
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
|
||||
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
|
||||
# carrying its tolerance forward would mask problems it no longer causes.
|
||||
- or:
|
||||
- "#check-failure=0"
|
||||
- and:
|
||||
- "#check-failure=1"
|
||||
- check-failure=Build (advisory)
|
||||
- "#check-failure=0"
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
|
||||
659
AGENTS.md
659
AGENTS.md
@@ -1,117 +1,600 @@
|
||||
# OmniRoute agent guide
|
||||
# omniroute — Agent Guidelines
|
||||
|
||||
## Project
|
||||
|
||||
OmniRoute is a unified AI proxy/router. The repository contains the Next.js application
|
||||
(`src/`), streaming engine workspace (`open-sse/`), Electron desktop app (`electron/`),
|
||||
CLI (`bin/`), and tests (`tests/`).
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
## Setup and focused checks
|
||||
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
|
||||
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
- Runtime: Node.js `>=22.22.3 <23` or `>=24.0.0 <27`; npm 10+.
|
||||
- Install dependencies: `npm install`.
|
||||
- Start development: `npm run dev`.
|
||||
- Build: `npm run build`; release build: `npm run build:release`.
|
||||
- Lint: `npm run lint`.
|
||||
- Core type check: `npm run typecheck:core`.
|
||||
- Run the most focused test for changed code first:
|
||||
`node --import tsx/esm --test tests/unit/<file>.test.ts`.
|
||||
- Other suites: `npm run test:vitest`, `npm run test:e2e`,
|
||||
`npm run test:protocols:e2e`, and `npm run test:ecosystem`.
|
||||
- Run `npm run check:docs-all` after changing documentation.
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
|
||||
For the complete test matrix, coverage requirements, and pull-request gates, read
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).
|
||||
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
|
||||
|
||||
## Documentation accuracy
|
||||
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
|
||||
Every claim in a `.md` file under `docs/` should be verifiable against the source.
|
||||
|
||||
Documentation must describe verified behavior, not plausible behavior.
|
||||
**Rules (enforced by `npm run check:fabricated-docs`):**
|
||||
|
||||
1. Before documenting an API name, endpoint, path, CLI command, or environment variable,
|
||||
search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not
|
||||
document it.
|
||||
2. Measure mutable counts instead of writing them from memory: use `wc -l <file>` or a
|
||||
directory-specific count command.
|
||||
3. Copy code examples from working usage or run them. Prefer a source link such as
|
||||
`path/to/file.ts:line` to an invented signature.
|
||||
4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs
|
||||
validation.
|
||||
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
|
||||
```bash
|
||||
grep -rn "theName" src/ open-sse/ bin/
|
||||
# 0 hits → do not document
|
||||
```
|
||||
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
|
||||
```bash
|
||||
wc -l <file> # exact line count
|
||||
ls <dir>/*.ts | wc -l # file count
|
||||
```
|
||||
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
|
||||
Link to a real call site (`path:line`) instead of inventing a signature.
|
||||
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
|
||||
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
|
||||
Wrong docs cost more than missing docs, because people trust and act on them.
|
||||
|
||||
## Code conventions
|
||||
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
|
||||
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
|
||||
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
|
||||
|
||||
- Format with Prettier: two spaces, semicolons, double quotes, 100-character line width,
|
||||
and ES5 trailing commas. Run Prettier on changed files.
|
||||
- TypeScript target is ES2022 with bundler module resolution. Prefer explicit types.
|
||||
- Import order: external, internal (`@/` and `@omniroute/open-sse`), then relative.
|
||||
- Do not add logic to `src/lib/localDb.ts`; import from the owning `src/lib/db/` module.
|
||||
- Use specific errors and contextual logging. Do not silently swallow SSE-stream failures;
|
||||
use abort signals for cleanup and return appropriate HTTP status codes.
|
||||
## Stack
|
||||
|
||||
## Security requirements
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
|
||||
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
|
||||
- **Schemas**: Zod v4 for all API / MCP input validation
|
||||
|
||||
- Never commit credentials or log SQLite encryption keys.
|
||||
- Validate API inputs with Zod and use the route's required authentication path.
|
||||
- Sanitize user HTML with DOMPurify.
|
||||
- Use `resolvePublicCred()` for public upstream OAuth identifiers; never add them as string
|
||||
literals. See [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md).
|
||||
- Use `buildErrorBody()` or `sanitizeErrorMessage()` for HTTP, SSE, executor, and MCP errors;
|
||||
do not return raw `err.stack` or `err.message`. See
|
||||
[`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md).
|
||||
- Pass runtime values to `exec()` or `spawn()` through `env`, not interpolation into a script.
|
||||
---
|
||||
|
||||
## Repository map
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change.
|
||||
| Command | Description |
|
||||
| ----------------------------------- | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Next.js dev server |
|
||||
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
|
||||
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
|
||||
| `npm run start` | Run production build |
|
||||
| `npm run build:cli` | Build CLI package |
|
||||
| `npm run lint` | ESLint on all source files |
|
||||
| `npm run typecheck:core` | TypeScript core type checking |
|
||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
||||
| `npm run check` | Run lint + test |
|
||||
| `npm run check:cycles` | Check for circular dependencies |
|
||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
||||
| `npm run electron:build` | Build Electron app for current OS |
|
||||
|
||||
| Area | Location | Start here |
|
||||
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||
| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Safety and governance | `src/lib/{guardrails,compliance}/`, `src/server/authz/` | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Operations | `src/mitm/`, tunnel modules, `electron/` | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md), [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
**Build output layout:**
|
||||
|
||||
## Review focus
|
||||
| Directory | Purpose | Gitignored |
|
||||
| --------- | -------------------------------------------------- | ---------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | No |
|
||||
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
|
||||
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
|
||||
|
||||
- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes.
|
||||
- Send provider requests through `open-sse/handlers/`.
|
||||
- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`.
|
||||
- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema
|
||||
validation.
|
||||
- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request
|
||||
pipeline, and A2A skills.
|
||||
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||
the contributor receives credit.
|
||||
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
|
||||
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
|
||||
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
|
||||
|
||||
## Upstream contributions
|
||||
### Running Tests
|
||||
|
||||
This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal
|
||||
automation changes out of upstream PRs.
|
||||
```bash
|
||||
# All tests (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
Start upstream work from the active upstream default branch, not `main`:
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
node --import tsx/esm --test tests/unit/plan3-p0.test.ts
|
||||
node --import tsx/esm --test tests/unit/fixes-p1.test.ts
|
||||
node --import tsx/esm --test tests/unit/security-fase01.test.ts
|
||||
|
||||
# Integration tests
|
||||
node --import tsx/esm --test tests/integration/*.test.ts
|
||||
|
||||
# Vitest (MCP server, autoCombo)
|
||||
npm run test:vitest
|
||||
|
||||
# E2E with Playwright
|
||||
npm run test:e2e
|
||||
|
||||
# Protocol clients E2E (MCP transports, A2A)
|
||||
npm run test:protocols:e2e
|
||||
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage (see CONTRIBUTING.md)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting (Prettier — enforced via lint-staged)
|
||||
|
||||
2 spaces · semicolons required · double quotes (`"`) · 100 char width · es5 trailing commas.
|
||||
Always run `prettier --write` on changed files.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Target**: ES2022 · **Module**: `esnext` · **Resolution**: `bundler`
|
||||
- `strict: false` — prefer explicit types, don't rely on inference
|
||||
- Path aliases: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
|
||||
### ESLint Rules
|
||||
|
||||
- **Security (error, everywhere)**: `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Relaxed in `open-sse/` and `tests/`**: `@typescript-eslint/no-explicit-any` = warn
|
||||
- React hooks rules and `@next/next/no-assign-module-variable` disabled in `open-sse/` and `tests/`
|
||||
|
||||
### Naming
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ------------------- | -------------------------------- | ------------------------------------ |
|
||||
| Files | camelCase / kebab-case | `chatCore.ts`, `tokenHealthCheck.ts` |
|
||||
| React components | PascalCase | `Dashboard.tsx`, `ProviderCard.tsx` |
|
||||
| Functions/variables | camelCase | `getHealth()`, `switchCombo()` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
||||
| Interfaces | PascalCase (`I` prefix optional) | `ProviderConfig` |
|
||||
| Enums | PascalCase (members too) | `LogLevel.Error` |
|
||||
|
||||
### Imports
|
||||
|
||||
- **Order**: external → internal (`@/`, `@omniroute/open-sse`) → relative (`./`, `../`)
|
||||
- **No barrel imports** from `localDb.ts` — import from the specific `db/` module instead
|
||||
|
||||
### Error Handling
|
||||
|
||||
- try/catch with specific error types; always log with context (pino logger)
|
||||
- Never silently swallow errors in SSE streams — use abort signals for cleanup
|
||||
- Return proper HTTP status codes (4xx client, 5xx server)
|
||||
|
||||
### Security
|
||||
|
||||
- **NEVER** commit API keys, secrets, or credentials
|
||||
- Validate all user inputs with Zod schemas
|
||||
- Auth middleware required on all API routes
|
||||
- Never log SQLite encryption keys
|
||||
- Sanitize user content (dompurify for HTML)
|
||||
- **Public upstream OAuth identifiers** (Gemini / Antigravity / Windsurf-style client_id/secret + Firebase Web keys extracted from public CLIs): use `resolvePublicCred()` from `open-sse/utils/publicCreds.ts`, **never** as string literals. Full pattern in `docs/security/PUBLIC_CREDS.md`.
|
||||
- **Error responses** (HTTP / SSE / executor / MCP): use `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts`, **never** put raw `err.stack` / `err.message` in a Response body. Full pattern in `docs/security/ERROR_SANITIZATION.md`.
|
||||
- **`exec()` / `spawn()` with runtime values**: pass via the `env` option, **never** string-interpolate paths/values into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
- Prefer secure-by-default libraries when available — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) for the curated list (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
- Routing: `combos.ts`, `modelComboMappings.ts`, `domainState.ts`, `commandCodeAuth.ts`
|
||||
- Auth: `apiKeys.ts`, `secrets.ts`, `registeredKeys.ts`, `sessionAccountAffinity.ts`
|
||||
- Usage / billing: `quotaSnapshots.ts`, `creditBalance.ts`, `usage*.ts`, `compressionCacheStats.ts`
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
|
||||
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
`combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest.
|
||||
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
|
||||
|
||||
### API Route Layer (`src/app/api/v1/`)
|
||||
|
||||
Next.js App Router routes — each follows a consistent pattern:
|
||||
|
||||
```
|
||||
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
|
||||
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
|
||||
```
|
||||
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
|
||||
|
||||
**No global Next.js middleware file** — interception is route-specific. Auth is optional
|
||||
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
|
||||
|
||||
### Request Pipeline (`open-sse/`)
|
||||
|
||||
The `open-sse/` workspace is the core streaming engine. Full request flow:
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ src/app/api/v1/.../route.ts (Next.js route)
|
||||
→ open-sse/handlers/chatCore.ts::handleChatCore()
|
||||
→ Semantic/signature cache check
|
||||
→ Rate limit check (rateLimitManager)
|
||||
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
|
||||
→ resolveComboTargets() → ordered ResolvedComboTarget[]
|
||||
→ For each target: handleSingleModel() (wraps chatCore)
|
||||
→ translateRequest() (open-sse/translator/)
|
||||
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
|
||||
→ getExecutor() → provider-specific executor instance
|
||||
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
|
||||
→ buildUrl() + buildHeaders() + transformRequest()
|
||||
→ fetch() to upstream provider
|
||||
→ Retry logic with exponential backoff
|
||||
→ Response translation back to client format
|
||||
→ If Responses API: responsesTransformer.ts TransformStream
|
||||
→ SSE stream or JSON response to client
|
||||
```
|
||||
|
||||
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
|
||||
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
|
||||
`audioTranscription.ts`, `moderations.ts`, `rerank.ts`, `search.ts`.
|
||||
|
||||
**Upstream headers**: merged after default auth; same header name replaces executor value.
|
||||
**T5 intra-family fallback** recomputes headers using only the fallback model id.
|
||||
Forbidden header names: `src/shared/constants/upstreamHeaders.ts` — keep sanitize,
|
||||
Zod schemas, and unit tests aligned when editing.
|
||||
|
||||
### Provider Categories
|
||||
|
||||
- **Free** (2): Qoder AI, Kiro AI
|
||||
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
|
||||
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
|
||||
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
|
||||
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
|
||||
Puter, Longcat, Alibaba, Kimi, Minimax, Blackbox, Synthetic, Kilo Gateway,
|
||||
Z.AI, GLM, Deepgram, AssemblyAI, ElevenLabs, Cartesia, PlayHT, Inworld,
|
||||
NanoBanana, SD WebUI, ComfyUI, Ollama Cloud, Perplexity Search, Serper, Brave, Exa,
|
||||
Tavily, OpenCode Zen/Go, Bailian Coding Plan, DeepInfra, Vercel AI Gateway,
|
||||
Lambda AI, SambaNova, nScale, OVHcloud AI, Baseten, PublicAI, Moonshot AI,
|
||||
Meta Llama API, v0 (Vercel), Morph, Featherless AI, FriendliAI, LlamaGate,
|
||||
Galadriel, Weights & Biases Inference, Volcengine, AI21 Labs, Venice.ai,
|
||||
Codestral, Upstage, Maritalk, Xiaomi MiMo, Inference.net, NanoGPT, Predibase,
|
||||
Bytez, Heroku AI, Databricks, Snowflake Cortex, GigaChat (Sber), CrofAI,
|
||||
AgentRouter, ChatGPT Web, Baidu Qianfan, AWS Polly, RunwayML, GitLab Duo,
|
||||
Amazon Q, Empower, Poe, and many more.
|
||||
- **Self-Hosted** (8+): LM Studio, vLLM, Lemonade, Llamafile, Triton, Docker Model Runner, Xinference, Oobabooga
|
||||
- **Custom**: OpenAI-compatible (`openai-compatible-*`) and Anthropic-compatible (`anthropic-compatible-*`) prefixes
|
||||
|
||||
Providers are registered in `src/shared/constants/providers.ts` with Zod validation at module load.
|
||||
|
||||
### Executors (`open-sse/executors/`)
|
||||
|
||||
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
|
||||
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
|
||||
|
||||
#### Executor Internals
|
||||
|
||||
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
|
||||
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
|
||||
override URL/header/transform methods for provider-specific behavior.
|
||||
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
|
||||
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
|
||||
header format, and request transformations.
|
||||
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
|
||||
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
|
||||
override only what differs from the default.
|
||||
|
||||
### Translator (`open-sse/translator/`)
|
||||
|
||||
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
|
||||
Includes request/response translators with helpers for image handling.
|
||||
|
||||
#### Translator Internals
|
||||
|
||||
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
|
||||
`chatCore.ts` before executor dispatch.
|
||||
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
|
||||
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
|
||||
transformed body ready for the target provider.
|
||||
- **Response translation** runs in reverse after upstream response, converting back to
|
||||
the client's expected format.
|
||||
|
||||
### Transformer (`open-sse/transformer/`)
|
||||
|
||||
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
|
||||
|
||||
#### Transformer Internals
|
||||
|
||||
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
|
||||
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
|
||||
(`response.output_item.added`, `response.output_text.delta`, etc.).
|
||||
- Used when the client sends a Responses API request: the request is internally converted
|
||||
to Chat Completions format, dispatched normally, and the response is piped through this
|
||||
transform stream before reaching the client.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
|
||||
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
|
||||
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, `compression/` (prompt
|
||||
compression pipeline), and more.
|
||||
|
||||
#### Prompt Compression Pipeline (`compression/`)
|
||||
|
||||
Modular prompt compression that runs proactively before the existing reactive context manager.
|
||||
|
||||
- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments,
|
||||
combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo >
|
||||
combo override > auto-trigger > default mode > off.
|
||||
- **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`,
|
||||
`compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at
|
||||
<1ms latency.
|
||||
- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in
|
||||
rules plus file-loaded language packs under `compression/rules/`.
|
||||
- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects
|
||||
command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code
|
||||
noise, and preserves errors/actionable context. The RTK JSON DSL supports replace,
|
||||
match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation,
|
||||
inline tests, trust-gated project/global custom filters, and optional redacted raw-output
|
||||
retention for authenticated recovery.
|
||||
- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines.
|
||||
- **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens,
|
||||
savings %, techniques used, engine breakdown, compression combo id).
|
||||
- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked),
|
||||
`CompressionConfig`, `CompressionStats`, `CompressionResult`.
|
||||
- DB settings in `src/lib/db/compression.ts`, compression combos in
|
||||
`src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`,
|
||||
`src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`.
|
||||
|
||||
#### Combo Routing Engine (`combo.ts`)
|
||||
|
||||
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
### Domain Layer (`src/domain/`)
|
||||
|
||||
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
`degradation.ts`, `fallbackPolicy.ts`, `lockoutPolicy.ts`, `modelAvailability.ts`,
|
||||
`providerExpiration.ts`, `quotaCache.ts`, `responses.ts`, `configAudit.ts`.
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
set_routing_strategy, set_resilience_profile, test_combo, get_provider_metrics,
|
||||
best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_pricing.
|
||||
|
||||
**Cache tools** (2): cache_stats, cache_flush.
|
||||
|
||||
**Compression tools** (5): compression_status, compression_configure, set_compression_engine,
|
||||
list_compression_combos, compression_combo_stats.
|
||||
|
||||
**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats.
|
||||
|
||||
**Memory tools** (3): memory_search, memory_add, memory_clear.
|
||||
|
||||
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
|
||||
|
||||
**Agent-skill tools** (3): A2A skill discovery / invocation bridges.
|
||||
|
||||
**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries.
|
||||
|
||||
**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection.
|
||||
|
||||
**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops).
|
||||
|
||||
#### MCP Internals
|
||||
|
||||
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
|
||||
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
|
||||
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
|
||||
### A2A Server (`src/lib/a2a/`)
|
||||
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
|
||||
Agent Card at `/.well-known/agent.json`.
|
||||
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
|
||||
|
||||
#### A2A Internals
|
||||
|
||||
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
|
||||
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
|
||||
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
|
||||
`tasks/cancel`. Dispatched via `POST /a2a`.
|
||||
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
|
||||
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
|
||||
quota; `smartRouting.ts` recommends routing decisions.
|
||||
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
|
||||
for client auto-discovery.
|
||||
|
||||
### ACP Module (`src/lib/acp/`)
|
||||
|
||||
Agent Communication Protocol registry and manager.
|
||||
|
||||
### Memory System (`src/lib/memory/`)
|
||||
|
||||
Extraction, injection, retrieval, summarization, and store modules for persistent
|
||||
conversational memory across sessions.
|
||||
|
||||
### Skills System (`src/lib/skills/`)
|
||||
|
||||
Extensible skill framework: registry, executor, sandbox, built-in skills,
|
||||
custom skill support, interception, and injection.
|
||||
|
||||
#### Skills Internals
|
||||
|
||||
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
|
||||
(name, description, version, enabled status) stored in SQLite.
|
||||
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
|
||||
Receives skill name + input, looks up the skill, runs it in the sandbox.
|
||||
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
|
||||
access and execution time.
|
||||
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
|
||||
alongside the registry.
|
||||
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
|
||||
processing) or inject context into prompts.
|
||||
|
||||
### Compliance (`src/lib/compliance/`)
|
||||
|
||||
Policy index for compliance enforcement.
|
||||
|
||||
### MITM Proxy (`src/mitm/`)
|
||||
|
||||
MITM proxy capability with certificate management, DNS handling, and target routing.
|
||||
|
||||
### Middleware (`src/middleware/`)
|
||||
|
||||
Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
### Guardrails (`src/lib/guardrails/`)
|
||||
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
|
||||
### Cloud Agents (`src/lib/cloudAgent/`)
|
||||
|
||||
`CloudAgentBase` abstract class + 3 agents (codex-cloud, devin, jules). Tasks persisted in `cloud_agent_tasks`; management auth required. See [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md).
|
||||
|
||||
### Evals (`src/lib/evals/`)
|
||||
|
||||
Generic eval framework: `evalRunner.ts`, `runtime.ts`. Targets: combo / model / suite-default. See [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md).
|
||||
|
||||
### Webhooks (`src/lib/webhookDispatcher.ts`)
|
||||
|
||||
HMAC-signed delivery, exponential backoff, auto-disable after 10 failures. 7 event types. See [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md).
|
||||
|
||||
### Authorization Pipeline (`src/server/authz/`)
|
||||
|
||||
`classify → policies → enforce`. 3 route classes (PUBLIC / CLIENT_API / MANAGEMENT). See [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md).
|
||||
|
||||
### Reasoning Replay (`src/lib/db/reasoningCache.ts` + `open-sse/services/reasoningCache.ts`)
|
||||
|
||||
Hybrid in-memory + SQLite cache for `reasoning_content`. Re-injects on multi-turn for strict providers (DeepSeek V4, Kimi K2, Qwen-Thinking, GLM, xiaomi-mimo). See [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md).
|
||||
|
||||
### Tunnels (`src/lib/{cloudflaredTunnel,ngrokTunnel}.ts` + `src/app/api/tunnels/`)
|
||||
|
||||
Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md).
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
1. Register in `src/shared/constants/providers.ts`
|
||||
2. Add executor in `open-sse/executors/` (if custom logic needed)
|
||||
3. Add translator in `open-sse/translator/` (if non-OpenAI format)
|
||||
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` (if OAuth-based)
|
||||
5. Add models in `open-sse/config/providerRegistry.ts`
|
||||
|
||||
---
|
||||
|
||||
## Subdirectory AGENTS.md Files
|
||||
|
||||
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
|
||||
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
|
||||
|
||||
## Reference Documentation (docs/)
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
## Fork / Upstream Workflow
|
||||
|
||||
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
|
||||
changes (for example GHCR image publishing, personal deployment workflows, or local
|
||||
automation) out of upstream contribution PRs.
|
||||
|
||||
When preparing a PR for upstream, always start the work branch from the upstream
|
||||
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
|
||||
Never branch from `main`: `main` only receives release squash-merges, so a branch
|
||||
cut there is weeks behind and produces conflict-heavy PRs
|
||||
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git switch -c <branch-name> upstream/<default-branch>
|
||||
# the default branch is the active release line, e.g. release/v3.8.49
|
||||
git switch -c <branch-name> upstream/release/vX.Y.Z
|
||||
```
|
||||
|
||||
Target that same release branch in the pull request. Stage only the intended files, run the
|
||||
focused checks, and use a Conventional Commit message (for example, `docs: slim AGENTS.md`).
|
||||
Only cherry-pick or reapply the changes intended for the upstream PR.
|
||||
|
||||
## Reference documentation
|
||||
---
|
||||
|
||||
Use the source of truth for the area you are changing:
|
||||
## Review Focus
|
||||
|
||||
| Area | Reference |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Repository navigation and architecture | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md), [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| API and providers | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md), [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md), [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Routing, resilience, and reasoning | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md), [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md), [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Security | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md), [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Platform features | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md), [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Releases and quality | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md), [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes
|
||||
- **Provider requests** flow through `open-sse/handlers/`
|
||||
- **MCP/A2A pages** are tabs inside `/dashboard/endpoint`, not standalone routes
|
||||
- **No memory leaks** in SSE streams (abort signals, cleanup)
|
||||
- **Rate limit headers** must be parsed correctly
|
||||
- All API inputs validated with **Zod schemas**
|
||||
- **Provider constants** validated at module load via Zod (`src/shared/validation/providerSchema.ts`)
|
||||
- **Pricing data** syncs from LiteLLM via `src/lib/pricingSync.ts`
|
||||
- **Memory/Skills** are cross-cutting: affect MCP tools, request pipeline, and A2A skills
|
||||
- **⛔ NEVER close a contributor's PR** after using their code — always merge via GitHub so they get credit. See `.agents/workflows/review-prs.md` for full policy.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** filter provider detail connections server-side while preserving full-page search and pagination. (thanks @RobertsXML)
|
||||
@@ -1,3 +0,0 @@
|
||||
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
|
||||
- fix(vision-bridge): validate fixedModel against usable credentials before short-circuiting in getBestVisionModel, so the default "openai/gpt-4o-mini" is not unconditionally selected when no OpenAI connection exists (#8430)
|
||||
- fix(vision-bridge): in the combo describe path, replace raw images with an error text stub when all describe attempts fail, instead of forwarding images to a confirmed non-vision backend that would reject them with an opaque serde error (#8430)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(api):** Let image and video providers enforce their own request-size limits instead of rejecting media payloads at OmniRoute's 10 MB global default ([#8843](https://github.com/diegosouzapw/OmniRoute/pull/8843)) — thanks @artickc
|
||||
@@ -1 +0,0 @@
|
||||
- fix(proxy-health): include credentials in proxy health check URLs (#8853)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(auth): setting first dashboard login password no longer fails with HTTP 400 PASSWORD_REQUIRED (#8950)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): copilot-m365-web enterprise turns send disconnectBehavior=continue (#8971)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** error-only streams now preserve sanitized executor diagnostics for operators without changing stream-readiness fallback classification ([#9022](https://github.com/diegosouzapw/OmniRoute/pull/9022)) — thanks @shixi-li
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** pass `output_config.effort="max"` through verbatim instead of unconditionally rewriting it to `xhigh`, so Anthropic → OpenAI-shape upstream calls reach `sanitizeReasoningEffortForProvider` with the carrier intact and providers that accept `max` literally (Ollama Cloud, opencode-go DeepSeek, Moonshot K3, native Claude) no longer 400 on `invalid reasoning value: 'xhigh'`. Regression guard: end-to-end test in `tests/unit/base-executor-sanitize-effort.test.ts`.
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): anthropic strips code-execution/skills beta flag, causing container rejection (#9064)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(dashboard):** the provider "Auto Sync" toggle now applies to every active connection and each connection gets its own Auto Sync toggle — previously only the lowest-priority connection was updated. ([#9149](https://github.com/diegosouzapw/OmniRoute/pull/9149))
|
||||
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547
|
||||
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** harden Claude format detection for relative message endpoints and kebab-case version metadata. (thanks @ervareza)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(claude): remove unconditional "always" return in claudeClassifierCompat so normal chat requests are not swallowed (#9276)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(db):** persist the account egress IP into `proxy_logs.egress_ip` (migration 134 + schema reconciler) so real traffic stays attributable to the actual node/IP even after restart — the egress IP was previously computed and logged but silently dropped from persistence ([#9291](https://github.com/diegosouzapw/OmniRoute/pull/9291)) — thanks @maxmad64bis
|
||||
@@ -1 +0,0 @@
|
||||
- fix(mcp): break circular import between googApiKeyAuth.ts and auth.ts to fix esbuild SyntaxError in MCP server bundle (#9297)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(security): require auth for /v1/models when management auth is configured (#9320)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): map kimi-web/K3 to K2D5 scenario instead of OK Computer premium mode to fix resource_exhausted on non-subscriber accounts (#9338)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(security): require explicit tool envelope to prevent bare JSON from being promoted to real tool_calls (#9343)
|
||||
@@ -1,2 +0,0 @@
|
||||
- fix(providers): treat claude-web 429 as unhealthy and forward upstream Retry-After header (#9406)
|
||||
- fix(providers): treat muse-spark-web 429 as unhealthy (#9406)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(api): fall back to slugified provider name when prefix is empty to prevent UUID leak in /v1/models (#9416)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(routing):** a Codex-native bare model id (`gpt-5.5`, the `gpt-5.6-sol`/`terra`/`luna` tiers) no longer routes to `codex` when no codex connection is active — an OpenAI-only install was getting `no active credentials for provider: codex` for a model OpenAI serves, and an install whose codex connection was merely inactive failed the same way. With codex active the Codex preference still wins over OpenAI, and ids only codex catalogs (`codex-auto-review`) still resolve to codex with no connection at all ([#9447](https://github.com/diegosouzapw/OmniRoute/pull/9447))
|
||||
@@ -1 +0,0 @@
|
||||
- **chore(ci):** stopped dependabot from grouping `ioredis` majors with routine production bumps — the package is resolved through a dynamic import in the distributed quota store, so a breaking major passes build, typecheck and both test suites and only surfaces at runtime for operators running Redis-backed quota ([#9425](https://github.com/diegosouzapw/OmniRoute/pull/9425))
|
||||
@@ -3307,6 +3307,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/usage/providerLimits.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/ws/handshake.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@@ -283,7 +283,6 @@
|
||||
"_rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was cap 800->900 / testCap 800->900 on 2026-07-27; v2 = v1 +20% buffer = cap 900->1000 (+100), testCap 900->1000 (+100). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise. Targets: decompose-existing-frozen unchanged (frozen still only-shrink — see frozen[] entries and the 105 files >900 that still need structural decomposition regardless of cap); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes (gives 150 units of post-tighten headroom vs the new 1000 ceiling). Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.",
|
||||
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
|
||||
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
|
||||
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
|
||||
"frozen": {
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
@@ -389,7 +388,6 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2250,
|
||||
"src/app/api/v1/models/catalog.ts": 1549,
|
||||
"src/lib/tokenHealthCheck.ts": 1021,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1637,
|
||||
"src/lib/db/migrationRunner.ts": 1077,
|
||||
|
||||
@@ -1,916 +0,0 @@
|
||||
---
|
||||
title: "MySQL conformance semantics and failure-mode matrix"
|
||||
status: proposed-test-specification
|
||||
lastUpdated: 2026-07-30
|
||||
---
|
||||
|
||||
# MySQL conformance semantics and failure-mode matrix
|
||||
|
||||
- **Tracking issue:** [#8075](https://github.com/diegosouzapw/OmniRoute/issues/8075)
|
||||
- **Governing proposal:** [Pluggable persistence boundary](persistence-backend-boundary.md)
|
||||
- **Measured baseline:** [SQLite coupling inventory](sqlite-coupling-inventory.md)
|
||||
- **Target:** MySQL 8.0 with InnoDB
|
||||
- **Runtime impact:** None. This document adds no driver, dependency, configuration, schema,
|
||||
migration, or support claim.
|
||||
|
||||
## 1. Purpose and normative language
|
||||
|
||||
The persistence-boundary ADR requires conformance tests to compare observable behavior, not only
|
||||
repository method signatures. This document turns the MySQL/InnoDB differences that can change
|
||||
OmniRoute behavior into an implementation-ready specification. It provides:
|
||||
|
||||
- a required server and session profile;
|
||||
- evidence from the current SQLite implementation;
|
||||
- minimal SQL probes that reviewers can reproduce independently;
|
||||
- a backend-neutral error and retry taxonomy;
|
||||
- normative decisions that a repository contract must make;
|
||||
- executable acceptance specifications for a future shared conformance harness;
|
||||
- a focused acceptance profile for combo definitions and model-to-combo mappings.
|
||||
|
||||
The terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. A proposed MySQL adapter is
|
||||
not conformant merely because its SQL succeeds. It is conformant only when the same repository
|
||||
fixture produces the same domain result, durable state, atomicity, ordering, and classified failure
|
||||
as the SQLite implementation.
|
||||
|
||||
## 2. Scope and non-goals
|
||||
|
||||
### 2.1 In scope
|
||||
|
||||
This specification covers portable durable-state behavior for:
|
||||
|
||||
- create, read, update, delete, and missing-row results;
|
||||
- uniqueness, collation, case and accent sensitivity, and `NULL`;
|
||||
- stable ordering and pagination;
|
||||
- no-op writes and affected-row reporting;
|
||||
- insert, identity-preserving upsert, and replacement;
|
||||
- IDs, JSON, exact numerics, and timestamps;
|
||||
- transactions, deadlocks, lock waits, disconnects, and retry boundaries;
|
||||
- foreign keys and atomic related-record changes;
|
||||
- migration ownership, implicit DDL commits, recovery, and readiness.
|
||||
|
||||
### 2.2 Out of scope
|
||||
|
||||
This specification does not:
|
||||
|
||||
- approve PostgreSQL or MySQL runtime support;
|
||||
- select a Node.js MySQL driver or pool;
|
||||
- define a public environment variable or configuration UI;
|
||||
- define final TypeScript repository interfaces;
|
||||
- add physical MySQL schema or migration files;
|
||||
- make SQLite maintenance, FTS5, `sqlite-vec`, backup files, or WAL portable;
|
||||
- replace domain-specific acceptance criteria;
|
||||
- permit runtime work while the governing ADR remains unapproved.
|
||||
|
||||
## 3. Evidence from the current repository
|
||||
|
||||
The current implementation establishes behavior that a portable contract must either preserve or
|
||||
explicitly revise. These are source-backed observations, not proposed MySQL schema.
|
||||
|
||||
### 3.1 Combo identity and lookup
|
||||
|
||||
`src/lib/db/migrations/001_initial_schema.sql` defines `combos.id` as the primary key and
|
||||
`combos.name` as unique. `src/lib/db/combos.ts` currently:
|
||||
|
||||
- generates UUIDs in the application;
|
||||
- generates timestamps with `new Date().toISOString()`;
|
||||
- performs exact name lookup first;
|
||||
- provides a separate `COLLATE NOCASE` fallback lookup;
|
||||
- lists by `sort_order ASC, name COLLATE NOCASE ASC`;
|
||||
- treats an update of a missing ID as `null`;
|
||||
- treats deletion of a missing ID as `false`;
|
||||
- updates the JSON payload and deduplicated columns together;
|
||||
- reorders all selected rows in one SQLite transaction.
|
||||
|
||||
Those choices imply that a future MySQL slice does not need database-generated numeric IDs for
|
||||
combos, but it must still define Unicode collation, complete tie-breakers, update/delete results, and
|
||||
reorder concurrency.
|
||||
|
||||
### 3.2 Model-to-combo mapping behavior
|
||||
|
||||
`src/lib/db/migrations/010_model_combo_mappings.sql` defines a foreign key from
|
||||
`model_combo_mappings.combo_id` to `combos.id` with `ON DELETE CASCADE`.
|
||||
`src/lib/db/modelComboMappings.ts` currently:
|
||||
|
||||
- generates mapping UUIDs and ISO timestamps in the application;
|
||||
- lists by `priority DESC, created_at ASC`;
|
||||
- returns a separate total count for paginated results;
|
||||
- maps integer `0`/`1` values to booleans;
|
||||
- treats a missing update as `null` and a missing delete as `false`;
|
||||
- resolves the first enabled matching pattern;
|
||||
- skips malformed combo JSON rather than failing resolution.
|
||||
|
||||
The current list and resolution order lacks a unique final tie-breaker. The MySQL implementation
|
||||
MUST NOT preserve that accidental nondeterminism. Before portability is claimed, the contract must
|
||||
add `id ASC` (or another unique stable key) after `created_at ASC` and the SQLite implementation
|
||||
must adopt the same order.
|
||||
|
||||
### 3.3 Existing SQLite-specific signals
|
||||
|
||||
The measured SQLite coupling inventory records widespread use of synchronous prepared statements,
|
||||
`INSERT OR REPLACE`, `lastInsertRowid`, SQLite transactions, and SQLite lifecycle operations. A
|
||||
future adapter must not translate those tokens mechanically. In particular:
|
||||
|
||||
- `INSERT OR REPLACE` is delete-then-insert conflict handling, not an update;
|
||||
- `changes` is a driver result, not a portable domain result;
|
||||
- `COLLATE NOCASE` is not equivalent to a modern MySQL Unicode collation;
|
||||
- SQLite numbered migration SQL is not reusable as MySQL migration SQL.
|
||||
|
||||
## 4. Required MySQL deployment and session profile
|
||||
|
||||
A conformance run MUST fail during backend initialization if the effective profile is outside the
|
||||
supported envelope. Silently inheriting server defaults would make behavior depend on an operator's
|
||||
installation history.
|
||||
|
||||
| Property | Required profile | Verification | Failure class |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------- |
|
||||
| Server family | Oracle MySQL 8.0.x until another family passes the same suite | `SELECT VERSION()` and server metadata | `unsupported` |
|
||||
| Storage engine | `InnoDB` for every portable table | `information_schema.tables` | `schema_incompatible` |
|
||||
| Character set | `utf8mb4` for schema, tables, and portable text columns | `information_schema.schemata`, `tables`, and `columns` | `schema_incompatible` |
|
||||
| Identity collation | Explicit per identity column; never inherited | `information_schema.columns.collation_name` | `schema_incompatible` |
|
||||
| SQL mode | Strict mode and the engine-substitution guard; adapter records the effective value | `SELECT @@SESSION.sql_mode` | `unsupported` |
|
||||
| Transaction isolation | Explicitly selected and verified by the backend | `SELECT @@SESSION.transaction_isolation` | `unsupported` |
|
||||
| Session time zone | UTC | `SELECT @@SESSION.time_zone` | `unsupported` |
|
||||
| Autocommit | Known pool default; repository transactions set boundaries explicitly | `SELECT @@SESSION.autocommit` | `unsupported` |
|
||||
| Connection character set | `utf8mb4` | `SELECT @@character_set_client, @@character_set_connection, @@character_set_results` | `unsupported` |
|
||||
| Found-rows behavior | One fixed pool setting, but repository results remain independent of it | Driver/pool configuration plus conformance probe | `unsupported` |
|
||||
| Foreign-key checks | Enabled for normal runtime and conformance tests | `SELECT @@SESSION.foreign_key_checks` | `unsupported` |
|
||||
| InnoDB page size | Recorded before validating indexed key lengths | `SELECT @@innodb_page_size` | `schema_incompatible` |
|
||||
|
||||
The backend readiness report SHOULD expose the verified profile without credentials. It MUST NOT
|
||||
log connection strings or secrets.
|
||||
|
||||
### 4.1 Initialization probe
|
||||
|
||||
The adapter acceptance suite should run an equivalent of the following read-only probe on a newly
|
||||
leased connection:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
VERSION() AS server_version,
|
||||
@@SESSION.sql_mode AS sql_mode,
|
||||
@@SESSION.transaction_isolation AS transaction_isolation,
|
||||
@@SESSION.time_zone AS time_zone,
|
||||
@@SESSION.autocommit AS autocommit,
|
||||
@@SESSION.foreign_key_checks AS foreign_key_checks,
|
||||
@@character_set_client AS character_set_client,
|
||||
@@character_set_connection AS character_set_connection,
|
||||
@@character_set_results AS character_set_results,
|
||||
@@innodb_page_size AS innodb_page_size;
|
||||
```
|
||||
|
||||
A pool MUST apply and verify session settings on every newly created physical connection. Applying
|
||||
settings only to the first connection is insufficient.
|
||||
|
||||
## 5. Normative semantic matrix
|
||||
|
||||
### 5.0 Observable SQLite/MySQL difference summary
|
||||
|
||||
This table is the review index for the detailed rules below. It distinguishes current or common
|
||||
backend behavior from the portable result the repository must expose. The MySQL column describes
|
||||
InnoDB under the verified session profile; it must not be read as permission to inherit an
|
||||
unverified server default.
|
||||
|
||||
| Concern | SQLite-shaped behavior | MySQL/InnoDB behavior | Required repository contract |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Text identity | Binary comparison by default; current code opts into ASCII-oriented `NOCASE` for selected reads and sorts | Equality, uniqueness, and sort order follow the selected column/expression collation | Declare byte-exact identity separately from named insensitive lookup and display order |
|
||||
| Nullable unique key | Multiple SQL `NULL` values can pass a plain unique constraint | Multiple SQL `NULL` values can pass a plain unique index | Enforce any "one logical null" invariant atomically outside a plain unique key |
|
||||
| Unordered/tied results | No total order without a complete `ORDER BY` | No total order without a complete `ORDER BY` | Define `NULL` position and a unique final tie-breaker for every portable list |
|
||||
| No-op update | Driver change count reflects SQLite's statement behavior | Changed-row count differs from matched-row mode for identical assignments | Return domain outcomes independently of raw affected-row counts |
|
||||
| Conflict write | `INSERT OR REPLACE` can delete then insert | Duplicate-key upsert updates one selected conflict | Classify every operation as insert-only, identity-preserving upsert, or replacement |
|
||||
| Generated identity | SQLite row IDs and driver-local last-insert state are connection-bound | Generated IDs and last-insert state are connection-bound | Retrieve identity in the insert operation/lease and use stable idempotency identity on retry |
|
||||
| JSON | Existing combo payloads are text and malformed legacy text can be observed | Native `JSON` validates and normalizes its representation | Choose text or typed JSON deliberately and compare the declared domain representation |
|
||||
| Exact values/time | Current modules commonly serialize JavaScript values and ISO UTC text | Driver conversion can lose large integers/decimals; temporal types depend on type and session zone | Fix exact representations, UTC policy, and precision across backends |
|
||||
| Concurrency/isolation | Deferred transactions and a database-wide single-writer model shape conflicts; read visibility depends on transaction mode and WAL state | InnoDB defaults to `REPEATABLE READ`, uses MVCC snapshots for consistent reads, and permits concurrent writers on different locked records | Select and verify isolation, then test domain-visible reads, conflicts, and retry boundaries rather than relying on either default |
|
||||
| DDL/migrations | SQLite migration sequences can be wrapped according to SQLite transaction rules | DDL commonly commits implicitly; one atomic DDL statement does not make a multi-step migration atomic | Use distributed ownership, durable phase checkpoints, postcondition inspection, and readiness gating |
|
||||
|
||||
### 5.1 Text identity, collation, and uniqueness
|
||||
|
||||
MySQL equality and unique indexes use the effective collation of the indexed expression. A `_ci`
|
||||
collation is case-insensitive; an `_ai` collation is also accent-insensitive. SQLite's default text
|
||||
comparison and `COLLATE NOCASE` do not provide an equivalent Unicode contract.
|
||||
|
||||
| Concern | SQLite-shaped risk | Required portable decision | MySQL implementation rule |
|
||||
| ---------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| IDs | Text IDs can inherit an unintended collation | IDs are byte-exact and case-sensitive | Use an explicit binary collation or binary representation |
|
||||
| Combo names | Exact lookup and insensitive fallback are separate today | Exact lookup remains exact; insensitive lookup is a named operation | Exact and insensitive queries use explicit, different collations or normalized keys |
|
||||
| Unique names | A server default can collapse case or accents | The domain declares whether case/accent variants conflict | Unique index uses the declared collation, never the database default |
|
||||
| Pattern text | Pattern matching occurs in application code | Stored pattern bytes round-trip unchanged | Store with an explicit case-sensitive collation |
|
||||
| User-facing sort | SQLite `NOCASE` order is not portable Unicode order | List order is defined by a normalized sort key or explicit collation policy | Schema and query use the selected policy and a unique tie-breaker |
|
||||
|
||||
Minimum probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_text (
|
||||
id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY,
|
||||
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci UNIQUE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_text (id, name) VALUES ('A', 'Résumé');
|
||||
-- The next statement conflicts under utf8mb4_0900_ai_ci.
|
||||
INSERT INTO conformance_text (id, name) VALUES ('a', 'resume');
|
||||
```
|
||||
|
||||
The harness MUST repeat the probe for the exact collation selected by the eventual schema; the
|
||||
example collation above is evidence, not an approval for combo names.
|
||||
|
||||
### 5.2 `NULL`, missing rows, and nullable unique keys
|
||||
|
||||
MySQL unique indexes permit multiple `NULL` values. SQLite does likewise for unique columns.
|
||||
However, neither behavior implements a domain invariant such as "only one active row may have no
|
||||
owner."
|
||||
|
||||
Repository contracts MUST distinguish:
|
||||
|
||||
- no row found;
|
||||
- a row found with a nullable field set to SQL `NULL`;
|
||||
- a JSON document containing JSON `null`;
|
||||
- a missing JSON member.
|
||||
|
||||
Minimum probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_null (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
optional_key VARCHAR(64) NULL,
|
||||
UNIQUE KEY uq_optional_key (optional_key)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_null VALUES ('one', NULL), ('two', NULL);
|
||||
SELECT COUNT(*) AS row_count FROM conformance_null;
|
||||
-- Expected: 2.
|
||||
```
|
||||
|
||||
If a domain allows at most one logical `NULL`, it MUST use an explicit atomic invariant rather than
|
||||
rely on a plain unique index.
|
||||
|
||||
### 5.3 Ordering, ties, and pagination
|
||||
|
||||
Without `ORDER BY`, result order is undefined. With a non-unique `ORDER BY`, tied rows still have an
|
||||
undefined relative order. Offset pagination can therefore duplicate or omit records if the complete
|
||||
order is not stable.
|
||||
|
||||
Every portable list MUST specify:
|
||||
|
||||
1. every user-visible sort expression;
|
||||
2. the position of `NULL` values;
|
||||
3. a unique final tie-breaker;
|
||||
4. the cursor comparison tuple, if cursor pagination is used;
|
||||
5. the snapshot/concurrency expectation across pages.
|
||||
|
||||
For the proposed combo/mapping slice:
|
||||
|
||||
```sql
|
||||
-- Combo list contract candidate.
|
||||
ORDER BY sort_order ASC, normalized_name ASC, id ASC
|
||||
|
||||
-- Mapping list and resolution contract candidate.
|
||||
ORDER BY priority DESC, created_at ASC, id ASC
|
||||
```
|
||||
|
||||
The exact `normalized_name` representation remains a contract decision. It MUST NOT be implemented
|
||||
by relying on an unspecified database default.
|
||||
|
||||
For nullable values, use an explicit sort key rather than a backend default:
|
||||
|
||||
```sql
|
||||
ORDER BY nullable_column IS NULL ASC, nullable_column ASC, id ASC
|
||||
```
|
||||
|
||||
### 5.4 Update, no-op, delete, and affected rows
|
||||
|
||||
MySQL `UPDATE` reports rows actually changed by default. With the C API found-rows connection flag,
|
||||
it reports rows matched. `INSERT ... ON DUPLICATE KEY UPDATE` reports 1 for insert, 2 for an actual
|
||||
update, and 0 for an update to identical values; the found-rows flag changes the last value to 1.
|
||||
These numbers MUST NOT become repository semantics.
|
||||
|
||||
| Repository outcome | Required meaning | Forbidden implementation shortcut |
|
||||
| ------------------ | ------------------------------------------------------ | --------------------------------------------- |
|
||||
| `updated` | Target existed and the operation's postcondition holds | `affectedRows > 0` alone |
|
||||
| `unchanged` | Target existed and already satisfied the postcondition | Treating 0 changed rows as missing |
|
||||
| `not_found` | Target identity did not exist | Treating every 0 count as unchanged |
|
||||
| `conflict` | Compare/update version or invariant failed | Returning generic `false` |
|
||||
| delete `true` | A row existed and was deleted | Assuming a successful statement deleted a row |
|
||||
| delete `false` | No row existed | Throwing a backend-specific error |
|
||||
|
||||
Minimum probe, run once with each supported connection mode:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_update (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
value_text VARCHAR(64) NOT NULL,
|
||||
version_no BIGINT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_update VALUES ('row', 'same', 1);
|
||||
UPDATE conformance_update SET value_text = 'same' WHERE id = 'row';
|
||||
UPDATE conformance_update SET value_text = 'changed' WHERE id = 'row';
|
||||
UPDATE conformance_update SET value_text = 'missing' WHERE id = 'missing';
|
||||
```
|
||||
|
||||
The harness asserts repository results and final rows, not raw driver counts. A versioned
|
||||
compare/update SHOULD use a predicate such as `WHERE id = ? AND version_no = ?`, then distinguish a
|
||||
missing identity from a stale version according to the domain contract.
|
||||
|
||||
### 5.5 Insert, upsert, and replacement
|
||||
|
||||
SQLite `INSERT OR REPLACE` deletes rows that conflict with a unique or primary key before inserting
|
||||
the new row. MySQL `INSERT ... ON DUPLICATE KEY UPDATE` updates one conflicting row. The two forms
|
||||
differ in foreign-key cascades, triggers, omitted columns, IDs, timestamps, and affected-row counts.
|
||||
|
||||
Every write method MUST be classified as exactly one of:
|
||||
|
||||
1. **insert-only:** duplicate identity returns `unique_violation`;
|
||||
2. **identity-preserving upsert:** duplicate identity updates an explicit allowlist of mutable fields;
|
||||
3. **replacement:** old identity is deleted and a new row is inserted, with cascade effects included
|
||||
in the contract.
|
||||
|
||||
A generic helper MUST NOT choose among these behaviors based on SQL convenience.
|
||||
|
||||
Minimum difference probe. This uses ordinary InnoDB tables because MySQL temporary tables cannot
|
||||
serve as the parent/child foreign-key fixture. Run it in an isolated conformance schema; cleanup is
|
||||
included so the probe is repeatable:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS conformance_child;
|
||||
DROP TABLE IF EXISTS conformance_parent;
|
||||
|
||||
CREATE TABLE conformance_parent (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
immutable_value VARCHAR(64) NOT NULL,
|
||||
mutable_value VARCHAR(64) NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE conformance_child (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
parent_id VARCHAR(64) NOT NULL,
|
||||
CONSTRAINT fk_conformance_child_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES conformance_parent(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_parent VALUES ('p', 'keep', 'old');
|
||||
INSERT INTO conformance_child VALUES ('c', 'p');
|
||||
INSERT INTO conformance_parent (id, immutable_value, mutable_value)
|
||||
VALUES ('p', 'replacement', 'new')
|
||||
ON DUPLICATE KEY UPDATE mutable_value = VALUES(mutable_value);
|
||||
|
||||
SELECT immutable_value, mutable_value FROM conformance_parent WHERE id = 'p';
|
||||
SELECT COUNT(*) AS child_count FROM conformance_child WHERE parent_id = 'p';
|
||||
-- Expected: immutable_value='keep', mutable_value='new', child_count=1.
|
||||
|
||||
DROP TABLE conformance_child;
|
||||
DROP TABLE conformance_parent;
|
||||
```
|
||||
|
||||
The `VALUES(mutable_value)` form is used here because the target remains MySQL 8.0 as a family and
|
||||
no minimum 8.0 patch release has been approved. It is deprecated in later MySQL 8.0 releases, so an
|
||||
adapter that establishes a newer minimum MAY use the supported row-alias form instead. The harness
|
||||
asserts identity-preserving behavior, not either SQL spelling.
|
||||
|
||||
Tables with multiple unique indexes require special care because a duplicate can select an
|
||||
unexpected conflicting row. Portable upsert schema SHOULD have one unambiguous conflict identity.
|
||||
|
||||
### 5.6 Unicode and index-size constraints
|
||||
|
||||
`utf8mb4` uses up to four bytes per character. InnoDB's maximum index key is 3072 bytes for common
|
||||
`DYNAMIC` or `COMPRESSED` row formats with a 16 KiB page, and is lower for smaller page sizes or
|
||||
legacy row formats. A prefix unique index is not equivalent to full-value uniqueness.
|
||||
|
||||
Schema acceptance MUST:
|
||||
|
||||
- set bounded lengths for all indexed identity strings;
|
||||
- calculate the worst-case byte length of every composite index;
|
||||
- verify the actual page size and row format;
|
||||
- reject a prefix unique index for a full-identity contract;
|
||||
- test maximum-length non-ASCII values before migration is accepted;
|
||||
- classify an incompatible definition as `schema_incompatible`, not `unique_violation`.
|
||||
|
||||
Example boundary probe for a 16 KiB/DYNAMIC profile:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_index (
|
||||
value_text VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
UNIQUE KEY uq_value_text (value_text)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
|
||||
```
|
||||
|
||||
The exact accepted length MUST be derived from all key parts and the verified deployment profile;
|
||||
this example is deliberately near a physical boundary and is not a proposed production column.
|
||||
|
||||
### 5.7 IDs and connection-local state
|
||||
|
||||
The current combo and mapping modules generate UUIDs in the application. A MySQL implementation
|
||||
SHOULD preserve this strategy for those domains.
|
||||
|
||||
If another domain uses a database-generated incrementing ID, the adapter MUST observe these rules:
|
||||
|
||||
- ID retrieval is part of the same driver operation and physical connection as the insert;
|
||||
- callers never issue a later connection-level `LAST_INSERT_ID()` query;
|
||||
- multi-row inserts define whether one ID or all IDs are returned;
|
||||
- an error or rollback makes a previously observed `LAST_INSERT_ID()` unsuitable as proof of commit;
|
||||
- retries use a stable domain idempotency key;
|
||||
- upsert defines whether it returns an existing or newly generated identity.
|
||||
|
||||
MySQL documents `LAST_INSERT_ID()` as per-connection state and leaves it undefined after some errors
|
||||
or error-driven rollbacks. Pool leases are therefore part of correctness, not merely performance.
|
||||
|
||||
### 5.8 JSON representation
|
||||
|
||||
Current combo data is JSON text, and malformed JSON is observable: combo reads can skip malformed
|
||||
rows and mapping resolution skips malformed combo payloads. Switching the MySQL column directly to
|
||||
native `JSON` would reject malformed rows at write/import time and normalize duplicate keys,
|
||||
whitespace, and key order.
|
||||
|
||||
Before choosing `LONGTEXT` or `JSON`, the combo contract MUST decide:
|
||||
|
||||
- whether malformed stored payloads remain representable for compatibility tests;
|
||||
- whether equality is structural or byte-for-byte;
|
||||
- whether duplicate object keys are rejected before persistence;
|
||||
- whether serialization order is stable and application-owned;
|
||||
- which fields are duplicated into typed columns and which representation is authoritative.
|
||||
|
||||
For the first slice, an identity-preserving migration SHOULD keep application serialization as the
|
||||
domain boundary. If native `JSON` is selected, imports MUST parse and validate before writing, and
|
||||
tests MUST compare parsed domain values rather than raw JSON text.
|
||||
|
||||
Minimum normalization probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_json (id VARCHAR(64) PRIMARY KEY, payload JSON) ENGINE=InnoDB;
|
||||
INSERT INTO conformance_json VALUES ('j', '{"b": 2, "a": 1, "a": 3}');
|
||||
SELECT payload FROM conformance_json WHERE id = 'j';
|
||||
-- The value is normalized; original whitespace/key duplication is not preserved.
|
||||
```
|
||||
|
||||
### 5.9 Exact numerics and timestamps
|
||||
|
||||
| Type | Risk | Required contract |
|
||||
| ----------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `BIGINT` | Values can exceed JavaScript's safe integer range | Return a string or validated bigint representation across every backend |
|
||||
| `DECIMAL` | Driver options may return strings or lossy numbers | Fix precision/scale and use an exact domain representation |
|
||||
| `TIMESTAMP` | Session time zone conversion and fractional precision | Force UTC session time zone and specify fractional precision |
|
||||
| `DATETIME` | No intrinsic time zone | Use only for explicitly zone-free civil time |
|
||||
| ISO text | Lexical ordering depends on one canonical format | Validate UTC suffix and exact precision before persistence |
|
||||
|
||||
Combo and mapping timestamps are currently application-generated ISO strings. The first slice SHOULD
|
||||
preserve their exact domain format rather than introducing server-generated local time.
|
||||
|
||||
### 5.10 Transaction isolation and observable concurrency
|
||||
|
||||
MySQL InnoDB uses `REPEATABLE READ` as its default isolation level. Within an explicit transaction,
|
||||
its consistent non-locking reads normally establish and reuse an MVCC snapshot, while locking reads
|
||||
and writes inspect and lock current index records or ranges. SQLite instead combines snapshot/read
|
||||
transaction behavior with a database-wide single-writer model; transaction mode and WAL state affect
|
||||
when a writer is admitted and when a read transaction can be upgraded. These mechanisms are not
|
||||
interchangeable even when a simple CRUD fixture produces the same final row.
|
||||
|
||||
The backend profile MUST select and verify an isolation level rather than silently accept either
|
||||
backend's default. The repository contract MUST then define observable results for each atomic
|
||||
operation. It MUST NOT promise the implementation mechanism itself, such as gap locks or a
|
||||
SQLite-wide writer lock.
|
||||
|
||||
| Scenario | SQLite-shaped risk | InnoDB `REPEATABLE READ` risk | Required conformance decision |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Two reads in one transaction | Snapshot timing depends on when the read transaction begins and the active journal mode | Consistent reads normally reuse the transaction's first established read view | State whether the operation requires one stable snapshot or deliberately performs a current read |
|
||||
| Range read plus concurrent insert | A concurrent writer may be serialized by SQLite's writer admission rules | A plain consistent read can retain its snapshot; a locking range read can lock index gaps | Define whether a later read sees the insert and whether the operation requires a locking predicate |
|
||||
| Read-modify-write | Single-writer serialization can mask an unsafe application sequence | Concurrent transactions can read the same value and later contend or overwrite without a version predicate | Require compare/update, a locking read, or another explicit invariant; never rely on backend serialization |
|
||||
| Writers touching different rows | SQLite still admits only one writer at a time | InnoDB can execute both until their record/range locks conflict | Do not infer portable throughput or lock order; assert only atomic effects and classified conflicts |
|
||||
| Pagination across transactions | Separate page reads can observe different committed states | Separate autocommit reads get separate views; one transaction may retain one view | Declare snapshot pagination or documented live pagination and test that policy |
|
||||
| Retry after conflict | Busy/locked outcomes and transaction upgrade failures are SQLite-shaped | Deadlocks and lock timeouts have different rollback scopes | Normalize the error, discard the failed context, and retry the complete idempotent operation only |
|
||||
|
||||
Minimum two-connection visibility probe for the selected MySQL profile:
|
||||
|
||||
```text
|
||||
Connection A Connection B
|
||||
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
||||
START TRANSACTION;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- establishes read view: 0
|
||||
START TRANSACTION;
|
||||
UPDATE conformance_isolation
|
||||
SET value_no = 1 WHERE id = 1;
|
||||
COMMIT;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- same consistent-read view: 0
|
||||
COMMIT;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- new transaction/view: 1
|
||||
```
|
||||
|
||||
The shared harness MUST NOT assert that every backend reproduces this internal sequence. It must use
|
||||
it to prove that the chosen repository operation either requests a stable snapshot explicitly or
|
||||
avoids depending on repeat-read visibility. If an operation uses a current/locking read, that choice
|
||||
and its conflict behavior need a separate test.
|
||||
|
||||
## 6. Transactions, failures, and retry policy
|
||||
|
||||
### 6.1 Transaction states
|
||||
|
||||
The backend contract should expose only opaque transaction contexts, but its implementation must
|
||||
maintain the following lifecycle:
|
||||
|
||||
```text
|
||||
idle
|
||||
-> active
|
||||
-> committed
|
||||
-> rolled_back
|
||||
-> failed_statement -> rolled_back
|
||||
-> failed_transaction -> rolled_back
|
||||
-> outcome_unknown -> reconciled | escalated
|
||||
```
|
||||
|
||||
A context in `committed`, `rolled_back`, `failed_transaction`, or `outcome_unknown` MUST reject new
|
||||
repository work. A context with a failed statement SHOULD be explicitly rolled back before its
|
||||
connection returns to the pool, even when MySQL would technically permit more statements.
|
||||
|
||||
### 6.2 Error classification matrix
|
||||
|
||||
Numeric codes and SQLSTATE values below are MySQL 8.0 server signals. A Node.js driver can also
|
||||
produce transport-specific codes; those MUST be normalized without leaking raw messages to callers.
|
||||
|
||||
| Condition | MySQL signal | Rollback scope | Portable class | Retry policy |
|
||||
| ------------------------------ | -------------------------------------- | ------------------------------------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||
| Duplicate key | `1062`, SQLSTATE `23000` | Statement | `unique_violation` | No, unless contract defines idempotent create |
|
||||
| Missing referenced parent | `1452`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||
| Parent still referenced | `1451`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||
| Deadlock victim | `1213`, SQLSTATE `40001` | Entire transaction | `transaction_conflict` | Retry whole atomic operation |
|
||||
| Lock wait timeout | `1205`, SQLSTATE `HY000` | Statement by default; server option can change it | `lock_timeout` | Roll back explicitly, then retry whole operation if idempotent |
|
||||
| Invalid JSON text | `3140`, SQLSTATE `22032` | Statement | `invalid_data` | No |
|
||||
| Data too long | `1406`, SQLSTATE `22001` | Statement | `invalid_data` | No |
|
||||
| Check constraint | `3819`, SQLSTATE `HY000` | Statement | `constraint_violation` | No |
|
||||
| Server gone before request | Driver/server transport signal | No operation or unknown | `unavailable` | Retry only if operation definitely was not sent |
|
||||
| Connection lost during request | Driver transport signal | Unknown | `outcome_unknown` | Reconcile by idempotency key; do not blind retry |
|
||||
| Pool acquisition timeout | Driver/pool signal | None | `unavailable` | Bounded retry outside transaction |
|
||||
| Unsupported profile | Initialization probe mismatch | None | `unsupported` | No; fail readiness |
|
||||
| Migration lock timeout | Named-lock acquisition returns timeout | None | `migration_lock_timeout` | Wait/back off according to startup policy |
|
||||
| Migration lock error | Named-lock acquisition returns error | None | `migration_lock_failed` | No blind retry; inspect connection state |
|
||||
|
||||
The adapter MUST classify by structured code and SQLSTATE where available, never by localized message
|
||||
text. Public HTTP/SSE/MCP responses must still pass through the repository's existing sanitized error
|
||||
helpers.
|
||||
|
||||
### 6.3 Retry rules
|
||||
|
||||
A retryable classification does not automatically make an operation safe to retry.
|
||||
|
||||
A retry loop MUST:
|
||||
|
||||
1. own the entire repository atomic operation;
|
||||
2. discard the failed transaction context;
|
||||
3. acquire a valid connection and begin a new transaction;
|
||||
4. preserve a stable operation or entity identity;
|
||||
5. use bounded attempts with jitter;
|
||||
6. stop on non-retryable classifications;
|
||||
7. reconcile `outcome_unknown` before issuing another write;
|
||||
8. emit structured diagnostics without credentials or raw SQL values.
|
||||
|
||||
MySQL explicitly recommends retrying the entire transaction after a deadlock. A lock wait timeout
|
||||
rolls back only the current statement by default, so explicit rollback is required to make the retry
|
||||
boundary independent of server configuration.
|
||||
|
||||
### 6.4 Reproducible two-connection deadlock probe
|
||||
|
||||
Use two physical connections, not two logical operations that might share one pool connection:
|
||||
|
||||
```sql
|
||||
CREATE TABLE conformance_deadlock (
|
||||
id INT PRIMARY KEY,
|
||||
value_no INT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
INSERT INTO conformance_deadlock VALUES (1, 0), (2, 0);
|
||||
```
|
||||
|
||||
```text
|
||||
Connection A Connection B
|
||||
START TRANSACTION; START TRANSACTION;
|
||||
UPDATE ... WHERE id = 1; UPDATE ... WHERE id = 2;
|
||||
UPDATE ... WHERE id = 2; UPDATE ... WHERE id = 1;
|
||||
```
|
||||
|
||||
Exactly one transaction should become the deadlock victim. The harness asserts that the victim is
|
||||
classified as retryable, its whole transaction is retried with a new context, both logical updates
|
||||
occur once, and no partial result remains.
|
||||
|
||||
## 7. Migration ownership and DDL recovery
|
||||
|
||||
### 7.1 Why a normal transaction is insufficient
|
||||
|
||||
MySQL DDL statements commonly commit the current transaction implicitly before execution and often
|
||||
afterward. Atomic DDL protects one supported DDL statement; it does not make a sequence of DDL,
|
||||
data backfill, and schema-history updates one user transaction.
|
||||
|
||||
A MySQL migration runner therefore MUST model a migration as recoverable phases:
|
||||
|
||||
```text
|
||||
lock acquired
|
||||
-> current schema inspected
|
||||
-> intent/checkpoint recorded
|
||||
-> DDL phase applied and verified
|
||||
-> data phase applied in bounded transactions
|
||||
-> postconditions verified
|
||||
-> logical milestone recorded
|
||||
-> readiness allowed
|
||||
-> lock released
|
||||
```
|
||||
|
||||
A process crash at any arrow must have a deterministic resume or stop condition.
|
||||
|
||||
### 7.2 Ownership alternatives
|
||||
|
||||
| Option | Strengths | Failure modes | Decision |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Process-local mutex | Simple and useful for one process | Does not coordinate replicas | Rejected for external-backend migration ownership |
|
||||
| Row lock held in a transaction | Uses normal InnoDB locking | DDL implicit commit releases transaction ownership | Rejected as the sole DDL migration lock |
|
||||
| Lease row with owner and expiry | Survives pooled connections and can support takeover | Requires clock/expiry/fencing design; stale owner may continue | Candidate for scheduled jobs, not first migration mechanism |
|
||||
| MySQL named lock | Server-wide, exclusive, tied to physical session, released on disconnect | Must pin one connection; not transaction-scoped; one-server scope; undefined waiter order | Recommended first MySQL migration mutex, combined with durable history |
|
||||
| External coordinator | Can coordinate across database topologies | Adds an operational dependency outside the database contract | Deferred unless deployment topology requires it |
|
||||
|
||||
### 7.3 Recommended first mechanism
|
||||
|
||||
For a single writable MySQL primary, the migration runner SHOULD:
|
||||
|
||||
1. lease and pin one physical connection;
|
||||
2. acquire one application-and-database-specific named lock of at most 64 characters;
|
||||
3. distinguish acquired (`1`), timeout (`0`), and error (`NULL`);
|
||||
4. inspect a durable migration-history table after acquiring the lock;
|
||||
5. execute idempotent physical phases with explicit postcondition checks;
|
||||
6. record completion only after all postconditions pass;
|
||||
7. release the named lock explicitly in `finally`;
|
||||
8. close/discard the pinned connection if release cannot be confirmed.
|
||||
|
||||
Named locks are released when the session ends, not on commit or rollback. They are server-wide on one
|
||||
`mysqld`; topology and failover behavior must be validated before active-active support is advertised.
|
||||
A durable history/checkpoint table remains necessary because lock ownership alone says nothing about
|
||||
partially completed DDL.
|
||||
|
||||
### 7.4 Migration failure matrix
|
||||
|
||||
| Injection point | Required durable evidence | Restart behavior | Readiness |
|
||||
| ------------------------------- | --------------------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||
| Before lock | No intent | Retry lock acquisition | Not ready while required migration is pending |
|
||||
| After lock, before intent | No schema change | Reinspect and restart | Not ready |
|
||||
| After DDL, before checkpoint | Schema postcondition reveals DDL applied | Mark/continue only after validation | Not ready |
|
||||
| During data backfill | Bounded checkpoint identifies completed range | Resume from verified checkpoint | Not ready |
|
||||
| After data, before milestone | Postconditions prove completion | Record milestone idempotently | Not ready until recorded |
|
||||
| After milestone, before release | History proves complete | New owner verifies and proceeds | Ready if all required milestones pass |
|
||||
|
||||
## 8. SQLite-to-MySQL migration validation
|
||||
|
||||
An offline migration tool is required before database switching can be advertised. For each migrated
|
||||
domain it MUST provide a dry run and a post-import report.
|
||||
|
||||
### 8.1 Preflight
|
||||
|
||||
- verify supported SQLite and MySQL schema milestones;
|
||||
- validate every source JSON payload according to the chosen target representation;
|
||||
- detect names that collide under the target collation;
|
||||
- validate UTF-8 and maximum indexed byte lengths;
|
||||
- detect orphaned foreign keys even if the source connection had checks disabled;
|
||||
- validate timestamps and numeric ranges;
|
||||
- count source rows by table and logical domain;
|
||||
- refuse to mutate either database during dry run.
|
||||
|
||||
### 8.2 Import
|
||||
|
||||
- preserve application-generated IDs;
|
||||
- use deterministic batches and checkpoints;
|
||||
- import parents before children;
|
||||
- do not use replacement semantics to hide conflicts;
|
||||
- classify every rejected row with a stable reason;
|
||||
- keep encrypted credential ciphertext opaque and never log it;
|
||||
- stop on an unclassified difference.
|
||||
|
||||
### 8.3 Postconditions
|
||||
|
||||
- row counts match for every migrated table;
|
||||
- identity sets match exactly;
|
||||
- foreign-key orphan counts are zero;
|
||||
- canonical domain digests match for JSON-backed records;
|
||||
- list ordering and mapping resolution produce the same results;
|
||||
- a second dry run reports no pending changes;
|
||||
- SQLite remains unchanged and available for operator rollback until cutover is accepted.
|
||||
|
||||
## 9. Backend-neutral conformance catalog
|
||||
|
||||
Each test below runs the same repository fixture against SQLite and MySQL. MySQL-specific probes may
|
||||
assert error metadata internally, but the shared assertion compares only domain results and durable
|
||||
state.
|
||||
|
||||
### 9.1 Core CRUD and representation
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `create_round_trips_domain_values` | Create Unicode, nullable, JSON, and timestamp fields | Parsed domain object equals normalized input |
|
||||
| `find_missing_distinguishes_absent_from_null` | Read an absent ID and a present nullable row | Results are distinct |
|
||||
| `update_missing_returns_not_found` | Update an absent ID | Stable `not_found` result |
|
||||
| `delete_is_idempotent_as_declared` | Delete the same ID twice | First and second results match the repository contract |
|
||||
| `json_round_trips_structurally` | Write equivalent JSON with different whitespace/order | Parsed values are equal; raw text is not asserted |
|
||||
| `timestamp_round_trips_in_utc` | Change MySQL session default before leasing a verified connection | Domain serialization remains canonical UTC |
|
||||
| `decimal_round_trips_without_float_loss` | Write precision/scale boundaries | Exact representation is unchanged |
|
||||
| `large_integer_does_not_cross_number_lossily` | Write beyond JavaScript safe integer range | String/bigint domain representation is exact |
|
||||
|
||||
### 9.2 Identity and collation
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `id_is_byte_exact` | Create IDs differing only by case | Both remain distinct if the ID contract is binary |
|
||||
| `exact_name_lookup_is_case_sensitive` | Store `MASTER-LIGHT`, query exact lowercase | Exact lookup misses |
|
||||
| `insensitive_name_lookup_uses_declared_policy` | Query the same row through the named insensitive operation | One deterministic row is returned |
|
||||
| `unique_name_case_policy_is_explicit` | Insert case variants | Result matches the selected name policy on both backends |
|
||||
| `unique_name_accent_policy_is_explicit` | Insert accent variants | Result matches the selected policy |
|
||||
| `unique_violation_is_classified` | Concurrently create one identity | One wins; loser is `unique_violation` without backend text |
|
||||
| `nullable_unique_policy_is_explicit` | Insert two `NULL` logical keys | Result matches domain rule, not accidental index behavior |
|
||||
|
||||
### 9.3 Ordering and pagination
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
|
||||
| `list_uses_unique_final_tiebreaker` | Insert rows with identical primary sort values | Repeated list order is identical and ID-ordered |
|
||||
| `pagination_has_no_gaps_or_duplicates` | Traverse small pages across tied rows | Union equals full ID set; page intersections are empty |
|
||||
| `nullable_sort_position_is_fixed` | Mix `NULL` and non-`NULL` values | `NULL` appears at the contract-defined end |
|
||||
| `cursor_predicate_matches_sort_tuple` | Page forward through mixed sort keys | Every row appears exactly once in declared order |
|
||||
| `concurrent_insert_pagination_behavior_is_declared` | Insert between page reads | Result matches snapshot or documented live-page policy |
|
||||
|
||||
### 9.4 Writes and affected rows
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ------------------------------------------- | ------------------------------------------ | -------------------------------------------------- |
|
||||
| `same_value_update_is_not_missing` | Update an existing row to identical values | `unchanged` or declared success, never `not_found` |
|
||||
| `same_value_result_ignores_found_rows_mode` | Run fixture with both connection modes | Domain result is identical |
|
||||
| `compare_update_detects_stale_version` | Two writers use one old version | One succeeds; one returns `conflict` |
|
||||
| `batch_count_uses_contract_definition` | Mix changed and unchanged matches | Count means the same thing on both backends |
|
||||
| `upsert_preserves_identity_and_children` | Upsert parent with a child row | ID, immutable fields, and child survive |
|
||||
| `insert_only_never_silently_updates` | Repeat insert-only identity | Second call is `unique_violation` |
|
||||
|
||||
### 9.5 Transactions, isolation, and failure injection
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `related_changes_commit_atomically` | Update parent and children | All postconditions commit together |
|
||||
| `related_changes_roll_back_atomically` | Inject a child constraint failure | All tables equal pre-operation state |
|
||||
| `stable_snapshot_behavior_is_declared` | Read, commit a concurrent update, then read in the same operation | Result follows the operation's declared snapshot/current-read policy |
|
||||
| `range_insert_visibility_is_declared` | Read a range while another transaction inserts a matching row | Later visibility matches the declared snapshot/live policy |
|
||||
| `read_modify_write_prevents_lost_update` | Two transactions read one version and attempt distinct updates | One declared winner; loser conflicts/retries without overwriting |
|
||||
| `independent_writers_preserve_atomic_effects` | Two transactions update different identities concurrently | Both logical effects commit; no contract depends on backend lock order |
|
||||
| `deadlock_retries_whole_operation` | Two physical connections lock in opposite order | One victim; final logical effect occurs once |
|
||||
| `lock_timeout_discards_context` | Hold a row lock past timeout | Explicit rollback; old context rejects work |
|
||||
| `duplicate_and_foreign_key_errors_are_distinct` | Trigger each constraint | Stable distinct classes |
|
||||
| `disconnect_before_send_is_unavailable` | Fail connection before dispatch | Safe bounded retry is permitted |
|
||||
| `disconnect_during_commit_is_outcome_unknown` | Drop connection at commit boundary | No blind retry; reconciliation is required |
|
||||
| `retry_uses_stable_operation_identity` | Fail first attempt after durable write | At most one logical effect exists |
|
||||
|
||||
### 9.6 Migration and readiness
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------- | ------------------------------------------- | -------------------------------------------------- |
|
||||
| `only_one_instance_owns_migration` | Two backend instances acquire one name | Exactly one executes migration phases |
|
||||
| `lock_timeout_is_not_reported_as_ready` | Hold migration lock from another connection | Startup waits/fails with classified state |
|
||||
| `disconnect_releases_named_lock` | Terminate owner connection | Another instance can acquire and reinspect |
|
||||
| `ddl_checkpoint_recovers_after_crash` | Stop after DDL before history update | Restart detects postcondition and continues safely |
|
||||
| `backfill_resumes_without_duplication` | Stop between deterministic batches | Completed rows are neither skipped nor duplicated |
|
||||
| `partial_migration_blocks_readiness` | Leave required milestone incomplete | Health may be alive; readiness is false |
|
||||
| `completed_history_is_idempotent` | Start against fully migrated schema | No DDL/data mutation occurs |
|
||||
|
||||
## 10. First-slice acceptance profile: combos and model mappings
|
||||
|
||||
This section specializes the general catalog for the candidate first slice discussed in #8075 and
|
||||
implemented experimentally in Draft PR #8757. It does not approve that runtime PR.
|
||||
|
||||
### 10.1 Contract decisions required before adapter code
|
||||
|
||||
| Decision | Current evidence | Required resolution |
|
||||
| --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| Combo ID | Application UUID | Preserve as byte-exact text/binary identity |
|
||||
| Combo name uniqueness | SQLite unique name; exact and insensitive reads differ | Select explicit uniqueness collation independently from insensitive fallback |
|
||||
| Combo list | `sort_order`, then `name NOCASE` | Add `id` as final tie-breaker and define Unicode name order |
|
||||
| Next sort order | `MAX(sort_order) + 1` | Replace race-prone read-then-insert with an atomic allocation or retryable unique invariant |
|
||||
| Reorder | One SQLite transaction updates all parseable rows | Define concurrent reorder serialization and all-or-nothing behavior |
|
||||
| Corrupt combo JSON | Reads/resolution skip malformed payloads | Decide whether MySQL schema can represent malformed legacy rows during migration |
|
||||
| Mapping order | `priority DESC, created_at ASC` | Add `id ASC` final tie-breaker |
|
||||
| Mapping delete | Boolean from affected rows | Preserve `true` then `false` behavior independent of found-rows mode |
|
||||
| Combo delete | Foreign key cascade removes mappings | Preserve one-operation atomic cascade |
|
||||
| Timestamps | Application ISO strings | Preserve canonical UTC text or define an exact typed conversion |
|
||||
|
||||
### 10.2 Required combo fixtures
|
||||
|
||||
The shared fixture MUST include:
|
||||
|
||||
- combo names `Alpha`, `alpha`, `Résumé`, and `resume` to exercise selected collation policy;
|
||||
- three combos with the same requested `sortOrder` to exercise the unique final order;
|
||||
- one missing ID for update and delete results;
|
||||
- one payload with explicit JSON `null` and one with a missing member;
|
||||
- one intentionally malformed legacy payload if compatibility requires it;
|
||||
- mappings with identical `priority` and `createdAt` but different IDs;
|
||||
- enabled, disabled, inactive-target, and corrupt-target mappings;
|
||||
- one combo with at least two dependent mappings for cascade verification.
|
||||
|
||||
### 10.3 Required combo assertions
|
||||
|
||||
A MySQL implementation cannot claim the first slice complete until the shared harness proves:
|
||||
|
||||
1. application UUIDs and ISO timestamps round-trip unchanged;
|
||||
2. exact and insensitive combo-name lookups remain distinct operations;
|
||||
3. uniqueness follows the approved name policy, not server defaults;
|
||||
4. combo and mapping lists have a total deterministic order;
|
||||
5. every offset page is a contiguous slice of that order;
|
||||
6. update of a missing combo/mapping returns `null`;
|
||||
7. first delete returns `true`, repeated delete returns `false`;
|
||||
8. reorder filters unknown/duplicate requested IDs exactly as the accepted contract specifies;
|
||||
9. reorder either commits every intended row or none;
|
||||
10. mapping resolution uses the deterministic order and skips disabled, inactive, and malformed targets;
|
||||
11. deleting a combo atomically removes all dependent mappings;
|
||||
12. errors are classified without raw MySQL messages;
|
||||
13. SQLite starts without loading a MySQL dependency;
|
||||
14. no external-backend support is advertised by the presence of this slice alone.
|
||||
|
||||
### 10.4 Concurrency probes specific to the slice
|
||||
|
||||
#### Concurrent combo creation
|
||||
|
||||
Two connections create different UUIDs with the same contract-equivalent name. Exactly one succeeds;
|
||||
the other receives `unique_violation`. If case/accent variants are allowed by the approved policy,
|
||||
both succeed and exact lookup returns the correct identity.
|
||||
|
||||
#### Concurrent sort allocation
|
||||
|
||||
Two connections create combos without an explicit sort order. The final values MUST follow the
|
||||
contract without duplicates caused by both transactions reading the same `MAX(sort_order)`. The
|
||||
implementation may serialize allocation, use a separate sequence, or retry a protected invariant;
|
||||
the contract must not require one specific SQL mechanism.
|
||||
|
||||
#### Concurrent reorder
|
||||
|
||||
Two connections reorder the same set in opposite orders. The accepted outcome MUST be one complete
|
||||
order or the other, never a mixed sequence or mismatched JSON/column `sortOrder`. The loser may wait,
|
||||
return conflict, or retry according to the approved contract.
|
||||
|
||||
#### Delete versus mapping creation
|
||||
|
||||
One connection deletes a combo while another creates a mapping to it. The final state MUST be either
|
||||
an existing combo with a valid mapping or no combo and no mapping. An orphan mapping is forbidden.
|
||||
|
||||
## 11. Implementation gate checklist
|
||||
|
||||
A MySQL adapter PR for any domain MUST NOT start until reviewers can answer all applicable items:
|
||||
|
||||
- [ ] Identity, case, accent, and collation semantics are explicit.
|
||||
- [ ] Every list has a complete order, `NULL` position, and unique tie-breaker.
|
||||
- [ ] Missing, unchanged, conflict, and delete results are distinguishable.
|
||||
- [ ] Every write is classified as insert-only, identity-preserving upsert, or replacement.
|
||||
- [ ] ID generation and idempotency ownership are explicit.
|
||||
- [ ] JSON and temporal representations are selected with migration compatibility in mind.
|
||||
- [ ] Error codes map to the backend-neutral taxonomy.
|
||||
- [ ] Retry ownership and maximum scope are explicit.
|
||||
- [ ] Migration mutex, durable checkpoints, and readiness rules are approved.
|
||||
- [ ] SQLite and MySQL fixtures run through one behavior harness.
|
||||
- [ ] Offline migration preflight and postconditions exist before cutover is advertised.
|
||||
- [ ] SQLite remains the zero-configuration default and clean startup path.
|
||||
|
||||
## 12. Reference sources
|
||||
|
||||
### 12.1 OmniRoute sources
|
||||
|
||||
- `docs/architecture/persistence-backend-boundary.md`
|
||||
- `docs/architecture/sqlite-coupling-inventory.md`
|
||||
- `src/lib/db/combos.ts`
|
||||
- `src/lib/db/modelComboMappings.ts`
|
||||
- `src/lib/db/migrations/001_initial_schema.sql`
|
||||
- `src/lib/db/migrations/010_model_combo_mappings.sql`
|
||||
- `src/lib/db/migrations/020_combo_sort_order.sql`
|
||||
|
||||
### 12.2 MySQL 8.0 reference manual
|
||||
|
||||
- [Character sets and collations](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/charset.html)
|
||||
- [CREATE TABLE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/create-table.html)
|
||||
- [UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/update.html)
|
||||
- [INSERT ... ON DUPLICATE KEY UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/insert-on-duplicate.html)
|
||||
- [Information functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/information-functions.html)
|
||||
- [The JSON data type](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/json.html)
|
||||
- [InnoDB transaction isolation](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-transaction-isolation-levels.html)
|
||||
- [InnoDB error handling](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-error-handling.html)
|
||||
- [Handling deadlocks](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-deadlocks-handling.html)
|
||||
- [Statements that cause an implicit commit](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/implicit-commit.html)
|
||||
- [Locking functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/locking-functions.html)
|
||||
- [InnoDB limits](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-limits.html)
|
||||
|
||||
### 12.3 SQLite references
|
||||
|
||||
- [ON CONFLICT](https://sqlite.org/lang_conflict.html)
|
||||
- [`NULL` handling](https://sqlite.org/nulls.html)
|
||||
- [Transactions](https://sqlite.org/lang_transaction.html)
|
||||
- [SELECT and ordering](https://sqlite.org/lang_select.html#orderby)
|
||||
|
||||
## 13. Open decisions
|
||||
|
||||
This specification deliberately leaves the following decisions to the accepted first-slice design:
|
||||
|
||||
1. the exact collation and normalization policy for combo names;
|
||||
2. the typed or text representation of combo JSON in MySQL;
|
||||
3. the repository result type for an existing same-value update;
|
||||
4. the isolation level selected by the backend profile;
|
||||
5. the concurrency mechanism for sort-order allocation and reorder;
|
||||
6. the physical MySQL migration schema and durable checkpoint format;
|
||||
7. the exact retry budget and backoff policy;
|
||||
8. the topology boundary within which a MySQL named migration lock is sufficient.
|
||||
|
||||
These are not adapter implementation details. Each changes observable behavior or operational
|
||||
correctness and therefore requires explicit review before runtime support proceeds.
|
||||
@@ -1,278 +0,0 @@
|
||||
---
|
||||
title: "Antigravity (Google One AI) — Onboarding with OmniRoute"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-07-31
|
||||
---
|
||||
|
||||
# OmniRoute Antigravity (Google One AI) Onboarding Guide
|
||||
|
||||
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
|
||||
|
||||
**Official references**:
|
||||
|
||||
- [Google Antigravity](https://antigravity.google) — product homepage
|
||||
- [Antigravity Plans & Pricing](https://antigravity.google/pricing) — subscription tiers
|
||||
- [Antigravity Docs: Plans](https://antigravity.google/docs/plans) — baseline quota details
|
||||
- [Google One AI Plans](https://one.google.com/about/google-ai-plans/) — Google One subscription comparison
|
||||
- [Antigravity CLI Blog](https://antigravity.google/blog/introducing-google-antigravity-cli) — CLI announcement
|
||||
|
||||
---
|
||||
|
||||
## 1. Antigravity vs Antigravity CLI (agy)
|
||||
|
||||
Both providers share the **same Google backend** — identical OAuth client, token refresh, endpoints, and Google accounts. The difference is what models you see.
|
||||
|
||||
> See [Antigravity CLI announcement](https://antigravity.google/blog/introducing-google-antigravity-cli) for Google's official comparison.
|
||||
|
||||
| Aspect | `antigravity` (IDE) | `agy` (CLI) |
|
||||
| -------------------- | ----------------------------------------- | --------------------------------------------------- |
|
||||
| **Google product** | Antigravity 2.0 / Antigravity IDE | Antigravity CLI |
|
||||
| **Backend** | Same Google Cloud Code API | Same Google Cloud Code API |
|
||||
| **OAuth / Token** | Same client, same refresh | Same client, same refresh |
|
||||
| **Model catalog** | Static curated list (OmniRoute hardcoded) | Live-probed from Google via `:fetchAvailableModels` |
|
||||
| **Claude models** | Sonnet 4.6, Opus 4.6 (4 variants each) | Sonnet 4.6, Opus 4.6 (4 variants each) |
|
||||
| **Gemini naming** | Clean labels (Low/Medium/High) | Upstream IDs (extra-low/low/agent) |
|
||||
| **Extra models** | `gpt-oss-120b-medium` | May include additional models from Google |
|
||||
| **Default use case** | IDE integration (VS Code, JetBrains) | CLI / API access |
|
||||
| **Quota** | Shared with agy (same Google account) | Shared with antigravity (same Google account) |
|
||||
|
||||
**Available models (verified via experiment, 2026-07-29)**:
|
||||
|
||||
- Gemini: 3.6 Flash, 3.5 Flash, 3.1 Pro, 3 Flash, 2.5 Flash (various thinking levels)
|
||||
- Claude: Sonnet 4.6, Opus 4.6 (each with default/low/medium/high variants)
|
||||
- Other: GPT-OSS 120B Medium
|
||||
- **Claude Sonnet 5 is NOT available** — only 4.6 variants are supported
|
||||
|
||||
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
|
||||
|
||||
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Google One AI Pro: Quota System
|
||||
|
||||
> See [Antigravity Docs: Plans](https://antigravity.google/docs/plans) for official quota details and [Changes to Antigravity Plans](https://antigravity.google/blog/changes-to-antigravity-plans) for the latest pricing updates.
|
||||
|
||||
Google Antigravity uses a **dual-layer quota** based on "Work Done" (computational weight), not message count.
|
||||
|
||||
### The Two Layers
|
||||
|
||||
| Layer | What it is | Refresh cycle |
|
||||
| ------------------ | ----------------------------- | ------------------------------------------------------------------ |
|
||||
| **5-hour sprint** | Immediate pool of "work done" | Resets 5 hours after first request in a session |
|
||||
| **7-day baseline** | Weekly hard cap | Overrides 5-hour refresh if hit; locks out until next 7-day period |
|
||||
|
||||
**How "Work Done" is calculated**: Agent-heavy tasks (e.g. "Refactor this entire repository") drain quota much faster than simple tasks (e.g. "Fix this function"). There is no real-time dashboard showing consumption.
|
||||
|
||||
### Plan Tiers
|
||||
|
||||
| Plan | Price | Quota | Weekly limit |
|
||||
| ------------ | ---------- | ---------------------------------- | ----------------------------- |
|
||||
| Free | $0 | Meaningful quota, refreshed weekly | Yes |
|
||||
| AI Pro | $19.99/mo | High quota, 5-hour rolling refresh | Yes (overrides 5-hour if hit) |
|
||||
| AI Ultra 5x | $99.99/mo | 5x Pro quota | No weekly limit |
|
||||
| AI Ultra 20x | $199.99/mo | 20x Pro quota | No weekly limit |
|
||||
|
||||
### Gemini vs Non-Gemini Models
|
||||
|
||||
- **Gemini models** (Flash + Pro): Share a single rate limit, drawn down by API pricing. If Flash is 8x cheaper than Pro, you get 8x more Flash tokens.
|
||||
- **Non-Gemini models** (Claude, GPT-OSS): Have **separate** rate limits. May remain available even when Gemini is locked out.
|
||||
|
||||
### AI Credits (Overage)
|
||||
|
||||
> See [Google One AI credits](https://support.google.com/googleone/answer/14534406) for how credits work.
|
||||
|
||||
When baseline quota is exhausted:
|
||||
|
||||
- **Never**: Wait for quota to refresh; shows "Baseline model quota reached"
|
||||
- **Always**: Auto-use AI credits; switches back to baseline when it refreshes
|
||||
|
||||
Credits are purchased separately and deducted at standard API pricing.
|
||||
|
||||
### Key Details
|
||||
|
||||
- Quota is **account-level shared** — the same Google account in Antigravity IDE, CLI, and OmniRoute shares one quota pool
|
||||
- Each Google account has its own independent quota — multiple accounts = multiple quota pools
|
||||
- AI Pro users have reported **7-day lockouts** instead of 5-hour resets when weekly baseline is hit (Google confirmed this is by design for high demand)
|
||||
|
||||
**When your account is exhausted**: OmniRoute automatically retries with the next available account in the combo route. No manual intervention needed.
|
||||
|
||||
---
|
||||
|
||||
## 3. How to Get a projectId
|
||||
|
||||
Every antigravity/agy connection needs a Google Cloud Code `projectId`. Without it, the `/v1internal:models` endpoint returns 404.
|
||||
|
||||
### Method A: Automatic (Recommended)
|
||||
|
||||
OmniRoute handles this automatically. When you add a new Google account via Dashboard OAuth:
|
||||
|
||||
1. OmniRoute refreshes the token
|
||||
2. Calls `loadCodeAssist` to discover the projectId
|
||||
3. If no project exists, calls `onboardUser` to create one
|
||||
4. Retries `loadCodeAssist` to get the newly created projectId
|
||||
5. Saves it to the database
|
||||
|
||||
**This works for most accounts** — no manual steps needed.
|
||||
|
||||
### Method B: Manual via agy CLI
|
||||
|
||||
If automatic discovery fails (see Section 5 for when this happens):
|
||||
|
||||
```bash
|
||||
# Install agy CLI (if not already)
|
||||
npm install -g @anthropic-ai/agy
|
||||
|
||||
# Login with your Google account
|
||||
agy login
|
||||
|
||||
# Select the account that needs onboarding
|
||||
# This triggers Cloud Code registration and assigns a projectId
|
||||
```
|
||||
|
||||
After `agy login` succeeds, refresh the token in OmniRoute Dashboard. The projectId will be discovered automatically.
|
||||
|
||||
### How to verify
|
||||
|
||||
Check the database:
|
||||
|
||||
```bash
|
||||
# Inside OmniRoute container
|
||||
node -e "const db=require('better-sqlite3')('/app/data/storage.sqlite'); \
|
||||
console.log(JSON.stringify(db.prepare(\
|
||||
'SELECT email,project_id FROM provider_connections WHERE provider=\"agy\"'\
|
||||
).all(), null, 2))"
|
||||
```
|
||||
|
||||
Or check the logs:
|
||||
|
||||
```
|
||||
podman logs omniroute 2>&1 | grep "projectId discovered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. OAuth Redirect URI
|
||||
|
||||
### The Problem
|
||||
|
||||
Google OAuth requires a valid redirect URI. OmniRoute's default uses `http://127.0.0.1:20128/callback` (loopback). This works for local builds but **fails for remote deployments** (e.g., a server accessed via LAN IP).
|
||||
|
||||
Google rejects redirect URIs that:
|
||||
|
||||
- Use IP addresses (must be a domain ending in `.com`, `.org`, etc.)
|
||||
- Don't match the registered redirect URIs in the OAuth client config
|
||||
|
||||
### The Solution
|
||||
|
||||
**Option A: Use the built-in OAuth flow (default)**
|
||||
|
||||
- Works when you access OmniRoute from `localhost` or `127.0.0.1`
|
||||
- No configuration needed
|
||||
|
||||
**Option B: Custom OAuth credentials**
|
||||
|
||||
- Set `ANTIGRAVITY_OAUTH_CLIENT_TYPE=web` in your environment
|
||||
- Provide your own Google OAuth credentials:
|
||||
```
|
||||
GOOGLE_OAUTH_CLIENT_ID=your-client-id
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
- Register `https://your-domain.com/callback` as an authorized redirect URI in Google Cloud Console
|
||||
|
||||
**Option C: Use agy CLI for initial login**
|
||||
|
||||
- Run `agy login` on the machine that will access OmniRoute
|
||||
- The OAuth flow completes locally, tokens are stored
|
||||
- Import the connection into OmniRoute via Dashboard
|
||||
|
||||
### Limitations
|
||||
|
||||
- Custom OAuth credentials require a domain name (Google does not accept IP addresses as redirect URIs)
|
||||
- If you don't have a domain, use Option A or C instead
|
||||
|
||||
---
|
||||
|
||||
## 5. Troubleshooting: When Automatic Setup Fails
|
||||
|
||||
OmniRoute handles projectId discovery and onboarding automatically for most accounts. When it fails, the root cause is usually one of these:
|
||||
|
||||
### Account region is blocked
|
||||
|
||||
**Symptom**: `agy login` returns "Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location."
|
||||
|
||||
**Root cause**: Google accounts have a backend "Country Association" field set at registration time. The agy CLI and Cloud Code API check this field strictly — unlike web Gemini which only checks your current IP.
|
||||
|
||||
> To check or change your account's associated region, visit [Google Country Association Form](https://policies.google.com/country-association-form).
|
||||
|
||||
**Why web Gemini works but agy doesn't**:
|
||||
|
||||
- Web Gemini / Google One: checks current IP only (proxy passes)
|
||||
- agy CLI / Cloud Code API: reads backend Country Association field (proxy doesn't help)
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Visit [Google Country Association Form](https://policies.google.com/country-association-form) while on a US IP
|
||||
2. Submit region change request (select "I live in a different country")
|
||||
3. Wait 1-24 hours for Google to process + email notification
|
||||
4. Then `agy login` should succeed
|
||||
|
||||
### Account has no Cloud Code project
|
||||
|
||||
**Symptom**: Logs show `loadCodeAssist returned no project id` and `onboardUser failed (400)`.
|
||||
|
||||
**Root cause**: The account has never been registered with Google Cloud Code, and the automatic onboarding failed.
|
||||
|
||||
**Fix**: Run `agy login` manually to trigger Cloud Code registration, then refresh the token in OmniRoute Dashboard.
|
||||
|
||||
### Token expired or revoked
|
||||
|
||||
**Symptom**: 401 errors in logs, or "Token has expired" messages.
|
||||
|
||||
**Fix**: Refresh the token in Dashboard → Providers → agy → Click refresh icon. If the refresh token itself is revoked, you'll need to re-authenticate via OAuth.
|
||||
|
||||
---
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```
|
||||
Account not working?
|
||||
│
|
||||
├─ Does it have a projectId in the database?
|
||||
│ ├─ YES → Problem is elsewhere (token expired, rate limit, etc.)
|
||||
│ └─ NO ↓
|
||||
│
|
||||
├─ Is the account's Country Association set to a restricted region?
|
||||
│ ├─ YES → Change region at Google Country Association Form
|
||||
│ │ (https://policies.google.com/country-association-form)
|
||||
│ │ Wait 1-24 hours, then retry
|
||||
│ └─ NO ↓
|
||||
│
|
||||
├─ Does the account have Google One AI Pro subscription?
|
||||
│ ├─ NO → Subscribe first at one.google.com
|
||||
│ └─ YES ↓
|
||||
│
|
||||
├─ Try automatic discovery (refresh token in Dashboard)
|
||||
│ ├─ Works → Done
|
||||
│ └─ Still fails ↓
|
||||
│
|
||||
└─ Manual: Run `agy login` on the machine
|
||||
├─ Works → Refresh token in Dashboard, projectId discovered
|
||||
└─ Fails → Check error message, likely region or subscription issue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command / URL |
|
||||
| --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Change account region | [Google Country Association Form](https://policies.google.com/country-association-form) |
|
||||
| agy CLI login | `agy login` |
|
||||
| Check projectId in DB | `SELECT email,project_id FROM provider_connections WHERE provider='agy'` |
|
||||
| Check logs | `podman logs omniroute 2>&1 \| grep projectId` |
|
||||
| Refresh token | Dashboard → Providers → agy → Click refresh icon |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-07-31. Based on OmniRoute v3.8.50._
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
title: "AgentRouter WAF"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-03
|
||||
---
|
||||
|
||||
# agentrouter.org WAF (Web Application Firewall)
|
||||
|
||||
The `agentrouter` upstream gateway runs a keyword-based content filter on
|
||||
|
||||
@@ -24,8 +24,6 @@ const ANTHROPIC_BETA_BASE = Object.freeze([
|
||||
"advisor-tool-2026-03-01",
|
||||
"extended-cache-ttl-2025-04-11",
|
||||
"cache-diagnosis-2026-04-07",
|
||||
"code-execution-2025-08-25",
|
||||
"skills-2025-10-02",
|
||||
]);
|
||||
|
||||
const CLAUDE_OAUTH_EXTRA_BETAS = Object.freeze(["fine-grained-tool-streaming-2025-05-14"]);
|
||||
@@ -55,8 +53,6 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [
|
||||
export const FORWARDABLE_CLIENT_BETAS = Object.freeze([
|
||||
"tool-search-tool-2025-10-19",
|
||||
"context-1m-2025-08-07",
|
||||
"code-execution-2025-08-25",
|
||||
"skills-2025-10-02",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,10 +12,16 @@ export interface KimiWebModelConfig {
|
||||
|
||||
const STATIC_MODEL_CONFIGS: Record<string, KimiWebModelConfig> = {
|
||||
k3: {
|
||||
scenario: "SCENARIO_K2D5",
|
||||
supportedReasoningEfforts: ["REASONING_EFFORT_NONE", "REASONING_EFFORT_LOW"],
|
||||
defaultReasoningEffort: "REASONING_EFFORT_NONE",
|
||||
supportedContextLengths: [],
|
||||
scenario: "SCENARIO_OK_COMPUTER",
|
||||
kimiPlusId: "ok-computer",
|
||||
supportedReasoningEfforts: [
|
||||
"REASONING_EFFORT_LOW",
|
||||
"REASONING_EFFORT_HIGH",
|
||||
"REASONING_EFFORT_MAX",
|
||||
],
|
||||
defaultReasoningEffort: "REASONING_EFFORT_MAX",
|
||||
supportedContextLengths: ["CONTEXT_LENGTH_L", "CONTEXT_LENGTH_XL"],
|
||||
defaultContextLength: "CONTEXT_LENGTH_L",
|
||||
},
|
||||
k2d6: {
|
||||
scenario: "SCENARIO_K2D5",
|
||||
|
||||
@@ -12,18 +12,6 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
|
||||
// Users can generate API keys at https://ollama.com/settings/keys
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
id: "gpt-oss:120b",
|
||||
name: "GPT-OSS 120B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
|
||||
@@ -15,7 +15,7 @@ export const perplexity_webProvider: RegistryEntry = {
|
||||
{ id: "pplx-gpt-5.6-sol", name: "GPT-5.6 Sol (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-sonnet", name: "Claude Sonnet 5.0 (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-opus", name: "Claude Opus 5.0 (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-opus", name: "Claude Opus 4.8 (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-glm", name: "GLM-5.2 (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-kimi", name: "Kimi K2.6 (via Perplexity)", toolCalling: false },
|
||||
{ id: "pplx-grok-4.5", name: "Grok 4.5 (via Perplexity)", toolCalling: false },
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface RegistryModel {
|
||||
aliases?: readonly string[];
|
||||
toolCalling?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
supportedThinkingEfforts?: readonly string[];
|
||||
supportsVision?: boolean;
|
||||
supportsXHighEffort?: boolean;
|
||||
maxOutputTokens?: number;
|
||||
|
||||
@@ -7,11 +7,10 @@ import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/provi
|
||||
/**
|
||||
* Sanitize reasoning_effort for providers that don't accept all values.
|
||||
*
|
||||
* The claude→openai translator passes output_config.effort through verbatim
|
||||
* (including max) and only performs form conversion; provider-aware effort
|
||||
* policy is owned here. Combined with runtime alias remapping (e.g.
|
||||
* claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value
|
||||
* to OpenAI-shape providers that don't accept it:
|
||||
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
|
||||
* client sends output_config.effort=max on a Claude-shape request. Combined with
|
||||
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
|
||||
* routes xhigh to OpenAI-shape providers that don't accept the value:
|
||||
*
|
||||
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
|
||||
* mistral : devstral models reject reasoning_effort entirely
|
||||
@@ -217,7 +216,10 @@ function writeEffortValue(
|
||||
}
|
||||
|
||||
/** Strip the effort field from every carrier that was present. */
|
||||
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
|
||||
function stripEffortValue(
|
||||
b: Record<string, unknown>,
|
||||
c: EffortCarriers
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...b };
|
||||
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
||||
if (c.hasReasoningEffort && c.reasoning) {
|
||||
|
||||
@@ -213,21 +213,14 @@ function makeErrorResponse(
|
||||
details?: unknown;
|
||||
type?: string;
|
||||
code?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
): Response {
|
||||
const body = buildErrorBody(status, message, options?.details);
|
||||
if (options?.type) body.error.type = options.type;
|
||||
if (options?.code) body.error.code = options.code;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (options?.extraHeaders) {
|
||||
for (const [key, value] of Object.entries(options.extraHeaders)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,12 +302,7 @@ async function errorResponseForTransport(
|
||||
return makeErrorResponse(401, "Session expired or invalid");
|
||||
}
|
||||
if (result.status === 429) {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
const upstreamRetryAfter = result.headers.get("retry-after");
|
||||
if (upstreamRetryAfter) {
|
||||
extraHeaders["Retry-After"] = upstreamRetryAfter;
|
||||
}
|
||||
return makeErrorResponse(429, "Rate limited by Claude Web API", { extraHeaders });
|
||||
return makeErrorResponse(429, "Rate limited by Claude Web API");
|
||||
}
|
||||
if (isClaudeWebChallenge({ ...result, bodyText })) {
|
||||
return makeErrorResponse(403, "Claude Web returned a Cloudflare browser challenge", {
|
||||
|
||||
@@ -217,20 +217,6 @@ function messageText(content: unknown): string {
|
||||
return content.map(contentPartText).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function buildPromptFromMessages(messages: unknown[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const candidate of messages) {
|
||||
if (!isRecord(candidate)) continue;
|
||||
const role = candidate.role;
|
||||
const text = messageText(candidate.content);
|
||||
if (!text) continue;
|
||||
if (role === "user" || role === "tool") {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function latestUserPrompt(messages: unknown[]): string {
|
||||
let prompt = "";
|
||||
for (const candidate of messages) {
|
||||
@@ -322,9 +308,7 @@ export function transformToClaude(
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const reasoningEffort = resolveClaudeWebReasoningEffort(body);
|
||||
const resolvedModel = model || DEFAULT_CLAUDE_MODEL;
|
||||
const prompt =
|
||||
turn?.prompt ?? (buildPromptFromMessages(messages) || latestUserPrompt(messages));
|
||||
const resolvedTurn = turn ?? defaultTurn(prompt);
|
||||
const resolvedTurn = turn ?? defaultTurn(latestUserPrompt(messages));
|
||||
|
||||
if (resolvedTurn.operation === "completion" && !resolvedTurn.prompt.trim()) {
|
||||
throw new Error("No user message found in request");
|
||||
|
||||
@@ -13,22 +13,14 @@ export interface ClaudeWebStreamOptions {
|
||||
}
|
||||
|
||||
type StreamPhase = "awaiting_message" | "in_message" | "stopped" | "failed";
|
||||
type BlockKind = "thinking" | "text" | "tool_use" | "other";
|
||||
type BlockKind = "thinking" | "text" | "other";
|
||||
const MAX_CLAUDE_WEB_SSE_PENDING_CHARS = 1024 * 1024;
|
||||
type SemanticEvent =
|
||||
| { kind: "content"; text: string }
|
||||
| { kind: "reasoning"; text: string }
|
||||
| { kind: "tool_call"; index: number; id: string; name: string; input: string }
|
||||
| { kind: "metadata"; eventType: string; data: Record<string, unknown> }
|
||||
| { kind: "finish"; stopReason: string };
|
||||
|
||||
interface ToolBlockInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
inputParts: string[];
|
||||
initialInput: string;
|
||||
}
|
||||
|
||||
const KNOWN_METADATA_EVENTS = new Set([
|
||||
"ping",
|
||||
"completion",
|
||||
@@ -201,7 +193,6 @@ function thinkingSummaryText(delta: Record<string, unknown>): string {
|
||||
interface ProtocolState {
|
||||
phase: StreamPhase;
|
||||
openBlocks: Map<number, BlockKind>;
|
||||
toolBlocks: Map<number, ToolBlockInfo>;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
@@ -250,7 +241,6 @@ function handleMessageStart(state: ProtocolState): null {
|
||||
function blockKind(block: Record<string, unknown>): BlockKind {
|
||||
if (block.type === "thinking") return "thinking";
|
||||
if (block.type === "text") return "text";
|
||||
if (block.type === "tool_use") return "tool_use";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -262,35 +252,17 @@ function handleContentBlockStart(
|
||||
const index = requireBlockIndex(event);
|
||||
if (state.openBlocks.has(index)) protocolFailure(state, "Content block was opened twice");
|
||||
|
||||
const contentBlock = requireRecord(event.content_block, "content_block");
|
||||
const kind = blockKind(contentBlock);
|
||||
const kind = blockKind(requireRecord(event.content_block, "content_block"));
|
||||
state.openBlocks.set(index, kind);
|
||||
|
||||
if (kind === "tool_use") {
|
||||
const id = typeof contentBlock.id === "string" ? contentBlock.id : "";
|
||||
const name = typeof contentBlock.name === "string" ? contentBlock.name : "";
|
||||
let initialInput = "";
|
||||
if (contentBlock.input !== undefined) {
|
||||
try {
|
||||
initialInput = JSON.stringify(contentBlock.input);
|
||||
} catch {
|
||||
initialInput = "";
|
||||
}
|
||||
}
|
||||
state.toolBlocks.set(index, { id, name, inputParts: [], initialInput });
|
||||
return null;
|
||||
}
|
||||
|
||||
return kind === "thinking" ? { kind: "reasoning", text: "" } : null;
|
||||
}
|
||||
|
||||
function handleContentBlockDelta(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent | null {
|
||||
): SemanticEvent {
|
||||
assertInMessage(state, "content_block_delta");
|
||||
const index = requireBlockIndex(event);
|
||||
const block = state.openBlocks.get(index);
|
||||
const block = state.openBlocks.get(requireBlockIndex(event));
|
||||
if (!block) protocolFailure(state, "Content delta has no open block");
|
||||
|
||||
const delta = requireRecord(event.delta, "delta");
|
||||
@@ -303,42 +275,14 @@ function handleContentBlockDelta(
|
||||
if (delta.type === "thinking_summary_delta" && block === "thinking") {
|
||||
return { kind: "reasoning", text: thinkingSummaryText(delta) };
|
||||
}
|
||||
if (delta.type === "input_json_delta" && block === "tool_use") {
|
||||
const toolBlock = state.toolBlocks.get(index);
|
||||
if (!toolBlock) protocolFailure(state, "input_json_delta has no tool block state");
|
||||
if (typeof delta.partial_json === "string") {
|
||||
toolBlock.inputParts.push(delta.partial_json);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return protocolFailure(state, "Content delta type does not match its block");
|
||||
}
|
||||
|
||||
function handleContentBlockStop(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent | null {
|
||||
function handleContentBlockStop(event: Record<string, unknown>, state: ProtocolState): null {
|
||||
assertInMessage(state, "content_block_stop");
|
||||
const index = requireBlockIndex(event);
|
||||
const kind = state.openBlocks.get(index);
|
||||
if (!kind) protocolFailure(state, "Content block stop has no open block");
|
||||
state.openBlocks.delete(index);
|
||||
|
||||
if (kind === "tool_use") {
|
||||
const toolBlock = state.toolBlocks.get(index);
|
||||
state.toolBlocks.delete(index);
|
||||
if (!toolBlock) protocolFailure(state, "Tool block stop has no tool state");
|
||||
|
||||
let inputStr = "";
|
||||
if (toolBlock.inputParts.length > 0) {
|
||||
inputStr = toolBlock.inputParts.join("");
|
||||
} else if (toolBlock.initialInput) {
|
||||
inputStr = toolBlock.initialInput;
|
||||
}
|
||||
|
||||
return { kind: "tool_call", index, id: toolBlock.id, name: toolBlock.name, input: inputStr };
|
||||
if (!state.openBlocks.delete(requireBlockIndex(event))) {
|
||||
protocolFailure(state, "Content block stop has no open block");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -392,7 +336,6 @@ async function* parseClaudeWebEvents(
|
||||
const state: ProtocolState = {
|
||||
phase: "awaiting_message",
|
||||
openBlocks: new Map(),
|
||||
toolBlocks: new Map(),
|
||||
stopReason: "end_turn",
|
||||
};
|
||||
|
||||
@@ -504,7 +447,6 @@ async function createBufferedResponse(
|
||||
let assistantText = "";
|
||||
let reasoningText = "";
|
||||
let stopReason = "end_turn";
|
||||
const toolCalls: Array<{ id: string; name: string; input: string }> = [];
|
||||
const metadataEvents: Array<{ type: string; data: Record<string, unknown> }> = [];
|
||||
const control: StreamControl = { reader: null, cancelled: false };
|
||||
|
||||
@@ -512,30 +454,12 @@ async function createBufferedResponse(
|
||||
for await (const event of parseClaudeWebEvents(source, control)) {
|
||||
if (event.kind === "content") assistantText += event.text;
|
||||
if (event.kind === "reasoning") reasoningText += event.text;
|
||||
if (event.kind === "tool_call") {
|
||||
toolCalls.push({ id: event.id, name: event.name, input: event.input });
|
||||
}
|
||||
if (event.kind === "metadata") {
|
||||
metadataEvents.push({ type: event.eventType, data: event.data });
|
||||
}
|
||||
if (event.kind === "finish") stopReason = event.stopReason;
|
||||
}
|
||||
notifyComplete(options, { assistantText, stopReason });
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: assistantText || null,
|
||||
...(reasoningText ? { reasoning_content: reasoningText } : {}),
|
||||
};
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: { name: tc.name, arguments: tc.input },
|
||||
}));
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
@@ -545,7 +469,11 @@ async function createBufferedResponse(
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
...(reasoningText ? { reasoning_content: reasoningText } : {}),
|
||||
},
|
||||
finish_reason: openAiFinishReason(stopReason),
|
||||
logprobs: null,
|
||||
},
|
||||
@@ -641,31 +569,6 @@ async function queueSemanticEvent(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "tool_call") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(
|
||||
state.id,
|
||||
state.created,
|
||||
options,
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: event.index,
|
||||
id: event.id,
|
||||
type: "function",
|
||||
function: { name: event.name, arguments: event.input },
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind === "metadata") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
|
||||
@@ -166,13 +166,6 @@ export interface ChatInvocationOptions {
|
||||
tone?: string;
|
||||
/** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */
|
||||
allowedMessageTypes?: readonly string[];
|
||||
/**
|
||||
* Tier-specific disconnect behavior sent in every type:4 chat invocation. The work
|
||||
* Surface rejects any value other than exactly "continue" (#8971). Defaults to ""
|
||||
* for individual/consumer/EDU tiers; {@link resolveChatInvocationOverrides} returns
|
||||
* "continue" for the enterprise tier.
|
||||
*/
|
||||
disconnectBehavior?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,21 +178,18 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
optionsSets: string[];
|
||||
tone: string;
|
||||
allowedMessageTypes: readonly string[];
|
||||
disconnectBehavior: string;
|
||||
} {
|
||||
if (tier === "enterprise") {
|
||||
return {
|
||||
optionsSets: [...M365_ENTERPRISE_OPTION_SETS],
|
||||
tone: "Magic",
|
||||
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES, ...M365_ENTERPRISE_EXTRA_MESSAGE_TYPES],
|
||||
disconnectBehavior: "continue",
|
||||
};
|
||||
}
|
||||
return {
|
||||
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
||||
tone: "",
|
||||
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
|
||||
disconnectBehavior: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,7 +253,7 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
isSbsSupported: false,
|
||||
tone: opts.tone ?? "",
|
||||
renderReferencesBehindEOS: true,
|
||||
disconnectBehavior: opts.disconnectBehavior ?? "",
|
||||
disconnectBehavior: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -395,26 +395,9 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
if (effectiveKey) {
|
||||
headers["x-api-key"] = effectiveKey;
|
||||
// Port of decolua/9router commit b977bf74:
|
||||
// Third-party Anthropic-compatible gateways frequently require
|
||||
// Authorization: Bearer ALONGSIDE x-api-key — without it they
|
||||
// return 401 missing_api_key on every forward. Only emit the
|
||||
// Bearer fallback for non-official upstreams; api.anthropic.com
|
||||
// (and the empty/default baseUrl that targets it) must keep the
|
||||
// x-api-key-only behavior to avoid regressing the official path.
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "";
|
||||
const isOfficial = isOfficialAnthropicBaseUrl(baseUrl);
|
||||
if (!isOfficial) {
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
// If neither effectiveKey nor accessToken is available, emit no
|
||||
// auth header — the handler will produce a clean "no credentials"
|
||||
// 4xx instead of forwarding garbage auth headers to the upstream.
|
||||
effectiveKey
|
||||
? (headers["x-api-key"] = effectiveKey)
|
||||
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);
|
||||
break;
|
||||
case "glm":
|
||||
case "glmt":
|
||||
|
||||
@@ -348,30 +348,6 @@ export class GeminiWebExecutor extends BaseExecutor {
|
||||
super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL });
|
||||
}
|
||||
|
||||
/**
|
||||
* testConnection — validates the cookie format without making a network call
|
||||
* or launching Playwright. Returns true when the cookie is non-empty and
|
||||
* contains at least one name=value pair with a non-empty value. This is a
|
||||
* lightweight pre-check before the browser automation path; full session
|
||||
* validation is done by validateGeminiWebProvider in the connection test
|
||||
* flow (#9407).
|
||||
*/
|
||||
async testConnection(
|
||||
credentials: Record<string, unknown>,
|
||||
_signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cookie = resolveGeminiWebCookie(
|
||||
credentials as unknown as ExecuteInput["credentials"]
|
||||
);
|
||||
if (!cookie) return false;
|
||||
const pairs = parseCookies(cookie);
|
||||
return pairs.some((p) => p.value.length > 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the live Playwright cookie jar back after a successful run and, if
|
||||
* Google rotated any of the __Secure-1PSID* cookies, forward the merged
|
||||
@@ -617,30 +593,6 @@ export class GeminiWebExecutor extends BaseExecutor {
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
// #9407: Playwright selector/click timeout errors are terminal — they indicate
|
||||
// the page DOM does not match expectations (e.g. Gemini changed their UI or
|
||||
// the session is so expired it lands on a different page). Return 400 so the
|
||||
// account-fallback system does NOT retry this request as a transient 5xx.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === "TimeoutError" ||
|
||||
rawMessage.includes("waitForSelector") ||
|
||||
rawMessage.includes("Timeout") ||
|
||||
rawMessage.includes("actionability") ||
|
||||
rawMessage.includes("interception"))
|
||||
) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: sanitizeErrorMessage(rawMessage),
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: GEMINI_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -388,10 +388,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
|
||||
let pplxMode: string;
|
||||
let modelPref: string;
|
||||
if (thinking && THINKING_MAP[model]) {
|
||||
// "copilot", not "search": the backend downgrades "search" to CONCISE and drops
|
||||
// model_preference, so the thinking variant would fail the same way the catalog
|
||||
// models do (see the note above MODEL_MAP).
|
||||
pplxMode = "copilot";
|
||||
pplxMode = "search";
|
||||
modelPref = THINKING_MAP[model];
|
||||
log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`);
|
||||
} else if (MODEL_MAP[model]) {
|
||||
|
||||
@@ -51,40 +51,31 @@ export const PPLX_STREAM_EOF_SYMBOL = "event: end_of_stream";
|
||||
export const PPLX_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0";
|
||||
|
||||
// mode / model_preference pairs — every entry posts mode:"copilot", like the live
|
||||
// www.perplexity.ai client does when a model is picked from the catalog.
|
||||
//
|
||||
// mode:"search" must NOT be used here. The backend now downgrades it to CONCISE and
|
||||
// drops model_preference entirely, answering with status:"FAILED" and the text
|
||||
// "Error in processing query." Verified against a paid `subscription_tier: "max"`
|
||||
// account: mode:"search" + claude50sonnet → {"mode":"CONCISE","status":"FAILED"},
|
||||
// while mode:"copilot" + the same preference → {"mode":"COPILOT",
|
||||
// "display_model":"claude50sonnet"} and a normal stream. Same for every other
|
||||
// catalog model, so "search" breaks the whole catalog, not just one entry.
|
||||
// mode / model_preference pairs. Live www.perplexity.ai still posts mode:"copilot"
|
||||
// for the default turbo path; search mode is used for the curated catalog models.
|
||||
export const MODEL_MAP: Record<string, [string, string]> = {
|
||||
// pplx-auto/pplx-sonar were already on "copilot" (with "search", pplx-sonar maps to
|
||||
// "experimental" — that model no longer streams answer-text blocks for many
|
||||
// sessions → empty content, issue #6955).
|
||||
// pplx-auto/pplx-sonar use "copilot" mode (was "search", which for pplx-sonar
|
||||
// maps to "experimental" — that model no longer streams answer-text blocks
|
||||
// for many sessions → empty content, issue #6955). The live web client uses
|
||||
// mode:"copilot" + model_preference:"turbo" for the default turbo path.
|
||||
"pplx-auto": ["copilot", "pplx_pro"],
|
||||
"pplx-sonar": ["copilot", "turbo"],
|
||||
"pplx-gpt-5.6-terra": ["copilot", "gpt56_terra"],
|
||||
"pplx-gpt-5.6-sol": ["copilot", "gpt56_sol"],
|
||||
"pplx-gemini": ["copilot", "gemini31pro_high"],
|
||||
"pplx-sonnet": ["copilot", "claude50sonnet"],
|
||||
// Perplexity's catalog moved Opus to 5.0; claude48opus is still accepted but
|
||||
// answers from the older model.
|
||||
"pplx-opus": ["copilot", "claude50opus"],
|
||||
"pplx-glm": ["copilot", "glm_5_2"],
|
||||
"pplx-kimi": ["copilot", "kimik26instant"],
|
||||
"pplx-grok-4.5": ["copilot", "grok45low"],
|
||||
"pplx-nemotron": ["copilot", "nv_nemotron_3_ultra"],
|
||||
"pplx-gpt-5.6-terra": ["search", "gpt56_terra"],
|
||||
"pplx-gpt-5.6-sol": ["search", "gpt56_sol"],
|
||||
"pplx-gemini": ["search", "gemini31pro_high"],
|
||||
"pplx-sonnet": ["search", "claude50sonnet"],
|
||||
"pplx-opus": ["search", "claude48opus"],
|
||||
"pplx-glm": ["search", "glm_5_2"],
|
||||
"pplx-kimi": ["search", "kimik26instant"],
|
||||
"pplx-grok-4.5": ["search", "grok45low"],
|
||||
"pplx-nemotron": ["search", "nv_nemotron_3_ultra"],
|
||||
};
|
||||
|
||||
export const THINKING_MAP: Record<string, string> = {
|
||||
"pplx-gpt-5.6-terra": "gpt56_terra_thinking",
|
||||
"pplx-gpt-5.6-sol": "gpt56_sol_thinking",
|
||||
"pplx-sonnet": "claude50sonnetthinking",
|
||||
"pplx-opus": "claude50opusthinking",
|
||||
"pplx-opus": "claude48opusthinking",
|
||||
"pplx-kimi": "kimik26thinking",
|
||||
"pplx-grok-4.5": "grok45medium",
|
||||
};
|
||||
|
||||
@@ -4651,7 +4651,12 @@ export async function handleChatCore({
|
||||
});
|
||||
if (streamReadiness.ok === false) {
|
||||
const { response: failureResponse, reason } = streamReadiness;
|
||||
const { classificationReason, upstreamDiagnostic } = streamReadiness;
|
||||
const failure = {
|
||||
status: failureResponse.status,
|
||||
message: reason,
|
||||
code: streamReadiness.code,
|
||||
type: streamReadiness.type,
|
||||
};
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
appendRequestLog({
|
||||
model,
|
||||
@@ -4663,11 +4668,7 @@ export async function handleChatCore({
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
providerRequest: finalBody || translatedBody,
|
||||
clientResponse: buildErrorBody(
|
||||
failureResponse.status,
|
||||
classificationReason,
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
|
||||
),
|
||||
clientResponse: buildErrorBody(failureResponse.status, reason),
|
||||
claudeCacheMeta: claudePromptCacheLogMeta,
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
@@ -4679,7 +4680,6 @@ export async function handleChatCore({
|
||||
success: false,
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
classificationError: classificationReason,
|
||||
errorType: streamReadiness.type,
|
||||
errorCode: streamReadiness.code,
|
||||
response: failureResponse,
|
||||
|
||||
@@ -41,8 +41,8 @@ function extractSystemTexts(body: Record<string, unknown> | null | undefined): s
|
||||
* True when the inbound request should be default-allowed without calling upstream.
|
||||
*
|
||||
* - `mode === "off"` (default): never short-circuits.
|
||||
* - `mode === "always"`: short-circuits only when the request carries the classifier's
|
||||
* system-prompt marker (same body-awareness as "auto").
|
||||
* - `mode === "always"`: short-circuits every Claude-format request (operator has
|
||||
* decided every `/v1/messages` call through this route is the classifier).
|
||||
* - `mode === "auto"`: only short-circuits when the request carries the classifier's
|
||||
* system-prompt marker. `</block>` in `stop_sequences` is corroborating evidence but
|
||||
* is never sufficient alone — the marker is the strong, classifier-unique signal;
|
||||
@@ -56,6 +56,7 @@ export function shouldDefaultAllowClassifier(
|
||||
): boolean {
|
||||
if (mode !== "auto" && mode !== "always") return false;
|
||||
if (sourceFormat !== FORMATS.CLAUDE) return false;
|
||||
if (mode === "always") return true;
|
||||
|
||||
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ import {
|
||||
resolveDelayMs,
|
||||
comboModelNotFoundResponse,
|
||||
isStreamReadinessFailureErrorBody,
|
||||
isStreamEarlyEofErrorBody,
|
||||
isTokenLimitBreachErrorBody,
|
||||
toRecordedTarget,
|
||||
getExhaustedTargetSkipReason,
|
||||
@@ -1512,11 +1511,6 @@ export async function handleComboChat({
|
||||
const isStreamReadinessFailure =
|
||||
(result.status === 502 || result.status === 504) &&
|
||||
isStreamReadinessFailureErrorBody(errorBody);
|
||||
// An early EOF is an upstream failure, not a readiness probe — the breaker must
|
||||
// see it even though the transient-retry path below treats both codes alike.
|
||||
const isStreamEarlyEof =
|
||||
(result.status === 502 || result.status === 504) &&
|
||||
isStreamEarlyEofErrorBody(errorBody);
|
||||
|
||||
// FIX 5: a local per-API-key token-limit 429 must not cool shared accounts.
|
||||
const isTokenLimitBreach =
|
||||
@@ -1719,7 +1713,6 @@ export async function handleComboChat({
|
||||
if (
|
||||
shouldRecordProviderBreakerFailure({
|
||||
isStreamReadinessFailure,
|
||||
isStreamEarlyEof,
|
||||
status: result.status,
|
||||
sameProviderNext,
|
||||
skipProviderBreaker: fallbackResult.skipProviderBreaker,
|
||||
|
||||
@@ -133,11 +133,7 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
|
||||
* failure (#1731 / #2743 gap-d). This is the consumer side of `skipProviderBreaker`:
|
||||
*
|
||||
* - Stream-readiness failures (pre-flight zombie/ping probes) never count as provider
|
||||
* failures — they are a connection-readiness signal, not an upstream outage. EXCEPT a
|
||||
* STREAM_EARLY_EOF (`isStreamEarlyEof`): there the upstream returned HTTP 200, opened the
|
||||
* SSE stream and then hung up without a single non-ping event, which is a genuine upstream
|
||||
* failure. Excluding it made a provider-wide outage invisible to the breaker — see the
|
||||
* STREAM_EARLY_EOF section of RESILIENCE_GUIDE.md.
|
||||
* failures — they are a connection-readiness signal, not an upstream outage.
|
||||
* - Only whole-provider failure statuses (408/500/502/503/504) count. A plain rate-limit
|
||||
* 429 is deliberately EXCLUDED — it belongs to connection cooldown / model lockout scope
|
||||
* (a genuine quota/token-limit 429 is handled there), NOT the whole-provider breaker. This
|
||||
@@ -167,10 +163,6 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
|
||||
*/
|
||||
export function shouldRecordProviderBreakerFailure(args: {
|
||||
isStreamReadinessFailure: boolean;
|
||||
/** True when the failure is specifically a STREAM_EARLY_EOF (upstream hung up after
|
||||
* HTTP 200). Overrides the `isStreamReadinessFailure` exemption only; every other
|
||||
* AND-term below still gates the trip. */
|
||||
isStreamEarlyEof?: boolean;
|
||||
status: number;
|
||||
sameProviderNext: boolean;
|
||||
skipProviderBreaker?: boolean;
|
||||
@@ -181,7 +173,7 @@ export function shouldRecordProviderBreakerFailure(args: {
|
||||
isProxyUnreachable?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
(!args.isStreamReadinessFailure || args.isStreamEarlyEof === true) &&
|
||||
!args.isStreamReadinessFailure &&
|
||||
PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) &&
|
||||
(!args.sameProviderNext || args.isProxyUnreachable === true) &&
|
||||
!args.skipProviderBreaker &&
|
||||
@@ -316,28 +308,6 @@ export function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
|
||||
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
|
||||
}
|
||||
|
||||
/**
|
||||
* A STREAM_EARLY_EOF specifically: the upstream accepted the request (HTTP 200), opened the
|
||||
* SSE stream, then closed it before emitting a single non-ping event.
|
||||
*
|
||||
* This is deliberately NOT the same signal as STREAM_READINESS_TIMEOUT. The readiness probe
|
||||
* is a pre-flight liveness check on a connection we have not committed to yet, so failing it
|
||||
* says "this connection looks stale", not "this provider is failing". An early EOF is the
|
||||
* opposite: the provider took the request and then failed to serve it, which is an upstream
|
||||
* failure by any reasonable definition.
|
||||
*
|
||||
* `isStreamReadinessFailureErrorBody` still covers both codes because the transient-retry and
|
||||
* semaphore-cooldown paths in combo.ts want identical treatment for both. Only the
|
||||
* whole-provider circuit breaker needs to tell them apart — see
|
||||
* `shouldRecordProviderBreakerFailure`.
|
||||
*/
|
||||
export function isStreamEarlyEofErrorBody(errorBody: unknown): boolean {
|
||||
if (!errorBody || typeof errorBody !== "object") return false;
|
||||
const error = (errorBody as Record<string, unknown>).error;
|
||||
if (!error || typeof error !== "object") return false;
|
||||
return (error as Record<string, unknown>).code === "STREAM_EARLY_EOF";
|
||||
}
|
||||
|
||||
/**
|
||||
* A local per-API-key token-limit breach surfaces as a 429 tagged with
|
||||
* errorCode "TOKEN_LIMIT_EXCEEDED" (see chatCore.ts Tier 2 early return). This
|
||||
|
||||
@@ -99,7 +99,7 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
retryDelayMs: 2000,
|
||||
fallbackDelayMs: 0,
|
||||
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
|
||||
queueTimeoutMs: 120000, // max wait time in semaphore queue (round-robin); raised from 30s for browser-automation providers like gemini-web (#9407)
|
||||
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
|
||||
queueDepth: DEFAULT_COMBO_QUEUE_DEPTH, // pre-cascade semaphore queue depth (round-robin, #3872)
|
||||
handoffThreshold: 0.85,
|
||||
handoffModel: "",
|
||||
|
||||
@@ -557,36 +557,20 @@ function parseAliasTarget(target: string): ResolvedModelTarget | null {
|
||||
}
|
||||
|
||||
async function resolveModelByProviderInference(modelId: string, extendedContext: boolean) {
|
||||
if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) {
|
||||
return {
|
||||
provider: "codex",
|
||||
model: modelId,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
|
||||
const [activeProviders, activeSyncedProviders, preferClaudeCodeForUnprefixedClaudeModels] =
|
||||
await Promise.all([
|
||||
getActiveProviderSet(),
|
||||
getActiveSyncedProvidersForModel(modelId),
|
||||
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
||||
]);
|
||||
|
||||
// Codex-native bare ids prefer the ChatGPT subscription, but the preference is only
|
||||
// allowed to PREEMPT another provider when a codex connection is actually active.
|
||||
// Returning "codex" unconditionally (as this did once the set grew past
|
||||
// `codex-auto-review` to cover gpt-5.5 / the gpt-5.6-sol tiers) hands ids that OpenAI
|
||||
// also serves to a provider the operator may not have configured: an OpenAI-only
|
||||
// install fails with "no active credentials for provider: codex" on a model that
|
||||
// works, and an install whose codex connection is merely *inactive* fails the same way.
|
||||
// Ids only codex catalogs (e.g. `codex-auto-review`) keep resolving to codex with no
|
||||
// connection at all — there is no alternative to preempt, and "no codex credentials"
|
||||
// is the honest error. With codex active the preference still beats OpenAI, and an
|
||||
// explicit `openai/…` prefix remains the per-request override either way.
|
||||
if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) {
|
||||
const codexNativeAlternatives = (MODEL_TO_PROVIDERS.get(modelId) || []).filter(
|
||||
(p) => p !== "codex"
|
||||
);
|
||||
if (codexNativeAlternatives.length === 0 || activeProviders?.has("codex")) {
|
||||
return {
|
||||
provider: "codex",
|
||||
model: modelId,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
|
||||
// claim ownership of models the provider does not actually serve (e.g. a
|
||||
// `kiro` upstream briefly advertising `claude-opus-5` before it was
|
||||
|
||||
@@ -135,17 +135,7 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
|
||||
// Thin wrapper for call sites that only have the full request URL (not the bare endpoint
|
||||
// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint.
|
||||
export function detectFormatFromUrl(body, requestUrl) {
|
||||
const rawUrl = typeof requestUrl === "string" ? requestUrl : "";
|
||||
let pathname = rawUrl;
|
||||
try {
|
||||
// Supplying a base URL keeps relative client endpoints (for example,
|
||||
// `/v1/messages`) valid while preserving pathname-only detection.
|
||||
pathname = new URL(rawUrl || "/", "http://omniroute.local").pathname;
|
||||
} catch {
|
||||
// Fall back to the raw value; detectFormatFromEndpoint is intentionally
|
||||
// safe for unknown or malformed paths.
|
||||
}
|
||||
return detectFormatFromEndpoint(body, pathname);
|
||||
return detectFormatFromEndpoint(body, new URL(requestUrl).pathname);
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
@@ -203,7 +193,7 @@ export function detectFormat(body) {
|
||||
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
||||
// Could be Claude or OpenAI multimodal
|
||||
// Check for Claude-specific fields
|
||||
if (body.system || body.anthropic_version || body["anthropic-version"]) {
|
||||
if (body.system || body.anthropic_version) {
|
||||
return "claude";
|
||||
}
|
||||
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
|
||||
@@ -226,7 +216,7 @@ export function detectFormat(body) {
|
||||
|
||||
// If content is string, it's likely OpenAI (Claude also supports this)
|
||||
// Check for other Claude-specific indicators
|
||||
if (body.system !== undefined || body.anthropic_version || body["anthropic-version"]) {
|
||||
if (body.system !== undefined || body.anthropic_version) {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ import { getVertexUsage } from "./usage/vertex.ts";
|
||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
|
||||
import { getXaiUsage } from "./usage/xai.ts";
|
||||
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
|
||||
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
||||
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -117,7 +116,6 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"xai",
|
||||
"xai-oauth",
|
||||
"xao",
|
||||
"grok-cli",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
"codebuddy-cn",
|
||||
@@ -212,8 +210,6 @@ export async function getUsageForProvider(
|
||||
case "xai-oauth":
|
||||
case "xao":
|
||||
return await getXaiOauthUsage(id || "", accessToken, connection);
|
||||
case "grok-cli":
|
||||
return await getGrokCliUsage(accessToken);
|
||||
case "codebuddy-cn":
|
||||
return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData);
|
||||
case "promptql":
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { GROK_BUILD_PROXY_BASE_URL, getGrokBuildModelsHeaders } from "../../config/grokBuild.ts";
|
||||
import {
|
||||
GROK_BUILD_ADDITIONAL_CREDITS_URL,
|
||||
type GrokAutoTopUpStatus,
|
||||
} from "../../../src/shared/utils/grokBilling.ts";
|
||||
|
||||
const GROK_BUILD_FETCH_TIMEOUT_MS = 10_000;
|
||||
const GROK_BUILD_MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
|
||||
const optionalNonEmptyString = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.optional()
|
||||
.nullable()
|
||||
.catch(undefined);
|
||||
const optionalPercent = z.number().finite().min(0).max(100).optional().nullable().catch(undefined);
|
||||
const centSchema = z
|
||||
.object({ val: z.number().finite().int().safe().optional() })
|
||||
.passthrough()
|
||||
.transform(({ val }) => ({ val: Math.abs(val ?? 0) }));
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
userId: optionalNonEmptyString,
|
||||
subscriptionTier: optionalNonEmptyString,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const productUsageSchema = z
|
||||
.object({
|
||||
product: z.string().trim().min(1).max(128),
|
||||
usagePercent: z.number().finite().min(0).max(100),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const productUsageListSchema = z
|
||||
.array(z.unknown())
|
||||
.max(100)
|
||||
.transform((items) =>
|
||||
items.flatMap((item) => {
|
||||
const parsed = productUsageSchema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
);
|
||||
|
||||
const currentPeriodSchema = z
|
||||
.object({
|
||||
type: optionalNonEmptyString,
|
||||
start: optionalNonEmptyString,
|
||||
end: optionalNonEmptyString,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const billingConfigSchema = z
|
||||
.object({
|
||||
creditUsagePercent: optionalPercent,
|
||||
currentPeriod: currentPeriodSchema.optional().nullable().catch(undefined),
|
||||
productUsage: productUsageListSchema.optional().nullable().catch(undefined),
|
||||
prepaidBalance: centSchema.optional().nullable().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const billingSchema = z
|
||||
.object({
|
||||
config: billingConfigSchema.optional().nullable().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const autoTopUpRuleSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
minBeforeHittingSl: centSchema.optional().nullable().catch(undefined),
|
||||
topupAmount: centSchema.optional().nullable().catch(undefined),
|
||||
maxAmountPerMonth: centSchema.optional().nullable().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const autoTopUpSchema = z
|
||||
.object({
|
||||
rule: autoTopUpRuleSchema.optional().nullable().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type JsonSchema<T> = z.ZodType<T>;
|
||||
type GrokBuildHeaders = ReturnType<typeof getGrokBuildModelsHeaders>;
|
||||
|
||||
function finitePercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function normalizeProduct(value: string): { key: string; displayName: string } {
|
||||
const compact = value
|
||||
.normalize("NFKC")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
if (compact === "grokbuild" || compact === "productgrokbuild") {
|
||||
return { key: "grok_build", displayName: "Grok Build" };
|
||||
}
|
||||
|
||||
const slug = value
|
||||
.normalize("NFKD")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return { key: slug || "unknown", displayName: value };
|
||||
}
|
||||
|
||||
function percentageQuota(used: number, resetAt: string | null, displayName?: string) {
|
||||
const normalizedUsed = finitePercent(used);
|
||||
const remaining = 100 - normalizedUsed;
|
||||
return {
|
||||
...(displayName ? { displayName } : {}),
|
||||
used: normalizedUsed,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt,
|
||||
isPercentageOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBoundedJson<T>(response: Response, schema: JsonSchema<T>): Promise<T | null> {
|
||||
if (!response.ok) return null;
|
||||
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > GROK_BUILD_MAX_RESPONSE_BYTES)
|
||||
return null;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return null;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > GROK_BUILD_MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return schema.parse(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGrokBuildJson<T>(
|
||||
path: string,
|
||||
headers: GrokBuildHeaders,
|
||||
schema: JsonSchema<T>
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const response = await fetch(`${GROK_BUILD_PROXY_BASE_URL}${path}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(GROK_BUILD_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
return await readBoundedJson(response, schema);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildProductQuotas(
|
||||
productUsage: z.infer<typeof productUsageSchema>[] | null | undefined,
|
||||
resetAt: string | null
|
||||
): Record<string, ReturnType<typeof percentageQuota>> {
|
||||
const quotas: Record<string, ReturnType<typeof percentageQuota>> = {};
|
||||
for (const product of productUsage ?? []) {
|
||||
const normalized = normalizeProduct(product.product);
|
||||
const baseKey = `product_${normalized.key}`;
|
||||
let key = baseKey;
|
||||
let suffix = 2;
|
||||
while (key in quotas) {
|
||||
key = `${baseKey}_${suffix++}`;
|
||||
}
|
||||
quotas[key] = percentageQuota(product.usagePercent, resetAt, normalized.displayName);
|
||||
}
|
||||
return quotas;
|
||||
}
|
||||
|
||||
function buildAutoTopUp(ruleResponse: z.infer<typeof autoTopUpSchema> | null): GrokAutoTopUpStatus {
|
||||
const rule = ruleResponse?.rule;
|
||||
if (!rule) return { available: false };
|
||||
|
||||
const enabled = rule.enabled === true;
|
||||
return {
|
||||
available: true,
|
||||
enabled,
|
||||
...(enabled && rule.minBeforeHittingSl
|
||||
? { thresholdMinorUnits: rule.minBeforeHittingSl.val }
|
||||
: {}),
|
||||
...(enabled && rule.topupAmount ? { amountMinorUnits: rule.topupAmount.val } : {}),
|
||||
...(enabled && rule.maxAmountPerMonth
|
||||
? { maxMonthlyMinorUnits: rule.maxAmountPerMonth.val }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGrokCliUsage(accessToken?: string) {
|
||||
if (!accessToken) {
|
||||
return { message: "Grok Build usage unavailable" };
|
||||
}
|
||||
|
||||
const baseHeaders = getGrokBuildModelsHeaders({ token: accessToken });
|
||||
const user = await fetchGrokBuildJson("/user?include=subscription", baseHeaders, userSchema);
|
||||
const userId = user?.userId || null;
|
||||
const tier = user?.subscriptionTier || null;
|
||||
const billing = await fetchGrokBuildJson(
|
||||
"/billing?format=credits",
|
||||
userId ? getGrokBuildModelsHeaders({ token: accessToken, userId }) : baseHeaders,
|
||||
billingSchema
|
||||
);
|
||||
|
||||
if (!billing?.config) {
|
||||
return {
|
||||
...(tier ? { plan: tier } : {}),
|
||||
message: "Grok Build billing status unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
const config = billing.config;
|
||||
const resetAt = config.currentPeriod?.end || null;
|
||||
const quotas: Record<string, ReturnType<typeof percentageQuota>> = {};
|
||||
if (config.creditUsagePercent != null) {
|
||||
quotas.weekly = percentageQuota(config.creditUsagePercent, resetAt);
|
||||
}
|
||||
Object.assign(quotas, buildProductQuotas(config.productUsage, resetAt));
|
||||
|
||||
const autoTopUpResponse = userId
|
||||
? await fetchGrokBuildJson(
|
||||
"/auto-topup-rule",
|
||||
getGrokBuildModelsHeaders({ token: accessToken, userId }),
|
||||
autoTopUpSchema
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
quotas,
|
||||
...(tier ? { plan: tier } : {}),
|
||||
billing: {
|
||||
currency: "USD",
|
||||
...(config.prepaidBalance ? { extraCreditsMinorUnits: config.prepaidBalance.val } : {}),
|
||||
autoTopUp: buildAutoTopUp(autoTopUpResponse),
|
||||
additionalCreditsUrl: GROK_BUILD_ADDITIONAL_CREDITS_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
billingSchema,
|
||||
userSchema,
|
||||
autoTopUpSchema,
|
||||
readBoundedJson,
|
||||
networkPolicy: {
|
||||
method: "GET",
|
||||
redirect: "error",
|
||||
timeoutMs: GROK_BUILD_FETCH_TIMEOUT_MS,
|
||||
maxResponseBytes: GROK_BUILD_MAX_RESPONSE_BYTES,
|
||||
} as const,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
|
||||
import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts";
|
||||
import { getReadableReasoningValue } from "../utils/reasoningFields.ts";
|
||||
import {
|
||||
isInternalReasoningPlaceholder,
|
||||
stripInternalReasoningPlaceholder,
|
||||
@@ -529,13 +528,10 @@ export function createResponsesApiTransformStream(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle OpenAI-compatible reasoning fields. Some providers use the
|
||||
// standard `reasoning_content` key while others use the string alias
|
||||
// `reasoning`; prefer the standard key when both are present.
|
||||
const reasoning = getReadableReasoningValue(delta);
|
||||
if (reasoning && !isInternalReasoningPlaceholder(reasoning)) {
|
||||
// Handle reasoning_content (OpenAI native format)
|
||||
if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) {
|
||||
startReasoning(controller, idx);
|
||||
emitReasoningDelta(controller, reasoning);
|
||||
emitReasoningDelta(controller, delta.reasoning_content);
|
||||
}
|
||||
|
||||
// Handle text content. Generic prompt-format tags are visible text;
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
resolveRequestedToolName,
|
||||
toArgumentsString,
|
||||
stripRanges,
|
||||
getToolNonce,
|
||||
type OpenAIToolCall,
|
||||
type RequestedToolName,
|
||||
} from "./webTools.ts";
|
||||
@@ -46,16 +45,10 @@ interface OpenAIToolDef {
|
||||
* (a) invent its own wrappers and (b) merely *describe* a plan instead of emitting a call.
|
||||
* The wording forces the single canonical `<tool>{json}</tool>` shape and forbids the
|
||||
* alternatives, while staying short to avoid wasting tokens.
|
||||
*
|
||||
* Includes a per-request nonce binding (#9343) to prevent bare JSON or copy-attacked
|
||||
* envelopes from being promoted to tool_calls.
|
||||
*/
|
||||
export function serializeDeepSeekToolPrompt(tools: unknown): string {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return "";
|
||||
|
||||
const nonce = getToolNonce(tools);
|
||||
if (!nonce) return "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const t of tools as OpenAIToolDef[]) {
|
||||
const fn = t?.function;
|
||||
@@ -75,10 +68,9 @@ export function serializeDeepSeekToolPrompt(tools: unknown): string {
|
||||
|
||||
return [
|
||||
"You can call tools. To call a tool, output ONLY this exact block (no markdown fence):",
|
||||
`<tool>{"name": "<tool_name>", "arguments": { ... }, "_nonce": "${nonce}"}</tool>`,
|
||||
'<tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
|
||||
"Rules:",
|
||||
"- Use exactly <tool>...</tool>. Do NOT use <tool:name>, <tool_call>, <name>, <parameter>, id=/name= attributes, or code fences.",
|
||||
`- Include the secret binding "_nonce": "${nonce}" exactly as shown.`,
|
||||
'- "name" must be one of the tools below; "arguments" must be a JSON object.',
|
||||
"- When a tool is needed, emit the <tool> block instead of only describing the plan.",
|
||||
"- Emit one <tool> block per call; you may put several blocks back to back.",
|
||||
@@ -458,7 +450,6 @@ export function parseDeepSeekToolCalls(
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
const acceptedRanges: Array<{ start: number; end: number }> = [];
|
||||
const nonce = getToolNonce(requestedTools);
|
||||
|
||||
for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) {
|
||||
const tagName =
|
||||
@@ -469,19 +460,6 @@ export function parseDeepSeekToolCalls(
|
||||
const inner = text.slice(block.innerStart, block.innerEnd);
|
||||
const call = extractCall(tagName, inner, requested, schemaMap);
|
||||
if (!call) continue;
|
||||
|
||||
// Nonce binding check (#9343): canonical JSON-body tool blocks (where the inner
|
||||
// text is JSON with a "name" field) that carry an explicit _nonce must match the
|
||||
// per-request binding. A wrong nonce means this is a copy-attack or hallucination.
|
||||
//
|
||||
// XML children (<parameter>, <name>, <arguments>) and tag-suffix blocks do not
|
||||
// have a JSON body, so the nonce check does not apply to them.
|
||||
// A missing _nonce is tolerated for backward compatibility.
|
||||
if (nonce) {
|
||||
const parsed = parseLooseJsonObject(inner);
|
||||
if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: `${idSeed}_${toolCalls.length}`,
|
||||
type: "function",
|
||||
@@ -491,11 +469,8 @@ export function parseDeepSeekToolCalls(
|
||||
}
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
// Tags were present but none parsed (e.g. malformed or nonce-rejected).
|
||||
// Do NOT fall back to parseToolCallsFromText — that would re-process content
|
||||
// already seen by this parser and potentially promote rejected tagged output
|
||||
// to tool_calls. (#9343)
|
||||
return { content: text, toolCalls: null };
|
||||
// Tags were present but none parsed (e.g. malformed) — try the canonical bare-JSON path.
|
||||
return parseToolCallsFromText(text, idSeed, requestedTools);
|
||||
}
|
||||
|
||||
// Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer
|
||||
|
||||
@@ -36,6 +36,7 @@ function normalizeToolSchema(schema: unknown): Record<string, unknown> {
|
||||
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
|
||||
if (typeof effort !== "string") return undefined;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "max") return "xhigh";
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { FORMATS } from "../formats.ts";
|
||||
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
|
||||
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
|
||||
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
|
||||
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
|
||||
import {
|
||||
isInternalReasoningPlaceholder,
|
||||
stripInternalReasoningPlaceholder,
|
||||
@@ -81,7 +80,9 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||
return flushEvents(state);
|
||||
}
|
||||
|
||||
// Normalize usage from any chunk so response.completed has Responses token fields.
|
||||
// Capture usage from all chunks that carry it (usage-only chunks OR final chunks with finish_reason)
|
||||
// Normalize Chat Completions format (prompt_tokens/completion_tokens) to Responses API format
|
||||
// (input_tokens/output_tokens) so response.completed always has the fields Codex expects.
|
||||
if (chunk.usage) {
|
||||
const u = chunk.usage;
|
||||
const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0;
|
||||
@@ -192,10 +193,9 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||
});
|
||||
}
|
||||
|
||||
const reasoning = getReadableReasoningValue(delta);
|
||||
if (reasoning && !isInternalReasoningPlaceholder(reasoning)) {
|
||||
if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) {
|
||||
startReasoning(state, emit, idx);
|
||||
emitReasoningDelta(state, emit, reasoning);
|
||||
emitReasoningDelta(state, emit, delta.reasoning_content);
|
||||
}
|
||||
// Strip the internal reasoning placeholder if the model echoed it
|
||||
// through ordinary content (#8081). Only the text-content emission is
|
||||
|
||||
@@ -27,21 +27,6 @@ const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
|
||||
// lives there, never in the tag's `name="..."` attribute (#3260).
|
||||
const TOOL_CALL_TAG_RE = /<tool_call(?:\s+[^>]*)?\s*>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
||||
|
||||
// Per-request nonce binding for tool envelopes (#9343). Associates a random nonce
|
||||
// with each tools[] array reference so the serializer and parser can share it
|
||||
// without threading extra parameters through executor call chains.
|
||||
const toolNonceMap = new WeakMap<object, string>();
|
||||
|
||||
export function getToolNonce(tools: unknown): string {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return "";
|
||||
let nonce = toolNonceMap.get(tools);
|
||||
if (!nonce) {
|
||||
nonce = Math.random().toString(36).slice(2, 10);
|
||||
toolNonceMap.set(tools, nonce);
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
||||
interface ToolParseCandidate {
|
||||
raw: string;
|
||||
start: number;
|
||||
@@ -360,18 +345,10 @@ export function toArgumentsString(value: unknown): string {
|
||||
* Serialize an OpenAI `tools` array into a system-prompt block that instructs the
|
||||
* web UI model how to invoke a tool (emit a `<tool>{...}</tool>` block). Returns an
|
||||
* empty string when there are no usable tools.
|
||||
*
|
||||
* Each invocation generates a per-request nonce that is embedded in the tool format
|
||||
* instructions. The parser (parseToolCallsFromText) requires this nonce in the model's
|
||||
* `<tool>` JSON to distinguish legitimate tool calls from bare JSON, code-fenced JSON,
|
||||
* or copy-attacked envelopes (#9343).
|
||||
*/
|
||||
export function serializeToolsToPrompt(tools: unknown): string {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return "";
|
||||
|
||||
const nonce = getToolNonce(tools);
|
||||
if (!nonce) return "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const t of tools as OpenAIToolDef[]) {
|
||||
const fn = t?.function;
|
||||
@@ -392,8 +369,7 @@ export function serializeToolsToPrompt(tools: unknown): string {
|
||||
|
||||
return [
|
||||
"You can call tools. To call a tool, reply with a single line containing a <tool> block",
|
||||
`with JSON that includes the secret binding "_nonce": "${nonce}":`,
|
||||
`<tool>{"name": "<tool_name>", "arguments": { ... }, "_nonce": "${nonce}"}</tool>`,
|
||||
'with JSON: <tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
|
||||
"Only emit the <tool> block when you actually want to call a tool; otherwise answer normally.",
|
||||
"",
|
||||
"Available tools:",
|
||||
@@ -402,19 +378,11 @@ export function serializeToolsToPrompt(tools: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `<tool>{...}</tool>` or `<tool_call>{...}</tool_call>` blocks out of
|
||||
* upstream text into OpenAI `tool_calls`.
|
||||
*
|
||||
* **Security hardening (#9343):** Bare JSON with name+arguments keys is NEVER
|
||||
* promoted to tool_calls — only explicit `<tool>` or `<tool_call>` envelopes are
|
||||
* accepted. When a nonce was embedded via serializeToolsToPrompt (stored from the
|
||||
* same tools[] reference), it MUST be present in the parsed JSON body as `_nonce`.
|
||||
* This prevents code-fenced JSON, prose JSON, and copy-attacked user envelopes from
|
||||
* triggering tool execution.
|
||||
*
|
||||
* Returns the content with the recognized blocks stripped, plus the tool calls
|
||||
* (or null when there are none). `arguments` is always a JSON *string*, matching
|
||||
* the OpenAI API.
|
||||
* Parse `<tool>{...}</tool>` blocks out of upstream text into OpenAI `tool_calls`.
|
||||
* When a requested `tools[]` set is provided, also accepts bare JSON tool-call
|
||||
* objects emitted by web models that ignored the `<tool>` wrapper contract.
|
||||
* Returns the content with the blocks stripped, plus the tool calls (or null when
|
||||
* there are none). `arguments` is always a JSON *string*, matching the OpenAI API.
|
||||
*
|
||||
* `idSeed` makes generated ids deterministic for callers that need stability; when
|
||||
* omitted, ids are still unique within a single call (index-based).
|
||||
@@ -425,37 +393,50 @@ export function parseToolCallsFromText(
|
||||
requestedTools?: unknown
|
||||
): { content: string; toolCalls: OpenAIToolCall[] | null } {
|
||||
const requestedToolNames = getRequestedToolNames(requestedTools);
|
||||
const canParseBareJson = requestedToolNames.length > 0;
|
||||
if (
|
||||
typeof text !== "string" ||
|
||||
(!text.includes("<tool>") && !text.includes("<tool_call"))
|
||||
(!text.includes("<tool>") && !text.includes("<tool_call") && !canParseBareJson)
|
||||
) {
|
||||
return { content: text ?? "", toolCalls: null };
|
||||
}
|
||||
|
||||
const nonce = getToolNonce(requestedTools);
|
||||
const candidates: ToolParseCandidate[] = [];
|
||||
const toolBlockRanges: Array<{ start: number; end: number }> = [];
|
||||
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
TOOL_BLOCK_RE.lastIndex = 0;
|
||||
while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) {
|
||||
const range = { start: blockMatch.index, end: TOOL_BLOCK_RE.lastIndex };
|
||||
toolBlockRanges.push(range);
|
||||
candidates.push({
|
||||
raw: blockMatch[1].trim(),
|
||||
start: blockMatch.index,
|
||||
end: TOOL_BLOCK_RE.lastIndex,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
requireRequestedTool: false,
|
||||
});
|
||||
}
|
||||
|
||||
TOOL_CALL_TAG_RE.lastIndex = 0;
|
||||
while ((blockMatch = TOOL_CALL_TAG_RE.exec(text)) !== null) {
|
||||
const range = { start: blockMatch.index, end: TOOL_CALL_TAG_RE.lastIndex };
|
||||
toolBlockRanges.push(range);
|
||||
candidates.push({
|
||||
raw: blockMatch[1].trim(),
|
||||
start: blockMatch.index,
|
||||
end: TOOL_CALL_TAG_RE.lastIndex,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
requireRequestedTool: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (canParseBareJson) {
|
||||
for (const candidate of findBareJsonCandidates(text)) {
|
||||
if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.start - b.start);
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
@@ -469,14 +450,6 @@ export function parseToolCallsFromText(
|
||||
? parsed.command
|
||||
: null;
|
||||
if (!emittedName) continue;
|
||||
|
||||
// Nonce binding check (#9343): when the tool prompt embedded a nonce, check
|
||||
// that any _nonce present in the JSON body matches. A wrong nonce (present but
|
||||
// does not match) means this is a copy-attack or hallucination — treat it as text
|
||||
// instead of executing it. A missing _nonce is tolerated for backward compatibility
|
||||
// with models that do not (yet) follow the nonce instruction.
|
||||
if (nonce && parsed && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
|
||||
|
||||
const name =
|
||||
resolveRequestedToolName(emittedName, requestedToolNames) ||
|
||||
(candidate.requireRequestedTool ? null : emittedName);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CORS_HEADERS } from "./cors.ts";
|
||||
import { getReadableReasoningValue } from "./reasoningFields.ts";
|
||||
|
||||
type PendingToolCall = {
|
||||
id?: string;
|
||||
@@ -39,7 +38,6 @@ export function transformToOllama(response, model) {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta || {};
|
||||
const content = delta.content || "";
|
||||
const thinking = getReadableReasoningValue(delta);
|
||||
const toolCalls = delta.tool_calls;
|
||||
|
||||
if (toolCalls) {
|
||||
@@ -49,11 +47,7 @@ export function transformToOllama(response, model) {
|
||||
const toolCallId = tc.id != null ? String(tc.id) : tc.id;
|
||||
|
||||
// T37: Prevent merging tool_calls on same index if ID changes
|
||||
if (
|
||||
pendingToolCalls[idx] &&
|
||||
toolCallId &&
|
||||
pendingToolCalls[idx].id !== toolCallId
|
||||
) {
|
||||
if (pendingToolCalls[idx] && toolCallId && pendingToolCalls[idx].id !== toolCallId) {
|
||||
completedToolCalls.push(pendingToolCalls[idx]);
|
||||
delete pendingToolCalls[idx];
|
||||
}
|
||||
@@ -70,16 +64,6 @@ export function transformToOllama(response, model) {
|
||||
}
|
||||
}
|
||||
|
||||
if (thinking) {
|
||||
const ollama =
|
||||
JSON.stringify({
|
||||
model,
|
||||
message: { role: "assistant", content: "", thinking },
|
||||
done: false,
|
||||
}) + "\n";
|
||||
controller.enqueue(new TextEncoder().encode(ollama));
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const ollama =
|
||||
JSON.stringify({ model, message: { role: "assistant", content }, done: false }) +
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
||||
|
||||
type StreamReadinessLogger = {
|
||||
debug?: (tag: string, message: string) => void;
|
||||
@@ -8,18 +7,7 @@ type StreamReadinessLogger = {
|
||||
|
||||
export type StreamReadinessResult =
|
||||
| { ok: true; response: Response }
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
/** Sanitized operator-facing context for logs and persisted diagnostics. */
|
||||
reason: string;
|
||||
/** Stable internal text for retry, quota, and account-health classification. */
|
||||
classificationReason: string;
|
||||
/** First non-empty sanitized message from an error-only SSE payload. */
|
||||
upstreamDiagnostic?: string;
|
||||
code: string;
|
||||
type: string;
|
||||
};
|
||||
| { ok: false; response: Response; reason: string; code: string; type: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -245,7 +233,6 @@ type StreamReadinessSignalState = {
|
||||
currentEvent: string;
|
||||
dataLines: string[];
|
||||
pendingLine: string;
|
||||
upstreamDiagnostic: string | null;
|
||||
};
|
||||
|
||||
function resetCurrentEvent(state: StreamReadinessSignalState): void {
|
||||
@@ -261,23 +248,7 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean
|
||||
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
|
||||
|
||||
try {
|
||||
const payload: unknown = JSON.parse(data);
|
||||
if (
|
||||
!state.upstreamDiagnostic &&
|
||||
isRecord(payload) &&
|
||||
isErrorOnlyStructuredPayload(payload)
|
||||
) {
|
||||
const error = payload.error;
|
||||
const rawMessage =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: isRecord(error) && typeof error.message === "string"
|
||||
? error.message
|
||||
: "";
|
||||
const diagnostic = sanitizeErrorMessage(rawMessage).trim();
|
||||
if (diagnostic) state.upstreamDiagnostic = diagnostic;
|
||||
}
|
||||
return hasNonPingStructuredPayload(payload, eventType);
|
||||
return hasNonPingStructuredPayload(JSON.parse(data), eventType);
|
||||
} catch {
|
||||
return data.length > 0;
|
||||
}
|
||||
@@ -323,7 +294,6 @@ export function hasStreamReadinessSignal(text: string): boolean {
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
if (appendStreamReadinessSignal(state, text)) return true;
|
||||
return finishStreamReadinessSignal(state);
|
||||
@@ -333,18 +303,16 @@ function createErrorResponse(
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
type: string,
|
||||
upstreamDiagnostic?: string
|
||||
type: string
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
status,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined,
|
||||
{ code, type }
|
||||
)
|
||||
),
|
||||
type,
|
||||
code,
|
||||
},
|
||||
}),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
@@ -417,7 +385,6 @@ export async function ensureStreamReadiness(
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||
@@ -447,7 +414,6 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -472,7 +438,6 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -495,11 +460,7 @@ export async function ensureStreamReadiness(
|
||||
return { ok: true, response: buildReadyResponse() };
|
||||
}
|
||||
|
||||
const classificationReason = "Stream ended before producing a non-ping SSE event";
|
||||
const upstreamDiagnostic = readinessState.upstreamDiagnostic || undefined;
|
||||
const reason = upstreamDiagnostic
|
||||
? `${classificationReason}: ${upstreamDiagnostic}`
|
||||
: classificationReason;
|
||||
const reason = "Stream ended before producing a non-ping SSE event";
|
||||
options.log?.warn?.(
|
||||
"STREAM",
|
||||
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
|
||||
@@ -507,16 +468,13 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason,
|
||||
...(upstreamDiagnostic ? { upstreamDiagnostic } : {}),
|
||||
code: "STREAM_EARLY_EOF",
|
||||
type: "stream_early_eof",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
classificationReason,
|
||||
reason,
|
||||
"STREAM_EARLY_EOF",
|
||||
"stream_early_eof",
|
||||
upstreamDiagnostic
|
||||
"stream_early_eof"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
400
package-lock.json
generated
400
package-lock.json
generated
@@ -103,7 +103,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "*",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -5894,6 +5894,29 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
@@ -6087,6 +6110,29 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -10042,6 +10088,29 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stryker-mutator/core/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@stryker-mutator/core/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@stryker-mutator/core/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -11233,6 +11302,29 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -12041,6 +12133,29 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -13687,14 +13802,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
@@ -14044,16 +14156,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -18402,6 +18512,29 @@
|
||||
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-sonarjs/node_modules/globals": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
||||
@@ -19048,9 +19181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -20148,6 +20281,29 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -20960,9 +21116,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"version": "4.12.31",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
|
||||
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -21651,6 +21807,29 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -22524,9 +22703,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -23626,9 +23805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -23902,6 +24081,29 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/junit-to-ctrf/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/junit-to-ctrf/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/junit-to-ctrf/node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
@@ -24482,18 +24684,10 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -26973,24 +27167,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -28096,9 +28272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -30412,6 +30588,29 @@
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -30667,9 +30866,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -30712,9 +30911,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -32065,17 +32264,10 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -33098,9 +33290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -34149,9 +34341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"devOptional": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -34242,6 +34434,29 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -34772,6 +34987,29 @@
|
||||
"typescript": "2 || 3 || 4 || 5"
|
||||
}
|
||||
},
|
||||
"node_modules/type-coverage-core/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/type-coverage-core/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/type-coverage-core/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
|
||||
19
package.json
19
package.json
@@ -403,25 +403,25 @@
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"sharp": "^0.35.0",
|
||||
"postcss": "^8.5.18",
|
||||
"ip-address": "^10.3.1",
|
||||
"ip-address": "10.2.0",
|
||||
"qs": "^6.15.2",
|
||||
"uuid": "^14.0.0",
|
||||
"form-data": "^4.0.6",
|
||||
"vite": "^8.0.16",
|
||||
"protobufjs": "^7.6.5",
|
||||
"protobufjs": "^7.6.3",
|
||||
"@babel/core": "^7.29.6",
|
||||
"hono": "^4.12.34",
|
||||
"hono": "^4.12.27",
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
"fast-uri": "^3.1.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"@yarnpkg/parsers": {
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"jsdom": {
|
||||
"undici": "^7.29.0"
|
||||
"undici": "^7.28.0"
|
||||
},
|
||||
"node-gyp": {
|
||||
"undici": "^6.28.0"
|
||||
"undici": "^6.27.0"
|
||||
},
|
||||
"concurrently": {
|
||||
"shell-quote": "^1.9.0"
|
||||
@@ -431,10 +431,7 @@
|
||||
"js-yaml": "^5.2.2",
|
||||
"@apidevtools/json-schema-ref-parser": {
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"undici": "^7.29.0"
|
||||
},
|
||||
"socket.io-parser": "^4.2.7",
|
||||
"tar": "^7.5.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,11 +214,6 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
src: ["node_modules", "undici"],
|
||||
dest: ["node_modules", "undici"],
|
||||
},
|
||||
{
|
||||
label: "sql.js WASM fallback runtime",
|
||||
src: ["node_modules", "sql.js"],
|
||||
dest: ["node_modules", "sql.js"],
|
||||
},
|
||||
{
|
||||
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
|
||||
src: ["node_modules", "sqlite-vec"],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Two tiers of checks:
|
||||
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
|
||||
// that historically caused the worst drift across user-facing documentation.
|
||||
// that historically caused the worst drift across README / AGENTS / docs.
|
||||
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
|
||||
// which is auto-generated from src/shared/constants/providers.ts)
|
||||
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
||||
@@ -259,14 +259,14 @@ export function buildChecks() {
|
||||
actual: readProviderTotal(),
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "CLAUDE.md"],
|
||||
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
|
||||
},
|
||||
{
|
||||
label: "i18n locales count",
|
||||
actual: countLocales(),
|
||||
docKey: "i18n locales",
|
||||
strict: true,
|
||||
files: ["docs/README.md", "docs/guides/I18N.md"],
|
||||
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
|
||||
},
|
||||
...(() => {
|
||||
const f = readCodeFacts();
|
||||
@@ -317,10 +317,19 @@ export function buildChecks() {
|
||||
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
|
||||
skipAfter: /^\s*\(\d+ CLI/,
|
||||
},
|
||||
["README.md", "CLAUDE.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, [
|
||||
"README.md",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
]),
|
||||
claim(
|
||||
f.cliTotal,
|
||||
"CLI tools",
|
||||
{ pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi },
|
||||
["README.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "CLAUDE.md"]),
|
||||
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
|
||||
];
|
||||
})(),
|
||||
{
|
||||
|
||||
@@ -20,13 +20,6 @@ import path from "node:path";
|
||||
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const BOOT_DEADLINE_MS = 240_000;
|
||||
const SQLJS_STARTUP_MARKER = "Pre-initializing sql.js WASM";
|
||||
|
||||
export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([
|
||||
"dist/node_modules/sql.js/package.json",
|
||||
"dist/node_modules/sql.js/dist/sql-wasm.js",
|
||||
"dist/node_modules/sql.js/dist/sql-wasm.wasm",
|
||||
]);
|
||||
|
||||
/** Parse `npm pack --json` output into the generated tarball filename. */
|
||||
export function pickTarball(packJsonOutput) {
|
||||
@@ -56,278 +49,20 @@ export function pickPort(seed = process.pid) {
|
||||
return 23000 + (seed % 4000);
|
||||
}
|
||||
|
||||
export function findMissingSqlJsRuntimeFiles(packageRoot, exists = fs.existsSync) {
|
||||
return REQUIRED_SQLJS_RUNTIME_FILES.filter(
|
||||
(relativePath) => !exists(path.join(packageRoot, relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateSqlJsRoundTrip({
|
||||
startupOutput,
|
||||
beforeValue,
|
||||
patchedValue,
|
||||
readBackValue,
|
||||
}) {
|
||||
const failures = [];
|
||||
if (!startupOutput.includes(SQLJS_STARTUP_MARKER)) {
|
||||
failures.push("server output did not confirm the forced sql.js startup path");
|
||||
}
|
||||
if (patchedValue !== !beforeValue) {
|
||||
failures.push(
|
||||
`PATCH debugMode returned ${String(patchedValue)} (expected ${String(!beforeValue)})`
|
||||
);
|
||||
}
|
||||
if (readBackValue !== !beforeValue) {
|
||||
failures.push(
|
||||
`GET debugMode returned ${String(readBackValue)} (expected ${String(!beforeValue)})`
|
||||
);
|
||||
}
|
||||
return { ok: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
/**
|
||||
* After a clean shutdown + restart with the same DATA_DIR, the value written in boot #1
|
||||
* must be read back from disk in boot #2. sql.js is in-memory with debounced/flush writes,
|
||||
* so this proves the persisted file actually landed and the restart reads it.
|
||||
*/
|
||||
export function evaluateRestartPersistence({ expectedValue, restartValue }) {
|
||||
const failures = [];
|
||||
if (restartValue !== expectedValue) {
|
||||
failures.push(
|
||||
`restart GET debugMode returned ${String(restartValue)} (expected ${String(expectedValue)} after restart)`
|
||||
);
|
||||
}
|
||||
return { ok: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
async function readJsonResponse(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const body = await response.json().catch(() => null);
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function verifySettingsRoundTrip(baseUrl, startupOutput) {
|
||||
const initial = await readJsonResponse(`${baseUrl}/api/settings`);
|
||||
if (initial.response.status !== 200 || !initial.body || typeof initial.body !== "object") {
|
||||
return {
|
||||
ok: false,
|
||||
failures: [`initial settings HTTP ${initial.response.status} or non-JSON body`],
|
||||
};
|
||||
}
|
||||
|
||||
const beforeValue = initial.body.debugMode === true;
|
||||
const expectedValue = !beforeValue;
|
||||
const patched = await readJsonResponse(`${baseUrl}/api/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ debugMode: expectedValue }),
|
||||
});
|
||||
if (patched.response.status !== 200 || !patched.body || typeof patched.body !== "object") {
|
||||
return {
|
||||
ok: false,
|
||||
failures: [`settings PATCH HTTP ${patched.response.status} or non-JSON body`],
|
||||
};
|
||||
}
|
||||
|
||||
const readBack = await readJsonResponse(`${baseUrl}/api/settings`);
|
||||
if (readBack.response.status !== 200 || !readBack.body || typeof readBack.body !== "object") {
|
||||
return {
|
||||
ok: false,
|
||||
failures: [`settings read-back HTTP ${readBack.response.status} or non-JSON body`],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...evaluateSqlJsRoundTrip({
|
||||
startupOutput,
|
||||
beforeValue,
|
||||
patchedValue: patched.body.debugMode,
|
||||
readBackValue: readBack.body.debugMode,
|
||||
}),
|
||||
// The exact value boot #2 must read back from disk to prove persistence.
|
||||
expectedValue,
|
||||
};
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[pack-boot] ${msg}`);
|
||||
}
|
||||
|
||||
/** Node sets exitCode/signalCode synchronously when the process dies — authoritative. */
|
||||
function hasExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGTERM the process GROUP and wait for its REAL exit — the graceful-shutdown handler
|
||||
* (initGracefulShutdown) drains requests, checkpoints the DB via closeDbInstance(), then
|
||||
* calls process.exit(0). A fixed sleep + hard kill could SIGKILL mid-flush and silently
|
||||
* drop the very persistence this gate proves, so SIGKILL is a last resort after the grace
|
||||
* deadline, and a CONFIRMED exit is required before returning: if even SIGKILL fails to
|
||||
* reap, throw, so boot #2 cannot start against a port a zombie still holds.
|
||||
*
|
||||
* The child is spawned with detached:true, so it leads its own process group and
|
||||
* -child.pid signals the whole tree, not just the launcher.
|
||||
*/
|
||||
async function stopChild(child, graceMs = 30_000) {
|
||||
if (!child?.pid) return;
|
||||
// Fast path: already reaped (crashed mid-smoke, or exited before this call) — nothing
|
||||
// left to signal or wait for.
|
||||
if (hasExited(child)) return;
|
||||
|
||||
let onSettled;
|
||||
const exited = new Promise((resolve) => {
|
||||
onSettled = () => resolve();
|
||||
child.once("exit", onSettled);
|
||||
child.once("close", onSettled);
|
||||
});
|
||||
// Race the exit/close promise against a timeout; then re-read authoritative state, so a
|
||||
// same-tick exit that lost the race still counts. Timer is always cleared.
|
||||
const waitForExit = (ms) => {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
exited,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, ms);
|
||||
}),
|
||||
])
|
||||
.finally(() => clearTimeout(timer))
|
||||
.then(() => hasExited(child));
|
||||
};
|
||||
|
||||
try {
|
||||
// Re-check AFTER attaching: if the process died in the gap between the fast path and
|
||||
// listener attach, once("exit") can never fire (event already emitted), and without
|
||||
// this waitForExit would burn the full grace window.
|
||||
if (hasExited(child)) return;
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* group already gone */
|
||||
}
|
||||
if (await waitForExit(graceMs)) return;
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
/* group already gone */
|
||||
}
|
||||
if (!(await waitForExit(5_000))) {
|
||||
throw new Error(
|
||||
`[pack-boot] server process group ${child.pid} still alive 5s after SIGKILL — ` +
|
||||
"refusing to reboot on the same port"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
child.removeListener("exit", onSettled);
|
||||
child.removeListener("close", onSettled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the installed CLI once on an isolated DATA_DIR. The child is spawned detached:true
|
||||
* so it leads its own process group — stopChild() relies on that to SIGTERM the whole tree.
|
||||
* The caller owns shutdown so the graceful DB flush lands before teardown.
|
||||
*/
|
||||
function spawnServer(binPath, port, dataDir) {
|
||||
const child = spawn(binPath, ["serve", "--port", String(port), "--log", "--no-open"], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000",
|
||||
API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
|
||||
OMNIROUTE_PACK_BOOT_SMOKE: "1",
|
||||
OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
});
|
||||
const tail = [];
|
||||
const keepTail = (chunk) => {
|
||||
tail.push(String(chunk));
|
||||
while (tail.length > 80) tail.shift();
|
||||
};
|
||||
child.stdout.on("data", keepTail);
|
||||
child.stderr.on("data", keepTail);
|
||||
return { child, tail };
|
||||
}
|
||||
|
||||
/** Poll /api/monitoring/health until the packed version answers or the boot deadline passes. */
|
||||
async function waitForHealthy(port, child, expectedVersion) {
|
||||
// Seed from authoritative state (Node sets these synchronously at death), then attach a
|
||||
// named once-listener, then re-check: a child that died before this call, or in the gap
|
||||
// before the listener attached, would otherwise never fire "exit" and waste the deadline.
|
||||
const exitDescriptor = (code, signal) => (signal ? `signal ${signal}` : `code ${code ?? -1}`);
|
||||
let childExit = hasExited(child) ? exitDescriptor(child.exitCode, child.signalCode) : null;
|
||||
const onChildExit = (code, signal) => {
|
||||
childExit = exitDescriptor(code, signal);
|
||||
};
|
||||
child.once("exit", onChildExit);
|
||||
if (hasExited(child)) {
|
||||
childExit = exitDescriptor(child.exitCode, child.signalCode);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + BOOT_DEADLINE_MS;
|
||||
let verdict = { ok: false, failures: ["never polled"] };
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
if (childExit !== null) {
|
||||
return { ok: false, failures: [`process exited (${childExit}) before serving`] };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
|
||||
const body = await res.json().catch(() => null);
|
||||
verdict = evaluateBoot(res.status, body, expectedVersion);
|
||||
if (verdict.ok) return verdict;
|
||||
} catch {
|
||||
// not listening yet — keep polling
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
return verdict;
|
||||
} finally {
|
||||
child.removeListener("exit", onChildExit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current debugMode setting and return the EXACT boolean. A missing or non-boolean
|
||||
* field throws: coercing with `=== true` would read `false` for a malformed response and
|
||||
* could falsely "pass" persistence whenever the expected value happens to be false.
|
||||
*/
|
||||
async function readSettingsDebugMode(baseUrl) {
|
||||
const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`);
|
||||
if (response.status !== 200 || !body || typeof body !== "object") {
|
||||
throw new Error(`settings GET HTTP ${response.status} or non-JSON body`);
|
||||
}
|
||||
if (typeof body.debugMode !== "boolean") {
|
||||
throw new Error(`settings debugMode is ${typeof body.debugMode} (expected boolean)`);
|
||||
}
|
||||
return body.debugMode;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ROOT = process.cwd();
|
||||
if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) {
|
||||
console.error(
|
||||
"[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)"
|
||||
);
|
||||
console.error("[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)");
|
||||
process.exit(2);
|
||||
}
|
||||
const expectedVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, "package.json"), "utf8")
|
||||
).version;
|
||||
const expectedVersion = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pack-boot-"));
|
||||
let child = null;
|
||||
let tail = [];
|
||||
let exitCode = 1;
|
||||
let primaryError = null; // a smoke-logic failure: boot/PATCH/GET/restart, or an in-flow stop
|
||||
let cleanupError = null; // recorded ONLY in finally, ONLY for a final stopChild failure
|
||||
let shutdownConfirmed = false; // process group confirmed stopped → safe to rm the workspace
|
||||
try {
|
||||
log(`packing v${expectedVersion}…`);
|
||||
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
|
||||
@@ -342,116 +77,87 @@ async function main() {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
const packageRoot = path.join(prefix, "lib", "node_modules", "omniroute");
|
||||
const missingSqlJsFiles = findMissingSqlJsRuntimeFiles(packageRoot);
|
||||
if (missingSqlJsFiles.length > 0) {
|
||||
throw new Error(
|
||||
`installed package is missing the sql.js runtime contract: ${missingSqlJsFiles.join(", ")}`
|
||||
);
|
||||
}
|
||||
log("installed package contains the complete sql.js WASM runtime");
|
||||
|
||||
const port = pickPort();
|
||||
const dataDir = path.join(tmp, "data");
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const binPath = path.join(prefix, "bin", "omniroute");
|
||||
log(`booting installed CLI on :${port} (DATA_DIR isolated)…`);
|
||||
child = spawn(binPath, ["serve", "--port", String(port)], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000",
|
||||
API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
});
|
||||
const tail = [];
|
||||
const keepTail = (chunk) => {
|
||||
tail.push(String(chunk));
|
||||
while (tail.length > 80) tail.shift();
|
||||
};
|
||||
child.stdout.on("data", keepTail);
|
||||
child.stderr.on("data", keepTail);
|
||||
let childExit = null;
|
||||
child.on("exit", (code) => {
|
||||
childExit = code ?? -1;
|
||||
});
|
||||
|
||||
// BOOT #1 — boot, prove the forced sql.js tier, PATCH a setting, then shut down cleanly
|
||||
// so the sql.js adapter's graceful persist actually lands on disk. The in-flow stopChild
|
||||
// THROWS on failure; that lands in catch as primaryError and boot #2 never starts.
|
||||
log(`boot #1: installed CLI on :${port} (DATA_DIR isolated)…`);
|
||||
({ child, tail } = spawnServer(binPath, port, dataDir));
|
||||
let verdict = await waitForHealthy(port, child, expectedVersion);
|
||||
if (verdict.ok) {
|
||||
log(`healthy: HTTP 200, version ${expectedVersion}`);
|
||||
const roundTrip = await verifySettingsRoundTrip(`http://127.0.0.1:${port}`, tail.join(""));
|
||||
if (roundTrip.ok) {
|
||||
log("settings write/read succeeded through the forced sql.js driver");
|
||||
await stopChild(child); // throws here → primaryError; boot #2 is skipped
|
||||
child = null;
|
||||
|
||||
// BOOT #2 — same DATA_DIR, fresh process: the value must be read back FROM DISK.
|
||||
log("boot #2: rebooting on the same DATA_DIR to prove disk persistence…");
|
||||
({ child, tail } = spawnServer(binPath, port, dataDir));
|
||||
verdict = await waitForHealthy(port, child, expectedVersion);
|
||||
if (verdict.ok) {
|
||||
log(`healthy: HTTP 200, version ${expectedVersion}`);
|
||||
const restartValue = await readSettingsDebugMode(`http://127.0.0.1:${port}`);
|
||||
const persistence = evaluateRestartPersistence({
|
||||
expectedValue: roundTrip.expectedValue,
|
||||
restartValue,
|
||||
});
|
||||
if (persistence.ok) {
|
||||
log("value survived a clean shutdown + restart — disk persistence proven");
|
||||
await stopChild(child); // throws here → primaryError
|
||||
child = null;
|
||||
exitCode = 0;
|
||||
} else {
|
||||
verdict = persistence;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
verdict = roundTrip;
|
||||
const deadline = Date.now() + BOOT_DEADLINE_MS;
|
||||
let verdict = { ok: false, failures: ["never polled"] };
|
||||
while (Date.now() < deadline) {
|
||||
if (childExit !== null) {
|
||||
verdict = { ok: false, failures: [`process exited with code ${childExit} before serving`] };
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
|
||||
const body = await res.json().catch(() => null);
|
||||
verdict = evaluateBoot(res.status, body, expectedVersion);
|
||||
if (verdict.ok) {
|
||||
log(`healthy: HTTP 200, version ${body.version}, status "${body.status}"`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// not listening yet — keep polling
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
if (!verdict.ok) {
|
||||
primaryError = new Error(verdict.failures.join("; "));
|
||||
|
||||
if (verdict.ok) {
|
||||
log("✅ the packed tarball boots — #7065 class gate green");
|
||||
exitCode = 0;
|
||||
} else {
|
||||
console.error(`[pack-boot] ❌ boot FAILED: ${verdict.failures.join("; ")}`);
|
||||
console.error("[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n"));
|
||||
exitCode = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// Every smoke-logic failure — boot/PATCH/GET/restart AND in-flow stopChild throws.
|
||||
primaryError = e;
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
// Tear down whatever is still running. This block records ONLY a stopChild failure,
|
||||
// and never overwrites primaryError.
|
||||
if (child) {
|
||||
if (child?.pid) {
|
||||
try {
|
||||
await stopChild(child);
|
||||
shutdownConfirmed = true;
|
||||
} catch (e) {
|
||||
cleanupError = e; // still !shutdownConfirmed → workspace preserved below
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
child = null;
|
||||
} else {
|
||||
// Stopped in-flow (already confirmed) or never spawned — nothing left to confirm.
|
||||
shutdownConfirmed = true;
|
||||
}
|
||||
// Remove the workspace ONLY after confirmed shutdown; a process group that refused to
|
||||
// die keeps its DATA_DIR for diagnosis.
|
||||
if (shutdownConfirmed) {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Report primaryError as the smoke failure; report cleanupError separately. Either one
|
||||
// fails the gate.
|
||||
if (primaryError) {
|
||||
console.error(`[pack-boot] ❌ smoke FAILED: ${primaryError.message}`);
|
||||
if (tail.length) {
|
||||
console.error(
|
||||
"[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cleanupError) {
|
||||
console.error(`[pack-boot] ❌ final shutdown FAILED: ${cleanupError.message}`);
|
||||
exitCode = 1;
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
log("✅ the packed tarball boots AND persists — #7065 class gate green");
|
||||
}
|
||||
if (!shutdownConfirmed) {
|
||||
console.error(
|
||||
`[pack-boot] ⚠ process group not confirmed stopped — workspace preserved for diagnosis: ${tmp}`
|
||||
);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
||||
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch((e) => {
|
||||
console.error("[pack-boot] fatal:", e.message);
|
||||
|
||||
@@ -27,22 +27,17 @@ export default function BootstrapBanner() {
|
||||
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-300">
|
||||
Running in zero-config mode
|
||||
{t("zeroConfigBannerTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
|
||||
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
{dataDir}
|
||||
</code>
|
||||
. No action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
JWT_SECRET
|
||||
</code>{" "}
|
||||
and{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
STORAGE_ENCRYPTION_KEY
|
||||
</code>{" "}
|
||||
to that file.
|
||||
{t.rich("zeroConfigBannerBody", {
|
||||
dataDir,
|
||||
code: (chunks) => (
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
{chunks}
|
||||
</code>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const cleanLatest = latest.replace(/^v/, "");
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
label: "Download DMG (macOS)",
|
||||
label: t("downloadDmg"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
label: "Download EXE (Windows)",
|
||||
label: t("downloadExe"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return {
|
||||
label: "Download AppImage (Linux)",
|
||||
label: t("downloadAppImage"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Download Update",
|
||||
label: t("downloadUpdate"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}, [platform, versionInfo?.latest, versionInfo?.current]);
|
||||
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
||||
|
||||
// Electron internal auto-updater state and listeners
|
||||
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
||||
@@ -539,29 +539,29 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{
|
||||
step: "install",
|
||||
status: "done",
|
||||
message: message || `Queued update to v${targetVersion}.`,
|
||||
message: message || t("updateQueued", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker image is rebuilding in the background.",
|
||||
message: t("updateDockerRebuilding"),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: message || `Installing v${targetVersion}.`,
|
||||
message: message || t("updateInstalling", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "complete",
|
||||
status: "done",
|
||||
message: `OmniRoute is now running v${targetVersion}.`,
|
||||
message: t("updateRunning", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
setUpdating(false);
|
||||
setUpdatePhase("done");
|
||||
notify.success(`OmniRoute updated to v${targetVersion}.`);
|
||||
notify.success(t("updateCompleted", { version: targetVersion }));
|
||||
await fetchData();
|
||||
return;
|
||||
}
|
||||
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: `Docker image is still rebuilding for v${targetVersion}.`,
|
||||
message: t("updateDockerStillRebuilding", { version: targetVersion }),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
|
||||
message: t("updateWaitingVersion", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker rebuild is still in progress.",
|
||||
message: t("updateDockerStillInProgress"),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "running",
|
||||
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
|
||||
message: t("updateRestarting"),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
mergeUpdateStep(prev, {
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
|
||||
message: t("updateTimeout", { version: targetVersion }),
|
||||
})
|
||||
);
|
||||
setUpdating(false);
|
||||
setUpdatePhase("failed");
|
||||
notify.error(`Update to v${targetVersion} timed out.`);
|
||||
notify.error(t("updateTimedOut", { version: targetVersion }));
|
||||
},
|
||||
[fetchData]
|
||||
[fetchData, t]
|
||||
);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
@@ -689,12 +689,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
// Passing the raw object to notify.error() rendered it as a React child →
|
||||
// "Minified React error #31" crash ("Internal Server Error" screen), e.g. on
|
||||
// the 403 from the loopback-only /api/system/version. Extract the string.
|
||||
notify.error(extractApiErrorMessage(data, "Failed to start update."));
|
||||
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
return;
|
||||
}
|
||||
notify.success(data.message || "Update started.");
|
||||
notify.success(data.message || t("updateStarted"));
|
||||
await pollBackgroundUpdate({
|
||||
channel: data.channel || "docker-compose",
|
||||
message: data.message || "",
|
||||
@@ -705,7 +705,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
|
||||
// SSE stream — read progress events
|
||||
if (!res.body) {
|
||||
notify.error("No response stream received.");
|
||||
notify.error(t("noResponseStream"));
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
return;
|
||||
@@ -735,10 +735,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
if (event.step === "complete") {
|
||||
setUpdatePhase("done");
|
||||
setUpdating(false);
|
||||
notify.success(event.message || "Update complete!");
|
||||
notify.success(event.message || t("updateComplete"));
|
||||
} else if (event.step === "error") {
|
||||
setUpdatePhase("failed");
|
||||
notify.error(event.message || "Update failed.");
|
||||
notify.error(event.message || t("updateFailed"));
|
||||
setUpdating(false);
|
||||
}
|
||||
} catch {
|
||||
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: "Network error — connection lost during update.",
|
||||
message: t("updateNetworkError"),
|
||||
},
|
||||
]);
|
||||
setUpdating(false);
|
||||
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [updatePhase]);
|
||||
const stepLabels: Record<string, string> = {
|
||||
install: "Install Package",
|
||||
rebuild: "Rebuild Native Modules",
|
||||
restart: "Restart Service",
|
||||
complete: "Complete",
|
||||
error: "Error",
|
||||
install: t("stepInstallPackage"),
|
||||
rebuild: t("stepRebuildNativeModules"),
|
||||
restart: t("stepRestartService"),
|
||||
complete: t("stepComplete"),
|
||||
error: t("stepError"),
|
||||
};
|
||||
const showUpdateOverlay = updatePhase !== "idle";
|
||||
|
||||
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{updatePhase === "done"
|
||||
? "Update Complete!"
|
||||
? t("updateCompleteTitle")
|
||||
: updatePhase === "failed"
|
||||
? "Update Failed"
|
||||
: "Updating OmniRoute..."}
|
||||
? t("updateFailedTitle")
|
||||
: t("updatingTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{updatePhase === "done"
|
||||
? "The page will reload automatically in a few seconds."
|
||||
? t("reloadNotice")
|
||||
: updatePhase === "failed"
|
||||
? "Please try again or update manually via the CLI."
|
||||
: "Do not close this page. The system will restart automatically."}
|
||||
? t("retryNotice")
|
||||
: t("restartNotice")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,7 +871,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<div className="mt-1 px-3 py-2.5 rounded-lg border border-green-500/30 bg-green-500/5">
|
||||
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
||||
</div>
|
||||
@@ -891,11 +891,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
if (updatePhase === "done") globalThis.window.location.reload();
|
||||
}}
|
||||
>
|
||||
{updatePhase === "done" ? "Reload Now" : "Close"}
|
||||
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
|
||||
</Button>
|
||||
{updatePhase === "failed" && (
|
||||
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
||||
Retry
|
||||
{t("retryUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -917,30 +917,32 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">
|
||||
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
|
||||
{t("updateAvailableTitle", {
|
||||
version: versionInfo.latest,
|
||||
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs opacity-80 mt-0.5">
|
||||
{isElectron ? (
|
||||
<>
|
||||
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
|
||||
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
|
||||
{electronUpdateStatus.status === "available" &&
|
||||
`Version v${versionInfo.latest} is available for download.`}
|
||||
t("versionAvailableForDownload", { version: versionInfo.latest })}
|
||||
{electronUpdateStatus.status === "downloading" &&
|
||||
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
|
||||
{electronUpdateStatus.status === "downloaded" &&
|
||||
"Update downloaded successfully! Click Restart & Install to apply."}
|
||||
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
|
||||
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
|
||||
{electronUpdateStatus.status === "error" &&
|
||||
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
|
||||
t("autoUpdateFailed", {
|
||||
reason: electronUpdateStatus.message || t("unknownUpdateError"),
|
||||
})}
|
||||
{(electronUpdateStatus.status === "idle" ||
|
||||
electronUpdateStatus.status === "not-available") &&
|
||||
`Version v${versionInfo.latest} is available for the desktop app.`}
|
||||
t("versionAvailableDesktop", { version: versionInfo.latest })}
|
||||
</>
|
||||
) : versionInfo.autoUpdateSupported ? (
|
||||
t("updateAvailableDesc") ||
|
||||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||
t("updateAvailableDesc")
|
||||
) : (
|
||||
versionInfo.autoUpdateError ||
|
||||
"Manual update required for this installation type."
|
||||
versionInfo.autoUpdateError || t("manualUpdateRequired")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -954,7 +956,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||
className="font-semibold"
|
||||
>
|
||||
Download Update
|
||||
{t("downloadUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
{electronUpdateStatus.status === "downloading" && (
|
||||
@@ -973,7 +975,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
||||
className="font-semibold animate-pulse"
|
||||
>
|
||||
Restart & Install
|
||||
{t("restartAndInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{(electronUpdateStatus.status === "error" ||
|
||||
@@ -989,7 +991,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
Check for Update
|
||||
{t("checkForUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1001,9 +1003,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
className="ml-4 shrink-0 font-semibold"
|
||||
title={versionInfo.autoUpdateError || ""}
|
||||
>
|
||||
{versionInfo.autoUpdateSupported
|
||||
? t("updateNow") || "Update Now"
|
||||
: "Manual Update"}
|
||||
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1015,9 +1015,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
electronUpdateStatus.status === "available" ||
|
||||
electronUpdateStatus.status === "not-available") && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-t border-primary/20 mt-2 pt-3 gap-2">
|
||||
<p className="text-xs opacity-75">
|
||||
Or download the respective installer format directly:
|
||||
</p>
|
||||
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1029,7 +1027,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}
|
||||
className="font-semibold text-xs py-1"
|
||||
>
|
||||
Release Notes
|
||||
{t("releaseNotes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1067,7 +1065,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
{versionInfo.news.linkLabel || "Ler Mais"}
|
||||
{versionInfo.news.linkLabel || t("readMore")}
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -1214,7 +1212,7 @@ function ProviderOverviewCard({
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
|
||||
const authTypeConfig = {
|
||||
"no-auth": { color: "bg-stone-500", label: "No Auth" },
|
||||
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
|
||||
free: { color: "bg-green-500", label: tc("free") },
|
||||
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
|
||||
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
|
||||
|
||||
@@ -42,9 +42,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
if (!stats) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +108,9 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||
return (
|
||||
<div key={variant} className="flex items-center gap-3">
|
||||
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
|
||||
<div className="w-32 text-sm font-medium capitalize">
|
||||
{variant || t("defaultVariantLabel")}
|
||||
</div>
|
||||
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all"
|
||||
@@ -133,9 +133,9 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 font-medium">Provider</th>
|
||||
<th className="text-right py-2 px-3 font-medium">Requests</th>
|
||||
<th className="text-right py-2 px-3 font-medium">Share</th>
|
||||
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -50,11 +50,13 @@ function ProviderBar({
|
||||
count,
|
||||
total,
|
||||
costUsd,
|
||||
queriesLabel,
|
||||
}: {
|
||||
provider: string;
|
||||
count: number;
|
||||
total: number;
|
||||
costUsd: number;
|
||||
queriesLabel: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
@@ -62,7 +64,7 @@ function ProviderBar({
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-text">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{count} queries · ${costUsd.toFixed(4)}
|
||||
{count} {queriesLabel} · ${costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
||||
@@ -99,7 +101,7 @@ export default function SearchAnalyticsTab() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
Loading search analytics…
|
||||
{t("searchAnalyticsLoading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,9 +110,11 @@ export default function SearchAnalyticsTab() {
|
||||
return (
|
||||
<div className="card p-6 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
|
||||
{error || "No search data available yet."}
|
||||
{error || t("searchAnalyticsNoData")}
|
||||
<p className="text-xs mt-2">
|
||||
Search requests will appear here after the first search via /v1/search.
|
||||
{t.rich("searchAnalyticsNoDataDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -126,25 +130,29 @@ export default function SearchAnalyticsTab() {
|
||||
icon="manage_search"
|
||||
label={t("searchAnalyticsTotalSearches")}
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
sub={t("searchAnalyticsToday", { count: stats.today })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label={t("searchAnalyticsCacheHitRate")}
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label={t("searchAnalyticsTotalCost")}
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
sub={t("searchAnalyticsApiCosts")}
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label={t("searchAnalyticsAvgResponse")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
sub={
|
||||
stats.errors > 0
|
||||
? t("searchAnalyticsErrors", { count: stats.errors })
|
||||
: t("searchAnalyticsNoErrors")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +161,7 @@ export default function SearchAnalyticsTab() {
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
|
||||
Provider Breakdown
|
||||
{t("searchAnalyticsProviderBreakdown")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{providers.map(([prov, data]) => (
|
||||
@@ -163,6 +171,7 @@ export default function SearchAnalyticsTab() {
|
||||
count={data.count}
|
||||
total={stats.total}
|
||||
costUsd={data.costUsd}
|
||||
queriesLabel={t("searchAnalyticsQueries")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,8 +186,9 @@ export default function SearchAnalyticsTab() {
|
||||
</span>
|
||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
{t.rich("searchAnalyticsEmptyDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -189,8 +199,9 @@ export default function SearchAnalyticsTab() {
|
||||
check_circle
|
||||
</span>
|
||||
<span>
|
||||
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
|
||||
Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover.
|
||||
{t.rich("searchAnalyticsFreeTier", {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,23 +122,23 @@ export default function McpAuditTab() {
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
label: "Calls (24h)",
|
||||
label: t("mcpMetricCalls24h"),
|
||||
value: stats.totalCalls.toLocaleString(),
|
||||
icon: "terminal",
|
||||
},
|
||||
{
|
||||
label: "Success rate",
|
||||
label: t("mcpMetricSuccessRate"),
|
||||
value: `${Math.round(stats.successRate * 100)}%`,
|
||||
icon: "check_circle",
|
||||
highlight: stats.successRate >= 0.9,
|
||||
},
|
||||
{
|
||||
label: "Avg duration",
|
||||
label: t("mcpMetricAvgDuration"),
|
||||
value: `${Math.round(stats.avgDurationMs)}ms`,
|
||||
icon: "timer",
|
||||
},
|
||||
{
|
||||
label: "Top tool",
|
||||
label: t("mcpMetricTopTool"),
|
||||
value: stats.topTools[0]?.tool ?? "—",
|
||||
icon: "star",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useBatchActions } from "./components/useBatchActions";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type BatchTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: BatchTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const isFuture = diffMs < 0;
|
||||
const absDiffMs = Math.abs(diffMs);
|
||||
@@ -22,8 +24,9 @@ function relativeTime(ts: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (isFuture) return `in ${res}`;
|
||||
return `${res} ago`;
|
||||
return isFuture
|
||||
? t("batchRelativeTimeIn", { value: res })
|
||||
: t("batchRelativeTimeAgo", { value: res });
|
||||
}
|
||||
|
||||
interface BatchRecord {
|
||||
@@ -93,6 +96,22 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
expired_with_failures: "expired (partial)",
|
||||
};
|
||||
|
||||
const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
completed: "batchStatusCompleted",
|
||||
completed_with_failures: "batchStatusCompletedWithFailures",
|
||||
failed: "batchStatusFailed",
|
||||
in_progress: "batchStatusInProgress",
|
||||
in_progress_with_failures: "batchStatusInProgressWithFailures",
|
||||
finalizing: "batchStatusFinalizing",
|
||||
finalizing_with_failures: "batchStatusFinalizingWithFailures",
|
||||
validating: "batchStatusValidating",
|
||||
cancelling: "batchStatusCancelling",
|
||||
cancelled: "batchStatusCancelled",
|
||||
cancelled_with_failures: "batchStatusCancelledWithFailures",
|
||||
expired: "batchStatusExpired",
|
||||
expired_with_failures: "batchStatusExpiredWithFailures",
|
||||
};
|
||||
|
||||
function effectiveStatus(batch: BatchRecord): string {
|
||||
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
|
||||
if (!hasFailed) return batch.status;
|
||||
@@ -106,10 +125,12 @@ function effectiveStatus(batch: BatchRecord): string {
|
||||
return map[batch.status] ?? batch.status;
|
||||
}
|
||||
|
||||
function StatusBadge({ batch }: { batch: BatchRecord }) {
|
||||
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
|
||||
const key = effectiveStatus(batch);
|
||||
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
|
||||
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
|
||||
const label = STATUS_TRANSLATION_KEYS[key]
|
||||
? t(STATUS_TRANSLATION_KEYS[key])
|
||||
: (STATUS_LABELS[key] ?? key.replace(/_/g, " "));
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
|
||||
{label}
|
||||
@@ -141,12 +162,24 @@ function formatTs(ts: number | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
|
||||
export default function BatchDetailModal({
|
||||
batch,
|
||||
files,
|
||||
onClose,
|
||||
onActionDone,
|
||||
}: BatchDetailModalProps) {
|
||||
const t = useTranslations("common");
|
||||
|
||||
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
||||
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
|
||||
useBatchActions({ onRefresh: onActionDone, t });
|
||||
const {
|
||||
cancelling,
|
||||
retrying,
|
||||
error: actionError,
|
||||
cancel,
|
||||
retry,
|
||||
downloadHrefOutput,
|
||||
downloadHrefErrors,
|
||||
} = useBatchActions({ onRefresh: onActionDone, t });
|
||||
|
||||
// ── Status flags ──────────────────────────────────────────────────────────────
|
||||
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
|
||||
@@ -195,8 +228,11 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
pending_actions
|
||||
</span>
|
||||
<div>
|
||||
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
Batch Details
|
||||
<h2
|
||||
id="batch-detail-modal-title"
|
||||
className="text-base font-semibold text-[var(--color-text-main)]"
|
||||
>
|
||||
{t("batchDetailsTitle")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
|
||||
@@ -227,16 +263,18 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Status
|
||||
{t("status")}
|
||||
</span>
|
||||
<StatusBadge batch={batch} />
|
||||
<StatusBadge batch={batch} t={t} />
|
||||
</div>
|
||||
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
|
||||
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
|
||||
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
|
||||
<Field
|
||||
label={t("batchDetailCreated")}
|
||||
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
||||
value={
|
||||
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +283,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
|
||||
Progress
|
||||
{t("batchProgress")}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{completed} / {total} ({pct}%)
|
||||
@@ -262,17 +300,9 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||
<span>
|
||||
<span className="text-emerald-400 font-medium">{completed}</span> completed
|
||||
</span>
|
||||
{failed > 0 && (
|
||||
<span>
|
||||
<span className="text-red-400 font-medium">{failed}</span> failed
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<span className="font-medium">{total - completed - failed}</span> pending
|
||||
</span>
|
||||
<span>{t("batchCompletedCount", { count: completed })}</span>
|
||||
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
|
||||
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -280,19 +310,19 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Timeline
|
||||
{t("batchTimeline")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
{[
|
||||
{ label: "Created", ts: batch.createdAt },
|
||||
{ label: "In Progress", ts: batch.inProgressAt },
|
||||
{ label: "Finalizing", ts: batch.finalizingAt },
|
||||
{ label: "Completed", ts: batch.completedAt },
|
||||
{ label: "Failed", ts: batch.failedAt },
|
||||
{ label: "Expires", ts: batch.expiresAt },
|
||||
{ label: "Expired", ts: batch.expiredAt },
|
||||
{ label: "Cancelling", ts: batch.cancellingAt },
|
||||
{ label: "Cancelled", ts: batch.cancelledAt },
|
||||
{ label: t("batchDetailCreated"), ts: batch.createdAt },
|
||||
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
|
||||
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
|
||||
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
|
||||
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
|
||||
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
|
||||
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
|
||||
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
|
||||
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
|
||||
]
|
||||
.filter((t) => t.ts)
|
||||
.map(({ label, ts }) => (
|
||||
@@ -315,9 +345,21 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
|
||||
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
|
||||
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
|
||||
{
|
||||
role: t("filesListUsedByRoleInput"),
|
||||
fileId: batch.inputFileId,
|
||||
record: inputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleOutput"),
|
||||
fileId: batch.outputFileId,
|
||||
record: outputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleError"),
|
||||
fileId: batch.errorFileId,
|
||||
record: errorFile,
|
||||
},
|
||||
]
|
||||
.filter((f) => f.fileId)
|
||||
.map(({ role, fileId, record }) => (
|
||||
@@ -350,7 +392,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||
Download
|
||||
{t("filesListDownload")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +404,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.usage && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Token Usage
|
||||
{t("batchTokenUsage")}
|
||||
</h3>
|
||||
<pre className="p-3 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs font-mono text-[var(--color-text-main)] overflow-x-auto">
|
||||
{JSON.stringify(batch.usage, null, 2)}
|
||||
@@ -374,7 +416,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.errors && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
|
||||
Errors
|
||||
{t("errors")}
|
||||
</h3>
|
||||
<pre className="p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-xs font-mono text-red-300 overflow-x-auto">
|
||||
{JSON.stringify(batch.errors, null, 2)}
|
||||
@@ -386,7 +428,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Metadata
|
||||
{t("batchMetadata")}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(batch.metadata).map(([k, v]) => (
|
||||
@@ -438,7 +480,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
if (
|
||||
window.confirm(
|
||||
t("batchDetailActionRetry") +
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
|
||||
)
|
||||
) {
|
||||
const result = await retry({
|
||||
|
||||
@@ -4,22 +4,24 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type FileTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: FileTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
|
||||
const diffDays = Math.round(diffHr / 24);
|
||||
return `${diffDays}d ago`;
|
||||
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
|
||||
}
|
||||
|
||||
function relativeExpiration(ts: number | null): string {
|
||||
if (!ts) return "Never";
|
||||
function relativeExpiration(ts: number | null, t: FileTranslator): string {
|
||||
if (!ts) return t("batchFilesNeverExpires");
|
||||
const diffMs = ts * 1000 - Date.now();
|
||||
if (diffMs <= 0) return "Expired";
|
||||
if (diffMs <= 0) return t("expirationBadgeExpired");
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
@@ -55,6 +57,22 @@ interface BatchRecord {
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
const BATCH_STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
completed: "batchStatusCompleted",
|
||||
completed_with_failures: "batchStatusCompletedWithFailures",
|
||||
failed: "batchStatusFailed",
|
||||
in_progress: "batchStatusInProgress",
|
||||
in_progress_with_failures: "batchStatusInProgressWithFailures",
|
||||
finalizing: "batchStatusFinalizing",
|
||||
finalizing_with_failures: "batchStatusFinalizingWithFailures",
|
||||
validating: "batchStatusValidating",
|
||||
cancelling: "batchStatusCancelling",
|
||||
cancelled: "batchStatusCancelled",
|
||||
cancelled_with_failures: "batchStatusCancelledWithFailures",
|
||||
expired: "batchStatusExpired",
|
||||
expired_with_failures: "batchStatusExpiredWithFailures",
|
||||
};
|
||||
|
||||
interface FileDetailModalProps {
|
||||
file: FileRecord;
|
||||
contents: string | null;
|
||||
@@ -133,7 +151,7 @@ export default function FileDetailModal({
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
File Contents
|
||||
{t("batchFileContents")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
|
||||
@@ -164,7 +182,7 @@ export default function FileDetailModal({
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Size
|
||||
{t("batchFilesSizeColumn")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{formatBytes(file.bytes)}
|
||||
@@ -172,24 +190,24 @@ export default function FileDetailModal({
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Purpose
|
||||
{t("batchFilesPurpose")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Created
|
||||
{t("batchDetailCreated")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{createdAtTs ? relativeTime(createdAtTs) : "—"}
|
||||
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Expires
|
||||
{t("batchFilesExpires")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +216,7 @@ export default function FileDetailModal({
|
||||
{relatedBatches.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
|
||||
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
||||
{t("batchFileUsedByCount", { count: relatedBatches.length })}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{relatedBatches.map((b) => (
|
||||
@@ -219,7 +237,9 @@ export default function FileDetailModal({
|
||||
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
||||
}`}
|
||||
>
|
||||
{b.status.replaceAll("_", " ")}
|
||||
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
|
||||
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
|
||||
: b.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -231,7 +251,7 @@ export default function FileDetailModal({
|
||||
<div className="flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Preview
|
||||
{t("batchFilePreview")}
|
||||
</h3>
|
||||
{contents && (
|
||||
<button
|
||||
@@ -241,7 +261,7 @@ export default function FileDetailModal({
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,7 +279,7 @@ export default function FileDetailModal({
|
||||
{isTruncated && (
|
||||
<div className="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/25 rounded-lg text-xs text-yellow-400 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">warning</span>
|
||||
Showing first 1000 lines ({lineCount} total lines)
|
||||
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +301,7 @@ export default function FileDetailModal({
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">download</span>
|
||||
Download Full File
|
||||
{t("batchFileDownloadFull")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ChaosTranslator = ((
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
export function chaosText(
|
||||
t: ChaosTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
if (typeof t.has !== "function" || !t.has(key)) return fallback;
|
||||
return values ? t(key, values) : t(key);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
||||
|
||||
/**
|
||||
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
|
||||
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
|
||||
onReset: () => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,7 +67,7 @@ export function ChaosConfigActionsBar({
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
|
||||
)}
|
||||
{testing ? "Running..." : t("testButton")}
|
||||
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
||||
|
||||
export interface ChaosModelResult {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -26,14 +29,24 @@ export interface ChaosTestResult {
|
||||
* complexity/size ratchet (config/quality/complexity-baseline.json).
|
||||
*/
|
||||
export function ChaosTestResultsPanel({ result }: { result: ChaosTestResult }) {
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const resultsTitle = chaosText(
|
||||
t,
|
||||
"testResults",
|
||||
`Test Results — ${result.mode} mode (${result.totalProviders} providers)`,
|
||||
{ mode: result.mode, count: result.totalProviders }
|
||||
);
|
||||
const startedLabel = chaosText(
|
||||
t,
|
||||
"started",
|
||||
`Started: ${new Date(result.startedAt).toLocaleTimeString()}`,
|
||||
{ time: new Date(result.startedAt).toLocaleTimeString() }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
|
||||
<h3 className="text-sm font-bold text-text-main">
|
||||
Test Results — {result.mode} mode ({result.totalProviders} providers)
|
||||
</h3>
|
||||
<div className="text-xs text-text-muted">
|
||||
Started: {new Date(result.startedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
|
||||
<div className="text-xs text-text-muted">{startedLabel}</div>
|
||||
{result.models.map((model, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
|
||||
*/
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import ChaosConfigPageClient from "./ChaosConfigPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Chaos Mode — OmniRoute",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("chaosConfig");
|
||||
return { title: `${t("pageTitle")} — OmniRoute` };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <ChaosConfigPageClient />;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ export function useChaosConfigPersistence(
|
||||
config: ChaosPageConfig,
|
||||
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
||||
) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
||||
|
||||
@@ -49,17 +50,26 @@ export function useChaosConfigPersistence(
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data.config);
|
||||
setMessage({ type: "success", text: "Config reset to defaults" });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
|
||||
});
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Reset failed" }));
|
||||
setMessage({ type: "error", text: err.error || "Reset failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to reset config" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: chaosText(t, "resetFailed", "Failed to reset config"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [setConfig]);
|
||||
}, [setConfig, t]);
|
||||
|
||||
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
||||
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
|
||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
|
||||
@@ -10,8 +11,11 @@ import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
* of the page component to keep it under the complexity/size ratchet
|
||||
* (config/quality/complexity-baseline.json).
|
||||
*/
|
||||
export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
export function useChaosTestRun(
|
||||
config: ChaosPageConfig,
|
||||
setMessage: (message: ChaosPageMessage) => void
|
||||
) {
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
|
||||
|
||||
@@ -34,11 +38,17 @@ export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: C
|
||||
const data: ChaosTestResult = await res.json();
|
||||
setTestResult(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
||||
setMessage({ type: "error", text: err.error || "Test failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: "error", text: err.message || "Test failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.message || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface ToolState {
|
||||
@@ -24,6 +25,7 @@ interface UpdateInfo {
|
||||
}
|
||||
|
||||
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||
const t = useTranslations("cliTools");
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
@@ -71,7 +73,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || `${action} succeeded` });
|
||||
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
||||
await fetchStatus();
|
||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||
} else {
|
||||
@@ -79,11 +81,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
type: "error",
|
||||
text:
|
||||
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
||||
`${action} failed`,
|
||||
t("cliproxyapiActionFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
@@ -93,14 +98,26 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
if (!toolState) return null;
|
||||
const s = toolState.status;
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
|
||||
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
|
||||
running: {
|
||||
label: t("cliproxyapiStatusRunning"),
|
||||
color: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
},
|
||||
stopped: {
|
||||
label: t("cliproxyapiStatusStopped"),
|
||||
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
},
|
||||
not_installed: {
|
||||
label: "Not Installed",
|
||||
label: t("cliproxyapiStatusNotInstalled"),
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
|
||||
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||
installed: {
|
||||
label: t("cliproxyapiStatusInstalled"),
|
||||
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
error: {
|
||||
label: t("cliproxyapiStatusError"),
|
||||
color: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
const badge = map[s] || map.not_installed;
|
||||
return (
|
||||
@@ -125,9 +142,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
||||
{statusBadge()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
Upstream proxy fallback (Go-based OAuth)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -161,7 +176,10 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
system_update
|
||||
</span>
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
||||
{t("cliproxyapiUpdateAvailable", {
|
||||
current: updateInfo.current,
|
||||
latest: updateInfo.latest,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -170,32 +188,34 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
Update
|
||||
{t("cliproxyapiUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
{toolState?.installedVersion
|
||||
? `v${toolState.installedVersion}`
|
||||
: t("cliproxyapiNotInstalledValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
|
||||
>
|
||||
{toolState?.healthStatus === "healthy"
|
||||
? `Healthy`
|
||||
? t("cliproxyapiHealthy")
|
||||
: toolState?.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
? t("cliproxyapiUnhealthy")
|
||||
: t("cliproxyapiUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
|
||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +229,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "install"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||
Install
|
||||
{t("cliproxyapiInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{toolState?.status === "running" ? (
|
||||
@@ -220,7 +240,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "stop"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||
Stop
|
||||
{t("cliproxyapiStop")}
|
||||
</Button>
|
||||
) : toolState?.installedVersion ? (
|
||||
<Button
|
||||
@@ -230,7 +250,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "start"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||
Start
|
||||
{t("cliproxyapiStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
{toolState?.status === "running" && (
|
||||
@@ -241,7 +261,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "restart"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||
Restart
|
||||
{t("cliproxyapiRestart")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -251,7 +271,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "check"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||
Check Updates
|
||||
{t("cliproxyapiCheckUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,55 +133,10 @@ export default function ToolDetailClient({ toolId, category }: ToolDetailClientP
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (providerModels.length === 0) {
|
||||
const prefix =
|
||||
typeof conn.providerSpecificData?.prefix === "string" &&
|
||||
conn.providerSpecificData.prefix.trim()
|
||||
? conn.providerSpecificData.prefix.trim()
|
||||
: alias;
|
||||
const fallbackModels: Array<{ id: string; name: string }> = [];
|
||||
const addFallbackModel = (model: any) => {
|
||||
const id = typeof model?.id === "string" ? model.id.trim() : "";
|
||||
if (!id || fallbackModels.some((candidate) => candidate.id === id)) return;
|
||||
fallbackModels.push({
|
||||
id,
|
||||
name: typeof model?.name === "string" && model.name.trim() ? model.name.trim() : id,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof conn.defaultModel === "string" && conn.defaultModel.trim()) {
|
||||
addFallbackModel({ id: conn.defaultModel });
|
||||
}
|
||||
if (Array.isArray(conn.providerSpecificData?.customModels)) {
|
||||
conn.providerSpecificData.customModels.forEach(addFallbackModel);
|
||||
}
|
||||
if (fallbackModels.length === 0 && conn.testStatus === "active") {
|
||||
addFallbackModel({ id: "model-id", name: `${prefix}/model-id` });
|
||||
}
|
||||
|
||||
fallbackModels.forEach((model) => {
|
||||
const modelValue = `${prefix}/${model.id}`;
|
||||
if (seenModels.has(modelValue)) return;
|
||||
seenModels.add(modelValue);
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: modelValue,
|
||||
provider: conn.provider,
|
||||
alias: prefix,
|
||||
connectionName: conn.name,
|
||||
modelId: model.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const activeAliases = new Set(
|
||||
activeProviders.flatMap((connection) => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[connection.provider] || connection.provider;
|
||||
const prefix = connection.providerSpecificData?.prefix;
|
||||
return typeof prefix === "string" && prefix.trim() ? [alias, prefix.trim()] : [alias];
|
||||
})
|
||||
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
|
||||
);
|
||||
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
|
||||
dynamicModels.forEach((dm) => {
|
||||
|
||||
@@ -133,8 +133,8 @@ export default function IntelligentComboPanel({
|
||||
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
|
||||
{combo?.name}
|
||||
</code>
|
||||
<span>{allCombos.length} intelligent combo(s)</span>
|
||||
<span>{providerScopeCount} providers in scope</span>
|
||||
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
|
||||
<span>{t("providersInScope", { count: providerScopeCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Candidate Pool
|
||||
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
|
||||
</div>
|
||||
@@ -183,7 +183,12 @@ export default function IntelligentComboPanel({
|
||||
</p>
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||
"{pack}",
|
||||
savingModePack
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -308,7 +313,7 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Exploration Rate
|
||||
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||
{Math.round(normalizedConfig.explorationRate * 100)}%
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function CombosError({
|
||||
error: _error,
|
||||
reset,
|
||||
@@ -7,6 +9,8 @@ export default function CombosError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("combos");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[400px]"
|
||||
@@ -14,14 +18,10 @@ export default function CombosError({
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Failed to load combos
|
||||
</h2>
|
||||
<p className="text-text-muted max-w-md">
|
||||
We could not load combo data right now. Check your connection and try again.
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
|
||||
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
|
||||
{_error?.digest && (
|
||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
|
||||
@@ -30,7 +30,7 @@ export default function CombosError({
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
Try Again
|
||||
{t("errorRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata = {
|
||||
title: "Compression",
|
||||
description: "Configure context compression settings to reduce token usage and costs.",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("metadata");
|
||||
return {
|
||||
title: t("compressionTitle"),
|
||||
description: t("compressionDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompressionPage() {
|
||||
redirect("/dashboard/context/caveman");
|
||||
|
||||
@@ -534,7 +534,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
if (!cloudConfigured) {
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: "Cloud sync is not configured on this instance.",
|
||||
message: translateOrFallback(
|
||||
"cloudSyncNotConfigured",
|
||||
"Cloud sync is not configured on this instance."
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1822,7 +1825,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-primary">hub</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryCore") || "Core APIs"}
|
||||
{t("categoryCore")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1843,7 +1846,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="code"
|
||||
iconColor="text-indigo-500"
|
||||
iconBg="bg-indigo-500/10"
|
||||
title={t("responses") || "Responses API"}
|
||||
title={t("responses")}
|
||||
path="/v1/responses"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1855,7 +1858,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="text_fields"
|
||||
iconColor="text-orange-500"
|
||||
iconBg="bg-orange-500/10"
|
||||
title={t("completionsLegacy") || "Completions (Legacy)"}
|
||||
title={t("completionsLegacy")}
|
||||
path="/v1/completions"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1867,7 +1870,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="psychology"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("messagesApi") || "Messages"}
|
||||
title={t("messagesApi")}
|
||||
path="/v1/messages"
|
||||
models={null}
|
||||
badge="Anthropic"
|
||||
@@ -1883,7 +1886,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryMedia") || "Media & Multi-Modal"}
|
||||
{t("categoryMedia")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1916,7 +1919,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="edit_square"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("imageEdits") || "Image Edits"}
|
||||
title={t("imageEdits")}
|
||||
path="/v1/images/edits"
|
||||
models={endpointData.images}
|
||||
copy={copy}
|
||||
@@ -1952,7 +1955,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="music_note"
|
||||
iconColor="text-fuchsia-500"
|
||||
iconBg="bg-fuchsia-500/10"
|
||||
title={t("musicGeneration") || "Music Generation"}
|
||||
title={t("musicGeneration")}
|
||||
path="/v1/music/generations"
|
||||
models={endpointData.music}
|
||||
copy={copy}
|
||||
@@ -1964,7 +1967,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="videocam"
|
||||
iconColor="text-red-500"
|
||||
iconBg="bg-red-500/10"
|
||||
title={t("videoGeneration") || "Video Generation"}
|
||||
title={t("videoGeneration")}
|
||||
path="/v1/videos/generations"
|
||||
models={endpointData.video}
|
||||
copy={copy}
|
||||
@@ -1983,7 +1986,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
travel_explore
|
||||
</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categorySearch") || "Search & Discovery"}
|
||||
{t("categorySearch")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1992,7 +1995,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="search"
|
||||
iconColor="text-cyan-500"
|
||||
iconBg="bg-cyan-500/10"
|
||||
title={t("webSearch") || "Web Search"}
|
||||
title={t("webSearch")}
|
||||
path="/v1/search"
|
||||
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
|
||||
copy={copy}
|
||||
@@ -2008,7 +2011,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryUtility") || "Utility & Management"}
|
||||
{t("categoryUtility")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -2041,7 +2044,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="view_list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("batchApi") || "Batch API"}
|
||||
title={t("batchApi")}
|
||||
path="/v1/batches"
|
||||
models={null}
|
||||
badge="OpenAI"
|
||||
@@ -2053,7 +2056,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="folder"
|
||||
iconColor="text-yellow-500"
|
||||
iconBg="bg-yellow-500/10"
|
||||
title={t("filesApi") || "Files API"}
|
||||
title={t("filesApi")}
|
||||
path="/v1/files"
|
||||
models={null}
|
||||
copy={copy}
|
||||
@@ -2064,7 +2067,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("listModels") || "List Models"}
|
||||
title={t("listModels")}
|
||||
path="/v1/models"
|
||||
models={null}
|
||||
copy={copy}
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function A2ADashboardPage() {
|
||||
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableState")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
||||
<th className="text-left py-2">{t("tableActions")}</th>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,18 @@ export default function NotionSourceCard() {
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const translateOrFallback = (key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key as never);
|
||||
if (!translated || translated === key || translated === `endpoint.${key}`) {
|
||||
return fallback;
|
||||
}
|
||||
return translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/notion");
|
||||
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!token.trim()) {
|
||||
setMessage({ type: "error", text: "Please enter a Notion integration token" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
|
||||
setConnected(true);
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
|
||||
});
|
||||
setConnected(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionConnectionFailed", "Connection failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
|
||||
setToken("");
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -97,11 +130,16 @@ export default function NotionSourceCard() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">Notion</span>
|
||||
<Badge variant={connected ? "success" : "default"}>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
{connected
|
||||
? translateOrFallback("notionConnected", "Connected")
|
||||
: translateOrFallback("notionNotConnected", "Not connected")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Search, read, query, and write to Notion through routed AI models
|
||||
{translateOrFallback(
|
||||
"notionDescription",
|
||||
"Search, read, query, and write to Notion through routed AI models"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
@@ -131,7 +169,10 @@ export default function NotionSourceCard() {
|
||||
{!connected ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted font-medium">
|
||||
Notion Internal Integration Token
|
||||
{translateOrFallback(
|
||||
"notionIntegrationToken",
|
||||
"Notion Internal Integration Token"
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -143,11 +184,14 @@ export default function NotionSourceCard() {
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
|
||||
Connect
|
||||
{translateOrFallback("notionConnect", "Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Create an Internal Integration at{" "}
|
||||
{translateOrFallback(
|
||||
"notionIntegrationHelp",
|
||||
"Create an Internal Integration at"
|
||||
)}{" "}
|
||||
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
||||
https://www.notion.so/profile/integrations
|
||||
</code>
|
||||
@@ -156,7 +200,10 @@ export default function NotionSourceCard() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted flex-1">
|
||||
Token configured. Notion tools are available via MCP.
|
||||
{translateOrFallback(
|
||||
"notionTokenConfigured",
|
||||
"Token configured. Notion tools are available via MCP."
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
@@ -165,7 +212,7 @@ export default function NotionSourceCard() {
|
||||
size="sm"
|
||||
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
|
||||
>
|
||||
Disconnect
|
||||
{translateOrFallback("notionDisconnect", "Disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,6 @@ import { useProviderConnections } from "./hooks/useProviderConnections";
|
||||
import { useProviderSettings } from "./hooks/useProviderSettings";
|
||||
import { useProviderModels } from "./hooks/useProviderModels";
|
||||
import { useCommandCodeAuth } from "./hooks/useCommandCodeAuth";
|
||||
import { useConnectionAutoSync } from "./hooks/useConnectionAutoSync";
|
||||
import { useExternalLinkFlow } from "./hooks/useExternalLinkFlow";
|
||||
import { useAuthFileHandlers } from "./hooks/useAuthFileHandlers";
|
||||
import { useModelImportHandlers } from "./hooks/useModelImportHandlers";
|
||||
@@ -98,7 +97,6 @@ export default function ProviderDetailPageClient() {
|
||||
const usesCuratedModelsOnly = providerUsesCuratedModelsOnly(providerId);
|
||||
const {
|
||||
connections,
|
||||
setConnections,
|
||||
providerNode,
|
||||
loading,
|
||||
retestingId,
|
||||
@@ -297,13 +295,6 @@ export default function ProviderDetailPageClient() {
|
||||
providerStorageAlias,
|
||||
});
|
||||
|
||||
const handleToggleConnectionAutoSync = useConnectionAutoSync(
|
||||
connections,
|
||||
setConnections,
|
||||
notify,
|
||||
t
|
||||
);
|
||||
|
||||
// ── model-related effects (loading gate) ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (loading || isSearchProvider) return;
|
||||
@@ -606,8 +597,6 @@ export default function ProviderDetailPageClient() {
|
||||
handleToggleRateLimit={handleToggleRateLimit}
|
||||
handleToggleQuotaVisibility={handleToggleQuotaVisibility}
|
||||
handleToggleClaudeExtraUsage={handleToggleClaudeExtraUsage}
|
||||
canAutoSync={!usesCuratedModelsOnly && compatibleSupportsModelImport}
|
||||
handleToggleConnectionAutoSync={handleToggleConnectionAutoSync}
|
||||
handleToggleCliproxyapiMode={handleToggleCliproxyapiMode}
|
||||
handleToggleCodexLimit={handleToggleCodexLimit}
|
||||
handleToggleProxyEnabled={handleToggleProxyEnabled}
|
||||
|
||||
@@ -54,6 +54,11 @@ vi.mock("next/link", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
// Echo the key back so assertions don't depend on a full message catalog.
|
||||
useTranslations: (namespace?: string) => (key: string) => (namespace ? `${namespace}.${key}` : key),
|
||||
}));
|
||||
|
||||
function renderProviderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ConnectionRow, { type ConnectionRowProps } from "../components/ConnectionRow";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function buildProps(overrides: Partial<ConnectionRowProps>): ConnectionRowProps {
|
||||
return {
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
isActive: true,
|
||||
providerSpecificData: { autoSync: false },
|
||||
},
|
||||
isOAuth: false,
|
||||
isFirst: false,
|
||||
isLast: false,
|
||||
onMoveUp: noop,
|
||||
onMoveDown: noop,
|
||||
onToggleActive: noop,
|
||||
onToggleRateLimit: noop,
|
||||
onRetest: noop,
|
||||
onEdit: noop,
|
||||
onDelete: noop,
|
||||
...overrides,
|
||||
} as ConnectionRowProps;
|
||||
}
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: ConnectionRowProps) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => root.render(<ConnectionRow {...props} />));
|
||||
roots.push({ root, el });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConnectionRow autoSync toggle", () => {
|
||||
it("does not render an autoSync toggle when onToggleAutoSync is absent", () => {
|
||||
render(buildProps({}));
|
||||
expect(document.body.textContent).not.toContain("Sync");
|
||||
});
|
||||
|
||||
it("renders the toggle when onToggleAutoSync is present", () => {
|
||||
render(buildProps({ onToggleAutoSync: vi.fn() }));
|
||||
expect(document.body.textContent).toContain("Sync");
|
||||
const button = [...document.querySelectorAll("button")].find((b) =>
|
||||
(b.textContent || "").includes("Sync")
|
||||
);
|
||||
expect((button as HTMLButtonElement).className).not.toContain("bg-emerald-500/15");
|
||||
});
|
||||
|
||||
it("renders the toggle in the on state when autoSync is true", () => {
|
||||
render(
|
||||
buildProps({
|
||||
connection: { id: "conn-1", isActive: true, providerSpecificData: { autoSync: true } },
|
||||
onToggleAutoSync: vi.fn(),
|
||||
})
|
||||
);
|
||||
expect(document.body.textContent).toContain("Sync");
|
||||
const button = [...document.querySelectorAll("button")].find((b) =>
|
||||
(b.textContent || "").includes("Sync")
|
||||
);
|
||||
expect((button as HTMLButtonElement).className).toContain("bg-emerald-500/15");
|
||||
});
|
||||
|
||||
it("invokes onToggleAutoSync with the inverse value on click", () => {
|
||||
const onToggleAutoSync = vi.fn();
|
||||
render(
|
||||
buildProps({
|
||||
connection: { id: "conn-1", isActive: true, providerSpecificData: { autoSync: false } },
|
||||
onToggleAutoSync,
|
||||
})
|
||||
);
|
||||
const button = [...document.querySelectorAll("button")].find((b) =>
|
||||
(b.textContent || "").includes("Sync")
|
||||
);
|
||||
act(() => button?.click());
|
||||
expect(onToggleAutoSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("disables the toggle when the connection is inactive", () => {
|
||||
render(
|
||||
buildProps({
|
||||
connection: { id: "conn-1", isActive: false, providerSpecificData: { autoSync: false } },
|
||||
onToggleAutoSync: vi.fn(),
|
||||
})
|
||||
);
|
||||
const button = [...document.querySelectorAll("button")].find((b) =>
|
||||
(b.textContent || "").includes("Sync")
|
||||
);
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useConnectionAutoSync } from "../hooks/useConnectionAutoSync";
|
||||
import type { ConnectionRowConnection } from "../components/ConnectionRow";
|
||||
|
||||
const t = ((key: string) => key) as ((key: string) => string) & {
|
||||
has: (key: string) => boolean;
|
||||
};
|
||||
t.has = () => false;
|
||||
|
||||
const notify = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderHandler(initial: ConnectionRowConnection[]) {
|
||||
let latest: {
|
||||
handler: (id: string, enabled: boolean) => Promise<void>;
|
||||
connections: ConnectionRowConnection[];
|
||||
} | null = null;
|
||||
function Wrapper() {
|
||||
const [connections, setConnections] = React.useState(initial);
|
||||
const handler = useConnectionAutoSync(
|
||||
connections,
|
||||
setConnections as React.Dispatch<React.SetStateAction<ConnectionRowConnection[]>>,
|
||||
notify,
|
||||
t
|
||||
);
|
||||
React.useEffect(() => {
|
||||
latest = { handler, connections };
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => root.render(<Wrapper />));
|
||||
roots.push({ root, el });
|
||||
return {
|
||||
get: () => {
|
||||
if (!latest) throw new Error("Hook did not render");
|
||||
return latest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useConnectionAutoSync", () => {
|
||||
it("PUTs the autoSync flag and notifies success", async () => {
|
||||
const conns: ConnectionRowConnection[] = [
|
||||
{ id: "conn-1", providerSpecificData: { autoSync: false } },
|
||||
];
|
||||
const h = renderHandler(conns);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await h.get().handler("conn-1", true);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/providers/conn-1",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: { autoSync: true },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(notify.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("spreads existing providerSpecificData instead of replacing it", async () => {
|
||||
const conns: ConnectionRowConnection[] = [
|
||||
{ id: "conn-1", providerSpecificData: { someOtherFlag: 42, autoSync: false } },
|
||||
];
|
||||
const h = renderHandler(conns);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await h.get().handler("conn-1", true);
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(body).toEqual({
|
||||
providerSpecificData: { someOtherFlag: 42, autoSync: true },
|
||||
});
|
||||
expect(h.get().connections).toEqual([
|
||||
{ id: "conn-1", providerSpecificData: { someOtherFlag: 42, autoSync: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("notifies error when the PUT fails", async () => {
|
||||
const conns: ConnectionRowConnection[] = [
|
||||
{ id: "conn-1", providerSpecificData: { autoSync: false } },
|
||||
];
|
||||
const h = renderHandler(conns);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await h.get().handler("conn-1", true);
|
||||
});
|
||||
|
||||
expect(notify.error).toHaveBeenCalled();
|
||||
expect(notify.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies autoSyncDisabled (info) when disabling autoSync", async () => {
|
||||
const conns: ConnectionRowConnection[] = [
|
||||
{ id: "conn-1", providerSpecificData: { autoSync: true } },
|
||||
];
|
||||
const h = renderHandler(conns);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await h.get().handler("conn-1", false);
|
||||
});
|
||||
|
||||
expect(notify.info).toHaveBeenCalledWith("autoSyncDisabled");
|
||||
expect(notify.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
useModelImportHandlers,
|
||||
type UseModelImportHandlersParams,
|
||||
type UseModelImportHandlersReturn,
|
||||
} from "../hooks/useModelImportHandlers";
|
||||
|
||||
type HookResult = UseModelImportHandlersReturn;
|
||||
|
||||
const t = ((key: string) => key) as ((key: string) => string) & {
|
||||
has: (key: string) => boolean;
|
||||
};
|
||||
t.has = () => false;
|
||||
|
||||
const notify = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
|
||||
function buildParams(
|
||||
overrides: Partial<UseModelImportHandlersParams>
|
||||
): UseModelImportHandlersParams {
|
||||
return {
|
||||
providerId: "cloudflare-ai",
|
||||
models: [],
|
||||
modelMeta: { customModels: [] },
|
||||
modelAliases: {},
|
||||
connections: [],
|
||||
isFreeNoAuth: false,
|
||||
handleSetAlias: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAliases: vi.fn().mockResolvedValue(undefined),
|
||||
fetchProviderModelMeta: vi.fn().mockResolvedValue(undefined),
|
||||
fetchConnections: vi.fn().mockResolvedValue(undefined),
|
||||
notify,
|
||||
t,
|
||||
providerStorageAlias: "cloudflare-ai",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderHook(params: UseModelImportHandlersParams): { get: () => HookResult } {
|
||||
let latestResult: HookResult | null = null;
|
||||
function Wrapper() {
|
||||
const result = useModelImportHandlers(params);
|
||||
React.useEffect(() => {
|
||||
latestResult = result;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => root.render(<Wrapper />));
|
||||
roots.push({ root, el });
|
||||
return {
|
||||
get: () => {
|
||||
if (!latestResult) throw new Error("Hook did not render");
|
||||
return latestResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function conn(id: string, active: boolean, autoSync?: boolean) {
|
||||
return {
|
||||
id,
|
||||
isActive: active,
|
||||
providerSpecificData: autoSync === undefined ? {} : { autoSync },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useModelImportHandlers — master autoSync", () => {
|
||||
it("isAutoSyncEnabled is true only when every active connection has autoSync on", () => {
|
||||
const mixed = renderHook(
|
||||
buildParams({ connections: [conn("a", true, true), conn("b", true, false)] })
|
||||
);
|
||||
expect(mixed.get().isAutoSyncEnabled).toBe(false);
|
||||
|
||||
const allOn = renderHook(
|
||||
buildParams({ connections: [conn("a", true, true), conn("b", true, true)] })
|
||||
);
|
||||
expect(allOn.get().isAutoSyncEnabled).toBe(true);
|
||||
|
||||
const oneOff = renderHook(
|
||||
buildParams({ connections: [conn("a", true, true), conn("b", false, true)] })
|
||||
);
|
||||
expect(oneOff.get().isAutoSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("handleToggleAutoSync fans out a PUT to every active connection (bug repro)", async () => {
|
||||
const fetchConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const hook = renderHook(
|
||||
buildParams({
|
||||
connections: [conn("conn-a", true, false), conn("conn-b", true, false)],
|
||||
fetchConnections,
|
||||
})
|
||||
);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/providers/conn-a",
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/providers/conn-b",
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
expect(fetchConnections).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes inactive connections from the fan-out", async () => {
|
||||
const hook = renderHook(
|
||||
buildParams({
|
||||
connections: [conn("conn-a", true, false), conn("conn-inactive", false, false)],
|
||||
})
|
||||
);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/providers/conn-a",
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
});
|
||||
|
||||
it("toggling from a mixed state (one on, one off) turns all active connections on", async () => {
|
||||
const hook = renderHook(
|
||||
buildParams({
|
||||
connections: [conn("conn-a", true, true), conn("conn-b", true, false)],
|
||||
})
|
||||
);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(hook.get().isAutoSyncEnabled).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/providers/conn-a",
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/providers/conn-b",
|
||||
expect.objectContaining({ method: "PUT" })
|
||||
);
|
||||
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(firstBody.providerSpecificData).toEqual({ autoSync: true });
|
||||
expect(secondBody.providerSpecificData).toEqual({ autoSync: true });
|
||||
expect(notify.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still calls fetchConnections when a fan-out PUT fails (partial failure)", async () => {
|
||||
const fetchConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const hook = renderHook(
|
||||
buildParams({
|
||||
connections: [conn("conn-a", true, false), conn("conn-b", true, false)],
|
||||
fetchConnections,
|
||||
})
|
||||
);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 } as Response);
|
||||
fetchMock.mockResolvedValueOnce({ ok: true } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(fetchConnections).toHaveBeenCalled();
|
||||
expect(notify.success).not.toHaveBeenCalled();
|
||||
expect(notify.error).not.toHaveBeenCalled();
|
||||
expect(notify.warning).toHaveBeenCalledWith("autoSyncPartialFailure");
|
||||
});
|
||||
|
||||
it("notifies error when every fan-out PUT fails", async () => {
|
||||
const hook = renderHook(
|
||||
buildParams({
|
||||
connections: [conn("conn-a", true, false), conn("conn-b", true, false)],
|
||||
})
|
||||
);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 } as Response);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(notify.error).toHaveBeenCalledWith("autoSyncToggleFailed");
|
||||
expect(notify.success).not.toHaveBeenCalled();
|
||||
expect(notify.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops without a PUT or notification when there are no active connections", async () => {
|
||||
const hook = renderHook(buildParams({ connections: [conn("conn-a", false, false)] }));
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
|
||||
await act(async () => {
|
||||
await hook.get().handleToggleAutoSync();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(notify.success).not.toHaveBeenCalled();
|
||||
expect(notify.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,6 @@ export interface ConnectionRowProps {
|
||||
onToggleRateLimit: (enabled?: boolean) => void;
|
||||
onToggleQuotaVisibility?: (visible: boolean) => void;
|
||||
onToggleClaudeExtraUsage?: (enabled?: boolean) => void;
|
||||
onToggleAutoSync?: (enabled: boolean) => void;
|
||||
onToggleCodex5h?: (enabled?: boolean) => void;
|
||||
onToggleCodexWeekly?: (enabled?: boolean) => void;
|
||||
isCcCompatible?: boolean;
|
||||
@@ -242,7 +241,7 @@ function getStatusPresentation(
|
||||
if (errorType === "account_deactivated") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusDeactivated", "Deactivated"),
|
||||
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -297,7 +296,7 @@ function getStatusPresentation(
|
||||
if (errorType === "banned") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusBanned", "Banned (403)"),
|
||||
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -307,7 +306,7 @@ function getStatusPresentation(
|
||||
if (errorType === "credits_exhausted") {
|
||||
return {
|
||||
statusVariant: "warning",
|
||||
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
||||
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-amber-500",
|
||||
@@ -351,7 +350,6 @@ export default function ConnectionRow({
|
||||
onToggleRateLimit,
|
||||
onToggleQuotaVisibility,
|
||||
onToggleClaudeExtraUsage,
|
||||
onToggleAutoSync,
|
||||
onToggleCodex5h,
|
||||
onToggleCodexWeekly,
|
||||
onToggleCliproxyapiMode,
|
||||
@@ -389,22 +387,10 @@ export default function ConnectionRow({
|
||||
t("oauthAccount")
|
||||
)
|
||||
: connection.name;
|
||||
const applyCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("applyCodexAuthLocal")
|
||||
? t("applyCodexAuthLocal")
|
||||
: "Apply auth";
|
||||
const exportCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("exportCodexAuthFile")
|
||||
? t("exportCodexAuthFile")
|
||||
: "Export auth";
|
||||
const applyClaudeAuthLabel =
|
||||
typeof t.has === "function" && t.has("applyClaudeAuthLocal")
|
||||
? t("applyClaudeAuthLocal")
|
||||
: "Apply auth";
|
||||
const exportClaudeAuthLabel =
|
||||
typeof t.has === "function" && t.has("exportClaudeAuthFile")
|
||||
? t("exportClaudeAuthFile")
|
||||
: "Export auth";
|
||||
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
|
||||
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
|
||||
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
|
||||
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export auth");
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
||||
@@ -512,8 +498,6 @@ export default function ConnectionRow({
|
||||
: false;
|
||||
const codexPlanLabel = getCodexPlanLabel(!!isCodex, connection.providerSpecificData);
|
||||
const cliproxyapiDeepMode = !!cliproxyapiEnabled;
|
||||
const autoSyncEnabled = !!(connection.providerSpecificData as Record<string, unknown> | undefined)
|
||||
?.autoSync;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -637,24 +621,6 @@ export default function ConnectionRow({
|
||||
onToggle={onToggleQuotaVisibility}
|
||||
/>
|
||||
)}
|
||||
{onToggleAutoSync && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleAutoSync?.(!autoSyncEnabled)}
|
||||
disabled={connection.isActive === false}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
autoSyncEnabled
|
||||
? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={t("autoSyncTooltip")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">sync</span>
|
||||
{t("autoSyncShort")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isClaude && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
|
||||
@@ -50,8 +50,6 @@ type ConnectionsListPanelProps = {
|
||||
handleToggleRateLimit: (id: string, enabled: boolean) => void;
|
||||
handleToggleQuotaVisibility: (id: string, visible: boolean) => void;
|
||||
handleToggleClaudeExtraUsage: (id: string, enabled: boolean) => void;
|
||||
canAutoSync?: boolean;
|
||||
handleToggleConnectionAutoSync?: (connectionId: string, enabled: boolean) => void;
|
||||
handleToggleCliproxyapiMode: (id: string, enabled: boolean) => void;
|
||||
handleToggleCodexLimit: (id: string, type: "use5h" | "useWeekly", enabled: boolean) => void;
|
||||
handleToggleProxyEnabled: (id: string, enabled: boolean) => void;
|
||||
@@ -130,7 +128,6 @@ export default function ConnectionsListPanel({
|
||||
handleToggleRateLimit,
|
||||
handleToggleQuotaVisibility,
|
||||
handleToggleClaudeExtraUsage,
|
||||
handleToggleConnectionAutoSync,
|
||||
handleToggleCliproxyapiMode,
|
||||
handleToggleCodexLimit,
|
||||
handleToggleProxyEnabled,
|
||||
@@ -145,7 +142,6 @@ export default function ConnectionsListPanel({
|
||||
handleToggleSelectAll,
|
||||
handleDistributeProxies,
|
||||
cpaProviderEnabled,
|
||||
canAutoSync,
|
||||
onOpenEditModal,
|
||||
onOpenOAuth,
|
||||
onSetProxyTarget,
|
||||
@@ -395,11 +391,6 @@ export default function ConnectionsListPanel({
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
onToggleAutoSync={
|
||||
canAutoSync && handleToggleConnectionAutoSync
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
@@ -593,11 +584,6 @@ export default function ConnectionsListPanel({
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
onToggleAutoSync={
|
||||
canAutoSync && handleToggleConnectionAutoSync
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatResetCountdown } from "@/shared/utils/formatting";
|
||||
import type { ConnectionRowConnection } from "./ConnectionRow";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export interface CoolingConnectionsPanelProps {
|
||||
readonly connections: readonly ConnectionRowConnection[];
|
||||
@@ -37,6 +39,7 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
|
||||
|
||||
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
|
||||
const { connections } = props;
|
||||
const t = useTranslations("providers");
|
||||
// Tick once per second so the human-readable countdown updates.
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -58,12 +61,17 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
||||
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
/>
|
||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Currently cooling ({cooling.length})
|
||||
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
|
||||
count: cooling.length,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
||||
them until the timer expires — no manual disable required.
|
||||
{providerText(
|
||||
t,
|
||||
"coolingConnectionsDescription",
|
||||
"These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip them until the timer expires — no manual disable required."
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{cooling.map((c) => {
|
||||
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
||||
c.displayName ||
|
||||
c.name ||
|
||||
c.email ||
|
||||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||
(c.id
|
||||
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
|
||||
: providerText(t, "connectionFallback", "connection"));
|
||||
return (
|
||||
<li
|
||||
key={c.id ?? label}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user