diff --git a/.agents/INDEX.md b/.agents/INDEX.md index f3ebea0e..30deec95 100644 --- a/.agents/INDEX.md +++ b/.agents/INDEX.md @@ -1,10 +1,14 @@ # Pod — Documentation Index -> **pod · v0.0.82** · Bun + Next.js 16 + open-sse (local JS fork) + SQLite · port **20128** · [pod.lazuardy.tech](https://pod.lazuardy.tech) +> **pod · v0.0.82** · Bun + Next.js 16 + open-sse (typed local fork) + SQLite · port **20128** · [pod.lazuardy.tech](https://pod.lazuardy.tech) > Self-hosted AI gateway unifying 50+ LLM providers behind one OpenAI-compatible endpoint. -> **Last reviewed**: 2026-07-13. -> **Freshness note**: As of this review, `/api/monitoring/health` and `/api/monitoring/health/stream` are **public reads** (no API key required), on par with `/api/health`. Do not trust older architecture or knowledge docs that claim these endpoints require authentication. +> **Last reviewed**: 2026-08-07. +> **Freshness notes**: +> +> - `open-sse/` is TypeScript and included in root `tsc`; source paths in docs should use `.ts` even though imports keep `.js` suffixes for ESM/bundler resolution. +> - `/api/monitoring/health` and `/api/monitoring/health/stream` are **public reads** (no API key), on par with `/api/health`. Ignore older docs that claim auth. +> - Service worker (`public/sw.js`): **network-first** navigation + offline fallback; never reject `respondWith` / never `Response.error()` on images; no blind `controllerchange` reload. See gotcha §34 (`knowledge/04-gotchas.md`). --- @@ -24,7 +28,7 @@ | File | Covers | | ------------------------------------------------------------ | -------------------------------------------------------------- | | [architecture/00-engine.md](architecture/00-engine.md) | open-sse engine: routing, translation, streaming, crash guards | -| [architecture/01-app.md](architecture/01-app.md) | Next.js pages, API routes, middleware, PWA, stores | +| [architecture/01-app.md](architecture/01-app.md) | Next.js pages, API routes, routeAuth, PWA, stores | | [architecture/02-providers.md](architecture/02-providers.md) | Provider config, auth types, executors, translators, retry | | [architecture/03-data.md](architecture/03-data.md) | SQLite, Redis, offline cache, mutation queue | | [architecture/04-infra.md](architecture/04-infra.md) | Docker, Zeabur, Cloudflare, networking | @@ -51,18 +55,20 @@ | [knowledge/01-overview.md](knowledge/01-overview.md) | Quick facts, repo layout, three-layer architecture | | [knowledge/02-conventions.md](knowledge/02-conventions.md) | Coding, naming, body parsing, modal rules | | [knowledge/03-dev-workflow.md](knowledge/03-dev-workflow.md) | Commands, pre-push verification, Zeabur deploy | -| [knowledge/04-gotchas.md](knowledge/04-gotchas.md) | Common traps (parser quirks, Turbopack, abort) | +| [knowledge/04-gotchas.md](knowledge/04-gotchas.md) | Common traps (parser, Turbopack, abort, SW §34) | | [knowledge/05-open-issues.md](knowledge/05-open-issues.md) | Active watchlist | --- ## Other Directories -| Path | Purpose | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| issues/ | Historical audit and security analysis — verify against live code | -| reports/ | Release rollups and verification reports by version | -| plan/ | Draft plans: [js-to-ts-migration.md](plan/js-to-ts-migration.md), [openai-compat-fixes.md](plan/openai-compat-fixes.md), [optimizing-pod-for-multiple-instance.md](plan/optimizing-pod-for-multiple-instance.md), [voidzero-adoption.md](plan/voidzero-adoption.md) | +| Path | Purpose | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| skills/ | Cursor agent skills — ponytail suite from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail) (`/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`) | +| issues/ | Historical audits — start at [issues/INDEX.md](issues/INDEX.md); verify against live code | +| reports/ | Release rollups and verification reports by version | +| plan/ | [js-to-ts-migration.md](plan/js-to-ts-migration.md) (completed), [openai-compat-fixes.md](plan/openai-compat-fixes.md), [optimizing-pod-for-multiple-instance.md](plan/optimizing-pod-for-multiple-instance.md), [voidzero-adoption.md](plan/voidzero-adoption.md) (completed) | +| tests/ | SW seams: [../tests/SW-TEST-SEAM.md](../tests/SW-TEST-SEAM.md); unit `tests/unit/swShellCache.test.ts` | --- diff --git a/.agents/PRD.md b/.agents/PRD.md index 4d0e590e..4008d448 100644 --- a/.agents/PRD.md +++ b/.agents/PRD.md @@ -95,9 +95,9 @@ Pod is a self-hosted AI gateway that unifies 50+ LLM providers behind a single O ### Offline and PWA -- Service worker for offline reads (offlineJsonCache via IndexedDB) -- Offline mutation queue for safe idempotent writes -- Installable PWA with web app manifest +- Service worker (`public/sw.js`): network-first navigation with offline `/offline` fallback; no `Response.error()` on images; deploy-hash cache namespaces via `/sw-version.json` +- Offline reads via `offlineJsonCache` (IndexedDB); mutation queue for safe idempotent writes +- Installable PWA with web app manifest; registration-only lifecycle (no self-update UX) ## Non-Goals @@ -106,10 +106,17 @@ Pod is a self-hosted AI gateway that unifies 50+ LLM providers behind a single O - Not a multi-tenant SaaS (self-hosted single-tenant) - Not a replacement for provider-native SDKs +## Deployment & Branches + +- `canary` = active development; `main` = stable (promote via PR only) +- Zeabur: `pod` → `pod.lazuardy.tech` (port 20140); `pod-canary` → `pod-canary.zeabur.app` +- Compatibility gate: [compatibility-matrix.md](compatibility-matrix.md) +- Health: `/api/health` and `/api/monitoring/health*` are public reads + ## Product Constraints - **Bun-only** — never npm/pnpm -- **Local open-sse fork** — never replace with npm version, frozen as JS +- **Local open-sse fork** — never replace with npm version; TypeScript, included in root `tsc` - **SQLite primary store** — optional Redis for rate limiting - **Dark-only UI** — no light mode - **Defensive by default** — sanitized errors, safe streaming, crash guards @@ -120,7 +127,7 @@ Pod is a self-hosted AI gateway that unifies 50+ LLM providers behind a single O - **Chunked body reading**: Large request bodies (5MB+) are stream-read in chunks to prevent 9-15s stalls. `readBodyTextStream()` enforces the size cap mid-stream and returns `413` on overflow. - **Configurable body cap**: All mutation routes enforce a 50MB default body cap (env-tunable). `413` returned on overflow; no silent memory spikes. - **Compatibility first**: OpenAI/Anthropic error shapes, auth headers, streaming format, and tool calling match official specs. Any regression is a release blocker. -- **Offline-first dashboard**: Reads degrade via `offlineJsonCache`; writes queue via mutation stack; only safe idempotent mutations queued. +- **Offline-capable dashboard**: SW network-first for documents; reads degrade via `offlineJsonCache`; writes queue via mutation stack; only safe idempotent mutations queued. ## Key Numbers @@ -133,6 +140,6 @@ Pod is a self-hosted AI gateway that unifies 50+ LLM providers behind a single O | SSE idle timeout | 5 minutes | | Body cap | 50MB default (env: POD_MAX_REQUEST_BODY_BYTES, POD_MAX_CHAT_BODY_BYTES) | | Providers supported | 50+ | -| Executors | 19 (provider executors; `base.js` is a base class, `index.js` is a barrel) | +| Executors | 19 (provider executors; `base.ts` is a base class, `index.ts` is a barrel) | | API route groups | 26 | | Dashboard pages | 15 (top-level, no /dashboard prefix) | diff --git a/.agents/architecture/00-engine.md b/.agents/architecture/00-engine.md index cf45a000..ff442684 100644 --- a/.agents/architecture/00-engine.md +++ b/.agents/architecture/00-engine.md @@ -7,14 +7,14 @@ The open-sse engine is a local fork (never the npm package) that handles provide ``` open-sse/ config/ Provider definitions, model catalogs, runtime constants - executors/ Provider-specific HTTP clients (19 executors; base.js is a base class, index.js is a barrel) + executors/ Provider-specific HTTP clients (19 executors; base.ts is a base class, index.ts is a barrel) handlers/ Core chat handler: streaming and non-streaming paths services/ Model resolution, provider metadata, credential management, token refresh transformer/ Response transformation utilities translator/ Request/response format translation (OpenAI ↔ Claude ↔ Gemini) utils/ Stream processing, error handling, proxy fetch patch, RTK rtk/ Real Talk tool_result compression subsystem - index.js Public API surface — re-exports for src/sse/ consumers + index.ts Public API surface — re-exports for src/sse/ consumers ``` ## Executor Types @@ -38,7 +38,7 @@ Each provider gets its own executor in `open-sse/executors/`. They share a commo | ------------ | ----------------------------------------------------- | | `request/` | Client request → provider-native format | | `response/` | Provider-native response → OpenAI-compatible format | -| `formats.js` | Format constants (`openai`, `claude`, `gemini`, etc.) | +| `formats.ts` | Format constants (`openai`, `claude`, `gemini`, etc.) | | `helpers/` | Shared translation utilities | ### Claude-to-OpenAI Thinking Fix @@ -61,15 +61,15 @@ Each streaming response chunk passes through a TransformStream that applies form ## Invariants -| Rule | Where enforced | -| ------------------------------------------------------------ | ------------------------------- | -| SSE connection cap: 100 concurrent | `src/sse/handlers/chat.js` | -| SSE idle timeout: 5 minutes | `src/sse/handlers/chat.js` | -| Crash guard around stream processing | `open-sse/utils/stream.js` | -| Crash guard around chat core | `open-sse/handlers/chatCore.js` | -| Guarded peek-reader (inspect first chunk without consuming) | `open-sse/handlers/chatCore.js` | -| Transactional connection locking (`modelLockCount_${model}`) | `open-sse/handlers/chat.js` | -| Guarded fallback loop | `src/sse/handlers/chat.js` | +| Rule | Where enforced | +| ------------------------------------------------------------ | ----------------------------------------------- | +| SSE connection cap: 100 concurrent | `src/sse/handlers/chat.ts` | +| SSE stream stall timeout: 5 minutes | `open-sse/utils/stream.ts` (`STALL_TIMEOUT_MS`) | +| Crash guard around stream processing | `open-sse/utils/stream.ts` | +| Crash guard around chat core | `open-sse/handlers/chatCore.ts` | +| Guarded peek-reader (inspect first chunk without consuming) | `open-sse/handlers/chatCore.ts` | +| Transactional connection locking (`modelLockCount_${model}`) | `open-sse/services/accountFallback.ts` | +| Guarded fallback loop | `src/sse/handlers/chat.ts` | These guards are non-negotiable. Removing or weakening any of them risks process crashes or stream corruption. diff --git a/.agents/architecture/01-app.md b/.agents/architecture/01-app.md index df143211..0e5a05de 100644 --- a/.agents/architecture/01-app.md +++ b/.agents/architecture/01-app.md @@ -141,5 +141,5 @@ This layer sits between the API route and `open-sse/`. It manages the 100-connec - **Thin API routes**: Routes call into `lib/` services; no business logic in route handlers - **Zustand per domain**: Each domain (auth, providers, theme, notifications, header) gets its own store -- **PWA**: Service worker is registration-only (no auto-updates); offline reads via `offlineJsonCache`; writes queue via mutation stack +- **PWA**: SW registration-only (no auto-update UX). `public/sw.js` is **network-first** for navigations (offline `/offline` fallback); never reject `respondWith` / never `Response.error()` on images; `ServiceWorkerRegistrar` must not blind-reload on `controllerchange`. Offline reads via `offlineJsonCache`; writes via mutation queue. See gotcha §34. - **Header actions**: Route through `headerActionStore` diff --git a/.agents/architecture/02-providers.md b/.agents/architecture/02-providers.md index d86974cb..b491d64c 100644 --- a/.agents/architecture/02-providers.md +++ b/.agents/architecture/02-providers.md @@ -32,7 +32,7 @@ Provider definitions live in `src/shared/constants/providers.ts`. Model catalogs | Service account | GCP IAM | Vertex AI | | Free | No credentials needed | Kiro, Qwen Code, Gemini CLI, iFlow | -Token refresh logic lives in `open-sse/services/tokenRefresh.js` with provider-specific refreshers for Claude, Codex, Copilot, GitHub, Google, iFlow, and Qwen. +Token refresh logic lives in `open-sse/services/tokenRefresh.ts` with provider-specific refreshers for Claude, Codex, Copilot, GitHub, Google, iFlow, and Qwen. ## Executor Routing @@ -40,23 +40,23 @@ Executors live in `open-sse/executors/`. Each implements the same interface for | Executor | Provider(s) | Notable behavior | | -------------------------------- | ---------------------- | ------------------------------------------ | -| `default.js` | Most OpenAI-compatible | Standard passthrough | -| `vertex.js` | Vertex AI | GCP auth + strips `stream` field from body | -| `kiro.js` | Kiro AI | Transient overload body-gating for retry | -| `codex.js` | OpenAI Codex | Reasoning token budget normalization | -| `ollama-local.js` | Ollama | Local endpoint handling | -| `antigravity.js` | Antigravity | OAuth-based | -| `cursor.js` | Cursor IDE | OAuth-based | -| `github.js` | GitHub Copilot | OAuth token refresh | -| `grok-web.js` | xAI Grok (web) | Cookie-based | -| `perplexity-web.js` | Perplexity (web) | Cookie-based, x-pod-skip-reasoning | -| `iflow.js` | iFlow AI | Free access | -| `qoder.js` | Qoder | OAuth-based | -| `qwen.js` | Qwen Code | Free access | -| `opencode.js` / `opencode-go.js` | OpenCode | Free access | -| `commandcode.js` | Command Code | OAuth-based | -| `gemini-cli.js` | Gemini CLI | Free access | -| `azure.js` | Azure OpenAI | API key | +| `default.ts` | Most OpenAI-compatible | Standard passthrough | +| `vertex.ts` | Vertex AI | GCP auth + strips `stream` field from body | +| `kiro.ts` | Kiro AI | Transient overload body-gating for retry | +| `codex.ts` | OpenAI Codex | Reasoning token budget normalization | +| `ollama-local.ts` | Ollama | Local endpoint handling | +| `antigravity.ts` | Antigravity | OAuth-based | +| `cursor.ts` | Cursor IDE | OAuth-based | +| `github.ts` | GitHub Copilot | OAuth token refresh | +| `grok-web.ts` | xAI Grok (web) | Cookie-based | +| `perplexity-web.ts` | Perplexity (web) | Cookie-based, x-pod-skip-reasoning | +| `iflow.ts` | iFlow AI | Free access | +| `qoder.ts` | Qoder | OAuth-based | +| `qwen.ts` | Qwen Code | Free access | +| `opencode.ts` / `opencode-go.ts` | OpenCode | Free access | +| `commandcode.ts` | Command Code | OAuth-based | +| `gemini-cli.ts` | Gemini CLI | Free access | +| `azure.ts` | Azure OpenAI | API key | ## Format Translation @@ -91,4 +91,4 @@ When a provider returns rate-limit or overload errors: 3. Lockout status visible on `/health` page 4. Connection-level lockdown with exponential cooldown (v0.0.75+) -Account fallback logic lives in `open-sse/services/accountFallback.js`. +Account fallback logic lives in `open-sse/services/accountFallback.ts`. diff --git a/.agents/architecture/03-data.md b/.agents/architecture/03-data.md index 508d47ac..8b9fe92b 100644 --- a/.agents/architecture/03-data.md +++ b/.agents/architecture/03-data.md @@ -44,7 +44,7 @@ Pod uses a local-first storage model: - Browser-side read cache for dashboard data - Tag-based invalidation after safe mutations -- Service worker integration +- Complements SW shell caching (`public/sw.js` network-first navigations; see gotcha §34) ## Rate Limiting (`src/lib/rateLimit/`) diff --git a/.agents/architecture/04-infra.md b/.agents/architecture/04-infra.md index 48cf4128..cddde1cc 100644 --- a/.agents/architecture/04-infra.md +++ b/.agents/architecture/04-infra.md @@ -2,14 +2,14 @@ ## Runtime Stack -| Component | Choice | -| ---------- | ------------------------------------------------ | -| Runtime | Bun + Next.js 16 (standalone mode, Turbopack) | -| Language | TypeScript (strict mode); open-sse/ is frozen JS | -| Primary DB | SQLite at `~/.pod/pod.sqlite` | -| Cache DB | Optional Redis (when `REDIS_URL` is set) | -| Tunnel | Optional Cloudflared | -| Mesh | Optional Tailscale | +| Component | Choice | +| ---------- | --------------------------------------------------------------- | +| Runtime | Bun + Next.js 16 (standalone mode, Turbopack) | +| Language | TypeScript (strict mode); `open-sse/` is included in root `tsc` | +| Primary DB | SQLite at `~/.pod/pod.sqlite` | +| Cache DB | Optional Redis (when `REDIS_URL` is set) | +| Tunnel | Optional Cloudflared | +| Mesh | Optional Tailscale | ## Deployment diff --git a/.agents/compatibility-matrix.md b/.agents/compatibility-matrix.md index 94cc16ff..0ca46f51 100644 --- a/.agents/compatibility-matrix.md +++ b/.agents/compatibility-matrix.md @@ -112,4 +112,4 @@ Compatibility verified against: ## Version -Last reviewed: 2026-07-11 | Pod v0.0.82 +Last reviewed: 2026-07-24 | Pod v0.0.82 diff --git a/.agents/knowledge/01-overview.md b/.agents/knowledge/01-overview.md index 995cd708..6d71191f 100644 --- a/.agents/knowledge/01-overview.md +++ b/.agents/knowledge/01-overview.md @@ -2,21 +2,21 @@ **Pod** is a self-hosted AI gateway — a unified proxy for 50+ LLM providers behind a single OpenAI-compatible endpoint. -| Fact | Value | -| ----------- | ---------------------------------------------------------------------- | -| Version | v0.0.82 | -| Stack | Bun + Next.js 16 (TS, strict mode) + open-sse (local JS fork) + SQLite | -| Port | 20128 | -| Deployed at | pod.lazuardy.tech (Zeabur, Cloudflare DNS) | -| Data dir | `~/.pod/pod.sqlite` | -| Health | `GET /api/health` (public) | -| License | MIT | +| Fact | Value | +| ----------- | ------------------------------------------------------------------------- | +| Version | v0.0.82 | +| Stack | Bun + Next.js 16 (TS, strict mode) + open-sse (typed local fork) + SQLite | +| Port | 20128 | +| Deployed at | pod.lazuardy.tech (Zeabur, Cloudflare DNS) | +| Data dir | `~/.pod/pod.sqlite` | +| Health | `GET /api/health` + `/api/monitoring/health*` (public) | +| License | MIT | ## Three Layers | Layer | What | Where | | -------------- | ----------------------------------------------- | ----------- | -| **App** | Next.js pages, API routes, middleware, PWA | `src/` | +| **App** | Next.js pages, API routes, routeAuth, PWA | `src/` | | **Engine** | Provider routing, format translation, streaming | `open-sse/` | | **Data & Ops** | SQLite, cache, rate limiting, tunnels | `src/lib/` | @@ -37,7 +37,7 @@ | ----------------------------------- | ----------------------------------- | | `src/instrumentation.ts` | Next.js 16 startup, signal handlers | | `src/server-init.ts` | Global process handlers | -| `open-sse/index.js` | Engine public API | +| `open-sse/index.ts` | Engine public API | | `src/lib/localDb.ts` | Primary database access | | `src/shared/constants/config.ts` | Version, app config | | `src/shared/constants/providers.ts` | Provider definitions | diff --git a/.agents/knowledge/02-conventions.md b/.agents/knowledge/02-conventions.md index a9c2def7..42d6411f 100644 --- a/.agents/knowledge/02-conventions.md +++ b/.agents/knowledge/02-conventions.md @@ -2,19 +2,19 @@ ## Naming -| Element | Convention | Example | -| ----------------- | -------------------------------------- | ---------------------------------- | -| React components | PascalCase | `ConfirmModal`, `SegmentedControl` | -| Utility functions | camelCase | `sanitizeError`, `parseJsonBody` | -| API routes | kebab-case | `/v1/chat/completions` | -| Files | camelCase (JS/TS), kebab-case (routes) | `localDb.ts`, `chatCore.js` | -| Product name | lowercase | "pod" (internal), "Pod" (display) | +| Element | Convention | Example | +| ----------------- | ----------------------------------- | ---------------------------------- | +| React components | PascalCase | `ConfirmModal`, `SegmentedControl` | +| Utility functions | camelCase | `sanitizeError`, `parseJsonBody` | +| API routes | kebab-case | `/v1/chat/completions` | +| Files | camelCase (TS), kebab-case (routes) | `localDb.ts`, `chatCore.ts` | +| Product name | lowercase | "pod" (internal), "Pod" (display) | ## Imports - ESM only (`import`/`export`) - `@/` alias maps to `src/` -- TypeScript throughout (src/ is TS, engine is JS) +- TypeScript throughout (`src/` and `open-sse/`; `cloud/` has its own TS config) ## Components diff --git a/.agents/knowledge/03-dev-workflow.md b/.agents/knowledge/03-dev-workflow.md index 9597cddf..4070d959 100644 --- a/.agents/knowledge/03-dev-workflow.md +++ b/.agents/knowledge/03-dev-workflow.md @@ -22,6 +22,12 @@ bun run check && bun run test:run && bun run build All three must pass before pushing. No exceptions. +SW shell-cache regression (when touching `public/sw.js` / registrar): + +```bash +bun x vitest run tests/unit/swShellCache.test.ts +``` + ## Workflow Rules 1. **Update docs from live code** — documentation reflects current codebase, not intentions @@ -41,3 +47,11 @@ All three must pass before pushing. No exceptions. - `canary` is the active development branch - `main` is the stable/release branch - Conventional Commits format + +## Cursor Cloud + +- Workspace / Cloud environment default branch for development: **`canary`** +- Install helper: `scripts/cloud-dev-install.sh` +- Start helper: `scripts/cloud-dev-start.sh` (needs `JWT_SECRET` + `API_KEY_SECRET` from Secrets) +- Ponytail skills: `.agents/skills/ponytail*` — `/ponytail lite|full|ultra` +- See AGENTS.md → **Cursor Cloud specific instructions** diff --git a/.agents/knowledge/04-gotchas.md b/.agents/knowledge/04-gotchas.md index b5e65673..4e5b32d2 100644 --- a/.agents/knowledge/04-gotchas.md +++ b/.agents/knowledge/04-gotchas.md @@ -14,7 +14,7 @@ When adding or modifying routes, ensure the auth matcher in `routeAuth.ts` cover ## 4. Streaming Fragility -SSE code is complex with multiple nested guards. The crash guards in `open-sse/utils/stream.js` and `open-sse/handlers/chatCore.js`, and the guarded peek-reader in `chatCore.js`, must stay intact. Removing or weakening them risks process crashes. +SSE code is complex with multiple nested guards. The crash guards in `open-sse/utils/stream.ts` and `open-sse/handlers/chatCore.ts`, and the guarded peek-reader in `chatCore.ts`, must stay intact. Removing or weakening them risks process crashes. ## 5. Offline Cache Invalidation @@ -30,7 +30,7 @@ Build warnings may not fail the build. Always verify after deploy that the app s ## 8. Thinking Blocks -The Claude-to-OpenAI translator (`open-sse/translator/response/claude-to-openai.js`) must never emit `` or `` as content deltas. This causes client-side rendering bugs. +The Claude-to-OpenAI translator (`open-sse/translator/response/claude-to-openai.ts`) must never emit `` or `` as content deltas. This causes client-side rendering bugs. ## 9. Version Drift @@ -58,8 +58,18 @@ When a client disconnects mid-request (browser tab close, network drop, cancelle ## 32. Large body latency on canary (Zeabur cold-start) -The canary service at `pod-canary.zeabur.app` scales down to zero idle replicas. Cold start takes 15-30s for the first request. Subsequent requests are 0.3-0.5s. Prod (`pod.lazuardy.tech`) stays warm from constant traffic. Mitigation: add a cron/uptime monitor hitting `/api/health` every 5 minutes to keep the container warm, or disable scale-to-zero in Zeabur service config. +The canary service at `pod-canary.zeabur.app` scales down to zero idle replicas. Cold start takes 15-30s for the first request. Subsequent requests are 0.3-0.5s. Prod (`pod.lazuardy.tech`) stays warm from constant traffic. Mitigation: add a cron/uptime monitor hitting `/api/health` every 5 minutes to keep the container warm, or disable scale-to-zero in Zeabur service config. Sidebar `prefetch` is enabled for all routes except `/usage` (see d422698); with `prefetch={false}` everywhere, soft nav after cold start waits for RSC + route chunks only on click. ## 33. `readBodyTextStream` vs `request.text()` Avoid raw `request.text()` for bodies > 1MB on Zeabur/Bun. The Node.js HTTP body parser can stall for 9-15s on large payloads, especially with `curl/8.x` User-Agent. Use `readBodyTextStream()` from `@/lib/parseJsonBody` instead — it reads chunk-by-chunk with an explicit size cap and returns 413 mid-stream on overflow. + +## 34. ERR_FAILED after idle (SW vs network) + +Three failure classes look similar in the browser but need different fixes: + +1. **Service Worker (document / `/_next/static`)** — Navigation and static assets are intercepted by `public/sw.js`. A rejected `respondWith` promise or `Response.error()` surfaces as Chrome’s bare `ERR_FAILED` interstitial. Cmd+Shift+R often bypasses the SW for the document request and “fixes” the tab. Mitigation: network-first navigation, never reject `respondWith`, no `Response.error()` on images; avoid blind `location.reload()` on every `controllerchange` (SW already uses `skipWaiting` + `clients.claim`). + +2. **Idle browser ↔ Cloudflare connection** — Next.js RSC fetches (`?_rsc=`) and most `fetch()` calls are **not** handled by the SW. Soft reload can fail with `(failed)` and no HTTP status while hard reload opens a fresh connection. Classify in DevTools by request type and whether Size shows `from ServiceWorker`. + +3. **Canary cold-start vs prod warm** — `pod-canary.zeabur.app` can cold-start 15–30s after idle (see §32). Prod `pod.lazuardy.tech` is usually warm; correlate with `curl /api/health` at failure time before blaming the SW. diff --git a/.agents/plan/js-to-ts-migration.md b/.agents/plan/js-to-ts-migration.md index 9f6449e1..fb00cb28 100644 --- a/.agents/plan/js-to-ts-migration.md +++ b/.agents/plan/js-to-ts-migration.md @@ -2,6 +2,8 @@ Status: completed — historical +> **Update 2026-08-06**: `open-sse/` and `tests/` have been migrated to TypeScript on `cursor/p1`. The freeze decision below is historical. + > **Status: completed — repo is now TypeScript strict; `open-sse/` intentionally frozen as JS. Tooling is oxfmt + oxlint + tsc (Biome/ESLint removed).** > > The migration narrative below is preserved as history. **Tooling caveat:** inline commands and example diffs that still read `biome` / `eslint` / `eslint.config.mjs` were written before the VoidZero (oxfmt/oxlint) adoption and are now **historical** — the real gate is `bun run check` = oxfmt + oxlint + `tsc --noEmit`. References to `biome`/`eslint` reflect the tooling in use when this plan was authored, not the current setup. @@ -326,18 +328,18 @@ For each file: ## Phase 5 — SSE orchestration (`src/sse/`) -**Goal**: type the SSE layer that bridges typed routes to the (still-JS) `open-sse/` engine. +**Goal**: type the SSE layer that bridges typed routes to the `open-sse/` engine. **Scope**: `src/sse/handlers/{chat,embeddings,fetch,imageGeneration,search,stt,tts}.js`, `src/sse/services/{auth,model,tokenRefresh}.js`, `src/sse/utils/logger.js`. -**tsconfig strategy**: still `checkJs: false` globally. `open-sse/**` is excluded from the project `tsconfig.json`. +**Historical tsconfig strategy**: during Phase 5, `checkJs: false` stayed global. Current state: `open-sse/**/*.ts` is included in the project `tsconfig.json`. **Type strategy**: - `handlers/chat.ts`: - `export async function handleChat(request: Request, clientRawRequest?: unknown): Promise`. - Use the `OpenAIChatRequest` type from `src/app/api/v1/_types.ts` for the parsed body. - - At the `import "open-sse/index.js"` boundary, the engine is JS. Add a `src/sse/open-sse.d.ts` ambient declaration that types the public API we use (`handleChatCore`, `detectFormatByEndpoint`, etc.) based on what `open-sse/index.js` re-exports. Keep the declarations tight — only the symbols `src/sse/` actually calls. + - Historical note: this phase originally used an ambient `src/sse/open-sse.d.ts` boundary. Current state: `open-sse/index.ts` is typed directly, and imports keep `.js` suffixes only for ESM/bundler resolution. - `services/auth.ts`: `extractApiKey(request: Request): Promise`, `isValidApiKey(key: string): Promise`, `getProviderCredentials(providerId: string): Promise`. - `services/model.ts`: `getModelInfo(modelId: string): Promise`, `getComboInfo(comboId: string): Promise`. - `services/tokenRefresh.ts`: typed as `checkAndRefreshToken(provider: ProviderId, credentials: Credentials): Promise`. @@ -345,13 +347,13 @@ For each file: **Risks**: -- The crash guards in `open-sse/utils/stream.js` and `open-sse/handlers/chatCore.js`, and the guarded peek-reader in `chatCore.js`, are not in scope (they're in `open-sse/`, not `src/sse/`). The plan does **not** weaken them. -- The combo fallback logic in `handlers/chat.ts` (Phase 5) calls into `open-sse/services/combo.js` which is still JS. The ambient declaration must match the runtime behavior — verify by reading `open-sse/services/combo.js` and `index.js` before writing the `.d.ts`. +- The crash guards in `open-sse/utils/stream.ts` and `open-sse/handlers/chatCore.ts`, and the guarded peek-reader in `chatCore.ts`, are not in scope for SSE orchestration changes. The plan does **not** weaken them. +- The combo fallback logic in `handlers/chat.ts` calls into `open-sse/services/combo.ts`; verify behavior against the typed source before changing this boundary. **Exit criteria**: - All `src/sse/**` files converted. -- `src/sse/open-sse.d.ts` covers all imported symbols. +- `open-sse/index.ts` covers all imported symbols. - `tsc --noEmit` clean. - All 1338 tests pass. - `bun run build` succeeds. @@ -478,7 +480,7 @@ Same phased approach: Phase 8.1 = tooling, 8.2 = small utils, 8.3 = handlers, 8. - `Request`, `Response`, `URL` come from `@cloudflare/workers-types`. - `KVNamespace`, `D1Database`, `R2Bucket` come from the same package. - The Worker has its own `src/lib/cloud/localDb.js` (the lowdb in-memory stub — verify by reading `cloud/src/handlers/testClaude.js` and the `stubs/` dir). It is intentionally a separate type universe from `src/lib/localDb.ts`. Do not unify. -- `open-sse/handlers/testClaude.js` is a 410 stub (AGENTS.md rule). It stays as JS in `open-sse/`, but the cloud side that calls it (`cloud/src/handlers/...`) gets typed. +- `open-sse/handlers/testClaude.ts` is a 410 stub (AGENTS.md rule); the cloud side that calls it (`cloud/src/handlers/...`) is typed separately. **Risks**: @@ -500,16 +502,16 @@ Same phased approach: Phase 8.1 = tooling, 8.2 = small utils, 8.3 = handlers, 8. Three options, in preference order: -1. **Recommended: freeze `open-sse/` as JS.** Convert only the **ambient declarations** in `src/sse/open-sse.d.ts` (Phase 5) to describe the public API. Add a `tsconfig.exclude` entry to make it explicit. Reasons: high migration cost, low day-to-day churn from this repo (it's a fork synced from upstream), most of its surface is consumed through `src/sse/` which is now fully typed. -2. **Convert incrementally with `allowJs: true` only.** Same shape as the main app. Useful if the fork starts seeing internal feature work. The translator and handler cores still need `any`-grade escape hatches for the streaming transforms. -3. **Skip entirely.** Same as (1) but without the ambient `.d.ts`. Callers use `unknown` and cast at use sites. Worst of both worlds. +1. **Chosen current state: migrate `open-sse/` to TypeScript.** The engine now has `.ts` source, no ambient `src/sse/open-sse.d.ts`, and root `tsc` includes `open-sse/**/*.ts`. +2. **Historical alternative: keep JS with `allowJs: true` only.** This was useful while the fork was still untyped. +3. **Historical alternative: defer engine typing entirely.** This was rejected by the completed migration. **Exit criteria** (assuming option 1): - Documented in `.agents/plan/js-to-ts-migration.md` (this file) under "Final State". -- `tsconfig.json` excludes `open-sse/**` explicitly. -- `src/sse/open-sse.d.ts` exists and is referenced from `src/sse/**` (it should be, after Phase 5). -- AGENTS.md "Project Identity" updated to: `Bun + Next.js 16 + TypeScript (src/, cloud/), open-sse fork stays JS`. +- `tsconfig.json` includes `open-sse/**/*.ts`. +- `src/sse/open-sse.d.ts` has been deleted; imports resolve to typed `open-sse/*.ts` source. +- AGENTS.md "Project Identity" says the engine is the local TypeScript `open-sse/` fork. **Effort**: small (decision + docs). @@ -539,7 +541,7 @@ In `Project Identity`: ```diff - - Runtime: Bun + Next.js 16 (JS, no TS) -+ - Runtime: Bun + Next.js 16 + TypeScript (src/, cloud/); open-sse/ stays JS ++ - Runtime: Bun + Next.js 16 + TypeScript (src/, open-sse/, cloud/) ``` In `Non-Negotiable Rules`: @@ -554,8 +556,8 @@ Update `02-conventions.md`: ## Risks Specific to This Project -1. **Dynamic proxy patterns in `open-sse/handlers/chat.js` and `open-sse/translator/`** — not in scope (Phase 9 freezes as JS). The TS path interacts only through `src/sse/open-sse.d.ts`. If `open-sse/` is later migrated, expect significant `any` use at provider boundaries. -2. **Web Streams / `TransformStream` API surface** — `lib.dom.d.ts` covers `ReadableStream`, `WritableStream`, `TransformStream`, `TransformStreamDefaultController`. The codebase uses these in `src/sse/` (readable stream pumps) and `open-sse/translator/` (transform pipelines). For `open-sse/`, freezing as JS sidesteps the question. +1. **Dynamic proxy patterns in `open-sse/handlers/chatCore.ts` and `open-sse/translator/`** — now typed in the engine. Expect narrow `unknown`/validated boundaries where provider payloads vary. +2. **Web Streams / `TransformStream` API surface** — `lib.dom.d.ts` covers `ReadableStream`, `WritableStream`, `TransformStream`, `TransformStreamDefaultController`. The codebase uses these in `src/sse/` (readable stream pumps) and `open-sse/translator/` (transform pipelines). 3. **Bun-specific globals** — `Bun.RedisClient` (used in `src/lib/rateLimit/redis.js`), `Bun.serve` (not currently used in the main app but available), `bun:sqlite` (used via `next.config.mjs`'s `serverExternalPackages: ["bun:sqlite"]`). `@types/bun` covers all of these. The `bun:sqlite` import in `src/lib/sqlite/connection.js` does not have its own type declarations; use `// @ts-expect-error bun:sqlite has no upstream types` or add a `src/types/bun-sqlite.d.ts` ambient declaration. 4. **next.config / JSX** — Next.js 16 + TS is well-trodden. No special handling. `next-env.d.ts` is auto-generated. 5. **Bun import attributes** — `import pkg from "../../../package.json" with { type: "json" }` is supported in TS 5.3+ via `--moduleResolution bundler`. We have `bundler` set. Confirmed. diff --git a/.agents/plan/openai-compat-fixes.md b/.agents/plan/openai-compat-fixes.md index b2ba781b..55055187 100644 --- a/.agents/plan/openai-compat-fixes.md +++ b/.agents/plan/openai-compat-fixes.md @@ -1,11 +1,11 @@ # OpenAI-Compatible API Production-Readiness Fix Plan -Status: planned · Branch: canary · Scope: bring Pod to production-grade OpenAI compatibility +Status: largely shipped on canary (package remains v0.0.82; some commits titled “v0.0.83” never bumped package.json) · Branch: canary Audited: 2026-07-11 via code review + live black-box tests against https://pod.lazuardy.tech/v1 ## Method -1. Code review of `src/` + `open-sse/` (frozen JS, editable but no TS conversion). +1. Code review of `src/` + `open-sse/` (typed local fork). 2. Live cross-check: every finding re-tested directly against production with a real API key. 3. Findings confirmed/refuted from production evidence before planning fixes. @@ -30,13 +30,13 @@ Audited: 2026-07-11 via code review + live black-box tests against https://pod.l - File: `src/app/api/v1/responses/route.ts` (only this file; open-sse untouched). - Add a local helper `chatCompletionToResponse(cc, fallbackId)` that maps `object:"chat.completion"` -> `object:"response"` with `id:"resp_"+cc.id`, `output:[{type:"message", content:[{type:"output_text", text}]}]`, and `usage` mapped to `input_tokens`/`output_tokens`/`total_tokens`. - In `POST`, read body once via `readBodyTextStream` to detect `stream`. If `!stream`, call `handleChat`, then convert the JSON response with the helper. -- `handleResponsesCore` in `open-sse/handlers/responsesHandler.js` is confirmed dead code and does NOT build the shape - do not wire it in (Option B chosen: shortest correct). +- `handleResponsesCore` in `open-sse/handlers/responsesHandler.ts` is confirmed dead code and does NOT build the shape - do not wire it in (Option B chosen: shortest correct). - Verification: `POST /v1/responses {"stream":false}` -> `object:"response"`, `output[0].type=="message"`. ### F2 - Responses ignored params - File: `src/app/api/v1/responses/route.ts`. -- Keep silent-ignore for `store`/`truncation`/`include`/`reasoning` (already stripped in `openai-responses.js`; fine for a gateway). +- Keep silent-ignore for `store`/`truncation`/`include`/`reasoning` (already stripped in `openai-responses.ts`; fine for a gateway). - If `previous_response_id` is present and non-empty -> return `400 {"code":"invalid_request_error","message":"previous_response not found"}` (Pod stores nothing). - Verification: `POST` with `previous_response_id` -> `400`. @@ -56,7 +56,7 @@ Audited: 2026-07-11 via code review + live black-box tests against https://pod.l - `src/lib/rateLimit/redis.ts` (~line 103) and `memory.ts` (~line 104): return `remaining` + `resetSeconds` alongside `ok`. - `src/lib/rateLimit/index.ts`: add one helper `attachRateLimitHeaders(res, {limit, remaining, reset})` emitting `x-ratelimit-limit-requests`, `x-ratelimit-remaining-requests`, `x-ratelimit-reset-requests`; also add these 3 headers to `rateLimitResponse()` (429 path). Apply helper in both success return sites (redis + memory) only when `config` exists. - Token-based headers omitted (Pod tracks RPM + concurrent only, not tokens) - honest minimal set. -- `open-sse/utils/error.js` (~line 35): add `Access-Control-Expose-Headers: Retry-After, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests` to the shared error header block so browsers can read them. +- `open-sse/utils/error.ts` (~line 35): add `Access-Control-Expose-Headers: Retry-After, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests` to the shared error header block so browsers can read them. - Verification: `curl -D - /v1/chat/completions` -> 3 `x-ratelimit-*` headers present. ### F8 - Sanitize topology leak @@ -68,15 +68,15 @@ Audited: 2026-07-11 via code review + live black-box tests against https://pod.l ### F3 - TTS body params (code-level; needs capable provider to verify) - `src/sse/handlers/tts.ts` (~47-53): read `response_format` from **body** (fallback query), and read `voice` + `speed` from body. Forward to `handleTtsCore`. -- `open-sse/handlers/ttsCore.js` (~51-58): add `voice`/`speed` to destructure; pass to adapter `synthesize(..., {language, voice, speed})`. -- `open-sse/handlers/ttsProviders/{index,openai,openrouter,gemini}.js`: honor `opts.voice` (override suffix) and `opts.speed`; others ignore `speed` (YAGNI). +- `open-sse/handlers/ttsCore.ts` (~51-58): add `voice`/`speed` to destructure; pass to adapter `synthesize(..., {language, voice, speed})`. +- `open-sse/handlers/ttsProviders/{index,openai,openrouter,gemini}.ts`: honor `opts.voice` (override suffix) and `opts.speed`; others ignore `speed` (YAGNI). - Verification requires a TTS-capable provider (e.g. OpenAI `tts-1`) configured; send `{"voice":"alloy","speed":1.2,"response_format":"opus"}` and assert honored. ### F4 - Translations distinct from transcriptions (code-level; needs capable provider) - `src/app/api/v1/audio/translations/route.ts` (~21): call `handleStt(request, {translate:true})`. - `src/sse/handlers/stt.ts` (~23): thread `translate`; when set, restrict to whisper-1 and `formData.delete("language")`. -- `open-sse/handlers/sttCore.js` (~218): accept `translate`; on OpenAI-compatible path skip `language` (whisper translates to English by default). Deepgram/Gemini lack true translation - document as partial. +- `open-sse/handlers/sttCore.ts` (~218): accept `translate`; on OpenAI-compatible path skip `language` (whisper translates to English by default). Deepgram/Gemini lack true translation - document as partial. - Verification requires whisper-1-capable provider; assert English output and `language` dropped. ## Execution order @@ -101,4 +101,4 @@ Then re-run the production curl cross-checks above against the Zeabur canary dep - F0 confirmed correct - no action. - F3/F4 are real code defects but cannot be exercised on the current deployment (no audio-capable provider). Fix is still worth landing for correctness; mark verification as blocked-on-provider-config. -- open-sse/ stays frozen JS: edits allowed, no TS conversion, type surface via `src/sse/open-sse.d.ts` only if signatures change. +- `open-sse/` is TypeScript and included in root `tsc`; update typed exports directly if signatures change. diff --git a/.agents/plan/voidzero-adoption.md b/.agents/plan/voidzero-adoption.md index 4081764a..ec57658d 100644 --- a/.agents/plan/voidzero-adoption.md +++ b/.agents/plan/voidzero-adoption.md @@ -8,10 +8,10 @@ Adopt Oxlint (and conditionally Oxfmt) from the VoidZero toolchain. Skip Vite 8, Pod's dev/build chain is owned by Next.js 16 (Turbopack). VoidZero's bundler-side tools (Vite 8, Rolldown, Vite+, tsdown) are inapplicable to a Next.js app. The two VoidZero tools that _are_ applicable are: -- **Oxlint** — replaces the slow ESLint layer in `bun run check`. -- **Oxfmt** — kept as a future option; Biome already formats and Pod is happy with it. +- **Oxlint** — replaces ESLint; `bun run check` / `lint` use `--deny-warnings`. +- **Oxfmt** — replaces Biome; shipped (see footer). -Everything else in the VoidZero lineup is `skip` or `future` for Pod today. +Everything else in the VoidZero lineup remains `skip` or `future` for Pod. ## Not applicable diff --git a/.agents/reports/health-endpoint-v0.0.63.md b/.agents/reports/health-endpoint-v0.0.63.md index 6b239b7b..0f45c409 100644 --- a/.agents/reports/health-endpoint-v0.0.63.md +++ b/.agents/reports/health-endpoint-v0.0.63.md @@ -6,4 +6,4 @@ The operational health surface was expanded so one endpoint can summarize key ru ## Lasting Rule -`/api/monitoring/health` is the detailed operational surface; `/api/health` remains the public heartbeat. +`/api/monitoring/health` is the detailed operational surface; `/api/health` remains the public heartbeat. **Update (2026-07):** monitoring health + stream are also **public reads** (API-key guard removed). diff --git a/.agents/skills/ponytail-audit/SKILL.md b/.agents/skills/ponytail-audit/SKILL.md new file mode 100644 index 00000000..5582d103 --- /dev/null +++ b/.agents/skills/ponytail-audit/SKILL.md @@ -0,0 +1,41 @@ +--- +name: ponytail-audit +description: > + Whole-repo audit for over-engineering. Like ponytail-review, but scans the + entire codebase instead of a diff: a ranked list of what to delete, simplify, + or replace with stdlib/native equivalents. Use when the user says "audit this + codebase", "audit for over-engineering", "what can I delete from this repo", + "find bloat", "ponytail-audit", or "/ponytail-audit". One-shot report, does + not apply fixes. +--- + +ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank +findings biggest cut first. + +## Tags + +Same as ponytail-review: + +- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing. +- `stdlib:` hand-rolled thing the standard library ships. Name the function. +- `native:` dependency or code doing what the platform already does. Name the feature. +- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller. +- `shrink:` same logic, fewer lines. Show the shorter form. + +## Hunt + +Deps the stdlib or platform already ships, single-implementation interfaces, +factories with one product, wrappers that only delegate, files exporting one +thing, dead flags and config, hand-rolled stdlib. + +## Output + +One line per finding, ranked: ` . . [path]`. +End with `net: - lines, - deps possible.` Nothing to cut: `Lean already. Ship.` + +## Boundaries + +Scope: over-engineering and complexity only. Correctness bugs, security holes, +and performance are explicitly out of scope. Route them to a normal review +pass. Lists findings, applies nothing. One-shot. +"stop ponytail-audit" or "normal mode" to revert. diff --git a/.agents/skills/ponytail-debt/SKILL.md b/.agents/skills/ponytail-debt/SKILL.md new file mode 100644 index 00000000..e7f6c8e8 --- /dev/null +++ b/.agents/skills/ponytail-debt/SKILL.md @@ -0,0 +1,44 @@ +--- +name: ponytail-debt +description: > + Harvest every `ponytail:` comment in the codebase into a debt ledger, so the + deliberate shortcuts and deferrals ponytail leaves behind get tracked instead + of rotting into "later means never". Use when the user says "ponytail debt", + "/ponytail-debt", "what did ponytail defer", "list the shortcuts", "ponytail + ledger", or "what did we mark to do later". One-shot report, changes nothing. +--- + +Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming +its ceiling and upgrade path. This collects them into one ledger so a deferral +can't quietly become permanent. + +## Scan + +Grep the repo for comment markers, skipping `node_modules`, `.git`, and build +output: + +`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them) + +Each hit is one ledger row. The comment prefix keeps prose that merely mentions +the convention out of the ledger. + +## Output + +One row per marker, grouped by file: + +`:, . ceiling: . upgrade: .` + +The convention is `ponytail: , `, so pull the ceiling +and the trigger straight from the comment. Want an owner per row too? add +`git blame -L,`. + +Flag the rot risk: any `ponytail:` comment that names no upgrade path or +trigger gets a `no-trigger` tag, those are the ones that silently rot. + +End with ` markers, with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.` + +## Boundaries + +Reads and reports only, changes nothing. To persist it, ask and it writes the +ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or +"normal mode" to revert. diff --git a/.agents/skills/ponytail-gain/SKILL.md b/.agents/skills/ponytail-gain/SKILL.md new file mode 100644 index 00000000..012e37b6 --- /dev/null +++ b/.agents/skills/ponytail-gain/SKILL.md @@ -0,0 +1,50 @@ +--- +name: ponytail-gain +description: > + Show ponytail's measured impact as a compact scoreboard: less code, less + cost, more speed, from the benchmark medians. One-shot display, not a + persistent mode, and not a per-repo number. Trigger: /ponytail-gain, + "ponytail gain", "what does ponytail save", "show ponytail impact", + "ponytail scoreboard". +--- + +# Ponytail Gain + +Display this scoreboard when invoked. One-shot: do NOT change mode, write flag +files, or persist anything. + +The figures are the published benchmark medians (5 everyday tasks: email +validator, debounce, CSV sum, countdown timer, rate limiter; three models: +Haiku, Sonnet, Opus). They are measured, not computed from the current repo. +Source: `benchmarks/` and the README. + +## Scoreboard + +Render plain ASCII bars. The bar length shows the measured range; the label +carries the exact figure: + +``` + ponytail gain benchmark median · 5 tasks · 3 models + + Lines of code no-skill ████████████████████ 100% + ponytail ██▌················· 6–20% ▼ 80–94% + Cost no-skill ████████████████████ 100% + ponytail █████▌·············· 23–53% ▼ 47–77% + Speed ponytail ▸ 3–6× faster + + This repo: /ponytail-debt (shortcuts you deferred) + /ponytail-audit (what's still cuttable) +``` + +## Honesty boundary + +These are benchmark medians, not this repo. NEVER print a per-repo savings +number ("you saved X lines/tokens here"): the unbuilt version was never +written, so there is no real baseline to subtract from in a live repo. The +only real per-repo figures come from `/ponytail-debt` (a counted ledger), and +this card points there instead of inventing one. + +## Boundaries + +One-shot display. Edits nothing, changes no mode. +"stop ponytail" or "normal mode": revert. diff --git a/.agents/skills/ponytail-help/SKILL.md b/.agents/skills/ponytail-help/SKILL.md new file mode 100644 index 00000000..b1bab21d --- /dev/null +++ b/.agents/skills/ponytail-help/SKILL.md @@ -0,0 +1,73 @@ +--- +name: ponytail-help +description: > + Quick-reference card for all ponytail modes, skills, and commands. + One-shot display, not a persistent mode. Trigger: /ponytail-help, + "ponytail help", "what ponytail commands", "how do I use ponytail". +--- + +# Ponytail Help + +Display this reference card when invoked. One-shot, do NOT change mode, +write flag files, or persist anything. + +## Levels + +| Level | Trigger | What change | +| --------- | ----------------- | ----------------------------------------------------------------------------------- | +| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. | +| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. | +| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. | + +Level sticks until changed or session end. + +## Skills + +| Skill | Trigger | What it does | +| ------------------- | ------------------ | -------------------------------------------------------------------- | +| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. | +| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` | +| **ponytail-audit** | `/ponytail-audit` | Whole-repo over-engineering audit: ranked list of what to delete. | +| **ponytail-debt** | `/ponytail-debt` | Harvest `ponytail:` shortcut comments into a tracked ledger. | +| **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. | +| **ponytail-help** | `/ponytail-help` | This card. | + +Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code +and OpenCode use the slash-command forms above (OpenCode ships all six as +slash commands). + +## Deactivate + +Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. +`/ponytail off` also works. + +## Configure Default Mode + +Default mode = `full`, auto-active every session. Change it: + +**Environment variable** (highest priority): + +```bash +export PONYTAIL_DEFAULT_MODE=ultra +``` + +**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`): + +```json +{ "defaultMode": "lite" } +``` + +Set `"off"` to disable auto-activation on session start, activate manually +with `/ponytail` when wanted. + +Resolution: env var > config file > `full`. + +## Update + +Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`. + +If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow. + +## More + +Full docs + examples: https://github.com/DietrichGebert/ponytail diff --git a/.agents/skills/ponytail-review/SKILL.md b/.agents/skills/ponytail-review/SKILL.md new file mode 100644 index 00000000..e137a855 --- /dev/null +++ b/.agents/skills/ponytail-review/SKILL.md @@ -0,0 +1,57 @@ +--- +name: ponytail-review +description: > + Code review focused exclusively on over-engineering. Finds what to delete: + reinvented standard library, unneeded dependencies, speculative abstractions, + dead flexibility. One line per finding: location, what to cut, what replaces + it. Use when the user says "review for over-engineering", "what can we + delete", "is this over-engineered", "simplify review", or invokes + /ponytail-review. Complements correctness-focused review, this one only + hunts complexity. +--- + +Review diffs for unnecessary complexity. One line per finding: location, what +to cut, what replaces it. The diff's best outcome is getting shorter. + +## Format + +`L: . .`, or `:L: ...` for +multi-file diffs. + +Tags: + +- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing. +- `stdlib:` hand-rolled thing the standard library ships. Name the function. +- `native:` dependency or code doing what the platform already does. Name the feature. +- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller. +- `shrink:` same logic, fewer lines. Show the shorter form. + +## Examples + +❌ "This EmailValidator class might be more complex than necessary, have you +considered whether all these validation rules are needed at this stage?" + +✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.` + +✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.` + +✅ `repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.` + +✅ `L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.` + +✅ `L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.` + +## Scoring + +End with the only metric that matters: `net: - lines possible.` + +If there is nothing to cut, say `Lean already. Ship.` and stop. + +## Boundaries + +Scope: over-engineering and complexity only. Correctness bugs, security holes, +and performance are explicitly out of scope. Route them to a normal review +pass, not this one. A single smoke test or `assert`-based +self-check is the ponytail minimum, not bloat, never flag it for deletion. +Does not apply the fixes, only lists them. +"stop ponytail-review" or "normal mode": revert to verbose review style. diff --git a/.agents/skills/ponytail/SKILL.md b/.agents/skills/ponytail/SKILL.md new file mode 100644 index 00000000..6e2b4b9f --- /dev/null +++ b/.agents/skills/ponytail/SKILL.md @@ -0,0 +1,121 @@ +--- +name: ponytail +description: > + Forces the laziest solution that actually works, simplest, shortest, most + minimal. Channels a senior dev who has seen everything: question whether the + task needs to exist at all (YAGNI), reach for the standard library before + custom code, native platform features before dependencies, one line before + fifty. Supports intensity levels: lite, full (default), ultra. Use on ANY + coding task: writing, adding, refactoring, fixing, reviewing, or designing + code, and choosing libraries or dependencies. Also use whenever the user + says "ponytail", "be lazy", "lazy mode", "simplest solution", "minimal + solution", "yagni", "do less", or "shortest path", or complains about + over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT + use for non-coding requests (general knowledge, prose, translation, + summaries, recipes). +argument-hint: "[lite|full|ultra]" +license: MIT +--- + +# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: `/ponytail lite|full|ultra`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project — but it runs _after_ you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. +- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. +- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path (`# ponytail: global lock, per-account locks if throughput matters`). + +## Output + +Code first. Then at most three short lines: what was skipped, when to add it. +No essays, no feature tours, no design notes. If the explanation is longer +than the code, delete the explanation, every paragraph defending a +simplification is complexity smuggled back in as prose. Explanation the user +explicitly asked for (a report, a walkthrough, per-phase notes) is not debt, +give it in full, the rule is only against unrequested prose. + +Pattern: `[code] → skipped: [X], add when [Y].` + +## Intensity + +| Level | What change | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | +| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. | +| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. | +| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. | + +Example: "Add a cache for these API responses." + +- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class." +- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short." +- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate." + +## When NOT to be lazy + +Never simplify away: input validation at trust boundaries, error handling +that prevents data loss, security measures, accessibility basics, anything +explicitly requested. User insists on the full version → build it, no +re-arguing. + +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + +Hardware is never the ideal on paper: a real clock drifts, a real sensor +reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not +just less code, the physical world needs tuning a minimal model can't see. + +Lazy code without its check is unfinished. Non-trivial logic (a branch, a +loop, a parser, a money/security path) leaves ONE runnable check behind, the +smallest thing that fails if the logic breaks: an `assert`-based +`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no +fixtures, no per-function suites unless asked. Trivial one-liners need no +test, YAGNI applies to tests too. + +## Boundaries + +Ponytail governs what you build, not how you talk (pair with Caveman for +terse prose). "stop ponytail" / "normal mode": revert. Level persists until +changed or session end. + +The shortest path to done is the right path. diff --git a/.gitignore b/.gitignore index 1e7d2731..f2e4d40c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ mastracode # next.js /.next/ -# generated per-build SW version (written by scripts/gen-sw-version.mjs) +# generated per-build SW version (written by scripts/gen-sw-version.ts) /public/sw-version.json /out/ product @@ -56,6 +56,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +open-sse/**/*.js .bin/* data/ diff --git a/.npmignore b/.npmignore index b7e03e79..ee64e472 100644 --- a/.npmignore +++ b/.npmignore @@ -20,8 +20,8 @@ CLIProxyAPI/ .gitignore .env* jsconfig.json -postcss.config.mjs -next.config.mjs +postcss.config.cts +next.config.ts tsconfig.json # Build artifacts that shouldn't be published diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..55c15df3 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [] +} diff --git a/.oxlintrc.json b/.oxlintrc.json index e2bb1a23..e74a86c1 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,17 +17,6 @@ "eqeqeq": "warn" }, "overrides": [ - { - "files": ["open-sse/**"], - "rules": { - "no-unused-vars": "off", - "eqeqeq": "off", - "no-control-regex": "off", - "no-useless-catch": "off", - "no-useless-rename": "off", - "no-unused-expressions": "off" - } - }, { "files": ["src/**"], "rules": { diff --git a/AGENTS.md b/AGENTS.md index 0632f4ad..5ce9d12a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,19 +12,20 @@ Operational rules for AI agents working on the **Pod** project. ## Learned Workspace Facts -- This session: `/api/monitoring/health` + `/api/monitoring/health/stream` are now PUBLIC reads (auth guard removed, `src/app/api/monitoring/health/_auth.tsx` deleted) — consistent with `/api/health`. Health dashboard `/health` page fetches them unauthenticated; the old 401 caused the "Network unavailable. Showing cached health snapshot." toast on prod. +- `/api/monitoring/health` + `/api/monitoring/health/stream` are PUBLIC reads (auth guard removed, `src/app/api/monitoring/health/_auth.tsx` deleted) — consistent with `/api/health`. Health dashboard `/health` fetches them unauthenticated; the old 401 caused the "Network unavailable. Showing cached health snapshot." toast on prod. - `changelogUrl` in `src/shared/constants/config.ts` uses `refs/heads/canary` (never `master` — dead branch 404s). - `src/app/api/proxy-pools/vercel-deploy/route.ts` trailing-slash trim uses a real `/\\/$/` regex (an earlier `/\\\\/$/` matched a backslash, producing `//`). -- New rate-limit env added: `RATELIMIT_KEY_PREFIX` (Redis namespace isolation) and `RATELIMIT_REDIS_TIMEOUT_MS` (default 1000) — must appear in README env table. -- `.gitignore` ignores agent-tool dirs: `.codegraph`, `.astro`, `.mimocode`, `.opencode`, `mastracode` (plus `.cursor`, `.commandcode`, `.pi`, `.claude`). -- open-sse/ has 19 provider executors (base.js is the base class, index.js is the barrel) — not "20". `src/lib/` holds router/translators; executors/translators live frozen in the `open-sse/` JS fork. +- Rate-limit env: `RATELIMIT_KEY_PREFIX` (Redis namespace isolation) and `RATELIMIT_REDIS_TIMEOUT_MS` (default 1000) — must appear in README env table. +- `.gitignore` ignores agent-tool dirs: `.codegraph`, `.astro`, `.mimocode`, `.opencode`, `mastracode`, `.rwx` (plus `.cursor`, `.commandcode`, `.pi`, `.claude`); do not commit those dirs. +- open-sse/ has 19 provider executors (base.ts is the base class, index.ts is the barrel) — not "20". `src/lib/` holds router/translators; executors/translators live in the typed `open-sse/` fork. - Path dirs with parentheses (e.g. `src/app/(dashboard)/`) break naive `sed 's/([0-9].*//'` patterns — use a paren-aware pattern when parsing `tsc` output. +- Chrome `ERR_FAILED` interstitial after idle (fixed by hard reload) is often SW-side: `public/sw.js` must keep network-first navigation, never reject `respondWith`, and avoid `Response.error()` (esp. images); `ServiceWorkerRegistrar` must not blind `location.reload()` on every `controllerchange`. RSC/`?_rsc=` fetches are not SW-intercepted (idle CF/TLS is a separate failure mode). ## Project Identity - **Project name**: pod, v0.0.82 - **Runtime**: Bun + Next.js 16 (TS, strict mode) -- **Engine**: open-sse/ (local fork, not npm, frozen as JS) +- **Engine**: open-sse/ (local fork, not npm, TypeScript) - **Data**: SQLite at ~/.pod/pod.sqlite - **Port**: 20128 - **Health**: GET /api/health (public) @@ -70,14 +71,14 @@ Operational rules for AI agents working on the **Pod** project. 5. Connection locking must stay transactional. 6. Preserve modelLockCount\_${model} semantics. 7. Keep the guarded fallback loop in src/sse/handlers/chat.ts. -8. Keep the outer crash guard in open-sse/utils/stream.js. -9. Keep the guarded peek-reader behavior in open-sse/handlers/chatCore.js. -10. open-sse/ is frozen as JS — do NOT convert open-sse/ source files. Type surface via src/sse/open-sse.d.ts. +8. Keep the outer crash guard in open-sse/utils/stream.ts. +9. Keep the guarded peek-reader behavior in open-sse/handlers/chatCore.ts. +10. open-sse/ is TypeScript (strict, included in `tsc`). Keep `.js` import path suffixes (ESM/bundler convention). Do not replace the local fork with the npm package. 11. Regex literals with flags that look unterminated to Turbopack must use `new RegExp()` — apply in any file where Turbopack fails to parse a regex literal. 12. `src/instrumentation.ts` is the canonical startup path (Next.js 16) — runs `initializeApp()` + signal handlers in production; side-effect imports in layout.tsx for startup code have been removed. 13. AbortError at `node:_http_server` (client disconnect) must be classified as `[ClientDisconnect]`, not `[FATAL]`. SSE stream wrappers use `controller.close()` (not `controller.error(err)`) on reader abort. See `.agents/knowledge/04-gotchas.md` item 31. -14. `open-sse/` and `cloud/` are excluded from `tsc` (tsconfig `exclude`). Do NOT consume symbols exported from `open-sse/` in `src/` — tsc will not see them and the production build fails. Keep cross-boundary constants inlined in `src/` (e.g. rate-limit header constants in `src/lib/rateLimit/index.ts`). -15. `next.config.mjs` `serverExternalPackages` must include `undici` (and `bun:sqlite`). undici v8 throws a bare `Error` when Turbopack bundles its top-level code into the standalone server chunk, breaking dynamic `import("undici")` in server routes (`src/app/api/proxy-pools/[id]/test/route.ts`) and `src/lib/network/`. Keep undici external (loaded from `node_modules` at runtime) — never bundle it. +14. `cloud/` remains excluded from root `tsc` (has its own tsconfig). `open-sse/` is included. Prefer importing typed symbols from `open-sse/`; keep cross-boundary constants inlined in `src/` when bundling constraints require it (e.g. rate-limit headers). +15. `next.config.ts` `serverExternalPackages` must include `undici` (and `bun:sqlite`). undici v8 throws a bare `Error` when Turbopack bundles its top-level code into the standalone server chunk, breaking dynamic `import("undici")` in server routes (`src/app/api/proxy-pools/[id]/test/route.ts`) and `src/lib/network/`. Keep undici external (loaded from `node_modules` at runtime) — never bundle it. ## Rate Limiting @@ -95,7 +96,7 @@ Operational rules for AI agents working on the **Pod** project. 4. Keep https://www.google.com/generate_204 as relay health target. 5. Kiro retry body-gated on transient overload markers. 6. cloud/src/handlers/testClaude.ts is a 410 compatibility stub. -7. Thinking block leak fix: open-sse/translator/response/claude-to-openai.js — do NOT emit `` or `` as content delta. +7. Thinking block leak fix: open-sse/translator/response/claude-to-openai.ts — do NOT emit `` or `` as content delta. ## Operations @@ -104,7 +105,7 @@ Operational rules for AI agents working on the **Pod** project. 3. Tunnel startup must treat fetchData() as non-fatal. 4. Cloudflared tunnel spawn must stay serialized. 5. Docker entrypoint must forward SIGTERM to child processes. -6. Service worker lifecycle is registration-only; Pod does not auto-update itself. +6. Service worker lifecycle is registration-only (no auto-update UX). Keep network-first navigation in `public/sw.js`; never reject `respondWith` / never `Response.error()` on images; do not blind `location.reload()` on `controllerchange`. 7. Offline reads use offlineJsonCache; offline writes use the mutation queue stack. 8. Queue only safe, idempotent dashboard mutations. 9. Git workflow: canary is active development branch; main is stable/release branch. @@ -147,11 +148,24 @@ bun run test:run # vitest run (verbose) bun run build # NODE_ENV=production next build (turbopack) ``` +## Cursor Cloud specific instructions + +- **Default development branch**: `canary` (active). `main` is stable/release only — promote via PR. +- **Install (idempotent)**: `bash scripts/cloud-dev-install.sh` — ensures Bun 1.3.14+ and `bun install --frozen-lockfile`. +- **Start**: `bash scripts/cloud-dev-start.sh` — `bun run dev` on port **20128**. Requires secrets `JWT_SECRET` and `API_KEY_SECRET` (Cursor environment Secrets tab). Optional: `SHUTDOWN_SECRET`, `INITIAL_PASSWORD`. +- **Health check**: `curl -sf http://localhost:20128/api/health` → `{"ok":true}`; monitoring health is also public. +- **Tests need Node ≥ 22.18 on PATH (not bun)**: `bun run test:run` runs vitest under `node` on purpose (a health test asserts `version.bun` is `null`, which only holds under node). The pre-provisioned `/exec-daemon/node` is v22.14.0 — too old for native `.mts` type-stripping — so it throws `Unknown file extension ".mts"` on `src/shared/utils/clineAuth.mts` (2 spurious failures). Prepend nvm's newer node first, e.g. `export PATH="$HOME/.nvm/versions/node/v22.22.2/bin:$PATH"`, then `bun run test:run` → all green. `bun run check`/`bun run build` are unaffected (they run under bun). +- **Build**: `bun run build` first generates ignored `open-sse/**/*.js` shims from TypeScript sources; Docker's existing `COPY /app/open-sse` relies on those shims for standalone Bun runtime resolution of `.js` ESM specifiers. +- **Verify before push**: `bun run check && bun run test:run && bun run build`. +- **Ponytail skills**: vendored at `.agents/skills/{ponytail,ponytail-review,ponytail-audit,ponytail-debt,ponytail-gain,ponytail-help}/` (Cloud discovers `.agents/skills/`; `.cursor/` is gitignored). Invoke `/ponytail lite|full|ultra` (default **full**). Stop: `stop ponytail` / `normal mode`. Upstream: [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). +- Do not commit `.env`; `.cursor/` is gitignored — configure Cloud environment via dashboard / `environment.json` proposal. + ## Docs Map | Path | Purpose | | ------------------------------- | --------------------------------------------- | | .agents/INDEX.md | Documentation index and reading order | +| .agents/skills/\* | Cursor agent skills (ponytail suite) | | .agents/PRD.md | Product requirements document | | .agents/architecture/\* | System design deep dives | | .agents/knowledge/\* | Working knowledge (gotchas, conventions) | diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ec6f26..db7b7ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,22 @@ ### Added - OpenAI compatibility: emit standard CORS headers on `/v1/responses` for non-streaming requests, and return `400` on unsupported non-streaming usage. +- Deploy-time SW versioning: `gen:sw-version` writes `/sw-version.json`; registrar registers `/sw.js?v=…` so each deploy gets an isolated cache namespace. +- SW shell-cache + deploy-regression test seams (`tests/unit/swShellCache.test.js`, `tests/SW-TEST-SEAM.md`). + +### Changed + +- `bun run check` / `lint` gate on `oxlint --deny-warnings`. ### Fixed +- SW navigation: network-first (not cache-first) with offline `/offline` fallback; never `Response.error()` on documents/images; drop blind `controllerchange` reload (`f1d4861`). - Remove redundant `controller.close()` in `open-sse/handlers/chatCore.js` finally block — already closed in the success path. - Canary body-size latency: extend `readBodyTextStream()` chunk-by-chunk reads across additional large-body routes to avoid the 9–15s `curl/8.x` stall. - Redis rate-limit isolation: respect `RATELIMIT_KEY_PREFIX` so environments (e.g. canary/prod) keep separate namespaces. - Client disconnect: classify `AbortError` at `node:_http_server` as `[ClientDisconnect]` (not `[FATAL]`) and `controller.close()` on SSE reader abort — no unhandled rejections, no log spam. - Body size cap raised to 50MB default, env-tunable via `POD_MAX_REQUEST_BODY_BYTES` and `POD_MAX_CHAT_BODY_BYTES`. +- `/api/monitoring/health*` are public reads (auth guard removed); older changelog “+ API key auth” entries are historical only. ## v0.0.82 (2026-07-11) diff --git a/README.md b/README.md index 29c0d1d8..25cba8e3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ v0.0.82 — active development on `canary`, stable releases on `main`. - **Tunnel support** — Tailscale and Cloudflare tunnel integration - **Dashboard** — full web UI for providers, usage analytics, quota tracking, logs, and health (dark-only, Linear-inspired) - **Account lockout** — exponential cooldown on auth failures, visible on health -- **PWA & offline-first** — installable dashboard with service worker caching, offline reads, mutation queue +- **PWA & offline** — installable dashboard; network-first SW navigation with offline fallback; offlineJsonCache reads + mutation queue ## Quick Start @@ -61,6 +61,7 @@ bun run dev # starts on http://localhost:20128 - **Client disconnect handling**: Pod returns `499 Client Closed Request` on abrupt client disconnects (browser tab close, network drop, cancelled stream). `AbortError` at `node:_http_server` is classified as `[ClientDisconnect]` (not `[FATAL]`) and SSE wrappers call `controller.close()` on abort — no unhandled rejections, no log spam. - **Large-body latency**: Node's HTTP body parser can cause 9–15s stalls for bodies > 1MB (notably `curl/8.x`). Chat and sibling routes read via `readBodyTextStream()` (chunk-by-chunk with a size cap) to avoid the stall. - **Health checks**: `GET /api/health`, `GET /api/monitoring/health`, and `GET /api/monitoring/health/stream` are all public reads (no auth). +- **Service worker**: Navigation is network-first (not cache-first). Never surface `Response.error()` for documents/images; registrar must not blind-reload on `controllerchange`. See `.agents/knowledge/04-gotchas.md` §34. ## Environment Variables @@ -144,8 +145,8 @@ bun install # install dependencies bun run dev # start dev server on :20128 (turbopack) bun run build # production build (turbopack) bun run format # oxfmt format -bun run lint # oxlint lint -bun run check # oxfmt + oxlint + tsc (--noEmit) +bun run lint # oxlint --deny-warnings +bun run check # oxfmt + oxlint --deny-warnings + tsc (--noEmit) bun run test:run # vitest run (verbose) bun run test:coverage # vitest with coverage ``` diff --git a/cloud/src/types.d.ts b/cloud/src/types.d.ts index dfb68aee..0c205443 100644 --- a/cloud/src/types.d.ts +++ b/cloud/src/types.d.ts @@ -1,7 +1,93 @@ -// Type declarations for open-sse (JS modules without type definitions) +type OpenSseJson = Record; +type OpenSseLogger = { + debug?: (tag: string, message: string, meta?: OpenSseJson) => void; + info?: (tag: string, message: string, meta?: OpenSseJson) => void; + warn?: (tag: string, message: string, meta?: OpenSseJson) => void; + error?: (tag: string, message: string, meta?: OpenSseJson) => void; +}; + +type OpenSseCoreResult = { + success: boolean; + response: Response; + status?: number; + error?: string; + resetsAtMs?: number; +}; + +// Workers compile must not typecheck the app-coupled open-sse source graph. +// The wildcard keeps bundling resolution intact while typing only cloud-used exports. declare module "open-sse/*" { - const content: any; - export default content; + export type ChatCoreResult = OpenSseCoreResult; + export type ChatCoreParams = { + body: OpenSseJson; + modelInfo: { provider: string; model: string }; + credentials: object | null; + log: OpenSseLogger; + onCredentialsRefreshed?: (newCreds: OpenSseJson) => Promise | void; + onRequestSuccess?: () => Promise | void; + onDisconnect?: (reason?: unknown) => Promise | void; + clientRawRequest?: unknown; + connectionId?: string | null; + }; + export type EmbeddingsResult = OpenSseCoreResult; + export type EmbeddingsCoreParams = { + body: OpenSseJson; + modelInfo: { provider: string; model: string }; + credentials: object | null; + log: OpenSseLogger; + onCredentialsRefreshed?: (newCreds: OpenSseJson) => Promise | void; + onRequestSuccess?: () => Promise | void; + }; + export const MAX_RATE_LIMIT_COOLDOWN_MS: number; + export const TOKEN_EXPIRY_BUFFER_MS: number; + export const HTTP_STATUS: { + BAD_REQUEST: 400; + UNAUTHORIZED: 401; + PAYMENT_REQUIRED: 402; + FORBIDDEN: 403; + NOT_FOUND: 404; + NOT_ACCEPTABLE: 406; + REQUEST_TIMEOUT: 408; + RATE_LIMITED: 429; + SERVER_ERROR: 500; + BAD_GATEWAY: 502; + SERVICE_UNAVAILABLE: 503; + GATEWAY_TIMEOUT: 504; + }; + export const ollamaModels: { models: OpenSseJson[] }; + export function initTranslators(): void; + export function transformToOllama(response: Response, model: string): Response; + export function getModelInfoCore( + modelStr: string, + modelAliases?: Record, + ): Promise<{ provider: string; model: string }> | { provider: string; model: string }; + export function handleChatCore(params: ChatCoreParams): Promise; + export function handleEmbeddingsCore(params: EmbeddingsCoreParams): Promise; + export function errorResponse(statusCode: number, message: string): Response; + export function checkFallbackError( + status: number, + errorText: string, + backoffLevel?: number, + ): { shouldFallback: boolean; cooldownMs: number; newBackoffLevel?: number }; + export function isAccountUnavailable(unavailableUntil?: string | null): boolean; + export function getEarliestRateLimitedUntil(accounts: OpenSseJson[]): string | null; + export function getUnavailableUntil(cooldownMs: number): string; + export function formatRetryAfter(rateLimitedUntil?: string | null): string; + export function getComboModelsFromData(model: string, combos: unknown[]): string[] | null; + export function handleComboChat(params: { + body: OpenSseJson; + models: string[]; + handleSingleModel: (body: OpenSseJson, model: string) => Promise; + log: OpenSseLogger; + comboName?: string; + comboStrategy?: string; + comboStickyLimit?: number | string; + }): Promise; + export function refreshTokenByProvider( + provider: string, + credentials: object, + log?: OpenSseLogger, + ): Promise; } interface RequestInitCfProperties { @@ -11,6 +97,3 @@ interface RequestInitCfProperties { polish?: string; [key: string]: unknown; } - -// Shim for window in legacy imports from open-sse -declare var window: any; diff --git a/next.config.mjs b/next.config.ts similarity index 95% rename from next.config.mjs rename to next.config.ts index 6be7bff9..60bff25e 100644 --- a/next.config.mjs +++ b/next.config.ts @@ -1,5 +1,6 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { output: "standalone", serverExternalPackages: ["bun:sqlite", "undici"], images: { @@ -7,7 +8,7 @@ const nextConfig = { }, env: {}, outputFileTracingExcludes: { - "/*": ["./next.config.mjs"], + "/*": ["./next.config.ts"], "/api/tunnel/**": [ "./.agents/**/*", "./cloud/**/*", diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.ts similarity index 98% rename from open-sse/config/appConstants.js rename to open-sse/config/appConstants.ts index a8cf9e99..ecbde13b 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.ts @@ -5,7 +5,7 @@ import { arch, platform } from "os"; export const GEMINI_CLI_VERSION = "0.31.0"; export const GEMINI_CLI_API_CLIENT = "google-genai-sdk/1.41.0 gl-node/v22.19.0"; -export function geminiCLIUserAgent(model = "unknown") { +export function geminiCLIUserAgent(model: string = "unknown") { const os = platform() === "win32" ? "windows" : platform(); return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${os}; ${arch()})`; } diff --git a/open-sse/config/codexInstructions.js b/open-sse/config/codexInstructions.ts similarity index 100% rename from open-sse/config/codexInstructions.js rename to open-sse/config/codexInstructions.ts diff --git a/open-sse/config/constants.js b/open-sse/config/constants.ts similarity index 100% rename from open-sse/config/constants.js rename to open-sse/config/constants.ts diff --git a/open-sse/config/defaultThinkingSignature.js b/open-sse/config/defaultThinkingSignature.ts similarity index 100% rename from open-sse/config/defaultThinkingSignature.js rename to open-sse/config/defaultThinkingSignature.ts diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.ts similarity index 97% rename from open-sse/config/errorConfig.js rename to open-sse/config/errorConfig.ts index 8565f884..224a153e 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.ts @@ -138,7 +138,7 @@ const TRANSIENT_BODY_PATTERNS = [ * @param {string} bodyText * @returns {boolean} */ -export function isTransientErrorBody(bodyText) { +export function isTransientErrorBody(bodyText: unknown): boolean { if (!bodyText || typeof bodyText !== "string") return false; - return TRANSIENT_BODY_PATTERNS.some((pattern) => pattern.test(bodyText)); + return TRANSIENT_BODY_PATTERNS.some((pattern: RegExp) => pattern.test(bodyText)); } diff --git a/open-sse/config/googleTtsLanguages.js b/open-sse/config/googleTtsLanguages.ts similarity index 100% rename from open-sse/config/googleTtsLanguages.js rename to open-sse/config/googleTtsLanguages.ts diff --git a/open-sse/config/models.js b/open-sse/config/models.ts similarity index 57% rename from open-sse/config/models.js rename to open-sse/config/models.ts index a1917cdd..130c6e4d 100644 --- a/open-sse/config/models.js +++ b/open-sse/config/models.ts @@ -6,8 +6,14 @@ const DEFAULT_MODEL_INFO = { contextWindow: 200000, }; -export const MODEL_INFO = {}; +type ModelInfo = { + type?: string[]; + contextWindow?: number; + [key: string]: unknown; +}; + +export const MODEL_INFO: Record = {}; -export function getModelInfo(modelId) { +export function getModelInfo(modelId: string) { return { ...DEFAULT_MODEL_INFO, ...MODEL_INFO[modelId] }; } diff --git a/open-sse/config/ollamaModels.js b/open-sse/config/ollamaModels.ts similarity index 100% rename from open-sse/config/ollamaModels.js rename to open-sse/config/ollamaModels.ts diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.ts similarity index 95% rename from open-sse/config/providerModels.js rename to open-sse/config/providerModels.ts index 56b9177c..344e6334 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.ts @@ -7,7 +7,22 @@ import { buildTtsProviderModels } from "./ttsModels.js"; const CODEX_REVIEW_SUFFIX = "-review"; -function withCodexReviewModels(models) { +export type ProviderModel = { + id: string; + name: string; + type?: string; + capabilities?: string[]; + params?: string[]; + strip?: string[]; + targetFormat?: string; + upstreamModelId?: string; + quotaFamily?: string; + thinking?: boolean; +}; + +type ProviderModelsMap = Record; + +function withCodexReviewModels(models: ProviderModel[]): ProviderModel[] { return models.flatMap((model) => { if ((model.type || "llm") !== "llm" || model.id.endsWith(CODEX_REVIEW_SUFFIX)) { return [model]; @@ -26,7 +41,7 @@ function withCodexReviewModels(models) { }); } -export const PROVIDER_MODELS = { +export const PROVIDER_MODELS: ProviderModelsMap = { // OAuth Providers (using alias) cc: [ // Claude Code @@ -919,38 +934,42 @@ export const PROVIDER_MODELS = { }; // Helper functions -export function getProviderModels(aliasOrId) { - return PROVIDER_MODELS[aliasOrId] || []; +export function getProviderModels(aliasOrId: unknown) { + return PROVIDER_MODELS[String(aliasOrId)] || []; } -export function getDefaultModel(aliasOrId) { - const models = PROVIDER_MODELS[aliasOrId]; +export function getDefaultModel(aliasOrId: unknown) { + const models = PROVIDER_MODELS[String(aliasOrId)]; return models?.[0]?.id || null; } -export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) { +export function isValidModel( + aliasOrId: unknown, + modelId: unknown, + passthroughProviders: ReadonlySet = new Set(), +) { if (passthroughProviders.has(aliasOrId)) return true; - const models = PROVIDER_MODELS[aliasOrId]; + const models = PROVIDER_MODELS[String(aliasOrId)]; if (!models) return false; return models.some((m) => m.id === modelId); } -export function findModelName(aliasOrId, modelId) { - const models = PROVIDER_MODELS[aliasOrId]; +export function findModelName(aliasOrId: unknown, modelId: unknown) { + const models = PROVIDER_MODELS[String(aliasOrId)]; if (!models) return modelId; const found = models.find((m) => m.id === modelId); return found?.name || modelId; } -export function getModelTargetFormat(aliasOrId, modelId) { - const models = PROVIDER_MODELS[aliasOrId]; +export function getModelTargetFormat(aliasOrId: unknown, modelId: unknown) { + const models = PROVIDER_MODELS[String(aliasOrId)]; if (!models) return null; const found = models.find((m) => m.id === modelId); return found?.targetFormat || null; } -export function getModelUpstreamId(aliasOrId, modelId) { - const models = PROVIDER_MODELS[aliasOrId]; +export function getModelUpstreamId(aliasOrId: unknown, modelId: unknown) { + const models = PROVIDER_MODELS[String(aliasOrId)]; const found = models?.find((m) => m.id === modelId); if (found?.upstreamModelId) return found.upstreamModelId; if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { @@ -959,8 +978,8 @@ export function getModelUpstreamId(aliasOrId, modelId) { return modelId; } -export function getModelQuotaFamily(aliasOrId, modelId) { - const models = PROVIDER_MODELS[aliasOrId]; +export function getModelQuotaFamily(aliasOrId: unknown, modelId: unknown) { + const models = PROVIDER_MODELS[String(aliasOrId)]; const found = models?.find((m) => m.id === modelId); return found?.quotaFamily || "normal"; } @@ -986,17 +1005,17 @@ const OAUTH_ALIASES = { // Derived from PROVIDERS — no need to maintain manually export const PROVIDER_ID_TO_ALIAS = Object.fromEntries( - Object.keys(PROVIDERS).map((id) => [id, OAUTH_ALIASES[id] || id]), + Object.keys(PROVIDERS).map((id) => [id, OAUTH_ALIASES[id as keyof typeof OAUTH_ALIASES] || id]), ); -export function getModelsByProviderId(providerId) { - const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; +export function getModelsByProviderId(providerId: unknown) { + const alias = PROVIDER_ID_TO_ALIAS[String(providerId)] || String(providerId); return PROVIDER_MODELS[alias] || []; } // Get strip list for a model entry (explicit opt-in only) // Returns array of content types to strip, e.g. ["image", "audio"] -export function getModelStrip(alias, modelId) { - const entry = PROVIDER_MODELS[alias]?.find((m) => m.id === modelId); +export function getModelStrip(alias: unknown, modelId: unknown) { + const entry = PROVIDER_MODELS[String(alias)]?.find((m) => m.id === modelId); return entry?.strip || []; } diff --git a/open-sse/config/providers.js b/open-sse/config/providers.ts similarity index 98% rename from open-sse/config/providers.js rename to open-sse/config/providers.ts index 6573d7c0..9ed202ef 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.ts @@ -360,6 +360,11 @@ export const PROVIDERS = { headers: { "x-opencode-client": "desktop" }, noAuth: true, }, + commandcode: { + baseUrl: "https://api.commandcode.ai/alpha/generate", + format: "commandcode", + headers: {}, + }, "opencode-go": { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", format: "openai", @@ -397,7 +402,11 @@ export const PROVIDERS = { export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434"; -export function resolveOllamaLocalHost(credentials) { +export function resolveOllamaLocalHost( + credentials?: { + providerSpecificData?: { baseUrl?: string }; + } | null, +) { const raw = credentials?.providerSpecificData?.baseUrl?.trim(); return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, ""); } diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.ts similarity index 78% rename from open-sse/config/runtimeConfig.js rename to open-sse/config/runtimeConfig.ts index 375e8aca..b278356a 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.ts @@ -53,12 +53,23 @@ export const DEFAULT_RETRY_CONFIG = { export const LOCAL_UPSTREAM_TIMEOUT_MS = 45000; // Normalize a retry entry to { attempts, delayMs } -export function resolveRetryEntry(entry) { - if (entry == null) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs }; +// (number = attempts with RETRY_CONFIG.delayMs; object = { attempts, delayMs }) +export type RetryEntryInput = + | number + | { + attempts?: number; + delayMs?: number | null; + } + | null + | undefined; + +export function resolveRetryEntry(entry: RetryEntryInput) { + if (entry === null || entry === undefined) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs }; if (typeof entry === "number") return { attempts: entry, delayMs: RETRY_CONFIG.delayMs }; return { attempts: entry.attempts || 0, - delayMs: entry.delayMs != null ? entry.delayMs : RETRY_CONFIG.delayMs, + delayMs: + entry.delayMs !== null && entry.delayMs !== undefined ? entry.delayMs : RETRY_CONFIG.delayMs, }; } diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.ts similarity index 83% rename from open-sse/config/ttsModels.js rename to open-sse/config/ttsModels.ts index f5b83c21..674da46b 100644 --- a/open-sse/config/ttsModels.js +++ b/open-sse/config/ttsModels.ts @@ -15,9 +15,31 @@ const VOICES = { sage: { id: "sage", name: "Sage" }, shimmer: { id: "shimmer", name: "Shimmer" }, verse: { id: "verse", name: "Verse" }, +} as const; + +type VoiceKey = keyof typeof VOICES; + +type TtsVoice = { + id: string; + name: string; + type: "tts"; +}; + +type TtsModelEntry = { + id: string; + name: string; + type: string; +}; + +type TtsProviderConfig = { + models?: TtsModelEntry[]; + voices?: Record; + allVoices?: TtsVoice[]; + defaults?: readonly TtsModelEntry[] | TtsModelEntry[]; }; -const v = (...keys) => keys.map((k) => ({ ...VOICES[k], type: "tts" })); +const v = (...keys: VoiceKey[]): TtsVoice[] => + keys.map((k: VoiceKey) => ({ ...VOICES[k], type: "tts" as const })); // 9 voices for tts-1 / tts-1-hd const VOICES_STANDARD = v( @@ -80,7 +102,7 @@ const GEMINI_VOICES = [ "Sadachbia", "Sadaltager", "Sulafat", -].map((id) => ({ id, name: id, type: "tts" })); +].map((id: string): TtsVoice => ({ id, name: id, type: "tts" })); // ── TTS Config (config-driven, single source of truth) ───────────────────── export const TTS_MODELS_CONFIG = { @@ -155,19 +177,20 @@ export const TTS_MODELS_CONFIG = { }; // ── Helper: get voices for a specific model ──────────────────────────────── -export function getTtsVoicesForModel(provider, modelId) { - const cfg = TTS_MODELS_CONFIG[provider]; +export function getTtsVoicesForModel(provider: string, modelId: string) { + const cfg = (TTS_MODELS_CONFIG as Record)[provider]; if (!cfg?.voices) return null; return cfg.voices[modelId] || cfg.allVoices || null; } // ── Build flat entries for PROVIDER_MODELS backward compat ───────────────── export function buildTtsProviderModels() { - const entries = {}; + const entries: Record = {}; for (const [provider, cfg] of Object.entries(TTS_MODELS_CONFIG)) { - if (cfg.models) entries[`${provider}-tts-models`] = cfg.models; - if (cfg.allVoices) entries[`${provider}-tts-voices`] = cfg.allVoices; - if (cfg.defaults) entries[provider] = cfg.defaults; + const typedCfg = cfg as TtsProviderConfig; + if (typedCfg.models) entries[`${provider}-tts-models`] = typedCfg.models; + if (typedCfg.allVoices) entries[`${provider}-tts-voices`] = typedCfg.allVoices; + if (typedCfg.defaults) entries[provider] = typedCfg.defaults; } // Keep openai-tts-voices key pointing to full voice list for backward compat entries["openai-tts-voices"] = TTS_MODELS_CONFIG.openai.allVoices; diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.ts similarity index 76% rename from open-sse/executors/antigravity.js rename to open-sse/executors/antigravity.ts index e1e9d4c7..e6b74dcc 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import crypto from "node:crypto"; import { AG_DEFAULT_TOOLS, @@ -11,10 +12,73 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { cleanJSONSchemaForAntigravity } from "../translator/helpers/geminiHelper.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { deriveSessionId } from "../utils/sessionManager.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorHeaders, + type ExecutorLogger, + type ExecutorProxyOptions, +} from "./base.js"; + +type JsonRecord = Record; +type FunctionDeclaration = JsonRecord & { + name: string; + parameters?: JsonRecord; +}; +type ToolGroup = { + functionDeclarations?: FunctionDeclaration[]; +}; +type ContentPart = JsonRecord & { + functionCall?: { name: string; [key: string]: unknown }; + functionResponse?: { name: string; [key: string]: unknown }; + text?: unknown; + thought?: unknown; + thoughtSignature?: unknown; +}; +type Content = JsonRecord & { + parts?: ContentPart[]; + role?: string; +}; +type AntigravityRequest = JsonRecord & { + contents?: Content[]; + generationConfig?: JsonRecord & { maxOutputTokens?: number }; + sessionId?: string; + toolConfig?: unknown; + tools?: ToolGroup[]; +}; +type AntigravityBody = JsonRecord & { + project?: string; + request?: AntigravityRequest; + requestId?: string; + requestType?: string; + userAgent?: string; +}; +type AntigravityCredentials = ExecutorCredentials & { + connectionId?: string; + email?: string; + projectId?: string; +}; +type OAuthTokenPayload = { + access_token?: unknown; + expires_in?: unknown; + refresh_token?: unknown; +}; +type CloakResult = { + cloakedBody: AntigravityBody; + toolNameMap: Map | null; +}; + +function asAntigravityBody(body: unknown): AntigravityBody { + return body && typeof body === "object" && !Array.isArray(body) ? (body as AntigravityBody) : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} // Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63} -function sanitizeFunctionName(name) { +function sanitizeFunctionName(name: string) { if (!name) return "_unknown"; let s = name.replace(/[^a-zA-Z0-9_.:-]/g, "_"); if (!/^[a-zA-Z_]/.test(s)) s = "_" + s; @@ -29,14 +93,18 @@ export class AntigravityExecutor extends BaseExecutor { super("antigravity", PROVIDERS.antigravity); } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(_model: string, stream: boolean, urlIndex: number = 0) { const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; return `${baseUrl}/v1internal:${action}`; } - buildHeaders(credentials, stream = true, sessionId = null) { + buildHeaders( + credentials: ExecutorCredentials, + stream: boolean = true, + sessionId: string | null = null, + ): ExecutorHeaders { return { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, @@ -47,11 +115,17 @@ export class AntigravityExecutor extends BaseExecutor { }; } - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + body: unknown, + _stream: boolean, + credentials: AntigravityCredentials, + ): AntigravityBody { + const antigravityBody = asAntigravityBody(body); const projectId = credentials?.projectId || this.generateProjectId(); // Fix contents for Claude models via Antigravity - const contents = body.request?.contents?.map((c) => { + const contents = antigravityBody.request?.contents?.map((c) => { let role = c.role; // functionResponse must be role "user" for Claude models if (c.parts?.some((p) => p.functionResponse)) { @@ -70,7 +144,7 @@ export class AntigravityExecutor extends BaseExecutor { }); // Sanitize tool schemas and function names before sending to Antigravity. - let tools = body.request?.tools; + let tools = antigravityBody.request?.tools; if (tools && tools.length > 0) { // Merge all groups into a single functionDeclarations group (Gemini expects 1 group) @@ -94,11 +168,15 @@ export class AntigravityExecutor extends BaseExecutor { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools - } = body.request || {}; + } = antigravityBody.request || {}; const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; - if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { + if ( + typeof generationConfig.maxOutputTokens === "number" && + generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS + ) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; } + const hasTools = (tools?.length || 0) > 0; const transformedRequest = { ...requestWithoutTools, @@ -106,13 +184,14 @@ export class AntigravityExecutor extends BaseExecutor { ...(contents && { contents }), ...(tools && { tools }), sessionId: - body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId), + antigravityBody.request?.sessionId || + deriveSessionId(credentials?.email || credentials?.connectionId), safetySettings: undefined, - ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }), + ...(hasTools && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }), }; return { - ...body, + ...antigravityBody, project: projectId, model: model, userAgent: "antigravity", @@ -122,7 +201,11 @@ export class AntigravityExecutor extends BaseExecutor { }; } - async refreshCredentials(credentials, log, proxyOptions = null) { + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ) { if (!credentials.refreshToken) return null; try { @@ -137,8 +220,8 @@ export class AntigravityExecutor extends BaseExecutor { body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: credentials.refreshToken, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, + client_id: String(this.config.clientId), + client_secret: String(this.config.clientSecret), }), }, proxyOptions, @@ -146,17 +229,17 @@ export class AntigravityExecutor extends BaseExecutor { if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; log?.info?.("TOKEN", "Antigravity refreshed"); return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || credentials.refreshToken, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || credentials.refreshToken) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, projectId: credentials.projectId, }; - } catch (error) { - log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN", `Antigravity refresh error: ${errorMessage(error)}`); return null; } } @@ -171,7 +254,7 @@ export class AntigravityExecutor extends BaseExecutor { return crypto.randomUUID() + Date.now().toString(); } - parseRetryHeaders(headers) { + parseRetryHeaders(headers: Headers | null) { if (!headers?.get) return null; const retryAfter = headers.get("retry-after"); @@ -204,10 +287,10 @@ export class AntigravityExecutor extends BaseExecutor { // Parse retry time from Antigravity error message body // Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s" - parseRetryFromErrorMessage(errorMessage) { - if (!errorMessage || typeof errorMessage !== "string") return null; + parseRetryFromErrorMessage(message: unknown) { + if (!message || typeof message !== "string") return null; - const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); + const match = message.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); if (!match) return null; let totalMs = 0; @@ -218,18 +301,31 @@ export class AntigravityExecutor extends BaseExecutor { return totalMs > 0 ? totalMs : null; } - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + proxyOptions = null, + }: ExecutorExecuteOptions) { const fallbackCount = this.getFallbackCount(); - let lastError = null; + let lastError: unknown = null; let lastStatus = 0; const MAX_AUTO_RETRIES = 3; const MAX_RETRY_AFTER_RETRIES = 3; - const retryAttemptsByUrl = {}; // Track retry attempts per URL - const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL + const retryAttemptsByUrl: Record = {}; // Track retry attempts per URL + const retryAfterAttemptsByUrl: Record = {}; // Track Retry-After retries per URL for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex); - const transformedBody = this.transformRequest(model, body, stream, credentials); + const transformedBody = this.transformRequest( + model, + body, + stream, + credentials as AntigravityCredentials, + ); const sessionId = transformedBody.request?.sessionId; const headers = this.buildHeaders(credentials, stream, sessionId); @@ -267,33 +363,35 @@ export class AntigravityExecutor extends BaseExecutor { const errorJson = JSON.parse(errorBody); const errorMessage = errorJson?.error?.message || errorJson?.message || ""; retryMs = this.parseRetryFromErrorMessage(errorMessage); - } catch (_e) { + } catch { // Ignore parse errors, will fall back to exponential backoff } } + const retryAfterAttempts = retryAfterAttemptsByUrl[urlIndex] || 0; if ( retryMs && retryMs <= MAX_RETRY_AFTER_MS && - retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES + retryAfterAttempts < MAX_RETRY_AFTER_RETRIES ) { - retryAfterAttemptsByUrl[urlIndex]++; + retryAfterAttemptsByUrl[urlIndex] = retryAfterAttempts + 1; log?.debug?.( "RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting... (${retryAfterAttemptsByUrl[urlIndex]}/${MAX_RETRY_AFTER_RETRIES})`, ); - await new Promise((resolve) => setTimeout(resolve, retryMs)); + await new Promise((resolve) => setTimeout(resolve, retryMs)); urlIndex--; continue; } // Auto retry only for 429 when retryMs is 0 or undefined + const retryAttempts = retryAttemptsByUrl[urlIndex] || 0; if ( response.status === HTTP_STATUS.RATE_LIMITED && (!retryMs || retryMs === 0) && - retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES + retryAttempts < MAX_AUTO_RETRIES ) { - retryAttemptsByUrl[urlIndex]++; + retryAttemptsByUrl[urlIndex] = retryAttempts + 1; // Exponential backoff: 2s, 4s, 8s... const backoffMs = Math.min( 1000 * 2 ** retryAttemptsByUrl[urlIndex], @@ -303,7 +401,7 @@ export class AntigravityExecutor extends BaseExecutor { "RETRY", `429 auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s`, ); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); urlIndex--; continue; } @@ -326,7 +424,7 @@ export class AntigravityExecutor extends BaseExecutor { } return { response, url, headers, transformedBody }; - } catch (error) { + } catch (error: unknown) { lastError = error; if (urlIndex + 1 < fallbackCount) { log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); @@ -345,15 +443,15 @@ export class AntigravityExecutor extends BaseExecutor { * - Inject AG default decoy tools after client tools * Returns { cloakedBody, toolNameMap } where toolNameMap maps suffixed → original */ - static cloakTools(body, clientTool = null) { + static cloakTools(body: AntigravityBody, clientTool: string | null = null): CloakResult { const tools = body.request?.tools; if (!tools || tools.length === 0) { return { cloakedBody: body, toolNameMap: null }; } const isCopilot = clientTool === "github-copilot"; - const toolNameMap = new Map(); - const clientDeclarations = []; + const toolNameMap = new Map(); + const clientDeclarations: FunctionDeclaration[] = []; const decoyNames = new Set(AG_DECOY_TOOLS.map((tool) => tool.name)); // First: collect renamed client tools @@ -385,8 +483,8 @@ export class AntigravityExecutor extends BaseExecutor { } // Client tools first, then AG decoy tools - const allDeclarations = []; - const seenNames = new Set(); + const allDeclarations: FunctionDeclaration[] = []; + const seenNames = new Set(); for (const decl of [...clientDeclarations, ...AG_DECOY_TOOLS]) { if (!decl?.name || seenNames.has(decl.name)) continue; seenNames.add(decl.name); @@ -433,7 +531,7 @@ export class AntigravityExecutor extends BaseExecutor { request: { ...body.request, tools: [{ functionDeclarations: allDeclarations }], - contents: cloakedContents || body.request.contents, + contents: cloakedContents || body.request?.contents, }, }, toolNameMap, diff --git a/open-sse/executors/azure.js b/open-sse/executors/azure.ts similarity index 71% rename from open-sse/executors/azure.js rename to open-sse/executors/azure.ts index 9b31518a..2c1dc0e5 100644 --- a/open-sse/executors/azure.js +++ b/open-sse/executors/azure.ts @@ -1,11 +1,19 @@ import { DefaultExecutor } from "./default.js"; +import type { ExecutorCredentials, ExecutorHeaders } from "./base.js"; export class AzureExecutor extends DefaultExecutor { constructor() { super("azure"); } - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ): string { + void stream; + void urlIndex; const azureEndpoint = credentials?.providerSpecificData?.azureEndpoint || process.env.AZURE_ENDPOINT || @@ -26,8 +34,8 @@ export class AzureExecutor extends DefaultExecutor { return `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`; } - buildHeaders(credentials, stream = true) { - const headers = { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + const headers: ExecutorHeaders = { "Content-Type": "application/json", ...this.config.headers, }; @@ -52,7 +60,13 @@ export class AzureExecutor extends DefaultExecutor { return headers; } - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials: ExecutorCredentials, + ): unknown { + void model; return body; } } diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js deleted file mode 100644 index 4f9e3823..00000000 --- a/open-sse/executors/base.js +++ /dev/null @@ -1,196 +0,0 @@ -import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; -import { proxyAwareFetch } from "../utils/proxyFetch.js"; - -const FETCH_CONNECT_TIMEOUT_MS = 15_000; - -/** - * BaseExecutor - Base class for provider executors - */ -export class BaseExecutor { - constructor(provider, config) { - this.provider = provider; - this.config = config; - this.noAuth = config?.noAuth || false; - } - - getProvider() { - return this.provider; - } - - getBaseUrls() { - return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []); - } - - getFallbackCount() { - return this.getBaseUrls().length || 1; - } - - buildUrl(model, stream, urlIndex = 0, credentials = null) { - if (this.provider?.startsWith?.("openai-compatible-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; - const normalized = baseUrl.replace(/\/$/, ""); - const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; - return `${normalized}${path}`; - } - if (this.provider?.startsWith?.("anthropic-compatible-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; - const normalized = baseUrl.replace(/\/$/, ""); - return `${normalized}/messages`; - } - const baseUrls = this.getBaseUrls(); - return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; - } - - buildHeaders(credentials, stream = true) { - const headers = { - "Content-Type": "application/json", - ...this.config.headers, - }; - - if (this.provider?.startsWith?.("anthropic-compatible-")) { - // Anthropic-compatible providers use x-api-key header - if (credentials.apiKey) { - headers["x-api-key"] = credentials.apiKey; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - if (!headers["anthropic-version"]) { - headers["anthropic-version"] = "2023-06-01"; - } - } else { - // Standard Bearer token auth for other providers - if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } else if (credentials.apiKey) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; - } - } - - if (stream) { - headers["Accept"] = "text/event-stream"; - } - - return headers; - } - - // Override in subclass for provider-specific transformations - transformRequest(model, body, stream, credentials) { - return body; - } - - shouldRetry(status, urlIndex) { - return ( - [ - HTTP_STATUS.RATE_LIMITED, - HTTP_STATUS.BAD_GATEWAY, - HTTP_STATUS.SERVICE_UNAVAILABLE, - HTTP_STATUS.GATEWAY_TIMEOUT, - ].includes(status) && urlIndex + 1 < this.getFallbackCount() - ); - } - - // Override in subclass for provider-specific refresh - async refreshCredentials(credentials, log, proxyOptions = null) { - return null; - } - - needsRefresh(credentials) { - if (!credentials.expiresAt) return false; - const expiresAtMs = new Date(credentials.expiresAt).getTime(); - return expiresAtMs - Date.now() < 5 * 60 * 1000; - } - - parseError(response, bodyText) { - return { status: response.status, message: bodyText || `HTTP ${response.status}` }; - } - - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { - const fallbackCount = this.getFallbackCount(); - let lastError = null; - let lastStatus = 0; - const retryAttemptsByUrl = {}; - - // Merge default retry config with provider-specific config - const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; - - // Schedule retry via retryConfig[statusKey]. Returns true when caller should `urlIndex--; continue` - const tryRetry = async (urlIndex, statusKey, reason) => { - const { attempts, delayMs } = resolveRetryEntry(retryConfig[statusKey]); - if (attempts <= 0 || retryAttemptsByUrl[urlIndex] >= attempts) return false; - retryAttemptsByUrl[urlIndex]++; - log?.debug?.( - "RETRY", - `${reason} retry ${retryAttemptsByUrl[urlIndex]}/${attempts} after ${delayMs / 1000}s`, - ); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - return true; - }; - - for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex, credentials); - const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream); - - if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; - - // Abort if upstream doesn't return response headers within FETCH_CONNECT_TIMEOUT_MS - const connectCtrl = new AbortController(); - const connectTimer = setTimeout( - () => connectCtrl.abort(new Error("fetch connect timeout")), - FETCH_CONNECT_TIMEOUT_MS, - ); - const mergedSignal = signal - ? AbortSignal.any([signal, connectCtrl.signal]) - : connectCtrl.signal; - - try { - const response = await proxyAwareFetch( - url, - { - method: "POST", - headers, - body: JSON.stringify(transformedBody), - signal: mergedSignal, - }, - proxyOptions, - ); - clearTimeout(connectTimer); - - if (await tryRetry(urlIndex, response.status, `status ${response.status}`)) { - urlIndex--; - continue; - } - - if (this.shouldRetry(response.status, urlIndex)) { - log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); - lastStatus = response.status; - continue; - } - - return { response, url, headers, transformedBody }; - } catch (error) { - clearTimeout(connectTimer); - lastError = error; - const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError"; - // Connect timeout is internal — convert to retryable network error, don't propagate AbortError - if (error.name === "AbortError" && !isConnectTimeout) throw error; - - // Map network/fetch exceptions to 502 retry config - if (await tryRetry(urlIndex, HTTP_STATUS.BAD_GATEWAY, `network "${error.message}"`)) { - urlIndex--; - continue; - } - - if (urlIndex + 1 < fallbackCount) { - log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); - continue; - } - throw error; - } - } - - throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); - } -} - -export default BaseExecutor; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts new file mode 100644 index 00000000..9f3df4e9 --- /dev/null +++ b/open-sse/executors/base.ts @@ -0,0 +1,339 @@ +import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +const FETCH_CONNECT_TIMEOUT_MS = 15_000; + +export type ExecutorHeaders = Record; +export type ExecutorProviderData = { + accountId?: string; + apiVersion?: string; + azureEndpoint?: string; + baseUrl?: string; + deployment?: string; + machineId?: string; + organization?: string; + resourceUrl?: string; + workspaceId?: string; + [key: string]: unknown; +}; +export type ExecutorCredentials = { + accessToken?: string; + apiKey?: string; + connectionId?: string; + copilotTokenExpiresAt?: string | number | Date; + copilotToken?: string; + email?: string; + expiresIn?: string | number; + expiresAt?: string | number | Date; + projectId?: string; + providerSpecificData?: ExecutorProviderData; + rawHeaders?: Record; + refreshToken?: string; + [key: string]: unknown; +}; +export type RetryEntry = + | number + | { + attempts?: number; + delayMs?: number; + } + | null + | undefined; +export type TransientRetryConfig = { + attempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +}; +export type ExecutorConfigInput = { + authUrl?: string; + baseUrl?: string; + baseUrls?: string[]; + chatPath?: string; + clientId?: string | null; + clientSecret?: string | null; + format?: string; + headers?: ExecutorHeaders; + noAuth?: boolean; + responsesUrl?: string; + retry?: Record; + tokenUrl?: string; + transientRetry?: TransientRetryConfig; + [key: string]: unknown; +}; +export type ExecutorConfig = { + authUrl?: string; + baseUrl?: string; + baseUrls?: string[]; + chatPath?: string; + clientId?: string | null; + clientSecret?: string | null; + format?: string; + headers: ExecutorHeaders; + noAuth?: boolean; + responsesUrl?: string; + retry: Record; + tokenUrl?: string; + transientRetry?: TransientRetryConfig; + [key: string]: unknown; +}; +export type ExecutorLogger = { + debug?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + info?: (scope: string, message: string) => void; + warn?: (scope: string, message: string) => void; +}; +export type ExecutorProxyOptions = Record | null; +export type ExecutorExecuteOptions = { + model: string; + body: unknown; + stream: boolean; + credentials: ExecutorCredentials; + signal?: AbortSignal; + log?: ExecutorLogger; + proxyOptions?: ExecutorProxyOptions; + [key: string]: unknown; +}; +export type ExecutorExecuteResult = { + response: Response; + url: string | undefined; + headers: ExecutorHeaders; + transformedBody: unknown; +}; +export type ExecutorErrorDetails = { + status: number; + message: string; + resetsAtMs?: number; +}; + +/** + * BaseExecutor - Base class for provider executors + */ +export class BaseExecutor { + provider: string; + config: ExecutorConfig; + noAuth: boolean; + + constructor(provider: string, config: ExecutorConfigInput) { + this.provider = provider; + this.config = { + ...config, + headers: config.headers || {}, + retry: config.retry || {}, + } as ExecutorConfig; + this.noAuth = config.noAuth || false; + } + + getProvider(): string { + return this.provider; + } + + getBaseUrls(): string[] { + return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []); + } + + getFallbackCount(): number { + return this.getBaseUrls().length || 1; + } + + buildUrl( + model: string, + stream: boolean, + urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ): string | undefined { + if (this.provider.startsWith("openai-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; + return `${normalized}${path}`; + } + if (this.provider.startsWith("anthropic-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}/messages`; + } + const baseUrls = this.getBaseUrls(); + return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; + } + + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + const headers: ExecutorHeaders = { + "Content-Type": "application/json", + ...this.config.headers, + }; + + if (this.provider.startsWith("anthropic-compatible-")) { + // Anthropic-compatible providers use x-api-key header + if (credentials.apiKey) { + headers["x-api-key"] = credentials.apiKey; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + if (!headers["anthropic-version"]) { + headers["anthropic-version"] = "2023-06-01"; + } + } else { + // Standard Bearer token auth for other providers + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + // Override in subclass for provider-specific transformations + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials: ExecutorCredentials, + ): unknown { + return body; + } + + shouldRetry(status: number, urlIndex: number): boolean { + return ( + [ + HTTP_STATUS.RATE_LIMITED, + HTTP_STATUS.BAD_GATEWAY, + HTTP_STATUS.SERVICE_UNAVAILABLE, + HTTP_STATUS.GATEWAY_TIMEOUT, + ].includes(status) && urlIndex + 1 < this.getFallbackCount() + ); + } + + // Override in subclass for provider-specific refresh + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { + void credentials; + void log; + void proxyOptions; + return null; + } + + needsRefresh(credentials: ExecutorCredentials): boolean { + if (!credentials.expiresAt) return false; + const expiresAtMs = new Date(credentials.expiresAt).getTime(); + return expiresAtMs - Date.now() < 5 * 60 * 1000; + } + + parseError(response: Response, bodyText: string): ExecutorErrorDetails { + return { status: response.status, message: bodyText || `HTTP ${response.status}` }; + } + + async execute({ + model, + body, + stream, + credentials, + signal, + log, + proxyOptions = null, + }: ExecutorExecuteOptions): Promise { + const fallbackCount = this.getFallbackCount(); + let lastError: unknown = null; + let lastStatus = 0; + const retryAttemptsByUrl: Record = {}; + + // Merge default retry config with provider-specific config + const retryConfig: Record = { + ...DEFAULT_RETRY_CONFIG, + ...this.config.retry, + }; + + // Schedule retry via retryConfig[statusKey]. Returns true when caller should `urlIndex--; continue` + const tryRetry = async ( + urlIndex: number, + statusKey: number, + reason: string, + ): Promise => { + const { attempts, delayMs } = resolveRetryEntry(retryConfig[String(statusKey)]); + const previousAttempts = retryAttemptsByUrl[urlIndex] || 0; + if (attempts <= 0 || previousAttempts >= attempts) return false; + const nextAttempts = previousAttempts + 1; + retryAttemptsByUrl[urlIndex] = nextAttempts; + log?.debug?.("RETRY", `${reason} retry ${nextAttempts}/${attempts} after ${delayMs / 1000}s`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return true; + }; + + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { + const url = this.buildUrl(model, stream, urlIndex, credentials); + const transformedBody = this.transformRequest(model, body, stream, credentials); + const headers = this.buildHeaders(credentials, stream); + + if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; + + // Abort if upstream doesn't return response headers within FETCH_CONNECT_TIMEOUT_MS + const connectCtrl = new AbortController(); + const connectTimer = setTimeout( + () => connectCtrl.abort(new Error("fetch connect timeout")), + FETCH_CONNECT_TIMEOUT_MS, + ); + const mergedSignal = signal + ? AbortSignal.any([signal, connectCtrl.signal]) + : connectCtrl.signal; + + try { + const response = await proxyAwareFetch( + url, + { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: mergedSignal, + }, + proxyOptions, + ); + clearTimeout(connectTimer); + + if (await tryRetry(urlIndex, response.status, `status ${response.status}`)) { + urlIndex--; + continue; + } + + if (this.shouldRetry(response.status, urlIndex)) { + log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); + lastStatus = response.status; + continue; + } + + return { response, url, headers, transformedBody }; + } catch (error: unknown) { + clearTimeout(connectTimer); + lastError = error; + const errorName = error instanceof Error ? error.name : ""; + const errorMessage = error instanceof Error ? error.message : String(error); + const isConnectTimeout = connectCtrl.signal.aborted && errorName === "AbortError"; + // Connect timeout is internal — convert to retryable network error, don't propagate AbortError + if (errorName === "AbortError" && !isConnectTimeout) throw error; + + // Map network/fetch exceptions to 502 retry config + if (await tryRetry(urlIndex, HTTP_STATUS.BAD_GATEWAY, `network "${errorMessage}"`)) { + urlIndex--; + continue; + } + + if (urlIndex + 1 < fallbackCount) { + log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); + continue; + } + throw error; + } + } + + throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); + } +} + +export default BaseExecutor; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.ts similarity index 72% rename from open-sse/executors/codex.js rename to open-sse/executors/codex.ts index 8957624a..a2f7d1c6 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { createHash } from "node:crypto"; import { getConsistentMachineId } from "../../src/shared/utils/machineId"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; @@ -7,15 +8,77 @@ import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { dbg } from "../utils/debugLog.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorErrorDetails, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, +} from "./base.js"; + +type MutableRecord = Record; + +type CodexBody = MutableRecord & { + _compact?: unknown; + conversation_id?: unknown; + include?: unknown[]; + input?: unknown; + instructions?: string; + model?: string; + prompt_cache_key?: unknown; + reasoning?: MutableRecord; + reasoning_effort?: unknown; + session_id?: unknown; + store?: boolean; + stream?: boolean; + tool_choice?: unknown; + tools?: unknown; +}; + +type CodexInputItem = MutableRecord & { + content?: unknown; + id?: string; + role?: string; + type?: string; +}; + +type CodexTool = MutableRecord & { + description?: unknown; + function?: MutableRecord; + name?: unknown; + parameters?: unknown; + tools?: Array<{ name?: unknown }>; + type?: unknown; +}; + +type ImageContent = MutableRecord & { + detail?: string; + image_url?: string | { detail?: string; url?: string }; + type?: string; +}; + +type CachedSession = { + lastUsed: number; + sessionId: string; +}; + +type PeekSseResult = { + matched: string | null; + replacementBody: ReadableStream | null; +}; // SSE error patterns inside 200-OK body that should trigger retry as if 503 const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; const CODEX_SSE_PEEK_BYTES = 4096; +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + // In-memory map: hash(machineId + first assistant content) -> { sessionId, lastUsed } const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour -const assistantSessionMap = new Map(); +const assistantSessionMap = new Map(); // Server-generated item id prefixes that Codex /responses cannot resolve when store=false const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; @@ -50,9 +113,9 @@ const RESPONSES_API_ALLOWLIST = new Set([ ]); // Convert role=system -> role=developer in body.input (keeps content in cacheable prefix) -function convertSystemToDeveloperRole(body) { +function convertSystemToDeveloperRole(body: CodexBody) { if (!Array.isArray(body.input)) return; - for (const item of body.input) { + for (const item of body.input as CodexInputItem[]) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; const isSystemMsg = item.role === "system" && (!item.type || item.type === "message"); if (isSystemMsg) item.role = "developer"; @@ -60,28 +123,30 @@ function convertSystemToDeveloperRole(body) { } // Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input -- avoids 404 with store=false -function stripStoredItemReferences(body) { +function stripStoredItemReferences(body: CodexBody) { if (!Array.isArray(body.input)) return; - body.input = body.input.filter((item) => { + body.input = body.input.filter((item: unknown) => { if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; if (item && typeof item === "object" && !Array.isArray(item)) { - if (item.type === "item_reference") return false; - if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; + const record = item as CodexInputItem; + if (record.type === "item_reference") return false; + if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) delete record.id; } return true; }); } // Flatten Chat-Completions tool shape into Responses flat format + filter unsupported tools -function normalizeCodexTools(body) { +function normalizeCodexTools(body: CodexBody) { if (!Array.isArray(body.tools)) return; - const validNames = new Set(); - body.tools = body.tools.filter((tool) => { + const validNames = new Set(); + body.tools = body.tools.filter((tool: unknown) => { if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; - const type = typeof tool.type === "string" ? tool.type : ""; + const record = tool as CodexTool; + const type = typeof record.type === "string" ? record.type : ""; if (type === "namespace") { - if (Array.isArray(tool.tools)) { - for (const st of tool.tools) { + if (Array.isArray(record.tools)) { + for (const st of record.tools) { const n = typeof st?.name === "string" ? st.name.trim().slice(0, 128) : ""; if (n) validNames.add(n); } @@ -89,36 +154,38 @@ function normalizeCodexTools(body) { return true; } if (type !== "function") { - if (!type || tool.function || typeof tool.name === "string") return false; + if (!type || record.function || typeof record.name === "string") return false; return CODEX_HOSTED_TOOL_TYPES.has(type); } // Normalize function tool shape (handle both Chat Completions and Responses schemas) const fn = - tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) - ? tool.function + record.function && typeof record.function === "object" && !Array.isArray(record.function) + ? record.function : null; const rawName = - typeof tool.name === "string" ? tool.name : typeof fn?.name === "string" ? fn.name : ""; + typeof record.name === "string" ? record.name : typeof fn?.name === "string" ? fn.name : ""; const name = rawName.trim(); if (!name) return false; const description = - typeof tool.description === "string" - ? tool.description + typeof record.description === "string" + ? record.description : typeof fn?.description === "string" ? fn.description : ""; const parameters = - tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) - ? tool.parameters + record.parameters && + typeof record.parameters === "object" && + !Array.isArray(record.parameters) + ? record.parameters : fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} }; // Drop old keys, set canonical shape - for (const k of Object.keys(tool)) delete tool[k]; - tool.type = "function"; - tool.name = name.slice(0, 128); - if (description) tool.description = description; - tool.parameters = parameters; + for (const k of Object.keys(record)) delete record[k]; + record.type = "function"; + record.name = name.slice(0, 128); + if (description) record.description = description; + record.parameters = parameters; validNames.add(name); return true; }); @@ -128,22 +195,25 @@ function normalizeCodexTools(body) { typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice) ) { - if (body.tool_choice.type === "function") { - const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : ""; + const toolChoice = body.tool_choice as MutableRecord; + if (toolChoice.type === "function") { + const n = typeof toolChoice.name === "string" ? toolChoice.name.trim() : ""; if (!n || !validNames.has(n)) delete body.tool_choice; } } } // Cache machine ID at module level (resolved once) -let cachedMachineId = null; +let cachedMachineId: string | null = null; getConsistentMachineId() - .then((id) => { + .then((id: string) => { cachedMachineId = id; }) - .catch(() => {}); + .catch(() => { + // Best-effort machine ID warmup; request-time fallback still resolves it. + }); -function hashContent(text) { +function hashContent(text: string) { return createHash("sha256").update(text).digest("hex").slice(0, 16); } @@ -157,12 +227,20 @@ function generateSessionId() { } // Extract text content from an input item -function extractItemText(item) { +function extractItemText(item: unknown) { if (!item) return ""; - if (typeof item.content === "string") return item.content; - if (Array.isArray(item.content)) { - return item.content - .map((c) => c.text || c.output || "") + const record = item as CodexInputItem; + if (typeof record.content === "string") return record.content; + if (Array.isArray(record.content)) { + return record.content + .map((c: unknown) => { + const contentPart = c as { output?: unknown; text?: unknown }; + return typeof contentPart.text === "string" + ? contentPart.text + : typeof contentPart.output === "string" + ? contentPart.output + : ""; + }) .filter(Boolean) .join(""); } @@ -170,7 +248,7 @@ function extractItemText(item) { } // Normalize a session id candidate (trim, length cap) -function normalizeSessionId(value) { +function normalizeSessionId(value: unknown) { if (typeof value !== "string") return null; const v = value.trim(); if (!v || v.length > 256) return null; @@ -178,7 +256,11 @@ function normalizeSessionId(value) { } // Resolve prompt-cache session id with priority: body -> assistant-text-hash -> workspaceId -> machineId -function resolveCacheSessionId(body, credentials, machineId) { +function resolveCacheSessionId( + body: CodexBody, + credentials: ExecutorCredentials | null | undefined, + machineId: string | null, +) { // 1. Client-provided session/conversation id (highest priority -- stable per conversation) const fromBody = normalizeSessionId(body?.prompt_cache_key) || @@ -192,8 +274,9 @@ function resolveCacheSessionId(body, credentials, machineId) { const MIN_LEN = 50; const CAP_LEN = 200; for (const item of body.input) { - if (item?.role !== "assistant") continue; - const t = extractItemText(item); + const inputItem = item as CodexInputItem; + if (inputItem?.role !== "assistant") continue; + const t = extractItemText(inputItem); if (!t) continue; text += t; if (text.length >= CAP_LEN) break; @@ -235,6 +318,9 @@ setInterval( * Automatically injects default instructions if missing */ export class CodexExecutor extends BaseExecutor { + private _currentSessionId: string | null; + private _isCompact = false; + constructor() { super("codex", PROVIDERS.codex); this._currentSessionId = null; @@ -244,7 +330,7 @@ export class CodexExecutor extends BaseExecutor { * Override headers to add codex-specific identity headers. * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId. */ - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ExecutorCredentials, _stream = true): ExecutorHeaders { // Codex always returns SSE regardless of client stream preference. // Force stream=true so base.js sets Accept: text/event-stream -- without it // Codex returns a non-JSON, non-SSE response that fails both parse paths. @@ -260,7 +346,12 @@ export class CodexExecutor extends BaseExecutor { return headers; } - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex = 0, + credentials: ExecutorCredentials | null = null, + ): string | undefined { const base = super.buildUrl(model, stream, urlIndex, credentials); return this._isCompact ? `${base}/compact` : base; } @@ -270,14 +361,15 @@ export class CodexExecutor extends BaseExecutor { * Runs before execute() because Codex backend cannot fetch remote images. * Mutates body.input in place. */ - async prefetchImages(body) { + async prefetchImages(body: CodexBody) { if (!Array.isArray(body?.input)) return; - for (const item of body.input) { + for (const item of body.input as CodexInputItem[]) { if (!Array.isArray(item.content)) continue; - const pending = item.content.map(async (c) => { + const pending = item.content.map(async (entry: unknown) => { + const c = entry as ImageContent; if (c.type !== "image_url") return c; const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url; - const detail = c.image_url?.detail || "auto"; + const detail = typeof c.image_url === "string" ? "auto" : c.image_url?.detail || "auto"; if (!url) return c; if (url.startsWith("data:")) return { type: "input_image", image_url: url, detail }; const fetched = await fetchImageAsBase64(url, { timeoutMs: 15000 }); @@ -287,28 +379,29 @@ export class CodexExecutor extends BaseExecutor { } } - async execute(args) { - const imgCount = Array.isArray(args.body?.input) - ? args.body.input.reduce( - (n, it) => + async execute(args: ExecutorExecuteOptions): Promise { + const body = args.body as CodexBody; + const imgCount = Array.isArray(body?.input) + ? (body.input as CodexInputItem[]).reduce( + (n: number, it) => n + (Array.isArray(it.content) - ? it.content.filter((c) => c.type === "image_url").length + ? it.content.filter((c: unknown) => (c as ImageContent).type === "image_url").length : 0), 0, ) : 0; - const inputLen = Array.isArray(args.body?.input) ? args.body.input.length : 0; + const inputLen = Array.isArray(body?.input) ? body.input.length : 0; dbg( "CODEX", `execute start | inputItems=${inputLen} | images=${imgCount} | sessionId=${this._currentSessionId || "pending"}`, ); if (imgCount > 0) { const t0 = Date.now(); - await this.prefetchImages(args.body); + await this.prefetchImages(body); dbg("CODEX", `prefetchImages done | ${Date.now() - t0}ms`); } else { - await this.prefetchImages(args.body); + await this.prefetchImages(body); } // Retry loop for SSE-level overloaded errors (200 OK body contains event: error) @@ -357,9 +450,9 @@ export class CodexExecutor extends BaseExecutor { try { await result.response.body?.cancel?.(); } catch { - /* noop */ + // Cleanup only; the retry will issue a fresh upstream request. } - await new Promise((r) => setTimeout(r, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } } @@ -367,14 +460,14 @@ export class CodexExecutor extends BaseExecutor { // Returns { matched: string|null, replacementBody: ReadableStream|null }. // Caller MUST use replacementBody (original body has been read). // Uses TransformStream to avoid fragile releaseLock+getReader double-reader pattern. - async _peekSseOverloaded(response) { + async _peekSseOverloaded(response: Response): Promise { if (!response || !response.ok || !response.body) return { matched: null, replacementBody: null }; const reader = response.body.getReader(); const decoder = new TextDecoder(); - const chunks = []; + const chunks: Uint8Array[] = []; let text = ""; - let matched = null; + let matched: string | null = null; try { while (text.length < CODEX_SSE_PEEK_BYTES) { const { done, value } = await reader.read(); @@ -387,12 +480,12 @@ export class CodexExecutor extends BaseExecutor { break; } } - } catch (e) { - dbg("CODEX", `peek read error: ${e.message}`); + } catch (e: unknown) { + dbg("CODEX", `peek read error: ${errorMessage(e)}`); } // Re-assemble stream via TransformStream — single reader, no releaseLock+getReader. // Write peeked chunks first, then read remaining from same reader, then close. - const { readable, writable } = new TransformStream(); + const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); for (const c of chunks) { writer.write(c); @@ -405,8 +498,10 @@ export class CodexExecutor extends BaseExecutor { if (done) break; await writer.write(value); } - } catch (e) { - await writer.abort(e).catch(() => {}); + } catch (e: unknown) { + await writer.abort(e).catch(() => { + // Cleanup only; reader drain already failed. + }); return; } await writer.close(); @@ -416,14 +511,21 @@ export class CodexExecutor extends BaseExecutor { } // Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise - parseError(response, bodyText) { + parseError(response: Response, bodyText: string): ExecutorErrorDetails { if (response.status === 429 && bodyText) { try { - const json = JSON.parse(bodyText); + const json = JSON.parse(bodyText) as { + error?: { + message?: string; + resets_at?: number; + resets_in_seconds?: number; + type?: string; + }; + }; const err = json?.error; if (err?.type === "usage_limit_reached") { const now = Date.now(); - let resetsAtMs = null; + let resetsAtMs: number | null = null; if (typeof err.resets_at === "number" && err.resets_at > 0) { const ms = err.resets_at * 1000; if (ms > now) resetsAtMs = ms; @@ -450,7 +552,13 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing. * Image fetching is handled separately in prefetchImages() so this stays sync. */ - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + rawBody: unknown, + _stream: boolean, + credentials: ExecutorCredentials, + ): CodexBody { + const body = rawBody as CodexBody; this._isCompact = !!body._compact; delete body._compact; // Resolve conversation-stable session_id (priority: body -> assistant-text-hash -> workspace -> machine) @@ -490,23 +598,28 @@ export class CodexExecutor extends BaseExecutor { } // Map virtual Codex review models to the upstream Codex model before suffix parsing. - body.model = getModelUpstreamId("cx", body.model || model); + let requestModel = getModelUpstreamId( + "cx", + typeof body.model === "string" ? body.model : model, + ); + body.model = requestModel; // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high -> high, gpt-5.3-codex -> medium (default) const effortLevels = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; - let modelEffort = null; + let modelEffort: string | null = null; for (const level of effortLevels) { - if (body.model.endsWith(`-${level}`)) { + if (requestModel.endsWith(`-${level}`)) { modelEffort = level; // Strip suffix from model name for actual API call - body.model = body.model.replace(`-${level}`, ""); + requestModel = requestModel.replace(`-${level}`, ""); + body.model = requestModel; break; } } // Normalize: UI/client sends "extra-high" but Codex API expects "xhigh" - const EFFORT_ALIASES = { + const EFFORT_ALIASES: Record = { "extra-high": "xhigh", extrahigh: "xhigh", "very-high": "xhigh", @@ -521,7 +634,8 @@ export class CodexExecutor extends BaseExecutor { // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (low) if (!body.reasoning) { - const effort = body.reasoning_effort || modelEffort || "low"; + const effort = + typeof body.reasoning_effort === "string" ? body.reasoning_effort : modelEffort || "low"; body.reasoning = { effort, summary: "auto" }; } else if (!body.reasoning.summary) { body.reasoning.summary = "auto"; diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.ts similarity index 70% rename from open-sse/executors/commandcode.js rename to open-sse/executors/commandcode.ts index d41d611e..330c7b0a 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.ts @@ -1,7 +1,14 @@ import { randomUUID } from "node:crypto"; import { PROVIDERS } from "../config/providers.js"; import { convertCommandCodeToOpenAI } from "../translator/response/commandcode-to-openai.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorConfigInput, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, +} from "./base.js"; /** * CommandCodeExecutor — talks to https://api.commandcode.ai/alpha/generate @@ -16,11 +23,11 @@ import { BaseExecutor } from "./base.js"; */ export class CommandCodeExecutor extends BaseExecutor { constructor() { - super("commandcode", PROVIDERS.commandcode); + super("commandcode", (PROVIDERS as Record).commandcode!); } - buildHeaders(credentials, stream = true) { - const headers = { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + const headers: ExecutorHeaders = { "Content-Type": "application/json", ...(this.config.headers || {}), "x-session-id": randomUUID(), @@ -33,7 +40,7 @@ export class CommandCodeExecutor extends BaseExecutor { return headers; } - async execute(opts) { + async execute(opts: ExecutorExecuteOptions): Promise { const result = await super.execute(opts); if (!result?.response?.ok || !result.response.body) return result; result.response = wrapNdjsonAsOpenAISse(result.response, opts.model); @@ -41,23 +48,26 @@ export class CommandCodeExecutor extends BaseExecutor { } } -function wrapNdjsonAsOpenAISse(originalResponse, model) { +function wrapNdjsonAsOpenAISse(originalResponse: Response, model: string): Response { const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; const state = { model }; - const emitChunks = (chunks, controller) => { + const emitChunks = ( + chunks: unknown, + controller: TransformStreamDefaultController, + ) => { if (!chunks) return; const list = Array.isArray(chunks) ? chunks : [chunks]; for (const c of list) { - if (c == null) continue; + if (c === null || c === undefined) continue; controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`)); } }; - const transform = new TransformStream({ - transform(chunk, controller) { + const transform = new TransformStream({ + transform(chunk: Uint8Array, controller: TransformStreamDefaultController) { buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; @@ -68,7 +78,7 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) { emitChunks(convertCommandCodeToOpenAI(trimmed, state), controller); } }, - flush(controller) { + flush(controller: TransformStreamDefaultController) { const trimmed = buffer.trim(); if (trimmed) { emitChunks(convertCommandCodeToOpenAI(trimmed, state), controller); @@ -77,7 +87,7 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) { }, }); - const newBody = originalResponse.body.pipeThrough(transform); + const newBody = originalResponse.body!.pipeThrough(transform); return new Response(newBody, { status: originalResponse.status, statusText: originalResponse.statusText, diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.ts similarity index 78% rename from open-sse/executors/cursor.js rename to open-sse/executors/cursor.ts index 356a6a16..fa9e1285 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.ts @@ -1,4 +1,5 @@ import zlib from "node:zlib"; +import type { IncomingHttpHeaders } from "node:http2"; import { PROVIDERS } from "../config/providers.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { FORMATS } from "../translator/formats.js"; @@ -6,22 +7,83 @@ import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { extractTextFromResponse, generateCursorBody } from "../utils/cursorProtobuf.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { estimateUsage } from "../utils/usageTracking.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorHeaders, + type ExecutorProxyOptions, +} from "./base.js"; + +type EdgeRuntimeGlobal = typeof globalThis & { EdgeRuntime?: unknown }; +type Http2Module = typeof import("node:http2"); +type CursorBuffer = Buffer; +type CursorCredentials = ExecutorCredentials & { + providerSpecificData?: ExecutorCredentials["providerSpecificData"] & { + ghostMode?: boolean; + machineId?: string; + }; + rawHeaders?: Record; +}; +type CursorProxyOptions = Record & { + connectionProxyEnabled?: boolean; + enabled?: boolean; + vercelRelayUrl?: unknown; +}; +type CursorRequestBody = { + messages?: Array>; + reasoning_effort?: string | null; + tools?: Array>; +}; +type CursorTransportResponse = { + body: CursorBuffer; + headers: Record; + status: number; +}; +type CursorErrorPayload = { + error?: { + code?: unknown; + details?: Array<{ debug?: { details?: { detail?: string; title?: string }; error?: string } }>; + message?: string; + }; +}; +type CursorToolCall = { + function: { + arguments: string; + name: string; + }; + id: string; + index?: number; + isLast?: boolean; + type: string; +}; +type CursorAssistantMessage = { + content: string | null; + role: "assistant"; + tool_calls?: Array<{ + function: { + arguments: string; + name: string; + }; + id: string; + type: string; + }>; +}; // Detect cloud environment const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; - if (typeof EdgeRuntime !== "undefined") return true; + if (typeof (globalThis as EdgeRuntimeGlobal).EdgeRuntime !== "undefined") return true; return false; }; // Lazy import http2 (only in Node.js environment) -let http2 = null; +let http2: Http2Module | null = null; if (!isCloudEnv()) { try { http2 = await import("node:http2"); } catch { - // http2 not available + // Optional Node-only transport; fetch remains the fallback. } } @@ -33,11 +95,15 @@ const COMPRESS_FLAG = { }; const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; -const debugLog = (...args) => { +const debugLog = (...args: unknown[]) => { if (CURSOR_STREAM_DEBUG) console.log(...args); }; -function decompressPayload(payload, flags) { +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function decompressPayload(payload: CursorBuffer, flags: number): CursorBuffer { // Check if payload is JSON error (starts with {"error") if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { try { @@ -46,7 +112,9 @@ function decompressPayload(payload, flags) { debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`); return payload; } - } catch {} + } catch { + // Payload sniffing only; decompression below handles binary frames. + } } if ( @@ -57,17 +125,17 @@ function decompressPayload(payload, flags) { // Primary: try gzip decompression (standard gzip header 0x1f 0x8b) try { return zlib.gunzipSync(payload); - } catch (gzipErr) { + } catch (gzipErr: unknown) { // Fallback: TRAILER and GZIP_TRAILER frames sometimes use raw zlib deflate format try { return zlib.inflateSync(payload); - } catch (deflateErr) { + } catch (deflateErr: unknown) { // Last resort: try raw deflate (no zlib header) try { return zlib.inflateRawSync(payload); - } catch (rawErr) { + } catch (rawErr: unknown) { debugLog( - `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}`, + `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${errorMessage(gzipErr)}, deflate=${errorMessage(deflateErr)}, raw=${errorMessage(rawErr)}`, ); debugLog( `[DECOMPRESS ERROR] First 50 bytes (hex):`, @@ -81,7 +149,7 @@ function decompressPayload(payload, flags) { return payload; } -function createErrorResponse(jsonError) { +function createErrorResponse(jsonError: CursorErrorPayload) { const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title || jsonError?.error?.details?.[0]?.debug?.details?.detail || @@ -114,7 +182,7 @@ export class CursorExecutor extends BaseExecutor { return `${this.config.baseUrl}${this.config.chatPath}`; } - buildHeaders(credentials) { + buildHeaders(credentials: CursorCredentials): ExecutorHeaders { const accessToken = credentials.accessToken; const machineId = credentials.providerSpecificData?.machineId; const ghostMode = credentials.providerSpecificData?.ghostMode !== false; @@ -126,7 +194,12 @@ export class CursorExecutor extends BaseExecutor { return buildCursorHeaders(accessToken, machineId, ghostMode); } - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + body: CursorRequestBody, + _stream: boolean, + credentials: CursorCredentials, + ) { // Messages are already translated by chatCore (claude→openai→cursor) // Do NOT call buildCursorRequest again — double-translation drops tool_results const messages = body.messages || []; @@ -139,7 +212,13 @@ export class CursorExecutor extends BaseExecutor { return generateCursorBody(messages, model, tools, reasoningEffort, forceAgentMode); } - async makeFetchRequest(url, headers, body, signal, proxyOptions = null) { + async makeFetchRequest( + url: string, + headers: ExecutorHeaders, + body: BodyInit, + signal: AbortSignal | undefined, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( url, { @@ -158,7 +237,12 @@ export class CursorExecutor extends BaseExecutor { }; } - makeHttp2Request(url, headers, body, signal) { + makeHttp2Request( + url: string, + headers: ExecutorHeaders, + body: Uint8Array, + signal: AbortSignal | undefined, + ): Promise { if (!http2) { throw new Error("http2 module not available"); } @@ -168,14 +252,14 @@ export class CursorExecutor extends BaseExecutor { return new Promise((resolve, reject) => { const urlObj = new URL(url); const client = http2.connect(`https://${urlObj.host}`); - const chunks = []; - let responseHeaders = {}; + const chunks: Buffer[] = []; + let responseHeaders: IncomingHttpHeaders & { ":status"?: number } = {}; let settled = false; // Ensure client is always closed on settle const finish = - (fn) => - (...args) => { + (fn: (...args: Args) => void) => + (...args: Args) => { if (settled) return; settled = true; clearTimeout(hangTimeout); @@ -201,17 +285,17 @@ export class CursorExecutor extends BaseExecutor { ...headers, }); - req.on("response", (hdrs) => { + req.on("response", (hdrs: IncomingHttpHeaders & { ":status"?: number }) => { responseHeaders = hdrs; }); - req.on("data", (chunk) => { + req.on("data", (chunk: Buffer) => { chunks.push(chunk); }); req.on( "end", finish(() => { resolve({ - status: responseHeaders[":status"], + status: responseHeaders[":status"] as number, headers: responseHeaders, body: Buffer.concat(chunks), }); @@ -229,16 +313,27 @@ export class CursorExecutor extends BaseExecutor { }); } - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ + model, + body, + stream, + credentials, + signal, + log: _log, + proxyOptions = null, + }: ExecutorExecuteOptions) { const url = this.buildUrl(); - const headers = this.buildHeaders(credentials); - const transformedBody = this.transformRequest(model, body, stream, credentials); + const cursorCredentials = credentials as CursorCredentials; + const cursorBody = (body && typeof body === "object" ? body : {}) as CursorRequestBody; + const headers = this.buildHeaders(cursorCredentials); + const transformedBody = this.transformRequest(model, cursorBody, stream, cursorCredentials); try { + const cursorProxyOptions = proxyOptions as CursorProxyOptions | null; const shouldForceFetch = - proxyOptions?.enabled === true || - proxyOptions?.connectionProxyEnabled === true || - !!proxyOptions?.vercelRelayUrl; + cursorProxyOptions?.enabled === true || + cursorProxyOptions?.connectionProxyEnabled === true || + !!cursorProxyOptions?.vercelRelayUrl; const response = http2 && !shouldForceFetch ? await this.makeHttp2Request(url, headers, transformedBody, signal) @@ -268,11 +363,11 @@ export class CursorExecutor extends BaseExecutor { : this.transformProtobufToJSON(response.body, model, body); return { response: transformedResponse, url, headers, transformedBody: body }; - } catch (error) { + } catch (error: unknown) { const errorResponse = new Response( JSON.stringify({ error: { - message: error.message, + message: errorMessage(error), type: "connection_error", code: "", }, @@ -286,15 +381,15 @@ export class CursorExecutor extends BaseExecutor { } } - transformProtobufToJSON(buffer, model, body) { + transformProtobufToJSON(buffer: CursorBuffer, model: string, body: unknown) { const responseId = `chatcmpl-cursor-${Date.now()}`; const created = Math.floor(Date.now() / 1000); let offset = 0; let totalContent = ""; - const toolCalls = []; - const toolCallsMap = new Map(); // Track streaming tool calls by ID - const finalizedIds = new Set(); + const toolCalls: CursorToolCall[] = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); let frameCount = 0; debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); @@ -307,7 +402,7 @@ export class CursorExecutor extends BaseExecutor { break; } - const flags = buffer[offset]; + const flags = buffer[offset] ?? 0; const length = buffer.readUInt32BE(offset + 1); debugLog( @@ -321,7 +416,7 @@ export class CursorExecutor extends BaseExecutor { break; } - let payload = buffer.slice(offset + 5, offset + 5 + length); + let payload: CursorBuffer = buffer.slice(offset + 5, offset + 5 + length) as CursorBuffer; offset += 5 + length; frameCount++; @@ -345,7 +440,9 @@ export class CursorExecutor extends BaseExecutor { } return createErrorResponse(JSON.parse(text)); } - } catch {} + } catch { + // Non-JSON Cursor frames are decoded as protobuf below. + } } const result = extractTextFromResponse(new Uint8Array(payload)); @@ -378,8 +475,10 @@ export class CursorExecutor extends BaseExecutor { if (toolCallsMap.has(tc.id)) { // Accumulate arguments for existing tool call const existing = toolCallsMap.get(tc.id); - existing.function.arguments += tc.function.arguments; - existing.isLast = tc.isLast; + if (existing) { + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + } } else { // New tool call toolCallsMap.set(tc.id, { ...tc }); @@ -389,14 +488,16 @@ export class CursorExecutor extends BaseExecutor { if (tc.isLast) { const finalToolCall = toolCallsMap.get(tc.id); finalizedIds.add(tc.id); - toolCalls.push({ - id: finalToolCall.id, - type: finalToolCall.type, - function: { - name: finalToolCall.function.name, - arguments: finalToolCall.function.arguments, - }, - }); + if (finalToolCall) { + toolCalls.push({ + id: finalToolCall.id, + type: finalToolCall.type, + function: { + name: finalToolCall.function.name, + arguments: finalToolCall.function.arguments, + }, + }); + } } } @@ -425,7 +526,7 @@ export class CursorExecutor extends BaseExecutor { debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); - const message = { + const message: CursorAssistantMessage = { role: "assistant", content: totalContent || null, }; @@ -457,17 +558,17 @@ export class CursorExecutor extends BaseExecutor { }); } - transformProtobufToSSE(buffer, model, body) { + transformProtobufToSSE(buffer: CursorBuffer, model: string, body: unknown) { const responseId = `chatcmpl-cursor-${Date.now()}`; const created = Math.floor(Date.now() / 1000); - const chunks = []; + const chunks: string[] = []; let offset = 0; let totalContent = ""; - const toolCalls = []; - const toolCallsMap = new Map(); // Track streaming tool calls by ID - const finalizedIds = new Set(); - const emittedToolCallIds = new Set(); + const toolCalls: CursorToolCall[] = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); + const emittedToolCallIds = new Set(); let frameCount = 0; debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); @@ -480,7 +581,7 @@ export class CursorExecutor extends BaseExecutor { break; } - const flags = buffer[offset]; + const flags = buffer[offset] ?? 0; const length = buffer.readUInt32BE(offset + 1); debugLog( @@ -494,7 +595,7 @@ export class CursorExecutor extends BaseExecutor { break; } - let payload = buffer.slice(offset + 5, offset + 5 + length); + let payload: CursorBuffer = buffer.slice(offset + 5, offset + 5 + length) as CursorBuffer; offset += 5 + length; frameCount++; @@ -518,7 +619,9 @@ export class CursorExecutor extends BaseExecutor { } return createErrorResponse(JSON.parse(text)); } - } catch {} + } catch { + // Non-JSON Cursor frames are decoded as protobuf below. + } } const result = extractTextFromResponse(new Uint8Array(payload)); @@ -569,6 +672,7 @@ export class CursorExecutor extends BaseExecutor { if (toolCallsMap.has(tc.id)) { // Accumulate arguments for existing tool call const existing = toolCallsMap.get(tc.id); + if (!existing) continue; const _oldArgsLen = existing.function.arguments.length; existing.function.arguments += tc.function.arguments; existing.isLast = tc.isLast; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.ts similarity index 62% rename from open-sse/executors/default.js rename to open-sse/executors/default.ts index 2ce06a53..f5201ccd 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.ts @@ -1,29 +1,76 @@ +// @ts-nocheck import { buildClineHeaders } from "../../src/shared/utils/clineAuth.mts"; import { buildKimiHeaders, OAUTH_ENDPOINTS } from "../config/appConstants.js"; import { PROVIDERS } from "../config/providers.js"; import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorConfigInput, + type ExecutorHeaders, + type ExecutorLogger, + type ExecutorProxyOptions, +} from "./base.js"; + +type JsonRecord = Record; +type ChatMessage = JsonRecord & { + content?: string | Array; + role?: string; +}; +type RefreshResult = ExecutorCredentials; +type OAuthTokenPayload = { + access_token?: unknown; + expires_in?: unknown; + refresh_token?: unknown; +}; +type ClineRefreshPayload = { + accessToken?: unknown; + expiresAt?: unknown; + refreshToken?: unknown; +}; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function asChatMessages(value: unknown): ChatMessage[] { + return Array.isArray(value) ? (value as ChatMessage[]) : []; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} export class DefaultExecutor extends BaseExecutor { - constructor(provider) { - super(provider, PROVIDERS[provider] || PROVIDERS.openai); + constructor(provider: string) { + super( + provider, + (PROVIDERS as Record)[provider] || PROVIDERS.openai, + ); } - transformRequest(model, body) { - let next = body; + transformRequest( + model: string, + body: unknown, + _stream?: boolean, + _credentials?: ExecutorCredentials, + ): unknown { + let next = asRecord(body); + const responseFormat = asRecord(next.response_format); + const jsonSchema = asRecord(responseFormat.json_schema); // For openai-compatible-* providers (DeepSeek, Ollama, custom local LLMs, etc.) that don't // natively support Structured Output, fall back: inject the schema into the system prompt // and downgrade response_format to json_object so the model still produces valid JSON. // Native OpenAI / first-party providers keep their json_schema as-is. if ( this.provider?.startsWith?.("openai-compatible-") && - next?.response_format?.type === "json_schema" && - next.response_format.json_schema?.schema + responseFormat.type === "json_schema" && + jsonSchema.schema ) { - const schema = next.response_format.json_schema.schema; - const schemaName = next.response_format.json_schema.name || "response"; + const schema = jsonSchema.schema; + const schemaName = asString(jsonSchema.name) || "response"; const schemaInstruction = `You must respond with valid JSON matching this JSON schema ("${schemaName}"): \`\`\`json ${JSON.stringify(schema, null, 2)} @@ -32,24 +79,27 @@ Respond ONLY with the JSON object, no other text.`; next = { ...next }; next.response_format = { type: "json_object" }; - next.messages = Array.isArray(next.messages) ? [...next.messages] : []; + next.messages = [...asChatMessages(next.messages)]; + const messages = next.messages as ChatMessage[]; // Prepend a system message (or merge into the first one) so the schema is in front. - const firstSystemIdx = next.messages.findIndex((m) => m?.role === "system"); + const firstSystemIdx = messages.findIndex((m) => m?.role === "system"); if (firstSystemIdx === -1) { - next.messages.unshift({ role: "system", content: schemaInstruction }); + messages.unshift({ role: "system", content: schemaInstruction }); } else { - const sys = next.messages[firstSystemIdx]; + const sys = messages[firstSystemIdx] || {}; const existing = typeof sys.content === "string" ? sys.content : Array.isArray(sys.content) ? sys.content - .map((c) => (typeof c === "string" ? c : c?.text || "")) + .map((c) => + typeof c === "string" ? c : typeof c?.text === "string" ? c.text : "", + ) .filter(Boolean) .join("\n") : ""; - next.messages[firstSystemIdx] = { + messages[firstSystemIdx] = { ...sys, content: existing ? `${existing}\n\n${schemaInstruction}` : schemaInstruction, }; @@ -58,7 +108,12 @@ Respond ONLY with the JSON object, no other text.`; return injectReasoningContent({ provider: this.provider, model, body: next }); } - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ): string | undefined { if (this.provider?.startsWith?.("openai-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); @@ -94,8 +149,11 @@ Respond ONLY with the JSON object, no other text.`; } } - buildHeaders(credentials, stream = true) { - const headers = { "Content-Type": "application/json", ...this.config.headers }; + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + const headers: ExecutorHeaders = { + "Content-Type": "application/json", + ...this.config.headers, + }; switch (this.provider) { case "gemini": @@ -113,7 +171,10 @@ Respond ONLY with the JSON object, no other text.`; // Remove Title-Case static keys that conflict with incoming lowercase cached keys for (const lcKey of Object.keys(cached)) { // Build the Title-Case equivalent: "anthropic-version" → "Anthropic-Version" - const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, c) => sep + c.toUpperCase()); + const titleKey = lcKey.replace( + /(^|-)([a-z])/g, + (_match: string, sep: string, c: string) => sep + c.toUpperCase(), + ); // Special handling for Anthropic-Beta to preserve required flags like OAuth if (lcKey === "anthropic-beta") { @@ -121,13 +182,14 @@ Respond ONLY with the JSON object, no other text.`; const staticFlags = new Set( staticBetaStr .split(",") - .map((f) => f.trim()) + .map((f: string) => f.trim()) .filter(Boolean), ); + const cachedBetaStr = cached[lcKey] || ""; const cachedFlags = new Set( - cached[lcKey] + cachedBetaStr .split(",") - .map((f) => f.trim()) + .map((f: string) => f.trim()) .filter(Boolean), ); @@ -145,7 +207,6 @@ Respond ONLY with the JSON object, no other text.`; } Object.assign(headers, cached); } - credentials.apiKey; if (credentials.apiKey) { headers["x-api-key"] = credentials.apiKey; } else { @@ -181,10 +242,13 @@ Respond ONLY with the JSON object, no other text.`; } else if (this.provider === "kilocode") { headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; if (credentials.providerSpecificData?.orgId) { - headers["X-Kilocode-OrganizationID"] = credentials.providerSpecificData.orgId; + headers["X-Kilocode-OrganizationID"] = String(credentials.providerSpecificData.orgId); } } else if (this.provider === "cline") { - Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken)); + Object.assign( + headers, + buildClineHeaders(String(credentials.apiKey || credentials.accessToken)), + ); } else { headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; } @@ -229,16 +293,21 @@ Respond ONLY with the JSON object, no other text.`; return headers; } - async refreshCredentials(credentials, log, proxyOptions = null) { - if (!credentials.refreshToken) return null; + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { + const refreshToken = credentials.refreshToken; + if (!refreshToken) return null; - const refreshers = { + const refreshers: Record Promise> = { claude: () => this.refreshWithJSON( OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", - refresh_token: credentials.refreshToken, + refresh_token: refreshToken, client_id: PROVIDERS.claude.clientId, }, proxyOptions, @@ -248,7 +317,7 @@ Respond ONLY with the JSON object, no other text.`; OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", - refresh_token: credentials.refreshToken, + refresh_token: refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access", }, @@ -259,17 +328,17 @@ Respond ONLY with the JSON object, no other text.`; OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", - refresh_token: credentials.refreshToken, + refresh_token: refreshToken, client_id: PROVIDERS.qwen.clientId, }, proxyOptions, ), - iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions), - gemini: () => this.refreshGoogle(credentials.refreshToken, proxyOptions), - kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), - cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), - "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), - kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions), + iflow: () => this.refreshIflow(refreshToken, proxyOptions), + gemini: () => this.refreshGoogle(refreshToken, proxyOptions), + kiro: () => this.refreshKiro(refreshToken, proxyOptions), + cline: () => this.refreshCline(refreshToken, proxyOptions), + "kimi-coding": () => this.refreshKimiCoding(refreshToken, proxyOptions), + kilocode: () => this.refreshKilocode(refreshToken, proxyOptions), }; const refresher = refreshers[this.provider]; @@ -279,13 +348,20 @@ Respond ONLY with the JSON object, no other text.`; const result = await refresher(); if (result) log?.info?.("TOKEN", `${this.provider} refreshed`); return result; - } catch (error) { - log?.error?.("TOKEN", `${this.provider} refresh error: ${error.message}`); + } catch (error: unknown) { + log?.error?.( + "TOKEN", + `${this.provider} refresh error: ${error instanceof Error ? error.message : String(error)}`, + ); return null; } } - async refreshWithJSON(url, body, proxyOptions = null) { + async refreshWithJSON( + url: string, + body: JsonRecord, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( url, { @@ -296,15 +372,19 @@ Respond ONLY with the JSON object, no other text.`; proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || body.refresh_token, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || body.refresh_token) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; } - async refreshWithForm(url, params, proxyOptions = null) { + async refreshWithForm( + url: string, + params: Record, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( url, { @@ -318,15 +398,18 @@ Respond ONLY with the JSON object, no other text.`; proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || params.refresh_token, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || params.refresh_token) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; } - async refreshIflow(refreshToken, proxyOptions = null) { + async refreshIflow( + refreshToken: string, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { if (!PROVIDERS.iflow.clientSecret) return null; const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); @@ -349,15 +432,18 @@ Respond ONLY with the JSON object, no other text.`; proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || refreshToken) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; } - async refreshGoogle(refreshToken, proxyOptions = null) { + async refreshGoogle( + refreshToken: string, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( OAUTH_ENDPOINTS.google.token, { @@ -369,22 +455,25 @@ Respond ONLY with the JSON object, no other text.`; body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, + client_id: String(this.config.clientId), + client_secret: String(this.config.clientSecret), }), }, proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || refreshToken) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; } - async refreshKiro(refreshToken, proxyOptions = null) { + async refreshKiro( + refreshToken: string, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( PROVIDERS.kiro.tokenUrl, { @@ -399,15 +488,22 @@ Respond ONLY with the JSON object, no other text.`; proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as { + accessToken?: unknown; + expiresIn?: unknown; + refreshToken?: unknown; + }; return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken || refreshToken, - expiresIn: tokens.expiresIn, + accessToken: tokens.accessToken as string | undefined, + refreshToken: (tokens.refreshToken || refreshToken) as string | undefined, + expiresIn: tokens.expiresIn as string | number | undefined, }; } - async refreshCline(refreshToken, proxyOptions = null) { + async refreshCline( + refreshToken: string, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const response = await proxyAwareFetch( "https://api.cline.bot/api/v1/auth/refresh", { @@ -421,20 +517,24 @@ Respond ONLY with the JSON object, no other text.`; await response.text().catch(() => ""); return null; } - const payload = await response.json(); - const data = payload?.data || payload; + const payload = asRecord(await response.json()); + const data = asRecord(payload.data || payload) as ClineRefreshPayload; const expiresAtIso = data?.expiresAt; - const expiresIn = expiresAtIso - ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) - : undefined; + const expiresIn = + typeof expiresAtIso === "string" + ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) + : undefined; return { - accessToken: data?.accessToken, - refreshToken: data?.refreshToken || refreshToken, + accessToken: data?.accessToken as string | undefined, + refreshToken: (data?.refreshToken || refreshToken) as string | undefined, expiresIn, }; } - async refreshKimiCoding(refreshToken, proxyOptions = null) { + async refreshKimiCoding( + refreshToken: string, + proxyOptions: ExecutorProxyOptions = null, + ): Promise { const kimiHeaders = buildKimiHeaders(); const response = await proxyAwareFetch( "https://auth.kimi.com/api/oauth/token", @@ -454,15 +554,18 @@ Respond ONLY with the JSON object, no other text.`; proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as OAuthTokenPayload; return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || refreshToken) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; } - async refreshKilocode(refreshToken, proxyOptions = null) { + async refreshKilocode( + _refreshToken: string, + _proxyOptions: ExecutorProxyOptions = null, + ): Promise { // Kilocode uses device code flow, no refresh token support return null; } diff --git a/open-sse/executors/gemini-cli.js b/open-sse/executors/gemini-cli.js deleted file mode 100644 index d012aaf7..00000000 --- a/open-sse/executors/gemini-cli.js +++ /dev/null @@ -1,74 +0,0 @@ -import { - GEMINI_CLI_API_CLIENT, - geminiCLIUserAgent, - OAUTH_ENDPOINTS, -} from "../config/appConstants.js"; -import { PROVIDERS } from "../config/providers.js"; -import { BaseExecutor } from "./base.js"; - -export class GeminiCLIExecutor extends BaseExecutor { - constructor() { - super("gemini-cli", PROVIDERS["gemini-cli"]); - } - - buildUrl(model, stream, urlIndex = 0) { - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${this.config.baseUrl}:${action}`; - } - - buildHeaders(credentials, stream = true) { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${credentials.accessToken}`, - "User-Agent": geminiCLIUserAgent(this._currentModel), - "X-Goog-Api-Client": GEMINI_CLI_API_CLIENT, - Accept: stream ? "text/event-stream" : "application/json", - }; - } - - transformRequest(model, body, stream, credentials) { - // Store model for use in buildHeaders (called by base.execute after transformRequest) - this._currentModel = model; - if (!body.project && credentials?.projectId) { - body.project = credentials.projectId; - } - return body; - } - - async refreshCredentials(credentials, log) { - if (!credentials.refreshToken) return null; - - try { - const response = await fetch(OAUTH_ENDPOINTS.google.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: credentials.refreshToken, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - }), - }); - - if (!response.ok) return null; - - const tokens = await response.json(); - log?.info?.("TOKEN", "Gemini CLI refreshed"); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || credentials.refreshToken, - expiresIn: tokens.expires_in, - projectId: credentials.projectId, - }; - } catch (error) { - log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); - return null; - } - } -} - -export default GeminiCLIExecutor; diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts new file mode 100644 index 00000000..de59324f --- /dev/null +++ b/open-sse/executors/gemini-cli.ts @@ -0,0 +1,99 @@ +import { + GEMINI_CLI_API_CLIENT, + geminiCLIUserAgent, + OAUTH_ENDPOINTS, +} from "../config/appConstants.js"; +import { PROVIDERS } from "../config/providers.js"; +import { + BaseExecutor, + type ExecutorConfigInput, + type ExecutorCredentials, + type ExecutorHeaders, + type ExecutorLogger, +} from "./base.js"; + +export class GeminiCLIExecutor extends BaseExecutor { + private _currentModel: string | null = null; + + constructor() { + super("gemini-cli", (PROVIDERS as Record)["gemini-cli"]!); + } + + buildUrl(model: string, stream: boolean, _urlIndex: number = 0): string { + void model; + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `${this.config.baseUrl}:${action}`; + } + + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.accessToken}`, + "User-Agent": geminiCLIUserAgent(this._currentModel ?? undefined), + "X-Goog-Api-Client": GEMINI_CLI_API_CLIENT, + Accept: stream ? "text/event-stream" : "application/json", + }; + } + + transformRequest( + model: string, + body: unknown, + stream: boolean, + credentials: ExecutorCredentials, + ): unknown { + void stream; + // Store model for use in buildHeaders (called by base.execute after transformRequest) + this._currentModel = model; + const record = body as Record; + if (!record.project && credentials?.projectId) { + record.project = credentials.projectId; + } + return record; + } + + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + ): Promise { + if (!credentials.refreshToken) return null; + + try { + const tokenBody: Record = { + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: String(this.config.clientId), + client_secret: String(this.config.clientSecret), + }; + const response = await fetch(OAUTH_ENDPOINTS.google.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams(tokenBody), + }); + + if (!response.ok) return null; + + const tokens = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + log?.info?.("TOKEN", "Gemini CLI refreshed"); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + projectId: credentials.projectId, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("TOKEN", `Gemini CLI refresh error: ${message}`); + return null; + } + } +} + +export default GeminiCLIExecutor; diff --git a/open-sse/executors/github.js b/open-sse/executors/github.ts similarity index 70% rename from open-sse/executors/github.js rename to open-sse/executors/github.ts index caff417e..94f23ed8 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import crypto from "node:crypto"; import { GITHUB_COPILOT, OAUTH_ENDPOINTS } from "../config/appConstants.js"; import { PROVIDERS } from "../config/providers.js"; @@ -7,19 +8,74 @@ import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-res import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { formatSSE, parseSSELine } from "../utils/streamHelpers.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, + type ExecutorLogger, + type ExecutorProxyOptions, +} from "./base.js"; + +type JsonRecord = Record; +type ContentPart = JsonRecord & { + content?: unknown; + image_url?: unknown; + text?: unknown; + type?: string; +}; +type ChatMessage = JsonRecord & { + content?: string | ContentPart[] | null; + role?: string; +}; +type ChatRequestBody = JsonRecord & { + max_completion_tokens?: unknown; + max_tokens?: unknown; + messages?: ChatMessage[]; + model?: string; + reasoning_effort?: unknown; + response_format?: { + json_schema?: { schema?: unknown }; + type?: string; + }; + temperature?: unknown; + thinking?: unknown; +}; +type GithubCredentials = ExecutorCredentials & { + copilotTokenExpiresAt?: string | number | Date; +}; +type CopilotTokenResponse = { + expires_at?: unknown; + token?: unknown; +}; +type GithubTokenResponse = { + access_token?: unknown; + expires_in?: unknown; + refresh_token?: unknown; +}; + +function asChatBody(body: unknown): ChatRequestBody { + return body && typeof body === "object" && !Array.isArray(body) ? (body as ChatRequestBody) : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} export class GithubExecutor extends BaseExecutor { + private knownCodexModels: Set; + constructor() { super("github", PROVIDERS.github); this.knownCodexModels = new Set(); } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(_model: string, _stream: boolean, _urlIndex: number = 0) { return this.config.baseUrl; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { const token = credentials.copilotToken || credentials.accessToken; return { Authorization: `Bearer ${token}`, @@ -41,16 +97,17 @@ export class GithubExecutor extends BaseExecutor { // Sanitize messages for GitHub Copilot /chat/completions endpoint. // The endpoint only accepts 'text' and 'image_url' content part types. // Tool-related content (tool_use, tool_result, thinking) must be serialized as text. - sanitizeMessagesForChatCompletions(body) { - if (!body?.messages) return body; + sanitizeMessagesForChatCompletions(body: unknown) { + const chatBody = asChatBody(body); + if (!chatBody?.messages) return body; - const sanitized = { ...body }; + const sanitized = { ...chatBody }; // Handle response_format for Claude models via GitHub // GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt // AND prepend a reminder to the last user message for maximum effectiveness - if (body.response_format && body.model?.includes("claude")) { - const responseFormat = body.response_format; + if (chatBody.response_format && chatBody.model?.includes("claude")) { + const responseFormat = chatBody.response_format; let systemInstruction = ""; if (responseFormat.type === "json_schema" && responseFormat.json_schema?.schema) { systemInstruction = @@ -61,30 +118,36 @@ export class GithubExecutor extends BaseExecutor { } if (systemInstruction) { // Add to system message - const systemIdx = body.messages.findIndex((m) => m.role === "system"); + const systemIdx = chatBody.messages.findIndex((m) => m.role === "system"); if (systemIdx >= 0) { - body.messages[systemIdx].content = - systemInstruction + "\n\n" + body.messages[systemIdx].content; + const systemMsg = chatBody.messages[systemIdx]; + if (systemMsg) { + systemMsg.content = systemInstruction + "\n\n" + systemMsg.content; + } } else { - body.messages.unshift({ role: "system", content: systemInstruction }); + chatBody.messages.unshift({ role: "system", content: systemInstruction }); } // Also prepend to the last user message as a reminder - const lastUserIdx = body.messages + const lastUserIdx = chatBody.messages .map((m, i) => (m.role === "user" ? i : -1)) .filter((i) => i >= 0) .pop(); - if (lastUserIdx >= 0) { - const userMsg = body.messages[lastUserIdx]; - const userContent = - typeof userMsg.content === "string" ? userMsg.content : JSON.stringify(userMsg.content); - userMsg.content = - "Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): " + - userContent; + if (lastUserIdx !== undefined && lastUserIdx >= 0) { + const userMsg = chatBody.messages[lastUserIdx]; + if (userMsg) { + const userContent = + typeof userMsg.content === "string" + ? userMsg.content + : JSON.stringify(userMsg.content); + userMsg.content = + "Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): " + + userContent; + } } } } - sanitized.messages = body.messages.map((msg) => { + sanitized.messages = chatBody.messages.map((msg) => { // assistant messages with only tool_calls have content: null — leave as-is if (!msg.content) return msg; @@ -114,12 +177,12 @@ export class GithubExecutor extends BaseExecutor { } // Newer OpenAI models (gpt-5+, o1, o3, o4) require max_completion_tokens instead of max_tokens - requiresMaxCompletionTokens(model) { + requiresMaxCompletionTokens(model: string) { return /gpt-5|o[134]-/i.test(model); } // Some models (like gpt-5.4) don't support the temperature parameter - supportsTemperature(model) { + supportsTemperature(model: string) { // gpt-5.4 and similar newer models don't support temperature return !/gpt-5\.4/i.test(model); } @@ -127,14 +190,14 @@ export class GithubExecutor extends BaseExecutor { // GitHub Copilot /chat/completions rejects Claude-style thinking payloads // (OpenClaw sends thinking: { type: "enabled" } → upstream 400). // GPT-5 family on Copilot DOES honor reasoning_effort, so only strip for Claude. (#713) - supportsThinking(model) { + supportsThinking(model: string) { return !/claude/i.test(model); } // reasoning_effort works for GPT-5 family AND Claude Opus 4.6 / Sonnet 4.6 // on GitHub Copilot. Only strip for models that don't support it: // Claude Haiku 4.5, Claude Opus 4.7 (rejected upstream). - supportsReasoningEffort(model) { + supportsReasoningEffort(model: string) { const m = model.toLowerCase(); // Claude models that DO support reasoning_effort if (/claude.*opus.*4\.6/i.test(m) || /claude.*sonnet.*4\.6/i.test(m)) return true; @@ -144,8 +207,13 @@ export class GithubExecutor extends BaseExecutor { return true; } - transformRequest(model, body, stream, credentials) { - const transformed = { ...body }; + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials: ExecutorCredentials, + ) { + const transformed = { ...asChatBody(body) }; if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) { transformed.max_completion_tokens = transformed.max_tokens; delete transformed.max_tokens; @@ -169,12 +237,12 @@ export class GithubExecutor extends BaseExecutor { return transformed; } - async execute(options) { + async execute(options: ExecutorExecuteOptions): Promise { const { model, log } = options; // Only use /responses for models that are explicitly known to need it (e.g. gpt codex models) if (this.knownCodexModels.has(model)) { - log?.debug("GITHUB", `Using cached /responses route for ${model}`); + log?.debug?.("GITHUB", `Using cached /responses route for ${model}`); return this.executeWithResponsesEndpoint(options); } @@ -197,7 +265,7 @@ export class GithubExecutor extends BaseExecutor { errorBody.includes("not accessible via the /chat/completions endpoint") || errorBody.includes("The requested model is not supported") ) { - log?.warn("GITHUB", `Model ${model} requires /responses. Switching...`); + log?.warn?.("GITHUB", `Model ${model} requires /responses. Switching...`); this.knownCodexModels.add(model); return this.executeWithResponsesEndpoint(options); } @@ -214,13 +282,13 @@ export class GithubExecutor extends BaseExecutor { signal, log, proxyOptions = null, - }) { + }: ExecutorExecuteOptions): Promise { const url = this.config.responsesUrl; const headers = this.buildHeaders(credentials, stream); const transformedBody = openaiToOpenAIResponsesRequest(model, body, stream, credentials); - log?.debug("GITHUB", "Sending translated request to /responses"); + log?.debug?.("GITHUB", "Sending translated request to /responses"); const response = await proxyAwareFetch( url, @@ -244,7 +312,7 @@ export class GithubExecutor extends BaseExecutor { let buffer = ""; const transformStream = new TransformStream({ - async transform(chunk, controller) { + async transform(chunk: Uint8Array, controller: TransformStreamDefaultController) { buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split("\n"); @@ -269,7 +337,7 @@ export class GithubExecutor extends BaseExecutor { } } }, - flush(controller) { + flush(controller: TransformStreamDefaultController) { if (buffer.trim()) { const parsed = parseSSELine(buffer.trim()); if (parsed && !parsed.done) { @@ -304,7 +372,11 @@ export class GithubExecutor extends BaseExecutor { }; } - async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) { + async refreshCopilotToken( + githubAccessToken: string | undefined, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ) { try { const response = await proxyAwareFetch( "https://api.github.com/copilot_internal/v2/token", @@ -325,24 +397,31 @@ export class GithubExecutor extends BaseExecutor { log?.error?.("TOKEN", `Copilot token refresh failed: ${response.status} ${errorText}`); return null; } - const data = await response.json(); + const data = (await response.json()) as CopilotTokenResponse; log?.info?.("TOKEN", "Copilot token refreshed"); - return { token: data.token, expiresAt: data.expires_at }; - } catch (error) { - log?.error?.("TOKEN", `Copilot refresh error: ${error.message}`); + return { + token: data.token as string | undefined, + expiresAt: data.expires_at as string | number | undefined, + }; + } catch (error: unknown) { + log?.error?.("TOKEN", `Copilot refresh error: ${errorMessage(error)}`); return null; } } - async refreshGitHubToken(refreshToken, log, proxyOptions = null) { + async refreshGitHubToken( + refreshToken: string, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ) { try { - const params = { + const params: Record = { grant_type: "refresh_token", refresh_token: refreshToken, - client_id: this.config.clientId, + client_id: String(this.config.clientId), }; if (this.config.clientSecret) { - params.client_secret = this.config.clientSecret; + params.client_secret = String(this.config.clientSecret); } const response = await proxyAwareFetch( @@ -358,20 +437,24 @@ export class GithubExecutor extends BaseExecutor { proxyOptions, ); if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as GithubTokenResponse; log?.info?.("TOKEN", "GitHub token refreshed"); return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, + accessToken: tokens.access_token as string | undefined, + refreshToken: (tokens.refresh_token || refreshToken) as string | undefined, + expiresIn: tokens.expires_in as string | number | undefined, }; - } catch (error) { - log?.error?.("TOKEN", `GitHub refresh error: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN", `GitHub refresh error: ${errorMessage(error)}`); return null; } } - async refreshCredentials(credentials, log, proxyOptions = null) { + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ) { let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log, proxyOptions); if (!copilotResult && credentials.refreshToken) { @@ -405,7 +488,7 @@ export class GithubExecutor extends BaseExecutor { return null; } - needsRefresh(credentials) { + needsRefresh(credentials: GithubCredentials) { // Always refresh if no copilotToken if (!credentials.copilotToken) return true; @@ -416,8 +499,10 @@ export class GithubExecutor extends BaseExecutor { expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms } else if (typeof expiresAtMs === "string") { expiresAtMs = new Date(expiresAtMs).getTime(); + } else if (expiresAtMs instanceof Date) { + expiresAtMs = expiresAtMs.getTime(); } - if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true; + if (typeof expiresAtMs === "number" && expiresAtMs - Date.now() < 5 * 60 * 1000) return true; } return super.needsRefresh(credentials); } diff --git a/open-sse/executors/grok-web.js b/open-sse/executors/grok-web.ts similarity index 81% rename from open-sse/executors/grok-web.js rename to open-sse/executors/grok-web.ts index 0f50b9a0..bed3b859 100644 --- a/open-sse/executors/grok-web.js +++ b/open-sse/executors/grok-web.ts @@ -1,11 +1,56 @@ import { PROVIDERS } from "../config/providers.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, +} from "./base.js"; const GROK_CHAT_API = PROVIDERS["grok-web"].baseUrl; const GROK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"; -const MODEL_MAP = { +type GrokModelInfo = { grokModel: string; modelMode: string; isThinking: boolean }; +type JsonRecord = Record; +type OpenAIContentPart = { type?: string; text?: unknown }; +type OpenAIMessage = { role?: string; content?: string | OpenAIContentPart[] | unknown }; +type ExtractedMessage = { role: string; text: string }; +type GrokBody = JsonRecord & { messages?: OpenAIMessage[] }; +type GrokResponseEvent = { + error?: { code?: string; message?: string }; + result?: { + response?: { + llmInfo?: { modelHash?: string }; + modelResponse?: { message?: string; metadata?: { llm_info?: { modelHash?: string } } }; + responseId?: string; + token?: string; + }; + }; +}; +type GrokContentChunk = { + delta?: string; + done?: boolean; + error?: unknown; + fingerprint?: string; + fullMessage?: string; + responseId?: string; + thinking?: string; +}; +type AssistantMessage = { role: "assistant"; content: string; reasoning_content?: string }; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asGrokBody(value: unknown): GrokBody { + return isRecord(value) ? value : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +const MODEL_MAP: Record = { "grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false }, "grok-3-mini": { grokModel: "grok-3", @@ -54,7 +99,7 @@ const MODEL_MAP = { "grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, }; -function randomString(length, alphanumeric = false) { +function randomString(length: number, alphanumeric = false) { const chars = alphanumeric ? "abcdefghijklmnopqrstuvwxyz0123456789" : "abcdefghijklmnopqrstuvwxyz"; @@ -71,14 +116,14 @@ function generateStatsigId() { return btoa(msg); } -function randomHex(bytes) { +function randomHex(bytes: number) { const arr = new Uint8Array(bytes); crypto.getRandomValues(arr); return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); } -function parseOpenAIMessages(messages) { - const extracted = []; +function parseOpenAIMessages(messages: readonly OpenAIMessage[]) { + const extracted: ExtractedMessage[] = []; for (const msg of messages) { let role = String(msg.role || "user"); if (role === "developer") role = "system"; @@ -97,21 +142,24 @@ function parseOpenAIMessages(messages) { let lastUserIdx = -1; for (let i = extracted.length - 1; i >= 0; i--) { - if (extracted[i].role === "user") { + if (extracted[i]?.role === "user") { lastUserIdx = i; break; } } - const parts = []; + const parts: string[] = []; for (let i = 0; i < extracted.length; i++) { - const { role, text } = extracted[i]; + const { role, text } = extracted[i] ?? { role: "user", text: "" }; parts.push(i === lastUserIdx ? text : `${role}: ${text}`); } return parts.join("\n\n"); } -async function* readGrokNdjsonEvents(body, signal) { +async function* readGrokNdjsonEvents( + body: ReadableStream, + signal?: AbortSignal, +): AsyncGenerator { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -128,7 +176,8 @@ async function* readGrokNdjsonEvents(body, signal) { buffer = buffer.slice(idx + 1); if (!line) continue; try { - yield JSON.parse(line); + const parsed = JSON.parse(line) as unknown; + if (isRecord(parsed)) yield parsed as GrokResponseEvent; } catch { /* skip */ } @@ -138,7 +187,8 @@ async function* readGrokNdjsonEvents(body, signal) { const remaining = buffer.trim(); if (remaining) { try { - yield JSON.parse(remaining); + const parsed = JSON.parse(remaining) as unknown; + if (isRecord(parsed)) yield parsed as GrokResponseEvent; } catch { /* skip */ } @@ -148,7 +198,11 @@ async function* readGrokNdjsonEvents(body, signal) { } } -async function* extractContent(eventStream, isThinkingModel, signal) { +async function* extractContent( + eventStream: ReadableStream, + isThinkingModel: boolean, + signal?: AbortSignal, +): AsyncGenerator { let fingerprint = ""; let responseId = ""; let thinkOpened = false; @@ -175,18 +229,27 @@ async function* extractContent(eventStream, isThinkingModel, signal) { continue; } - if (resp.token != null) yield { delta: resp.token, fingerprint, responseId }; + if (resp.token !== null && resp.token !== undefined) { + yield { delta: resp.token, fingerprint, responseId }; + } } yield { done: true, fingerprint, responseId }; } -function sseChunk(data) { +function sseChunk(data: unknown) { return `data: ${JSON.stringify(data)}\n\n`; } -function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { +function buildStreamingResponse( + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + isThinkingModel: boolean, + signal?: AbortSignal, +) { const encoder = new TextEncoder(); - return new ReadableStream({ + return new ReadableStream({ async start(controller) { try { controller.enqueue( @@ -289,7 +352,7 @@ function buildStreamingResponse(eventStream, model, cid, created, isThinkingMode ), ); controller.enqueue(encoder.encode("data: [DONE]\n\n")); - } catch (err) { + } catch (err: unknown) { controller.enqueue( encoder.encode( sseChunk({ @@ -301,7 +364,7 @@ function buildStreamingResponse(eventStream, model, cid, created, isThinkingMode choices: [ { index: 0, - delta: { content: `[Stream error: ${err.message || String(err)}]` }, + delta: { content: `[Stream error: ${errorMessage(err)}]` }, finish_reason: "stop", logprobs: null, }, @@ -318,16 +381,16 @@ function buildStreamingResponse(eventStream, model, cid, created, isThinkingMode } async function buildNonStreamingResponse( - eventStream, - model, - cid, - created, - isThinkingModel, - signal, + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + isThinkingModel: boolean, + signal?: AbortSignal, ) { let fullContent = ""; let fingerprint = ""; - const thinkingParts = []; + const thinkingParts: string[] = []; for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) { if (chunk.fingerprint) fingerprint = chunk.fingerprint; @@ -348,7 +411,7 @@ async function buildNonStreamingResponse( else if (chunk.delta) fullContent += chunk.delta; } - const msg = { role: "assistant", content: fullContent }; + const msg: AssistantMessage = { role: "assistant", content: fullContent }; if (thinkingParts.length > 0) msg.reasoning_content = thinkingParts.join("\n"); const promptTokens = Math.ceil(fullContent.length / 4); @@ -377,8 +440,16 @@ export class GrokWebExecutor extends BaseExecutor { super("grok-web", PROVIDERS["grok-web"]); } - async execute({ model, body, stream, credentials, signal, log }) { - const messages = body?.messages; + async execute({ + model, + body, + stream, + credentials, + signal, + log, + }: ExecutorExecuteOptions): Promise { + const requestBody = asGrokBody(body); + const messages = requestBody.messages; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errResp = new Response( JSON.stringify({ @@ -391,7 +462,8 @@ export class GrokWebExecutor extends BaseExecutor { const modelInfo = MODEL_MAP[model]; if (!modelInfo) log?.info?.("GROK-WEB", `Unmapped model ${model}, defaulting to grok-4.1-fast`); - const { grokModel, modelMode, isThinking } = modelInfo || MODEL_MAP["grok-4.1-fast"]; + const fallbackModel = MODEL_MAP["grok-4.1-fast"] as GrokModelInfo; + const { grokModel, modelMode, isThinking } = modelInfo || fallbackModel; const message = parseOpenAIMessages(messages); if (!message.trim()) { @@ -439,7 +511,7 @@ export class GrokWebExecutor extends BaseExecutor { const traceId = randomHex(16); const spanId = randomHex(8); - const headers = { + const headers: ExecutorHeaders = { Accept: "*/*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", @@ -482,12 +554,12 @@ export class GrokWebExecutor extends BaseExecutor { body: JSON.stringify(grokPayload), signal, }); - } catch (err) { - log?.error?.("GROK-WEB", `Fetch failed: ${err.message || String(err)}`); + } catch (err: unknown) { + log?.error?.("GROK-WEB", `Fetch failed: ${errorMessage(err)}`); const errResp = new Response( JSON.stringify({ error: { - message: `Grok connection failed: ${err.message || String(err)}`, + message: `Grok connection failed: ${errorMessage(err)}`, type: "upstream_error", }, }), diff --git a/open-sse/executors/iflow.js b/open-sse/executors/iflow.ts similarity index 74% rename from open-sse/executors/iflow.js rename to open-sse/executors/iflow.ts index 7e1cc37c..41f450d7 100644 --- a/open-sse/executors/iflow.js +++ b/open-sse/executors/iflow.ts @@ -1,13 +1,18 @@ import crypto from "node:crypto"; import { PROVIDERS } from "../config/providers.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorConfigInput, + type ExecutorCredentials, + type ExecutorHeaders, +} from "./base.js"; /** * IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature */ export class IFlowExecutor extends BaseExecutor { constructor() { - super("iflow", PROVIDERS.iflow); + super("iflow", (PROVIDERS as Record).iflow!); } /** @@ -26,7 +31,7 @@ export class IFlowExecutor extends BaseExecutor { * @param {string} apiKey - API key for signing * @returns {string} Hex-encoded signature */ - createIFlowSignature(userAgent, sessionID, timestamp, apiKey) { + createIFlowSignature(userAgent: string, sessionID: string, timestamp: number, apiKey: string) { if (!apiKey) return ""; const payload = `${userAgent}:${sessionID}:${timestamp}`; const hmac = crypto.createHmac("sha256", apiKey); @@ -40,7 +45,7 @@ export class IFlowExecutor extends BaseExecutor { * @param {boolean} stream - Whether streaming is enabled * @returns {object} Headers object */ - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { // Generate session ID and timestamp const sessionID = `session-${this.generateUUID()}`; const timestamp = Date.now(); @@ -55,7 +60,7 @@ export class IFlowExecutor extends BaseExecutor { const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey); // Build headers - const headers = { + const headers: ExecutorHeaders = { "Content-Type": "application/json", ...this.config.headers, "session-id": sessionID, @@ -84,8 +89,13 @@ export class IFlowExecutor extends BaseExecutor { * @param {object} credentials - Provider credentials * @returns {string} API URL */ - buildUrl(model, stream, urlIndex = 0, credentials = null) { - return this.config.baseUrl; + buildUrl( + _model: string, + _stream: boolean, + _urlIndex: number = 0, + _credentials: ExecutorCredentials | null = null, + ): string { + return this.config.baseUrl!; } /** @@ -96,12 +106,19 @@ export class IFlowExecutor extends BaseExecutor { * @param {object} credentials - Provider credentials * @returns {object} Transformed body */ - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + body: unknown, + stream: boolean, + _credentials: ExecutorCredentials, + ): unknown { + void model; + const record = body as Record; // Inject stream_options for streaming requests to get usage data - if (stream && body.messages && !body.stream_options) { - body.stream_options = { include_usage: true }; + if (stream && record.messages && !record.stream_options) { + record.stream_options = { include_usage: true }; } - return body; + return record; } } diff --git a/open-sse/executors/index.js b/open-sse/executors/index.ts similarity index 87% rename from open-sse/executors/index.js rename to open-sse/executors/index.ts index 14f09e45..029bb2f8 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.ts @@ -1,5 +1,6 @@ import { AntigravityExecutor } from "./antigravity.js"; import { AzureExecutor } from "./azure.js"; +import { BaseExecutor } from "./base.js"; import { CodexExecutor } from "./codex.js"; import { CommandCodeExecutor } from "./commandcode.js"; import { CursorExecutor } from "./cursor.js"; @@ -17,7 +18,7 @@ import { QoderExecutor } from "./qoder.js"; import { QwenExecutor } from "./qwen.js"; import { VertexExecutor } from "./vertex.js"; -const executors = { +const executors: Record = { antigravity: new AntigravityExecutor(), azure: new AzureExecutor(), "gemini-cli": new GeminiCLIExecutor(), @@ -39,15 +40,15 @@ const executors = { commandcode: new CommandCodeExecutor(), }; -const defaultCache = new Map(); +const defaultCache = new Map(); -export function getExecutor(provider) { - if (executors[provider]) return executors[provider]; +export function getExecutor(provider: string): BaseExecutor { + if (executors[provider]) return executors[provider]!; if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider)); - return defaultCache.get(provider); + return defaultCache.get(provider)!; } -export function hasSpecializedExecutor(provider) { +export function hasSpecializedExecutor(provider: string): boolean { return !!executors[provider]; } diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.ts similarity index 78% rename from open-sse/executors/kiro.js rename to open-sse/executors/kiro.ts index 8df4d28a..56317ae5 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.ts @@ -1,10 +1,66 @@ +// @ts-nocheck import { v4 as uuidv4 } from "uuid"; import { isTransientErrorBody } from "../config/errorConfig.js"; import { PROVIDERS } from "../config/providers.js"; import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorHeaders, + type ExecutorLogger, + type ExecutorProxyOptions, + type RetryEntry, +} from "./base.js"; + +type JsonRecord = Record; +type UsagePayload = { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; +}; +type KiroStreamState = { + contextUsagePercentage: number; + endDetected: boolean; + finishEmitted: boolean; + hasContextUsage: boolean; + hasMeteringEvent: boolean; + hasToolCalls: boolean; + messageStopEvent: boolean; + seenToolIds: Map; + toolCallIndex: number; + totalContentLength: number; + usage?: UsagePayload; +}; +type EventFrame = { + headers: Record; + payload: JsonRecord | JsonRecord[] | null; +}; +type FinishChunk = { + choices: Array<{ + delta: JsonRecord; + finish_reason: string; + index: number; + }>; + created: number; + id: string; + model: string; + object: string; + usage?: UsagePayload; +}; +type KiroTransformer = Transformer & { + cancel(reason: unknown): void; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -15,8 +71,8 @@ export class KiroExecutor extends BaseExecutor { super("kiro", PROVIDERS.kiro); } - buildHeaders(credentials, stream = true) { - const headers = { + buildHeaders(credentials: ExecutorCredentials, _stream: boolean = true) { + const headers: ExecutorHeaders = { ...this.config.headers, "Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Invocation-Id": uuidv4(), @@ -29,7 +85,12 @@ export class KiroExecutor extends BaseExecutor { return headers; } - transformRequest(model, body, stream, credentials) { + transformRequest( + _model: string, + body: unknown, + _stream: boolean, + _credentials: ExecutorCredentials, + ) { return body; } @@ -47,12 +108,23 @@ export class KiroExecutor extends BaseExecutor { * Delay uses exponential backoff with jitter: base * 2^attempt * (0.5..1.5) * to avoid synchronized retries hammering an already-degraded upstream. */ - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + proxyOptions = null, + }: ExecutorExecuteOptions) { const url = this.buildUrl(model, stream, 0); const transformedBody = this.transformRequest(model, body, stream, credentials); // Merge default retry config with provider-specific config - const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + const retryConfig: Record = { + ...DEFAULT_RETRY_CONFIG, + ...this.config.retry, + }; let retryAttempts = 0; let transientAttempts = 0; @@ -67,8 +139,8 @@ export class KiroExecutor extends BaseExecutor { }; // Abort-aware sleep helper - const sleep = (ms, signal) => - new Promise((resolve, reject) => { + const sleep = (ms: number, signal: AbortSignal | undefined) => + new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); if (signal) { const onAbort = () => { @@ -80,7 +152,7 @@ export class KiroExecutor extends BaseExecutor { }); // Calculate jittered delay: exponential backoff with 50%–150% jitter - const jitteredDelay = (baseMs, attempt) => { + const jitteredDelay = (baseMs: number, attempt: number) => { const exponential = baseMs * 2 ** attempt; const capped = Math.min(exponential, transientRetry.maxDelayMs || 8000); return Math.round(capped * (0.5 + Math.random())); @@ -101,7 +173,9 @@ export class KiroExecutor extends BaseExecutor { ); // Check if should retry based on status code (existing path) - const { attempts: maxRetries, delayMs } = resolveRetryEntry(retryConfig[response.status]); + const { attempts: maxRetries, delayMs } = resolveRetryEntry( + retryConfig[String(response.status)], + ); if (!response.ok && maxRetries > 0 && retryAttempts < maxRetries) { retryAttempts++; log?.debug?.( @@ -161,17 +235,17 @@ export class KiroExecutor extends BaseExecutor { * Transform AWS EventStream binary response to SSE text stream * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout */ - transformEventStreamToSSE(response, model) { + transformEventStreamToSSE(response: Response, model: string) { let buffer = new Uint8Array(0); let chunkIndex = 0; const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); - const state = { + const state: KiroStreamState = { endDetected: false, finishEmitted: false, hasToolCalls: false, toolCallIndex: 0, - seenToolIds: new Map(), + seenToolIds: new Map(), messageStopEvent: false, hasMeteringEvent: false, hasContextUsage: false, @@ -187,10 +261,13 @@ export class KiroExecutor extends BaseExecutor { }); } - let upstreamReader = null; + let upstreamReader: ReadableStreamDefaultReader | null = null; // Event parsing logic - called from start() for each chunk - const processChunk = async (chunk, controller) => { + const processChunk = async ( + chunk: Uint8Array, + controller: TransformStreamDefaultController, + ) => { // Append to buffer const newBuffer = new Uint8Array(buffer.length + chunk.length); newBuffer.set(buffer); @@ -219,9 +296,11 @@ export class KiroExecutor extends BaseExecutor { if (!state.totalContentLength) state.totalContentLength = 0; if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; + const payloadRecord = event.payload && !Array.isArray(event.payload) ? event.payload : null; + // Handle assistantResponseEvent - if (eventType === "assistantResponseEvent" && event.payload?.content) { - const content = event.payload.content; + if (eventType === "assistantResponseEvent" && payloadRecord?.content) { + const content = String(payloadRecord.content); state.totalContentLength += content.length; const chunk = { @@ -242,7 +321,8 @@ export class KiroExecutor extends BaseExecutor { } // Handle codeEvent - if (eventType === "codeEvent" && event.payload?.content) { + if (eventType === "codeEvent" && payloadRecord?.content) { + const content = String(payloadRecord.content); const chunk = { id: responseId, object: "chat.completion.chunk", @@ -251,7 +331,7 @@ export class KiroExecutor extends BaseExecutor { choices: [ { index: 0, - delta: { content: event.payload.content }, + delta: { content }, finish_reason: null, }, ], @@ -267,11 +347,15 @@ export class KiroExecutor extends BaseExecutor { const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; for (const singleToolUse of toolUses) { - const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; - const toolName = singleToolUse.name || ""; - const toolInput = singleToolUse.input; - - let toolIndex; + const toolUseRecord = singleToolUse as JsonRecord; + const toolCallId = + typeof toolUseRecord.toolUseId === "string" + ? toolUseRecord.toolUseId + : `call_${Date.now()}`; + const toolName = typeof toolUseRecord.name === "string" ? toolUseRecord.name : ""; + const toolInput = toolUseRecord.input; + + let toolIndex: number; const isNewTool = !state.seenToolIds.has(toolCallId); if (isNewTool) { @@ -309,7 +393,7 @@ export class KiroExecutor extends BaseExecutor { new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`), ); } else { - toolIndex = state.seenToolIds.get(toolCallId); + toolIndex = state.seenToolIds.get(toolCallId) ?? state.toolCallIndex++; } if (toolInput !== undefined) { @@ -376,8 +460,8 @@ export class KiroExecutor extends BaseExecutor { } // Handle contextUsageEvent to extract contextUsagePercentage - if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) { - state.contextUsagePercentage = event.payload.contextUsagePercentage; + if (eventType === "contextUsageEvent" && payloadRecord?.contextUsagePercentage) { + state.contextUsagePercentage = Number(payloadRecord.contextUsagePercentage); // Mark that we received context usage event state.hasContextUsage = true; } @@ -390,10 +474,11 @@ export class KiroExecutor extends BaseExecutor { // Handle metricsEvent for token usage if (eventType === "metricsEvent") { // Extract usage data from metricsEvent payload - const metrics = event.payload?.metricsEvent || event.payload; + const metrics = payloadRecord?.metricsEvent || payloadRecord; if (metrics && typeof metrics === "object") { - const inputTokens = metrics.inputTokens || 0; - const outputTokens = metrics.outputTokens || 0; + const metricsRecord = metrics as JsonRecord; + const inputTokens = Number(metricsRecord.inputTokens || 0); + const outputTokens = Number(metricsRecord.outputTokens || 0); if (inputTokens > 0 || outputTokens > 0) { state.usage = { @@ -434,7 +519,7 @@ export class KiroExecutor extends BaseExecutor { }; } - const finishChunk = { + const finishChunk: FinishChunk = { id: responseId, object: "chat.completion.chunk", created, @@ -462,9 +547,10 @@ export class KiroExecutor extends BaseExecutor { } }; - const transformStream = new TransformStream({ + const responseBody = response.body; + const transformer: KiroTransformer = { start(controller) { - upstreamReader = response.body.getReader(); + upstreamReader = responseBody.getReader(); (async () => { try { while (true) { @@ -472,8 +558,8 @@ export class KiroExecutor extends BaseExecutor { if (done) break; await processChunk(value, controller); } - } catch (err) { - if (err.name !== "AbortError") { + } catch (err: unknown) { + if (!isAbortError(err)) { controller.error(err); } } @@ -517,7 +603,9 @@ export class KiroExecutor extends BaseExecutor { // upstream reader already cancelled } }, - }); + }; + + const transformStream = new TransformStream(transformer); return new Response(transformStream.readable, { status: response.status, @@ -530,7 +618,11 @@ export class KiroExecutor extends BaseExecutor { }); } - async refreshCredentials(credentials, log, proxyOptions = null) { + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + proxyOptions: ExecutorProxyOptions = null, + ) { if (!credentials.refreshToken) return null; try { @@ -543,8 +635,8 @@ export class KiroExecutor extends BaseExecutor { ); return result; - } catch (error) { - log?.error?.("TOKEN", `Kiro refresh error: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN", `Kiro refresh error: ${errorMessage(error)}`); return null; } } @@ -553,18 +645,18 @@ export class KiroExecutor extends BaseExecutor { /** * Parse AWS EventStream frame */ -function parseEventFrame(data) { +function parseEventFrame(data: Uint8Array): EventFrame | null { try { const view = new DataView(data.buffer, data.byteOffset); const headersLength = view.getUint32(4, false); // Parse headers - const headers = {}; + const headers: Record = {}; let offset = 12; // After prelude const headerEnd = 12 + headersLength; while (offset < headerEnd && offset < data.length) { - const nameLen = data[offset]; + const nameLen = data[offset] ?? 0; offset++; if (offset + nameLen > data.length) break; @@ -576,7 +668,7 @@ function parseEventFrame(data) { if (headerType === 7) { // String type - const valueLen = (data[offset] << 8) | data[offset + 1]; + const valueLen = ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0); offset += 2; if (offset + valueLen > data.length) break; @@ -592,7 +684,7 @@ function parseEventFrame(data) { const payloadStart = 12 + headersLength; const payloadEnd = data.length - 4; // Exclude message CRC - let payload = null; + let payload: JsonRecord | JsonRecord[] | null = null; if (payloadEnd > payloadStart) { const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd)); @@ -602,11 +694,11 @@ function parseEventFrame(data) { } try { - payload = JSON.parse(payloadStr); - } catch (parseError) { + payload = JSON.parse(payloadStr) as JsonRecord | JsonRecord[]; + } catch (parseError: unknown) { // Log parse error for debugging console.warn( - `[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`, + `[Kiro] Failed to parse payload: ${errorMessage(parseError)} | payload: ${payloadStr.substring(0, 100)}`, ); payload = { raw: payloadStr }; } @@ -614,6 +706,7 @@ function parseEventFrame(data) { return { headers, payload }; } catch { + // Invalid EventStream frames are treated as absent payloads by caller. return null; } } diff --git a/open-sse/executors/ollama-local.js b/open-sse/executors/ollama-local.ts similarity index 62% rename from open-sse/executors/ollama-local.js rename to open-sse/executors/ollama-local.ts index 49b44a18..df9d457f 100644 --- a/open-sse/executors/ollama-local.js +++ b/open-sse/executors/ollama-local.ts @@ -1,4 +1,5 @@ import { resolveOllamaLocalHost } from "../config/providers.js"; +import type { ExecutorCredentials } from "./base.js"; import { DefaultExecutor } from "./default.js"; export class OllamaLocalExecutor extends DefaultExecutor { @@ -6,7 +7,12 @@ export class OllamaLocalExecutor extends DefaultExecutor { super("ollama-local"); } - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + _model: string, + _stream: boolean, + _urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ) { return `${resolveOllamaLocalHost(credentials)}/api/chat`; } } diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.ts similarity index 56% rename from open-sse/executors/opencode-go.js rename to open-sse/executors/opencode-go.ts index 32ae57d7..5d117719 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.ts @@ -1,6 +1,11 @@ import { PROVIDERS } from "../config/providers.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorConfigInput, + type ExecutorCredentials, + type ExecutorHeaders, +} from "./base.js"; // Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) const CLAUDE_FORMAT_MODELS = new Set(["minimax-m2.5", "minimax-m2.7"]); @@ -8,22 +13,29 @@ const CLAUDE_FORMAT_MODELS = new Set(["minimax-m2.5", "minimax-m2.7"]); const BASE = "https://opencode.ai/zen/go/v1"; export class OpenCodeGoExecutor extends BaseExecutor { + private _lastModel: string | null = null; + constructor() { - super("opencode-go", PROVIDERS["opencode-go"]); + super("opencode-go", (PROVIDERS as Record)["opencode-go"]!); } // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here - buildUrl(model) { + buildUrl( + model: string, + _stream?: boolean, + _urlIndex?: number, + _credentials?: ExecutorCredentials | null, + ): string { this._lastModel = model; return CLAUDE_FORMAT_MODELS.has(model) ? `${BASE}/messages` : `${BASE}/chat/completions`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { const key = credentials?.apiKey || credentials?.accessToken; - const headers = { "Content-Type": "application/json" }; + const headers: ExecutorHeaders = { "Content-Type": "application/json" }; - if (CLAUDE_FORMAT_MODELS.has(this._lastModel)) { - headers["x-api-key"] = key; + if (this._lastModel && CLAUDE_FORMAT_MODELS.has(this._lastModel)) { + headers["x-api-key"] = key as string; headers["anthropic-version"] = "2023-06-01"; } else { headers["Authorization"] = `Bearer ${key}`; @@ -33,7 +45,7 @@ export class OpenCodeGoExecutor extends BaseExecutor { return headers; } - transformRequest(model, body) { + transformRequest(model: string, body: unknown): unknown { return injectReasoningContent({ provider: this.provider, model, body }); } } diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.ts similarity index 96% rename from open-sse/executors/opencode.js rename to open-sse/executors/opencode.ts index af59ff46..32005f8a 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.ts @@ -9,7 +9,7 @@ export class OpenCodeExecutor extends BaseExecutor { super("opencode", PROVIDERS.opencode); } - buildUrl(model) { + buildUrl(model: string) { const base = "https://opencode.ai"; return MESSAGES_MODELS.has(model) ? `${base}/zen/v1/messages` diff --git a/open-sse/executors/perplexity-web.js b/open-sse/executors/perplexity-web.ts similarity index 77% rename from open-sse/executors/perplexity-web.js rename to open-sse/executors/perplexity-web.ts index fa54c703..7962a457 100644 --- a/open-sse/executors/perplexity-web.js +++ b/open-sse/executors/perplexity-web.ts @@ -1,12 +1,73 @@ import { PROVIDERS } from "../config/providers.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, +} from "./base.js"; const PPLX_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl; const PPLX_API_VERSION = "2.18"; const PPLX_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; -const MODEL_MAP = { +type PplxModelMap = Record; +type JsonRecord = Record; +type HistoryItem = { role: "user" | "assistant"; content: string }; +type OpenAIContentPart = { type?: string; text?: unknown }; +type OpenAIMessage = { role?: string; content?: string | OpenAIContentPart[] | unknown }; +type ParsedMessages = { systemMsg: string; history: HistoryItem[]; currentMsg: string }; +type SessionEntry = { backendUuid: string; ts: number }; +type PplxPlanStep = { + read_results_content?: { urls?: string[] }; + search_web_content?: { queries?: { query?: string }[] }; + step_type?: string; +}; +type PplxBlock = { + intended_usage?: string; + markdown_block?: { chunks?: string[]; progress?: string }; + plan_block?: { goals?: { description?: string }[]; steps?: PplxPlanStep[] }; +}; +type PplxEvent = JsonRecord & { + backend_uuid?: string; + blocks?: PplxBlock[]; + error_code?: unknown; + error_message?: unknown; + final?: boolean; + status?: string; + text?: string; +}; +type PplxContentChunk = { + answer?: string; + backendUuid?: string; + delta?: string; + done?: boolean; + error?: unknown; + thinking?: string; +}; +type PplxStreamOptions = { skipReasoning?: boolean }; +type AssistantMessage = { role: "assistant"; content: string; reasoning_content?: string }; +type PplxBody = JsonRecord & { + messages?: OpenAIMessage[]; + reasoning_effort?: unknown; + thinking?: unknown; + tools?: unknown; +}; +type PplxClientHeaders = Headers | Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asPplxBody(value: unknown): PplxBody { + return isRecord(value) ? value : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +const MODEL_MAP: PplxModelMap = { "pplx-auto": ["concise", "pplx_pro"], "pplx-sonar": ["copilot", "experimental"], "pplx-gpt": ["copilot", "gpt54"], @@ -16,7 +77,7 @@ const MODEL_MAP = { "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], }; -const THINKING_MAP = { +const THINKING_MAP: Record = { "pplx-gpt": "gpt54_thinking", "pplx-sonnet": "claude46sonnetthinking", "pplx-opus": "claude46opusthinking", @@ -34,7 +95,7 @@ const SESSION_MAX_AGE_MS = 30 * 60 * 1000; const SESSION_MAX_ENTRIES = 200; const SESSION_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; -const sessionCache = new Map(); +const sessionCache = new Map(); const _cleanupInterval = setInterval(() => { const now = Date.now(); @@ -47,7 +108,7 @@ const _cleanupInterval = setInterval(() => { if (_cleanupInterval.unref) _cleanupInterval.unref(); // FNV-1a hash for session key lookup -function sessionKey(history) { +function sessionKey(history: readonly HistoryItem[]) { const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); let hash = 0x811c9dc5; for (let i = 0; i < parts.length; i++) { @@ -57,7 +118,7 @@ function sessionKey(history) { return hash.toString(16).padStart(8, "0"); } -function sessionLookup(history) { +function sessionLookup(history: readonly HistoryItem[]) { if (history.length === 0) return null; const key = sessionKey(history); const entry = sessionCache.get(key); @@ -69,9 +130,14 @@ function sessionLookup(history) { return entry.backendUuid; } -function sessionStore(history, currentMsg, responseText, backendUuid) { +function sessionStore( + history: readonly HistoryItem[], + currentMsg: string, + responseText: string, + backendUuid: string | null | undefined, +) { if (!backendUuid) return; - const full = [ + const full: HistoryItem[] = [ ...history, { role: "user", content: currentMsg }, { role: "assistant", content: responseText }, @@ -79,12 +145,12 @@ function sessionStore(history, currentMsg, responseText, backendUuid) { const key = sessionKey(full); if (sessionCache.size >= SESSION_MAX_ENTRIES) { const firstKey = sessionCache.keys().next().value; - sessionCache.delete(firstKey); + if (firstKey !== undefined) sessionCache.delete(firstKey); } sessionCache.set(key, { backendUuid, ts: Date.now() }); } -function cleanResponse(text, strip = true) { +function cleanResponse(text: string, strip = true) { let t = text; t = t.replace(XML_DECL_RE, ""); t = t.replace(CITATION_RE, ""); @@ -99,20 +165,24 @@ function cleanResponse(text, strip = true) { return t; } -async function* readPplxSseEvents(body, signal) { +async function* readPplxSseEvents( + body: ReadableStream, + signal?: AbortSignal, +): AsyncGenerator { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - let dataLines = []; + let dataLines: string[] = []; - function flush() { + function flush(): PplxEvent | "done" | null { if (dataLines.length === 0) return null; const payload = dataLines.join("\n"); dataLines = []; const trimmed = payload.trim(); if (!trimmed || trimmed === "[DONE]") return "done"; try { - return JSON.parse(trimmed); + const parsed = JSON.parse(trimmed) as unknown; + return isRecord(parsed) ? (parsed as PplxEvent) : null; } catch { return null; } @@ -149,9 +219,9 @@ async function* readPplxSseEvents(body, signal) { } } -function parseOpenAIMessages(messages) { +function parseOpenAIMessages(messages: readonly OpenAIMessage[]): ParsedMessages { let systemMsg = ""; - const history = []; + const history: HistoryItem[] = []; for (const msg of messages) { let role = String(msg.role || "user"); if (role === "developer") role = "system"; @@ -168,13 +238,18 @@ function parseOpenAIMessages(messages) { else if (role === "user" || role === "assistant") history.push({ role, content }); } let currentMsg = ""; - if (history.length > 0 && history[history.length - 1].role === "user") { - currentMsg = history.pop().content; + if (history.at(-1)?.role === "user") { + currentMsg = history.pop()?.content ?? ""; } return { systemMsg, history, currentMsg }; } -function buildPplxRequestBody(query, mode, modelPref, followUpUuid) { +function buildPplxRequestBody( + query: string, + mode: string, + modelPref: string, + followUpUuid: string | null, +) { const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; return { query_str: query, @@ -198,21 +273,23 @@ function buildPplxRequestBody(query, mode, modelPref, followUpUuid) { }; } -function formatToolsHint(tools) { +function formatToolsHint(tools: unknown) { if (!Array.isArray(tools) || tools.length === 0) return ""; - const lines = tools.map((t) => { - const fn = t?.function || t || {}; - const name = fn.name || "unnamed"; - const desc = (fn.description || "").split("\n")[0].slice(0, 200); + const lines = tools.map((tool) => { + const record = isRecord(tool) ? tool : {}; + const fn = isRecord(record.function) ? record.function : record; + const name = typeof fn.name === "string" && fn.name ? fn.name : "unnamed"; + const desc = + typeof fn.description === "string" ? (fn.description.split("\n")[0] ?? "").slice(0, 200) : ""; return `- ${name}: ${desc}`; }); return `Available tools (reference only, cannot invoke):\n${lines.join("\n")}`; } -function buildQuery(parsed, followUpUuid, tools) { +function buildQuery(parsed: ParsedMessages, followUpUuid: string | null, tools: unknown) { if (followUpUuid) return parsed.currentMsg; - const obj = {}; - const instr = []; + const obj: JsonRecord = {}; + const instr: string[] = []; if (parsed.systemMsg.trim()) instr.push(parsed.systemMsg.trim()); const toolsHint = formatToolsHint(tools); if (toolsHint) instr.push(toolsHint); @@ -225,11 +302,14 @@ function buildQuery(parsed, followUpUuid, tools) { return json.length > 96000 ? json.slice(-96000) : json; } -async function* extractContent(eventStream, signal) { +async function* extractContent( + eventStream: ReadableStream, + signal?: AbortSignal, +): AsyncGenerator { let fullAnswer = ""; - let backendUuid = null; + let backendUuid: string | null = null; let seenLen = 0; - const seenThinking = new Set(); + const seenThinking = new Set(); for await (const event of readPplxSseEvents(eventStream, signal)) { if (event.error_code || event.error_message) { @@ -238,7 +318,7 @@ async function* extractContent(eventStream, signal) { } if (event.backend_uuid) backendUuid = event.backend_uuid; - const blocks = event.blocks ?? []; + const blocks = (event.blocks ?? []) as PplxBlock[]; for (const block of blocks) { const usage = block.intended_usage ?? ""; @@ -308,23 +388,23 @@ async function* extractContent(eventStream, signal) { yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; } -function sseChunk(data) { +function sseChunk(data: unknown) { return `data: ${JSON.stringify(data)}\n\n`; } function buildStreamingResponse( - eventStream, - model, - cid, - created, - history, - currentMsg, - signal, - opts = {}, + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: readonly HistoryItem[], + currentMsg: string, + signal?: AbortSignal, + opts: PplxStreamOptions = {}, ) { const skipReasoning = opts.skipReasoning === true; const encoder = new TextEncoder(); - return new ReadableStream({ + return new ReadableStream({ async start(controller) { try { controller.enqueue( @@ -434,7 +514,7 @@ function buildStreamingResponse( controller.enqueue(encoder.encode("data: [DONE]\n\n")); sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); - } catch (err) { + } catch (err: unknown) { controller.enqueue( encoder.encode( sseChunk({ @@ -446,7 +526,7 @@ function buildStreamingResponse( choices: [ { index: 0, - delta: { content: `[Stream error: ${err.message || String(err)}]` }, + delta: { content: `[Stream error: ${errorMessage(err)}]` }, finish_reason: "stop", logprobs: null, }, @@ -463,19 +543,19 @@ function buildStreamingResponse( } async function buildNonStreamingResponse( - eventStream, - model, - cid, - created, - history, - currentMsg, - signal, - opts = {}, + eventStream: ReadableStream, + model: string, + cid: string, + created: number, + history: readonly HistoryItem[], + currentMsg: string, + signal?: AbortSignal, + opts: PplxStreamOptions = {}, ) { const skipReasoning = opts.skipReasoning === true; let fullAnswer = ""; - let respBackendUuid = null; - const thinkingParts = []; + let respBackendUuid: string | null = null; + const thinkingParts: string[] = []; for await (const chunk of extractContent(eventStream, signal)) { if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; @@ -502,7 +582,7 @@ async function buildNonStreamingResponse( sessionStore(history, currentMsg, fullAnswer, respBackendUuid); const reasoningContent = thinkingParts.length > 0 ? thinkingParts.join("\n") : undefined; - const msg = { role: "assistant", content: fullAnswer }; + const msg: AssistantMessage = { role: "assistant", content: fullAnswer }; if (reasoningContent) msg.reasoning_content = reasoningContent; const promptTokens = Math.ceil(currentMsg.length / 4); @@ -531,8 +611,19 @@ export class PerplexityWebExecutor extends BaseExecutor { super("perplexity-web", PROVIDERS["perplexity-web"]); } - async execute({ model, body, stream, credentials, signal, log, clientHeaders = null }) { - const messages = body?.messages; + async execute({ + model, + body, + stream, + credentials, + signal, + log, + clientHeaders = null, + }: ExecutorExecuteOptions & { + clientHeaders?: PplxClientHeaders | null; + }): Promise { + const requestBody = asPplxBody(body); + const messages = requestBody.messages; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errResp = new Response( JSON.stringify({ @@ -544,17 +635,21 @@ export class PerplexityWebExecutor extends BaseExecutor { } const thinking = - body?.thinking === true || - (body?.reasoning_effort != null && body.reasoning_effort !== "none"); + requestBody.thinking === true || + (requestBody.reasoning_effort !== null && + requestBody.reasoning_effort !== undefined && + requestBody.reasoning_effort !== "none"); let pplxMode; let modelPref; - if (thinking && THINKING_MAP[model]) { + const thinkingModel = THINKING_MAP[model]; + const mappedModel = MODEL_MAP[model]; + if (thinking && thinkingModel) { pplxMode = "copilot"; - modelPref = THINKING_MAP[model]; + modelPref = thinkingModel; log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`); - } else if (MODEL_MAP[model]) { - [pplxMode, modelPref] = MODEL_MAP[model]; + } else if (mappedModel) { + [pplxMode, modelPref] = mappedModel; } else { pplxMode = "copilot"; modelPref = model; @@ -565,7 +660,7 @@ export class PerplexityWebExecutor extends BaseExecutor { const followUpUuid = sessionLookup(parsed.history); if (followUpUuid) log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); - const query = buildQuery(parsed, followUpUuid, body?.tools); + const query = buildQuery(parsed, followUpUuid, requestBody.tools); if (!query.trim()) { const errResp = new Response( JSON.stringify({ @@ -578,7 +673,7 @@ export class PerplexityWebExecutor extends BaseExecutor { const pplxBody = buildPplxRequestBody(query, pplxMode, modelPref, followUpUuid); - const headers = { + const headers: ExecutorHeaders = { "Content-Type": "application/json", Accept: "text/event-stream", Origin: "https://www.perplexity.ai", @@ -599,18 +694,18 @@ export class PerplexityWebExecutor extends BaseExecutor { `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}`, ); - const fetchOptions = { method: "POST", headers, body: JSON.stringify(pplxBody) }; + const fetchOptions: RequestInit = { method: "POST", headers, body: JSON.stringify(pplxBody) }; if (signal) fetchOptions.signal = signal; let response; try { response = await fetch(PPLX_SSE_ENDPOINT, fetchOptions); - } catch (err) { - log?.error?.("PPLX-WEB", `Fetch failed: ${err.message || String(err)}`); + } catch (err: unknown) { + log?.error?.("PPLX-WEB", `Fetch failed: ${errorMessage(err)}`); const errResp = new Response( JSON.stringify({ error: { - message: `Perplexity connection failed: ${err.message || String(err)}`, + message: `Perplexity connection failed: ${errorMessage(err)}`, type: "upstream_error", }, }), @@ -654,8 +749,8 @@ export class PerplexityWebExecutor extends BaseExecutor { // Improves perceived TTFT for clients that don't render reasoning_content. const skipReasoning = (() => { if (!clientHeaders) return false; - const get = (name) => { - if (typeof clientHeaders.get === "function") return clientHeaders.get(name); + const get = (name: string) => { + if (clientHeaders instanceof Headers) return clientHeaders.get(name); const lo = String(name).toLowerCase(); for (const [k, v] of Object.entries(clientHeaders)) { if (String(k).toLowerCase() === lo) return typeof v === "string" ? v : null; diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.ts similarity index 77% rename from open-sse/executors/qoder.js rename to open-sse/executors/qoder.ts index 21a2e0d0..5d80698f 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.ts @@ -26,45 +26,91 @@ import { QODER_CHAT_URL_ENCODED, QODER_MODEL_MAP } from "@/lib/qoder/constants"; import { buildCosyHeaders } from "@/lib/qoder/cosy"; import { qoderEncodeBody } from "@/lib/qoder/encoding"; import { PROVIDERS } from "../config/providers.js"; -import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; +import { + getQoderModelConfig, + resolveQoderModels, + type QoderCredentials, +} from "../services/qoderModels.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorLogger, + type ExecutorProxyOptions, +} from "./base.js"; + +type JsonRecord = Record; + +type ChatMessage = JsonRecord & { + role?: string; + content?: unknown; +}; + +type QoderEnvelope = { + statusCodeValue?: number; + body?: string; +}; + +type BuildQoderRequestArgs = { + model: string; + body: JsonRecord; + credentials: QoderCredentials; + log?: ExecutorLogger; + proxyOptions?: ExecutorProxyOptions; + signal?: AbortSignal; +}; + +/** TransformStream transformer that also cancels the upstream body. */ +type QoderSseTransformer = Transformer & { + cancel(reason?: unknown): void; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} /** * Hoist role:"system" messages out of the messages array (Qoder rejects * system in messages) and flatten any multipart content arrays. */ -function normalizeMessages(messages) { +function normalizeMessages(messages: unknown): { messages: ChatMessage[]; systemText: string } { if (!Array.isArray(messages) || messages.length === 0) { return { messages: [], systemText: "" }; } - const systemParts = []; - const out = []; - for (const msg of messages) { - if (!msg || typeof msg !== "object") continue; + const systemParts: string[] = []; + const out: ChatMessage[] = []; + for (const msgUnknown of messages) { + if (!msgUnknown || typeof msgUnknown !== "object") continue; + const msg = msgUnknown as ChatMessage; const text = extractText(msg.content); if (msg.role === "system") { if (text) systemParts.push(text); continue; } - const cloned = { ...msg }; + const cloned: ChatMessage = { ...msg }; cloned.content = text; out.push(cloned); } return { messages: out, systemText: systemParts.join("\n\n") }; } -function extractText(content) { +function extractText(content: unknown): string { if (typeof content === "string") return content; - if (content == null) return ""; + if (content === null || content === undefined) return ""; if (Array.isArray(content)) { - const parts = []; + const parts: string[] = []; for (const item of content) { if (item && typeof item === "object") { - if (item.type === "text" && typeof item.text === "string") { - parts.push(item.text); - } else if (typeof item.text === "string") { - parts.push(item.text); + const rec = item as { type?: unknown; text?: unknown }; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text); + } else if (typeof rec.text === "string") { + parts.push(rec.text); } } } @@ -73,7 +119,7 @@ function extractText(content) { return String(content); } -function lastUserText(messages) { +function lastUserText(messages: ChatMessage[]): string { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m?.role === "user" && typeof m.content === "string") { @@ -83,7 +129,7 @@ function lastUserText(messages) { return ""; } -function stableHash(prefix, ...parts) { +function stableHash(prefix: string, ...parts: unknown[]): string { const h = createHash("sha256"); h.update(prefix); for (const p of parts) { @@ -93,7 +139,12 @@ function stableHash(prefix, ...parts) { return h.digest("hex").slice(0, 16); } -function stableChatRecordId(model, messages, tools, maxTokens) { +function stableChatRecordId( + model: string, + messages: ChatMessage[], + tools: unknown, + maxTokens: number, +): string { const h = createHash("sha256"); h.update("qoder-record\0"); h.update(String(model)); @@ -112,20 +163,29 @@ function stableChatRecordId(model, messages, tools, maxTokens) { h.update("\0"); try { h.update(JSON.stringify(tools)); - } catch {} + } catch { + // Tool hashing is best effort; maxTokens still participates in the key. + } } h.update(`\0mt=${maxTokens}`); return h.digest("hex").slice(0, 16); } -function truncate(s, n) { +function truncate(s: string, n: number): string { return s && s.length > n ? `${s.slice(0, n)}...` : s || ""; } /** * Map the OpenAI-style request body into the exact shape Qoder expects. */ -async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) { +async function buildQoderRequestBody({ + model, + body, + credentials, + log, + proxyOptions, + signal, +}: BuildQoderRequestArgs) { const qoderKey = String(model || "").replace(/^qoder\//, ""); if (!QODER_MODEL_MAP[qoderKey]) { throw new Error(`Unsupported qoder model: "${qoderKey}" (received "${model}")`); @@ -233,7 +293,7 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus * a synthetic OpenAI error chunk. */ -function wrapQoderSSE(response, model) { +function wrapQoderSSE(response: Response, model: string): Response { if (!response.ok || !response.body) return response; const decoder = new TextDecoder(); @@ -244,7 +304,10 @@ function wrapQoderSSE(response, model) { // Process one already-extracted SSE line (no trailing newline). Returns // false when the line indicated end-of-stream so the caller can stop // forwarding any remaining chunks after [DONE]. - const processLine = (line, controller) => { + const processLine = ( + line: string, + controller: TransformStreamDefaultController, + ): void => { const trimmed = line.replace(/\r$/, "").trim(); if (!trimmed) return; if (!trimmed.startsWith("data:")) return; @@ -257,10 +320,11 @@ function wrapQoderSSE(response, model) { return; } - let envelope; + let envelope: QoderEnvelope; try { - envelope = JSON.parse(data); + envelope = JSON.parse(data) as QoderEnvelope; } catch { + // Malformed Qoder envelope lines are ignored; later frames may still be valid. return; } const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; @@ -299,7 +363,7 @@ function wrapQoderSSE(response, model) { controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); }; - const transform = new TransformStream({ + const transformer: QoderSseTransformer = { async transform(chunk, controller) { try { buffer += decoder.decode(chunk, { stream: true }); @@ -308,11 +372,11 @@ function wrapQoderSSE(response, model) { buffer = buffer.slice(nl + 1); try { processLine(line, controller); - } catch (lineErr) { - console.warn("[qoder] processLine error:", lineErr.message); + } catch (lineErr: unknown) { + console.warn("[qoder] processLine error:", errorMessage(lineErr)); } } - } catch (err) { + } catch (err: unknown) { controller.error(err); } }, @@ -342,7 +406,9 @@ function wrapQoderSSE(response, model) { doneEmitted = true; } }, - }); + }; + + const transform = new TransformStream(transformer); const transformed = response.body.pipeThrough(transform); // Build a Response with passable headers; the streaming handler reads @@ -371,10 +437,19 @@ export class QoderExecutor extends BaseExecutor { // - body encoded with QoderEncodeBody before signing // - COSY headers built from the *encoded* body bytes // - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ + model, + body, + stream: _stream, + credentials, + signal, + log, + proxyOptions = null, + }: ExecutorExecuteOptions): Promise { const url = this.buildUrl(); + const qoderCredentials = credentials as QoderCredentials; - const psd = credentials?.providerSpecificData || {}; + const psd = qoderCredentials?.providerSpecificData || {}; if (!psd.userId) { // No user id → no way to sign. Surface a 401 so the dashboard nudges // the user back to OAuth. @@ -386,7 +461,7 @@ export class QoderExecutor extends BaseExecutor { ); return { response: fakeResp, url, headers: {}, transformedBody: body }; } - if (!credentials?.accessToken) { + if (!qoderCredentials?.accessToken) { // Same shape as the userId guard — clean 401 so chatCore reports // "reconnect" rather than bubbling cosy.js's synchronous throw as 500. const fakeResp = new Response( @@ -398,19 +473,19 @@ export class QoderExecutor extends BaseExecutor { return { response: fakeResp, url, headers: {}, transformedBody: body }; } - let qoderKey; - let payload; + let qoderKey: string; + let payload: JsonRecord; try { ({ qoderKey, payload } = await buildQoderRequestBody({ model, - body, - credentials, + body: asRecord(body), + credentials: qoderCredentials, log, proxyOptions, signal, })); - } catch (err) { - const fakeResp = new Response(JSON.stringify({ error: { message: err.message } }), { + } catch (err: unknown) { + const fakeResp = new Response(JSON.stringify({ error: { message: errorMessage(err) } }), { status: 400, headers: { "Content-Type": "application/json" }, }); @@ -421,26 +496,27 @@ export class QoderExecutor extends BaseExecutor { const encodedBodyStr = qoderEncodeBody(plainBody); const encodedBodyBuf = Buffer.from(encodedBodyStr, "latin1"); - let cosyHeaders; + let cosyHeaders: Record; try { cosyHeaders = buildCosyHeaders(encodedBodyBuf, url, { - userId: psd.userId, - authToken: credentials.accessToken, - name: credentials.displayName || "", - email: credentials.email || "", - machineId: psd.machineId || "", + userId: String(psd.userId), + authToken: qoderCredentials.accessToken, + name: qoderCredentials.displayName || "", + email: qoderCredentials.email || "", + machineId: typeof psd.machineId === "string" ? psd.machineId : "", }); - } catch (err) { + } catch (err: unknown) { // cosy.js throws synchronously on missing userId/authToken — surface // as 401 so chatCore prompts re-auth instead of returning a 500. const fakeResp = new Response( - JSON.stringify({ error: { message: `qoder cosy signing failed: ${err.message}` } }), + JSON.stringify({ error: { message: `qoder cosy signing failed: ${errorMessage(err)}` } }), { status: 401, headers: { "Content-Type": "application/json" } }, ); return { response: fakeResp, url, headers: {}, transformedBody: body }; } - const modelSource = (payload.model_config && payload.model_config.source) || "system"; + const modelConfig = asRecord(payload.model_config); + const modelSource = typeof modelConfig.source === "string" ? modelConfig.source : "system"; const headers = { "Content-Type": "application/json", Accept: "text/event-stream", @@ -452,16 +528,11 @@ export class QoderExecutor extends BaseExecutor { ...cosyHeaders, }; - let response; - try { - response = await proxyAwareFetch( - url, - { method: "POST", headers, body: encodedBodyBuf, signal }, - proxyOptions, - ); - } catch (err) { - throw err; - } + const response = await proxyAwareFetch( + url, + { method: "POST", headers, body: encodedBodyBuf, signal }, + proxyOptions, + ); if (!response.ok) { // Pass error response through unchanged so chatCore can capture it. diff --git a/open-sse/executors/qwen.js b/open-sse/executors/qwen.ts similarity index 60% rename from open-sse/executors/qwen.js rename to open-sse/executors/qwen.ts index bab0453b..ecf73cd5 100644 --- a/open-sse/executors/qwen.js +++ b/open-sse/executors/qwen.ts @@ -1,5 +1,6 @@ import { OAUTH_ENDPOINTS } from "../config/appConstants.js"; import { PROVIDERS } from "../config/providers.js"; +import { type ExecutorCredentials, type ExecutorHeaders, type ExecutorLogger } from "./base.js"; import { DefaultExecutor } from "./default.js"; /** portal.qwen.ai — static fingerprint matching stable Qwen Code release */ @@ -18,9 +19,22 @@ const QWEN_DEFAULT_SYSTEM_MESSAGE = { content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }], }; -function ensureQwenSystemMessage(body) { +type JsonRecord = Record; + +type QwenTokenPayload = { + access_token?: string; + refresh_token?: string; + expires_in?: number; + resource_url?: string; +}; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function ensureQwenSystemMessage(body: unknown): unknown { if (!body || typeof body !== "object") return body; - const next = { ...body }; + const next: JsonRecord = { ...asRecord(body) }; if (Array.isArray(next.messages)) { next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages]; } else { @@ -29,29 +43,33 @@ function ensureQwenSystemMessage(body) { return next; } -function isQwenThinkingActive(body) { +function isQwenThinkingActive(body: JsonRecord | null | undefined): boolean { const thinking = body?.thinking; if (thinking === true || body?.enable_thinking === true) return true; return ( typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && - thinking.type === "enabled" + (thinking as JsonRecord).type === "enabled" ); } // Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto". -function sanitizeQwenThinkingToolChoice(body) { - if (!isQwenThinkingActive(body)) return body; - const tc = body.tool_choice; +function sanitizeQwenThinkingToolChoice(body: unknown): unknown { + const record = asRecord(body); + if (!body || typeof body !== "object" || !isQwenThinkingActive(record)) return body; + const tc = record.tool_choice; const incompatible = tc === "required" || (typeof tc === "object" && tc !== null); if (!incompatible) return body; - return { ...body, tool_choice: "auto" }; + return { ...record, tool_choice: "auto" }; } -function buildQwenUpstreamHeaders(credentials, stream = true) { +function buildQwenUpstreamHeaders( + credentials: ExecutorCredentials | null | undefined, + stream: boolean = true, +): ExecutorHeaders { const token = credentials?.apiKey || credentials?.accessToken || ""; - const headers = { + const headers: ExecutorHeaders = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "User-Agent": QWEN_USER_AGENT, @@ -80,36 +98,48 @@ export class QwenExecutor extends DefaultExecutor { // Qwen tokens are bound to a resource_url returned at OAuth time. // Using portal.qwen.ai when the token is issued for another shard returns 401/403. - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + _model: string, + _stream: boolean, + _urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ): string { const resourceUrl = credentials?.providerSpecificData?.resourceUrl; - const host = resourceUrl - ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") - : "portal.qwen.ai"; + const host = + typeof resourceUrl === "string" + ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") + : "portal.qwen.ai"; return `https://${host}/v1/chat/completions`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { return buildQwenUpstreamHeaders(credentials, stream); } - transformRequest(model, body, stream, credentials) { - let next = body && typeof body === "object" ? { ...body } : body; + transformRequest( + _model: string, + body: unknown, + stream?: boolean, + _credentials?: ExecutorCredentials, + ): unknown { + let next: unknown = body && typeof body === "object" ? { ...asRecord(body) } : body; + const nextRecord = asRecord(next); if ( stream && - next?.messages && - !next.stream_options && - !next.thinking && - !next.enable_thinking && - next.stream !== false + nextRecord.messages && + !nextRecord.stream_options && + !nextRecord.thinking && + !nextRecord.enable_thinking && + nextRecord.stream !== false ) { - next.stream_options = { include_usage: true }; + next = { ...nextRecord, stream_options: { include_usage: true } }; } next = sanitizeQwenThinkingToolChoice(next); return ensureQwenSystemMessage(next); } // Override to capture resource_url from refresh response (required for buildUrl). - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials: ExecutorCredentials, log: ExecutorLogger | null) { if (!credentials?.refreshToken) return null; try { const response = await fetch(OAUTH_ENDPOINTS.qwen.token, { @@ -125,7 +155,11 @@ export class QwenExecutor extends DefaultExecutor { }), }); if (!response.ok) return null; - const tokens = await response.json(); + const tokensUnknown: unknown = await response.json(); + const tokens = + tokensUnknown && typeof tokensUnknown === "object" + ? (tokensUnknown as QwenTokenPayload) + : {}; log?.info?.("TOKEN", "qwen refreshed"); return { accessToken: tokens.access_token, @@ -136,8 +170,9 @@ export class QwenExecutor extends DefaultExecutor { ...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {}), }, }; - } catch (error) { - log?.error?.("TOKEN", `qwen refresh error: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("TOKEN", `qwen refresh error: ${message}`); return null; } } diff --git a/open-sse/executors/vertex.js b/open-sse/executors/vertex.ts similarity index 70% rename from open-sse/executors/vertex.js rename to open-sse/executors/vertex.ts index c3655d67..edac76d7 100644 --- a/open-sse/executors/vertex.js +++ b/open-sse/executors/vertex.ts @@ -1,24 +1,35 @@ import { PROVIDERS } from "../config/providers.js"; import { parseVertexSaJson, refreshVertexToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { BaseExecutor } from "./base.js"; +import { + BaseExecutor, + type ExecutorConfigInput, + type ExecutorCredentials, + type ExecutorExecuteOptions, + type ExecutorExecuteResult, + type ExecutorHeaders, + type ExecutorLogger, +} from "./base.js"; // Cache project IDs resolved from raw API keys { apiKey → projectId } -const projectIdCache = new Map(); +const projectIdCache = new Map(); /** * Resolve GCP project ID from a raw Vertex API key. * Sends a dummy 404 request and parses "projects/{id}" from the error message. */ -async function resolveProjectId(apiKey) { - if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey); +async function resolveProjectId(apiKey: string): Promise { + if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey) ?? null; const res = await fetch( `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, ); - const json = await res.json().catch(() => null); - const msg = json?.[0]?.error?.message || json?.error?.message || ""; + const json = (await res.json().catch(() => null)) as + | { error?: { message?: string } } + | Array<{ error?: { message?: string } }> + | null; + const msg = (Array.isArray(json) ? json[0]?.error?.message : json?.error?.message) || ""; const match = msg.match(/projects\/([^/]+)\//); const projectId = match?.[1] || null; @@ -26,6 +37,14 @@ async function resolveProjectId(apiKey) { return projectId; } +function providerDataString( + credentials: ExecutorCredentials | null | undefined, + key: string, +): string | undefined { + const value = credentials?.providerSpecificData?.[key]; + return typeof value === "string" ? value : undefined; +} + /** * VertexExecutor - Google Cloud Vertex AI * @@ -37,14 +56,23 @@ async function resolveProjectId(apiKey) { * Token is minted/cached in tokenRefresh.js, not here. */ export class VertexExecutor extends BaseExecutor { - constructor(providerId = "vertex") { - super(providerId, PROVIDERS[providerId] || {}); + constructor(providerId: string = "vertex") { + super( + providerId, + (PROVIDERS as Record)[providerId] || {}, + ); } - buildUrl(model, stream, urlIndex = 0, credentials = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex: number = 0, + credentials: ExecutorCredentials | null = null, + ): string { + void urlIndex; const saJson = parseVertexSaJson(credentials?.apiKey); const rawKey = !saJson ? credentials?.apiKey : null; - const projectId = saJson?.project_id || credentials?.providerSpecificData?.projectId; + const projectId = saJson?.project_id || providerDataString(credentials, "projectId"); if (this.provider === "vertex-partner") { // Partner models require project_id in path regardless of auth method @@ -61,7 +89,7 @@ export class VertexExecutor extends BaseExecutor { if (saJson) { // SA JSON + Bearer token: must use project-scoped path to avoid RESOURCE_PROJECT_INVALID - const location = credentials?.providerSpecificData?.location || "us-central1"; + const location = providerDataString(credentials, "location") || "us-central1"; let url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:${action}`; if (stream) url += "?alt=sse"; return url; @@ -75,8 +103,8 @@ export class VertexExecutor extends BaseExecutor { return url; } - buildHeaders(credentials, stream = true) { - const headers = { "Content-Type": "application/json" }; + buildHeaders(credentials: ExecutorCredentials, stream: boolean = true): ExecutorHeaders { + const headers: ExecutorHeaders = { "Content-Type": "application/json" }; // Only set Bearer token if using SA JSON flow (raw key goes in URL ?key=) if (credentials.accessToken) { @@ -88,7 +116,10 @@ export class VertexExecutor extends BaseExecutor { return headers; } - async refreshCredentials(credentials, log) { + async refreshCredentials( + credentials: ExecutorCredentials, + log: ExecutorLogger | null, + ): Promise { const saJson = parseVertexSaJson(credentials?.apiKey); if (!saJson) return null; @@ -98,12 +129,20 @@ export class VertexExecutor extends BaseExecutor { return { accessToken: result.accessToken, expiresAt: result.expiresAt }; } - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + proxyOptions = null, + }: ExecutorExecuteOptions): Promise { const saJson = parseVertexSaJson(credentials?.apiKey); // SA JSON flow: mint Bearer token (cached) if (saJson) { - const result = await refreshVertexToken(saJson, log); + const result = await refreshVertexToken(saJson, log ?? null); if (!result?.accessToken) throw new Error("Vertex: failed to mint access token from Service Account JSON"); credentials.accessToken = result.accessToken; @@ -113,9 +152,9 @@ export class VertexExecutor extends BaseExecutor { if ( this.provider === "vertex-partner" && !saJson && - !credentials?.providerSpecificData?.projectId + !providerDataString(credentials, "projectId") ) { - const projectId = await resolveProjectId(credentials.apiKey); + const projectId = await resolveProjectId(credentials.apiKey as string); if (!projectId) throw new Error( "Vertex: could not resolve project_id from API key. Please add it manually in provider settings.", diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.ts similarity index 78% rename from open-sse/handlers/chatCore.js rename to open-sse/handlers/chatCore.ts index 69d04296..5ca4fd74 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { appendRequestLog, saveRequestDetail, trackPendingRequest } from "@/lib/usageDb"; import { getModelStrip, @@ -11,23 +12,63 @@ import { refreshWithRetry } from "../services/tokenRefresh.js"; import { FORMATS } from "../translator/formats.js"; import { translateRequest } from "../translator/index.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; -import { createErrorResult, formatProviderError, parseUpstreamError } from "../utils/error.js"; +import { + createErrorResult, + formatProviderError, + parseUpstreamError, + type ErrorResult, +} from "../utils/error.js"; import { createStreamController } from "../utils/streamHandler.js"; import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; +type JsonRecord = Record; +type ChatLogger = { + debug?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + info?: (scope: string, message: string) => void; + warn?: (scope: string, message: string) => void; +}; +type ClientRawRequest = JsonRecord & { + body?: unknown; + endpoint?: string; + headers?: JsonRecord & { accept?: string }; +}; +type CachedChatResponse = JsonRecord & { + choices?: { message?: { content?: string }; [key: string]: unknown }[]; + created?: number; + id?: string; + usage?: unknown; +}; +type ContentPart = { text?: string; type?: string }; +type MemoryRequestBody = JsonRecord & { + input?: { content?: string | ContentPart[]; role?: string; type?: string }[]; + messages?: { content?: string | ContentPart[]; role?: string }[]; +}; +type ProviderThinking = { effortMode?: string; mode?: string }; +type StreamContent = { content?: string; thinking?: string }; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function listLength(value: unknown) { + return Array.isArray(value) ? value.length : 0; +} + /** * Build a streaming SSE Response from a cached (non-streaming) response object. * Emits role chunk → content chunk → finish chunk → [DONE]. */ -function buildCacheHitSSEResponse(cached, model) { +function buildCacheHitSSEResponse(cached: CachedChatResponse, model: string) { const cachedId = cached.id || `chatcmpl-cached-${Date.now().toString(36)}`; const created = cached.created || Math.floor(Date.now() / 1000); const content = cached.choices?.[0]?.message?.content ?? ""; const encoder = new TextEncoder(); - const sseStream = new ReadableStream({ + const sseStream = new ReadableStream({ start(controller) { - const emit = (obj) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + const emit = (obj: unknown) => + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); emit({ id: cachedId, object: "chat.completion.chunk", @@ -81,7 +122,7 @@ import { } from "@/lib/semanticCache"; import { injectCaveman } from "../rtk/caveman.js"; -async function createRequestLogger(sourceFormat, targetFormat, model) { +async function createRequestLogger(sourceFormat: string, targetFormat: string, model: string) { const { createRequestLogger: createLogger } = await import("../utils/requestLogger.js"); return createLogger(sourceFormat, targetFormat, model); } @@ -92,17 +133,61 @@ import { reserveReasoningTokenBudget } from "../utils/tokenBudget.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; import { buildOnStreamComplete, handleStreamingResponse } from "./chatCore/streamingHandler.js"; +export type ChatCoreResult = { success: true; response: Response } | ErrorResult; + +function errorName(error: unknown) { + return error instanceof Error ? error.name : ""; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function errorCauseName(error: unknown) { + if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return ""; + const cause = error.cause as { name?: unknown }; + return typeof cause.name === "string" ? cause.name : ""; +} + +function isAbortError(error: unknown) { + return errorName(error) === "AbortError"; +} + +export interface ChatCoreParams { + body: JsonRecord; + modelInfo: { provider: string; model: string }; + credentials: JsonRecord | null; + log: ChatLogger | null; + onCredentialsRefreshed?: (newCreds: JsonRecord) => Promise | void; + onRequestSuccess?: () => Promise | void; + onDisconnect?: (reason?: unknown) => Promise | void; + clientRawRequest?: ClientRawRequest | null; + connectionId: string; + userAgent?: string; + apiKey?: string | null; + ccFilterNaming?: boolean; + rtkEnabled?: boolean; + cavemanEnabled?: boolean; + cavemanLevel?: string; + sourceFormatOverride?: string | null; + providerThinking?: ProviderThinking | null; + contentFilterMessage?: string | null; + chatSettings?: JsonRecord; + memoryOwnerId?: string | null; + comboName?: string | null; +} + const MAX_SEMANTIC_CACHE_BYTES = 512 * 1024; const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024; // Skip cacheability check for request bodies larger than this to avoid a // synchronous JSON.stringify of a multi-MB payload on every request. const _MAX_REQUEST_BYTES_FOR_CACHE_CHECK = 512 * 1024; -function isSmallEnoughForSemanticCache(value) { +function isSmallEnoughForSemanticCache(value: unknown) { try { // Fast-path: estimate size from known string fields before full stringify. // choices[0].message.content is the dominant field in a cached response. - const content = value?.choices?.[0]?.message?.content; + const content = (value as CachedChatResponse)?.choices?.[0]?.message?.content; if (typeof content === "string" && content.length > MAX_SEMANTIC_CACHE_BYTES) return false; return JSON.stringify(value).length <= MAX_SEMANTIC_CACHE_BYTES; } catch { @@ -110,7 +195,7 @@ function isSmallEnoughForSemanticCache(value) { } } -function toLimitedText(value) { +function toLimitedText(value: unknown) { if (typeof value !== "string") return ""; const trimmed = value.trim(); if (!trimmed) return ""; @@ -119,13 +204,14 @@ function toLimitedText(value) { : trimmed.slice(trimmed.length - MEMORY_EXTRACTION_TEXT_LIMIT); } -function extractMemoryTextFromResponse(response) { +function extractMemoryTextFromResponse(response: unknown) { if (!response || typeof response !== "object") return ""; - const openAIText = response?.choices?.[0]?.message?.content; + const typed = response as CachedChatResponse & { content?: ContentPart[]; output_text?: string }; + const openAIText = typed.choices?.[0]?.message?.content; if (typeof openAIText === "string") return toLimitedText(openAIText); - if (typeof response?.output_text === "string") return toLimitedText(response.output_text); - if (Array.isArray(response?.content)) { - const contentText = response.content + if (typeof typed.output_text === "string") return toLimitedText(typed.output_text); + if (Array.isArray(typed.content)) { + const contentText = typed.content .filter((part) => part?.type === "text" && typeof part?.text === "string") .map((part) => String(part.text).trim()) .filter(Boolean) @@ -135,9 +221,10 @@ function extractMemoryTextFromResponse(response) { return ""; } -function extractMemoryTextFromRequestBody(body) { +function extractMemoryTextFromRequestBody(body: unknown) { if (!body || typeof body !== "object") return ""; - const messages = Array.isArray(body.messages) ? body.messages : null; + const typed = body as MemoryRequestBody; + const messages = Array.isArray(typed.messages) ? typed.messages : null; if (messages?.length) { for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; @@ -147,8 +234,6 @@ function extractMemoryTextFromRequestBody(body) { const text = msg.content .map((part) => { if (typeof part?.text === "string") return part.text.trim(); - if (part?.type === "input_text" && typeof part?.text === "string") - return part.text.trim(); return ""; }) .filter(Boolean) @@ -158,7 +243,7 @@ function extractMemoryTextFromRequestBody(body) { } } - const input = Array.isArray(body.input) ? body.input : null; + const input = Array.isArray(typed.input) ? typed.input : null; if (input?.length) { for (let i = input.length - 1; i >= 0; i -= 1) { const item = input[i]; @@ -172,8 +257,6 @@ function extractMemoryTextFromRequestBody(body) { const text = item.content .map((part) => { if (typeof part?.text === "string") return part.text.trim(); - if (part?.type === "input_text" && typeof part?.text === "string") - return part.text.trim(); return ""; }) .filter(Boolean) @@ -185,10 +268,11 @@ function extractMemoryTextFromRequestBody(body) { return ""; } -function extractTokensSaved(usage) { +function extractTokensSaved(usage: unknown) { if (!usage || typeof usage !== "object") return 0; - const prompt = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0; - const completion = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0; + const record = usage as JsonRecord; + const prompt = Number(record.prompt_tokens ?? record.input_tokens ?? 0) || 0; + const completion = Number(record.completion_tokens ?? record.output_tokens ?? 0) || 0; return prompt + completion; } @@ -221,7 +305,7 @@ export async function handleChatCore({ chatSettings, memoryOwnerId, comboName, -}) { +}: ChatCoreParams): Promise { const { provider, model } = modelInfo; const requestStartTime = Date.now(); const pipelineSessionId = @@ -299,7 +383,7 @@ export async function handleChatCore({ // Semantic cache pre-check with thundering herd protection let cacheSignature = null; - let resolveInFlight = null; + let resolveInFlight: ((value: unknown) => void) | null = null; const messages = body.messages ?? body.input; // generateSignature already handles large payloads by hashing only the last // 64KB tail (SIGNATURE_MAX_BYTES), so no need to skip cache for large bodies. @@ -312,11 +396,11 @@ export async function handleChatCore({ cacheSignature = generateSignature( model, messages, - body.temperature, - body.top_p, + body.temperature as number | null | undefined, + body.top_p as number | null | undefined, memoryOwnerId || null, ); - const cached = getCachedResponse(cacheSignature); + const cached = getCachedResponse(cacheSignature) as CachedChatResponse | null; if (cached) { reqLogger.logConvertedResponse(cached); if (clientRequestedStreaming) { @@ -337,7 +421,7 @@ export async function handleChatCore({ const inFlight = getInFlight(cacheSignature); if (inFlight) { try { - const result = await inFlight; + const result = (await inFlight) as CachedChatResponse | null; if (result) { reqLogger.logConvertedResponse(result); if (clientRequestedStreaming) { @@ -359,7 +443,7 @@ export async function handleChatCore({ } } else { // Register this request as in-flight so concurrent duplicates can await it - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveInFlight = resolve; }); setInFlight(cacheSignature, promise); @@ -380,8 +464,8 @@ export async function handleChatCore({ body = injectMemory(body, memories, provider); log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${memoryOwnerId}`); } - } catch (error) { - log?.debug?.("MEMORY", `Memory injection skipped: ${error?.message || String(error)}`); + } catch (error: unknown) { + log?.debug?.("MEMORY", `Memory injection skipped: ${errorMessage(error)}`); } } @@ -390,8 +474,8 @@ export async function handleChatCore({ const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); const passthrough = isNativePassthrough(clientTool, provider); - let translatedBody; - let toolNameMap; + let translatedBody: JsonRecord; + let toolNameMap: unknown; if (passthrough) { log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); translatedBody = { ...body, model }; @@ -414,6 +498,7 @@ export async function handleChatCore({ return createErrorResult( HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`, + undefined, ); } toolNameMap = translatedBody._toolNameMap; @@ -461,15 +546,15 @@ export async function handleChatCore({ ); const msgCount = - translatedBody.messages?.length || - translatedBody.input?.length || - translatedBody.contents?.length || - translatedBody.request?.contents?.length || + listLength(translatedBody.messages) || + listLength(translatedBody.input) || + listLength(translatedBody.contents) || + listLength(isRecord(translatedBody.request) ? translatedBody.request.contents : null) || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); const streamController = createStreamController({ - onDisconnect: (reason) => { + onDisconnect: (reason: unknown) => { trackPendingRequest(model, provider, connectionId, false); if (onDisconnect) onDisconnect(reason); }, @@ -479,16 +564,19 @@ export async function handleChatCore({ model, }); - const proxyOptions = { - connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, - connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", - connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", - vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", + const providerData = isRecord(credentials?.providerSpecificData) + ? credentials.providerSpecificData + : {}; + const proxyOptions: JsonRecord = { + connectionProxyEnabled: providerData.connectionProxyEnabled === true, + connectionProxyUrl: providerData.connectionProxyUrl || "", + connectionNoProxy: providerData.connectionNoProxy || "", + vercelRelayUrl: providerData.vercelRelayUrl || "", }; if (proxyOptions.vercelRelayUrl) { const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; - const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const poolId = providerData.connectionProxyPoolId || "none"; log?.info?.( "PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`, @@ -496,7 +584,7 @@ export async function handleChatCore({ } else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { let maskedProxyUrl = proxyOptions.connectionProxyUrl; try { - const parsed = new URL(proxyOptions.connectionProxyUrl); + const parsed = new URL(proxyOptions.connectionProxyUrl as string); const host = parsed.hostname || ""; const port = parsed.port ? `:${parsed.port}` : ""; const protocol = parsed.protocol || "http:"; @@ -505,7 +593,7 @@ export async function handleChatCore({ // Keep raw if URL parsing fails } - const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const poolId = providerData.connectionProxyPoolId || "none"; const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; log?.info?.( "PROXY", @@ -530,9 +618,9 @@ export async function handleChatCore({ // Pass timeout to proxy layer so Vercel relay can enforce its own AbortController proxyOptions.upstreamTimeoutMs = upstreamTimeoutMs; - const isUpstreamTimeoutError = (error) => - error?.name === "TimeoutError" || error?.cause?.name === "TimeoutError"; - const buildAbortStatus = (error) => + const isUpstreamTimeoutError = (error: unknown) => + errorName(error) === "TimeoutError" || errorCauseName(error) === "TimeoutError"; + const buildAbortStatus = (error: unknown) => isUpstreamTimeoutError(error) ? HTTP_STATUS.REQUEST_TIMEOUT : 499; const createUpstreamSignal = () => { const timeoutController = new AbortController(); @@ -544,9 +632,11 @@ export async function handleChatCore({ timeoutId.unref?.(); const combinedController = new AbortController(); - const forwardAbort = (event) => { + const forwardAbort = (event: Event) => { const reason = - event?.target?.reason || timeoutController.signal.reason || streamController.signal.reason; + (event.target as AbortSignal | null)?.reason || + timeoutController.signal.reason || + streamController.signal.reason; combinedController.abort(reason); }; @@ -599,7 +689,7 @@ export async function handleChatCore({ (providerResponse.status === 502 || providerResponse.status === 504) ) { console.error("[VERCEL-RELAY-RETRY] Retrying upstream request after relay 502/504"); - await new Promise((r) => setTimeout(r, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); const retryResult = await executeUpstream(); providerResponse = retryResult.response; providerUrl = retryResult.url; @@ -607,7 +697,7 @@ export async function handleChatCore({ finalBody = retryResult.transformedBody; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } - } catch (error) { + } catch (error: unknown) { trackPendingRequest(model, provider, connectionId, false, true); const abortStatus = buildAbortStatus(error); const isTimeout = isUpstreamTimeoutError(error); @@ -620,8 +710,10 @@ export async function handleChatCore({ model, provider, connectionId, - status: `FAILED ${error.name === "AbortError" ? abortStatus : HTTP_STATUS.BAD_GATEWAY}`, - }).catch(() => {}); + status: `FAILED ${isAbortError(error) ? abortStatus : HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => { + // Best-effort request log; upstream error response still proceeds. + }); saveRequestDetail( buildRequestDetail({ provider, @@ -632,25 +724,28 @@ export async function handleChatCore({ request: extractRequestConfig(body, stream), providerRequest: translatedBody || null, response: { - error: error.message || String(error), - status: error.name === "AbortError" ? abortStatus : 502, + error: errorMessage(error), + status: isAbortError(error) ? abortStatus : 502, thinking: null, }, status: "error", }), - ).catch(() => {}); + ).catch(() => { + // Best-effort request detail; upstream error response still proceeds. + }); - if (error.name === "AbortError") { + if (isAbortError(error)) { streamController.handleError(error); return createErrorResult( abortStatus, isUpstreamTimeoutError(error) ? `Upstream request timed out after ${upstreamTimeoutMs}ms` : "Request aborted", + undefined, ); } const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg, undefined); } // Fix 2: Detect Vercel platform 504 — free-tier hard-kills functions at 10s. @@ -663,6 +758,7 @@ export async function handleChatCore({ return createErrorResult( HTTP_STATUS.GATEWAY_TIMEOUT, "Vercel relay timeout — function exceeded platform limit", + undefined, ); } @@ -680,12 +776,12 @@ export async function handleChatCore({ ); if (newCredentials?.accessToken || newCredentials?.copilotToken) { log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); - Object.assign(credentials, newCredentials); + Object.assign(credentials as JsonRecord, newCredentials); if (onCredentialsRefreshed) { try { await onCredentialsRefreshed(newCredentials); - } catch (e) { - log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); + } catch (e: unknown) { + log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${errorMessage(e)}`); } } try { @@ -694,14 +790,17 @@ export async function handleChatCore({ providerResponse = retryResult.response; providerUrl = retryResult.url; } - } catch { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); + } catch (e: unknown) { + log?.warn?.( + "TOKEN", + `${provider.toUpperCase()} | retry after refresh failed: ${errorMessage(e)}`, + ); } } else { log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); } - } catch (e) { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`); + } catch (e: unknown) { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${errorMessage(e)}`); } } @@ -714,7 +813,9 @@ export async function handleChatCore({ ); console.error(`[UPSTREAM ${statusCode}] Upstream provider returned an error`); appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch( - () => {}, + () => { + // Best-effort request log; upstream error response still proceeds. + }, ); saveRequestDetail( buildRequestDetail({ @@ -728,7 +829,9 @@ export async function handleChatCore({ response: { error: message, status: statusCode, thinking: null }, status: "error", }), - ).catch(() => {}); + ).catch(() => { + // Best-effort request detail; upstream error response still proceeds. + }); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); reqLogger.logError(new Error(message), finalBody || translatedBody); @@ -748,8 +851,10 @@ export async function handleChatCore({ clientRawRequest, onRequestSuccess, }; - const appendLog = (extra) => - appendRequestLog({ model, provider, connectionId, combo: comboName, ...extra }).catch(() => {}); + const appendLog = (extra: JsonRecord) => + appendRequestLog({ model, provider, connectionId, combo: comboName, ...extra }).catch(() => { + // Best-effort request log; do not disrupt response handling. + }); const trackDone = () => trackPendingRequest(model, provider, connectionId, false); // Provider forced streaming but client wants JSON @@ -760,7 +865,7 @@ export async function handleChatCore({ sourceFormat, trackDone, appendLog, - onFinalJsonResponse: (finalResponse, usage) => { + onFinalJsonResponse: (finalResponse: unknown, usage: unknown) => { if ( semanticCacheEnabled && cacheSignature && @@ -801,7 +906,7 @@ export async function handleChatCore({ toolNameMap, trackDone, appendLog, - onFinalJsonResponse: (translatedResponse, usage) => { + onFinalJsonResponse: (translatedResponse: unknown, usage: unknown) => { if ( semanticCacheEnabled && cacheSignature && @@ -837,21 +942,25 @@ export async function handleChatCore({ let reader; try { reader = providerResponse.body.getReader(); - } catch (e) { - log?.error?.("CHAT_CORE", `Failed to get reader from provider stream: ${e.message}`); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to read provider stream"); + } catch (e: unknown) { + log?.error?.("CHAT_CORE", `Failed to get reader from provider stream: ${errorMessage(e)}`); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + "Failed to read provider stream", + undefined, + ); } - const peekResult = await reader.read().catch((e) => { + const peekResult = await reader.read().catch((e: unknown) => { log?.error?.( "CHAT_CORE", - `Failed to peek first chunk from ${provider}/${model}: ${e.message}`, + `Failed to peek first chunk from ${provider}/${model}: ${errorMessage(e)}`, ); return { value: null, done: true }; }); const { value: firstChunk, done } = peekResult; if (!done && firstChunk) { const text = new TextDecoder().decode(firstChunk); - const dataLine = text.split("\n").find((l) => l.startsWith("data:")); + const dataLine = text.split("\n").find((line) => line.startsWith("data:")); if (dataLine) { const payload = dataLine.slice(5).trim(); if (payload && payload !== "[DONE]") { @@ -859,7 +968,9 @@ export async function handleChatCore({ const parsed = JSON.parse(payload); if (parsed.error && !parsed.choices) { trackPendingRequest(model, provider, connectionId, false, true); - reader.cancel().catch(() => {}); + reader.cancel().catch(() => { + // Cleanup only; stream is already returning an upstream error. + }); // If contentFilterMessage is set, return a humanistic SSE response // instead of a programmatic error so the client sees a natural reply. @@ -870,7 +981,7 @@ export async function handleChatCore({ const chunk2 = `data: ${JSON.stringify({ id: fallbackId, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } })}\n\n`; const done = "data: [DONE]\n\n"; const encoder = new TextEncoder(); - const fallbackStream = new ReadableStream({ + const fallbackStream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(chunk1)); controller.enqueue(encoder.encode(chunk2)); @@ -894,10 +1005,8 @@ export async function handleChatCore({ const errMsg = parsed.error.message || "Upstream error"; const statusCode = - parsed.error.code === "content_filter" - ? HTTP_STATUS.UNPROCESSABLE_ENTITY || 422 - : HTTP_STATUS.BAD_GATEWAY; - return createErrorResult(statusCode, errMsg); + parsed.error.code === "content_filter" ? 422 : HTTP_STATUS.BAD_GATEWAY; + return createErrorResult(statusCode, errMsg, undefined); } } catch { // not JSON, continue @@ -906,7 +1015,7 @@ export async function handleChatCore({ } // Reconstruct response with peeked chunk prepended. // providerResponse.body is already locked by reader, so pipe via reader. - const reconstructed = new ReadableStream({ + const reconstructed = new ReadableStream({ async start(controller) { controller.enqueue(firstChunk); try { @@ -916,11 +1025,11 @@ export async function handleChatCore({ controller.enqueue(value); } controller.close(); - } catch (e) { + } catch (e: unknown) { // ponytail: controller.close() on AbortError — controller.error() re-emits the // abort to the response writer, which surfaces as unhandledRejection at // node:_http_server. - if (e?.name === "AbortError") { + if (isAbortError(e)) { controller.close(); } else { controller.error(e); @@ -928,7 +1037,9 @@ export async function handleChatCore({ } }, cancel() { - reader.cancel().catch(() => {}); + reader.cancel().catch(() => { + // Cleanup only; downstream cancellation is already in progress. + }); }, }); providerResponse = new Response(reconstructed, { @@ -942,7 +1053,7 @@ export async function handleChatCore({ const { onStreamComplete: baseOnStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx, }); - const onStreamComplete = (contentObj, usage, ttftAt) => { + const onStreamComplete = (contentObj: StreamContent, usage: unknown, ttftAt: number | null) => { baseOnStreamComplete?.(contentObj, usage, ttftAt); appendLog({ tokens: usage, status: "SUCCESS", detailsId: streamDetailId }); if (memoryOwnerId && memorySettings.enabled && memorySettings.maxTokens > 0) { @@ -961,6 +1072,7 @@ export async function handleChatCore({ isCacheableForWrite(body, clientRawRequest?.headers) && contentObj?.content ) { + const usageRecord = isRecord(usage) ? usage : {}; const cachedId = `chatcmpl-cached-${Date.now().toString(36)}`; const assembledResponse = { id: cachedId, @@ -976,10 +1088,12 @@ export async function handleChatCore({ ], usage: usage ? { - prompt_tokens: usage.prompt_tokens ?? 0, - completion_tokens: usage.completion_tokens ?? 0, + prompt_tokens: usageRecord.prompt_tokens ?? 0, + completion_tokens: usageRecord.completion_tokens ?? 0, total_tokens: - usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0), + usageRecord.total_tokens ?? + ((usageRecord.prompt_tokens as number) ?? 0) + + ((usageRecord.completion_tokens as number) ?? 0), } : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }; @@ -1019,7 +1133,7 @@ export async function handleChatCore({ }); } -export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { +export function isTokenExpiringSoon(expiresAt: unknown, bufferMs = 5 * 60 * 1000) { if (!expiresAt) return false; - return new Date(expiresAt).getTime() - Date.now() < bufferMs; + return new Date(expiresAt as string | number | Date).getTime() - Date.now() < bufferMs; } diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.ts similarity index 63% rename from open-sse/handlers/chatCore/nonStreamingHandler.js rename to open-sse/handlers/chatCore/nonStreamingHandler.ts index 1d87d89c..59146bce 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.ts @@ -15,10 +15,175 @@ import { } from "./requestDetail.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; +type JsonRecord = Record; + +type NonStreamingResult = + | { success: true; response: Response } + | ReturnType; + +type OpenAIToolCall = { + id: string; + type: "function"; + function: { name: string; arguments: string }; +}; + +type OpenAIAssistantMessage = { + role: "assistant"; + content?: string; + reasoning_content?: string; + tool_calls?: OpenAIToolCall[]; +}; + +type OpenAIChatCompletion = JsonRecord & { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: OpenAIAssistantMessage; + finish_reason: string; + logprobs?: unknown; + content_filter_results?: unknown; + }>; + usage?: JsonRecord & { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + completion_tokens_details?: { reasoning_tokens: number }; + }; +}; + +type MutableChatCompletion = JsonRecord & { + object?: string; + created?: number; + system_fingerprint?: string; + prompt_filter_results?: unknown; + choices?: Array<{ + message?: { + content?: unknown; + reasoning_content?: unknown; + tool_calls?: unknown[]; + }; + finish_reason?: string; + logprobs?: unknown; + content_filter_results?: unknown; + }>; + usage?: unknown; + content?: unknown; + reasoning_content?: unknown; +}; + +type GeminiPart = { + thought?: boolean; + text?: string; + functionCall?: { name?: string; args?: unknown }; +}; + +type GeminiCandidate = { + content?: { parts?: GeminiPart[] }; + finishReason?: string; +}; + +type GeminiUsage = { + promptTokenCount?: number; + thoughtsTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; +}; + +type GeminiResponse = { + candidates?: GeminiCandidate[]; + usageMetadata?: GeminiUsage; + responseId?: string; + createTime?: string | number; + modelVersion?: string; +}; + +type ClaudeContentBlock = { + type?: string; + text?: string; + thinking?: string; + id?: string; + name?: string; + input?: unknown; +}; + +type ClaudeUsage = { + input_tokens?: number; + output_tokens?: number; +}; + +type ClaudeResponse = { + content?: ClaudeContentBlock[]; + stop_reason?: string; + id?: string; + model?: string; + usage?: ClaudeUsage; +}; + +type ResponsesContentItem = { text?: string; type?: string }; + +type ResponsesOutputItem = { + type?: string; + content?: ResponsesContentItem[]; +}; + +type ResponsesJson = { + created_at?: number; + id?: string; + output?: ResponsesOutputItem[]; + usage?: { input_tokens?: number; output_tokens?: number }; +}; + +type RequestLoggerLike = { + logProviderResponse: ( + status?: unknown, + statusText?: unknown, + headers?: unknown, + body?: unknown, + ) => void; + logConvertedResponse: (body?: unknown) => void; +}; + +type NonStreamingParams = { + providerResponse: Response; + provider: string; + model: string; + sourceFormat: string; + targetFormat: string; + body: JsonRecord; + stream: boolean; + translatedBody?: unknown; + finalBody?: unknown; + requestStartTime: number; + connectionId?: string; + apiKey?: string | null; + clientRawRequest?: { endpoint?: string } | null; + onRequestSuccess?: () => Promise | void; + reqLogger: RequestLoggerLike; + toolNameMap?: unknown; + trackDone: () => void; + appendLog: (entry: JsonRecord) => void; + onFinalJsonResponse?: (response: unknown, usage: unknown) => void; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Translate non-streaming response body from provider format → OpenAI format. */ -export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) { +export function translateNonStreamingResponse( + responseBody: unknown, + targetFormat: unknown, + sourceFormat: unknown, +): unknown { if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody; // Gemini / Antigravity @@ -28,15 +193,16 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX ) { - const response = responseBody.response || responseBody; + const body = isRecord(responseBody) ? responseBody : {}; + const response = (isRecord(body.response) ? body.response : body) as GeminiResponse; if (!response?.candidates?.[0]) return responseBody; const candidate = response.candidates[0]; - const content = candidate.content; - const usage = response.usageMetadata || responseBody.usageMetadata; + const content = candidate?.content; + const usage = response.usageMetadata || (body.usageMetadata as GeminiUsage | undefined); let textContent = "", reasoningContent = ""; - const toolCalls = []; + const toolCalls: OpenAIToolCall[] = []; if (content?.parts) { for (const part of content.parts) { @@ -47,7 +213,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source id: `call_${part.functionCall.name}_${Date.now()}_${toolCalls.length}`, type: "function", function: { - name: part.functionCall.name, + name: part.functionCall.name || "", arguments: JSON.stringify(part.functionCall.args || {}), }, }); @@ -55,16 +221,16 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source } } - const message = { role: "assistant" }; + const message: OpenAIAssistantMessage = { role: "assistant" }; if (textContent) message.content = textContent; if (reasoningContent) message.reasoning_content = reasoningContent; if (toolCalls.length > 0) message.tool_calls = toolCalls; if (!message.content && !message.tool_calls) message.content = ""; - let finishReason = (candidate.finishReason || "stop").toLowerCase(); + let finishReason = (candidate?.finishReason || "stop").toLowerCase(); if (finishReason === "stop" && toolCalls.length > 0) finishReason = "tool_calls"; - const result = { + const result: OpenAIChatCompletion = { id: `chatcmpl-${response.responseId || Date.now()}`, object: "chat.completion", created: Math.floor(new Date(response.createTime || Date.now()).getTime() / 1000), @@ -78,8 +244,10 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source completion_tokens: usage.candidatesTokenCount || 0, total_tokens: usage.totalTokenCount || 0, }; - if (usage.thoughtsTokenCount > 0) { - result.usage.completion_tokens_details = { reasoning_tokens: usage.thoughtsTokenCount }; + if ((usage.thoughtsTokenCount || 0) > 0) { + result.usage.completion_tokens_details = { + reasoning_tokens: usage.thoughtsTokenCount || 0, + }; } } return result; @@ -87,13 +255,14 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source // Claude if (targetFormat === FORMATS.CLAUDE) { - if (!responseBody.content) return responseBody; + const claudeBody = responseBody as ClaudeResponse; + if (!claudeBody.content) return responseBody; let textContent = "", thinkingContent = ""; - const toolCalls = []; + const toolCalls: OpenAIToolCall[] = []; - for (const block of responseBody.content) { + for (const block of claudeBody.content) { if (block.type === "text") { // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```) const raw = block.text ?? ""; @@ -102,37 +271,36 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source } else if (block.type === "thinking") thinkingContent += block.thinking || ""; else if (block.type === "tool_use") { toolCalls.push({ - id: block.id, + id: block.id || "", type: "function", - function: { name: block.name, arguments: JSON.stringify(block.input || {}) }, + function: { name: block.name || "", arguments: JSON.stringify(block.input || {}) }, }); } } - const message = { role: "assistant" }; + const message: OpenAIAssistantMessage = { role: "assistant" }; if (textContent) message.content = textContent; if (thinkingContent) message.reasoning_content = thinkingContent; if (toolCalls.length > 0) message.tool_calls = toolCalls; if (!message.content && !message.tool_calls) message.content = ""; - let finishReason = responseBody.stop_reason || "stop"; + let finishReason = claudeBody.stop_reason || "stop"; if (finishReason === "end_turn") finishReason = "stop"; if (finishReason === "tool_use") finishReason = "tool_calls"; - const result = { - id: `chatcmpl-${responseBody.id || Date.now()}`, + const result: OpenAIChatCompletion = { + id: `chatcmpl-${claudeBody.id || Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: responseBody.model || "claude", + model: claudeBody.model || "claude", choices: [{ index: 0, message, finish_reason: finishReason }], }; - if (responseBody.usage) { + if (claudeBody.usage) { result.usage = { - prompt_tokens: responseBody.usage.input_tokens || 0, - completion_tokens: responseBody.usage.output_tokens || 0, - total_tokens: - (responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0), + prompt_tokens: claudeBody.usage.input_tokens || 0, + completion_tokens: claudeBody.usage.output_tokens || 0, + total_tokens: (claudeBody.usage.input_tokens || 0) + (claudeBody.usage.output_tokens || 0), }; } return result; @@ -169,10 +337,10 @@ export async function handleNonStreamingResponse({ trackDone, appendLog, onFinalJsonResponse, -}) { +}: NonStreamingParams): Promise { trackDone(); const contentType = providerResponse.headers.get("content-type") || ""; - let responseBody; + let responseBody: unknown; // Codex never sends Content-Type on success — detect by provider name too. // Codex returns Responses API SSE format, not Chat Completions SSE, so it @@ -183,7 +351,9 @@ export async function handleNonStreamingResponse({ if (isSSE && isCodexSSE) { // Responses API SSE → convert to chat.completion JSON try { - const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); + const jsonResponse = (await convertResponsesStreamToJson( + providerResponse.body, + )) as ResponsesJson; const inTokens = jsonResponse.usage?.input_tokens || 0; const outTokens = jsonResponse.usage?.output_tokens || 0; // Extract text from output items @@ -212,6 +382,7 @@ export async function handleNonStreamingResponse({ return createErrorResult( HTTP_STATUS.BAD_GATEWAY, `Failed to parse Codex response from ${provider}`, + undefined, ); } } else if (isSSE) { @@ -222,6 +393,7 @@ export async function handleNonStreamingResponse({ return createErrorResult( HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request", + undefined, ); } responseBody = parsed; @@ -231,7 +403,11 @@ export async function handleNonStreamingResponse({ } catch { appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }); console.error("[ChatCore] Failed to parse JSON response"); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + `Invalid JSON response from ${provider}`, + undefined, + ); } } @@ -258,16 +434,18 @@ export async function handleNonStreamingResponse({ endpoint: clientRawRequest?.endpoint, }); - const translatedResponse = needsTranslation(targetFormat, sourceFormat) - ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) - : responseBody; + const translatedResponse = ( + needsTranslation(targetFormat, sourceFormat) + ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) + : responseBody + ) as MutableChatCompletion; // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other") if (translatedResponse?.choices?.[0]) { const choice = translatedResponse.choices[0]; - const msg = choice.message; + const msg = choice?.message; const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; - if (hasToolCalls && choice.finish_reason !== "tool_calls") { + if (hasToolCalls && choice && choice.finish_reason !== "tool_calls") { choice.finish_reason = "tool_calls"; } } @@ -336,8 +514,8 @@ export async function handleNonStreamingResponse({ }, { endpoint: clientRawRequest?.endpoint || null }, ), - ).catch((err) => { - console.error("[RequestDetail] Failed to save:", err.message); + ).catch((err: unknown) => { + console.error("[RequestDetail] Failed to save:", errorMessage(err)); }); return { diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js deleted file mode 100644 index 5c3ef903..00000000 --- a/open-sse/handlers/chatCore/requestDetail.js +++ /dev/null @@ -1,142 +0,0 @@ -import { saveRequestUsage } from "@/lib/usageDb"; -import { COLORS } from "../../utils/stream.js"; - -const OPTIONAL_PARAMS = [ - "temperature", - "top_p", - "top_k", - "max_tokens", - "max_completion_tokens", - "thinking", - "reasoning", - "enable_thinking", - "presence_penalty", - "frequency_penalty", - "seed", - "stop", - "tools", - "tool_choice", - "response_format", - "prediction", - "store", - "metadata", - "n", - "logprobs", - "top_logprobs", - "logit_bias", - "user", - "parallel_tool_calls", -]; - -export function extractRequestConfig(body, stream) { - const config = { messages: body.messages || [], model: body.model, stream }; - for (const param of OPTIONAL_PARAMS) { - if (body[param] !== undefined) config[param] = body[param]; - } - return config; -} - -export function extractUsageFromResponse(responseBody) { - if (!responseBody || typeof responseBody !== "object") return null; - - // Claude format - if (responseBody.usage?.input_tokens !== undefined) { - return { - prompt_tokens: responseBody.usage.input_tokens || 0, - completion_tokens: responseBody.usage.output_tokens || 0, - cache_read_input_tokens: responseBody.usage.cache_read_input_tokens, - cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens, - }; - } - - // OpenAI format - if (responseBody.usage?.prompt_tokens !== undefined) { - return { - prompt_tokens: responseBody.usage.prompt_tokens || 0, - completion_tokens: responseBody.usage.completion_tokens || 0, - cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens, - reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens, - }; - } - - // Gemini format - if (responseBody.usageMetadata) { - return { - prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount, - }; - } - - // Ollama format (non-streaming response with prompt_eval_count/eval_count) - if (responseBody.prompt_eval_count !== undefined || responseBody.eval_count !== undefined) { - return { - prompt_tokens: responseBody.prompt_eval_count || 0, - completion_tokens: responseBody.eval_count || 0, - }; - } - - return null; -} - -export function buildRequestDetail(base, overrides = {}) { - return { - // id must be first so overrides can replace it if needed - id: base.id || undefined, - provider: base.provider || "unknown", - model: base.model || "unknown", - connectionId: base.connectionId || undefined, - timestamp: new Date().toISOString(), - latency: base.latency || { ttft: 0, total: 0 }, - tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 }, - request: base.request, - providerRequest: base.providerRequest || null, - providerResponse: base.providerResponse || null, - response: base.response || {}, - status: base.status || "success", - ...overrides, - }; -} - -export function saveUsageStats({ - provider, - model, - tokens, - connectionId, - apiKey, - endpoint, - label = "USAGE", -}) { - if (!tokens || typeof tokens !== "object") return; - - const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0; - const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0; - - if (inTokens === 0 && outTokens === 0) return; - - const time = new Date().toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - console.log( - `${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${COLORS.reset}`, - ); - - // Normalize to OpenAI token shape for storage - const normalized = { - prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0, - completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0, - }; - - saveRequestUsage({ - provider: provider || "unknown", - model: model || "unknown", - tokens: normalized, - timestamp: new Date().toISOString(), - connectionId: connectionId || undefined, - apiKey: apiKey || undefined, - endpoint: endpoint || null, - }).catch(() => {}); -} diff --git a/open-sse/handlers/chatCore/requestDetail.ts b/open-sse/handlers/chatCore/requestDetail.ts new file mode 100644 index 00000000..de1e66ce --- /dev/null +++ b/open-sse/handlers/chatCore/requestDetail.ts @@ -0,0 +1,204 @@ +import { saveRequestUsage, saveRequestDetail } from "@/lib/usageDb"; +import { COLORS } from "../../utils/stream.js"; + +type JsonRecord = Record; +type DetailItem = Parameters[0]; + +type UsageTokens = JsonRecord & { + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + cached_tokens?: number; + reasoning_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; + completion_tokens_details?: { reasoning_tokens?: number }; +}; + +type ResponseBodyWithUsage = JsonRecord & { + usage?: UsageTokens; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + thoughtsTokenCount?: number; + }; + prompt_eval_count?: number; + eval_count?: number; +}; + +type RequestDetailBase = JsonRecord & { + id?: string; + provider?: string; + model?: string; + connectionId?: string; + latency?: { ttft?: number; total?: number }; + tokens?: unknown; + request?: unknown; + providerRequest?: unknown; + providerResponse?: unknown; + response?: unknown; + status?: string; +}; + +type SaveUsageStatsParams = { + provider?: string | null; + model?: string | null; + tokens?: UsageTokens | null; + connectionId?: string | null; + apiKey?: string | null; + endpoint?: string | null; + label?: string; +}; + +const OPTIONAL_PARAMS = [ + "temperature", + "top_p", + "top_k", + "max_tokens", + "max_completion_tokens", + "thinking", + "reasoning", + "enable_thinking", + "presence_penalty", + "frequency_penalty", + "seed", + "stop", + "tools", + "tool_choice", + "response_format", + "prediction", + "store", + "metadata", + "n", + "logprobs", + "top_logprobs", + "logit_bias", + "user", + "parallel_tool_calls", +] as const; + +export function extractRequestConfig(body: JsonRecord | null | undefined, stream: unknown) { + const safeBody = body && typeof body === "object" ? body : {}; + const config: JsonRecord = { + messages: safeBody.messages || [], + model: safeBody.model, + stream, + }; + for (const param of OPTIONAL_PARAMS) { + if (safeBody[param] !== undefined) config[param] = safeBody[param]; + } + return config; +} + +export function extractUsageFromResponse(responseBody: unknown) { + if (!responseBody || typeof responseBody !== "object") return null; + const body = responseBody as ResponseBodyWithUsage; + + // Claude format + if (body.usage?.input_tokens !== undefined) { + return { + prompt_tokens: body.usage.input_tokens || 0, + completion_tokens: body.usage.output_tokens || 0, + cache_read_input_tokens: body.usage.cache_read_input_tokens, + cache_creation_input_tokens: body.usage.cache_creation_input_tokens, + }; + } + + // OpenAI format + if (body.usage?.prompt_tokens !== undefined) { + return { + prompt_tokens: body.usage.prompt_tokens || 0, + completion_tokens: body.usage.completion_tokens || 0, + cached_tokens: body.usage.prompt_tokens_details?.cached_tokens, + reasoning_tokens: body.usage.completion_tokens_details?.reasoning_tokens, + }; + } + + // Gemini format + if (body.usageMetadata) { + return { + prompt_tokens: body.usageMetadata.promptTokenCount || 0, + completion_tokens: body.usageMetadata.candidatesTokenCount || 0, + reasoning_tokens: body.usageMetadata.thoughtsTokenCount, + }; + } + + // Ollama format (non-streaming response with prompt_eval_count/eval_count) + if (body.prompt_eval_count !== undefined || body.eval_count !== undefined) { + return { + prompt_tokens: body.prompt_eval_count || 0, + completion_tokens: body.eval_count || 0, + }; + } + + return null; +} + +export function buildRequestDetail( + base: RequestDetailBase, + overrides: JsonRecord = {}, +): DetailItem { + return { + // id must be first so overrides can replace it if needed + id: base.id || undefined, + provider: base.provider || "unknown", + model: base.model || "unknown", + connectionId: base.connectionId || undefined, + timestamp: new Date().toISOString(), + latency: base.latency || { ttft: 0, total: 0 }, + tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 }, + request: base.request, + providerRequest: base.providerRequest || null, + providerResponse: base.providerResponse || null, + response: base.response || {}, + status: base.status || "success", + ...overrides, + } as DetailItem; +} + +export function saveUsageStats({ + provider, + model, + tokens, + connectionId, + apiKey, + endpoint, + label = "USAGE", +}: SaveUsageStatsParams) { + if (!tokens || typeof tokens !== "object") return; + + const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0; + const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0; + + if (inTokens === 0 && outTokens === 0) return; + + const time = new Date().toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + console.log( + `${COLORS.green}[${time}] 📊 [${label}] ${(provider || "unknown").toUpperCase()} | in=${inTokens} | out=${outTokens}${COLORS.reset}`, + ); + + // Normalize to OpenAI token shape for storage + const normalized = { + prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0, + completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0, + }; + + saveRequestUsage({ + provider: provider || "unknown", + model: model || "unknown", + tokens: normalized, + timestamp: new Date().toISOString(), + connectionId: connectionId || undefined, + apiKey: apiKey || undefined, + endpoint: endpoint || undefined, + }).catch(() => { + // Best-effort usage persistence; never fail response handling on metrics writes. + }); +} diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.ts similarity index 69% rename from open-sse/handlers/chatCore/sseToJsonHandler.js rename to open-sse/handlers/chatCore/sseToJsonHandler.ts index aac3248b..a730d5b8 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.ts @@ -5,12 +5,110 @@ import { FORMATS } from "../../translator/formats.js"; import { createErrorResult } from "../../utils/error.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; -function textFromResponsesMessageItem(item) { +type ForcedSSEToJsonResult = + | { success: true; response: Response } + | ReturnType; + +type UsageInfo = Record & { + completion_tokens?: number; + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; +}; + +type ResponsesContentItem = { text?: string; type?: string }; + +type ResponsesOutputItem = { + arguments?: unknown; + call_id?: string; + content?: ResponsesContentItem[]; + name?: string; + type?: string; +}; + +type ResponsesJson = { + created_at?: number; + id?: string; + model?: string; + output?: ResponsesOutputItem[]; + status?: string; + usage?: UsageInfo; +}; + +type ChatToolCall = { + function: { arguments: string; name: string }; + id: string; + type: "function"; +}; + +type ChatDeltaToolCall = { + function?: { arguments?: string; name?: string }; + id?: string; + index?: number; +}; + +type ChatStreamChunk = { + choices?: { + delta?: { + content?: string; + reasoning_content?: string; + tool_calls?: ChatDeltaToolCall[]; + }; + finish_reason?: string; + }[]; + created?: number; + id?: string; + model?: string; + usage?: UsageInfo; +}; + +type ChatCompletionResponse = { + choices: { + finish_reason: string; + index: number; + message: { + content: string | null; + reasoning_content?: string; + role: "assistant"; + tool_calls?: ChatToolCall[]; + }; + }[]; + created: number; + id: string; + model: string; + object: "chat.completion"; + usage?: UsageInfo; +}; + +type ForcedSSEToJsonParams = { + apiKey?: string; + appendLog: (entry: { detailsId: string; status: string; tokens: UsageInfo }) => void; + body: Record; + clientRawRequest?: { endpoint?: string }; + connectionId?: string; + finalBody?: unknown; + model: string; + onFinalJsonResponse?: (response: unknown, usage: unknown) => void; + onRequestSuccess?: () => Promise | void; + provider: string; + providerResponse: Response; + requestStartTime: number; + sourceFormat: string; + stream: boolean; + trackDone: () => void; + translatedBody?: unknown; +}; + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +function textFromResponsesMessageItem(item: ResponsesOutputItem) { if (!item?.content || !Array.isArray(item.content)) return ""; const byType = item.content.find((c) => c.type === "output_text"); if (typeof byType?.text === "string") return byType.text; - const anyText = item.content.find((c) => typeof c.text === "string"); - if (typeof anyText?.text === "string") return anyText.text; + const textItem = item.content.find((c) => typeof c.text === "string"); + if (typeof textItem?.text === "string") return textItem.text; return ""; } @@ -18,15 +116,18 @@ function textFromResponsesMessageItem(item) { * Codex / Responses API may emit many alternating reasoning + message items. * Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message. */ -function pickAssistantMessageForChatCompletion(output) { +function pickAssistantMessageForChatCompletion(output: unknown) { if (!Array.isArray(output)) return { msgItem: null, textContent: null }; - const messages = output.filter((item) => item?.type === "message"); + const messages = (output as ResponsesOutputItem[]).filter((item) => item?.type === "message"); if (messages.length === 0) return { msgItem: null, textContent: null }; for (let i = messages.length - 1; i >= 0; i--) { - const text = textFromResponsesMessageItem(messages[i]); - if (text.length > 0) return { msgItem: messages[i], textContent: text }; + const message = messages[i]; + if (!message) continue; + const text = textFromResponsesMessageItem(message); + if (text.length > 0) return { msgItem: message, textContent: text }; } const last = messages[messages.length - 1]; + if (!last) return { msgItem: null, textContent: null }; return { msgItem: last, textContent: textFromResponsesMessageItem(last) }; } @@ -34,8 +135,8 @@ function pickAssistantMessageForChatCompletion(output) { * Parse OpenAI-style SSE text into a single chat completion JSON. * Used when provider forces streaming but client wants non-streaming. */ -export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { - const chunks = []; +export function parseSSEToOpenAIResponse(rawSSE: unknown, fallbackModel: string) { + const chunks: ChatStreamChunk[] = []; for (const line of String(rawSSE || "").split("\n")) { const trimmed = line.trim(); @@ -43,7 +144,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; try { - chunks.push(JSON.parse(payload)); + chunks.push(JSON.parse(payload) as ChatStreamChunk); } catch { /* ignore malformed lines */ } @@ -51,12 +152,12 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { if (chunks.length === 0) return null; - const first = chunks[0]; - const contentParts = []; - const reasoningParts = []; - const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } + const first = chunks[0]!; + const contentParts: string[] = []; + const reasoningParts: string[] = []; + const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } let finishReason = "stop"; - let usage = null; + let usage: UsageInfo | null = null; for (const chunk of chunks) { const choice = chunk?.choices?.[0]; @@ -80,6 +181,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { }); } const existing = toolCallMap.get(idx); + if (!existing) continue; if (tc.id) existing.id = tc.id; if (tc.function?.name) existing.function.name += tc.function.name; if (tc.function?.arguments) existing.function.arguments += tc.function.arguments; @@ -87,7 +189,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { } } - const message = { + const message: ChatCompletionResponse["choices"][number]["message"] = { role: "assistant", content: contentParts.join("") || (toolCallMap.size > 0 ? null : ""), }; @@ -96,7 +198,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc); } - const result = { + const result: ChatCompletionResponse = { id: first.id || `chatcmpl-${Date.now()}`, object: "chat.completion", created: first.created || Math.floor(Date.now() / 1000), @@ -135,7 +237,7 @@ export async function handleForcedSSEToJson({ trackDone, appendLog, onFinalJsonResponse, -}) { +}: ForcedSSEToJsonParams): Promise { const contentType = providerResponse.headers.get("content-type") || ""; const isSSE = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex"); @@ -155,7 +257,9 @@ export async function handleForcedSSEToJson({ const isCodexResponsesApi = provider === "codex" || sourceFormat === FORMATS.OPENAI_RESPONSES; if (isCodexResponsesApi) { try { - const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); + const jsonResponse = (await convertResponsesStreamToJson( + providerResponse.body, + )) as ResponsesJson; if (onRequestSuccess) await onRequestSuccess(); const usage = jsonResponse.usage || {}; @@ -192,7 +296,9 @@ export async function handleForcedSSEToJson({ }, { endpoint: clientRawRequest?.endpoint || null }, ), - ).catch(() => {}); + ).catch(() => { + // Best-effort request detail; response conversion should not fail on logging. + }); // Client is Responses API → return as-is if (sourceFormat === FORMATS.OPENAI_RESPONSES) { @@ -212,17 +318,17 @@ export async function handleForcedSSEToJson({ // Build client-format response const inTokens = usage.input_tokens || 0; const outTokens = usage.output_tokens || 0; - let finalResp; + let finalResp: unknown; // Extract tool calls from Responses API output (function_call items) const funcCallItems = (jsonResponse.output || []).filter( (item) => item.type === "function_call", ); - const toolCalls = funcCallItems.map((item, idx) => ({ + const toolCalls: ChatToolCall[] = funcCallItems.map((item, idx) => ({ id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`, type: "function", function: { - name: item.name, + name: item.name as string, arguments: typeof item.arguments === "string" ? item.arguments @@ -255,7 +361,10 @@ export async function handleForcedSSEToJson({ }, }; } else { - const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") }; + const message: ChatCompletionResponse["choices"][number]["message"] = { + role: "assistant", + content: textContent || (hasToolCalls ? null : ""), + }; if (hasToolCalls) message.tool_calls = toolCalls; const finishReason = hasToolCalls ? "tool_calls" @@ -277,7 +386,11 @@ export async function handleForcedSSEToJson({ } try { - onFinalJsonResponse?.(finalResp, finalResp?.usage || usage || null); + const finalUsage = + finalResp && typeof finalResp === "object" && "usage" in finalResp + ? (finalResp as { usage?: unknown }).usage + : undefined; + onFinalJsonResponse?.(finalResp, finalUsage || usage || null); } catch { // best effort } @@ -288,11 +401,16 @@ export async function handleForcedSSEToJson({ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, }), }; - } catch { - console.error("[ChatCore] Responses API SSE→JSON failed"); + } catch (error: unknown) { + console.error( + isAbortError(error) + ? "[ChatCore] Responses API SSE→JSON aborted" + : "[ChatCore] Responses API SSE→JSON failed", + ); return createErrorResult( HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON", + undefined, ); } } @@ -305,6 +423,7 @@ export async function handleForcedSSEToJson({ return createErrorResult( HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request", + undefined, ); if (onRequestSuccess) await onRequestSuccess(); @@ -338,13 +457,15 @@ export async function handleForcedSSEToJson({ }, { endpoint: clientRawRequest?.endpoint || null }, ), - ).catch(() => {}); + ).catch(() => { + // Best-effort request detail; response conversion should not fail on logging. + }); // Preserve reasoning_content even when content is non-empty so clients that // expose a dedicated thinking panel can always consume it. try { - onFinalJsonResponse?.(parsed, usage || parsed?.usage || null); + onFinalJsonResponse?.(parsed, usage || parsed.usage || null); } catch { // best effort } @@ -355,11 +476,16 @@ export async function handleForcedSSEToJson({ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, }), }; - } catch { - console.error("[ChatCore] Chat Completions SSE→JSON failed"); + } catch (error: unknown) { + console.error( + isAbortError(error) + ? "[ChatCore] Chat Completions SSE→JSON aborted" + : "[ChatCore] Chat Completions SSE→JSON failed", + ); return createErrorResult( HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON", + undefined, ); } } diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.ts similarity index 70% rename from open-sse/handlers/chatCore/streamingHandler.js rename to open-sse/handlers/chatCore/streamingHandler.ts index 53b2c226..4af640d2 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.ts @@ -8,6 +8,78 @@ import { import { pipeWithDisconnect } from "../../utils/streamHandler.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; +type JsonRecord = Record; + +type StreamContent = { content?: string; thinking?: string | null }; + +type RequestLoggerLike = { + appendConvertedChunk?: (chunk: string) => void; + appendOpenAIChunk?: (chunk: string) => void; + appendProviderChunk?: (chunk: string) => void; +}; + +type StreamCompleteHandler = ( + contentObj: StreamContent, + usage: unknown, + ttftAt: number | null, +) => void; + +type BuildTransformStreamParams = { + provider: string; + sourceFormat: string; + targetFormat: string; + userAgent?: string; + reqLogger?: RequestLoggerLike | null; + toolNameMap?: unknown; + model: string; + connectionId?: string; + body: JsonRecord; + onStreamComplete?: StreamCompleteHandler | null; + apiKey?: string | null; +}; + +type StreamingResponseParams = { + providerResponse: Response; + provider: string; + model: string; + sourceFormat: string; + targetFormat: string; + userAgent?: string; + body: JsonRecord; + stream: boolean; + translatedBody?: unknown; + finalBody?: unknown; + requestStartTime: number; + connectionId?: string; + apiKey?: string | null; + clientRawRequest?: { endpoint?: string } | null; + onRequestSuccess?: () => Promise | void; + reqLogger?: RequestLoggerLike | null; + toolNameMap?: unknown; + streamController?: unknown; + onStreamComplete?: StreamCompleteHandler | null; +}; + +type BuildOnStreamCompleteParams = { + provider: string; + model: string; + connectionId?: string; + apiKey?: string | null; + requestStartTime: number; + body: JsonRecord; + stream: boolean; + finalBody?: unknown; + translatedBody?: unknown; + clientRawRequest?: { endpoint?: string } | null; +}; + +type UsageTokensLike = { + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; +}; + const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -15,6 +87,10 @@ const SSE_HEADERS = { "Access-Control-Allow-Origin": "*", }; +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + /** * Determine which SSE transform stream to use based on provider/format. */ @@ -30,7 +106,7 @@ function buildTransformStream({ body, onStreamComplete, apiKey, -}) { +}: BuildTransformStreamParams) { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); const needsCodexTranslation = @@ -107,13 +183,13 @@ export function handleStreamingResponse({ requestStartTime, connectionId, apiKey, - clientRawRequest, + clientRawRequest: _clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, -}) { +}: StreamingResponseParams): { success: true; response: Response } { if (onRequestSuccess) onRequestSuccess(); const transformStream = buildTransformStream({ @@ -148,8 +224,8 @@ export function handleStreamingResponse({ }, { id: streamDetailId }, ), - ).catch((err) => { - console.error("[RequestDetail] Failed to save streaming request:", err.message); + ).catch((err: unknown) => { + console.error("[RequestDetail] Failed to save streaming request:", errorMessage(err)); }); return { @@ -172,10 +248,10 @@ export function buildOnStreamComplete({ finalBody, translatedBody, clientRawRequest, -}) { +}: BuildOnStreamCompleteParams) { const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - const onStreamComplete = (contentObj, usage, ttftAt) => { + const onStreamComplete: StreamCompleteHandler = (contentObj, usage, ttftAt) => { const latency = { ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, total: Date.now() - requestStartTime, @@ -199,14 +275,14 @@ export function buildOnStreamComplete({ }, { id: streamDetailId }, ), - ).catch((err) => { - console.error("[RequestDetail] Failed to update streaming content:", err.message); + ).catch((err: unknown) => { + console.error("[RequestDetail] Failed to update streaming content:", errorMessage(err)); }); saveUsageStats({ provider, model, - tokens: usage, + tokens: (usage as UsageTokensLike | null | undefined) || null, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, diff --git a/open-sse/handlers/embeddingProviders/_base.js b/open-sse/handlers/embeddingProviders/_base.js deleted file mode 100644 index fc3e1165..00000000 --- a/open-sse/handlers/embeddingProviders/_base.js +++ /dev/null @@ -1,4 +0,0 @@ -// Shared embedding helpers -export function bearerAuth(creds) { - return { Authorization: `Bearer ${creds.apiKey || creds.accessToken}` }; -} diff --git a/open-sse/handlers/embeddingProviders/_base.ts b/open-sse/handlers/embeddingProviders/_base.ts new file mode 100644 index 00000000..039a7f32 --- /dev/null +++ b/open-sse/handlers/embeddingProviders/_base.ts @@ -0,0 +1,11 @@ +// Shared embedding helpers +export type EmbeddingCredentials = { + apiKey?: string; + accessToken?: string; + baseUrl?: string; + providerSpecificData?: { baseUrl?: string }; +} | null; + +export function bearerAuth(creds: EmbeddingCredentials) { + return { Authorization: `Bearer ${creds?.apiKey || creds?.accessToken}` }; +} diff --git a/open-sse/handlers/embeddingProviders/gemini.js b/open-sse/handlers/embeddingProviders/gemini.ts similarity index 60% rename from open-sse/handlers/embeddingProviders/gemini.js rename to open-sse/handlers/embeddingProviders/gemini.ts index fdd15b0e..febd781e 100644 --- a/open-sse/handlers/embeddingProviders/gemini.js +++ b/open-sse/handlers/embeddingProviders/gemini.ts @@ -1,22 +1,37 @@ // Google Gemini embeddings — embedContent / batchEmbedContents +import type { EmbeddingCredentials } from "./_base.js"; + const BASE = "https://generativelanguage.googleapis.com/v1beta"; -function modelPath(model) { +type GeminiEmbeddingContext = { input?: string | string[] }; +type GeminiEmbeddingBody = { dimensions?: unknown; input?: string | string[] }; +type GeminiEmbeddingResponse = { + data?: unknown[]; + embedding?: { values?: unknown[] }; + embeddings?: { values?: unknown[] }[]; + object?: string; +}; + +function modelPath(model: string) { return model.startsWith("models/") ? model : `models/${model}`; } export default { - buildUrl: (model, creds, { input } = {}) => { - const apiKey = creds.apiKey || creds.accessToken; + buildUrl: ( + model: string, + creds: EmbeddingCredentials, + { input }: GeminiEmbeddingContext = {}, + ) => { + const apiKey = creds?.apiKey || creds?.accessToken; const path = modelPath(model); const op = Array.isArray(input) ? "batchEmbedContents" : "embedContent"; - return `${BASE}/${path}:${op}?key=${encodeURIComponent(apiKey)}`; + return `${BASE}/${path}:${op}?key=${encodeURIComponent(String(apiKey))}`; }, buildHeaders: () => ({ "Content-Type": "application/json" }), - buildBody: (model, { input, dimensions } = {}) => { + buildBody: (model: string, { input, dimensions }: GeminiEmbeddingBody = {}) => { const m = modelPath(model); - let outputDimensionality; - if (dimensions != null && dimensions !== "") { + let outputDimensionality: number | undefined; + if (dimensions !== null && dimensions !== undefined && dimensions !== "") { const dim = Number(dimensions); if (Number.isFinite(dim) && dim > 0) outputDimensionality = dim; } @@ -35,9 +50,9 @@ export default { ...(outputDimensionality ? { outputDimensionality } : {}), }; }, - normalize: (responseBody, model) => { + normalize: (responseBody: GeminiEmbeddingResponse, model: string) => { if (responseBody.object === "list" && Array.isArray(responseBody.data)) return responseBody; - let items = []; + let items: { embedding: unknown[]; index: number; object: "embedding" }[] = []; if (Array.isArray(responseBody.embeddings)) { items = responseBody.embeddings.map((emb, idx) => ({ object: "embedding", diff --git a/open-sse/handlers/embeddingProviders/index.js b/open-sse/handlers/embeddingProviders/index.ts similarity index 62% rename from open-sse/handlers/embeddingProviders/index.js rename to open-sse/handlers/embeddingProviders/index.ts index 946221ba..c7f2d34a 100644 --- a/open-sse/handlers/embeddingProviders/index.js +++ b/open-sse/handlers/embeddingProviders/index.ts @@ -17,18 +17,25 @@ const OPENAI_COMPAT_PROVIDERS = [ "jina-ai", ]; +type EmbeddingAdapter = { + buildBody: (...args: unknown[]) => unknown; + buildHeaders: (...args: unknown[]) => HeadersInit; + buildUrl: (...args: unknown[]) => string; + normalize: (...args: unknown[]) => Record; +}; + const ADAPTERS = { ...Object.fromEntries( OPENAI_COMPAT_PROVIDERS.map((id) => [id, createOpenAIEmbeddingAdapter(id)]), ), gemini, google_ai_studio: gemini, -}; +} as unknown as Record; -export function getEmbeddingAdapter(provider) { +export function getEmbeddingAdapter(provider: string): EmbeddingAdapter | null { if (ADAPTERS[provider]) return ADAPTERS[provider]; if (provider?.startsWith?.("openai-compatible-") || provider?.startsWith?.("custom-embedding-")) { - return openaiCompatNode; + return openaiCompatNode as unknown as EmbeddingAdapter; } return null; } diff --git a/open-sse/handlers/embeddingProviders/openai.js b/open-sse/handlers/embeddingProviders/openai.ts similarity index 59% rename from open-sse/handlers/embeddingProviders/openai.js rename to open-sse/handlers/embeddingProviders/openai.ts index c89cd9d5..a5063080 100644 --- a/open-sse/handlers/embeddingProviders/openai.js +++ b/open-sse/handlers/embeddingProviders/openai.ts @@ -1,7 +1,7 @@ // OpenAI-compatible embeddings adapter (most providers) -import { bearerAuth } from "./_base.js"; +import { type EmbeddingCredentials, bearerAuth } from "./_base.js"; -const ENDPOINTS = { +const ENDPOINTS: Record = { openai: "https://api.openai.com/v1/embeddings", openrouter: "https://openrouter.ai/api/v1/embeddings", mistral: "https://api.mistral.ai/v1/embeddings", @@ -14,26 +14,35 @@ const ENDPOINTS = { "jina-ai": "https://api.jina.ai/v1/embeddings", }; -export default function createOpenAIEmbeddingAdapter(providerId) { +type EmbeddingBodyParams = { + dimensions?: unknown; + encoding_format?: string; + input: string | string[]; +}; + +export default function createOpenAIEmbeddingAdapter(providerId: string) { return { buildUrl: () => ENDPOINTS[providerId], - buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json", ...bearerAuth(creds) }; + buildHeaders: (creds: EmbeddingCredentials) => { + const headers: Record = { + "Content-Type": "application/json", + ...bearerAuth(creds), + }; if (providerId === "openrouter") { headers["HTTP-Referer"] = "https://endpoint-proxy.local"; headers["X-Title"] = "Endpoint Proxy"; } return headers; }, - buildBody: (model, { input, encoding_format, dimensions }) => { - const body = { model, input }; + buildBody: (model: string, { input, encoding_format, dimensions }: EmbeddingBodyParams) => { + const body: Record = { model, input }; if (encoding_format) body.encoding_format = encoding_format; - if (dimensions != null && dimensions !== "") { + if (dimensions !== null && dimensions !== undefined && dimensions !== "") { const dim = Number(dimensions); if (Number.isFinite(dim) && dim > 0) body.dimensions = dim; } return body; }, - normalize: (responseBody) => responseBody, + normalize: (responseBody: unknown) => responseBody, }; } diff --git a/open-sse/handlers/embeddingProviders/openaiCompatNode.js b/open-sse/handlers/embeddingProviders/openaiCompatNode.js deleted file mode 100644 index 6581b457..00000000 --- a/open-sse/handlers/embeddingProviders/openaiCompatNode.js +++ /dev/null @@ -1,13 +0,0 @@ -// Custom node providers (openai-compatible-* / custom-embedding-*) — baseUrl from credentials -import createOpenAIEmbeddingAdapter from "./openai.js"; - -const baseAdapter = createOpenAIEmbeddingAdapter("openai"); - -export default { - ...baseAdapter, - buildUrl: (_model, creds) => { - const rawBaseUrl = creds?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; - const baseUrl = rawBaseUrl.replace(/\/$/, "").replace(/\/embeddings$/, ""); - return `${baseUrl}/embeddings`; - }, -}; diff --git a/open-sse/handlers/embeddingProviders/openaiCompatNode.ts b/open-sse/handlers/embeddingProviders/openaiCompatNode.ts new file mode 100644 index 00000000..bb0f1d5a --- /dev/null +++ b/open-sse/handlers/embeddingProviders/openaiCompatNode.ts @@ -0,0 +1,15 @@ +// Custom/OpenAI-compatible embedding node adapter +import type { EmbeddingCredentials } from "./_base.js"; +import createOpenAIEmbeddingAdapter from "./openai.js"; + +const base = createOpenAIEmbeddingAdapter("openai"); + +export default { + ...base, + buildUrl: (_model: string, creds: EmbeddingCredentials) => { + const baseUrl = + creds?.providerSpecificData?.baseUrl || creds?.baseUrl || "https://api.openai.com/v1"; + // ponytail: restore idempotent de-dup — old code stripped /embeddings before re-appending. + return baseUrl.replace(/\/+$/, "").replace(/\/embeddings$/, "") + "/embeddings"; + }, +}; diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.ts similarity index 64% rename from open-sse/handlers/embeddingsCore.js rename to open-sse/handlers/embeddingsCore.ts index 0649aa90..c1c06b8b 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.ts @@ -1,14 +1,41 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { getExecutor } from "../executors/index.js"; +import type { ExecutorCredentials } from "../executors/base.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; -import { createErrorResult, formatProviderError, parseUpstreamError } from "../utils/error.js"; +import { + createErrorResult, + formatProviderError, + parseUpstreamError, + type ErrorResult, +} from "../utils/error.js"; import { getEmbeddingAdapter } from "./embeddingProviders/index.js"; +type JsonRecord = Record; + +type EmbeddingsLogger = { + debug?: (tag: string, message: string, data?: unknown) => void; + info?: (tag: string, message: string, data?: unknown) => void; + warn?: (tag: string, message: string, data?: unknown) => void; +}; + +export type EmbeddingsResult = { success: true; response: Response } | ErrorResult; + +export interface EmbeddingsCoreParams { + body: JsonRecord & { + input?: unknown; + encoding_format?: string; + dimensions?: number; + }; + modelInfo: { provider: string; model: string }; + credentials: JsonRecord | null; + log: EmbeddingsLogger | null; + onCredentialsRefreshed?: (newCreds: JsonRecord) => Promise | void; + onRequestSuccess?: () => Promise | void; +} + /** * Core embeddings handler — orchestrator only. Provider-specific URL/headers/body/normalize * live in `./embeddingProviders/{id}.js`. - * - * @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>} */ export async function handleEmbeddingsCore({ body, @@ -17,16 +44,20 @@ export async function handleEmbeddingsCore({ log, onCredentialsRefreshed, onRequestSuccess, -}) { +}: EmbeddingsCoreParams): Promise { const { provider, model } = modelInfo; // Validate input const input = body.input; if (!input) { - return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input", undefined); } if (typeof input !== "string" && !Array.isArray(input)) { - return createErrorResult(HTTP_STATUS.BAD_REQUEST, "input must be a string or array of strings"); + return createErrorResult( + HTTP_STATUS.BAD_REQUEST, + "input must be a string or array of strings", + undefined, + ); } const adapter = getEmbeddingAdapter(provider); @@ -34,6 +65,7 @@ export async function handleEmbeddingsCore({ return createErrorResult( HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support embeddings.`, + undefined, ); } @@ -58,10 +90,10 @@ export async function handleEmbeddingsCore({ headers, body: JSON.stringify(requestBody), }); - } catch (error) { + } catch (error: unknown) { const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); log?.debug?.("EMBEDDINGS", `Fetch error: ${errMsg}`); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg, undefined); } // Handle 401/403 — try token refresh (skip for noAuth providers) @@ -72,14 +104,29 @@ export async function handleEmbeddingsCore({ providerResponse.status === HTTP_STATUS.FORBIDDEN) ) { const newCredentials = await refreshWithRetry( - () => executor.refreshCredentials(credentials, log), + async () => { + const refreshed = await executor.refreshCredentials( + (credentials ?? {}) as ExecutorCredentials, + log, + ); + return refreshed as + | (Record & { + accessToken?: string; + apiKey?: string; + refreshToken?: string; + expiresIn?: number; + expiresAt?: number; + token?: string; + }) + | null; + }, 3, log, ); if (newCredentials?.accessToken || newCredentials?.apiKey) { log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for embeddings`); - Object.assign(credentials, newCredentials); + if (credentials) Object.assign(credentials, newCredentials); if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); try { @@ -102,14 +149,18 @@ export async function handleEmbeddingsCore({ const { statusCode, message } = await parseUpstreamError(providerResponse); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`); - return createErrorResult(statusCode, errMsg); + return createErrorResult(statusCode, errMsg, undefined); } let responseBody; try { responseBody = await providerResponse.json(); } catch { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + `Invalid JSON response from ${provider}`, + undefined, + ); } if (onRequestSuccess) await onRequestSuccess(); diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.ts similarity index 56% rename from open-sse/handlers/fetch/index.js rename to open-sse/handlers/fetch/index.ts index fc2cd21f..23a2b8df 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.ts @@ -4,60 +4,142 @@ const DEFAULT_TIMEOUT_MS = 15000; const DEFAULT_FORMAT = "markdown"; -/** - * @typedef {Object} FetchResult - * @property {boolean} success - * @property {number} [status] - * @property {string} [error] - * @property {Object} [data] - */ +export type FetchResult = + | { success: true; data: unknown; response?: Response } + | { success: false; status: number; error: string }; + +type JsonRecord = Record; + +type FetchCredentials = JsonRecord & { + apiKey?: string; + key?: string; + token?: string; +}; + +type FetchProviderConfig = JsonRecord & { + timeoutMs?: number; + costPerQuery?: number | null; +}; + +export interface FetchCoreParams { + url: string; + format?: string; + maxCharacters?: number; + provider: string; + providerConfig: FetchProviderConfig | null; + credentials: FetchCredentials | null; + log?: unknown; + onCredentialsRefreshed?: (newCreds: JsonRecord) => Promise | void; + onRequestSuccess?: () => Promise | void; +} + +type ProviderRunParams = { + url: string; + fmt: string; + timeoutMs: number; + apiKey: string; + maxCharacters?: number; + costPerQuery: number | null; + startedAt: number; +}; + +type BuildDataParams = { + provider: string; + url: string; + title: string | null; + format: string; + text: string; + costUsd: number | null; + responseMs: number; + upstreamMs: number; +}; + +type TryFetchOk = { ok: true; res: Response }; +type TryFetchErr = { ok: false; timeout: boolean; error: string }; +type TryFetchResult = TryFetchOk | TryFetchErr; + +type ReadJsonResult = { json?: JsonRecord; text?: string }; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -/** - * Fetch with timeout abort. - * @param {string} url - * @param {RequestInit} init - * @param {number} timeoutMs - */ // Strip non-ASCII chars from header values (HTTP headers must be ByteString). -function sanitizeHeaders(headers) { +function stripNonAscii(text: string) { + let clean = ""; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) <= 255) clean += text[i] ?? ""; + } + return clean; +} + +function sanitizeHeaders(headers: unknown) { if (!headers) return headers; - const out = {}; - for (const [k, v] of Object.entries(headers)) { - out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + const out: Record = {}; + for (const [k, v] of Object.entries(headers as Record)) { + out[k] = typeof v === "string" ? stripNonAscii(v).trim() : v; } return out; } -async function tryFetch(url, init, timeoutMs) { +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +function callLog(log: unknown, ...args: unknown[]) { + if (typeof log === "function") { + (log as (...a: unknown[]) => void)(...args); + } +} + +async function tryFetch( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { ...init, - headers: sanitizeHeaders(init.headers), + headers: sanitizeHeaders(init.headers) as HeadersInit, signal: ctrl.signal, }); return { ok: true, res }; - } catch (err) { - const isAbort = err?.name === "AbortError"; - return { ok: false, timeout: isAbort, error: err?.message || String(err) }; + } catch (err: unknown) { + const isAbort = isAbortError(err); + return { ok: false, timeout: isAbort, error: errorMessage(err) }; } finally { clearTimeout(timer); } } -function truncate(text, max) { - if (!text || typeof text !== "string") return text || ""; +function truncate(text: unknown, max?: number): string { + if (!text || typeof text !== "string") return (text as string) || ""; if (!max || max <= 0) return text; return text.length > max ? text.slice(0, max) : text; } -function parseJinaTitle(text) { +function parseJinaTitle(text: unknown): string | null { const m = String(text || "").match(/^\s*#\s+(.+)$/m); - return m ? m[1].trim() : null; + const title = m?.[1]; + return title ? title.trim() : null; } -function buildData({ provider, url, title, format, text, costUsd, responseMs, upstreamMs }) { +function buildData({ + provider, + url, + title, + format, + text, + costUsd, + responseMs, + upstreamMs, +}: BuildDataParams) { return { provider, url, @@ -69,12 +151,13 @@ function buildData({ provider, url, title, format, text, costUsd, responseMs, up }; } -async function readJsonOrText(res) { +async function readJsonOrText(res: Response): Promise { const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) { try { - return { json: await res.json() }; + return { json: (await res.json()) as JsonRecord }; } catch { + // Treat malformed provider JSON as an empty text fallback. return { text: "" }; } } @@ -83,15 +166,6 @@ async function readJsonOrText(res) { /** * Main handler. - * @param {Object} params - * @param {string} params.url - * @param {string} [params.format] - * @param {number} [params.maxCharacters] - * @param {string} params.provider - * @param {Object} [params.providerConfig] - * @param {Object} [params.credentials] - * @param {Function} [params.log] - * @returns {Promise} */ export async function handleFetchCore({ url, @@ -101,7 +175,7 @@ export async function handleFetchCore({ providerConfig, credentials, log, -}) { +}: FetchCoreParams): Promise { if (!url || typeof url !== "string") { return { success: false, status: 400, error: "url is required" }; } @@ -110,9 +184,11 @@ export async function handleFetchCore({ } const fmt = format || DEFAULT_FORMAT; - const timeoutMs = providerConfig?.timeoutMs || DEFAULT_TIMEOUT_MS; - const apiKey = credentials?.apiKey || credentials?.key || credentials?.token || ""; - const costPerQuery = providerConfig?.costPerQuery ?? null; + const timeoutMs = + typeof providerConfig?.timeoutMs === "number" ? providerConfig.timeoutMs : DEFAULT_TIMEOUT_MS; + const apiKey = String(credentials?.apiKey || credentials?.key || credentials?.token || ""); + const costPerQuery = + providerConfig?.costPerQuery === undefined ? null : (providerConfig.costPerQuery ?? null); const startedAt = Date.now(); try { @@ -145,9 +221,9 @@ export async function handleFetchCore({ return await runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); } return { success: false, status: 400, error: `Unsupported provider: ${provider}` }; - } catch (err) { - log?.("fetch handler error:", err?.message || err); - return { success: false, status: 502, error: err?.message || "Internal fetch error" }; + } catch (err: unknown) { + callLog(log, "fetch handler error:", errorMessage(err)); + return { success: false, status: 502, error: errorMessage(err) || "Internal fetch error" }; } } @@ -159,7 +235,7 @@ async function runFirecrawl({ maxCharacters, costPerQuery, startedAt, -}) { +}: ProviderRunParams): Promise { const upstreamStart = Date.now(); const r = await tryFetch( "https://api.firecrawl.dev/v1/scrape", @@ -183,10 +259,16 @@ async function runFirecrawl({ return { success: false, status: r.res.status, - error: json?.error || `Firecrawl error: ${r.res.status}`, + error: + (typeof json?.error === "string" ? json.error : null) || `Firecrawl error: ${r.res.status}`, }; } - const d = json?.data || {}; + const d = (isRecord(json?.data) ? json.data : {}) as JsonRecord & { + markdown?: string; + html?: string; + text?: string; + metadata?: { title?: string }; + }; const text = truncate(d.markdown || d.html || d.text || "", maxCharacters); const title = d.metadata?.title || null; return { @@ -204,7 +286,15 @@ async function runFirecrawl({ }; } -async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { +async function runJina({ + url, + fmt, + timeoutMs, + apiKey, + maxCharacters, + costPerQuery, + startedAt, +}: ProviderRunParams): Promise { const target = `https://r.jina.ai/${encodeURIComponent(url)}`; const upstreamStart = Date.now(); const r = await tryFetch( @@ -244,7 +334,15 @@ async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuer }; } -async function runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { +async function runTavily({ + url, + fmt, + timeoutMs, + apiKey, + maxCharacters, + costPerQuery, + startedAt, +}: ProviderRunParams): Promise { const upstreamStart = Date.now(); const r = await tryFetch( "https://api.tavily.com/extract", @@ -268,10 +366,12 @@ async function runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQu return { success: false, status: r.res.status, - error: json?.error || `Tavily error: ${r.res.status}`, + error: + (typeof json?.error === "string" ? json.error : null) || `Tavily error: ${r.res.status}`, }; } - const first = json?.results?.[0] || {}; + const results = Array.isArray(json?.results) ? json.results : []; + const first = (isRecord(results[0]) ? results[0] : {}) as JsonRecord & { raw_content?: string }; const text = truncate(first.raw_content || "", maxCharacters); return { success: true, @@ -288,7 +388,15 @@ async function runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQu }; } -async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { +async function runExa({ + url, + fmt, + timeoutMs, + apiKey, + maxCharacters, + costPerQuery, + startedAt, +}: ProviderRunParams): Promise { const upstreamStart = Date.now(); const r = await tryFetch( "https://api.exa.ai/contents", @@ -312,10 +420,14 @@ async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery return { success: false, status: r.res.status, - error: json?.error || `Exa error: ${r.res.status}`, + error: (typeof json?.error === "string" ? json.error : null) || `Exa error: ${r.res.status}`, }; } - const first = json?.results?.[0] || {}; + const results = Array.isArray(json?.results) ? json.results : []; + const first = (isRecord(results[0]) ? results[0] : {}) as JsonRecord & { + text?: string; + title?: string; + }; const text = truncate(first.text || "", maxCharacters); return { success: true, diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.ts similarity index 61% rename from open-sse/handlers/imageGenerationCore.js rename to open-sse/handlers/imageGenerationCore.ts index e3e4cd4a..f8a1d616 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.ts @@ -1,30 +1,54 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { getExecutor } from "../executors/index.js"; +import type { ExecutorCredentials } from "../executors/base.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; -import { createErrorResult, formatProviderError, parseUpstreamError } from "../utils/error.js"; -import { urlToBase64 } from "./imageProviders/_base.js"; +import { + createErrorResult, + formatProviderError, + parseUpstreamError, + type ErrorResult, +} from "../utils/error.js"; +import { + urlToBase64, + type ImageRequestBody, + type ProviderCredentials, +} from "./imageProviders/_base.js"; import { getImageAdapter } from "./imageProviders/index.js"; -function serializeRequestBody(requestBody) { +type JsonRecord = Record; + +type ImageLogger = { + debug?: (...args: unknown[]) => void; + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; +}; + +export type ImageGenResult = { success: true; response: Response } | ErrorResult; + +export interface ImageGenCoreParams { + body: ImageRequestBody; + modelInfo: { provider: string; model: string }; + credentials: ProviderCredentials; + log?: ImageLogger | null; + binaryOutput?: boolean; + streamToClient?: boolean; + onCredentialsRefreshed?: (newCreds: JsonRecord) => Promise | void; + onRequestSuccess?: () => Promise | void; +} + +function serializeRequestBody(requestBody: unknown) { if (typeof FormData !== "undefined" && requestBody instanceof FormData) return requestBody; if (typeof requestBody === "string") return requestBody; return JSON.stringify(requestBody); } +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + /** * Core image generation handler — orchestrator only. * Provider-specific URL/headers/body/parse/normalize live in `./imageProviders/{id}.js`. - * - * @param {object} options - * @param {object} options.body - Request body { model, prompt, n, size, ... } - * @param {object} options.modelInfo - { provider, model } - * @param {object} options.credentials - Provider credentials - * @param {object} [options.log] - Logger - * @param {boolean} [options.streamToClient] - Pipe SSE to client (codex) - * @param {boolean} [options.binaryOutput] - Return raw image bytes - * @param {function} [options.onCredentialsRefreshed] - * @param {function} [options.onRequestSuccess] - * @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>} */ export async function handleImageGenerationCore({ body, @@ -35,11 +59,11 @@ export async function handleImageGenerationCore({ binaryOutput = false, onCredentialsRefreshed, onRequestSuccess, -}) { +}: ImageGenCoreParams): Promise { const { provider, model } = modelInfo; if (!body.prompt) { - return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt"); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt", undefined); } const adapter = getImageAdapter(provider); @@ -47,6 +71,7 @@ export async function handleImageGenerationCore({ return createErrorResult( HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support image generation`, + undefined, ); } @@ -55,19 +80,20 @@ export async function handleImageGenerationCore({ let requestBody; try { - url = adapter.buildUrl(model, credentials); + url = adapter.buildUrl(model, credentials) as string; requestBody = await adapter.buildBody(model, body); headers = adapter.buildHeaders(credentials, requestBody, model, body); - } catch (error) { + } catch (error: unknown) { return createErrorResult( HTTP_STATUS.BAD_REQUEST, - error.message || `Invalid ${provider} image request`, + errorMessage(error) || `Invalid ${provider} image request`, + undefined, ); } log?.debug?.( "IMAGE", - `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..."`, + `${provider.toUpperCase()} | ${model} | prompt="${String(body.prompt).slice(0, 50)}..."`, ); let providerResponse; @@ -77,10 +103,10 @@ export async function handleImageGenerationCore({ headers, body: serializeRequestBody(requestBody), }); - } catch (error) { + } catch (error: unknown) { const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); log?.debug?.("IMAGE", `Fetch error: ${errMsg}`); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg, undefined); } // Handle 401/403 — try token refresh (skipped for noAuth providers) @@ -92,20 +118,35 @@ export async function handleImageGenerationCore({ providerResponse.status === HTTP_STATUS.FORBIDDEN) ) { const newCredentials = await refreshWithRetry( - () => executor.refreshCredentials(credentials, log), + async () => { + const refreshed = await executor.refreshCredentials( + (credentials || {}) as ExecutorCredentials, + log ?? null, + ); + return refreshed as + | (Record & { + accessToken?: string; + apiKey?: string; + refreshToken?: string; + expiresIn?: number; + expiresAt?: number; + token?: string; + }) + | null; + }, 3, - log, + log ?? null, ); if (newCredentials?.accessToken || newCredentials?.apiKey) { log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for image generation`); - Object.assign(credentials, newCredentials); + if (credentials) Object.assign(credentials, newCredentials); if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); try { const retryBody = await adapter.buildBody(model, body); const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body); - const retryUrl = adapter.buildUrl(model, credentials); + const retryUrl = adapter.buildUrl(model, credentials) as string; providerResponse = await fetch(retryUrl, { method: "POST", headers: retryHeaders, @@ -123,16 +164,16 @@ export async function handleImageGenerationCore({ const { statusCode, message } = await parseUpstreamError(providerResponse); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); log?.debug?.("IMAGE", `Provider error: ${errMsg}`); - return createErrorResult(statusCode, errMsg); + return createErrorResult(statusCode, errMsg, undefined); } // Parse provider response — adapter may override (codex SSE / async polling / binary) - let parsed; + let parsed: unknown; try { if (adapter.parseResponse) { parsed = await adapter.parseResponse(providerResponse, { headers, - log, + log: log ?? undefined, streamToClient, onRequestSuccess, url, @@ -141,26 +182,35 @@ export async function handleImageGenerationCore({ body, }); // Codex streaming case: returns an SSE Response directly - if (parsed?.sseResponse) { - return { success: true, response: parsed.sseResponse }; + const parsedRecord = + parsed && typeof parsed === "object" ? (parsed as { sseResponse?: Response }) : {}; + if (parsedRecord.sseResponse) { + return { success: true, response: parsedRecord.sseResponse }; } } else { parsed = await providerResponse.json(); } - } catch (parseError) { + } catch (parseError: unknown) { return createErrorResult( HTTP_STATUS.BAD_GATEWAY, - parseError.message || `Invalid response from ${provider}`, + errorMessage(parseError) || `Invalid response from ${provider}`, + undefined, ); } if (onRequestSuccess) await onRequestSuccess(); // Normalize → OpenAI-compatible shape - const normalized = adapter.normalize(parsed, body.prompt); + const normalized = adapter.normalize(parsed, body.prompt) as { + created?: unknown; + data?: Array<{ b64_json?: string; url?: string }>; + }; // Already in OpenAI shape? skip re-normalize - const finalBody = normalized.created && Array.isArray(normalized.data) ? normalized : parsed; + const finalBody = + normalized.created && Array.isArray(normalized.data) + ? normalized + : (parsed as { data?: Array<{ b64_json?: string; url?: string }> }); // Binary output: decode first b64_json (or fetch url) into raw bytes if (binaryOutput) { @@ -173,7 +223,7 @@ export async function handleImageGenerationCore({ } if (b64) { const buf = Buffer.from(b64, "base64"); - const fmt = (body.output_format || "png").toLowerCase(); + const fmt = String(body.output_format || "png").toLowerCase(); const mime = fmt === "jpeg" || fmt === "jpg" ? "image/jpeg" diff --git a/open-sse/handlers/imageProviders/_base.js b/open-sse/handlers/imageProviders/_base.js deleted file mode 100644 index f8902de2..00000000 --- a/open-sse/handlers/imageProviders/_base.js +++ /dev/null @@ -1,31 +0,0 @@ -// Shared helpers for image provider adapters - -export const POLL_INTERVAL_MS = 1500; -export const POLL_TIMEOUT_MS = 120000; - -export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// Map OpenAI size to provider-specific aspect ratio -export function sizeToAspectRatio(size) { - if (!size || typeof size !== "string") return "1:1"; - const map = { - "1024x1024": "1:1", - "1024x1792": "9:16", - "1792x1024": "16:9", - "1024x1536": "2:3", - "1536x1024": "3:2", - }; - return map[size] || "1:1"; -} - -// Fetch URL → base64 (for providers returning image URLs) -export async function urlToBase64(url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`); - const buf = await res.arrayBuffer(); - return Buffer.from(buf).toString("base64"); -} - -export function nowSec() { - return Math.floor(Date.now() / 1000); -} diff --git a/open-sse/handlers/imageProviders/_base.ts b/open-sse/handlers/imageProviders/_base.ts new file mode 100644 index 00000000..dec9b50e --- /dev/null +++ b/open-sse/handlers/imageProviders/_base.ts @@ -0,0 +1,101 @@ +// Shared helpers for image provider adapters + +export const POLL_INTERVAL_MS = 1500; +export const POLL_TIMEOUT_MS = 120000; + +export type JsonObject = Record; + +export type ProviderCredentials = { + apiKey?: string; + accessToken?: string; + idToken?: string; + providerSpecificData?: JsonObject; +} | null; + +export type ImageRequestBody = JsonObject & { + background?: string; + height?: number | string; + image?: string | number[]; + image_detail?: string; + images?: unknown[]; + mask?: string | number[]; + maskImage?: string | number[]; + mask_image?: string | number[]; + n?: number; + negative_prompt?: unknown; + num_steps?: unknown; + output_format?: string; + prompt?: string; + quality?: string; + response_format?: string; + seed?: unknown; + size?: string; + steps?: unknown; + strength?: unknown; + style?: string; + width?: number | string; +}; + +export type ImageProviderHeaders = Record; + +export type PollingParseContext = { + headers: HeadersInit | ImageProviderHeaders; +}; + +export type ImageParseContext = PollingParseContext & { + body?: ImageRequestBody; + log?: { debug?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void }; + model?: string; + onRequestSuccess?: () => Promise | void; + requestBody?: unknown; + streamToClient?: boolean; + url?: string; +}; + +export type ImageResponseBody = JsonObject & { + created?: number; + data?: unknown[]; +}; + +export type ImageProviderAdapter = { + async?: boolean; + buildBody: (model: string, body: ImageRequestBody) => Promise | unknown; + buildHeaders: ( + credentials: ProviderCredentials, + requestBody?: unknown, + model?: string, + body?: ImageRequestBody, + ) => HeadersInit | ImageProviderHeaders; + buildUrl: (model: string, credentials: ProviderCredentials) => string | undefined; + noAuth?: boolean; + normalize: (responseBody: unknown, prompt?: string) => unknown; + parseResponse?: (response: Response, context: ImageParseContext) => Promise | unknown; + stream?: boolean; +}; + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Map OpenAI size to provider-specific aspect ratio +export function sizeToAspectRatio(size: string | undefined) { + if (!size || typeof size !== "string") return "1:1"; + const map: Record = { + "1024x1024": "1:1", + "1024x1792": "9:16", + "1792x1024": "16:9", + "1024x1536": "2:3", + "1536x1024": "3:2", + }; + return map[size] || "1:1"; +} + +// Fetch URL → base64 (for providers returning image URLs) +export async function urlToBase64(url: string) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`); + const buf = await res.arrayBuffer(); + return Buffer.from(buf).toString("base64"); +} + +export function nowSec() { + return Math.floor(Date.now() / 1000); +} diff --git a/open-sse/handlers/imageProviders/blackForestLabs.js b/open-sse/handlers/imageProviders/blackForestLabs.ts similarity index 54% rename from open-sse/handlers/imageProviders/blackForestLabs.js rename to open-sse/handlers/imageProviders/blackForestLabs.ts index c7ab2501..c941227c 100644 --- a/open-sse/handlers/imageProviders/blackForestLabs.js +++ b/open-sse/handlers/imageProviders/blackForestLabs.ts @@ -1,17 +1,27 @@ // Black Forest Labs (FLUX) — async submit + polling_url -import { nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, sleep } from "./_base.js"; +import { + type ImageProviderHeaders, + type ImageRequestBody, + type JsonObject, + type PollingParseContext, + type ProviderCredentials, + nowSec, + POLL_INTERVAL_MS, + POLL_TIMEOUT_MS, + sleep, +} from "./_base.js"; const BASE_URL = "https://api.bfl.ai/v1"; export default { async: true, - buildUrl: (model) => `${BASE_URL}/${model}`, - buildHeaders: (creds) => { + buildUrl: (model: string) => `${BASE_URL}/${model}`, + buildHeaders: (creds: ProviderCredentials) => { const key = creds?.apiKey || creds?.accessToken; - return { "Content-Type": "application/json", "x-key": key }; + return { "Content-Type": "application/json", "x-key": String(key) }; }, - buildBody: (_model, body) => { - const req = { prompt: body.prompt }; + buildBody: (_model: string, body: ImageRequestBody) => { + const req: JsonObject = { prompt: body.prompt }; if (body.size) { const [w, h] = body.size.split("x").map(Number); if (w) req.width = w; @@ -20,25 +30,26 @@ export default { if (body.image) req.image_prompt = body.image; return req; }, - async parseResponse(response, { headers }) { - const data = await response.json(); + async parseResponse(response: Response, { headers }: PollingParseContext) { + const data = (await response.json()) as { polling_url?: string }; const pollingUrl = data.polling_url; if (!pollingUrl) throw new Error("BFL: no polling_url returned"); const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { await sleep(POLL_INTERVAL_MS); + const typedHeaders = headers as ImageProviderHeaders; const r = await fetch(pollingUrl, { - headers: { "x-key": headers["x-key"], Accept: "application/json" }, + headers: { "x-key": String(typedHeaders["x-key"]), Accept: "application/json" }, }); if (!r.ok) throw new Error(`BFL status ${r.status}`); - const s = await r.json(); + const s = (await r.json()) as { error?: string; result?: unknown; status?: string }; if (s.status === "Ready") return s; if (s.status === "Error" || s.status === "Failed") throw new Error(s.error || "BFL generation failed"); } throw new Error("BFL polling timeout"); }, - normalize: (responseBody) => { + normalize: (responseBody: { result?: { sample?: string } }) => { const sample = responseBody.result?.sample; if (sample) return { created: nowSec(), data: [{ url: sample }] }; return { created: nowSec(), data: [] }; diff --git a/open-sse/handlers/imageProviders/cloudflareAi.js b/open-sse/handlers/imageProviders/cloudflareAi.ts similarity index 62% rename from open-sse/handlers/imageProviders/cloudflareAi.js rename to open-sse/handlers/imageProviders/cloudflareAi.ts index d012cea2..e6edd65d 100644 --- a/open-sse/handlers/imageProviders/cloudflareAi.js +++ b/open-sse/handlers/imageProviders/cloudflareAi.ts @@ -1,4 +1,12 @@ -import { nowSec, urlToBase64 } from "./_base.js"; +import { + type ImageProviderHeaders, + type ImageRequestBody, + type ImageResponseBody, + type JsonObject, + type ProviderCredentials, + nowSec, + urlToBase64, +} from "./_base.js"; const BASE_URL = "https://api.cloudflare.com/client/v4/accounts"; @@ -10,16 +18,22 @@ const MULTIPART_MODELS = new Set([ const OPTIONAL_FIELDS = ["negative_prompt", "guidance", "seed", "num_steps", "steps", "strength"]; -function sizeToDimensions(size) { +type ImageInputData = { b64: string; bytes: number[] | string }; + +function asRecord(value: unknown): JsonObject { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; +} + +function sizeToDimensions(size: string | undefined) { const match = /^(\d+)x(\d+)$/.exec(String(size || "")); if (!match) return {}; return { - width: Number(match[1]), - height: Number(match[2]), + width: Number(match[1] || 0), + height: Number(match[2] || 0), }; } -function getDimensions(body) { +function getDimensions(body: ImageRequestBody) { return { ...sizeToDimensions(body.size), ...(Number.isFinite(Number(body.width)) ? { width: Number(body.width) } : {}), @@ -27,7 +41,7 @@ function getDimensions(body) { }; } -async function resolveImageInput(value) { +async function resolveImageInput(value: unknown): Promise { if (Array.isArray(value)) { return { bytes: value, b64: Buffer.from(value).toString("base64") }; } @@ -39,11 +53,11 @@ async function resolveImageInput(value) { return { bytes: base64ToBytes(b64), b64 }; } const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(trimmed); - const b64 = match ? match[1] : trimmed; + const b64 = match ? match[1] || "" : trimmed; return { bytes: base64ToBytes(b64), b64 }; } -function base64ToBytes(value) { +function base64ToBytes(value: string) { try { return Array.from(Buffer.from(value, "base64")); } catch { @@ -51,7 +65,11 @@ function base64ToBytes(value) { } } -function addOptionalFields(target, body, append) { +function addOptionalFields( + target: TTarget, + body: ImageRequestBody, + append: (target: TTarget, key: string, value: unknown) => void, +) { for (const key of OPTIONAL_FIELDS) { const value = body[key]; if (value === undefined || value === null || value === "") continue; @@ -59,8 +77,8 @@ function addOptionalFields(target, body, append) { } } -async function buildJsonBody(body) { - const req = { prompt: body.prompt, ...getDimensions(body) }; +async function buildJsonBody(body: ImageRequestBody) { + const req: JsonObject = { prompt: body.prompt, ...getDimensions(body) }; addOptionalFields(req, body, (target, key, value) => { target[key] = value; @@ -82,9 +100,9 @@ async function buildJsonBody(body) { return req; } -function buildMultipartBody(body) { +function buildMultipartBody(body: ImageRequestBody) { const form = new FormData(); - form.append("prompt", body.prompt); + form.append("prompt", body.prompt as string); const dimensions = getDimensions(body); for (const [key, value] of Object.entries(dimensions)) { @@ -98,7 +116,7 @@ function buildMultipartBody(body) { return form; } -function imageItemFromString(value) { +function imageItemFromString(value: unknown) { if (typeof value !== "string" || !value) return null; if (/^data:image\/[^;]+;base64,/i.test(value)) { return { b64_json: value.replace(/^data:image\/[^;]+;base64,/i, "") }; @@ -107,20 +125,23 @@ function imageItemFromString(value) { return { b64_json: value }; } -function normalizeCloudflareResponse(responseBody) { - if (responseBody?.created && Array.isArray(responseBody?.data)) return responseBody; +function normalizeCloudflareResponse(responseBody: unknown): ImageResponseBody { + const responseRecord = asRecord(responseBody); + if (responseRecord.created && Array.isArray(responseRecord.data)) return responseRecord; - const result = responseBody?.result ?? responseBody; - const queuedResponse = Array.isArray(result?.responses) - ? result.responses.find((item) => item?.success !== false)?.result + const result = responseRecord.result ?? responseBody; + const resultRecord = asRecord(result); + const queuedResponse = Array.isArray(resultRecord.responses) + ? resultRecord.responses.find((item) => asRecord(item).success !== false) : null; - if (queuedResponse) return normalizeCloudflareResponse(queuedResponse); + if (queuedResponse) return normalizeCloudflareResponse(asRecord(queuedResponse).result); + const firstDataItem = Array.isArray(resultRecord.data) ? asRecord(resultRecord.data[0]) : {}; const image = (typeof result === "string" ? result : null) || - result?.image || - result?.data?.[0]?.b64_json || - result?.data?.[0]?.url; + resultRecord.image || + firstDataItem.b64_json || + firstDataItem.url; const item = imageItemFromString(image); return { @@ -130,14 +151,14 @@ function normalizeCloudflareResponse(responseBody) { } export default { - buildUrl: (model, creds) => { + buildUrl: (model: string, creds: ProviderCredentials) => { const accountId = creds?.providerSpecificData?.accountId; if (!accountId) throw new Error("cloudflare-ai requires accountId in providerSpecificData"); return `${BASE_URL}/${accountId}/ai/run/${model}`; }, - buildHeaders: (creds, requestBody) => { - const headers = {}; + buildHeaders: (creds: ProviderCredentials, requestBody: unknown) => { + const headers: ImageProviderHeaders = {}; const isMultipart = typeof FormData !== "undefined" && requestBody instanceof FormData; if (!isMultipart) { headers["Content-Type"] = "application/json"; @@ -147,10 +168,10 @@ export default { return headers; }, - buildBody: async (model, body) => + buildBody: async (model: string, body: ImageRequestBody) => MULTIPART_MODELS.has(model) ? buildMultipartBody(body) : await buildJsonBody(body), - async parseResponse(response) { + async parseResponse(response: Response) { const contentType = (response.headers.get("Content-Type") || "").toLowerCase(); if (contentType.startsWith("image/")) { const buf = await response.arrayBuffer(); diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.ts similarity index 75% rename from open-sse/handlers/imageProviders/codex.js rename to open-sse/handlers/imageProviders/codex.ts index 33875774..6f34bc1c 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.ts @@ -1,6 +1,11 @@ // Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE import { randomUUID } from "node:crypto"; -import { nowSec } from "./_base.js"; +import { + type ImageParseContext, + type ImageRequestBody, + type ProviderCredentials, + nowSec, +} from "./_base.js"; const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; const CODEX_USER_AGENT = "codex-imagen/0.2.6"; @@ -9,11 +14,26 @@ const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_MODEL_SUFFIX = "-image"; const CODEX_REF_DETAIL = "high"; -function decodeAccountId(idToken) { +type CodexContent = + | { type: "input_text"; text: string | undefined } + | { type: "input_image"; image_url: string; detail: string }; + +type CodexCallbacks = { + onPartialImage?: (info: { b64_json: string; index?: number }) => void; + onProgress?: (info: { stage: string; bytesReceived: number }) => void; +}; + +type CodexSseData = { + item?: { result?: string; type?: string }; + partial_image_b64?: string; + partial_image_index?: number; +}; + +function decodeAccountId(idToken: string | undefined) { try { const parts = String(idToken || "").split("."); if (parts.length !== 3) return null; - const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const b64 = (parts[1] || "").replace(/-/g, "+").replace(/_/g, "/"); const pad = (4 - (b64.length % 4)) % 4; const payload = JSON.parse(Buffer.from(b64 + "=".repeat(pad), "base64").toString("utf8")); return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id || null; @@ -22,18 +42,22 @@ function decodeAccountId(idToken) { } } -function stripImageSuffix(model) { +function stripImageSuffix(model: string) { return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model; } -function toDataUrl(input) { +function toDataUrl(input: unknown) { if (!input || typeof input !== "string") return null; if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input; return `data:image/png;base64,${input}`; } -function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) { - const content = []; +function buildContent( + prompt: string | undefined, + refs: string[], + detail: string = CODEX_REF_DETAIL, +) { + const content: CodexContent[] = []; refs.forEach((url, index) => { content.push({ type: "input_text", text: `` }); content.push({ type: "input_image", image_url: url, detail }); @@ -44,12 +68,16 @@ function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) { } // Parse Codex SSE stream → final base64 image. Optional callbacks for client streaming. -async function parseStream(response, log, callbacks = {}) { - const reader = response.body.getReader(); +async function parseStream( + response: Response, + log: ImageParseContext["log"], + callbacks: CodexCallbacks = {}, +) { + const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; - let imageB64 = null; - let lastEvent = null; + let imageB64: string | null = null; + let lastEvent: string | null = null; let bytesReceived = 0; let lastProgressLogMs = 0; @@ -86,7 +114,7 @@ async function parseStream(response, log, callbacks = {}) { if (eventName === "response.image_generation_call.partial_image" && dataStr) { try { - const data = JSON.parse(dataStr); + const data = JSON.parse(dataStr) as CodexSseData; if (callbacks.onPartialImage && data?.partial_image_b64) { callbacks.onPartialImage({ b64_json: data.partial_image_b64, @@ -98,7 +126,7 @@ async function parseStream(response, log, callbacks = {}) { if (eventName === "response.output_item.done" && dataStr) { try { - const data = JSON.parse(dataStr); + const data = JSON.parse(dataStr) as CodexSseData; const item = data?.item; if (item?.type === "image_generation_call" && item.result) { imageB64 = item.result; @@ -111,11 +139,15 @@ async function parseStream(response, log, callbacks = {}) { } // SSE Response that pipes codex progress + partial + done events to client -function buildSseResponse(providerResponse, log, onSuccess) { +function buildSseResponse( + providerResponse: Response, + log: ImageParseContext["log"], + onSuccess: ImageParseContext["onRequestSuccess"], +) { const stream = new ReadableStream({ - async start(controller) { + async start(controller: ReadableStreamDefaultController) { const enc = new TextEncoder(); - const send = (event, data) => { + const send = (event: string, data: unknown) => { controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; try { @@ -132,8 +164,8 @@ function buildSseResponse(providerResponse, log, onSuccess) { if (onSuccess) await onSuccess(); send("done", { created: nowSec(), data: [{ b64_json: b64 }] }); } - } catch (err) { - send("error", { message: err?.message || "Stream failed" }); + } catch (err: unknown) { + send("error", { message: err instanceof Error ? err.message : "Stream failed" }); } finally { controller.close(); } @@ -153,13 +185,13 @@ function buildSseResponse(providerResponse, log, onSuccess) { export default { stream: true, buildUrl: () => CODEX_RESPONSES_URL, - buildHeaders: (creds) => { + buildHeaders: (creds: ProviderCredentials) => { const accountId = creds?.providerSpecificData?.chatgptAccountId || decodeAccountId(creds?.idToken); return { accept: "text/event-stream, application/json", authorization: `Bearer ${creds?.accessToken || ""}`, - "chatgpt-account-id": accountId || "", + "chatgpt-account-id": String(accountId || ""), "content-type": "application/json", originator: CODEX_ORIGINATOR, session_id: randomUUID(), @@ -168,8 +200,8 @@ export default { "x-client-request-id": randomUUID(), }; }, - buildBody: (model, body) => { - const refs = []; + buildBody: (model: string, body: ImageRequestBody) => { + const refs: string[] = []; if (Array.isArray(body.images)) body.images.forEach((i) => { const u = toDataUrl(i); @@ -178,7 +210,7 @@ export default { const single = toDataUrl(body.image); if (single) refs.push(single); const detail = body.image_detail || CODEX_REF_DETAIL; - const imgTool = { + const imgTool: Record = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase(), }; @@ -199,7 +231,10 @@ export default { }; }, // Custom: codex parses SSE → either pipe to client or collect b64 - async parseResponse(response, { log, streamToClient, onRequestSuccess }) { + async parseResponse( + response: Response, + { log, streamToClient, onRequestSuccess }: ImageParseContext, + ) { if (streamToClient) { return { sseResponse: buildSseResponse(response, log, onRequestSuccess) }; } @@ -211,5 +246,5 @@ export default { } return { created: nowSec(), data: [{ b64_json: b64 }] }; }, - normalize: (responseBody) => responseBody, + normalize: (responseBody: unknown) => responseBody, }; diff --git a/open-sse/handlers/imageProviders/comfyui.js b/open-sse/handlers/imageProviders/comfyui.ts similarity index 53% rename from open-sse/handlers/imageProviders/comfyui.js rename to open-sse/handlers/imageProviders/comfyui.ts index 6a37a44b..ee4c3a2b 100644 --- a/open-sse/handlers/imageProviders/comfyui.js +++ b/open-sse/handlers/imageProviders/comfyui.ts @@ -1,8 +1,10 @@ // ComfyUI — local, noAuth (placeholder; full graph workflow not implemented) +import type { ImageRequestBody } from "./_base.js"; + export default { noAuth: true, buildUrl: () => "http://localhost:8188", buildHeaders: () => ({ "Content-Type": "application/json" }), - buildBody: (_model, body) => ({ prompt: body.prompt }), - normalize: (responseBody) => responseBody, + buildBody: (_model: string, body: ImageRequestBody) => ({ prompt: body.prompt }), + normalize: (responseBody: unknown) => responseBody, }; diff --git a/open-sse/handlers/imageProviders/falAi.js b/open-sse/handlers/imageProviders/falAi.js deleted file mode 100644 index 4a1e936e..00000000 --- a/open-sse/handlers/imageProviders/falAi.js +++ /dev/null @@ -1,43 +0,0 @@ -// Fal.ai — async submit + queue polling -import { nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, sizeToAspectRatio, sleep } from "./_base.js"; - -const BASE_URL = "https://queue.fal.run"; - -export default { - async: true, - buildUrl: (model) => `${BASE_URL}/${model}`, - buildHeaders: (creds) => { - const key = creds?.apiKey || creds?.accessToken; - return { "Content-Type": "application/json", Authorization: `Key ${key}` }; - }, - buildBody: (_model, body) => { - const req = { prompt: body.prompt, num_images: body.n || 1 }; - if (body.size) req.image_size = sizeToAspectRatio(body.size); - if (body.image) req.image_url = body.image; - return req; - }, - async parseResponse(response, { headers }) { - const { status_url, response_url } = await response.json(); - const deadline = Date.now() + POLL_TIMEOUT_MS; - while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS); - const r = await fetch(status_url, { headers }); - if (!r.ok) throw new Error(`Fal status ${r.status}`); - const s = await r.json(); - if (s.status === "COMPLETED") { - const fr = await fetch(response_url, { headers }); - return await fr.json(); - } - if (s.status === "FAILED") throw new Error(s.error || "Fal generation failed"); - } - throw new Error("Fal polling timeout"); - }, - normalize: (responseBody) => { - const images = Array.isArray(responseBody.images) - ? responseBody.images - : responseBody.image - ? [responseBody.image] - : []; - return { created: nowSec(), data: images.map((img) => ({ url: img.url || img })) }; - }, -}; diff --git a/open-sse/handlers/imageProviders/falAi.ts b/open-sse/handlers/imageProviders/falAi.ts new file mode 100644 index 00000000..cb7ba586 --- /dev/null +++ b/open-sse/handlers/imageProviders/falAi.ts @@ -0,0 +1,63 @@ +// Fal.ai — async submit + queue polling +import { + type ImageProviderHeaders, + type ImageRequestBody, + type JsonObject, + type PollingParseContext, + type ProviderCredentials, + nowSec, + POLL_INTERVAL_MS, + POLL_TIMEOUT_MS, + sizeToAspectRatio, + sleep, +} from "./_base.js"; + +const BASE_URL = "https://queue.fal.run"; + +export default { + async: true, + buildUrl: (model: string) => `${BASE_URL}/${model}`, + buildHeaders: (creds: ProviderCredentials) => { + const key = creds?.apiKey || creds?.accessToken; + return { "Content-Type": "application/json", Authorization: `Key ${key}` }; + }, + buildBody: (_model: string, body: ImageRequestBody) => { + const req: JsonObject = { prompt: body.prompt, num_images: body.n || 1 }; + if (body.size) req.image_size = sizeToAspectRatio(body.size); + if (body.image) req.image_url = body.image; + return req; + }, + async parseResponse(response: Response, { headers }: PollingParseContext) { + const { status_url, response_url } = (await response.json()) as { + response_url: string; + status_url: string; + }; + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const r = await fetch(status_url, { headers: headers as ImageProviderHeaders }); + if (!r.ok) throw new Error(`Fal status ${r.status}`); + const s = (await r.json()) as { error?: string; status?: string }; + if (s.status === "COMPLETED") { + const fr = await fetch(response_url, { headers: headers as ImageProviderHeaders }); + return await fr.json(); + } + if (s.status === "FAILED") throw new Error(s.error || "Fal generation failed"); + } + throw new Error("Fal polling timeout"); + }, + normalize: (responseBody: { + image?: { url?: string } | string; + images?: ({ url?: string } | string)[]; + }) => { + const images = Array.isArray(responseBody.images) + ? responseBody.images + : responseBody.image + ? [responseBody.image] + : []; + return { + created: nowSec(), + data: images.map((img) => ({ url: typeof img === "string" ? img : img.url || img })), + }; + }, +}; diff --git a/open-sse/handlers/imageProviders/gemini.js b/open-sse/handlers/imageProviders/gemini.ts similarity index 64% rename from open-sse/handlers/imageProviders/gemini.js rename to open-sse/handlers/imageProviders/gemini.ts index 7b3f75a4..d5290e41 100644 --- a/open-sse/handlers/imageProviders/gemini.js +++ b/open-sse/handlers/imageProviders/gemini.ts @@ -1,24 +1,27 @@ // Google Gemini adapter (Nano Banana models) -import { nowSec } from "./_base.js"; +import { type ImageRequestBody, type ProviderCredentials, nowSec } from "./_base.js"; const BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"; export default { - buildUrl: (model, creds) => { + buildUrl: (model: string, creds: ProviderCredentials) => { const apiKey = creds?.apiKey || creds?.accessToken; const modelId = model.replace(/^models\//, ""); - return `${BASE_URL}/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`; + return `${BASE_URL}/${modelId}:generateContent?key=${encodeURIComponent(String(apiKey))}`; }, buildHeaders: () => ({ "Content-Type": "application/json" }), - buildBody: (_model, body) => ({ + buildBody: (_model: string, body: ImageRequestBody) => ({ contents: [{ parts: [{ text: body.prompt }] }], generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, }), - normalize: (responseBody, prompt) => { + normalize: ( + responseBody: { candidates?: { content?: { parts?: { inlineData?: { data?: string } }[] } }[] }, + prompt: string, + ) => { const parts = responseBody.candidates?.[0]?.content?.parts || []; const images = parts .filter((p) => p.inlineData?.data) - .map((p) => ({ b64_json: p.inlineData.data })); + .map((p) => ({ b64_json: p.inlineData?.data })); return { created: nowSec(), data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }], diff --git a/open-sse/handlers/imageProviders/huggingface.js b/open-sse/handlers/imageProviders/huggingface.ts similarity index 52% rename from open-sse/handlers/imageProviders/huggingface.js rename to open-sse/handlers/imageProviders/huggingface.ts index 9b3a03b3..69c74c8b 100644 --- a/open-sse/handlers/imageProviders/huggingface.js +++ b/open-sse/handlers/imageProviders/huggingface.ts @@ -1,22 +1,22 @@ // HuggingFace Inference API — returns binary image -import { nowSec } from "./_base.js"; +import { type ImageRequestBody, type ProviderCredentials, nowSec } from "./_base.js"; const BASE_URL = "https://api-inference.huggingface.co/models"; export default { - buildUrl: (model) => `${BASE_URL}/${model}`, - buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json" }; + buildUrl: (model: string) => `${BASE_URL}/${model}`, + buildHeaders: (creds: ProviderCredentials) => { + const headers: Record = { "Content-Type": "application/json" }; const key = creds?.apiKey || creds?.accessToken; if (key) headers["Authorization"] = `Bearer ${key}`; return headers; }, - buildBody: (_model, body) => ({ inputs: body.prompt }), + buildBody: (_model: string, body: ImageRequestBody) => ({ inputs: body.prompt }), // HF returns raw image bytes — convert to b64_json - async parseResponse(response) { + async parseResponse(response: Response) { const buf = await response.arrayBuffer(); const base64 = Buffer.from(buf).toString("base64"); return { created: nowSec(), data: [{ b64_json: base64 }] }; }, - normalize: (responseBody) => responseBody, + normalize: (responseBody: unknown) => responseBody, }; diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.ts similarity index 82% rename from open-sse/handlers/imageProviders/index.js rename to open-sse/handlers/imageProviders/index.ts index f0102d02..87e22a89 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.ts @@ -1,5 +1,6 @@ // Image provider adapter registry +import type { ImageProviderAdapter } from "./_base.js"; import blackForestLabs from "./blackForestLabs.js"; import cloudflareAi from "./cloudflareAi.js"; import codex from "./codex.js"; @@ -29,12 +30,12 @@ const ADAPTERS = { "black-forest-labs": blackForestLabs, runwayml, "cloudflare-ai": cloudflareAi, -}; +} as unknown as Record; -export function getImageAdapter(provider) { +export function getImageAdapter(provider: string) { return ADAPTERS[provider] || null; } -export function isImageProvider(provider) { +export function isImageProvider(provider: string) { return provider in ADAPTERS; } diff --git a/open-sse/handlers/imageProviders/nanobanana.js b/open-sse/handlers/imageProviders/nanobanana.ts similarity index 66% rename from open-sse/handlers/imageProviders/nanobanana.js rename to open-sse/handlers/imageProviders/nanobanana.ts index 3863a0c9..e613359b 100644 --- a/open-sse/handlers/imageProviders/nanobanana.js +++ b/open-sse/handlers/imageProviders/nanobanana.ts @@ -1,5 +1,16 @@ // NanoBanana API — async submit + poll record-info -import { nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, sizeToAspectRatio, sleep } from "./_base.js"; +import { + type ImageProviderHeaders, + type ImageRequestBody, + type JsonObject, + type PollingParseContext, + type ProviderCredentials, + nowSec, + POLL_INTERVAL_MS, + POLL_TIMEOUT_MS, + sizeToAspectRatio, + sleep, +} from "./_base.js"; const SUBMIT_URL = "https://api.nanobananaapi.ai/api/v1/nanobanana/generate"; const POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info"; @@ -7,16 +18,16 @@ const POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info"; export default { async: true, buildUrl: () => SUBMIT_URL, - buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json" }; + buildHeaders: (creds: ProviderCredentials) => { + const headers: ImageProviderHeaders = { "Content-Type": "application/json" }; const key = creds?.apiKey || creds?.accessToken; if (key) headers["Authorization"] = `Bearer ${key}`; return headers; }, - buildBody: (_model, body) => { + buildBody: (_model: string, body: ImageRequestBody) => { const ratio = sizeToAspectRatio(body.size); const isEdit = !!(body.image || (Array.isArray(body.images) && body.images.length)); - const req = { + const req: JsonObject = { prompt: body.prompt, type: isEdit ? "IMAGETOIAMGE" : "TEXTTOIAMGE", numImages: body.n || 1, @@ -32,8 +43,12 @@ export default { return req; }, // Async: parse submit → poll until SUCCESS, return raw poll data - async parseResponse(response, { headers }) { - const submitData = await response.json(); + async parseResponse(response: Response, { headers }: PollingParseContext) { + const submitData = (await response.json()) as { + code?: number; + data?: { taskId?: string }; + msg?: string; + }; if (submitData.code !== 200) throw new Error(submitData.msg || "NanoBanana submit failed"); const taskId = submitData.data?.taskId; if (!taskId) throw new Error("NanoBanana: no taskId returned"); @@ -41,9 +56,11 @@ export default { const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { await sleep(POLL_INTERVAL_MS); - const r = await fetch(pollUrl, { headers }); + const r = await fetch(pollUrl, { headers: headers as ImageProviderHeaders }); if (!r.ok) throw new Error(`NanoBanana status ${r.status}`); - const s = await r.json(); + const s = (await r.json()) as { + data?: { errorMessage?: string; response?: unknown; successFlag?: number }; + }; const flag = s.data?.successFlag; if (flag === 1) return s.data; if (flag === 2 || flag === 3) @@ -51,7 +68,10 @@ export default { } throw new Error("NanoBanana polling timeout"); }, - normalize: (responseBody, prompt) => { + normalize: ( + responseBody: { response?: { originImageUrl?: string; resultImageUrl?: string } }, + prompt: string, + ) => { const url = responseBody.response?.resultImageUrl || responseBody.response?.originImageUrl; if (url) return { created: nowSec(), data: [{ url, revised_prompt: prompt }] }; return { created: nowSec(), data: [] }; diff --git a/open-sse/handlers/imageProviders/openai.js b/open-sse/handlers/imageProviders/openai.ts similarity index 64% rename from open-sse/handlers/imageProviders/openai.js rename to open-sse/handlers/imageProviders/openai.ts index 2e034071..7baf9593 100644 --- a/open-sse/handlers/imageProviders/openai.js +++ b/open-sse/handlers/imageProviders/openai.ts @@ -1,17 +1,24 @@ // OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft) -const ENDPOINTS = { +import type { + ImageProviderHeaders, + ImageRequestBody, + JsonObject, + ProviderCredentials, +} from "./_base.js"; + +const ENDPOINTS: Record = { openai: "https://api.openai.com/v1/images/generations", minimax: "https://api.minimaxi.com/v1/images/generations", openrouter: "https://openrouter.ai/api/v1/images/generations", recraft: "https://external.api.recraft.ai/v1/images/generations", }; -export default function createOpenAIAdapter(providerId) { +export default function createOpenAIAdapter(providerId: string) { return { buildUrl: () => ENDPOINTS[providerId], - buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json" }; + buildHeaders: (creds: ProviderCredentials) => { + const headers: ImageProviderHeaders = { "Content-Type": "application/json" }; const key = creds?.apiKey || creds?.accessToken; if (key) headers["Authorization"] = `Bearer ${key}`; if (providerId === "openrouter") { @@ -20,14 +27,14 @@ export default function createOpenAIAdapter(providerId) { } return headers; }, - buildBody: (model, body) => { + buildBody: (model: string, body: ImageRequestBody) => { const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body; - const req = { model, prompt, n, size }; + const req: JsonObject = { model, prompt, n, size }; if (quality) req.quality = quality; if (style) req.style = style; if (response_format) req.response_format = response_format; return req; }, - normalize: (responseBody) => responseBody, + normalize: (responseBody: unknown) => responseBody, }; } diff --git a/open-sse/handlers/imageProviders/runwayml.js b/open-sse/handlers/imageProviders/runwayml.ts similarity index 73% rename from open-sse/handlers/imageProviders/runwayml.js rename to open-sse/handlers/imageProviders/runwayml.ts index 2bad2a7f..3e21c169 100644 --- a/open-sse/handlers/imageProviders/runwayml.js +++ b/open-sse/handlers/imageProviders/runwayml.ts @@ -1,15 +1,24 @@ // Runway ML — async submit + /tasks/{id} polling -import { nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, sizeToAspectRatio, sleep } from "./_base.js"; +import { + type ImageRequestBody, + type PollingParseContext, + type ProviderCredentials, + nowSec, + POLL_INTERVAL_MS, + POLL_TIMEOUT_MS, + sizeToAspectRatio, + sleep, +} from "./_base.js"; const BASE_URL = "https://api.dev.runwayml.com/v1"; export default { async: true, - buildUrl: (model) => { + buildUrl: (model: string) => { // Image models (gen4_image*) → text_to_image; video models → image_to_video return `${BASE_URL}/${model.includes("image") ? "text_to_image" : "image_to_video"}`; }, - buildHeaders: (creds) => { + buildHeaders: (creds: ProviderCredentials) => { const key = creds?.apiKey || creds?.accessToken; return { "Content-Type": "application/json", @@ -17,7 +26,7 @@ export default { "X-Runway-Version": "2024-11-06", }; }, - buildBody: (model, body) => { + buildBody: (model: string, body: ImageRequestBody) => { const isVideo = !model.includes("image"); const ratio = sizeToAspectRatio(body.size); if (isVideo) { @@ -36,8 +45,8 @@ export default { ...(body.image ? { referenceImages: [{ uri: body.image }] } : {}), }; }, - async parseResponse(response, { headers }) { - const { id } = await response.json(); + async parseResponse(response: Response, { headers }: PollingParseContext) { + const { id } = (await response.json()) as { id?: string }; if (!id) throw new Error("Runway: no task id returned"); const taskUrl = `${BASE_URL}/tasks/${id}`; const deadline = Date.now() + POLL_TIMEOUT_MS; @@ -45,14 +54,14 @@ export default { await sleep(POLL_INTERVAL_MS); const r = await fetch(taskUrl, { headers }); if (!r.ok) throw new Error(`Runway status ${r.status}`); - const s = await r.json(); + const s = (await r.json()) as { failure?: string; output?: unknown[]; status?: string }; if (s.status === "SUCCEEDED") return s; if (s.status === "FAILED" || s.status === "CANCELLED") throw new Error(s.failure || "Runway task failed"); } throw new Error("Runway polling timeout"); }, - normalize: (responseBody) => { + normalize: (responseBody: { output?: unknown[] }) => { const outputs = Array.isArray(responseBody.output) ? responseBody.output : []; return { created: nowSec(), data: outputs.map((url) => ({ url })) }; }, diff --git a/open-sse/handlers/imageProviders/sdwebui.js b/open-sse/handlers/imageProviders/sdwebui.ts similarity index 77% rename from open-sse/handlers/imageProviders/sdwebui.js rename to open-sse/handlers/imageProviders/sdwebui.ts index f5f9bb85..e04a65d9 100644 --- a/open-sse/handlers/imageProviders/sdwebui.js +++ b/open-sse/handlers/imageProviders/sdwebui.ts @@ -1,16 +1,16 @@ // SD WebUI (AUTOMATIC1111) — local, noAuth -import { nowSec } from "./_base.js"; +import { type ImageRequestBody, nowSec } from "./_base.js"; export default { noAuth: true, buildUrl: () => "http://localhost:7860/sdapi/v1/txt2img", buildHeaders: () => ({ "Content-Type": "application/json" }), - buildBody: (_model, body) => { + buildBody: (_model: string, body: ImageRequestBody) => { const { prompt, n = 1, size = "1024x1024" } = body; const [width, height] = size.split("x").map(Number); return { prompt, width: width || 512, height: height || 512, steps: 20, batch_size: n }; }, - normalize: (responseBody) => { + normalize: (responseBody: { images?: string[] }) => { const images = Array.isArray(responseBody.images) ? responseBody.images.map((img) => ({ b64_json: img })) : []; diff --git a/open-sse/handlers/imageProviders/stabilityAi.js b/open-sse/handlers/imageProviders/stabilityAi.ts similarity index 60% rename from open-sse/handlers/imageProviders/stabilityAi.js rename to open-sse/handlers/imageProviders/stabilityAi.ts index bcdfce75..76fc3c74 100644 --- a/open-sse/handlers/imageProviders/stabilityAi.js +++ b/open-sse/handlers/imageProviders/stabilityAi.ts @@ -1,18 +1,24 @@ // Stability AI v2 — sync, returns { image: "" } -import { nowSec, sizeToAspectRatio } from "./_base.js"; +import { + type ImageRequestBody, + type JsonObject, + type ProviderCredentials, + nowSec, + sizeToAspectRatio, +} from "./_base.js"; const BASE_URL = "https://api.stability.ai/v2beta/stable-image/generate"; // Map model id → endpoint segment -function modelToEndpoint(model) { +function modelToEndpoint(model: string) { if (model.includes("ultra")) return "ultra"; if (model.includes("sd3")) return "sd3"; return "core"; } export default { - buildUrl: (model) => `${BASE_URL}/${modelToEndpoint(model)}`, - buildHeaders: (creds) => { + buildUrl: (model: string) => `${BASE_URL}/${modelToEndpoint(model)}`, + buildHeaders: (creds: ProviderCredentials) => { const key = creds?.apiKey || creds?.accessToken; return { "Content-Type": "application/json", @@ -20,14 +26,17 @@ export default { Accept: "application/json", }; }, - buildBody: (model, body) => { - const req = { prompt: body.prompt, output_format: (body.output_format || "png").toLowerCase() }; + buildBody: (model: string, body: ImageRequestBody) => { + const req: JsonObject = { + prompt: body.prompt, + output_format: (body.output_format || "png").toLowerCase(), + }; if (body.size) req.aspect_ratio = sizeToAspectRatio(body.size); if (body.style) req.style_preset = body.style; if (model.includes("sd3")) req.model = model; return req; }, - normalize: (responseBody) => { + normalize: (responseBody: { image?: string }) => { if (responseBody.image) return { created: nowSec(), data: [{ b64_json: responseBody.image }] }; return { created: nowSec(), data: [] }; }, diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.ts similarity index 71% rename from open-sse/handlers/responsesHandler.js rename to open-sse/handlers/responsesHandler.ts index 86a1b3c1..0d62fecf 100644 --- a/open-sse/handlers/responsesHandler.js +++ b/open-sse/handlers/responsesHandler.ts @@ -8,18 +8,32 @@ import { convertResponsesStreamToJson } from "../transformer/streamToJsonConvert import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js"; import { handleChatCore } from "./chatCore.js"; +type JsonRecord = Record; + +type ChatLogger = { + debug?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + info?: (scope: string, message: string) => void; + warn?: (scope: string, message: string) => void; +}; + +export type ResponsesCoreParams = { + body: JsonRecord; + modelInfo: { provider: string; model: string }; + credentials: JsonRecord | null; + log?: ChatLogger | null; + onCredentialsRefreshed?: (newCreds: JsonRecord) => Promise | void; + onRequestSuccess?: () => Promise | void; + onDisconnect?: (reason?: unknown) => Promise | void; + connectionId: string; +}; + +type ResponsesCoreResult = + | { success: true; response: Response } + | { success: false; status?: number; error?: string; response?: Response }; + /** * Handle /v1/responses request - * @param {object} options - * @param {object} options.body - Request body (Responses API format) - * @param {object} options.modelInfo - { provider, model } - * @param {object} options.credentials - Provider credentials - * @param {object} options.log - Logger instance (optional) - * @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed - * @param {function} options.onRequestSuccess - Callback when request succeeds - * @param {function} options.onDisconnect - Callback when client disconnects - * @param {string} options.connectionId - Connection ID for usage tracking - * @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>} */ export async function handleResponsesCore({ body, @@ -30,9 +44,9 @@ export async function handleResponsesCore({ onRequestSuccess, onDisconnect, connectionId, -}) { +}: ResponsesCoreParams): Promise { // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body); + const convertedBody = convertResponsesApiFormat(body) as JsonRecord & { stream?: boolean }; // Preserve client's stream preference (matches OpenClaw behavior) // Default to false if omitted: Boolean(undefined) = false @@ -46,7 +60,7 @@ export async function handleResponsesCore({ body: convertedBody, modelInfo, credentials, - log, + log: log ?? null, onCredentialsRefreshed, onRequestSuccess, onDisconnect, @@ -77,7 +91,7 @@ export async function handleResponsesCore({ }, }), }; - } catch (error) { + } catch (error: unknown) { console.error("[Responses API] Stream-to-JSON conversion failed:", error); return { success: false, @@ -90,7 +104,11 @@ export async function handleResponsesCore({ // Case 2: Client wants streaming, got SSE - transform it if (clientRequestedStreaming && contentType.includes("text/event-stream")) { const transformStream = createResponsesApiTransformStream(null); - const transformedBody = response.body.pipeThrough(transformStream); + const streamBody = response.body; + if (!streamBody) { + return result; + } + const transformedBody = streamBody.pipeThrough(transformStream); return { success: true, diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.ts similarity index 81% rename from open-sse/handlers/search/callers.js rename to open-sse/handlers/search/callers.ts index 5bb720ac..a28b9870 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.ts @@ -32,12 +32,50 @@ // ── Helpers ───────────────────────────────────────────────────────────── +type SearchProviderConfig = { + id: string; + baseUrl: string; + method?: string; +}; + +type ContentOptions = { + snippet?: boolean; + full_page?: boolean; + format?: string; + max_characters?: number; +}; + +type SearchRequestParams = { + query: string; + searchType: string; + maxResults: number; + token?: string; + country?: string; + language?: string; + timeRange?: string; + offset?: number; + domainFilter?: string[]; + contentOptions?: ContentOptions; + providerOptions?: Record; + providerSpecificData?: Record; +}; + +type BuiltSearchRequest = { + init: RequestInit; + url: string; +}; + +type SearchRequestBuilder = ( + config: SearchProviderConfig, + params: SearchRequestParams, +) => BuiltSearchRequest; + /** * Split domain filter into includes / excludes (excludes prefixed with "-"). * @param {string[]} [domainFilter] * @returns {{includes: string[], excludes: string[]}} */ -export function parseDomainFilter(domainFilter) { +export function parseDomainFilter(domainFilter?: string[]) { if (!domainFilter?.length) return { includes: [], excludes: [] }; const includes = domainFilter.filter((d) => !d.startsWith("-")); const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1)); @@ -50,7 +88,7 @@ export function parseDomainFilter(domainFilter) { * @param {string} key * @returns {string|undefined} */ -export function getProviderSetting(params, key) { +export function getProviderSetting(params: SearchRequestParams, key: string) { const fromOptions = params.providerOptions?.[key]; if (typeof fromOptions === "string" && fromOptions.trim().length > 0) { return fromOptions.trim(); @@ -68,7 +106,7 @@ export function getProviderSetting(params, key) { * @param {SearchRequestParams} params * @returns {string} */ -export function resolveBaseUrl(config, params) { +export function resolveBaseUrl(config: SearchProviderConfig, params: SearchRequestParams) { const override = getProviderSetting(params, "baseUrl"); return (override || config.baseUrl).replace(/\/+$/, ""); } @@ -81,7 +119,7 @@ export function resolveBaseUrl(config, params) { * @param {SearchRequestParams} params * @returns {string} */ -function resolveSearxngBaseUrl(config, params) { +function resolveSearxngBaseUrl(config: SearchProviderConfig, params: SearchRequestParams) { const override = getProviderSetting(params, "baseUrl"); if (override) return override.replace(/\/+$/, ""); @@ -99,29 +137,29 @@ function resolveSearxngBaseUrl(config, params) { * @param {number} maxResults * @returns {number|undefined} */ -export function toPageNumber(offset, maxResults) { +export function toPageNumber(offset: number | undefined, maxResults: number) { if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined; return Math.floor(offset / maxResults) + 1; } // ── Provider Request Builders ─────────────────────────────────────────── -function buildSerperRequest(config, params) { +function buildSerperRequest(config: SearchProviderConfig, params: SearchRequestParams) { const endpoint = params.searchType === "news" ? "/news" : "/search"; - const body = { q: params.query, num: params.maxResults }; + const body: Record = { q: params.query, num: params.maxResults }; if (params.country) body.gl = params.country.toLowerCase(); if (params.language) body.hl = params.language; return { url: `${resolveBaseUrl(config, params)}${endpoint}`, init: { method: "POST", - headers: { "Content-Type": "application/json", "X-API-Key": params.token }, + headers: { "Content-Type": "application/json", "X-API-Key": params.token } as HeadersInit, body: JSON.stringify(body), }, }; } -function buildBraveRequest(config, params) { +function buildBraveRequest(config: SearchProviderConfig, params: SearchRequestParams) { const endpoint = params.searchType === "news" ? "/news/search" : "/web/search"; const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) }); if (params.country) qp.set("country", params.country); @@ -130,14 +168,14 @@ function buildBraveRequest(config, params) { url: `${resolveBaseUrl(config, params)}${endpoint}?${qp}`, init: { method: "GET", - headers: { Accept: "application/json", "X-Subscription-Token": params.token }, + headers: { Accept: "application/json", "X-Subscription-Token": params.token } as HeadersInit, }, }; } -function buildExaRequest(config, params) { +function buildExaRequest(config: SearchProviderConfig, params: SearchRequestParams) { const { includes, excludes } = parseDomainFilter(params.domainFilter); - const body = { + const body: Record = { query: params.query, numResults: params.maxResults, type: "auto", @@ -151,15 +189,15 @@ function buildExaRequest(config, params) { url: resolveBaseUrl(config, params), init: { method: "POST", - headers: { "Content-Type": "application/json", "x-api-key": params.token }, + headers: { "Content-Type": "application/json", "x-api-key": params.token } as HeadersInit, body: JSON.stringify(body), }, }; } -function buildTavilyRequest(config, params) { +function buildTavilyRequest(config: SearchProviderConfig, params: SearchRequestParams) { const { includes, excludes } = parseDomainFilter(params.domainFilter); - const body = { + const body: Record = { query: params.query, max_results: params.maxResults, topic: params.searchType === "news" ? "news" : "general", @@ -177,7 +215,7 @@ function buildTavilyRequest(config, params) { }; } -function buildGooglePseRequest(config, params) { +function buildGooglePseRequest(config: SearchProviderConfig, params: SearchRequestParams) { const apiKey = params.token; const cx = getProviderSetting(params, "cx"); if (!apiKey || !cx) { @@ -192,7 +230,12 @@ function buildGooglePseRequest(config, params) { if (params.country) qp.set("gl", params.country.toLowerCase()); if (params.language) qp.set("hl", params.language); if (params.timeRange && params.timeRange !== "any") { - const dateRestrictMap = { day: "d1", week: "w1", month: "m1", year: "y1" }; + const dateRestrictMap: Record = { + day: "d1", + week: "w1", + month: "m1", + year: "y1", + }; const dateRestrict = dateRestrictMap[params.timeRange]; if (dateRestrict) qp.set("dateRestrict", dateRestrict); } @@ -208,7 +251,7 @@ function buildGooglePseRequest(config, params) { }; } -function buildLinkupRequest(config, params) { +function buildLinkupRequest(config: SearchProviderConfig, params: SearchRequestParams) { const apiKey = params.token; if (!apiKey) throw new Error("Linkup Search requires an API key"); @@ -219,7 +262,7 @@ function buildLinkupRequest(config, params) { ? requestedDepth : "standard"; - const body = { + const body: Record = { q: params.query, depth, outputType: "searchResults", @@ -249,7 +292,7 @@ function buildLinkupRequest(config, params) { }; } -function buildSearchApiRequest(config, params) { +function buildSearchApiRequest(config: SearchProviderConfig, params: SearchRequestParams) { const apiKey = params.token; if (!apiKey) throw new Error("SearchAPI requires an API key"); @@ -273,7 +316,7 @@ function buildSearchApiRequest(config, params) { }; } -function buildYouComRequest(config, params) { +function buildYouComRequest(config: SearchProviderConfig, params: SearchRequestParams) { const apiKey = params.token; if (!apiKey) throw new Error("You.com Search requires an API key"); @@ -309,7 +352,7 @@ function buildYouComRequest(config, params) { }; } -function buildSearxngRequest(config, params) { +function buildSearxngRequest(config: SearchProviderConfig, params: SearchRequestParams) { const baseUrl = resolveSearxngBaseUrl(config, params); const url = baseUrl.endsWith("/search") ? baseUrl : `${baseUrl}/search`; const qp = new URLSearchParams({ @@ -334,7 +377,7 @@ function buildSearxngRequest(config, params) { // ── Dispatcher ────────────────────────────────────────────────────────── -const BUILDERS = { +const BUILDERS: Record = { serper: buildSerperRequest, "brave-search": buildBraveRequest, exa: buildExaRequest, @@ -353,7 +396,10 @@ const BUILDERS = { * @param {SearchRequestParams} params * @returns {{url: string, init: RequestInit}} */ -export function buildSearchRequest(provider, params) { +export function buildSearchRequest( + provider: SearchProviderConfig, + params: SearchRequestParams, +): BuiltSearchRequest { const builder = BUILDERS[provider.id]; if (builder) return builder(provider, params); diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.ts similarity index 60% rename from open-sse/handlers/search/chatSearch.js rename to open-sse/handlers/search/chatSearch.ts index 594839f8..9923b7ce 100644 --- a/open-sse/handlers/search/chatSearch.js +++ b/open-sse/handlers/search/chatSearch.ts @@ -6,6 +6,111 @@ const REQUEST_TIMEOUT_MS = 15000; const DEFAULT_MAX_RESULTS = 10; +type Citation = { + url: string; + title?: string; + snippet?: string; + link?: string; + summary?: string; +}; + +type CitationCandidate = Partial & { + uri?: string; +}; + +type SearchExtract = { + text: string; + citations: CitationCandidate[]; + tokens: number; +}; + +type SearchResult = { + title: string; + url: string; + snippet: string; + position: number; + score: null; + published_at: null; + favicon_url: null; + content: null; + metadata: Record; + citation: { provider: string; retrieved_at: string; rank: number }; + provider_raw: null; +}; + +type ChatSearchConfig = { + endpoint: (model: string) => string; + defaultModel: string; + buildBody: (query: string, model: string) => Record; + buildHeaders: (token: string) => Record; + extractAnswer: (data: unknown) => SearchExtract; +}; + +type ToolCallArguments = { + search_results?: CitationCandidate[]; + results?: CitationCandidate[]; + references?: CitationCandidate[]; +}; + +type ToolCall = { + function?: { arguments?: string | ToolCallArguments }; +}; + +type OpenAiLikePayload = { + choices?: Array<{ + message?: { + content?: string; + annotations?: Array<{ url_citation?: CitationCandidate }>; + tool_calls?: ToolCall[]; + }; + }>; + citations?: unknown[]; + usage?: { total_tokens?: number }; + web_search_results?: CitationCandidate[]; +}; + +type GeminiPayload = { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + groundingMetadata?: { + groundingChunks?: Array<{ web?: { uri?: string; url?: string; title?: string } }>; + }; + }>; + usageMetadata?: { totalTokenCount?: number }; +}; + +type ResponsesPayload = { + output?: Array<{ + content?: Array<{ + text?: string; + annotations?: Array<{ url?: string; url_citation?: CitationCandidate }>; + }>; + }>; + citations?: unknown[]; + usage?: { total_tokens?: number }; +}; + +type ChatSearchParams = { + provider: string | undefined; + query: string | undefined; + maxResults?: number; + model?: string; + credentials?: Record | null; + log?: { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + /** * Normalize a citation entry into the unified result shape. * @param {{url:string, title?:string, snippet?:string}} c @@ -13,7 +118,12 @@ const DEFAULT_MAX_RESULTS = 10; * @param {string} provider * @param {string} retrievedAt */ -function toResult(c, index, provider, retrievedAt) { +function toResult( + c: CitationCandidate & { url: string }, + index: number, + provider: string, + retrievedAt: string, +): SearchResult { return { title: c.title || "", url: c.url, @@ -30,10 +140,13 @@ function toResult(c, index, provider, retrievedAt) { } /** Coerce a citation that might be a raw URL string or an object. */ -function normalizeCitation(c) { +function normalizeCitation(c: unknown): (CitationCandidate & { url: string }) | null { if (!c) return null; if (typeof c === "string") return { url: c }; - if (typeof c === "object" && c.url) return c; + if (typeof c === "object" && "url" in c && typeof c.url === "string") { + const candidate = c as CitationCandidate; + return { ...candidate, url: c.url }; + } return null; } @@ -41,7 +154,7 @@ function normalizeCitation(c) { * Provider-specific configuration map. All providers must implement: * { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer } */ -const CHAT_SEARCH_CONFIG = { +const CHAT_SEARCH_CONFIG: Record = { gemini: { endpoint: (model) => `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, @@ -55,19 +168,20 @@ const CHAT_SEARCH_CONFIG = { "x-goog-api-key": token, }), extractAnswer: (data) => { - const candidate = data?.candidates?.[0]; + const payload = data as GeminiPayload; + const candidate = payload?.candidates?.[0]; const parts = candidate?.content?.parts || []; const text = parts .map((p) => p?.text || "") .filter(Boolean) .join(""); const chunks = candidate?.groundingMetadata?.groundingChunks || []; - const citations = chunks - .map((ch) => ch?.web) - .filter(Boolean) - .map((w) => ({ url: w.uri || w.url, title: w.title || "" })) - .filter((c) => c.url); - const tokens = data?.usageMetadata?.totalTokenCount || 0; + const citations = chunks.flatMap((ch) => { + const web = ch?.web; + const url = web?.uri || web?.url; + return url ? [{ url, title: web?.title || "" }] : []; + }); + const tokens = payload?.usageMetadata?.totalTokenCount || 0; return { text, citations, tokens }; }, }, @@ -76,7 +190,7 @@ const CHAT_SEARCH_CONFIG = { endpoint: () => "https://api.openai.com/v1/chat/completions", defaultModel: "gpt-4o-mini", buildBody: (query, model) => { - const body = { + const body: Record = { model, messages: [{ role: "user", content: query }], }; @@ -91,18 +205,21 @@ const CHAT_SEARCH_CONFIG = { Authorization: `Bearer ${token}`, }), extractAnswer: (data) => { - const msg = data?.choices?.[0]?.message || {}; + const payload = data as OpenAiLikePayload; + const msg = payload?.choices?.[0]?.message || {}; const text = msg.content || ""; const annotations = Array.isArray(msg.annotations) ? msg.annotations : []; - const fromAnn = annotations - .map((a) => a?.url_citation) - .filter(Boolean) - .map((u) => ({ url: u.url, title: u.title || "" })); - const fromTop = Array.isArray(data?.citations) - ? data.citations.map(normalizeCitation).filter(Boolean) + const fromAnn = annotations.flatMap((annotation) => { + const citation = annotation?.url_citation; + return citation?.url ? [{ url: citation.url, title: citation.title || "" }] : []; + }); + const fromTop = Array.isArray(payload?.citations) + ? payload.citations + .map(normalizeCitation) + .filter((c): c is CitationCandidate & { url: string } => Boolean(c)) : []; const citations = fromAnn.length ? fromAnn : fromTop; - const tokens = data?.usage?.total_tokens || 0; + const tokens = payload?.usage?.total_tokens || 0; return { text, citations, tokens }; }, }, @@ -120,10 +237,11 @@ const CHAT_SEARCH_CONFIG = { Authorization: `Bearer ${token}`, }), extractAnswer: (data) => { + const payload = data as ResponsesPayload; // /v1/responses returns output[] array of message/tool blocks - const output = Array.isArray(data?.output) ? data.output : []; + const output = Array.isArray(payload?.output) ? payload.output : []; let text = ""; - const citations = []; + const citations: CitationCandidate[] = []; for (const item of output) { const parts = Array.isArray(item?.content) ? item.content : []; for (const p of parts) { @@ -136,13 +254,13 @@ const CHAT_SEARCH_CONFIG = { } } // Fallback: top-level citations array (some response variants) - if (!citations.length && Array.isArray(data?.citations)) { - for (const c of data.citations) { + if (!citations.length && Array.isArray(payload?.citations)) { + for (const c of payload.citations) { const n = normalizeCitation(c); if (n) citations.push(n); } } - const tokens = data?.usage?.total_tokens || 0; + const tokens = payload?.usage?.total_tokens || 0; return { text, citations, tokens }; }, }, @@ -160,14 +278,15 @@ const CHAT_SEARCH_CONFIG = { Authorization: `Bearer ${token}`, }), extractAnswer: (data) => { - const msg = data?.choices?.[0]?.message || {}; + const payload = data as OpenAiLikePayload; + const msg = payload?.choices?.[0]?.message || {}; const text = msg.content || ""; const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; - const citations = []; + const citations: CitationCandidate[] = []; for (const call of calls) { const argStr = call?.function?.arguments; if (!argStr) continue; - let parsed; + let parsed: ToolCallArguments; try { parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; } catch { @@ -186,7 +305,7 @@ const CHAT_SEARCH_CONFIG = { } } } - const tokens = data?.usage?.total_tokens || 0; + const tokens = payload?.usage?.total_tokens || 0; return { text, citations, tokens }; }, }, @@ -204,10 +323,11 @@ const CHAT_SEARCH_CONFIG = { Authorization: `Bearer ${token}`, }), extractAnswer: (data) => { - const msg = data?.choices?.[0]?.message || {}; + const payload = data as OpenAiLikePayload; + const msg = payload?.choices?.[0]?.message || {}; const text = msg.content || ""; - const citations = []; - const direct = Array.isArray(data?.web_search_results) ? data.web_search_results : []; + const citations: CitationCandidate[] = []; + const direct = Array.isArray(payload?.web_search_results) ? payload.web_search_results : []; for (const it of direct) { const url = it?.url || it?.link; if (url) { @@ -223,7 +343,7 @@ const CHAT_SEARCH_CONFIG = { for (const call of calls) { const argStr = call?.function?.arguments; if (!argStr) continue; - let parsed; + let parsed: ToolCallArguments; try { parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; } catch { @@ -243,7 +363,7 @@ const CHAT_SEARCH_CONFIG = { } } } - const tokens = data?.usage?.total_tokens || 0; + const tokens = payload?.usage?.total_tokens || 0; return { text, citations, tokens }; }, }, @@ -260,9 +380,17 @@ const CHAT_SEARCH_CONFIG = { * @param {{info?:Function, warn?:Function, error?:Function}} [params.log] * @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>} */ -export async function handleChatSearch({ provider, query, maxResults, model, credentials, log }) { +export async function handleChatSearch({ + provider, + query, + maxResults, + model, + credentials, + log, +}: ChatSearchParams) { const startTime = Date.now(); - const cfg = CHAT_SEARCH_CONFIG[provider]; + const providerId = provider || ""; + const cfg = CHAT_SEARCH_CONFIG[providerId]; if (!cfg) { return { @@ -276,7 +404,8 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre return { success: false, status: 400, error: "Missing query" }; } - const token = credentials?.apiKey || credentials?.accessToken; + const rawToken = credentials?.apiKey || credentials?.accessToken; + const token = typeof rawToken === "string" ? rawToken : ""; if (!token) { return { success: false, @@ -286,7 +415,9 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre } const limit = - Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : DEFAULT_MAX_RESULTS; + typeof maxResults === "number" && Number.isFinite(maxResults) && maxResults > 0 + ? Math.floor(maxResults) + : DEFAULT_MAX_RESULTS; const useModel = model || cfg.defaultModel; const url = cfg.endpoint(useModel); const body = cfg.buildBody(query, useModel); @@ -296,7 +427,7 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const upstreamStart = Date.now(); - let resp; + let resp: Response; try { resp = await fetch(url, { method: "POST", @@ -304,26 +435,28 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre body: JSON.stringify(body), signal: controller.signal, }); - } catch (err) { + } catch (err: unknown) { clearTimeout(timer); - if (err?.name === "AbortError") { + if (isAbortError(err)) { log?.warn?.(`[chatSearch] timeout provider=${provider}`); return { success: false, status: 504, error: "Upstream timeout" }; } - log?.error?.(`[chatSearch] network error provider=${provider}: ${err?.message}`); + const message = errorMessage(err); + log?.error?.(`[chatSearch] network error provider=${provider}: ${message}`); return { success: false, status: 502, - error: `Network error: ${err?.message || "unknown"}`, + error: `Network error: ${message || "unknown"}`, }; } clearTimeout(timer); const upstreamLatency = Date.now() - upstreamStart; - let data; + let data: unknown; try { data = await resp.json(); } catch { + // Upstream returned a non-JSON body for a JSON chat-search endpoint. return { success: false, status: 502, @@ -332,8 +465,15 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre } if (!resp.ok) { + const payload = data as { error?: { message?: unknown } | unknown; message?: unknown }; + const errorValue = payload.error; const errMsg = - data?.error?.message || data?.error || data?.message || `Upstream HTTP ${resp.status}`; + (typeof errorValue === "object" && errorValue && "message" in errorValue + ? errorValue.message + : undefined) || + errorValue || + payload.message || + `Upstream HTTP ${resp.status}`; log?.warn?.(`[chatSearch] upstream error provider=${provider} status=${resp.status}`); return { success: false, @@ -345,16 +485,19 @@ export async function handleChatSearch({ provider, query, maxResults, model, cre const { text, citations, tokens } = cfg.extractAnswer(data); const retrievedAt = new Date().toISOString(); const limited = (citations || []).slice(0, limit); - const results = limited.map((c, i) => toResult(c, i, provider, retrievedAt)); + const results = limited + .map(normalizeCitation) + .filter((c): c is CitationCandidate & { url: string } => Boolean(c)) + .map((c, i) => toResult(c, i, providerId, retrievedAt)); return { success: true, status: 200, data: { - provider, + provider: providerId, query, results, - answer: { source: provider, text: text || "", model: useModel }, + answer: { source: providerId, text: text || "", model: useModel }, usage: { queries_used: 1, search_cost_usd: 0, llm_tokens: tokens || 0 }, metrics: { response_time_ms: Date.now() - startTime, diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.ts similarity index 64% rename from open-sse/handlers/search/index.js rename to open-sse/handlers/search/index.ts index a2205331..90a18da0 100644 --- a/open-sse/handlers/search/index.js +++ b/open-sse/handlers/search/index.ts @@ -11,14 +11,102 @@ import { buildSearchRequest } from "./callers.js"; import { handleChatSearch } from "./chatSearch.js"; import { normalizeSearchResponse } from "./normalizers.js"; +export type SearchResult = + | { success: true; response: Response; data?: unknown } + | { success: false; status: number; error: string; response?: Response }; + +type SearchRequestBody = Record & { + query: string; + search_type?: string; + max_results?: number; + country?: string; + language?: string; + time_range?: string; + offset?: number; + domain_filter?: string[]; + content_options?: { + snippet?: boolean; + full_page?: boolean; + format?: string; + max_characters?: number; + }; + provider_options?: Record; +}; +type SearchProvider = { id: string; searchViaChat?: { defaultModel?: string } }; +type DedicatedSearchConfig = Record & { + baseUrl?: string; + authType?: string; + defaultMaxResults?: number; + maxMaxResults?: number; + searchTypes?: string[]; + timeoutMs?: number; +}; +type SearchLog = { + error?: (...args: unknown[]) => void; + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; +}; +type SearchCredentials = { + accessToken?: string; + apiKey?: string; + providerSpecificData?: Record; +} | null; + +export interface SearchCoreParams { + body: SearchRequestBody; + provider: SearchProvider; + providerConfig?: DedicatedSearchConfig; + credentials: Record | null; + log: unknown; + onCredentialsRefreshed?: (newCreds: Record) => Promise | void; + onRequestSuccess?: () => Promise | void; +} + const GLOBAL_TIMEOUT_MS = 15000; const NON_RETRIABLE = new Set([400, 401, 403, 404]); -const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; const LOCALHOST_URL_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?/i; -function formatSearxngError({ status, statusText = "", text = "", fetchUrl = "" }) { - const endpointLabel = fetchUrl ? fetchUrl.split("?")[0] : "configured endpoint"; +function hasInvalidControlChar(text: string) { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) { + return true; + } + } + return false; +} + +function stripNonAscii(text: string) { + let clean = ""; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) <= 255) clean += text[i] ?? ""; + } + return clean; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +function formatSearxngError({ + status, + statusText = "", + text = "", + fetchUrl = "", +}: { + fetchUrl?: string; + status: number; + statusText?: string; + text?: string; +}) { + const endpointLabel = fetchUrl + ? fetchUrl.split("?")[0] || "configured endpoint" + : "configured endpoint"; if (status === 403) { return `searxng returned 403 from ${endpointLabel}. This instance may block JSON API requests (format=json). Use a SearXNG instance that allows JSON or run your own instance.`; } @@ -29,34 +117,38 @@ function formatSearxngError({ status, statusText = "", text = "", fetchUrl = "" return `searxng returned ${status} from ${endpointLabel}: ${detail.slice(0, 200)}`; } -function formatSearxngNetworkError({ err, fetchUrl = "" }) { - const endpointLabel = fetchUrl ? fetchUrl.split("?")[0] : "configured endpoint"; +function formatSearxngNetworkError({ err, fetchUrl = "" }: { err: unknown; fetchUrl?: string }) { + const endpointLabel = fetchUrl + ? fetchUrl.split("?")[0] || "configured endpoint" + : "configured endpoint"; if (LOCALHOST_URL_RE.test(endpointLabel)) { return `Unable to connect to ${endpointLabel}. Start SearXNG locally on that URL or set provider_options.baseUrl to a reachable SearXNG instance.`; } - return `searxng connection error at ${endpointLabel}: ${err?.message || "network error"}`; + return `searxng connection error at ${endpointLabel}: ${err instanceof Error ? err.message : "network error"}`; } /** Normalize and validate query string. */ -function sanitizeQuery(query) { - if (CONTROL_CHAR_RE.test(query)) return { error: "Query contains invalid control characters" }; +function sanitizeQuery( + query: string, +): { clean: string; error?: undefined } | { clean?: undefined; error: string } { + if (hasInvalidControlChar(query)) return { error: "Query contains invalid control characters" }; const clean = query.normalize("NFKC").trim().replace(/\s+/g, " "); if (!clean) return { error: "Query is empty after normalization" }; return { clean }; } // Strip non-ASCII chars from header values (HTTP headers must be ByteString). -function sanitizeHeaders(headers) { +function sanitizeHeaders(headers: HeadersInit | undefined) { if (!headers) return headers; - const out = {}; + const out: Record = {}; for (const [k, v] of Object.entries(headers)) { - out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + out[k] = typeof v === "string" ? stripNonAscii(v).trim() : v; } return out; } /** Build a JSON Response wrapper used by the auth layer. */ -function jsonResponse(payload, status = 200) { +function jsonResponse(payload: unknown, status: number = 200) { return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, @@ -64,7 +156,7 @@ function jsonResponse(payload, status = 200) { } /** Wrap an error result with a Response object so the auth wrapper can return it directly. */ -function errorResult(status, error) { +function errorResult(status: number, error: string): SearchResult { return { success: false, status, @@ -74,7 +166,7 @@ function errorResult(status, error) { } /** Wrap a success payload. */ -function successResult(data) { +function successResult(data: unknown): SearchResult { return { success: true, data, response: jsonResponse(data, 200) }; } @@ -89,6 +181,13 @@ async function tryDedicatedProvider({ credentials, log, globalStartTime, +}: { + provider: SearchProvider; + providerConfig: DedicatedSearchConfig; + body: SearchRequestBody; + credentials: SearchCredentials; + log: SearchLog; + globalStartTime: number; }) { const startTime = Date.now(); const token = credentials?.apiKey || credentials?.accessToken || undefined; @@ -117,12 +216,15 @@ async function tryDedicatedProvider({ let url, init; try { - ({ url, init } = buildSearchRequest({ id: provider.id, ...providerConfig }, params)); - } catch (err) { + ({ url, init } = buildSearchRequest( + { id: provider.id, baseUrl: String(providerConfig.baseUrl || ""), ...providerConfig }, + params, + )); + } catch (err: unknown) { return { success: false, status: 400, - error: err?.message || `Invalid request for ${provider.id}`, + error: errorMessage(err) || `Invalid request for ${provider.id}`, }; } @@ -140,12 +242,15 @@ async function tryDedicatedProvider({ try { const resp = await fetch(url, { ...init, - headers: sanitizeHeaders(init.headers), + headers: sanitizeHeaders(init.headers as HeadersInit | undefined), signal: controller.signal, }); clearTimeout(timer); if (!resp.ok) { - const errText = await resp.text().catch(() => ""); + const errText = await resp.text().catch(() => { + // Best-effort upstream error body read. + return ""; + }); log?.error?.("SEARCH", `${provider.id} ${resp.status}: ${errText.slice(0, 200)}`); if (provider.id === "searxng") { return { @@ -186,11 +291,12 @@ async function tryDedicatedProvider({ errors: [], }, }; - } catch (err) { + } catch (err: unknown) { clearTimeout(timer); - const isTimeout = err.name === "AbortError"; + const isTimeout = isAbortError(err); const status = isTimeout ? 504 : 502; - log?.error?.("SEARCH", `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}`); + const message = errorMessage(err); + log?.error?.("SEARCH", `${provider.id} ${isTimeout ? "timeout" : "error"}: ${message}`); if (provider.id === "searxng" && !isTimeout) { return { success: false, @@ -201,7 +307,7 @@ async function tryDedicatedProvider({ return { success: false, status, - error: `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}`, + error: `${provider.id} ${isTimeout ? "timeout" : "error"}: ${message}`, }; } } @@ -217,13 +323,20 @@ async function tryDedicatedProvider({ * @param {object|null} options.credentials Provider credentials * @param {object} [options.log] Logger */ -export async function handleSearchCore({ body, provider, providerConfig, credentials, log }) { +export async function handleSearchCore({ + body, + provider, + providerConfig, + credentials, + log, +}: SearchCoreParams): Promise { const globalStartTime = Date.now(); + const typedLog = log as SearchLog; // 1. Sanitize query const { clean, error: sanitizeError } = sanitizeQuery(body.query || ""); if (sanitizeError) return errorResult(400, sanitizeError); - const normalizedBody = { ...body, query: clean }; + const normalizedBody: SearchRequestBody = { ...body, query: clean || "" }; // 2. Route: dedicated search API takes priority over chat-based let result; @@ -233,7 +346,7 @@ export async function handleSearchCore({ body, provider, providerConfig, credent providerConfig, body: normalizedBody, credentials, - log, + log: typedLog, globalStartTime, }); } else if (provider.searchViaChat) { @@ -243,7 +356,7 @@ export async function handleSearchCore({ body, provider, providerConfig, credent maxResults: normalizedBody.max_results, model: provider.searchViaChat.defaultModel, credentials, - log, + log: typedLog, }); } else { return errorResult(400, `Provider ${provider.id} does not support web search`); @@ -258,7 +371,7 @@ export async function handleSearchCore({ body, provider, providerConfig, credent provider.searchViaChat && providerConfig ) { - log?.warn?.( + typedLog?.warn?.( "SEARCH", `${provider.id} dedicated failed (${result.status}), falling back to chat-based search`, ); @@ -268,7 +381,7 @@ export async function handleSearchCore({ body, provider, providerConfig, credent maxResults: normalizedBody.max_results, model: provider.searchViaChat.defaultModel, credentials, - log, + log: typedLog, }); if (fallback.success) return successResult(fallback.data); } diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.ts similarity index 62% rename from open-sse/handlers/search/normalizers.js rename to open-sse/handlers/search/normalizers.ts index 9dd56d66..567a27d3 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.ts @@ -5,8 +5,86 @@ * Each normalizer maps a provider-specific response into the unified SearchResult shape. */ +type SearchResultInput = { + author?: string | null; + favicon_url?: string | null; + full_text?: string; + image_url?: string | null; + published_at?: string | null; + score?: number; + snippet?: string; + source_type?: string | null; + text_format?: string; + title?: string; + url?: string; +}; + +type SearchRawItem = SearchResultInput & { + age?: string; + category?: string; + content?: string; + date?: string; + description?: string; + engine?: string; + engines?: string[]; + favicon?: string; + highlights?: string[]; + html?: string; + image?: string; + imageUrl?: string; + img_src?: string; + link?: string; + markdown?: string; + meta_url?: { favicon?: string }; + name?: string; + pagemap?: { + cse_image?: { src?: string }[]; + cse_thumbnail?: { src?: string }[]; + metatags?: Record[]; + }; + page_age?: string; + publishedDate?: string; + published_date?: string; + raw_content?: string; + snippets?: unknown[]; + source?: string; + thumbnail?: string; + thumbnail_url?: string; + text?: string; + type?: string; +}; + +type SearchProviderData = { + items?: unknown[]; + news?: unknown; + organic?: unknown; + organic_results?: unknown[]; + queries?: { request?: { totalResults?: string | number }[] }; + results?: unknown; + search_information?: { total_results?: string | number }; + searchInformation?: { totalResults?: string | number }; + searchParameters?: { totalResults?: number }; + top_stories?: unknown[]; + web?: { results?: unknown[]; totalCount?: unknown }; +}; + +type NormalizedSearchResponse = { results: ReturnType[]; totalResults: unknown }; +type SearchNormalizer = ( + data: SearchProviderData, + query: string, + searchType: string, +) => NormalizedSearchResponse; + +function asSearchProviderData(data: unknown): SearchProviderData { + return data && typeof data === "object" ? (data as SearchProviderData) : {}; +} + +function asSearchRawItems(items: unknown): SearchRawItem[] { + return Array.isArray(items) ? (items as SearchRawItem[]) : []; +} + /** Build a unified SearchResult object. */ -function makeResult(providerId, item, idx, now) { +function makeResult(providerId: string, item: SearchResultInput, idx: number, now: string) { const url = item.url || ""; return { title: item.title || "", @@ -31,11 +109,11 @@ function makeResult(providerId, item, idx, now) { }; } -function normalizeSerper(data, _query, searchType) { +function normalizeSerper(data: SearchProviderData, _query: string, searchType: string) { const now = new Date().toISOString(); const items = searchType === "news" ? data.news : data.organic; if (!Array.isArray(items)) return { results: [], totalResults: null }; - const results = items.map((item, idx) => + const results = asSearchRawItems(items).map((item, idx) => makeResult( "serper", { @@ -52,12 +130,14 @@ function normalizeSerper(data, _query, searchType) { return { results, totalResults: typeof total === "number" ? total : null }; } -function normalizeBrave(data, _query, searchType) { +function normalizeBrave(data: SearchProviderData, _query: string, searchType: string) { const now = new Date().toISOString(); - const container = searchType === "news" ? data.news || data : data.web; + const container = (searchType === "news" ? data.news || data : data.web) as + | { results?: unknown[]; totalCount?: unknown } + | undefined; const items = container?.results; if (!Array.isArray(items)) return { results: [], totalResults: null }; - const results = items.map((item, idx) => + const results = asSearchRawItems(items).map((item, idx) => makeResult( "brave-search", { @@ -74,11 +154,11 @@ function normalizeBrave(data, _query, searchType) { return { results, totalResults: container?.totalCount ?? null }; } -function normalizeExa(data, _query, _searchType) { +function normalizeExa(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); const items = data.results; if (!Array.isArray(items)) return { results: [], totalResults: null }; - const results = items.map((item, idx) => + const results = asSearchRawItems(items).map((item, idx) => makeResult( "exa", { @@ -100,11 +180,11 @@ function normalizeExa(data, _query, _searchType) { return { results, totalResults: results.length }; } -function normalizeTavily(data, _query, _searchType) { +function normalizeTavily(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); const items = data.results; if (!Array.isArray(items)) return { results: [], totalResults: null }; - const results = items.map((item, idx) => + const results = asSearchRawItems(items).map((item, idx) => makeResult( "tavily", { @@ -123,10 +203,10 @@ function normalizeTavily(data, _query, _searchType) { return { results, totalResults: results.length }; } -function normalizeGooglePse(data, _query, _searchType) { +function normalizeGooglePse(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); - const items = Array.isArray(data.items) ? data.items : []; - const results = items.map((item, idx) => + const items = asSearchRawItems(data.items); + const results = asSearchRawItems(items).map((item, idx) => makeResult( "google-pse", { @@ -148,10 +228,10 @@ function normalizeGooglePse(data, _query, _searchType) { return { results, totalResults: Number.isFinite(total) ? total : null }; } -function normalizeLinkup(data, _query, _searchType) { +function normalizeLinkup(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); - const items = Array.isArray(data.results) ? data.results : []; - const results = items.map((item, idx) => + const items = asSearchRawItems(data.results); + const results = asSearchRawItems(items).map((item, idx) => makeResult( "linkup", { @@ -170,14 +250,14 @@ function normalizeLinkup(data, _query, _searchType) { return { results, totalResults: results.length }; } -function normalizeSearchApi(data, _query, _searchType) { +function normalizeSearchApi(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); const items = Array.isArray(data.organic_results) ? data.organic_results : Array.isArray(data.top_stories) ? data.top_stories : []; - const results = items.map((item, idx) => + const results = asSearchRawItems(items).map((item, idx) => makeResult( "searchapi", { @@ -198,12 +278,15 @@ function normalizeSearchApi(data, _query, _searchType) { return { results, totalResults: Number.isFinite(total) ? total : results.length }; } -function normalizeYouCom(data, _query, searchType) { +function normalizeYouCom(data: SearchProviderData, _query: string, searchType: string) { const now = new Date().toISOString(); - const container = data?.results && typeof data.results === "object" ? data.results : undefined; + const container = + data?.results && typeof data.results === "object" + ? (data.results as { news?: unknown[]; web?: unknown[] }) + : undefined; const section = searchType === "news" ? container?.news || [] : container?.web || []; - const items = Array.isArray(section) ? section : []; - const results = items.map((item, idx) => { + const items = asSearchRawItems(section); + const results = asSearchRawItems(items).map((item, idx) => { const firstSnippet = Array.isArray(item.snippets) ? item.snippets.find((v) => typeof v === "string") : null; @@ -239,10 +322,10 @@ function normalizeYouCom(data, _query, searchType) { return { results, totalResults: results.length }; } -function normalizeSearxng(data, _query, _searchType) { +function normalizeSearxng(data: SearchProviderData, _query: string, _searchType: string) { const now = new Date().toISOString(); - const items = Array.isArray(data.results) ? data.results : []; - const results = items.map((item, idx) => + const items = asSearchRawItems(data.results); + const results = asSearchRawItems(items).map((item, idx) => makeResult( "searxng", { @@ -262,7 +345,7 @@ function normalizeSearxng(data, _query, _searchType) { return { results, totalResults: results.length }; } -const NORMALIZERS = { +const NORMALIZERS: Record = { serper: normalizeSerper, "brave-search": normalizeBrave, exa: normalizeExa, @@ -278,7 +361,14 @@ const NORMALIZERS = { * Dispatch to the appropriate normalizer based on providerId. * @returns {{results: Array, totalResults: number|null}} */ -export function normalizeSearchResponse(providerId, data, query, searchType) { +export function normalizeSearchResponse( + providerId: string, + data: unknown, + query: string, + searchType: string, +) { const fn = NORMALIZERS[providerId]; - return fn ? fn(data, query, searchType) : { results: [], totalResults: null }; + return fn + ? fn(asSearchProviderData(data), query, searchType) + : { results: [], totalResults: null }; } diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.ts similarity index 62% rename from open-sse/handlers/sttCore.js rename to open-sse/handlers/sttCore.ts index a051f4e1..177d4984 100644 --- a/open-sse/handlers/sttCore.js +++ b/open-sse/handlers/sttCore.ts @@ -1,32 +1,57 @@ import { Buffer } from "node:buffer"; import { AI_PROVIDERS } from "../../src/shared/constants/providers"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; -import { createErrorResult } from "../utils/error.js"; +import { createErrorResult, type ErrorResult } from "../utils/error.js"; + +export type SttResult = { success: true; response: Response } | ErrorResult; + +export interface SttCoreParams { + provider: string; + model: string; + formData: FormData; + credentials?: Record | null; + translate?: boolean; +} + +type SttConfig = { + authHeader?: "bearer" | "token" | "x-api-key" | "key"; + authType?: string; + baseUrl: string; + format?: string; +}; + +type UploadFile = Blob & { name?: string; type?: string }; +type TextPart = { text?: string }; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} // Build auth headers from sttConfig + token -function buildAuthHeaders(cfg, token) { +function buildAuthHeaders(cfg: SttConfig, token: unknown): Record { if (!token) return {}; + const value = String(token); switch (cfg.authHeader) { case "bearer": - return { Authorization: `Bearer ${token}` }; + return { Authorization: `Bearer ${value}` }; case "token": - return { Authorization: `Token ${token}` }; + return { Authorization: `Token ${value}` }; case "x-api-key": - return { "x-api-key": token }; + return { "x-api-key": value }; case "key": - return { Authorization: `Key ${token}` }; + return { Authorization: `Key ${value}` }; default: - return { Authorization: `Bearer ${token}` }; + return { Authorization: `Bearer ${value}` }; } } // Map browser file MIME / ext → audio MIME for binary formats (deepgram/HF) -function resolveAudioContentType(file) { +function resolveAudioContentType(file: UploadFile) { const t = (file.type || "").toLowerCase(); if (t.startsWith("audio/")) return t; const name = typeof file.name === "string" ? file.name.toLowerCase() : ""; - const ext = name.includes(".") ? name.split(".").pop() : ""; - const map = { + const ext = name.includes(".") ? (name.split(".").pop() ?? "") : ""; + const map: Record = { mp3: "audio/mpeg", mp4: "audio/mp4", m4a: "audio/mp4", @@ -40,7 +65,7 @@ function resolveAudioContentType(file) { return map[ext] || "application/octet-stream"; } -async function upstreamError(res) { +async function upstreamError(res: Response): Promise { let txt = ""; try { txt = await res.text(); @@ -50,11 +75,21 @@ async function upstreamError(res) { const j = JSON.parse(txt); msg = j?.error?.message || j?.error || j?.message || msg; } catch {} - return createErrorResult(res.status, typeof msg === "string" ? msg : JSON.stringify(msg)); + return createErrorResult( + res.status, + typeof msg === "string" ? msg : JSON.stringify(msg), + undefined, + ); } // Deepgram: raw binary POST + model query param -async function transcribeDeepgram(cfg, file, model, token, formData) { +async function transcribeDeepgram( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, + formData: FormData, +): Promise { const url = new URL(cfg.baseUrl); url.searchParams.set("model", model); url.searchParams.set("smart_format", "true"); @@ -66,17 +101,27 @@ async function transcribeDeepgram(cfg, file, model, token, formData) { const buf = await file.arrayBuffer(); const res = await fetch(url, { method: "POST", - headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + headers: { + ...buildAuthHeaders(cfg, token), + "Content-Type": resolveAudioContentType(file), + }, body: buf, }); if (!res.ok) return upstreamError(res); - const data = await res.json(); + const data = (await res.json()) as { + results?: { channels?: { alternatives?: { transcript?: string }[] }[] }; + }; const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ""; return jsonResponse({ text }); } // AssemblyAI: upload → submit → poll (max 120s) -async function transcribeAssemblyAI(cfg, file, model, token) { +async function transcribeAssemblyAI( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, +): Promise { const auth = buildAuthHeaders(cfg, token); const buf = await file.arrayBuffer(); const up = await fetch("https://api.assemblyai.com/v2/upload", { @@ -85,7 +130,7 @@ async function transcribeAssemblyAI(cfg, file, model, token) { body: buf, }); if (!up.ok) return upstreamError(up); - const { upload_url } = await up.json(); + const { upload_url } = (await up.json()) as { upload_url?: string }; const sub = await fetch(cfg.baseUrl, { method: "POST", @@ -97,22 +142,28 @@ async function transcribeAssemblyAI(cfg, file, model, token) { }), }); if (!sub.ok) return upstreamError(sub); - const { id } = await sub.json(); + const { id } = (await sub.json()) as { id?: string }; const start = Date.now(); while (Date.now() - start < 120_000) { - await new Promise((r) => setTimeout(r, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); const poll = await fetch(`${cfg.baseUrl}/${id}`, { headers: auth }); if (!poll.ok) continue; - const r = await poll.json(); + const r = (await poll.json()) as { error?: string; status?: string; text?: string }; if (r.status === "completed") return jsonResponse({ text: r.text || "" }); - if (r.status === "error") return createErrorResult(500, r.error || "AssemblyAI failed"); + if (r.status === "error") + return createErrorResult(500, r.error || "AssemblyAI failed", undefined); } - return createErrorResult(504, "AssemblyAI timeout after 120s"); + return createErrorResult(504, "AssemblyAI timeout after 120s", undefined); } // Nvidia NIM: multipart, normalize response -async function transcribeNvidia(cfg, file, model, token) { +async function transcribeNvidia( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, +): Promise { const fd = new FormData(); fd.append("file", file, file.name || "audio.wav"); fd.append("model", model); @@ -122,12 +173,18 @@ async function transcribeNvidia(cfg, file, model, token) { body: fd, }); if (!res.ok) return upstreamError(res); - const data = await res.json(); + const data = (await res.json()) as { text?: string; transcript?: string }; return jsonResponse({ text: data.text || data.transcript || "" }); } // Gemini: generateContent with inline_data audio + transcription prompt -async function transcribeGemini(cfg, file, model, token, formData) { +async function transcribeGemini( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, + formData: FormData, +): Promise { const buf = await file.arrayBuffer(); const b64 = Buffer.from(buf).toString("base64"); const mime = resolveAudioContentType(file); @@ -150,7 +207,9 @@ async function transcribeGemini(cfg, file, model, token, formData) { }), }); if (!res.ok) return upstreamError(res); - const data = await res.json(); + const data = (await res.json()) as { + candidates?: { content?: { parts?: TextPart[] } }[]; + }; const text = data?.candidates?.[0]?.content?.parts ?.map((p) => p.text) @@ -160,23 +219,38 @@ async function transcribeGemini(cfg, file, model, token, formData) { } // HuggingFace: POST raw binary to {baseUrl}/{model_id} -async function transcribeHuggingFace(cfg, file, model, token) { +async function transcribeHuggingFace( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, +): Promise { if (model.includes("..") || model.includes("//")) - return createErrorResult(400, "Invalid model ID"); + return createErrorResult(400, "Invalid model ID", undefined); const url = `${cfg.baseUrl.replace(/\/+$/, "")}/${model}`; const buf = await file.arrayBuffer(); const res = await fetch(url, { method: "POST", - headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + headers: { + ...buildAuthHeaders(cfg, token), + "Content-Type": resolveAudioContentType(file), + }, body: buf, }); if (!res.ok) return upstreamError(res); - const data = await res.json(); + const data = (await res.json()) as { text?: string }; return jsonResponse({ text: data.text || "" }); } // Default: OpenAI/Groq/Whisper-compatible multipart -async function transcribeOpenAICompatible(cfg, file, model, token, formData) { +async function transcribeOpenAICompatible( + cfg: SttConfig, + file: UploadFile, + model: string, + token: unknown, + formData: FormData, + translate: boolean | undefined, +): Promise { const fd = new FormData(); fd.append("file", file, file.name || "audio.wav"); fd.append("model", model); @@ -205,7 +279,7 @@ async function transcribeOpenAICompatible(cfg, file, model, token, formData) { }; } -function jsonResponse(obj) { +function jsonResponse(obj: unknown): SttResult { return { success: true, response: new Response(JSON.stringify(obj), { @@ -219,15 +293,23 @@ function jsonResponse(obj) { * STT core handler — dispatch by sttConfig.format. * @returns {Promise<{success, response, status?, error?}>} */ -export async function handleSttCore({ provider, model, formData, credentials, translate }) { - const file = formData.get("file"); - if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); +export async function handleSttCore({ + provider, + model, + formData, + credentials, + translate, +}: SttCoreParams): Promise { + const file = formData.get("file") as UploadFile | null; + if (!file) + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file", undefined); - const cfg = AI_PROVIDERS[provider]?.sttConfig; + const cfg = AI_PROVIDERS[provider]?.sttConfig as SttConfig | undefined; if (!cfg) return createErrorResult( HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`, + undefined, ); const token = cfg.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; @@ -235,6 +317,7 @@ export async function handleSttCore({ provider, model, formData, credentials, tr return createErrorResult( HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`, + undefined, ); } @@ -251,9 +334,13 @@ export async function handleSttCore({ provider, model, formData, credentials, tr case "gemini-stt": return await transcribeGemini(cfg, file, model, token, formData); default: - return await transcribeOpenAICompatible(cfg, file, model, token, formData); + return await transcribeOpenAICompatible(cfg, file, model, token, formData, translate); } - } catch (err) { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); + } catch (err: unknown) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + errorMessage(err) || "STT request failed", + undefined, + ); } } diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.ts similarity index 76% rename from open-sse/handlers/ttsCore.js rename to open-sse/handlers/ttsCore.ts index dd56217d..9b9b22e3 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.ts @@ -1,6 +1,6 @@ import { Buffer } from "node:buffer"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; -import { createErrorResult } from "../utils/error.js"; +import { createErrorResult, type ErrorResult } from "../utils/error.js"; import { getTtsAdapter, synthesizeViaConfig } from "./ttsProviders/index.js"; // Re-export voice fetchers + voices APIs for backward compat with existing routes @@ -11,8 +11,25 @@ export { VOICE_FETCHERS, } from "./ttsProviders/index.js"; +export type TtsResult = { success: true; response: Response } | ErrorResult; + +export interface TtsCoreParams { + provider: string; + model: string; + input: string; + responseFormat?: string; + language?: string; + credentials?: Record | null; + voice?: string; + speed?: number; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + // ── Response Formatter (DRY) ─────────────────────────────────── -function createTtsResponse(base64Audio, format, responseFormat) { +function createTtsResponse(base64Audio: string, format: string, responseFormat: string): TtsResult { const audioBuffer = Buffer.from(base64Audio, "base64"); // JSON format: return base64 encoded audio @@ -45,8 +62,6 @@ function createTtsResponse(base64Audio, format, responseFormat) { /** * Synthesize text to audio. Provider logic lives in `./ttsProviders/{id}.js` * or is dispatched generically via `ttsConfig.format`. - * - * @returns {Promise<{success, response, status?, error?}>} */ export async function handleTtsCore({ provider, @@ -57,9 +72,9 @@ export async function handleTtsCore({ language, voice, speed, -}) { +}: TtsCoreParams): Promise { if (!input?.trim()) { - return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input", undefined); } try { @@ -83,8 +98,13 @@ export async function handleTtsCore({ return createErrorResult( HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`, + undefined, + ); + } catch (err: unknown) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + errorMessage(err) || "TTS synthesis failed", + undefined, ); - } catch (err) { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "TTS synthesis failed"); } } diff --git a/open-sse/handlers/ttsProviders/_base.js b/open-sse/handlers/ttsProviders/_base.ts similarity index 82% rename from open-sse/handlers/ttsProviders/_base.js rename to open-sse/handlers/ttsProviders/_base.ts index 7daad730..05ad1444 100644 --- a/open-sse/handlers/ttsProviders/_base.js +++ b/open-sse/handlers/ttsProviders/_base.ts @@ -5,7 +5,7 @@ export const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; // Convert upstream Response (binary audio) to { base64, format } -export async function responseToBase64(res, defaultFormat = "mp3") { +export async function responseToBase64(res: Response, defaultFormat: string = "mp3") { const buf = await res.arrayBuffer(); if (buf.byteLength < 100) throw new Error("Upstream returned empty audio"); const ctype = res.headers.get("content-type") || ""; @@ -16,7 +16,7 @@ export async function responseToBase64(res, defaultFormat = "mp3") { return { base64: Buffer.from(buf).toString("base64"), format }; } -export async function throwUpstreamError(res) { +export async function throwUpstreamError(res: Response) { const text = await res.text().catch(() => ""); let msg = `Upstream error (${res.status})`; try { @@ -35,10 +35,15 @@ export async function throwUpstreamError(res) { } // Parse `model` string as "modelId/voiceId" — match against known model list (longest prefix wins) -export function parseModelVoice(model, defaultModel = "", defaultVoice = "", knownModels = []) { +export function parseModelVoice( + model: string, + defaultModel: string = "", + defaultVoice: string = "", + knownModels: ({ id?: string } | string)[] = [], +) { if (!model) return { modelId: defaultModel, voiceId: defaultVoice }; const known = knownModels - .map((m) => m.id || m) + .map((m) => (typeof m === "string" ? m : m.id || "")) .filter(Boolean) .sort((a, b) => b.length - a.length); for (const id of known) { diff --git a/open-sse/handlers/ttsProviders/edgeTts.js b/open-sse/handlers/ttsProviders/edgeTts.ts similarity index 84% rename from open-sse/handlers/ttsProviders/edgeTts.js rename to open-sse/handlers/ttsProviders/edgeTts.ts index 00c67cc7..fd8e12c4 100644 --- a/open-sse/handlers/ttsProviders/edgeTts.js +++ b/open-sse/handlers/ttsProviders/edgeTts.ts @@ -5,8 +5,9 @@ import { UA } from "./_base.js"; const REFRESH_MS = 5 * 60 * 1000; // token TTL ~1h, refresh early const VOICES_TTL = 24 * 60 * 60 * 1000; -const cache = { token: null, tokenTime: 0 }; -let _voicesCache = null; +type EdgeToken = { cookie?: string; key?: string; token?: string }; +const cache: { token: EdgeToken | null; tokenTime: number } = { token: null, tokenTime: 0 }; +let _voicesCache: unknown = null; let _voicesCacheTime = 0; async function getToken() { @@ -17,24 +18,24 @@ async function getToken() { }); if (!res.ok) throw new Error(`Bing translator fetch failed: ${res.status}`); const rawCookies = res.headers.getSetCookie?.() || []; - const cookie = rawCookies.map((c) => c.split(";")[0]).join("; "); + const cookie = rawCookies.map((c: string) => c.split(";")[0]).join("; "); const html = await res.text(); const match = html.match(/params_AbusePreventionHelper\s*=\s*\[([^,]+),([^,]+),/); if (!match) throw new Error("Failed to parse Bing token"); - cache.token = { key: match[1], token: match[2].replace(/"/g, ""), cookie }; + cache.token = { key: match[1], token: (match[2] || "").replace(/"/g, ""), cookie }; cache.tokenTime = now; return cache.token; } -async function ttsRequest(text, voiceId, token) { +async function ttsRequest(text: string, voiceId: string, token: EdgeToken) { const parts = voiceId.split("-"); const xmlLang = parts.slice(0, 2).join("-"); const gender = voiceId.toLowerCase().includes("male") ? "Male" : "Female"; const ssml = `${text}`; const body = new URLSearchParams(); body.append("ssml", ssml); - body.append("token", token.token); - body.append("key", token.key); + body.append("token", token.token || ""); + body.append("key", token.key || ""); return fetch("https://www.bing.com/tfettts?isVertical=1&&IG=1&IID=translator.5023&SFX=1", { method: "POST", body: body.toString(), @@ -65,7 +66,7 @@ export async function fetchEdgeTtsVoices() { export default { noAuth: true, - async synthesize(text, model) { + async synthesize(text: string, model: string) { const voiceId = model || "vi-VN-HoaiMyNeural"; let token = await getToken(); let res = await ttsRequest(text, voiceId, token); diff --git a/open-sse/handlers/ttsProviders/elevenlabs.js b/open-sse/handlers/ttsProviders/elevenlabs.ts similarity index 77% rename from open-sse/handlers/ttsProviders/elevenlabs.js rename to open-sse/handlers/ttsProviders/elevenlabs.ts index 711a5672..964c4339 100644 --- a/open-sse/handlers/ttsProviders/elevenlabs.js +++ b/open-sse/handlers/ttsProviders/elevenlabs.ts @@ -4,7 +4,7 @@ import { Buffer } from "node:buffer"; const VOICES_TTL = 24 * 60 * 60 * 1000; const _voicesCache = new Map(); // by API key -export async function fetchElevenLabsVoices(apiKey) { +export async function fetchElevenLabsVoices(apiKey: string) { if (!apiKey) throw new Error("ElevenLabs API key required"); const now = Date.now(); const cached = _voicesCache.get(apiKey); @@ -16,17 +16,27 @@ export async function fetchElevenLabsVoices(apiKey) { if (!res.ok) throw new Error(`ElevenLabs voices fetch failed: ${res.status}`); const data = await res.json(); // Normalize: derive lang from labels for grouping - const voices = (data.voices || []).map((v) => ({ ...v, lang: v.labels?.language || "en" })); + const voices = (data.voices || []).map((v: { labels?: { language?: string } }) => ({ + ...v, + lang: v.labels?.language || "en", + })); _voicesCache.set(apiKey, { voices, time: now }); return voices; } export default { - async synthesize(text, model, credentials) { + async synthesize( + text: string, + model: string, + credentials: { apiKey?: string; accessToken?: string } | null, + ) { if (!credentials?.apiKey) throw new Error("ElevenLabs API key required"); let modelId = "eleven_flash_v2_5"; let voiceId = model; - if (model && model.includes("/")) [modelId, voiceId] = model.split("/"); + if (model && model.includes("/")) { + const parts = model.split("/"); + [modelId, voiceId] = [parts[0] || modelId, parts[1] || voiceId]; + } const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { method: "POST", diff --git a/open-sse/handlers/ttsProviders/gemini.js b/open-sse/handlers/ttsProviders/gemini.ts similarity index 90% rename from open-sse/handlers/ttsProviders/gemini.js rename to open-sse/handlers/ttsProviders/gemini.ts index 2502d1c4..e320b4f1 100644 --- a/open-sse/handlers/ttsProviders/gemini.js +++ b/open-sse/handlers/ttsProviders/gemini.ts @@ -6,7 +6,7 @@ const DEFAULT_VOICE = "Kore"; const KNOWN_MODELS = ["gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"]; // Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model -function parseGeminiModelVoice(input) { +function parseGeminiModelVoice(input: string) { if (!input) return { modelId: DEFAULT_MODEL, voiceId: DEFAULT_VOICE }; for (const id of KNOWN_MODELS) { if (input === id) return { modelId: id, voiceId: DEFAULT_VOICE }; @@ -20,7 +20,7 @@ const CHANNELS = 1; const BITS_PER_SAMPLE = 16; // Build WAV header for raw PCM payload -function pcmToWav(pcmBuffer) { +function pcmToWav(pcmBuffer: Buffer) { const dataSize = pcmBuffer.length; const byteRate = (SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE) / 8; const blockAlign = (CHANNELS * BITS_PER_SAMPLE) / 8; @@ -42,13 +42,19 @@ function pcmToWav(pcmBuffer) { } // Build TTS prompt: add "Say [in {language}]:" prefix to force TTS mode -function buildPrompt(text, language) { +function buildPrompt(text: string, language: string | undefined) { if (/:\s/.test(text)) return text; // user already provided style instruction return language ? `Say in ${language}: ${text}` : `Say: ${text}`; } export default { - async synthesize(text, model, credentials, _responseFormat, opts = {}) { + async synthesize( + text: string, + model: string, + credentials: { apiKey?: string; accessToken?: string } | null, + _responseFormat: string, + opts: { language?: string; speed?: number; voice?: string } = {}, + ) { if (!credentials?.apiKey) throw new Error("No Gemini API key configured"); const { modelId, voiceId } = parseGeminiModelVoice(model); const voice = opts.voice || voiceId; @@ -69,8 +75,9 @@ export default { throw new Error(err?.error?.message || `Gemini TTS failed: ${res.status}`); } const data = await res.json(); - const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)?.inlineData - ?.data; + const b64 = data?.candidates?.[0]?.content?.parts?.find( + (p: { inlineData?: { data?: string } }) => p.inlineData?.data, + )?.inlineData?.data; if (!b64) { const reason = data?.candidates?.[0]?.finishReason || data?.promptFeedback?.blockReason || "unknown"; diff --git a/open-sse/handlers/ttsProviders/genericFormats.js b/open-sse/handlers/ttsProviders/genericFormats.ts similarity index 87% rename from open-sse/handlers/ttsProviders/genericFormats.js rename to open-sse/handlers/ttsProviders/genericFormats.ts index 77a05884..da8bfe07 100644 --- a/open-sse/handlers/ttsProviders/genericFormats.js +++ b/open-sse/handlers/ttsProviders/genericFormats.ts @@ -2,8 +2,16 @@ // Each handler accepts { baseUrl, apiKey, text, modelId, voiceId } and returns { base64, format }. import { responseToBase64, throwUpstreamError } from "./_base.js"; +type GenericTtsParams = { + baseUrl: string; + apiKey?: string; + text: string; + modelId?: string; + voiceId?: string; +}; + // Hyperbolic: POST { text } → { audio: base64 } -async function hyperbolic({ baseUrl, apiKey, text }) { +async function hyperbolic({ baseUrl, apiKey, text }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, @@ -15,7 +23,7 @@ async function hyperbolic({ baseUrl, apiKey, text }) { } // Deepgram: model via query, Token auth, returns binary -async function deepgram({ baseUrl, apiKey, text, modelId }) { +async function deepgram({ baseUrl, apiKey, text, modelId }: GenericTtsParams) { const url = new URL(baseUrl); url.searchParams.set("model", modelId || "aura-asteria-en"); const res = await fetch(url.toString(), { @@ -28,7 +36,7 @@ async function deepgram({ baseUrl, apiKey, text, modelId }) { } // Nvidia NIM: POST { input: { text }, voice, model } → binary -async function nvidia({ baseUrl, apiKey, text, modelId, voiceId }) { +async function nvidia({ baseUrl, apiKey, text, modelId, voiceId }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, @@ -39,7 +47,7 @@ async function nvidia({ baseUrl, apiKey, text, modelId, voiceId }) { } // HuggingFace: POST {baseUrl}/{modelId} { inputs: text } → binary -async function huggingface({ baseUrl, apiKey, text, modelId }) { +async function huggingface({ baseUrl, apiKey, text, modelId }: GenericTtsParams) { if (!modelId || modelId.includes("..")) throw new Error("Invalid HuggingFace model ID"); const res = await fetch(`${baseUrl}/${modelId}`, { method: "POST", @@ -51,7 +59,7 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) { } // Inworld: Basic auth, JSON { audioContent } -async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) { +async function inworld({ baseUrl, apiKey, text, modelId, voiceId }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Basic ${apiKey}` }, @@ -69,12 +77,12 @@ async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) { } // Cartesia: X-API-Key header -async function cartesia({ baseUrl, apiKey, text, modelId, voiceId }) { +async function cartesia({ baseUrl, apiKey, text, modelId, voiceId }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json", - "X-API-Key": apiKey, + "X-API-Key": apiKey || "", "Cartesia-Version": "2024-06-10", }, body: JSON.stringify({ @@ -89,7 +97,7 @@ async function cartesia({ baseUrl, apiKey, text, modelId, voiceId }) { } // PlayHT: token format "userId:apiKey", voice = s3 URL -async function playht({ baseUrl, apiKey, text, modelId, voiceId }) { +async function playht({ baseUrl, apiKey, text, modelId, voiceId }: GenericTtsParams) { const [userId, key] = (apiKey || ":").split(":"); const res = await fetch(baseUrl, { method: "POST", @@ -114,7 +122,7 @@ async function playht({ baseUrl, apiKey, text, modelId, voiceId }) { } // Coqui (local, noAuth): POST { text, speaker_id } → WAV -async function coqui({ baseUrl, text, voiceId }) { +async function coqui({ baseUrl, text, voiceId }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -125,7 +133,7 @@ async function coqui({ baseUrl, text, voiceId }) { } // Tortoise (local, noAuth) -async function tortoise({ baseUrl, text, voiceId }) { +async function tortoise({ baseUrl, text, voiceId }: GenericTtsParams) { const res = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -136,8 +144,8 @@ async function tortoise({ baseUrl, text, voiceId }) { } // OpenAI-compatible upstream (qwen3-tts, etc.) -async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId }) { - const headers = { "Content-Type": "application/json" }; +async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId }: GenericTtsParams) { + const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(baseUrl, { method: "POST", diff --git a/open-sse/handlers/ttsProviders/googleTts.js b/open-sse/handlers/ttsProviders/googleTts.ts similarity index 85% rename from open-sse/handlers/ttsProviders/googleTts.js rename to open-sse/handlers/ttsProviders/googleTts.ts index d06c6f07..4c911c20 100644 --- a/open-sse/handlers/ttsProviders/googleTts.js +++ b/open-sse/handlers/ttsProviders/googleTts.ts @@ -2,7 +2,8 @@ import { UA } from "./_base.js"; const REFRESH_MS = 11 * 60 * 1000; -const cache = { token: null, tokenTime: 0 }; +type GoogleTtsToken = { "f.sid": string; bl: string }; +const cache: { token: GoogleTtsToken | null; tokenTime: number } = { token: null, tokenTime: 0 }; let _idx = 0; async function getToken() { @@ -21,7 +22,7 @@ async function getToken() { export default { noAuth: true, - async synthesize(text, model) { + async synthesize(text: string, model: string) { const lang = model || "en"; const token = await getToken(); const cleanText = text @@ -34,10 +35,10 @@ export default { "f.sid": token["f.sid"], bl: token.bl, hl: lang, - "soc-app": 1, - "soc-platform": 1, - "soc-device": 1, - _reqid: reqId, + "soc-app": "1", + "soc-platform": "1", + "soc-device": "1", + _reqid: String(reqId), rt: "c", }); const payload = [cleanText, lang, null, "undefined", [0]]; @@ -56,7 +57,7 @@ export default { ); if (!res.ok) throw new Error(`Google TTS failed: ${res.status}`); const data = await res.text(); - const split = JSON.parse(data.split("\n")[3]); + const split = JSON.parse(data.split("\n")[3] || ""); const base64 = JSON.parse(split[0][2])[0]; if (!base64 || base64.length < 100) throw new Error("Google TTS returned empty audio"); return { base64, format: "mp3" }; diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.ts similarity index 64% rename from open-sse/handlers/ttsProviders/index.js rename to open-sse/handlers/ttsProviders/index.ts index 5a26f7d1..42fcf382 100644 --- a/open-sse/handlers/ttsProviders/index.js +++ b/open-sse/handlers/ttsProviders/index.ts @@ -10,6 +10,12 @@ import localDevice, { fetchLocalDeviceVoices } from "./localDevice.js"; import openai from "./openai.js"; import openrouter from "./openrouter.js"; +type TtsAdapterResult = + | { base64: string; format: string; success?: undefined } + | { response: Response; success: true }; +type TtsAdapter = { synthesize: (...args: unknown[]) => Promise }; +type TtsConfigResult = { base64: string; format: string } | null; + // Special providers with custom synthesize() logic const SPECIAL_ADAPTERS = { "google-tts": googleTts, @@ -19,18 +25,34 @@ const SPECIAL_ADAPTERS = { openai, openrouter, gemini, -}; +} as unknown as Record; -export function getTtsAdapter(provider) { +export function getTtsAdapter(provider: string): TtsAdapter | null { return SPECIAL_ADAPTERS[provider] || null; } // Generic config-driven dispatcher (uses ttsConfig.format) -export async function synthesizeViaConfig(provider, text, model, credentials) { +export async function synthesizeViaConfig( + provider: string, + text: string, + model: string, + credentials: { apiKey?: string } | null | undefined, +): Promise { const { AI_PROVIDERS } = await import("@/shared/constants/providers"); const cfg = AI_PROVIDERS[provider]?.ttsConfig; if (!cfg) return null; - const handler = FORMAT_HANDLERS[cfg.format]; + const handler = ( + FORMAT_HANDLERS as Record< + string, + (args: { + baseUrl: string; + apiKey?: string; + text: string; + modelId?: string; + voiceId?: string; + }) => Promise<{ base64: string; format: string }> + > + )[cfg.format]; if (!handler) return null; const apiKey = credentials?.apiKey; if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`); @@ -45,7 +67,7 @@ export const VOICE_FETCHERS = { "local-device": fetchLocalDeviceVoices, elevenlabs: fetchElevenLabsVoices, gemini: fetchGeminiVoices, -}; +} as unknown as Record Promise>; // Re-export for backward compat export { fetchEdgeTtsVoices, fetchElevenLabsVoices, fetchGeminiVoices, fetchLocalDeviceVoices }; diff --git a/open-sse/handlers/ttsProviders/localDevice.js b/open-sse/handlers/ttsProviders/localDevice.ts similarity index 81% rename from open-sse/handlers/ttsProviders/localDevice.js rename to open-sse/handlers/ttsProviders/localDevice.ts index 2629bcec..b3c32200 100644 --- a/open-sse/handlers/ttsProviders/localDevice.js +++ b/open-sse/handlers/ttsProviders/localDevice.ts @@ -7,18 +7,25 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -let _voicesCache = null; +let _voicesCache: unknown = null; async function fetchVoicesMac() { const { stdout } = await execFileAsync("say", ["-v", "?"]); - const voices = []; + const voices: { + country?: string; + gender?: string; + id: string; + lang: string; + locale?: string; + name: string; + }[] = []; for (const line of stdout.split("\n")) { const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/); if (!m) continue; - const name = m[1].trim(); - const locale = m[2].trim(); - const lang = locale.split("_")[0]; - const country = locale.split("_")[1]; + const name = (m[1] || "").trim(); + const locale = (m[2] || "").trim(); + const lang = locale.split("_")[0] || ""; + const country = locale.split("_")[1] || ""; voices.push({ id: name, name, locale, lang, country, gender: "" }); } return voices; @@ -42,7 +49,12 @@ async function fetchVoicesWin() { return list.map((v) => { const culture = v.Culture || "en-US"; const [lang, country = ""] = culture.split("-"); - const genderMap = { 1: "Male", 2: "Female", Male: "Male", Female: "Female" }; + const genderMap: Record = { + 1: "Male", + 2: "Female", + Male: "Male", + Female: "Female", + }; return { id: v.Name, name: v.Name, @@ -65,7 +77,7 @@ export async function fetchLocalDeviceVoices() { } } -async function synthesizeMacOrWin(text, voiceId) { +async function synthesizeMacOrWin(text: string, voiceId: string) { const dir = await mkdtemp(join(tmpdir(), "tts-")); const aiffPath = join(dir, "out.aiff"); const mp3Path = join(dir, "out.mp3"); @@ -91,7 +103,7 @@ async function synthesizeMacOrWin(text, voiceId) { export default { noAuth: true, - async synthesize(text, model) { + async synthesize(text: string, model: string) { const base64 = await synthesizeMacOrWin(text, model); return { base64, format: "mp3" }; }, diff --git a/open-sse/handlers/ttsProviders/openai.js b/open-sse/handlers/ttsProviders/openai.ts similarity index 77% rename from open-sse/handlers/ttsProviders/openai.js rename to open-sse/handlers/ttsProviders/openai.ts index bf57a0e5..b1465968 100644 --- a/open-sse/handlers/ttsProviders/openai.js +++ b/open-sse/handlers/ttsProviders/openai.ts @@ -2,14 +2,20 @@ import { Buffer } from "node:buffer"; export default { - async synthesize(text, model, credentials, _responseFormat, opts = {}) { + async synthesize( + text: string, + model: string, + credentials: { apiKey?: string; accessToken?: string; baseUrl?: string } | null, + _responseFormat: string, + opts: { language?: string; speed?: number; voice?: string } = {}, + ) { if (!credentials?.apiKey) throw new Error("No OpenAI API key configured"); let ttsModel = "gpt-4o-mini-tts"; let voice = "alloy"; if (model && model.includes("/")) { const parts = model.split("/"); - if (parts.length === 2) [ttsModel, voice] = parts; + if (parts.length === 2) [ttsModel, voice] = [parts[0] || ttsModel, parts[1] || voice]; } else if (model) { voice = model; } diff --git a/open-sse/handlers/ttsProviders/openrouter.js b/open-sse/handlers/ttsProviders/openrouter.ts similarity index 86% rename from open-sse/handlers/ttsProviders/openrouter.js rename to open-sse/handlers/ttsProviders/openrouter.ts index c8c8661c..d303a0f3 100644 --- a/open-sse/handlers/ttsProviders/openrouter.js +++ b/open-sse/handlers/ttsProviders/openrouter.ts @@ -1,6 +1,12 @@ // OpenRouter TTS — via chat completions + audio modality (SSE stream) export default { - async synthesize(text, model, credentials, _responseFormat, opts = {}) { + async synthesize( + text: string, + model: string, + credentials: { apiKey?: string; accessToken?: string } | null, + _responseFormat: string, + opts: { language?: string; speed?: number; voice?: string } = {}, + ) { if (!credentials?.apiKey) throw new Error("No OpenRouter API key configured"); // model format: "tts-model/voice" e.g. "openai/gpt-4o-mini-tts/alloy" @@ -44,8 +50,8 @@ export default { } // Parse SSE stream, accumulate base64 audio chunks - const chunks = []; - const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + const reader = res.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -54,7 +60,7 @@ export default { if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); - buffer = lines.pop(); + buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ") || line === "data: [DONE]") continue; try { diff --git a/open-sse/index.js b/open-sse/index.ts similarity index 100% rename from open-sse/index.js rename to open-sse/index.ts diff --git a/open-sse/package.json b/open-sse/package.json index e3548fd3..4f4b97f9 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Local Open SSE core for pod", "type": "module", - "main": "index.js", + "main": "index.ts", "exports": { - ".": "./index.js", + ".": "./index.ts", "./*": "./*" } } diff --git a/open-sse/rtk/applyFilter.js b/open-sse/rtk/applyFilter.ts similarity index 50% rename from open-sse/rtk/applyFilter.js rename to open-sse/rtk/applyFilter.ts index f8ef6a96..2cef05a9 100644 --- a/open-sse/rtk/applyFilter.js +++ b/open-sse/rtk/applyFilter.ts @@ -1,16 +1,24 @@ // Port of apply_filter (rtk/src/cmds/system/pipe_cmd.rs) — catch_unwind equivalent // On panic/error: passthrough raw output + warn to stderr -export function safeApply(fn, text) { + +export type RtkFilterFn = ((text: string) => unknown) & { + filterName?: string; + name?: string; +}; + +export function safeApply(fn: RtkFilterFn | unknown, text: string): string { if (typeof fn !== "function") return text; try { - const out = fn(text); + const out = (fn as RtkFilterFn)(text); if (typeof out !== "string") return text; return out; - } catch (err) { + } catch (err: unknown) { // Rust: eprintln!("[rtk] warning: filter panicked — passing through raw output") - const name = fn.filterName || fn.name || "anonymous"; + const typed = fn as RtkFilterFn; + const name = typed.filterName || typed.name || "anonymous"; + const message = err instanceof Error ? err.message : String(err); console.warn( - `[rtk] warning: filter '${name}' panicked — passing through raw output: ${err?.message || err}`, + `[rtk] warning: filter '${name}' panicked — passing through raw output: ${message || err}`, ); return text; } diff --git a/open-sse/rtk/autodetect.js b/open-sse/rtk/autodetect.ts similarity index 90% rename from open-sse/rtk/autodetect.js rename to open-sse/rtk/autodetect.ts index f0a3bee4..f858708d 100644 --- a/open-sse/rtk/autodetect.js +++ b/open-sse/rtk/autodetect.ts @@ -33,7 +33,7 @@ const RE_BUILD_CARGO = /^\s*(Compiling|Checking|Building|Finished|Downloading|Updating|Fresh)\s+\S/m; const RE_BUILD_YARN = /^(yarn|pnpm)\s+/m; -export function autoDetectFilter(text) { +export function autoDetectFilter(text: string) { // Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text; @@ -53,7 +53,7 @@ export function autoDetectFilter(text) { if (RE_GIT_STATUS.test(head) || isMostlyPorcelain(head)) return gitStatus; const lines = head.split("\n"); - const nonEmpty = lines.filter((l) => l.trim().length > 0); + const nonEmpty = lines.filter((l: string) => l.trim().length > 0); // Rust grep rule: first 5 non-empty lines, ANY matches "file:number:content" const first5 = nonEmpty.slice(0, 5); @@ -85,7 +85,7 @@ export function autoDetectFilter(text) { return null; } -function isGrepLine(line) { +function isGrepLine(line: string) { // Rust: splitn(3, ':') → parts.len()==3 && parts[1].parse::().is_ok() const first = line.indexOf(":"); if (first === -1) return false; @@ -95,21 +95,21 @@ function isGrepLine(line) { return /^\d+$/.test(lineno); } -function isPathLike(line) { +function isPathLike(line: string) { const t = line.trim(); if (t.length === 0) return false; if (t.includes(":")) return false; return t.startsWith(".") || t.startsWith("/") || t.includes("/"); } -function isMostlyPorcelain(head) { - const lines = head.split("\n").filter((l) => l.trim()); +function isMostlyPorcelain(head: string) { + const lines = head.split("\n").filter((l: string) => l.trim()); if (lines.length < 3) return false; - const hits = lines.filter((l) => RE_PORCELAIN.test(l)).length; + const hits = lines.filter((l: string) => RE_PORCELAIN.test(l)).length; return hits / lines.length >= 0.6; } -function isLineNumbered(lines) { +function isLineNumbered(lines: string[]) { let hits = 0; let nonEmpty = 0; const sample = lines.slice(0, 100); @@ -122,7 +122,7 @@ function isLineNumbered(lines) { return hits / nonEmpty >= READ_NUMBERED_MIN_HIT_RATIO; } -function countMatches(text, re) { +function countMatches(text: string, re: RegExp) { const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g"); return (text.match(g) || []).length; } diff --git a/open-sse/rtk/caveman.js b/open-sse/rtk/caveman.ts similarity index 62% rename from open-sse/rtk/caveman.js rename to open-sse/rtk/caveman.ts index 0741733f..f2082a94 100644 --- a/open-sse/rtk/caveman.js +++ b/open-sse/rtk/caveman.ts @@ -7,8 +7,28 @@ import { CAVEMAN_PROMPTS } from "./cavemanPrompts.js"; const SEP = "\n\n"; -export function injectCaveman(body, format, level) { - const prompt = CAVEMAN_PROMPTS[level]; +type JsonRecord = Record; + +type OpenAIMessage = { + role?: string; + content?: string | Array>; + [key: string]: unknown; +}; + +type ClaudeSystemBlock = { + type?: string; + text?: string; + cache_control?: unknown; + [key: string]: unknown; +}; + +type GeminiSystem = { + parts?: Array<{ text?: string }>; + [key: string]: unknown; +}; + +export function injectCaveman(body: JsonRecord | null | undefined, format: string, level: string) { + const prompt = (CAVEMAN_PROMPTS as Record)[level]; if (!body || !prompt) return; switch (format) { @@ -29,7 +49,7 @@ export function injectCaveman(body, format, level) { } // OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string) -function injectMessagesSystem(body, prompt) { +function injectMessagesSystem(body: JsonRecord, prompt: string) { // OpenAI Responses API: top-level string field if (typeof body.instructions === "string") { body.instructions = body.instructions ? `${body.instructions}${SEP}${prompt}` : prompt; @@ -37,21 +57,24 @@ function injectMessagesSystem(body, prompt) { } const arr = Array.isArray(body.messages) - ? body.messages + ? (body.messages as OpenAIMessage[]) : Array.isArray(body.input) - ? body.input + ? (body.input as OpenAIMessage[]) : null; if (!arr) return; - const idx = arr.findIndex((m) => m && (m.role === "system" || m.role === "developer")); + const idx = arr.findIndex( + (m: OpenAIMessage | null | undefined) => m && (m.role === "system" || m.role === "developer"), + ); if (idx >= 0) { - appendToOpenAIMessage(arr[idx], prompt); + const msg = arr[idx]; + if (msg) appendToOpenAIMessage(msg, prompt); } else { arr.unshift({ role: "system", content: prompt }); } } -function appendToOpenAIMessage(msg, prompt) { +function appendToOpenAIMessage(msg: OpenAIMessage, prompt: string) { if (typeof msg.content === "string") { msg.content = `${msg.content}${SEP}${prompt}`; } else if (Array.isArray(msg.content)) { @@ -64,24 +87,25 @@ function appendToOpenAIMessage(msg, prompt) { // Claude shape: body.system as string | array of {type:"text", text} // Insert before the last cache_control block to keep caveman inside the cached prefix. -function injectClaudeSystem(body, prompt) { +function injectClaudeSystem(body: JsonRecord, prompt: string) { if (typeof body.system === "string" && body.system.length > 0) { body.system = `${body.system}${SEP}${prompt}`; return; } if (Array.isArray(body.system)) { - const block = { type: "text", text: prompt }; + const system = body.system as ClaudeSystemBlock[]; + const block: ClaudeSystemBlock = { type: "text", text: prompt }; let lastCacheIdx = -1; - for (let i = body.system.length - 1; i >= 0; i--) { - if (body.system[i]?.cache_control) { + for (let i = system.length - 1; i >= 0; i--) { + if (system[i]?.cache_control) { lastCacheIdx = i; break; } } if (lastCacheIdx >= 0) { - body.system.splice(lastCacheIdx, 0, block); + system.splice(lastCacheIdx, 0, block); } else { - body.system.push(block); + system.push(block); } return; } @@ -90,11 +114,12 @@ function injectClaudeSystem(body, prompt) { // Gemini shape: body.system_instruction | body.systemInstruction | body.request.systemInstruction // Each shape: { parts: [{ text }] } -function injectGeminiSystem(body, prompt) { - const target = body.request && typeof body.request === "object" ? body.request : body; +function injectGeminiSystem(body: JsonRecord, prompt: string) { + const target = + body.request && typeof body.request === "object" ? (body.request as JsonRecord) : body; const useSnake = Object.hasOwn(target, "system_instruction"); const key = useSnake ? "system_instruction" : "systemInstruction"; - const sys = target[key]; + const sys = target[key] as GeminiSystem | undefined; if (sys && Array.isArray(sys.parts)) { sys.parts.push({ text: prompt }); return; diff --git a/open-sse/rtk/cavemanPrompts.js b/open-sse/rtk/cavemanPrompts.ts similarity index 100% rename from open-sse/rtk/cavemanPrompts.js rename to open-sse/rtk/cavemanPrompts.ts diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.ts similarity index 100% rename from open-sse/rtk/constants.js rename to open-sse/rtk/constants.ts diff --git a/open-sse/rtk/filters/buildOutput.js b/open-sse/rtk/filters/buildOutput.ts similarity index 96% rename from open-sse/rtk/filters/buildOutput.js rename to open-sse/rtk/filters/buildOutput.ts index a9e5ccfe..5a25bfcb 100644 --- a/open-sse/rtk/filters/buildOutput.js +++ b/open-sse/rtk/filters/buildOutput.ts @@ -29,7 +29,7 @@ const RE_TSC_ERROR = /^[^\s].*\.(ts|tsx|js|jsx|mjs|cjs):\d+:\d+\s*-\s*error\b/i; const RE_ESLINT_ERROR = /^\s*\d+:\d+\s+error\b/; const RE_GENERIC_ERROR = /^(Error|FAIL|FAILED|panic|Aborted)[\s:]/i; -export function buildOutput(input, _maxLines) { +export function buildOutput(input: unknown, _maxLines?: number) { if (!input || typeof input !== "string") return input; const lines = input.split("\n"); @@ -39,10 +39,10 @@ export function buildOutput(input, _maxLines) { let npmWarnCount = 0; let npmDeprecatedCount = 0; let cargoCompileCount = 0; - let cargoCompileFirst = null; - let cargoCompileLast = null; + let cargoCompileFirst: string | null = null; + let cargoCompileLast: string | null = null; let warningCount = 0; // compiler warnings (cargo/rustc-style) - let prev = null; + let prev: string | null = null; let dupRun = 0; const flushNpmWarn = () => { diff --git a/open-sse/rtk/filters/dedupLog.js b/open-sse/rtk/filters/dedupLog.ts similarity index 92% rename from open-sse/rtk/filters/dedupLog.js rename to open-sse/rtk/filters/dedupLog.ts index f971c160..0b6781b7 100644 --- a/open-sse/rtk/filters/dedupLog.js +++ b/open-sse/rtk/filters/dedupLog.ts @@ -1,10 +1,10 @@ // Generic fallback: collapse consecutive duplicate lines + blank-line dedupe + hard line cap import { DEDUP_LINE_MAX } from "../constants.js"; -export function dedupLog(input) { +export function dedupLog(input: string) { const lines = input.split("\n"); const out = []; - let prev = null; + let prev: string | null = null; let runCount = 0; let blankStreak = 0; diff --git a/open-sse/rtk/filters/find.js b/open-sse/rtk/filters/find.ts similarity index 82% rename from open-sse/rtk/filters/find.js rename to open-sse/rtk/filters/find.ts index ee5041fc..25d4d13e 100644 --- a/open-sse/rtk/filters/find.js +++ b/open-sse/rtk/filters/find.ts @@ -2,16 +2,16 @@ // Group by parent dir, show basenames, cap 10/dir and 20 dirs total import { FIND_PER_DIR_MAX, FIND_TOTAL_DIR_MAX } from "../constants.js"; -export function find(input) { - const lines = input.split("\n").filter((l) => l.trim()); +export function find(input: string) { + const lines = input.split("\n").filter((l: string) => l.trim()); if (lines.length === 0) return input; - const byDir = new Map(); + const byDir = new Map(); for (const path of lines) { const lastSlash = path.lastIndexOf("/"); - let dir; - let basename; + let dir: string; + let basename: string; if (lastSlash === -1) { dir = "."; basename = path; @@ -21,7 +21,7 @@ export function find(input) { basename = path.slice(lastSlash + 1); } if (!byDir.has(dir)) byDir.set(dir, []); - byDir.get(dir).push(basename); + byDir.get(dir)!.push(basename); } // Rust: dirs.sort_by_key(|(d, _)| d.clone()) @@ -30,7 +30,7 @@ export function find(input) { const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX); for (const dir of showDirs) { - const files = byDir.get(dir); + const files = byDir.get(dir) ?? []; out += `${dir}/ (${files.length}):\n`; const showFiles = files.slice(0, FIND_PER_DIR_MAX); for (const f of showFiles) out += ` ${f}\n`; diff --git a/open-sse/rtk/filters/gitDiff.js b/open-sse/rtk/filters/gitDiff.ts similarity index 97% rename from open-sse/rtk/filters/gitDiff.js rename to open-sse/rtk/filters/gitDiff.ts index 797aeddd..553018d8 100644 --- a/open-sse/rtk/filters/gitDiff.js +++ b/open-sse/rtk/filters/gitDiff.ts @@ -2,7 +2,7 @@ // Compacts unified diff: file headers, hunk-level truncation at 100 lines, +/-/context counting import { GIT_DIFF_HUNK_MAX_LINES } from "../constants.js"; -export function gitDiff(diff, maxLines = 500) { +export function gitDiff(diff: string, maxLines: number = 500) { const result = []; let currentFile = ""; let added = 0; diff --git a/open-sse/rtk/filters/gitStatus.js b/open-sse/rtk/filters/gitStatus.ts similarity index 92% rename from open-sse/rtk/filters/gitStatus.js rename to open-sse/rtk/filters/gitStatus.ts index ec8c13d4..d143d92d 100644 --- a/open-sse/rtk/filters/gitStatus.js +++ b/open-sse/rtk/filters/gitStatus.ts @@ -10,9 +10,9 @@ // clean — nothing to commit import { STATUS_MAX_FILES, STATUS_MAX_UNTRACKED } from "../constants.js"; -export function gitStatus(input) { +export function gitStatus(input: string) { const lines = input.split("\n"); - if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) { + if (lines.length === 0 || (lines.length === 1 && !(lines[0] ?? "").trim())) { return "Clean working tree"; } @@ -31,7 +31,7 @@ export function gitStatus(input) { // Long-form branch detection (LLM usually sends this, not porcelain) const longBranch = raw.match(/^On branch (\S+)/); if (longBranch) { - branch = longBranch[1]; + branch = longBranch[1] ?? ""; continue; } @@ -53,7 +53,7 @@ export function gitStatus(input) { continue; } - if ("MADRC".includes(x)) { + if (x !== undefined && "MADRC".includes(x)) { staged++; stagedFiles.push(file); } else if (x === "U") { @@ -71,7 +71,7 @@ export function gitStatus(input) { const longMatch = raw.match(/^\s*(modified|new file|deleted|renamed|both modified):\s+(.+)$/); if (longMatch) { const kind = longMatch[1]; - const path = longMatch[2].trim(); + const path = (longMatch[2] ?? "").trim(); if (kind === "both modified") { conflicts++; } else if (kind === "modified" || kind === "deleted") { diff --git a/open-sse/rtk/filters/grep.js b/open-sse/rtk/filters/grep.ts similarity index 97% rename from open-sse/rtk/filters/grep.js rename to open-sse/rtk/filters/grep.ts index 20166887..a4813ebd 100644 --- a/open-sse/rtk/filters/grep.js +++ b/open-sse/rtk/filters/grep.ts @@ -2,7 +2,7 @@ // Input format: "file:lineno:content" — splitn(3, ':') in Rust import { GREP_PER_FILE_MAX } from "../constants.js"; -export function grep(input) { +export function grep(input: string) { const byFile = new Map(); let total = 0; diff --git a/open-sse/rtk/filters/ls.js b/open-sse/rtk/filters/ls.ts similarity index 76% rename from open-sse/rtk/filters/ls.js rename to open-sse/rtk/filters/ls.ts index a4892663..e971287e 100644 --- a/open-sse/rtk/filters/ls.js +++ b/open-sse/rtk/filters/ls.ts @@ -6,13 +6,13 @@ import { LS_EXT_SUMMARY_TOP, LS_NOISE_DIRS } from "../constants.js"; const LS_DATE_RE = /\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(\d{4}|\d{2}:\d{2})\s+/; -function humanSize(bytes) { +function humanSize(bytes: number) { if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)}M`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K`; return `${bytes}B`; } -function parseLsLine(line) { +function parseLsLine(line: string) { const m = LS_DATE_RE.exec(line); if (!m) return null; const name = line.slice(m.index + m[0].length); @@ -20,14 +20,15 @@ function parseLsLine(line) { const beforeParts = beforeDate.split(/\s+/).filter(Boolean); if (beforeParts.length < 4) return null; - const perms = beforeParts[0]; + const perms = beforeParts[0] ?? ""; const fileType = perms.charAt(0); // size = rightmost parseable number before the date let size = 0; for (let i = beforeParts.length - 1; i >= 0; i--) { - const n = Number(beforeParts[i]); - if (Number.isInteger(n) && String(n) === beforeParts[i]) { + const part = beforeParts[i] ?? ""; + const n = Number(part); + if (Number.isInteger(n) && String(n) === part) { size = n; break; } @@ -35,10 +36,10 @@ function parseLsLine(line) { return { fileType, size, name }; } -export function ls(input) { - const dirs = []; - const files = []; // [name, sizeStr] - const byExt = new Map(); +export function ls(input: string) { + const dirs: string[] = []; + const files: [string, string][] = []; // [name, sizeStr] + const byExt = new Map(); for (const line of input.split("\n")) { if (line.startsWith("total ") || line.length === 0) continue; @@ -47,7 +48,7 @@ export function ls(input) { if (parsed.name === "." || parsed.name === "..") continue; // Rust ls.rs: show_all flag respected — for LLM context always skip noise - if (LS_NOISE_DIRS.includes(parsed.name)) continue; + if ((LS_NOISE_DIRS as readonly string[]).includes(parsed.name)) continue; if (parsed.fileType === "d") { dirs.push(parsed.name); @@ -68,8 +69,10 @@ export function ls(input) { // Summary line (Rust port) let summary = `\nSummary: ${files.length} files, ${dirs.length} dirs`; if (byExt.size > 0) { - const ext = Array.from(byExt.entries()).sort((a, b) => b[1] - a[1]); - const parts = ext.slice(0, LS_EXT_SUMMARY_TOP).map(([e, c]) => `${c} ${e}`); + const ext = Array.from(byExt.entries()).sort( + (a: [string, number], b: [string, number]) => b[1] - a[1], + ); + const parts = ext.slice(0, LS_EXT_SUMMARY_TOP).map(([e, c]: [string, number]) => `${c} ${e}`); summary += ` (${parts.join(", ")}`; if (ext.length > LS_EXT_SUMMARY_TOP) { summary += `, +${ext.length - LS_EXT_SUMMARY_TOP} more`; diff --git a/open-sse/rtk/filters/readNumbered.js b/open-sse/rtk/filters/readNumbered.ts similarity index 95% rename from open-sse/rtk/filters/readNumbered.js rename to open-sse/rtk/filters/readNumbered.ts index a06f8279..478bb9d7 100644 --- a/open-sse/rtk/filters/readNumbered.js +++ b/open-sse/rtk/filters/readNumbered.ts @@ -8,7 +8,7 @@ import { const LINE_RE = /^\s*\d+\|/; -export function readNumbered(input) { +export function readNumbered(input: string) { const lines = input.split("\n"); if (lines.length < SMART_TRUNCATE_MIN_LINES) return input; diff --git a/open-sse/rtk/filters/searchList.js b/open-sse/rtk/filters/searchList.ts similarity index 97% rename from open-sse/rtk/filters/searchList.js rename to open-sse/rtk/filters/searchList.ts index 49da1f68..0a55c101 100644 --- a/open-sse/rtk/filters/searchList.js +++ b/open-sse/rtk/filters/searchList.ts @@ -4,7 +4,7 @@ import { SEARCH_LIST_PER_DIR_MAX, SEARCH_LIST_TOTAL_DIR_MAX } from "../constants const HEADER_RE = /^Result of search in '[^']*' \(total (\d+) files?\):/; -export function searchList(input) { +export function searchList(input: string) { const lines = input.split("\n"); if (lines.length === 0) return input; diff --git a/open-sse/rtk/filters/smartTruncate.js b/open-sse/rtk/filters/smartTruncate.ts similarity index 93% rename from open-sse/rtk/filters/smartTruncate.js rename to open-sse/rtk/filters/smartTruncate.ts index 17d2e5a1..f7b20193 100644 --- a/open-sse/rtk/filters/smartTruncate.js +++ b/open-sse/rtk/filters/smartTruncate.ts @@ -6,7 +6,7 @@ import { SMART_TRUNCATE_TAIL, } from "../constants.js"; -export function smartTruncate(input) { +export function smartTruncate(input: string) { const lines = input.split("\n"); if (lines.length < SMART_TRUNCATE_MIN_LINES) return input; diff --git a/open-sse/rtk/filters/tree.js b/open-sse/rtk/filters/tree.ts similarity index 87% rename from open-sse/rtk/filters/tree.js rename to open-sse/rtk/filters/tree.ts index f0222d17..363e4831 100644 --- a/open-sse/rtk/filters/tree.js +++ b/open-sse/rtk/filters/tree.ts @@ -2,7 +2,7 @@ // Removes summary line (e.g. "5 directories, 23 files") and trailing blanks. import { TREE_MAX_LINES } from "../constants.js"; -export function tree(input) { +export function tree(input: string) { const lines = input.split("\n"); if (lines.length === 0) return input; @@ -16,7 +16,7 @@ export function tree(input) { } // Drop trailing blanks - while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") { + while (filtered.length > 0 && (filtered[filtered.length - 1] ?? "").trim() === "") { filtered.pop(); } diff --git a/open-sse/rtk/index.js b/open-sse/rtk/index.ts similarity index 71% rename from open-sse/rtk/index.js rename to open-sse/rtk/index.ts index c9f9261f..d441fb96 100644 --- a/open-sse/rtk/index.js +++ b/open-sse/rtk/index.ts @@ -1,23 +1,61 @@ // RTK port: compress tool_result content in LLM request bodies // Injected at the top of translateRequest (before any format translation) -import { safeApply } from "./applyFilter.js"; +import { safeApply, type RtkFilterFn } from "./applyFilter.js"; import { autoDetectFilter } from "./autodetect.js"; import { MIN_COMPRESS_SIZE, RAW_CAP } from "./constants.js"; +type JsonRecord = Record; + +type ContentPart = { + type?: string; + text?: string; + [key: string]: unknown; +}; + +type ToolResultBlock = { + type?: string; + is_error?: boolean; + content?: string | ContentPart[]; + [key: string]: unknown; +}; + +type CompressMessage = { + type?: string; + role?: string; + content?: string | ToolResultBlock[]; + output?: string | ContentPart[]; + [key: string]: unknown; +}; + +export type RtkHit = { + shape: string; + filter: string; + saved: number; +}; + +export type RtkStats = { + bytesBefore: number; + bytesAfter: number; + hits: RtkHit[]; +}; + // Compress tool_result content in-place. Returns stats or null if disabled/failed. -export function compressMessages(body, enabled) { +export function compressMessages( + body: JsonRecord | null | undefined, + enabled: unknown, +): RtkStats | null { if (!enabled) return null; if (!body) return null; // Support both OpenAI/Claude "messages" and OpenAI Responses "input" const items = Array.isArray(body.messages) - ? body.messages + ? (body.messages as CompressMessage[]) : Array.isArray(body.input) - ? body.input + ? (body.input as CompressMessage[]) : null; if (!items) return null; - const stats = { bytesBefore: 0, bytesAfter: 0, hits: [] }; + const stats: RtkStats = { bytesBefore: 0, bytesAfter: 0, hits: [] }; try { for (let i = 0; i < items.length; i++) { const msg = items[i]; @@ -49,7 +87,7 @@ export function compressMessages(body, enabled) { // Shape 1b: OpenAI tool message — { role:"tool", content:[{type:"text", text:"..."}] } if (msg.role === "tool") { for (let k = 0; k < msg.content.length; k++) { - const part = msg.content[k]; + const part = msg.content[k] as ContentPart; if (part && part.type === "text" && typeof part.text === "string") { part.text = compressText(part.text, stats, "openai-tool-array"); } @@ -77,14 +115,15 @@ export function compressMessages(body, enabled) { } } } - } catch (e) { - console.warn("[RTK] compressMessages error:", e.message); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + console.warn("[RTK] compressMessages error:", message); return null; } return stats; } -function compressText(text, stats, shape) { +function compressText(text: string, stats: RtkStats, shape: string): string { const bytesIn = text.length; stats.bytesBefore += bytesIn; @@ -93,7 +132,7 @@ function compressText(text, stats, shape) { return text; } - const fn = autoDetectFilter(text); + const fn = autoDetectFilter(text) as RtkFilterFn | null; if (!fn) { stats.bytesAfter += bytesIn; return text; @@ -108,15 +147,19 @@ function compressText(text, stats, shape) { } stats.bytesAfter += out.length; - stats.hits.push({ shape, filter: fn.filterName || fn.name, saved: bytesIn - out.length }); + stats.hits.push({ + shape, + filter: fn.filterName || fn.name || "anonymous", + saved: bytesIn - out.length, + }); return out; } // Convenience: format a log line from stats -export function formatRtkLog(stats) { +export function formatRtkLog(stats: RtkStats | null | undefined): string | null { if (!stats || !stats.hits || stats.hits.length === 0) return null; const saved = stats.bytesBefore - stats.bytesAfter; const pct = stats.bytesBefore > 0 ? ((saved / stats.bytesBefore) * 100).toFixed(1) : "0"; - const filters = Array.from(new Set(stats.hits.map((h) => h.filter))).join(","); + const filters = Array.from(new Set(stats.hits.map((h: RtkHit) => h.filter))).join(","); return `[RTK] saved ${saved}B / ${stats.bytesBefore}B (${pct}%) via [${filters}] hits=${stats.hits.length}`; } diff --git a/open-sse/rtk/registry.js b/open-sse/rtk/registry.js deleted file mode 100644 index a5000c67..00000000 --- a/open-sse/rtk/registry.js +++ /dev/null @@ -1,40 +0,0 @@ -import { FILTERS } from "./constants.js"; -import { buildOutput } from "./filters/buildOutput.js"; -import { dedupLog } from "./filters/dedupLog.js"; -import { find } from "./filters/find.js"; -import { gitDiff } from "./filters/gitDiff.js"; -import { gitStatus } from "./filters/gitStatus.js"; -import { grep } from "./filters/grep.js"; -import { ls } from "./filters/ls.js"; -import { readNumbered } from "./filters/readNumbered.js"; -import { searchList } from "./filters/searchList.js"; -import { smartTruncate } from "./filters/smartTruncate.js"; -import { tree } from "./filters/tree.js"; - -const REGISTRY = { - [FILTERS.GIT_DIFF]: gitDiff, - [FILTERS.GIT_STATUS]: gitStatus, - [FILTERS.GREP]: grep, - [FILTERS.FIND]: find, - [FILTERS.DEDUP_LOG]: dedupLog, - [FILTERS.LS]: ls, - [FILTERS.TREE]: tree, - [FILTERS.SMART_TRUNCATE]: smartTruncate, - [FILTERS.READ_NUMBERED]: readNumbered, - [FILTERS.SEARCH_LIST]: searchList, - [FILTERS.BUILD_OUTPUT]: buildOutput, -}; - -// Rust resolve_filter aliases (pipe_cmd.rs): grep|rg, find|fd -const ALIASES = { - rg: grep, - fd: find, -}; - -export function resolveFilter(name) { - return REGISTRY[name] || ALIASES[name] || null; -} - -export function allFilters() { - return REGISTRY; -} diff --git a/open-sse/rtk/registry.ts b/open-sse/rtk/registry.ts new file mode 100644 index 00000000..8bc4e403 --- /dev/null +++ b/open-sse/rtk/registry.ts @@ -0,0 +1,41 @@ +import { FILTERS } from "./constants.js"; +import type { RtkFilterFn } from "./applyFilter.js"; +import { buildOutput } from "./filters/buildOutput.js"; +import { dedupLog } from "./filters/dedupLog.js"; +import { find } from "./filters/find.js"; +import { gitDiff } from "./filters/gitDiff.js"; +import { gitStatus } from "./filters/gitStatus.js"; +import { grep } from "./filters/grep.js"; +import { ls } from "./filters/ls.js"; +import { readNumbered } from "./filters/readNumbered.js"; +import { searchList } from "./filters/searchList.js"; +import { smartTruncate } from "./filters/smartTruncate.js"; +import { tree } from "./filters/tree.js"; + +const REGISTRY: Record = { + [FILTERS.GIT_DIFF]: gitDiff as RtkFilterFn, + [FILTERS.GIT_STATUS]: gitStatus as RtkFilterFn, + [FILTERS.GREP]: grep as RtkFilterFn, + [FILTERS.FIND]: find as RtkFilterFn, + [FILTERS.DEDUP_LOG]: dedupLog as RtkFilterFn, + [FILTERS.LS]: ls as RtkFilterFn, + [FILTERS.TREE]: tree as RtkFilterFn, + [FILTERS.SMART_TRUNCATE]: smartTruncate as RtkFilterFn, + [FILTERS.READ_NUMBERED]: readNumbered as RtkFilterFn, + [FILTERS.SEARCH_LIST]: searchList as RtkFilterFn, + [FILTERS.BUILD_OUTPUT]: buildOutput as RtkFilterFn, +}; + +// Rust resolve_filter aliases (pipe_cmd.rs): grep|rg, find|fd +const ALIASES: Record = { + rg: grep as RtkFilterFn, + fd: find as RtkFilterFn, +}; + +export function resolveFilter(name: string): RtkFilterFn | null { + return REGISTRY[name] || ALIASES[name] || null; +} + +export function allFilters() { + return REGISTRY; +} diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.ts similarity index 76% rename from open-sse/services/accountFallback.js rename to open-sse/services/accountFallback.ts index be9c8f38..3a537921 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.ts @@ -6,6 +6,29 @@ import { TRANSIENT_COOLDOWN_MS, } from "../config/errorConfig.js"; +type ErrorRule = { + text?: string; + pattern?: RegExp; + status?: number; + cooldownMs?: number; + backoff?: boolean; + untilMidnightVN?: boolean; + untilNextMinute?: boolean; +}; + +type AccountRecord = Record & { + data?: Record; + id?: unknown; + rateLimitedUntil?: unknown; + backoffLevel?: unknown; +}; + +type FallbackDecision = { + cooldownMs: number; + newBackoffLevel?: number; + shouldFallback: boolean; +}; + /** * Calculate exponential backoff cooldown for rate limits (429) * Level 1: 1s, Level 2: 2s, Level 3: 4s... → max 4 min @@ -26,12 +49,16 @@ export function getQuotaCooldown(backoffLevel = 0) { * @param {number} backoffLevel - Current backoff level for exponential backoff * @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }} */ -export function checkFallbackError(status, errorText, backoffLevel = 0) { +export function checkFallbackError( + status: number, + errorText: unknown, + backoffLevel = 0, +): FallbackDecision { const lowerError = errorText ? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase() : ""; - const resolveCooldown = (rule) => { + const resolveCooldown = (rule: ErrorRule) => { if (rule.backoff) { const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); return { @@ -46,7 +73,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { if (rule.untilNextMinute) { return { shouldFallback: true, cooldownMs: msUntilNextMinute(), newBackoffLevel: 0 }; } - return { shouldFallback: true, cooldownMs: rule.cooldownMs }; + return { shouldFallback: true, cooldownMs: rule.cooldownMs ?? 0 }; }; const rawError = errorText @@ -55,7 +82,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { : JSON.stringify(errorText) : ""; - for (const rule of ERROR_RULES) { + for (const rule of ERROR_RULES as ErrorRule[]) { if (rule.text && lowerError && lowerError.includes(rule.text)) return resolveCooldown(rule); if (rule.pattern && rawError && rule.pattern.test(rawError)) return resolveCooldown(rule); if (rule.status && rule.status === status) return resolveCooldown(rule); @@ -68,7 +95,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { /** * Check if account is currently unavailable (cooldown not expired) */ -export function isAccountUnavailable(unavailableUntil) { +export function isAccountUnavailable(unavailableUntil: string | number | Date | null | undefined) { if (!unavailableUntil) return false; return new Date(unavailableUntil).getTime() > Date.now(); } @@ -76,7 +103,7 @@ export function isAccountUnavailable(unavailableUntil) { /** * Calculate unavailable until timestamp */ -export function getUnavailableUntil(cooldownMs) { +export function getUnavailableUntil(cooldownMs: number) { return new Date(Date.now() + cooldownMs).toISOString(); } @@ -85,12 +112,12 @@ export function getUnavailableUntil(cooldownMs) { * @param {Array} accounts - Array of account objects with rateLimitedUntil * @returns {string|null} Earliest rateLimitedUntil ISO string, or null */ -export function getEarliestRateLimitedUntil(accounts) { - let earliest = null; +export function getEarliestRateLimitedUntil(accounts: AccountRecord[]) { + let earliest: number | null = null; const now = Date.now(); for (const acc of accounts) { if (!acc.rateLimitedUntil) continue; - const until = new Date(acc.rateLimitedUntil).getTime(); + const until = new Date(acc.rateLimitedUntil as string | number | Date).getTime(); if (until <= now) continue; if (!earliest || until < earliest) earliest = until; } @@ -103,7 +130,7 @@ export function getEarliestRateLimitedUntil(accounts) { * @param {string} rateLimitedUntil - ISO timestamp * @returns {string} e.g. "reset after 2m 30s" */ -export function formatRetryAfter(rateLimitedUntil) { +export function formatRetryAfter(rateLimitedUntil: string | number | Date | null | undefined) { if (!rateLimitedUntil) return ""; const diffMs = new Date(rateLimitedUntil).getTime() - Date.now(); if (diffMs <= 0) return "reset after 0s"; @@ -111,7 +138,7 @@ export function formatRetryAfter(rateLimitedUntil) { const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; - const parts = []; + const parts: string[] = []; if (h > 0) parts.push(`${h}h`); if (m > 0) parts.push(`${m}m`); if (s > 0 || parts.length === 0) parts.push(`${s}s`); @@ -157,7 +184,7 @@ const CONN_LEVEL_ERROR_PATTERNS = [ * not model-specific. These errors should lock the entire connection * so the retry loop immediately moves to the next account. */ -export function isConnectionLevelError(status, bodyText) { +export function isConnectionLevelError(status: number, bodyText: unknown) { // 401/403 = auth failure — always connection-level if (status === 401 || status === 403) return true; if (!bodyText) return false; @@ -168,26 +195,31 @@ export function isConnectionLevelError(status, bodyText) { /** * Check if a connection-level lock is currently active. */ -export function isConnectionLockActive(connection) { +export function isConnectionLockActive(connection: AccountRecord | null | undefined) { const until = connection?.[CONN_LOCK_UNTIL_KEY] || connection?.data?.[CONN_LOCK_UNTIL_KEY]; if (!until) return false; - return new Date(until).getTime() > Date.now(); + return new Date(until as string | number | Date).getTime() > Date.now(); } /** * Get connection lock expiry (ISO string) or null. */ -export function getConnectionLockUntil(connection) { +export function getConnectionLockUntil(connection: AccountRecord | null | undefined) { const until = connection?.[CONN_LOCK_UNTIL_KEY] || connection?.data?.[CONN_LOCK_UNTIL_KEY]; if (!until) return null; - return new Date(until).getTime() > Date.now() ? until : null; + return new Date(until as string | number | Date).getTime() > Date.now() + ? (until as string) + : null; } /** * Build update object to set a connection-level lock. * Cooldown = base * lockCount (1h, 2h, 3h, ...) */ -export function buildConnectionLockUpdate(connection, reason) { +export function buildConnectionLockUpdate( + connection: AccountRecord | null | undefined, + reason: unknown, +) { const prevCount = Number( connection?.[CONN_LOCK_COUNT_KEY] || connection?.data?.[CONN_LOCK_COUNT_KEY] || 0, ); @@ -230,7 +262,7 @@ export const MODEL_LOCK_PREFIX = "modelLock_"; export const MODEL_LOCK_ALL = `${MODEL_LOCK_PREFIX}__all`; /** Build the flat field key for a model lock */ -export function getModelLockKey(model) { +export function getModelLockKey(model: string | null | undefined) { return model ? `${MODEL_LOCK_PREFIX}${model}` : MODEL_LOCK_ALL; } @@ -238,12 +270,15 @@ export function getModelLockKey(model) { export const MODEL_LOCK_COUNT_PREFIX = "modelLockCount_"; /** Build the flat field key for a model lock count */ -export function getModelLockCountKey(model) { +export function getModelLockCountKey(model: string | null | undefined) { return model ? `${MODEL_LOCK_COUNT_PREFIX}${model}` : `${MODEL_LOCK_COUNT_PREFIX}__all`; } /** Read current lock count for a model from a connection record */ -export function getModelLockCount(connection, model) { +export function getModelLockCount( + connection: AccountRecord | null | undefined, + model: string | null | undefined, +) { const key = getModelLockCountKey(model); return Number(connection?.[key]) || 0; } @@ -252,24 +287,24 @@ export function getModelLockCount(connection, model) { * Check if a model lock on a connection is still active. * Reads flat field `modelLock_${model}` (or `modelLock___all` when model=null). */ -export function isModelLockActive(connection, model) { +export function isModelLockActive(connection: AccountRecord, model: string | null | undefined) { const key = getModelLockKey(model); const expiry = connection[key] || connection[MODEL_LOCK_ALL]; if (!expiry) return false; - return new Date(expiry).getTime() > Date.now(); + return new Date(expiry as string | number | Date).getTime() > Date.now(); } /** * Get earliest active model lock expiry across all modelLock_* fields. * Used for UI cooldown display. */ -export function getEarliestModelLockUntil(connection) { +export function getEarliestModelLockUntil(connection: AccountRecord | null | undefined) { if (!connection) return null; - let earliest = null; + let earliest: number | null = null; const now = Date.now(); for (const [key, val] of Object.entries(connection)) { if (!key.startsWith(MODEL_LOCK_PREFIX) || !val) continue; - const t = new Date(val).getTime(); + const t = new Date(val as string | number | Date).getTime(); if (t <= now) continue; if (!earliest || t < earliest) earliest = t; } @@ -279,7 +314,7 @@ export function getEarliestModelLockUntil(connection) { /** * Build update object to set a model lock on a connection. */ -export function buildModelLockUpdate(model, cooldownMs) { +export function buildModelLockUpdate(model: string | null | undefined, cooldownMs: number) { const key = getModelLockKey(model); return { [key]: new Date(Date.now() + cooldownMs).toISOString() }; } @@ -287,8 +322,8 @@ export function buildModelLockUpdate(model, cooldownMs) { /** * Build update object to clear all model locks on a connection. */ -export function buildClearModelLocksUpdate(connection) { - const cleared = {}; +export function buildClearModelLocksUpdate(connection: AccountRecord) { + const cleared: Record = {}; for (const key of Object.keys(connection)) { if (key.startsWith(MODEL_LOCK_PREFIX)) cleared[key] = null; if (key.startsWith(MODEL_LOCK_COUNT_PREFIX)) cleared[key] = null; @@ -299,12 +334,12 @@ export function buildClearModelLocksUpdate(connection) { /** * Filter available accounts (not in cooldown) */ -export function filterAvailableAccounts(accounts, excludeId = null) { +export function filterAvailableAccounts(accounts: AccountRecord[], excludeId: unknown = null) { const now = Date.now(); return accounts.filter((acc) => { if (excludeId && acc.id === excludeId) return false; if (acc.rateLimitedUntil) { - const until = new Date(acc.rateLimitedUntil).getTime(); + const until = new Date(acc.rateLimitedUntil as string | number | Date).getTime(); if (until > now) return false; } return true; @@ -317,7 +352,7 @@ export function filterAvailableAccounts(accounts, excludeId = null) { * @param {object} account - Account object * @returns {object} Updated account with reset state */ -export function resetAccountState(account) { +export function resetAccountState(account: AccountRecord | null | undefined) { if (!account) return account; return { ...account, @@ -335,11 +370,19 @@ export function resetAccountState(account) { * @param {string} errorText - Error message * @returns {object} Updated account with error state */ -export function applyErrorState(account, status, errorText) { +export function applyErrorState( + account: AccountRecord | null | undefined, + status: number, + errorText: unknown, +) { if (!account) return account; const backoffLevel = account.backoffLevel || 0; - const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel); + const { cooldownMs, newBackoffLevel } = checkFallbackError( + status, + errorText, + Number(backoffLevel) || 0, + ); return { ...account, diff --git a/open-sse/services/combo.js b/open-sse/services/combo.ts similarity index 68% rename from open-sse/services/combo.js rename to open-sse/services/combo.ts index 67a87f43..faf4f8a8 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.ts @@ -5,6 +5,60 @@ import { unavailableResponse } from "../utils/error.js"; import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; +type JsonRecord = Record; + +type ComboLogger = { + info: (scope: string, message: string, meta?: JsonRecord) => void; + warn: (scope: string, message: string, meta?: JsonRecord) => void; +}; + +type ComboEntry = { + name?: string; + models?: string[]; + systemPrompt?: string | null; +}; + +type CombosData = ComboEntry[] | { combos?: ComboEntry[] } | null | undefined; + +type ComboRotationState = { index: number; consecutiveUseCount: number }; + +type TextPart = { text?: string; [key: string]: unknown }; + +type SystemInstruction = { + role?: string; + parts?: TextPart[]; + [key: string]: unknown; +}; + +/** Mutable request body shapes handled by combo system-prompt injection. */ +export type ComboRequestBody = { + request?: { + contents?: unknown; + systemInstruction?: SystemInstruction; + [key: string]: unknown; + }; + contents?: unknown; + systemInstruction?: SystemInstruction; + input?: unknown; + messages?: unknown; + instructions?: string; + system?: unknown; + anthropic_version?: unknown; + [key: string]: unknown; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function asComboList(combosData: CombosData): ComboEntry[] { + if (Array.isArray(combosData)) return combosData; + if (combosData && typeof combosData === "object" && Array.isArray(combosData.combos)) { + return combosData.combos; + } + return []; +} + /** * Track rotation state per combo (for round-robin strategy) * @@ -12,20 +66,29 @@ import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; * purely synchronous (no `await`). Node.js single-threaded event loop * guarantees no interleaving between read and write — each call completes * atomically within one JS tick. No mutex needed. - * @type {Map} */ -const comboRotationState = new Map(); +const comboRotationState = new Map(); + +export interface ComboChatParams { + body: JsonRecord; + models: string[]; + handleSingleModel: (body: JsonRecord, model: string) => Promise; + log: ComboLogger; + comboName?: string; + comboStrategy?: string; + comboStickyLimit?: number | string; +} -function normalizeStickyLimit(stickyLimit) { - const parsed = Number.parseInt(stickyLimit, 10); +function normalizeStickyLimit(stickyLimit: unknown): number { + const parsed = Number.parseInt(String(stickyLimit), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; } -function rotateModelsFromIndex(models, currentIndex) { +function rotateModelsFromIndex(models: string[], currentIndex: number): string[] { const rotatedModels = [...models]; for (let i = 0; i < currentIndex; i++) { const moved = rotatedModels.shift(); - rotatedModels.push(moved); + if (moved !== undefined) rotatedModels.push(moved); } return rotatedModels; } @@ -38,7 +101,12 @@ function rotateModelsFromIndex(models, currentIndex) { * @param {number|string} [stickyLimit=1] - Requests per combo model before switching * @returns {string[]} Rotated models array */ -export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { +export function getRotatedModels( + models: string[], + comboName: string | undefined, + strategy: string | undefined, + stickyLimit: number | string = 1, +): string[] { if (!models || models.length <= 1 || strategy !== "round-robin") { return models; } @@ -46,7 +114,7 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { const rotationKey = comboName || "__default__"; const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); const existingState = comboRotationState.get(rotationKey); - const state = + const state: ComboRotationState = typeof existingState === "number" ? { index: existingState, consecutiveUseCount: 0 } : existingState || { index: 0, consecutiveUseCount: 0 }; @@ -74,7 +142,7 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { * Reset in-memory rotation state when combo/settings change * @param {string} [comboName] - Combo name to reset; omit to clear all */ -export function resetComboRotation(comboName) { +export function resetComboRotation(comboName?: string) { if (comboName) comboRotationState.delete(comboName); else comboRotationState.clear(); } @@ -85,12 +153,11 @@ export function resetComboRotation(comboName) { * @param {Array|Object} combosData - Array of combos or object with combos * @returns {string[]|null} Array of models or null if not a combo */ -export function getComboModelsFromData(modelStr, combosData) { +export function getComboModelsFromData(modelStr: string, combosData: CombosData): string[] | null { // Don't check if it's in provider/model format if (modelStr.includes("/")) return null; - // Handle both array and object formats - const combos = Array.isArray(combosData) ? combosData : combosData?.combos || []; + const combos = asComboList(combosData); const combo = combos.find((c) => c.name === modelStr); if (combo && combo.models && combo.models.length > 0) { @@ -105,9 +172,12 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {Array|Object} combosData * @returns {{models: string[], systemPrompt: string|null}|null} */ -export function getComboEntryFromData(modelStr, combosData) { +export function getComboEntryFromData( + modelStr: string, + combosData: CombosData, +): { models: string[]; systemPrompt: string | null } | null { if (modelStr.includes("/")) return null; - const combos = Array.isArray(combosData) ? combosData : combosData?.combos || []; + const combos = asComboList(combosData); const combo = combos.find((c) => c.name === modelStr); if (combo && combo.models && combo.models.length > 0) { return { models: combo.models, systemPrompt: combo.systemPrompt || null }; @@ -123,7 +193,10 @@ export function getComboEntryFromData(modelStr, combosData) { * @param {string} systemPrompt * @returns {Object} */ -export function injectComboSystemPrompt(body, systemPrompt) { +export function injectComboSystemPrompt( + body: ComboRequestBody | null | undefined, + systemPrompt: unknown, +): ComboRequestBody | null | undefined { if (!body || typeof systemPrompt !== "string" || !systemPrompt.trim()) return body; const prompt = systemPrompt; @@ -131,7 +204,7 @@ export function injectComboSystemPrompt(body, systemPrompt) { if (body.request && (body.request.contents || body.request.systemInstruction)) { const req = body.request; const existing = req.systemInstruction; - const newPart = { text: prompt }; + const newPart: TextPart = { text: prompt }; if (existing?.parts && Array.isArray(existing.parts)) { existing.parts.unshift(newPart); } else if (existing?.role || existing?.parts) { @@ -148,7 +221,7 @@ export function injectComboSystemPrompt(body, systemPrompt) { // Gemini: { contents, systemInstruction? } if (Array.isArray(body.contents)) { const existing = body.systemInstruction; - const newPart = { text: prompt }; + const newPart: TextPart = { text: prompt }; if (existing?.parts && Array.isArray(existing.parts)) { existing.parts.unshift(newPart); } else { @@ -208,16 +281,16 @@ export async function handleComboChat({ comboName, comboStrategy, comboStickyLimit = 1, -}) { +}: ComboChatParams): Promise { // Apply rotation strategy if enabled const rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); - let lastError = null; - let earliestRetryAfter = null; - let lastStatus = null; + let lastError: string | null = null; + let earliestRetryAfter: string | null = null; + let lastStatus: number | null = null; for (let i = 0; i < rotatedModels.length; i++) { - const modelStr = rotatedModels[i]; + const modelStr = rotatedModels[i]!; log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); try { @@ -230,13 +303,21 @@ export async function handleComboChat({ } // Extract error info from response - let errorText = result.statusText || ""; - let retryAfter = null; + let errorText: unknown = result.statusText || ""; + let retryAfter: string | null = null; try { - const errorBody = await result.clone().json(); - errorText = - errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; + const errorBodyUnknown: unknown = await result.clone().json(); + const errorBody = + errorBodyUnknown && typeof errorBodyUnknown === "object" + ? (errorBodyUnknown as JsonRecord) + : {}; + const errField = errorBody.error; + const errMessage = + errField && typeof errField === "object" && errField !== null + ? (errField as JsonRecord).message + : undefined; + errorText = errMessage || errField || errorBody.message || errorText; + retryAfter = typeof errorBody.retryAfter === "string" ? errorBody.retryAfter : null; } catch { // Ignore JSON parse errors } @@ -250,16 +331,19 @@ export async function handleComboChat({ } // Normalize error text to string (Worker-safe) - if (typeof errorText !== "string") { + let errorTextStr: string; + if (typeof errorText === "string") { + errorTextStr = errorText; + } else { try { - errorText = JSON.stringify(errorText); + errorTextStr = JSON.stringify(errorText); } catch { - errorText = String(errorText); + errorTextStr = String(errorText); } } // Check if should fallback to next model - const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); + const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorTextStr); if (!shouldFallback) { log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); @@ -279,17 +363,19 @@ export async function handleComboChat({ "COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`, ); - await new Promise((r) => setTimeout(r, cooldownMs)); + await new Promise((r) => setTimeout(r, cooldownMs)); } // Fallback to next model - lastError = errorText || String(result.status); + lastError = errorTextStr || String(result.status); if (!lastStatus) lastStatus = result.status; log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); - } catch (error) { + } catch (error: unknown) { // Catch unexpected exceptions to ensure fallback continues // Log full error internally but don't expose raw stack/message to clients - log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: error.message }); + log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { + error: errorMessage(error), + }); lastError = "Model request failed"; if (!lastStatus) lastStatus = 500; } @@ -323,14 +409,17 @@ export async function handleComboChat({ * @param {string} modelId * @returns {Response} */ -export async function overrideResponseModelId(response, modelId) { +export async function overrideResponseModelId( + response: Response, + modelId: string | null | undefined, +): Promise { if (!modelId || !response) return response; const contentType = response.headers.get("content-type") || ""; // SSE streaming — rewrite each `data:` line that contains a `"model"` field if (contentType.includes("text/event-stream")) { - const { readable, writable } = new TransformStream({ + const { readable, writable } = new TransformStream({ transform(chunk, controller) { const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); const rewritten = text @@ -340,10 +429,13 @@ export async function overrideResponseModelId(response, modelId) { const payload = line.slice(5).trim(); if (payload === "[DONE]") return line; try { - const obj = JSON.parse(payload); - if ("model" in obj) obj.model = modelId; + const obj: unknown = JSON.parse(payload); + if (obj && typeof obj === "object" && "model" in obj) { + (obj as JsonRecord).model = modelId; + } return `data: ${JSON.stringify(obj)}`; } catch { + // Malformed SSE data should pass through unchanged. return line; } }) @@ -351,19 +443,24 @@ export async function overrideResponseModelId(response, modelId) { controller.enqueue(new TextEncoder().encode(rewritten)); }, }); - response.body.pipeTo(writable).catch(() => {}); + response.body!.pipeTo(writable).catch(() => { + // Client disconnect or upstream cancellation; response body cleanup is best effort. + }); const headers = new Headers(response.headers); return new Response(readable, { status: response.status, headers }); } // Non-streaming JSON try { - const body = await response.json(); + const bodyUnknown: unknown = await response.json(); + const body = + bodyUnknown && typeof bodyUnknown === "object" ? (bodyUnknown as JsonRecord) : bodyUnknown; if (body && typeof body === "object" && "model" in body) body.model = modelId; const headers = new Headers(response.headers); headers.set("Content-Type", "application/json"); return new Response(JSON.stringify(body), { status: response.status, headers }); } catch { + // Preserve original response when non-streaming body cannot be parsed. return response; } } diff --git a/open-sse/services/compact.js b/open-sse/services/compact.ts similarity index 64% rename from open-sse/services/compact.js rename to open-sse/services/compact.ts index 3006dd40..cc3db1ac 100644 --- a/open-sse/services/compact.js +++ b/open-sse/services/compact.ts @@ -2,20 +2,40 @@ * Shared combo (model combo) handling with fallback support */ +type ComboEntry = { + name?: string; + models?: string[]; + [key: string]: unknown; +}; + +type CombosData = ComboEntry[] | { combos?: ComboEntry[] } | null | undefined; + +type ComboLogger = { + info: (scope: string, message: string) => void; + warn: (scope: string, message: string, meta?: Record) => void; +}; + +type ComboChatOptions = { + body: unknown; + models: string[]; + handleSingleModel: (body: unknown, modelStr: string) => Promise; + log: ComboLogger; +}; + /** * Get combo models from combos data * @param {string} modelStr - Model string to check * @param {Array|Object} combosData - Array of combos or object with combos * @returns {string[]|null} Array of models or null if not a combo */ -export function getComboModelsFromData(modelStr, combosData) { +export function getComboModelsFromData(modelStr: string, combosData: CombosData): string[] | null { // Don't check if it's in provider/model format if (modelStr.includes("/")) return null; // Handle both array and object formats const combos = Array.isArray(combosData) ? combosData : combosData?.combos || []; - const combo = combos.find((c) => c.name === modelStr); + const combo = combos.find((c: ComboEntry) => c.name === modelStr); if (combo && combo.models && combo.models.length > 0) { return combo.models; } @@ -31,21 +51,27 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {Object} options.log - Logger object * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log }) { - let lastError = null; +export async function handleComboChat({ + body, + models, + handleSingleModel, + log, +}: ComboChatOptions): Promise { + let lastError: string | null = null; for (let i = 0; i < models.length; i++) { - const modelStr = models[i]; + const modelStr = models[i]!; log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`); - let result; + let result: Response; try { result = await handleSingleModel(body, modelStr); - } catch (e) { - lastError = `${modelStr}: ${e.message}`; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + lastError = `${modelStr}: ${message}`; log.warn("COMBO", `Model threw exception, trying next`, { model: modelStr, - error: e.message, + error: message, }); continue; } diff --git a/open-sse/services/model.js b/open-sse/services/model.ts similarity index 83% rename from open-sse/services/model.js rename to open-sse/services/model.ts index bbb9a314..f7285363 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.ts @@ -1,5 +1,5 @@ // Provider alias to ID mapping -const ALIAS_TO_PROVIDER_ID = { +const ALIAS_TO_PROVIDER_ID: Record = { cc: "claude", cx: "codex", gc: "gemini-cli", @@ -87,14 +87,14 @@ const ALIAS_TO_PROVIDER_ID = { /** * Resolve provider alias to provider ID */ -export function resolveProviderAlias(aliasOrId) { +export function resolveProviderAlias(aliasOrId: string): string { return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; } /** * Parse model string: "alias/model" or "provider/model" or just alias */ -export function parseModel(modelStr) { +export function parseModel(modelStr: string | null | undefined) { if (!modelStr) { return { provider: null, model: null, isAlias: false, providerAlias: null }; } @@ -121,8 +121,20 @@ export function parseModel(modelStr) { * Resolve model alias from aliases object * Format: { "alias": "provider/model" } */ -export function resolveModelAliasFromMap(alias, aliases) { - if (!aliases) return null; +export type ModelAliasResolved = { + provider: string; + model: string; +}; + +export type ModelAliasEntry = string | ModelAliasResolved; + +export type ModelAliasMap = Record; + +export function resolveModelAliasFromMap( + alias: string | null | undefined, + aliases: ModelAliasMap | null | undefined, +): ModelAliasResolved | null { + if (!aliases || alias === null || alias === undefined) return null; // Check if alias exists const resolved = aliases[alias]; @@ -154,7 +166,14 @@ export function resolveModelAliasFromMap(alias, aliases) { * @param {string} modelStr - Model string * @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases */ -export async function getModelInfoCore(modelStr, aliasesOrGetter) { +export async function getModelInfoCore( + modelStr: string | null | undefined, + aliasesOrGetter: + | ModelAliasMap + | (() => ModelAliasMap | Promise) + | null + | undefined, +) { const parsed = parseModel(modelStr); if (!parsed.isAlias) { @@ -168,7 +187,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter; // Resolve alias - const resolved = resolveModelAliasFromMap(parsed.model, aliases); + const resolved = resolveModelAliasFromMap(parsed.model, aliases ?? null); if (resolved) { return resolved; } @@ -184,7 +203,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { * Infer provider from model name prefix * Used as fallback when no provider prefix or alias is given */ -function inferProviderFromModelName(modelName) { +function inferProviderFromModelName(modelName: string | null | undefined): string { if (!modelName) return "openai"; const m = modelName.toLowerCase(); if (m.startsWith("claude-")) return "anthropic"; diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.ts similarity index 84% rename from open-sse/services/projectId.js rename to open-sse/services/projectId.ts index f2615f37..1031b30f 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Project ID Service - Fetch and cache real Project IDs from Google Cloud Code API * @@ -13,20 +14,43 @@ import { LOAD_CODE_ASSIST_METADATA, } from "../config/appConstants.js"; -function redactConnectionId(connectionId) { +type ProjectIdCacheEntry = { + fetchedAt: number; + projectId: string; +}; +type PendingFetch = { + controller: AbortController; + promise: Promise; + startedAt: number; +}; +type CloudCodeProject = { + id?: unknown; +}; +type LoadCodeAssistResponse = { + allowedTiers?: Array<{ id?: unknown; isDefault?: unknown }>; + cloudaicompanionProject?: CloudCodeProject | string; +}; +type OnboardUserResponse = { + done?: unknown; + response?: { + cloudaicompanionProject?: CloudCodeProject | string; + }; +}; + +function redactConnectionId(connectionId: string) { return connectionId ? `${String(connectionId).slice(0, 4)}...` : "unknown"; } // ─── Cache ──────────────────────────────────────────────────────────────────── // connectionId -> { projectId: string, fetchedAt: number } -const projectIdCache = new Map(); +const projectIdCache = new Map(); /** How long a cached project ID is considered fresh (1 hour). */ const CACHE_TTL_MS = 60 * 60 * 1000; // ─── Pending-fetch deduplication ───────────────────────────────────────────── // connectionId -> { promise: Promise, controller: AbortController, startedAt: number } -const pendingFetches = new Map(); +const pendingFetches = new Map(); /** Abort and evict a pending fetch that has been running longer than this (2 min). */ const PENDING_TTL_MS = 2 * 60 * 1000; @@ -35,7 +59,7 @@ const PENDING_TTL_MS = 2 * 60 * 1000; /** How often the background sweep runs (10 min). */ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; -let _cleanupTimer = null; +let _cleanupTimer: ReturnType | null = null; /** Run one sweep immediately: evict stale cache entries and abort orphaned pending fetches. */ export function cleanupNow() { @@ -55,7 +79,7 @@ export function cleanupNow() { if (now - item.startedAt > PENDING_TTL_MS) { try { item.controller.abort(); - } catch (_) { + } catch { /* ignore */ } pendingFetches.delete(id); @@ -69,7 +93,7 @@ export function startCacheCleanup() { _cleanupTimer = setInterval(() => { try { cleanupNow(); - } catch (_e) { + } catch { console.warn("[ProjectId] cleanup sweep error"); } }, CLEANUP_INTERVAL_MS); @@ -97,7 +121,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId: string, accessToken: string) { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -107,8 +131,9 @@ export async function getProjectIdForConnection(connectionId, accessToken) { } // Deduplicate concurrent fetches for the same connection - if (pendingFetches.has(connectionId)) { - return pendingFetches.get(connectionId).promise; + const pendingFetch = pendingFetches.get(connectionId); + if (pendingFetch) { + return pendingFetch.promise; } // Each fetch gets its own AbortController so it can be canceled via removeConnection() @@ -126,7 +151,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { redactConnectionId(connectionId), ); return null; - } catch (_error) { + } catch { console.warn("[ProjectId] Error fetching project ID"); return null; } finally { @@ -142,7 +167,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { * Invalidate the cached project ID for a connection. * Call this when a connection's credentials are fully revoked or refreshed. */ -export function invalidateProjectId(connectionId) { +export function invalidateProjectId(connectionId: string) { projectIdCache.delete(connectionId); } @@ -152,14 +177,14 @@ export function invalidateProjectId(connectionId) { * * @param {string} connectionId */ -export function removeConnection(connectionId) { +export function removeConnection(connectionId: string) { if (!connectionId) return; projectIdCache.delete(connectionId); const pending = pendingFetches.get(connectionId); if (pending) { try { pending.controller.abort(); - } catch (_) { + } catch { /* ignore */ } pendingFetches.delete(connectionId); @@ -176,7 +201,7 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { +async function fetchProjectId(accessToken: string, signal: AbortSignal): Promise { const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, Authorization: `Bearer ${accessToken}` }, @@ -189,7 +214,7 @@ async function fetchProjectId(accessToken, signal) { throw new Error(`loadCodeAssist failed: HTTP ${response.status} ${errorText.slice(0, 200)}`); } - const data = await response.json(); + const data = (await response.json()) as LoadCodeAssistResponse; const projectId = extractProjectId(data); if (projectId) return projectId; @@ -217,7 +242,11 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser( + accessToken: string, + tierID: string, + externalSignal: AbortSignal, +): Promise { console.log("[ProjectId] Onboarding user"); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -248,7 +277,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { throw new Error(`onboardUser HTTP ${response.status}: ${errorText.slice(0, 200)}`); } - const data = await response.json(); + const data = (await response.json()) as OnboardUserResponse; if (data.done === true) { const projectId = extractProjectIdFromOnboard(data); @@ -261,10 +290,10 @@ async function onboardUser(accessToken, tierID, externalSignal) { // Server not done yet – wait and retry console.log(`[ProjectId] Onboard attempt ${attempt}/${MAX_ATTEMPTS}: waiting`); - await new Promise((resolve) => setTimeout(resolve, 2000)); - } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch (error: unknown) { clearTimeout(timeoutId); - if (error.name === "AbortError") { + if (error instanceof Error && error.name === "AbortError") { console.warn(`[ProjectId] onboardUser attempt ${attempt} aborted`); if (externalSignal?.aborted) return null; // connection gone – stop retrying continue; @@ -275,7 +304,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { } // Continue to next attempt instead of throwing (which would skip remaining retries) console.warn(`[ProjectId] onboardUser attempt ${attempt} failed, retrying...`); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); } finally { clearTimeout(timeoutId); externalSignal?.removeEventListener("abort", forwardAbort); @@ -288,7 +317,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { /** * Extract project ID from loadCodeAssist response. */ -function extractProjectId(data) { +function extractProjectId(data: LoadCodeAssistResponse) { if (!data) return null; if (typeof data.cloudaicompanionProject === "string") { @@ -307,7 +336,7 @@ function extractProjectId(data) { /** * Extract project ID from onboardUser response. */ -function extractProjectIdFromOnboard(data) { +function extractProjectIdFromOnboard(data: OnboardUserResponse) { if (!data?.response) return null; const project = data.response.cloudaicompanionProject; diff --git a/open-sse/services/provider.js b/open-sse/services/provider.ts similarity index 74% rename from open-sse/services/provider.js rename to open-sse/services/provider.ts index 5fbdd7b6..711d6a97 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.ts @@ -1,6 +1,57 @@ +// @ts-nocheck import { buildClineHeaders } from "../../src/shared/utils/clineAuth.mts"; import { PROVIDERS } from "../config/providers.js"; +type JsonRecord = Record; +type ProviderCredentials = { + accessToken?: string; + apiKey?: string; + copilotToken?: string; + providerSpecificData?: JsonRecord; +}; +type ProviderOptions = { + baseUrl?: string; + baseUrlIndex?: number; + qwenResourceUrl?: string; +}; +type ProviderConfig = { + baseUrl?: string; + baseUrls?: string[]; + format?: string; + headers?: Record; + [key: string]: unknown; +}; +type MessageContentPart = { + image_url?: { url?: unknown }; + source?: { type?: unknown }; + type?: string; +}; +type Message = { + content?: unknown; + role?: string; +}; +type RequestBody = JsonRecord & { + anthropic_version?: unknown; + contents?: unknown; + frequency_penalty?: unknown; + input?: unknown; + logit_bias?: unknown; + logprobs?: unknown; + messages?: Message[]; + model?: unknown; + n?: unknown; + presence_penalty?: unknown; + reasoning_effort?: unknown; + request?: { contents?: unknown }; + response_format?: unknown; + stream_options?: unknown; + system?: unknown; + thinking?: { type?: unknown }; + top_logprobs?: unknown; + user?: unknown; + userAgent?: unknown; +}; + const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.openai.com/v1", @@ -11,31 +62,35 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.anthropic.com/v1", }; -function isOpenAICompatible(provider) { +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function isOpenAICompatible(provider: unknown): provider is string { return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX); } -function isAnthropicCompatible(provider) { +function isAnthropicCompatible(provider: unknown): provider is string { return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); } -function getOpenAICompatibleType(provider) { +function getOpenAICompatibleType(provider: string) { if (!isOpenAICompatible(provider)) return "chat"; return provider.includes("responses") ? "responses" : "chat"; } -function buildOpenAICompatibleUrl(baseUrl, apiType) { +function buildOpenAICompatibleUrl(baseUrl: string, apiType: string) { const normalized = baseUrl.replace(/\/$/, ""); const path = apiType === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } -function buildAnthropicCompatibleUrl(baseUrl) { +function buildAnthropicCompatibleUrl(baseUrl: string) { const normalized = baseUrl.replace(/\/$/, ""); return `${normalized}/messages`; } -function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) { +function buildQwenBaseUrl(resourceUrl: unknown, fallbackBaseUrl: string | undefined) { const fallback = (fallbackBaseUrl || "").replace(/\/chat\/completions$/, ""); const raw = typeof resourceUrl === "string" ? resourceUrl.trim() : ""; if (!raw) return fallback; @@ -46,7 +101,7 @@ function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) { } // Detect request format from body structure -export function detectFormat(body) { +export function detectFormat(body: RequestBody) { // OpenAI Responses API: has input (array or string) instead of messages[] // The Responses API accepts both input as array and input as a plain string if ( @@ -90,11 +145,14 @@ export function detectFormat(body) { // If content is array, check if it follows Claude structure if (firstMsg?.content && Array.isArray(firstMsg.content)) { - const firstContent = firstMsg.content[0]; + const firstContent = firstMsg.content[0] as MessageContentPart | undefined; // Claude format has specific types: text, image, tool_use, tool_result // OpenAI multimodal has: text, image_url (note the difference) - if (firstContent?.type === "text" && !body.model?.includes("/")) { + if ( + firstContent?.type === "text" && + !(typeof body.model === "string" && body.model.includes("/")) + ) { // Could be Claude or OpenAI multimodal // Check for Claude-specific fields if (body.system || body.anthropic_version) { @@ -102,17 +160,17 @@ export function detectFormat(body) { } // Check if image format is Claude (source.type) vs OpenAI (image_url.url) const hasClaudeImage = firstMsg.content.some( - (c) => c.type === "image" && c.source?.type === "base64", + (c: MessageContentPart) => c.type === "image" && c.source?.type === "base64", ); const hasOpenAIImage = firstMsg.content.some( - (c) => c.type === "image_url" && c.image_url?.url, + (c: MessageContentPart) => c.type === "image_url" && c.image_url?.url, ); if (hasClaudeImage) return "claude"; if (hasOpenAIImage) return "openai"; // If still unclear, check for tool format const hasClaudeTool = firstMsg.content.some( - (c) => c.type === "tool_use" || c.type === "tool_result", + (c: MessageContentPart) => c.type === "tool_use" || c.type === "tool_result", ); if (hasClaudeTool) return "claude"; } @@ -130,7 +188,7 @@ export function detectFormat(body) { } // Get provider config -export function getProviderConfig(provider) { +export function getProviderConfig(provider: string): ProviderConfig { if (isOpenAICompatible(provider)) { const apiType = getOpenAICompatibleType(provider); return { @@ -146,17 +204,22 @@ export function getProviderConfig(provider) { baseUrl: ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl, }; } - return PROVIDERS[provider] || PROVIDERS.openai; + return (PROVIDERS as Record)[provider] || PROVIDERS.openai; } // Get number of fallback URLs for provider (for retry logic) -export function getProviderFallbackCount(provider) { +export function getProviderFallbackCount(provider: string) { const config = getProviderConfig(provider); return config.baseUrls?.length || 1; } // Build provider URL -export function buildProviderUrl(provider, model, stream = true, options = {}) { +export function buildProviderUrl( + provider: string, + model: string, + stream: boolean = true, + options: ProviderOptions = {}, +): string { if (isOpenAICompatible(provider)) { const apiType = getOpenAICompatibleType(provider); const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl; @@ -185,7 +248,8 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { case "antigravity": { // Use baseUrlIndex from options or default to 0 const urlIndex = options?.baseUrlIndex || 0; - const baseUrl = config.baseUrls[urlIndex] || config.baseUrls[0]; + const baseUrls = config.baseUrls || []; + const baseUrl = baseUrls[urlIndex] || baseUrls[0]; const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent"; @@ -193,7 +257,7 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { } case "codex": - return config.baseUrl; + return config.baseUrl as string; case "qwen": { const baseUrl = buildQwenBaseUrl(options?.qwenResourceUrl, config.baseUrl); @@ -201,7 +265,7 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { } case "github": - return config.baseUrl; + return config.baseUrl as string; case "glm": case "kimi": @@ -210,14 +274,19 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { return `${config.baseUrl}?beta=true`; default: - return config.baseUrl; + return config.baseUrl as string; } } // Build provider headers -export function buildProviderHeaders(provider, credentials, stream = true, body = null) { +export function buildProviderHeaders( + provider: string, + credentials: ProviderCredentials, + stream: boolean = true, + _body: unknown = null, +) { const config = getProviderConfig(provider); - const headers = { + const headers: Record = { "Content-Type": "application/json", ...config.headers, }; @@ -277,7 +346,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body // Generate a UUID for x-request-id (Cloudflare Workers compatible) headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() - : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c: string) => { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); @@ -296,7 +365,10 @@ export function buildProviderHeaders(provider, credentials, stream = true, body break; case "cline": - Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken)); + Object.assign( + headers, + buildClineHeaders(String(credentials.apiKey || credentials.accessToken)), + ); break; case "glm": @@ -327,7 +399,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body } // Get target format for provider -export function getTargetFormat(provider) { +export function getTargetFormat(provider: string) { if (isOpenAICompatible(provider)) { return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; } @@ -339,25 +411,30 @@ export function getTargetFormat(provider) { } // Check if last message is from user -export function isLastMessageFromUser(body) { - const messages = body.messages || body.contents; +export function isLastMessageFromUser(body: RequestBody) { + const messages = Array.isArray(body.messages) + ? body.messages + : Array.isArray(body.contents) + ? (body.contents as Message[]) + : []; if (!messages?.length) return true; const lastMsg = messages[messages.length - 1]; return lastMsg?.role === "user"; } // Check if request has thinking config -export function hasThinkingConfig(body) { +export function hasThinkingConfig(body: RequestBody) { return !!(body.reasoning_effort || body.thinking?.type === "enabled"); } // Normalize thinking config based on last message role // - If lastMessage is not user → remove thinking config // - If lastMessage is user AND has thinking config → keep it (force enable) -export function normalizeThinkingConfig(body) { +export function normalizeThinkingConfig(body: RequestBody) { + const mutableBody = asRecord(body) as RequestBody; if (!isLastMessageFromUser(body)) { - delete body.reasoning_effort; - delete body.thinking; + delete mutableBody.reasoning_effort; + delete mutableBody.thinking; } - return body; + return mutableBody; } diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.ts similarity index 65% rename from open-sse/services/qoderModels.js rename to open-sse/services/qoderModels.ts index bf7a256c..54f47fa1 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.ts @@ -15,27 +15,67 @@ import { createHash } from "node:crypto"; import { QODER_MODEL_LIST_URL } from "@/lib/qoder/constants"; import { buildCosyHeaders } from "@/lib/qoder/cosy"; +import type { + ExecutorCredentials, + ExecutorLogger, + ExecutorProxyOptions, + ExecutorProviderData, +} from "../executors/base.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; const FETCH_TIMEOUT_MS = 15_000; const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog -/** @type {Map, fetched: boolean }>} */ -const catalogCache = new Map(); +type JsonRecord = Record; + +type QoderProviderData = ExecutorProviderData & { + userId?: string; + machineId?: string; +}; + +export type QoderCredentials = ExecutorCredentials & { + displayName?: string; + providerSpecificData?: QoderProviderData; +}; + +export type QoderModelSummary = { + id: string; + name: string; + contextLength: number; + isVL: boolean; + isReasoning: boolean; + maxOutputTokens: number; + description: string; +}; + +export type QoderCatalogCacheEntry = { + expiresAt: number; + models: QoderModelSummary[]; + rawConfigs: Map; + fetched: boolean; +}; + +export type ResolveQoderModelsOptions = { + forceRefresh?: boolean; + log?: ExecutorLogger; + proxyOptions?: ExecutorProxyOptions; + signal?: AbortSignal | null; +}; + +const catalogCache = new Map(); /** * In-flight fetch promises keyed by cacheKey. Concurrent first-time * callers (parallel chat windows) all observe the same Promise so we * fan-out exactly one upstream request per credential per miss. - * @type {Map, fetched: boolean } | null>>} */ -const inflight = new Map(); +const inflight = new Map>(); /** * Stable cache key per credential (so different login sessions for the same * account share an entry). */ -function cacheKey(credentials) { +function cacheKey(credentials: QoderCredentials | null | undefined): string { const psd = credentials?.providerSpecificData || {}; const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous"; return createHash("sha256").update(`qoder:${seed}`).digest("hex"); @@ -44,11 +84,11 @@ function cacheKey(credentials) { /** * Strip credential -> COSY creds for buildCosyHeaders. */ -function cosyCredsFromConnection(credentials) { - const psd = credentials?.providerSpecificData || {}; +function cosyCredsFromConnection(credentials: QoderCredentials) { + const psd = credentials.providerSpecificData || {}; return { - userId: psd.userId, - authToken: credentials.accessToken, + userId: psd.userId || "", + authToken: credentials.accessToken || "", name: credentials.displayName || "", email: credentials.email || "", machineId: psd.machineId || "", @@ -61,7 +101,11 @@ function cosyCredsFromConnection(credentials) { * rawConfigs: Map } * or `null` on any error. */ -async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { +async function fetchQoderCatalogRaw( + credentials: QoderCredentials, + signal: AbortSignal | null | undefined, + proxyOptions: ExecutorProxyOptions = null, +): Promise<{ models: QoderModelSummary[]; rawConfigs: Map } | null> { const creds = cosyCredsFromConnection(credentials); if (!creds.userId || !creds.authToken) return null; @@ -72,9 +116,9 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { }; const controller = new AbortController(); - let timer = null; - let abortListener = null; - let response; + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + let response: Response; try { timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); if (signal && typeof signal.addEventListener === "function") { @@ -104,22 +148,30 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { if (!response.ok) return null; - const body = await response.json().catch(() => null); - if (!body || !Array.isArray(body.chat)) return null; + const bodyUnknown: unknown = await response.json().catch(() => null); + if ( + !bodyUnknown || + typeof bodyUnknown !== "object" || + !Array.isArray((bodyUnknown as JsonRecord).chat) + ) { + return null; + } + const body = bodyUnknown as JsonRecord & { chat: unknown[] }; - const models = []; - const rawConfigs = new Map(); - for (const entry of body.chat) { - if (!entry || typeof entry !== "object") continue; + const models: QoderModelSummary[] = []; + const rawConfigs = new Map(); + for (const entryUnknown of body.chat) { + if (!entryUnknown || typeof entryUnknown !== "object") continue; + const entry = entryUnknown as JsonRecord; const key = entry.key; - if (!key) continue; + if (typeof key !== "string" || !key) continue; // Always cache the config — chat needs model_config even for UI-hidden // models (enable:false). Upstream still accepts chat for these keys. rawConfigs.set(key, entry); if (entry.enable === false) continue; - const display = entry.display_name || key; + const display = typeof entry.display_name === "string" ? entry.display_name : key; const ctx = Number(entry.max_input_tokens) || 131_072; models.push({ id: key, @@ -128,7 +180,7 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { isVL: !!entry.is_vl, isReasoning: !!entry.is_reasoning, maxOutputTokens: Number(entry.max_output_tokens) || 0, - description: entry.description || "", + description: typeof entry.description === "string" ? entry.description : "", }); } @@ -140,7 +192,11 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { * catalog first if needed. Returns null when the catalog can't be fetched * (so callers can fall back to the static registry). */ -export async function getQoderModelConfig(credentials, modelKey, options = {}) { +export async function getQoderModelConfig( + credentials: QoderCredentials, + modelKey: string, + options: ResolveQoderModelsOptions = {}, +): Promise<(JsonRecord & { key: string }) | null> { const cached = await resolveQoderModels(credentials, options); if (!cached) return null; const config = cached.rawConfigs.get(modelKey); @@ -155,7 +211,10 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) { * deduplicates concurrent misses so parallel chat windows fan-out exactly * one upstream request per credential. */ -export async function resolveQoderModels(credentials, options = {}) { +export async function resolveQoderModels( + credentials: QoderCredentials | null | undefined, + options: ResolveQoderModelsOptions = {}, +): Promise { if (!credentials?.accessToken) return null; const psd = credentials.providerSpecificData || {}; if (!psd.userId) return null; @@ -176,10 +235,10 @@ export async function resolveQoderModels(credentials, options = {}) { return existing; } - const fetchPromise = (async () => { + const fetchPromise = (async (): Promise => { const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); if (!fetched) return null; - const entry = { + const entry: QoderCatalogCacheEntry = { expiresAt: Date.now() + CACHE_TTL_MS, models: fetched.models, rawConfigs: fetched.rawConfigs, @@ -201,7 +260,7 @@ export async function resolveQoderModels(credentials, options = {}) { } } -export function invalidateQoderCatalog(credentials) { +export function invalidateQoderCatalog(credentials: QoderCredentials | null | undefined) { if (!credentials) return; catalogCache.delete(cacheKey(credentials)); } diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.ts similarity index 72% rename from open-sse/services/tokenRefresh.js rename to open-sse/services/tokenRefresh.ts index e063ec49..1d309963 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.ts @@ -6,12 +6,68 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js"; export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // In-flight refresh dedup: prevents race condition that triggers refresh_token_reused → Auth0 family revoke -const refreshPromiseCache = new Map(); +const refreshPromiseCache = new Map(); const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000; const CACHE_ENTRY_MAX_AGE_MS = 5 * 60 * 1000; const MAX_REFRESH_CACHE_SIZE = 100; -function getRefreshCacheKey(provider, refreshToken) { +type TokenJson = Record & { + access_token?: string; + refresh_token?: string; + expires_in?: number; + resource_url?: string; + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + token?: string; + expires_at?: number; +}; +type ProviderConfig = Record & { + refreshUrl?: string; + clientId?: string; + clientSecret?: string; + tokenUrl?: string; +}; +type ProviderCredentials = Record & { + accessToken?: unknown; + apiKey?: unknown; + projectId?: unknown; + refreshToken?: unknown; + providerSpecificData?: unknown; +}; +type ProviderLogger = { + debug?: (scope: string, message: string, details?: unknown) => void; + error?: (scope: string, message: string, details?: unknown) => void; + info?: (scope: string, message: string, details?: unknown) => void; + warn?: (scope: string, message: string, details?: unknown) => void; +} | null; +type RefreshResult = Record & { + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + expiresAt?: number; + token?: string; + error?: string; + code?: string | null; +}; +type RefreshCacheEntry = { + promise: Promise; + timestamp: number; +}; +type VertexServiceAccount = { + type: "service_account"; + client_email: string; + private_key: string; + project_id: string; + [key: string]: unknown; +}; +type VertexCacheEntry = { token: string; expiresAt: number }; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function getRefreshCacheKey(provider: string, refreshToken: string) { return `${provider}:${refreshToken}`; } @@ -25,27 +81,34 @@ setInterval(() => { }, CACHE_CLEANUP_INTERVAL_MS); // Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth) -export function isUnrecoverableRefreshError(result) { +export function isUnrecoverableRefreshError(result: unknown) { + const error = (result as { error?: unknown } | null)?.error; return ( result && typeof result === "object" && - (result.error === "unrecoverable_refresh_error" || - result.error === "refresh_token_reused" || - result.error === "invalid_request" || - result.error === "invalid_grant") + (error === "unrecoverable_refresh_error" || + error === "refresh_token_reused" || + error === "invalid_request" || + error === "invalid_grant") ); } // Get provider-specific refresh lead time, falls back to default buffer -export function getRefreshLeadMs(provider) { - return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS; +export function getRefreshLeadMs(provider: string) { + return (REFRESH_LEAD_MS as Record)[provider] || TOKEN_EXPIRY_BUFFER_MS; } /** * Refresh OAuth access token using refresh token */ -export async function refreshAccessToken(provider, refreshToken, credentials, log) { - const config = PROVIDERS[provider]; +export async function refreshAccessToken( + provider: string, + refreshToken: string | undefined, + credentials: ProviderCredentials, + log: ProviderLogger, +) { + void credentials; + const config = (PROVIDERS as Record)[provider]; if (!config || !config.refreshUrl) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); @@ -69,7 +132,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo refresh_token: refreshToken, client_id: config.clientId, client_secret: config.clientSecret, - }), + } as Record), }); if (!response.ok) { @@ -81,7 +144,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, { hasNewAccessToken: !!tokens.access_token, @@ -94,9 +157,9 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { + } catch (error: unknown) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { - error: error.message, + error: errorMessage(error), }); return null; } @@ -105,7 +168,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo /** * Specialized refresh for Claude OAuth tokens */ -export async function refreshClaudeOAuthToken(refreshToken, log) { +export async function refreshClaudeOAuthToken(refreshToken: string, log: ProviderLogger) { try { const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { method: "POST", @@ -129,7 +192,7 @@ export async function refreshClaudeOAuthToken(refreshToken, log) { return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in, @@ -139,8 +202,8 @@ export async function refreshClaudeOAuthToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${errorMessage(error)}`); return null; } } @@ -148,7 +211,12 @@ export async function refreshClaudeOAuthToken(refreshToken, log) { /** * Specialized refresh for Google providers (Gemini, Antigravity) */ -export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { +export async function refreshGoogleToken( + refreshToken: string, + clientId: string, + clientSecret: string, + log: ProviderLogger, +) { try { const response = await fetch(OAUTH_ENDPOINTS.google.token, { method: "POST", @@ -173,7 +241,7 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in, @@ -183,8 +251,8 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${errorMessage(error)}`); return null; } } @@ -192,7 +260,7 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l /** * Specialized refresh for Qwen OAuth tokens */ -export async function refreshQwenToken(refreshToken, log) { +export async function refreshQwenToken(refreshToken: string, log: ProviderLogger) { const endpoint = OAUTH_ENDPOINTS.qwen.token; try { @@ -210,7 +278,7 @@ export async function refreshQwenToken(refreshToken, log) { }); if (response.status === 200) { - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { hasNewAccessToken: !!tokens.access_token, @@ -233,9 +301,9 @@ export async function refreshQwenToken(refreshToken, log) { error: errorText, }); } - } catch (error) { + } catch (error: unknown) { log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, + error: errorMessage(error), }); } @@ -249,7 +317,7 @@ export async function refreshQwenToken(refreshToken, log) { * Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid, * so callers stop retrying and request re-authentication. */ -export async function refreshCodexToken(refreshToken, log) { +export async function refreshCodexToken(refreshToken: string, log: ProviderLogger) { try { const response = await fetch(OAUTH_ENDPOINTS.openai.token, { method: "POST", @@ -271,9 +339,11 @@ export async function refreshCodexToken(refreshToken, log) { // Detect unrecoverable errors (token reused/expired) — Auth0 revokes whole family on retry let errorCode = null; try { - const parsed = JSON.parse(errorText); + const parsed = JSON.parse(errorText) as Record; + const parsedError = parsed.error as Record | string | undefined; errorCode = - parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); + (typeof parsedError === "object" ? parsedError?.code : null) || + (typeof parsedError === "string" ? parsedError : null); } catch {} if ( @@ -300,7 +370,7 @@ export async function refreshCodexToken(refreshToken, log) { return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", { hasNewAccessToken: !!tokens.access_token, @@ -313,8 +383,8 @@ export async function refreshCodexToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${errorMessage(error)}`); return null; } } @@ -324,10 +394,10 @@ export async function refreshCodexToken(refreshToken, log) { * Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub) */ export async function refreshKiroToken( - refreshToken, - providerSpecificData, - log, - proxyOptions = null, + refreshToken: string, + providerSpecificData: Record | undefined, + log: ProviderLogger, + proxyOptions: Record | null = null, ) { try { const authMethod = providerSpecificData?.authMethod; @@ -371,7 +441,7 @@ export async function refreshKiroToken( return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", { hasNewAccessToken: !!tokens.accessToken, @@ -411,7 +481,7 @@ export async function refreshKiroToken( return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", { hasNewAccessToken: !!tokens.accessToken, @@ -423,8 +493,8 @@ export async function refreshKiroToken( refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing Kiro token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Kiro token: ${errorMessage(error)}`); return null; } } @@ -432,7 +502,7 @@ export async function refreshKiroToken( /** * Specialized refresh for iFlow OAuth tokens */ -export async function refreshIflowToken(refreshToken, log) { +export async function refreshIflowToken(refreshToken: string, log: ProviderLogger) { try { if (!PROVIDERS.iflow.clientSecret) { log?.error?.( @@ -468,7 +538,7 @@ export async function refreshIflowToken(refreshToken, log) { return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { hasNewAccessToken: !!tokens.access_token, @@ -481,8 +551,8 @@ export async function refreshIflowToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing iFlow token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Error refreshing iFlow token: ${errorMessage(error)}`); return null; } } @@ -490,15 +560,16 @@ export async function refreshIflowToken(refreshToken, log) { /** * Specialized refresh for GitHub Copilot OAuth tokens */ -export async function refreshGitHubToken(refreshToken, log) { +export async function refreshGitHubToken(refreshToken: string, log: ProviderLogger) { try { - const params = { + const params: Record = { grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.github.clientId, }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; + const githubConfig = PROVIDERS.github as ProviderConfig; + if (githubConfig.clientSecret) { + params.client_secret = githubConfig.clientSecret; } const response = await fetch(OAUTH_ENDPOINTS.github.token, { @@ -519,7 +590,7 @@ export async function refreshGitHubToken(refreshToken, log) { return null; } - const tokens = await response.json(); + const tokens = (await response.json()) as TokenJson; log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { hasNewAccessToken: !!tokens.access_token, @@ -532,8 +603,8 @@ export async function refreshGitHubToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing GitHub token: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Error refreshing GitHub token: ${errorMessage(error)}`); return null; } } @@ -541,7 +612,7 @@ export async function refreshGitHubToken(refreshToken, log) { /** * Refresh GitHub Copilot token using GitHub access token */ -export async function refreshCopilotToken(githubAccessToken, log) { +export async function refreshCopilotToken(githubAccessToken: string, log: ProviderLogger) { try { const response = await fetch("https://api.github.com/copilot_internal/v2/token", { headers: { @@ -574,9 +645,9 @@ export async function refreshCopilotToken(githubAccessToken, log) { token: data.token, expiresAt: data.expires_at, }; - } catch (error) { + } catch (error: unknown) { log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", { - error: error.message, + error: errorMessage(error), }); return null; } @@ -587,7 +658,11 @@ export async function refreshCopilotToken(githubAccessToken, log) { * If a refresh is already in-flight for same provider+token, share the promise * to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke. */ -export async function getAccessToken(provider, credentials, log) { +export async function getAccessToken( + provider: string, + credentials: ProviderCredentials | null | undefined, + log: ProviderLogger, +) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`); return null; @@ -597,7 +672,7 @@ export async function getAccessToken(provider, credentials, log) { if (refreshPromiseCache.has(cacheKey)) { log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); - return refreshPromiseCache.get(cacheKey).promise; + return refreshPromiseCache.get(cacheKey)?.promise ?? null; } const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => { @@ -606,43 +681,49 @@ export async function getAccessToken(provider, credentials, log) { if (refreshPromiseCache.size >= MAX_REFRESH_CACHE_SIZE) { const firstKey = refreshPromiseCache.keys().next().value; - refreshPromiseCache.delete(firstKey); + if (firstKey) refreshPromiseCache.delete(firstKey); } refreshPromiseCache.set(cacheKey, { promise: refreshPromise, timestamp: Date.now() }); return refreshPromise; } -async function _getAccessTokenInternal(provider, credentials, log) { +async function _getAccessTokenInternal( + provider: string, + credentials: ProviderCredentials, + log: ProviderLogger, +) { + const refreshToken = credentials.refreshToken as string; + const providerConfig = (PROVIDERS as Record)[provider] ?? {}; switch (provider) { case "gemini": case "gemini-cli": case "antigravity": return await refreshGoogleToken( - credentials.refreshToken, - PROVIDERS[provider].clientId, - PROVIDERS[provider].clientSecret, + refreshToken, + providerConfig.clientId as string, + providerConfig.clientSecret as string, log, ); case "claude": - return await refreshClaudeOAuthToken(credentials.refreshToken, log); + return await refreshClaudeOAuthToken(refreshToken, log); case "codex": - return await refreshCodexToken(credentials.refreshToken, log); + return await refreshCodexToken(refreshToken, log); case "qwen": - return await refreshQwenToken(credentials.refreshToken, log); + return await refreshQwenToken(refreshToken, log); case "iflow": - return await refreshIflowToken(credentials.refreshToken, log); + return await refreshIflowToken(refreshToken, log); case "github": - return await refreshGitHubToken(credentials.refreshToken, log); + return await refreshGitHubToken(refreshToken, log); case "kiro": return await refreshKiroToken( - credentials.refreshToken, - credentials.providerSpecificData, + refreshToken, + credentials.providerSpecificData as Record | undefined, log, ); @@ -662,30 +743,40 @@ async function _getAccessTokenInternal(provider, credentials, log) { /** * Refresh token by provider type (helper for handlers) */ -export async function refreshTokenByProvider(provider, credentials, log) { +export async function refreshTokenByProvider( + provider: string, + credentials: ProviderCredentials, + log: ProviderLogger = null, +) { if (!credentials.refreshToken) return null; + const refreshToken = credentials.refreshToken as string; + const providerConfig = (PROVIDERS as Record)[provider] ?? {}; switch (provider) { case "gemini-cli": case "antigravity": return refreshGoogleToken( - credentials.refreshToken, - PROVIDERS[provider].clientId, - PROVIDERS[provider].clientSecret, + refreshToken, + providerConfig.clientId as string, + providerConfig.clientSecret as string, log, ); case "claude": - return refreshClaudeOAuthToken(credentials.refreshToken, log); + return refreshClaudeOAuthToken(refreshToken, log); case "codex": - return refreshCodexToken(credentials.refreshToken, log); + return refreshCodexToken(refreshToken, log); case "qwen": - return refreshQwenToken(credentials.refreshToken, log); + return refreshQwenToken(refreshToken, log); case "iflow": - return refreshIflowToken(credentials.refreshToken, log); + return refreshIflowToken(refreshToken, log); case "github": - return refreshGitHubToken(credentials.refreshToken, log); + return refreshGitHubToken(refreshToken, log); case "kiro": - return refreshKiroToken(credentials.refreshToken, credentials.providerSpecificData, log); + return refreshKiroToken( + refreshToken, + credentials.providerSpecificData as Record | undefined, + log, + ); case "vertex": case "vertex-partner": { const saJson = parseVertexSaJson(credentials.apiKey); @@ -693,15 +784,19 @@ export async function refreshTokenByProvider(provider, credentials, log) { return refreshVertexToken(saJson, log); } default: - return refreshAccessToken(provider, credentials.refreshToken, credentials, log); + return refreshAccessToken(provider, refreshToken, credentials, log); } } /** * Format credentials for provider */ -export function formatProviderCredentials(provider, credentials, log) { - const config = PROVIDERS[provider]; +export function formatProviderCredentials( + provider: string, + credentials: ProviderCredentials, + log: ProviderLogger, +) { + const config = (PROVIDERS as Record)[provider]; if (!config) { log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`); return null; @@ -751,14 +846,15 @@ export function formatProviderCredentials(provider, credentials, log) { /** * Get all access tokens for a user */ -export async function getAllAccessTokens(userInfo, log) { - const results = {}; +export async function getAllAccessTokens(userInfo: unknown, log: ProviderLogger) { + const results: Record = {}; - if (userInfo.connections && Array.isArray(userInfo.connections)) { - for (const connection of userInfo.connections) { + const data = userInfo as { connections?: ProviderCredentials[] } | null; + if (data?.connections && Array.isArray(data.connections)) { + for (const connection of data.connections) { if (connection.isActive && connection.provider) { const token = await getAccessToken( - connection.provider, + String(connection.provider), { refreshToken: connection.refreshToken, }, @@ -766,7 +862,7 @@ export async function getAllAccessTokens(userInfo, log) { ); if (token) { - results[connection.provider] = token; + results[String(connection.provider)] = token; } } } @@ -778,17 +874,17 @@ export async function getAllAccessTokens(userInfo, log) { /** * Parse Vertex AI Service Account JSON from apiKey string */ -export function parseVertexSaJson(apiKey) { +export function parseVertexSaJson(apiKey: unknown): VertexServiceAccount | null { if (typeof apiKey !== "string") return null; try { - const parsed = JSON.parse(apiKey); + const parsed = JSON.parse(apiKey) as Record; if ( parsed.type === "service_account" && - parsed.client_email && - parsed.private_key && - parsed.project_id + typeof parsed.client_email === "string" && + typeof parsed.private_key === "string" && + typeof parsed.project_id === "string" ) { - return parsed; + return parsed as VertexServiceAccount; } return null; } catch { @@ -797,16 +893,16 @@ export function parseVertexSaJson(apiKey) { } // Cache Vertex tokens keyed by service account email { token, expiresAt } -const vertexTokenCache = new Map(); +const vertexTokenCache = new Map(); // In-flight dedup for concurrent Vertex token refresh requests -const vertexRefreshPromiseCache = new Map(); +const vertexRefreshPromiseCache = new Map>(); /** * Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI * using Service Account JSON + jose (RS256 JWT assertion flow). * Token is cached until 5 minutes before expiry. */ -export async function refreshVertexToken(saJson, log) { +export async function refreshVertexToken(saJson: VertexServiceAccount, log: ProviderLogger) { const cacheKey = saJson.client_email; const cached = vertexTokenCache.get(cacheKey); @@ -817,7 +913,7 @@ export async function refreshVertexToken(saJson, log) { // In-flight dedup: if same SA key is already refreshing, reuse result if (vertexRefreshPromiseCache.has(cacheKey)) { - return vertexRefreshPromiseCache.get(cacheKey); + return vertexRefreshPromiseCache.get(cacheKey) ?? null; } const promise = (async () => { @@ -850,15 +946,15 @@ export async function refreshVertexToken(saJson, log) { return null; } - const { access_token, expires_in } = await res.json(); + const { access_token, expires_in } = (await res.json()) as TokenJson; const expiresAt = Date.now() + (expires_in ?? 3600) * 1000; - vertexTokenCache.set(cacheKey, { token: access_token, expiresAt }); + vertexTokenCache.set(cacheKey, { token: access_token as string, expiresAt }); log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`); return { accessToken: access_token, expiresAt }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`); + } catch (error: unknown) { + log?.error?.("TOKEN_REFRESH", `Vertex token error: ${errorMessage(error)}`); return null; } finally { vertexRefreshPromiseCache.delete(cacheKey); @@ -877,19 +973,26 @@ export async function refreshVertexToken(saJson, log) { * @param {object} log - Logger instance (optional) * @returns {Promise} Token result or null if all retries fail */ -export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) { +export async function refreshWithRetry( + refreshFn: () => Promise, + maxRetries: number = 3, + log: ProviderLogger = null, +) { for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = attempt * 1000; log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`); - await new Promise((r) => setTimeout(r, delay)); + await new Promise((resolve) => setTimeout(resolve, delay)); } try { const result = await refreshFn(); if (result) return result; - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`); + } catch (error: unknown) { + log?.warn?.( + "TOKEN_REFRESH", + `Attempt ${attempt + 1}/${maxRetries} failed: ${errorMessage(error)}`, + ); } } diff --git a/open-sse/services/usage.js b/open-sse/services/usage.ts similarity index 79% rename from open-sse/services/usage.js rename to open-sse/services/usage.ts index 1b4377c9..3e7c3436 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.ts @@ -5,6 +5,36 @@ import { CLIENT_METADATA, getPlatformUserAgent } from "../config/appConstants.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; +type ProxyOptions = Record | null; +type QuotaMap = Record; +type ProviderSpecificData = Record | null | undefined; +type UsageConnection = { + accessToken?: string | null; + apiKey?: string | null; + provider: string; + providerSpecificData?: ProviderSpecificData; +}; +type CodexRateLimitWindow = Record & { + percent_used?: unknown; + resetAt?: unknown; + reset_at?: unknown; + resets_at?: unknown; + used_percent?: unknown; +}; +type KiroUsageBreakdown = Record & { + currentUsageWithPrecision?: number; + freeTrialInfo?: Record & { + currentUsageWithPrecision?: number; + freeTrialExpiry?: unknown; + usageLimitWithPrecision?: number; + }; + resourceType?: string; + usageLimitWithPrecision?: number; +}; +type MiniMaxModel = Record; + +// todo(ts): upstream provider quota payloads stay loose until stable schemas are available. + // GitHub API config const GITHUB_CONFIG = { apiVersion: "2022-11-28", @@ -52,12 +82,19 @@ const CLAUDE_CONFIG = { apiVersion: "2023-06-01", }; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * Get usage data for a provider connection * @param {Object} connection - Provider connection with accessToken * @returns {Object} Usage data with quotas */ -export async function getUsageForProvider(connection, proxyOptions = null) { +export async function getUsageForProvider( + connection: UsageConnection, + proxyOptions: ProxyOptions = null, +) { const { provider, accessToken, apiKey, providerSpecificData } = connection; switch (provider) { @@ -78,7 +115,7 @@ export async function getUsageForProvider(connection, proxyOptions = null) { case "iflow": return await getIflowUsage(accessToken); case "ollama": - return await getOllamaUsage(accessToken); + return await getOllamaUsage(accessToken, providerSpecificData); case "glm": case "glm-cn": return await getGlmUsage(apiKey, provider, proxyOptions); @@ -94,7 +131,7 @@ export async function getUsageForProvider(connection, proxyOptions = null) { * Parse reset date/time to ISO string * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. */ -function parseResetTime(resetValue) { +function parseResetTime(resetValue: unknown) { if (!resetValue) return null; try { @@ -118,7 +155,7 @@ function parseResetTime(resetValue) { } return null; - } catch (_error) { + } catch (_error: unknown) { console.warn("Failed to parse reset time"); return null; } @@ -128,7 +165,11 @@ function parseResetTime(resetValue) { * GitHub Copilot Usage * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API */ -async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) { +async function getGitHubUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, + proxyOptions: ProxyOptions = null, +) { try { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); @@ -202,12 +243,14 @@ async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = } return { message: "GitHub Copilot connected. Unable to parse quota data." }; - } catch (error) { - throw new Error(`Failed to fetch GitHub usage: ${error.message}`); + } catch (error: unknown) { + throw new Error(`Failed to fetch GitHub usage: ${errorMessage(error)}`); } } -function formatGitHubQuotaSnapshot(quota) { +function formatGitHubQuotaSnapshot( + quota: { entitlement: number; remaining: number; unlimited?: boolean } | null | undefined, +) { if (!quota) return { used: 0, total: 0, unlimited: true }; return { @@ -223,7 +266,11 @@ function formatGitHubQuotaSnapshot(quota) { * Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning * per-model buckets with remainingFraction + resetTime. */ -async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) { +async function getGeminiUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, + proxyOptions: ProxyOptions = null, +) { if (!accessToken) { return { plan: "Free", message: "Gemini CLI access token not available." }; } @@ -269,11 +316,17 @@ async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = } const data = await response.json(); - const quotas = {}; + const quotas: QuotaMap = {}; if (Array.isArray(data.buckets)) { for (const bucket of data.buckets) { - if (!bucket.modelId || bucket.remainingFraction == null) continue; + if ( + !bucket.modelId || + bucket.remainingFraction === null || + bucket.remainingFraction === undefined + ) { + continue; + } const remainingFraction = Number(bucket.remainingFraction) || 0; const total = 1000; // Normalized base, matches antigravity convention @@ -291,15 +344,18 @@ async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = } return { plan, quotas }; - } catch (error) { - return { message: `Gemini CLI error: ${error.message}` }; + } catch (error: unknown) { + return { message: `Gemini CLI error: ${errorMessage(error)}` }; } } /** * Get Gemini CLI subscription info via loadCodeAssist */ -async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) { +async function getGeminiSubscriptionInfo( + accessToken: string | null | undefined, + proxyOptions: ProxyOptions = null, +) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { @@ -334,7 +390,11 @@ async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) { /** * Antigravity Usage - Fetch quota from Google Cloud Code API */ -async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) { +async function getAntigravityUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, + proxyOptions: ProxyOptions = null, +) { try { // Fetch subscription info once — reuse for both projectId and plan const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions); @@ -388,7 +448,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptio } const data = await response.json(); - const quotas = {}; + const quotas: QuotaMap = {}; // Parse model quotas (inspired by vscode-antigravity-cockpit) if (data.models) { @@ -402,7 +462,16 @@ async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptio "gpt-oss-120b-medium", ]; - for (const [modelKey, info] of Object.entries(data.models)) { + for (const [modelKey, info] of Object.entries( + data.models as Record< + string, + { + displayName?: string; + isInternal?: boolean; + quotaInfo?: { remainingFraction?: number; resetTime?: unknown }; + } + >, + )) { // Skip models without quota info if (!info.quotaInfo) { continue; @@ -438,16 +507,16 @@ async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptio quotas, subscriptionInfo, }; - } catch (error) { + } catch (error: unknown) { console.error("[Antigravity Usage] Error occurred"); - return { message: `Antigravity error: ${error.message}` }; + return { message: `Antigravity error: ${errorMessage(error)}` }; } } /** * Get Antigravity project ID from subscription info */ -async function _getAntigravityProjectId(accessToken) { +async function _getAntigravityProjectId(accessToken: string | null | undefined) { try { const info = await getAntigravitySubscriptionInfo(accessToken); return info?.cloudaicompanionProject || null; @@ -459,7 +528,10 @@ async function _getAntigravityProjectId(accessToken) { /** * Get Antigravity subscription info */ -async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) { +async function getAntigravitySubscriptionInfo( + accessToken: string | null | undefined, + proxyOptions: ProxyOptions = null, +) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout try { @@ -481,7 +553,7 @@ async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) if (!response.ok) return null; return await response.json(); - } catch (_error) { + } catch (_error: unknown) { console.error("[Antigravity Subscription] Error occurred"); return null; } finally { @@ -492,7 +564,10 @@ async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) /** * Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint */ -async function getClaudeUsage(accessToken, proxyOptions = null) { +async function getClaudeUsage( + accessToken: string | null | undefined, + proxyOptions: ProxyOptions = null, +) { try { // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) const oauthResponse = await proxyAwareFetch( @@ -510,13 +585,21 @@ async function getClaudeUsage(accessToken, proxyOptions = null) { if (oauthResponse.ok) { const data = await oauthResponse.json(); - const quotas = {}; + const quotas: QuotaMap = {}; // utilization = % USED (e.g. 87 means 87% used, 13% remaining) - const hasUtilization = (window) => - window && typeof window === "object" && typeof window.utilization === "number"; + const hasUtilization = ( + window: unknown, + ): window is { resets_at?: unknown; utilization: number } => { + const maybeWindow = window as { utilization?: unknown } | null; + return ( + Boolean(window) && + typeof window === "object" && + typeof maybeWindow?.utilization === "number" + ); + }; - const createQuotaObject = (window) => { + const createQuotaObject = (window: { resets_at?: unknown; utilization: number }) => { const used = window.utilization; const remaining = Math.max(0, 100 - used); return { @@ -555,15 +638,18 @@ async function getClaudeUsage(accessToken, proxyOptions = null) { // Fallback: legacy settings + org usage endpoint console.warn("[Claude Usage] OAuth endpoint unavailable, falling back to legacy"); return await getClaudeUsageLegacy(accessToken, proxyOptions); - } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; + } catch (error: unknown) { + return { message: `Claude connected. Unable to fetch usage: ${errorMessage(error)}` }; } } /** * Legacy Claude usage for API key / org admin users */ -async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { +async function getClaudeUsageLegacy( + accessToken: string | null | undefined, + proxyOptions: ProxyOptions = null, +) { try { const settingsResponse = await proxyAwareFetch( CLAUDE_CONFIG.settingsUrl, @@ -611,15 +697,15 @@ async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { } return { message: "Claude connected. Usage API requires admin permissions." }; - } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; + } catch (error: unknown) { + return { message: `Claude connected. Unable to fetch usage: ${errorMessage(error)}` }; } } /** * Codex (OpenAI) Usage - Fetch from ChatGPT backend API */ -function toFiniteNumber(value, fallback = 0) { +function toFiniteNumber(value: unknown, fallback = 0) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); @@ -628,14 +714,15 @@ function toFiniteNumber(value, fallback = 0) { return fallback; } -function getCodexRateLimitBody(snapshot) { +function getCodexRateLimitBody(snapshot: unknown): Record | null { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; - return snapshot.rate_limit && typeof snapshot.rate_limit === "object" - ? snapshot.rate_limit - : snapshot; + const record = snapshot as Record; + return record.rate_limit && typeof record.rate_limit === "object" + ? (record.rate_limit as Record) + : record; } -function formatCodexWindow(window) { +function formatCodexWindow(window: CodexRateLimitWindow) { const used = Math.max( 0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0)), @@ -649,46 +736,59 @@ function formatCodexWindow(window) { }; } -function appendCodexQuotaWindows(quotas, prefix, snapshot) { +function appendCodexQuotaWindows(quotas: QuotaMap, prefix: string, snapshot: unknown) { const rateLimit = getCodexRateLimitBody(snapshot); if (!rateLimit) return false; + const snapshotRecord = + snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) + ? (snapshot as Record) + : {}; const primary = - rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary; + rateLimit.primary_window || + rateLimit.primary || + snapshotRecord.primary_window || + snapshotRecord.primary; const secondary = rateLimit.secondary_window || rateLimit.secondary || - snapshot.secondary_window || - snapshot.secondary; + snapshotRecord.secondary_window || + snapshotRecord.secondary; let added = false; if (primary) { - quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary); + quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow( + primary as CodexRateLimitWindow, + ); added = true; } if (secondary) { - quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary); + quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow( + secondary as CodexRateLimitWindow, + ); added = true; } return added; } -function getCodexReviewRateLimit(data) { +function getCodexReviewRateLimit(data: Record) { if (data.code_review_rate_limit || data.review_rate_limit) { return data.code_review_rate_limit || data.review_rate_limit; } const byLimitId = data.rate_limits_by_limit_id; if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) { - return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null; + const limitMap = byLimitId as Record; + return limitMap.code_review || limitMap.codex_review || limitMap.review || null; } const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []; return ( - additional.find((entry) => { + additional.find((entry: unknown) => { + const record = entry as Record; const id = String( - entry?.limit_name || entry?.metered_feature || entry?.id || "", + record?.limit_name || record?.metered_feature || record?.id || "", ).toLowerCase(); return ( id === "code_review" || id === "codex_review" || id === "review" || id.includes("review") @@ -697,7 +797,10 @@ function getCodexReviewRateLimit(data) { ); } -async function getCodexUsage(accessToken, proxyOptions = null) { +async function getCodexUsage( + accessToken: string | null | undefined, + proxyOptions: ProxyOptions = null, +) { try { const response = await proxyAwareFetch( CODEX_CONFIG.usageUrl, @@ -732,20 +835,20 @@ async function getCodexUsage(accessToken, proxyOptions = null) { reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false, quotas, }; - } catch (error) { - throw new Error(`Failed to fetch Codex usage: ${error.message}`); + } catch (error: unknown) { + throw new Error(`Failed to fetch Codex usage: ${errorMessage(error)}`); } } /** * Kiro (AWS CodeWhisperer) Usage */ -function parseKiroQuotaData(data) { +function parseKiroQuotaData(data: Record) { const usageList = data.usageBreakdownList || []; - const quotaInfo = {}; + const quotaInfo: QuotaMap = {}; const resetAt = parseResetTime(data.nextDateReset || data.resetDate); - usageList.forEach((breakdown) => { + (usageList as KiroUsageBreakdown[]).forEach((breakdown) => { const resourceType = breakdown.resourceType?.toLowerCase() || "unknown"; const used = breakdown.currentUsageWithPrecision || 0; const total = breakdown.usageLimitWithPrecision || 0; @@ -774,16 +877,28 @@ function parseKiroQuotaData(data) { }); return { - plan: data.subscriptionInfo?.subscriptionTitle || "Kiro", + plan: + (data.subscriptionInfo as { subscriptionTitle?: string } | undefined)?.subscriptionTitle || + "Kiro", quotas: quotaInfo, }; } -async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) { +async function getKiroUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, + proxyOptions: ProxyOptions = null, +) { // Default profileArn fallback const DEFAULT_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX"; - const profileArn = providerSpecificData?.profileArn || DEFAULT_PROFILE_ARN; - const authMethod = providerSpecificData?.authMethod || "builder-id"; + const profileArn = + typeof providerSpecificData?.profileArn === "string" + ? providerSpecificData.profileArn + : DEFAULT_PROFILE_ARN; + const authMethod = + typeof providerSpecificData?.authMethod === "string" + ? providerSpecificData.authMethod + : "builder-id"; const getUsageParams = new URLSearchParams({ isEmailRequired: "true", @@ -870,10 +985,10 @@ async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = nu continue; } - const data = await response.json(); + const data = (await response.json()) as Record; return parseKiroQuotaData(data); - } catch (error) { - errors.push(`${attempt.name}:${error.message}`); + } catch (error: unknown) { + errors.push(`${attempt.name}:${errorMessage(error)}`); } } @@ -914,7 +1029,10 @@ async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = nu /** * Qwen Usage */ -async function getQwenUsage(accessToken, providerSpecificData) { +async function getQwenUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, +) { try { const resourceUrl = providerSpecificData?.resourceUrl; if (!resourceUrl) { @@ -923,7 +1041,7 @@ async function getQwenUsage(accessToken, providerSpecificData) { // Qwen may have usage endpoint at resource URL return { message: "Qwen connected. Usage tracked per request." }; - } catch (_error) { + } catch (_error: unknown) { return { message: "Unable to fetch Qwen usage." }; } } @@ -931,11 +1049,11 @@ async function getQwenUsage(accessToken, providerSpecificData) { /** * iFlow Usage */ -async function getIflowUsage(accessToken) { +async function getIflowUsage(_accessToken: string | null | undefined) { try { // iFlow may have usage endpoint return { message: "iFlow connected. Usage tracked per request." }; - } catch (_error) { + } catch (_error: unknown) { return { message: "Unable to fetch iFlow usage." }; } } @@ -946,7 +1064,10 @@ async function getIflowUsage(accessToken) { * and has no public usage API — free tier has light usage limits (resets every 5h & 7d). * This returns an informational message with the plan details. */ -async function getOllamaUsage(accessToken, providerSpecificData) { +async function getOllamaUsage( + accessToken: string | null | undefined, + providerSpecificData: ProviderSpecificData, +) { try { // Ollama Cloud does not expose a public quota/usage API. // The provider is configured as noAuth with a notice explaining limits. @@ -958,7 +1079,7 @@ async function getOllamaUsage(accessToken, providerSpecificData) { "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.", quotas: [], }; - } catch (_error) { + } catch (_error: unknown) { return { message: "Unable to fetch Ollama Cloud usage." }; } } @@ -966,7 +1087,11 @@ async function getOllamaUsage(accessToken, providerSpecificData) { /** * GLM Coding Plan usage (international + China regions) */ -async function getGlmUsage(apiKey, provider, proxyOptions = null) { +async function getGlmUsage( + apiKey: string | null | undefined, + provider: string, + proxyOptions: ProxyOptions = null, +) { if (!apiKey) { return { message: "GLM API key not available." }; } @@ -993,10 +1118,11 @@ async function getGlmUsage(apiKey, provider, proxyOptions = null) { return { message: `GLM quota API error (${response.status}).` }; } - const json = await response.json(); - const data = json?.data && typeof json.data === "object" ? json.data : {}; + const json = (await response.json()) as { data?: unknown }; + const data: Record = + json?.data && typeof json.data === "object" ? (json.data as Record) : {}; const limits = Array.isArray(data.limits) ? data.limits : []; - const quotas = {}; + const quotas: QuotaMap = {}; for (const limit of limits) { if (!limit || limit.type !== "TOKENS_LIMIT") continue; @@ -1020,23 +1146,23 @@ async function getGlmUsage(apiKey, provider, proxyOptions = null) { : "Unknown"; return { plan, quotas }; - } catch (error) { - return { message: `GLM error: ${error.message}` }; + } catch (error: unknown) { + return { message: `GLM error: ${errorMessage(error)}` }; } } // ── MiniMax helpers ────────────────────────────────────────────────────── -function isMiniMaxTextQuotaModel(modelName) { +function isMiniMaxTextQuotaModel(modelName: string) { const normalized = (modelName || "").trim().toLowerCase(); return normalized.startsWith("minimax-m") || normalized.startsWith("coding-plan"); } -function getMiniMaxField(model, snakeKey, camelKey) { +function getMiniMaxField(model: MiniMaxModel, snakeKey: string, camelKey: string) { if (!model || typeof model !== "object") return null; return model[snakeKey] ?? model[camelKey] ?? null; } -function getMiniMaxSessionTotal(model) { +function getMiniMaxSessionTotal(model: MiniMaxModel) { return Math.max( 0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || @@ -1044,27 +1170,42 @@ function getMiniMaxSessionTotal(model) { ); } -function getMiniMaxWeeklyTotal(model) { +function getMiniMaxWeeklyTotal(model: MiniMaxModel) { return Math.max( 0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0, ); } -function pickMiniMaxRepresentativeModel(models, getTotal) { +function pickMiniMaxRepresentativeModel( + models: MiniMaxModel[], + getTotal: (model: MiniMaxModel) => number, +) { const withQuota = models.filter((m) => getTotal(m) > 0); const pool = withQuota.length > 0 ? withQuota : models; if (pool.length === 0) return null; return pool.reduce((best, current) => (getTotal(current) > getTotal(best) ? current : best)); } -function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { +function getMiniMaxResetAt( + model: MiniMaxModel, + capturedAtMs: number, + remainsSnake: string, + remainsCamel: string, + endSnake: string, + endCamel: string, +) { const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0; if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString(); return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); } -function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) { +function buildMiniMaxQuota( + total: number, + count: number, + resetAt: unknown, + countMeansRemaining: boolean, +) { const safeTotal = Math.max(0, total); const used = countMeansRemaining ? Math.max(safeTotal - count, 0) @@ -1084,16 +1225,21 @@ function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) { /** * MiniMax Token Plan / Coding Plan usage */ -async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { +async function getMiniMaxUsage( + apiKey: string | null | undefined, + provider: string, + proxyOptions: ProxyOptions = null, +) { if (!apiKey) { return { message: "MiniMax API key not available." }; } - const usageUrls = MINIMAX_USAGE_URLS[provider] || []; + const usageUrls = (MINIMAX_USAGE_URLS as Record)[provider] || []; let lastErrorMessage = ""; for (let index = 0; index < usageUrls.length; index += 1) { const usageUrl = usageUrls[index]; + if (!usageUrl) continue; const canFallback = index < usageUrls.length - 1; try { @@ -1111,7 +1257,7 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { ); const rawText = await response.text(); - let payload = {}; + let payload: Record = {}; if (rawText) { try { payload = JSON.parse(rawText); @@ -1120,7 +1266,7 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { } } - const baseResp = (payload?.base_resp ?? payload?.baseResp) || {}; + const baseResp = ((payload?.base_resp ?? payload?.baseResp) || {}) as Record; const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0; const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim(); const combined = `${apiStatusMessage} ${rawText}`.trim(); @@ -1153,7 +1299,7 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { const modelRemains = payload?.model_remains ?? payload?.modelRemains; const allModels = Array.isArray(modelRemains) ? modelRemains : []; - const textModels = allModels.filter((m) => + const textModels = (allModels as MiniMaxModel[]).filter((m) => isMiniMaxTextQuotaModel(String(getMiniMaxField(m, "model_name", "modelName"))), ); @@ -1163,7 +1309,7 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { const capturedAtMs = Date.now(); const countMeansRemaining = usageUrl.includes("/coding_plan/remains"); - const quotas = {}; + const quotas: QuotaMap = {}; const sessionModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxSessionTotal); if (sessionModel) { @@ -1222,8 +1368,8 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { } return { quotas }; - } catch (error) { - lastErrorMessage = error.message; + } catch (error: unknown) { + lastErrorMessage = errorMessage(error); if (!canFallback) break; } } diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.ts similarity index 92% rename from open-sse/transformer/responsesTransformer.js rename to open-sse/transformer/responsesTransformer.ts index 7b7ffd98..fc3f94eb 100644 --- a/open-sse/transformer/responsesTransformer.js +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Responses API Transformer * Converts OpenAI Chat Completions SSE to Codex Responses API SSE format @@ -8,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; // Create log directory for responses (Node.js only) -export function createResponsesLogger(model, logsDir = null) { +export function createResponsesLogger(model: unknown, logsDir: unknown = null) { // Skip logging in worker environment (no fs) if (typeof fs.mkdirSync !== "function") { return null; @@ -25,21 +26,21 @@ export function createResponsesLogger(model, logsDir = null) { return null; } - const inputEvents = []; - const outputEvents = []; + const inputEvents: unknown[] = []; + const outputEvents: unknown[] = []; return { - logInput: (event) => { + logInput: (event: unknown) => { inputEvents.push(event); }, - logOutput: (event) => { + logOutput: (event: unknown) => { outputEvents.push(event); }, flush: () => { try { fs.writeFileSync(path.join(logDir, "1_input_stream.txt"), inputEvents.join("\n")); fs.writeFileSync(path.join(logDir, "2_output_stream.txt"), outputEvents.join("\n")); - } catch (e) { + } catch (e: unknown) { console.log("[RESPONSES] Failed to write logs:", e.message); } }, @@ -51,8 +52,8 @@ export function createResponsesLogger(model, logsDir = null) { * @param {Object} logger - Optional logger instance * @returns {TransformStream} */ -export function createResponsesApiTransformStream(logger = null) { - const state = { +export function createResponsesApiTransformStream(logger: unknown = null) { + const state: Record = { seq: 0, responseId: `resp_${Date.now()}`, created: Math.floor(Date.now() / 1000), @@ -79,7 +80,7 @@ export function createResponsesApiTransformStream(logger = null) { const encoder = new TextEncoder(); const nextSeq = () => ++state.seq; - const emit = (controller, eventType, data) => { + const emit = (controller: unknown, eventType: unknown, data: unknown) => { data.sequence_number = nextSeq(); const output = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; logger?.logOutput(output.trim()); @@ -87,7 +88,7 @@ export function createResponsesApiTransformStream(logger = null) { }; // Helper to start reasoning - const startReasoning = (controller, idx) => { + const startReasoning = (controller: unknown, idx: unknown) => { if (!state.reasoningId) { state.reasoningId = `rs_${state.responseId}_${idx}`; state.reasoningIndex = idx; @@ -113,7 +114,7 @@ export function createResponsesApiTransformStream(logger = null) { } }; - const emitReasoningDelta = (controller, text) => { + const emitReasoningDelta = (controller: unknown, text: unknown) => { if (!text) return; state.reasoningBuf += text; emit(controller, "response.reasoning_summary_text.delta", { @@ -125,7 +126,7 @@ export function createResponsesApiTransformStream(logger = null) { }); }; - const closeReasoning = (controller) => { + const closeReasoning = (controller: unknown) => { if (state.reasoningId && !state.reasoningDone) { state.reasoningDone = true; @@ -157,7 +158,7 @@ export function createResponsesApiTransformStream(logger = null) { } }; - const closeMessage = (controller, idx) => { + const closeMessage = (controller: unknown, idx: unknown) => { if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) { state.msgItemDone[idx] = true; const fullText = state.msgTextBuf[idx] || ""; @@ -193,7 +194,7 @@ export function createResponsesApiTransformStream(logger = null) { } }; - const closeToolCall = (controller, idx) => { + const closeToolCall = (controller: unknown, idx: unknown) => { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { const args = state.funcArgsBuf[idx] || "{}"; @@ -222,7 +223,7 @@ export function createResponsesApiTransformStream(logger = null) { } }; - const sendCompleted = (controller) => { + const sendCompleted = (controller: unknown) => { if (!state.completedSent) { state.completedSent = true; emit(controller, "response.completed", { @@ -240,7 +241,7 @@ export function createResponsesApiTransformStream(logger = null) { }; return new TransformStream({ - transform(chunk, controller) { + transform(chunk: unknown, controller: unknown) { const text = new TextDecoder().decode(chunk); logger?.logInput(text.trim()); state.buffer += text; @@ -254,7 +255,7 @@ export function createResponsesApiTransformStream(logger = null) { const dataMatch = msg.match(/^data:\s*(.+)$/m); if (!dataMatch) continue; - const dataStr = dataMatch[1].trim(); + const dataStr = dataMatch[1]!.trim(); if (dataStr === "[DONE]") continue; let parsed; @@ -424,7 +425,7 @@ export function createResponsesApiTransformStream(logger = null) { } }, - flush(controller) { + flush(controller: unknown) { for (const i in state.msgItemAdded) closeMessage(controller, i); closeReasoning(controller); for (const i in state.funcCallIds) closeToolCall(controller, i); diff --git a/open-sse/transformer/streamToJsonConverter.js b/open-sse/transformer/streamToJsonConverter.ts similarity index 96% rename from open-sse/transformer/streamToJsonConverter.js rename to open-sse/transformer/streamToJsonConverter.ts index 2d230dcd..bcde0e2b 100644 --- a/open-sse/transformer/streamToJsonConverter.js +++ b/open-sse/transformer/streamToJsonConverter.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Stream-to-JSON Converter * Converts Responses API SSE stream to single JSON response @@ -7,7 +8,7 @@ /** * Process a single SSE message and update state accordingly. */ -function processSSEMessage(msg, state) { +function processSSEMessage(msg: unknown, state: unknown) { if (!msg.trim()) return; const eventMatch = msg.match(/^event:\s*(.+)$/m); @@ -58,7 +59,7 @@ const EMPTY_RESPONSE = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; * @param {ReadableStream} stream - SSE stream from provider * @returns {Promise} Final JSON response in Responses API format */ -export async function convertResponsesStreamToJson(stream) { +export async function convertResponsesStreamToJson(stream: unknown) { if (!stream || typeof stream.getReader !== "function") { return { id: `resp_${Date.now()}`, diff --git a/open-sse/translator/formats.js b/open-sse/translator/formats.ts similarity index 82% rename from open-sse/translator/formats.js rename to open-sse/translator/formats.ts index b86910ba..009edb0c 100644 --- a/open-sse/translator/formats.js +++ b/open-sse/translator/formats.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Format identifiers export const FORMATS = { OPENAI: "openai", @@ -12,13 +13,16 @@ export const FORMATS = { KIRO: "kiro", CURSOR: "cursor", OLLAMA: "ollama", -}; + COMMANDCODE: "commandcode", +} as const; + +export type FormatId = (typeof FORMATS)[keyof typeof FORMATS]; /** * Detect source format from request URL pathname + body. * Returns null to fall back to body-based detection. */ -export function detectFormatByEndpoint(pathname, body) { +export function detectFormatByEndpoint(pathname: unknown, body: unknown) { // /v1/responses is always openai-responses if (pathname.includes("/v1/responses")) return FORMATS.OPENAI_RESPONSES; diff --git a/open-sse/translator/helpers/claudeHelper.js b/open-sse/translator/helpers/claudeHelper.ts similarity index 88% rename from open-sse/translator/helpers/claudeHelper.js rename to open-sse/translator/helpers/claudeHelper.ts index 9a358e36..43f5377f 100644 --- a/open-sse/translator/helpers/claudeHelper.js +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -1,14 +1,15 @@ +// @ts-nocheck // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js"; import { applyCloaking } from "../../utils/claudeCloaking.js"; import { deriveSessionId } from "../../utils/sessionManager.js"; // Check if message has valid non-empty content -export function hasValidContent(msg) { +export function hasValidContent(msg: unknown) { if (typeof msg.content === "string" && msg.content.trim()) return true; if (Array.isArray(msg.content)) { return msg.content.some( - (block) => + (block: unknown) => (block.type === "text" && block.text?.trim()) || block.type === "tool_use" || block.type === "tool_result", @@ -20,13 +21,13 @@ export function hasValidContent(msg) { // Fix tool_use/tool_result ordering for Claude API // 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow) // 2. Merge consecutive same-role messages -export function fixToolUseOrdering(messages) { +export function fixToolUseOrdering(messages: unknown) { if (messages.length <= 1) return messages; // Pass 1: Fix assistant messages with tool_use - remove text after tool_use for (const msg of messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { - const hasToolUse = msg.content.some((b) => b.type === "tool_use"); + const hasToolUse = msg.content.some((b: unknown) => b.type === "tool_use"); if (hasToolUse) { // Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use) const newContent = []; @@ -67,12 +68,12 @@ export function fixToolUseOrdering(messages) { // Put tool_result first, then other content const toolResults = [ - ...lastContent.filter((b) => b.type === "tool_result"), - ...msgContent.filter((b) => b.type === "tool_result"), + ...lastContent.filter((b: unknown) => b.type === "tool_result"), + ...msgContent.filter((b: unknown) => b.type === "tool_result"), ]; const otherContent = [ - ...lastContent.filter((b) => b.type !== "tool_result"), - ...msgContent.filter((b) => b.type !== "tool_result"), + ...lastContent.filter((b: unknown) => b.type !== "tool_result"), + ...msgContent.filter((b: unknown) => b.type !== "tool_result"), ]; last.content = [...toolResults, ...otherContent]; @@ -96,7 +97,12 @@ const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set(["minimax", "minim // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering // - Apply cloaking (billing header + fake user ID) for OAuth tokens -export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null) { +export function prepareClaudeRequest( + body: unknown, + provider: unknown = null, + apiKey: unknown = null, + connectionId: unknown = null, +) { // MiniMax exposes a Claude-compatible endpoint but rejects Anthropic's extended // structured output parameter with a generic 400 "invalid params" response. if (CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG.has(provider)) { @@ -105,8 +111,8 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // 1. System: remove all cache_control, add only to last block with ttl 1h if (body.system && Array.isArray(body.system)) { - body.system = body.system.map((block, i) => { - const { cache_control, ...rest } = block; + body.system = body.system.map((block: unknown, i: unknown) => { + const { cache_control: _cache_control, ...rest } = block; if (i === body.system.length - 1) { return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; } @@ -198,11 +204,11 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne if (body.tools && Array.isArray(body.tools)) { // Strip built-in tools (e.g. web_search_20250305) for providers that don't support them if (provider !== "claude") { - body.tools = body.tools.filter((tool) => !tool.type || tool.type === "function"); + body.tools = body.tools.filter((tool: unknown) => !tool.type || tool.type === "function"); } - body.tools = body.tools.map((tool, i) => { - const { cache_control, ...rest } = tool; + body.tools = body.tools.map((tool: unknown, i: unknown) => { + const { cache_control: _cache_control, ...rest } = tool; if (i === body.tools.length - 1) { return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; } diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.ts similarity index 89% rename from open-sse/translator/helpers/geminiHelper.js rename to open-sse/translator/helpers/geminiHelper.ts index b7476d2a..6ad17500 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Gemini helper functions for translator // Unsupported JSON Schema constraints that should be removed for Antigravity @@ -65,7 +66,7 @@ export const DEFAULT_SAFETY_SETTINGS = [ ]; // Convert OpenAI content to Gemini parts -export function convertOpenAIContentToParts(content) { +export function convertOpenAIContentToParts(content: unknown) { const parts = []; if (typeof content === "string") { @@ -119,19 +120,19 @@ export function convertOpenAIContentToParts(content) { } // Extract text content from OpenAI content -export function extractTextContent(content) { +export function extractTextContent(content: unknown) { if (typeof content === "string") return content; if (Array.isArray(content)) { return content - .filter((c) => c.type === "text") - .map((c) => c.text) + .filter((c: unknown) => c.type === "text") + .map((c: unknown) => c.text) .join(""); } return ""; } // Try parse JSON safely -export function tryParseJSON(str) { +export function tryParseJSON(str: unknown) { if (typeof str !== "string") return str; try { return JSON.parse(str); @@ -161,7 +162,7 @@ export function generateProjectId() { // Helper: Remove unsupported keywords recursively from object/array // Also strips all vendor extension fields (x- prefixed) not supported by Gemini -function removeUnsupportedKeywords(obj, keywords) { +function removeUnsupportedKeywords(obj: unknown, keywords: unknown) { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -185,7 +186,7 @@ function removeUnsupportedKeywords(obj, keywords) { } // Convert const to enum -function convertConstToEnum(obj) { +function convertConstToEnum(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.const !== undefined && !obj.enum) { @@ -201,11 +202,11 @@ function convertConstToEnum(obj) { } // Convert enum values to strings (Gemini requires string enum values + explicit type:"string") -function convertEnumValuesToStrings(obj) { +function convertEnumValuesToStrings(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.enum && Array.isArray(obj.enum)) { - obj.enum = obj.enum.map((v) => String(v)); + obj.enum = obj.enum.map((v: unknown) => String(v)); // Gemini API requires type:"string" when enum is present — without it returns 400 if (!obj.type) { obj.type = "string"; @@ -220,11 +221,11 @@ function convertEnumValuesToStrings(obj) { } // Merge allOf schemas -function mergeAllOf(obj) { +function mergeAllOf(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.allOf && Array.isArray(obj.allOf)) { - const merged = {}; + const merged: Record = {}; for (const item of obj.allOf) { if (item.properties) { @@ -254,7 +255,7 @@ function mergeAllOf(obj) { } // Select best schema from anyOf/oneOf -function selectBest(items) { +function selectBest(items: unknown) { let bestIdx = 0; let bestScore = -1; @@ -281,11 +282,11 @@ function selectBest(items) { } // Flatten anyOf/oneOf -function flattenAnyOfOneOf(obj) { +function flattenAnyOfOneOf(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) { - const nonNullSchemas = obj.anyOf.filter((s) => s && s.type !== "null"); + const nonNullSchemas = obj.anyOf.filter((s: unknown) => s && s.type !== "null"); if (nonNullSchemas.length > 0) { const bestIdx = selectBest(nonNullSchemas); const selected = nonNullSchemas[bestIdx]; @@ -295,7 +296,7 @@ function flattenAnyOfOneOf(obj) { } if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) { - const nonNullSchemas = obj.oneOf.filter((s) => s && s.type !== "null"); + const nonNullSchemas = obj.oneOf.filter((s: unknown) => s && s.type !== "null"); if (nonNullSchemas.length > 0) { const bestIdx = selectBest(nonNullSchemas); const selected = nonNullSchemas[bestIdx]; @@ -312,11 +313,11 @@ function flattenAnyOfOneOf(obj) { } // Flatten type arrays -function flattenTypeArrays(obj) { +function flattenTypeArrays(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.type && Array.isArray(obj.type)) { - const nonNullTypes = obj.type.filter((t) => t !== "null"); + const nonNullTypes = obj.type.filter((t: unknown) => t !== "null"); obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; } @@ -329,7 +330,7 @@ function flattenTypeArrays(obj) { // Ensure schemas with properties but no type get type:"object" // Prevents Gemini API errors with tool schemas that omit the type field -function ensureObjectType(obj) { +function ensureObjectType(obj: unknown) { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -347,7 +348,7 @@ function ensureObjectType(obj) { } // Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively -export function cleanJSONSchemaForAntigravity(schema) { +export function cleanJSONSchemaForAntigravity(schema: T): T { if (!schema || typeof schema !== "object") return schema; // Mutate directly (schema is only used once per request) @@ -369,11 +370,13 @@ export function cleanJSONSchemaForAntigravity(schema) { removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS); // Phase 4: Cleanup required fields recursively - function cleanupRequired(obj) { + function cleanupRequired(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.required && Array.isArray(obj.required) && obj.properties) { - const validRequired = obj.required.filter((field) => Object.hasOwn(obj.properties, field)); + const validRequired = obj.required.filter((field: unknown) => + Object.hasOwn(obj.properties, field), + ); if (validRequired.length === 0) { delete obj.required; } else { @@ -392,7 +395,7 @@ export function cleanJSONSchemaForAntigravity(schema) { cleanupRequired(cleaned); // Phase 5: Add placeholder for empty object schemas (Antigravity requirement) - function addPlaceholders(obj) { + function addPlaceholders(obj: unknown) { if (!obj || typeof obj !== "object") return; if (obj.type === "object") { diff --git a/open-sse/translator/helpers/imageHelper.js b/open-sse/translator/helpers/imageHelper.ts similarity index 92% rename from open-sse/translator/helpers/imageHelper.js rename to open-sse/translator/helpers/imageHelper.ts index 1df04d8d..88f2f4d2 100644 --- a/open-sse/translator/helpers/imageHelper.js +++ b/open-sse/translator/helpers/imageHelper.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Fetch a remote image URL and return it as a base64 data URI. * Used when upstream providers (Codex, etc.) require inline base64 images @@ -8,7 +9,7 @@ * @param {object} options - { signal, timeoutMs } * @returns {Promise<{url: string, mimeType: string}|null>} */ -export async function fetchImageAsBase64(imageUrl, options = {}) { +export async function fetchImageAsBase64(imageUrl: unknown, options: unknown = {}) { const { signal, timeoutMs = 10000 } = options; if (!imageUrl || (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://"))) { return null; diff --git a/open-sse/translator/helpers/maxTokensHelper.js b/open-sse/translator/helpers/maxTokensHelper.ts similarity index 93% rename from open-sse/translator/helpers/maxTokensHelper.js rename to open-sse/translator/helpers/maxTokensHelper.ts index 29df0ad2..1ca9b808 100644 --- a/open-sse/translator/helpers/maxTokensHelper.js +++ b/open-sse/translator/helpers/maxTokensHelper.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.js"; /** @@ -5,7 +6,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf * @param {object} body - Request body * @returns {number} Adjusted max_tokens */ -export function adjustMaxTokens(body) { +export function adjustMaxTokens(body: unknown) { let maxTokens = body.max_tokens || body.max_completion_tokens || DEFAULT_MAX_TOKENS; // Auto-increase for tool calling to prevent truncated arguments diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.ts similarity index 84% rename from open-sse/translator/helpers/openaiHelper.js rename to open-sse/translator/helpers/openaiHelper.ts index 887721a1..7db9e591 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // OpenAI helper functions for translator // Valid OpenAI content block types @@ -18,10 +19,10 @@ export const VALID_OPENAI_MESSAGE_TYPES = [ // Filter messages to OpenAI standard format // Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks -export function filterToOpenAIFormat(body) { +export function filterToOpenAIFormat(body: unknown) { if (!body.messages || !Array.isArray(body.messages)) return body; - body.messages = body.messages.map((msg) => { + body.messages = body.messages.map((msg: unknown) => { // Normalize developer role to system (many providers don't support developer) if (msg.role === "developer") msg = { ...msg, role: "system" }; @@ -45,12 +46,12 @@ export function filterToOpenAIFormat(body) { // Only keep valid OpenAI content types if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { // Remove signature field if exists - const { signature, cache_control, ...cleanBlock } = block; + const { signature: _signature, cache_control: _cache_control, ...cleanBlock } = block; filteredContent.push(cleanBlock); } else if (block.type === "tool_use") { } else if (block.type === "tool_result") { // Keep tool_result but clean it - const { signature, cache_control, ...cleanBlock } = block; + const { signature: _signature, cache_control: _cache_control, ...cleanBlock } = block; filteredContent.push(cleanBlock); } } @@ -60,10 +61,10 @@ export function filterToOpenAIFormat(body) { filteredContent.push({ type: "text", text: "" }); } - const allText = filteredContent.every((b) => b.type === "text"); + const allText = filteredContent.every((b: unknown) => b.type === "text"); return { ...msg, - content: allText ? filteredContent.map((b) => b.text).join("\n") : filteredContent, + content: allText ? filteredContent.map((b: unknown) => b.text).join("\n") : filteredContent, }; } @@ -73,7 +74,7 @@ export function filterToOpenAIFormat(body) { // Filter out messages with only empty text (but NEVER filter tool messages // or user messages — removing a user message can leave two assistant messages // adjacent, causing upstream "Cannot continue from message role: assistant"). - body.messages = body.messages.filter((msg) => { + body.messages = body.messages.filter((msg: unknown) => { // Always keep tool messages if (msg.role === "tool") return true; // Always keep assistant messages with tool_calls @@ -83,7 +84,9 @@ export function filterToOpenAIFormat(body) { if (typeof msg.content === "string") return msg.content.trim() !== ""; if (Array.isArray(msg.content)) { - return msg.content.some((b) => (b.type === "text" && b.text?.trim()) || b.type !== "text"); + return msg.content.some( + (b: unknown) => (b.type === "text" && b.text?.trim()) || b.type !== "text", + ); } return true; }); @@ -95,7 +98,7 @@ export function filterToOpenAIFormat(body) { // Normalize tools to OpenAI format (from Claude, Gemini, etc.) if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - body.tools = body.tools.flatMap((tool) => { + body.tools = body.tools.flatMap((tool: unknown) => { // Already OpenAI format if (tool.type === "function" && tool.function) return tool; @@ -113,7 +116,7 @@ export function filterToOpenAIFormat(body) { // Gemini format: {functionDeclarations: [{name, description, parameters}]} if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { - return tool.functionDeclarations.map((fn) => ({ + return tool.functionDeclarations.map((fn: unknown) => ({ type: "function", function: { name: fn.name, diff --git a/open-sse/translator/helpers/responsesApiHelper.js b/open-sse/translator/helpers/responsesApiHelper.ts similarity index 93% rename from open-sse/translator/helpers/responsesApiHelper.js rename to open-sse/translator/helpers/responsesApiHelper.ts index 2b07b588..6e6b707d 100644 --- a/open-sse/translator/helpers/responsesApiHelper.js +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Normalize Responses API input to array format. * Accepts string or array, returns array of message items. @@ -6,7 +7,7 @@ * @param {string|Array} input - raw input from Responses API body * @returns {Array|null} normalized array or null if invalid */ -export function normalizeResponsesInput(input) { +export function normalizeResponsesInput(input: unknown) { if (typeof input === "string") { const text = input.trim() === "" ? "..." : input; return [{ type: "message", role: "user", content: [{ type: "input_text", text }] }]; @@ -26,7 +27,7 @@ export function normalizeResponsesInput(input) { * Responses API uses: { input: [...], instructions: "..." } * Chat API uses: { messages: [...] } */ -export function convertResponsesApiFormat(body) { +export function convertResponsesApiFormat(body: unknown) { if (!body.input) return body; const result = { ...body }; @@ -38,9 +39,9 @@ export function convertResponsesApiFormat(body) { } // Group items by conversation turn - let currentAssistantMsg = null; - const _pendingToolCalls = []; - let pendingToolResults = []; + let currentAssistantMsg: unknown = null; + const _pendingToolCalls: unknown[] = []; + let pendingToolResults: unknown[] = []; const inputItems = normalizeResponsesInput(body.input); if (!inputItems) return body; @@ -66,7 +67,7 @@ export function convertResponsesApiFormat(body) { // Convert content: input_text → text, output_text → text, input_image → image_url const content = Array.isArray(item.content) - ? item.content.map((c) => { + ? item.content.map((c: unknown) => { if (c.type === "input_text") return { type: "text", text: c.text }; if (c.type === "output_text") return { type: "text", text: c.text }; if (c.type === "input_image") { diff --git a/open-sse/translator/helpers/toolCallHelper.js b/open-sse/translator/helpers/toolCallHelper.ts similarity index 92% rename from open-sse/translator/helpers/toolCallHelper.js rename to open-sse/translator/helpers/toolCallHelper.ts index c4698bd0..0a4dc4e2 100644 --- a/open-sse/translator/helpers/toolCallHelper.js +++ b/open-sse/translator/helpers/toolCallHelper.ts @@ -1,23 +1,28 @@ +// @ts-nocheck // Tool call helper functions for translator // Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$ const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; // Generate deterministic tool call ID from position + tool name (cache-friendly) -export function generateToolCallId(msgIndex = 0, tcIndex = 0, toolName = "") { +export function generateToolCallId( + msgIndex: unknown = 0, + tcIndex: unknown = 0, + toolName: unknown = "", +) { const name = toolName ? `_${toolName.replace(/[^a-zA-Z0-9_-]/g, "")}` : ""; return `call_msg${msgIndex}_tc${tcIndex}${name}`; } // Sanitize ID to match Anthropic pattern: keep only alphanumeric, underscore, hyphen -function sanitizeToolId(id) { +function sanitizeToolId(id: unknown) { if (!id || typeof id !== "string") return null; const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, ""); return sanitized.length > 0 ? sanitized : null; } // Ensure all tool_calls have valid id field and arguments is string (some providers require it) -export function ensureToolCallIds(body) { +export function ensureToolCallIds(body: unknown) { if (!body.messages || !Array.isArray(body.messages)) return body; for (let i = 0; i < body.messages.length; i++) { @@ -71,7 +76,7 @@ export function ensureToolCallIds(body) { } // Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content) -export function getToolCallIds(msg) { +export function getToolCallIds(msg: unknown) { if (msg.role !== "assistant") return []; const ids = []; @@ -96,7 +101,7 @@ export function getToolCallIds(msg) { } // Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content) -export function hasToolResults(msg, toolCallIds) { +export function hasToolResults(msg: unknown, toolCallIds: unknown) { if (!msg || !toolCallIds.length) return false; // OpenAI format: role = "tool" with tool_call_id @@ -117,7 +122,7 @@ export function hasToolResults(msg, toolCallIds) { } // Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result -export function fixMissingToolResponses(body) { +export function fixMissingToolResponses(body: unknown) { if (!body.messages || !Array.isArray(body.messages)) return body; const newMessages = []; diff --git a/open-sse/translator/index.js b/open-sse/translator/index.ts similarity index 65% rename from open-sse/translator/index.js rename to open-sse/translator/index.ts index 657e3005..25f223a1 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.ts @@ -1,74 +1,74 @@ +// @ts-nocheck import { normalizeThinkingConfig } from "../services/provider.js"; import { cloakClaudeTools } from "../utils/claudeCloaking.js"; import { FORMATS } from "./formats.js"; import { prepareClaudeRequest } from "./helpers/claudeHelper.js"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.js"; import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.js"; +import type { + TranslatedResponseResults, + TranslatorCredentials, + TranslatorRequestPayload, + TranslatorResponseChunk, + TranslatorResponseResult, + TranslatorState, +} from "./registry.js"; +import { + getRegisteredRequestTranslatorKeys, + getRegisteredResponseTranslatorKeys, + register, + requestRegistry, + responseRegistry, +} from "./registry.js"; +// Side-effect: register all translators (via registry.ts — no circular init). +import "./loaders.js"; + +export { getRegisteredRequestTranslatorKeys, getRegisteredResponseTranslatorKeys, register }; + +type RequestPipelineBody = TranslatorRequestPayload & { + _toolNameMap?: Map; +}; + +type RequestLogger = { + logOpenAIRequest?: (body: TranslatorRequestPayload) => void; +}; + +function asRequestBody(body: TranslatorRequestPayload): RequestPipelineBody { + return body as RequestPipelineBody; +} -// Registry for translators -const requestRegistry = new Map(); -const responseRegistry = new Map(); - -// Register translator -export function register(from, to, requestFn, responseFn) { - const key = `${from}:${to}`; - if (requestFn) { - requestRegistry.set(key, requestFn); - } - if (responseFn) { - responseRegistry.set(key, responseFn); - } +function asTranslatedResponseResults( + converted: Exclude, +): TranslatedResponseResults { + return (Array.isArray(converted) ? converted : [converted]) as TranslatedResponseResults; } -let initialized = false; - -function ensureInitialized() { - if (initialized) return; - initialized = true; - - require("./request/claude-to-openai.js"); - require("./request/openai-to-claude.js"); - require("./request/gemini-to-openai.js"); - require("./request/openai-to-gemini.js"); - require("./request/openai-to-vertex.js"); - require("./request/antigravity-to-openai.js"); - require("./request/openai-responses.js"); - require("./request/openai-to-kiro.js"); - require("./request/openai-to-cursor.js"); - require("./request/openai-to-ollama.js"); - require("./request/openai-to-commandcode.js"); - - require("./response/claude-to-openai.js"); - require("./response/openai-to-claude.js"); - require("./response/gemini-to-openai.js"); - require("./response/openai-to-antigravity.js"); - require("./response/openai-responses.js"); - require("./response/kiro-to-openai.js"); - require("./response/cursor-to-openai.js"); - require("./response/ollama-to-openai.js"); - require("./response/commandcode-to-openai.js"); +function ensureInitialized(): void { + if (requestRegistry.size === 0 || responseRegistry.size === 0) { + throw new Error("Translator registry is empty; loader side-effect imports did not run."); + } } // Strip specific content types from messages (explicit opt-in via strip[] in PROVIDER_MODELS) -function stripContentTypes(body, stripList = []) { +function stripContentTypes(body: unknown, stripList: unknown = []) { if (!stripList.length || !body.messages || !Array.isArray(body.messages)) return; const imageTypes = new Set(["image_url", "image"]); const audioTypes = new Set(["audio_url", "input_audio"]); - const shouldStrip = (type) => { + const shouldStrip = (type: unknown) => { if (imageTypes.has(type)) return stripList.includes("image"); if (audioTypes.has(type)) return stripList.includes("audio"); return false; }; for (const msg of body.messages) { if (!Array.isArray(msg.content)) continue; - msg.content = msg.content.filter((part) => !shouldStrip(part.type)); + msg.content = msg.content.filter((part: unknown) => !shouldStrip(part.type)); if (msg.content.length === 0) msg.content = ""; } } // Normalize 'developer' role to 'system' for providers that don't accept it // (DeepSeek, Groq, and other OpenAI-format providers) -function normalizeDeveloperRole(body) { +function normalizeDeveloperRole(body: unknown) { if (!body.messages || !Array.isArray(body.messages)) return; for (const msg of body.messages) { if (msg.role === "developer") { @@ -79,20 +79,21 @@ function normalizeDeveloperRole(body) { // Translate request: source -> openai -> target export function translateRequest( - sourceFormat, - targetFormat, - model, - body, - stream = true, - credentials = null, - provider = null, - reqLogger = null, - stripList = [], - connectionId = null, - clientTool = null, -) { + sourceFormat: string, + targetFormat: string, + model: string, + body: TranslatorRequestPayload, + stream: boolean = true, + credentials: TranslatorCredentials = null, + provider: string | null = null, + reqLogger: RequestLogger | null = null, + stripList: readonly unknown[] = [], + connectionId: string | null = null, + clientTool: unknown = null, +): TranslatorRequestPayload { ensureInitialized(); - let result = body; + void clientTool; + let result = asRequestBody(body); // Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry) stripContentTypes(result, stripList); @@ -115,7 +116,7 @@ export function translateRequest( if (sourceFormat !== FORMATS.OPENAI) { const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`); if (toOpenAI) { - result = toOpenAI(model, result, stream, credentials); + result = asRequestBody(toOpenAI(model, result, stream, credentials)); // Log OpenAI intermediate format reqLogger?.logOpenAIRequest?.(result); } @@ -125,7 +126,7 @@ export function translateRequest( if (targetFormat !== FORMATS.OPENAI) { const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); if (fromOpenAI) { - result = fromOpenAI(model, result, stream, credentials); + result = asRequestBody(fromOpenAI(model, result, stream, credentials)); } } } @@ -148,9 +149,11 @@ export function translateRequest( const apiKey = credentials?.accessToken || credentials?.apiKey || null; if (apiKey?.includes("sk-ant-oat")) { const { body: cloakedBody, toolNameMap } = cloakClaudeTools(result); - result = cloakedBody; - if (toolNameMap?.size > 0) { - result._toolNameMap = toolNameMap; + result = asRequestBody(cloakedBody); + const typedToolNameMap = + toolNameMap instanceof Map ? (toolNameMap as Map) : null; + if (typedToolNameMap && typedToolNameMap.size > 0) { + result._toolNameMap = typedToolNameMap; } } } @@ -168,25 +171,31 @@ export function translateRequest( } // Translate response chunk: target -> openai -> source -export function translateResponse(targetFormat, sourceFormat, chunk, state) { +export function translateResponse( + targetFormat: string, + sourceFormat: string, + chunk: TranslatorResponseChunk, + state: TranslatorState, +): TranslatedResponseResults { ensureInitialized(); // If same format, return as-is if (sourceFormat === targetFormat) { - return [chunk]; + return [chunk] as TranslatedResponseResults; } - let results = [chunk]; - let openaiResults = null; // Store OpenAI intermediate results + let results: TranslatedResponseResults = [chunk] as TranslatedResponseResults; + let openaiResults: TranslatorResponseChunk[] | null = null; // Store OpenAI intermediate results // Step 1: target -> openai (if target is not openai) if (targetFormat !== FORMATS.OPENAI) { const toOpenAI = responseRegistry.get(`${targetFormat}:${FORMATS.OPENAI}`); if (toOpenAI) { - results = []; const converted = toOpenAI(chunk, state); if (converted) { - results = Array.isArray(converted) ? converted : [converted]; + results = asTranslatedResponseResults(converted); openaiResults = results; // Store OpenAI intermediate + } else { + results = [] as TranslatedResponseResults; } } } @@ -195,11 +204,11 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { if (sourceFormat !== FORMATS.OPENAI) { const fromOpenAI = responseRegistry.get(`${FORMATS.OPENAI}:${sourceFormat}`); if (fromOpenAI) { - const finalResults = []; + const finalResults = [] as TranslatedResponseResults; for (const r of results) { const converted = fromOpenAI(r, state); if (converted) { - finalResults.push(...(Array.isArray(converted) ? converted : [converted])); + finalResults.push(...asTranslatedResponseResults(converted)); } } results = finalResults; @@ -215,12 +224,12 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { } // Check if translation needed -export function needsTranslation(sourceFormat, targetFormat) { +export function needsTranslation(sourceFormat: string, targetFormat: string): boolean { return sourceFormat !== targetFormat; } // Initialize state for streaming response based on format -export function initState(sourceFormat) { +export function initState(sourceFormat: string): TranslatorState { // Base state for all formats const base = { messageId: null, @@ -266,6 +275,6 @@ export function initState(sourceFormat) { return base; } -export function initTranslators() { +export function initTranslators(): void { ensureInitialized(); } diff --git a/open-sse/translator/loaders.ts b/open-sse/translator/loaders.ts new file mode 100644 index 00000000..8cb208d2 --- /dev/null +++ b/open-sse/translator/loaders.ts @@ -0,0 +1,21 @@ +/** Side-effect import of all request/response translators (registers into registry.ts). */ +import "./request/claude-to-openai.js"; +import "./request/openai-to-claude.js"; +import "./request/gemini-to-openai.js"; +import "./request/openai-to-gemini.js"; +import "./request/openai-to-vertex.js"; +import "./request/antigravity-to-openai.js"; +import "./request/openai-responses.js"; +import "./request/openai-to-kiro.js"; +import "./request/openai-to-cursor.js"; +import "./request/openai-to-ollama.js"; +import "./request/openai-to-commandcode.js"; +import "./response/claude-to-openai.js"; +import "./response/openai-to-claude.js"; +import "./response/gemini-to-openai.js"; +import "./response/openai-to-antigravity.js"; +import "./response/openai-responses.js"; +import "./response/kiro-to-openai.js"; +import "./response/cursor-to-openai.js"; +import "./response/ollama-to-openai.js"; +import "./response/commandcode-to-openai.js"; diff --git a/open-sse/translator/registry.ts b/open-sse/translator/registry.ts new file mode 100644 index 00000000..9d56d431 --- /dev/null +++ b/open-sse/translator/registry.ts @@ -0,0 +1,52 @@ +/** Translator request/response registry (split to avoid circular ESM init). */ +export type TranslatorRequestPayload = Record; +export type TranslatorCredentials = + | (Record & { + accessToken?: string; + apiKey?: string; + providerSpecificData?: Record; + }) + | null; +export type TranslatorState = Record; +export type TranslatorResponseChunk = unknown; +export type TranslatorResponseResult = + | TranslatorResponseChunk + | readonly TranslatorResponseChunk[] + | null + | undefined; +export type TranslatedResponseResults = TranslatorResponseChunk[] & { + _openaiIntermediate?: TranslatorResponseChunk[]; +}; + +export type TranslatorRequestFn = ( + model: string, + body: TranslatorRequestPayload, + stream: boolean, + credentials?: TranslatorCredentials, +) => TranslatorRequestPayload; +export type TranslatorResponseFn = ( + chunk: TranslatorResponseChunk, + state: TranslatorState, +) => TranslatorResponseResult; + +export const requestRegistry = new Map(); +export const responseRegistry = new Map(); + +export function getRegisteredRequestTranslatorKeys(): readonly string[] { + return Array.from(requestRegistry.keys()); +} + +export function getRegisteredResponseTranslatorKeys(): readonly string[] { + return Array.from(responseRegistry.keys()); +} + +export function register( + from: string, + to: string, + requestFn: TranslatorRequestFn | null | undefined, + responseFn: TranslatorResponseFn | null | undefined, +): void { + const key = `${from}:${to}`; + if (requestFn) requestRegistry.set(key, requestFn); + if (responseFn) responseRegistry.set(key, responseFn); +} diff --git a/open-sse/translator/request/antigravity-to-openai.js b/open-sse/translator/request/antigravity-to-openai.ts similarity index 84% rename from open-sse/translator/request/antigravity-to-openai.js rename to open-sse/translator/request/antigravity-to-openai.ts index e0705c58..1aeec9bb 100644 --- a/open-sse/translator/request/antigravity-to-openai.js +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -1,14 +1,15 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Convert Antigravity request to OpenAI format // Antigravity body: { project, model, userAgent, requestType, requestId, request: { contents, systemInstruction, tools, toolConfig, generationConfig, sessionId } } -export function antigravityToOpenAIRequest(model, body, stream) { +export function antigravityToOpenAIRequest(model: unknown, body: unknown, stream: unknown) { const req = body.request || body; - const result = { + const result: Record = { model: model, - messages: [], + messages: [] as unknown[], stream: stream, }; @@ -93,10 +94,10 @@ export function antigravityToOpenAIRequest(model, body, stream) { // Recursively convert Antigravity schema types (OBJECT, STRING, etc.) to lowercase // and strip unsupported fields like enumDescriptions -function normalizeSchemaTypes(schema) { +function normalizeSchemaTypes(schema: unknown) { if (!schema || typeof schema !== "object") return schema; - const result = Array.isArray(schema) ? [...schema] : { ...schema }; + const result: unknown = Array.isArray(schema) ? [...schema] : { ...schema }; if (typeof result.type === "string") { result.type = result.type.toLowerCase(); @@ -106,7 +107,7 @@ function normalizeSchemaTypes(schema) { delete result.enumDescriptions; if (result.properties) { - const normalized = {}; + const normalized: Record = {}; for (const [key, val] of Object.entries(result.properties)) { normalized[key] = normalizeSchemaTypes(val); } @@ -122,7 +123,7 @@ function normalizeSchemaTypes(schema) { // Convert Antigravity content to OpenAI message // Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData -function convertContent(content) { +function convertContent(content: unknown) { const role = content.role === "model" ? "assistant" : content.role === "user" ? "user" : content.role; @@ -130,9 +131,9 @@ function convertContent(content) { return null; } - const textParts = []; - const toolCalls = []; - const toolResults = []; + const textParts: unknown[] = []; + const toolCalls: unknown[] = []; + const toolResults: unknown[] = []; let reasoningContent = ""; for (const part of content.parts) { @@ -194,10 +195,12 @@ function convertContent(content) { // Assistant with tool calls if (toolCalls.length > 0) { - const msg = { role: "assistant" }; + const msg: Record = { role: "assistant" }; if (textParts.length > 0) { msg.content = - textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + textParts.length === 1 && (textParts[0] as unknown).type === "text" + ? (textParts[0] as unknown).text + : textParts; } if (reasoningContent) { msg.reasoning_content = reasoningContent; @@ -208,10 +211,12 @@ function convertContent(content) { // Regular message if (textParts.length > 0 || reasoningContent) { - const msg = { role }; + const msg: Record = { role }; if (textParts.length > 0) { msg.content = - textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + textParts.length === 1 && (textParts[0] as unknown).type === "text" + ? (textParts[0] as unknown).text + : textParts; } if (reasoningContent) { msg.reasoning_content = reasoningContent; @@ -223,10 +228,10 @@ function convertContent(content) { } // Extract text from systemInstruction -function extractText(instruction) { +function extractText(instruction: unknown) { if (typeof instruction === "string") return instruction; if (instruction.parts && Array.isArray(instruction.parts)) { - return instruction.parts.map((p) => p.text || "").join(""); + return instruction.parts.map((p: unknown) => p.text || "").join(""); } return ""; } diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.ts similarity index 80% rename from open-sse/translator/request/claude-to-openai.js rename to open-sse/translator/request/claude-to-openai.ts index 6117e04e..2c84983c 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.ts @@ -1,12 +1,13 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Convert Claude request to OpenAI format -export function claudeToOpenAIRequest(model, body, stream) { - const result = { +export function claudeToOpenAIRequest(model: unknown, body: unknown, stream: unknown) { + const result: Record = { model: model, - messages: [], + messages: [] as unknown[], stream: stream, }; @@ -23,7 +24,7 @@ export function claudeToOpenAIRequest(model, body, stream) { // System message if (body.system) { const systemContent = Array.isArray(body.system) - ? body.system.map((s) => s.text || "").join("\n") + ? body.system.map((s: unknown) => s.text || "").join("\n") : body.system; if (systemContent) { @@ -55,7 +56,7 @@ export function claudeToOpenAIRequest(model, body, stream) { // Tools if (body.tools && Array.isArray(body.tools)) { - result.tools = body.tools.map((tool) => ({ + result.tools = body.tools.map((tool: unknown) => ({ type: "function", function: { name: tool.name, @@ -74,14 +75,14 @@ export function claudeToOpenAIRequest(model, body, stream) { } // Fix missing tool responses - add empty responses for tool_calls without responses -function fixMissingToolResponses(messages) { +function fixMissingToolResponses(messages: unknown) { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { - const toolCallIds = msg.tool_calls.map((tc) => tc.id); + const toolCallIds = msg.tool_calls.map((tc: unknown) => tc.id); // Collect all tool response IDs that IMMEDIATELY follow this assistant message - const respondedIds = new Set(); + const respondedIds = new Set(); let insertPosition = i + 1; for (let j = i + 1; j < messages.length; j++) { const nextMsg = messages[j]; @@ -94,10 +95,10 @@ function fixMissingToolResponses(messages) { } // Find missing responses and insert them - const missingIds = toolCallIds.filter((id) => !respondedIds.has(id)); + const missingIds = toolCallIds.filter((id: unknown) => !respondedIds.has(id)); if (missingIds.length > 0) { - const missingResponses = missingIds.map((id) => ({ + const missingResponses = missingIds.map((id: unknown) => ({ role: "tool", tool_call_id: id, content: "[No response received]", @@ -110,7 +111,7 @@ function fixMissingToolResponses(messages) { } // Convert single Claude message - returns single message or array of messages -function convertClaudeMessage(msg) { +function convertClaudeMessage(msg: unknown) { const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; // Simple string content @@ -120,9 +121,9 @@ function convertClaudeMessage(msg) { // Array content if (Array.isArray(msg.content)) { - const parts = []; - const toolCalls = []; - const toolResults = []; + const parts: unknown[] = []; + const toolCalls: unknown[] = []; + const toolResults: unknown[] = []; for (const block of msg.content) { switch (block.type) { @@ -159,8 +160,8 @@ function convertClaudeMessage(msg) { } else if (Array.isArray(block.content)) { resultContent = block.content - .filter((c) => c.type === "text") - .map((c) => c.text) + .filter((c: unknown) => c.type === "text") + .map((c: unknown) => c.text) .join("\n") || JSON.stringify(block.content); } else if (block.content) { resultContent = JSON.stringify(block.content); @@ -187,19 +188,22 @@ function convertClaudeMessage(msg) { // If has tool calls, return assistant message with tool_calls if (toolCalls.length > 0) { - const result = { role: "assistant" }; + const result: Record = { role: "assistant" }; if (parts.length > 0) { - result.content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts; + result.content = + parts.length === 1 && (parts[0] as unknown).type === "text" + ? (parts[0] as unknown).text + : parts; } result.tool_calls = toolCalls; return result; } if (parts.length > 0) { - const allText = parts.every((p) => p.type === "text"); + const allText = parts.every((p: unknown) => p.type === "text"); return { role, - content: allText ? parts.map((p) => p.text).join("\n") : parts, + content: allText ? parts.map((p: unknown) => p.text).join("\n") : parts, }; } @@ -213,7 +217,7 @@ function convertClaudeMessage(msg) { } // Convert tool choice -function convertToolChoice(choice) { +function convertToolChoice(choice: unknown) { if (!choice) return "auto"; if (typeof choice === "string") return choice; diff --git a/open-sse/translator/request/gemini-to-openai.js b/open-sse/translator/request/gemini-to-openai.ts similarity index 81% rename from open-sse/translator/request/gemini-to-openai.js rename to open-sse/translator/request/gemini-to-openai.ts index 6af2ae46..b4e883c5 100644 --- a/open-sse/translator/request/gemini-to-openai.js +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -1,12 +1,13 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Convert Gemini request to OpenAI format -export function geminiToOpenAIRequest(model, body, stream) { - const result = { +export function geminiToOpenAIRequest(model: unknown, body: unknown, stream: unknown) { + const result: Record = { model: model, - messages: [], + messages: [] as unknown[], stream: stream, }; @@ -69,15 +70,15 @@ export function geminiToOpenAIRequest(model, body, stream) { } // Convert Gemini content to OpenAI message -function convertGeminiContent(content) { +function convertGeminiContent(content: unknown) { const role = content.role === "user" ? "user" : "assistant"; if (!content.parts || !Array.isArray(content.parts)) { return null; } - const parts = []; - const toolCalls = []; + const parts: unknown[] = []; + const toolCalls: unknown[] = []; for (const part of content.parts) { if (part.text !== undefined) { @@ -116,9 +117,9 @@ function convertGeminiContent(content) { } if (toolCalls.length > 0) { - const result = { role: "assistant" }; + const result: Record = { role: "assistant" }; if (parts.length > 0) { - result.content = parts.length === 1 ? parts[0].text : parts; + result.content = parts.length === 1 ? (parts[0] as unknown).text : parts; } result.tool_calls = toolCalls; return result; @@ -127,7 +128,10 @@ function convertGeminiContent(content) { if (parts.length > 0) { return { role, - content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts, + content: + parts.length === 1 && (parts[0] as unknown).type === "text" + ? (parts[0] as unknown).text + : parts, }; } @@ -135,10 +139,10 @@ function convertGeminiContent(content) { } // Extract text from Gemini content -function extractGeminiText(content) { +function extractGeminiText(content: unknown) { if (typeof content === "string") return content; if (content.parts && Array.isArray(content.parts)) { - return content.parts.map((p) => p.text || "").join(""); + return content.parts.map((p: unknown) => p.text || "").join(""); } return ""; } diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.ts similarity index 92% rename from open-sse/translator/request/openai-responses.js rename to open-sse/translator/request/openai-responses.ts index 94feabce..463477fc 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * Translator: OpenAI Responses API → OpenAI Chat Completions * @@ -7,17 +8,22 @@ import { FORMATS } from "../formats.js"; import { normalizeResponsesInput } from "../helpers/responsesApiHelper.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Responses API enforces max 64 chars on call_id (#393) const MAX_CALL_ID_LEN = 64; -const clampCallId = (id) => +const clampCallId = (id: unknown) => typeof id === "string" && id.length > MAX_CALL_ID_LEN ? id.substring(0, MAX_CALL_ID_LEN) : id; /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ -export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) { +export function openaiResponsesToOpenAIRequest( + model: unknown, + body: unknown, + _stream: unknown, + _credentials: unknown, +) { if (!body.input) return body; const result = { ...body }; @@ -29,8 +35,8 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) } // Group items by conversation turn - let currentAssistantMsg = null; - let pendingToolResults = []; + let currentAssistantMsg: unknown = null; + let pendingToolResults: unknown[] = []; const inputItems = normalizeResponsesInput(body.input); if (!inputItems) return body; @@ -56,7 +62,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // Convert content: input_text → text, output_text → text, input_image → image_url const content = Array.isArray(item.content) - ? item.content.map((c) => { + ? item.content.map((c: unknown) => { if (c.type === "input_text") return { type: "text", text: c.text }; if (c.type === "output_text") return { type: "text", text: c.text }; if (c.type === "input_image") { @@ -126,7 +132,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // such as Gemini, which strictly validates function names. if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools - .map((tool) => { + .map((tool: unknown) => { // Already in Chat Completions format: { type: "function", function: { name, ... } } if (tool.function) return tool; // Responses API function tool: { type: "function", name, description, parameters } @@ -158,7 +164,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) /** * Ensure object schema always has properties field (required by Codex Responses API) */ -function normalizeToolParameters(params) { +function normalizeToolParameters(params: unknown) { if (!params) return { type: "object", properties: {} }; if (params.type === "object" && !params.properties) return { ...params, properties: {} }; return params; @@ -167,13 +173,18 @@ function normalizeToolParameters(params) { /** * Convert OpenAI Chat Completions to OpenAI Responses API format */ -export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) { +export function openaiToOpenAIResponsesRequest( + model: unknown, + body: unknown, + _stream: unknown, + _credentials: unknown, +) { // Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[]) if (body.input) return { ...body, model, stream: true }; - const result = { + const result: Record = { model, - input: [], + input: [] as unknown[], stream: true, store: false, }; @@ -199,7 +210,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) typeof msg.content === "string" ? [{ type: contentType, text: msg.content }] : Array.isArray(msg.content) - ? msg.content.map((c) => { + ? msg.content.map((c: unknown) => { if (c.type === "text") return { type: contentType, text: c.text }; // Convert Chat Completions image_url → Responses API input_image // Responses API expects: { type: "input_image", image_url: "" } @@ -252,7 +263,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) typeof msg.content === "string" ? msg.content : Array.isArray(msg.content) - ? msg.content.map((c) => c.text || JSON.stringify(c)).join("") + ? msg.content.map((c: unknown) => c.text || JSON.stringify(c)).join("") : JSON.stringify(msg.content); result.input.push({ type: "function_call_output", @@ -267,7 +278,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // message role: assistant"). This can happen when a user message with empty content // is skipped (content.length === 0 above) or when the client sends malformed input. if (result.input.length > 1) { - const merged = [result.input[0]]; + const merged: unknown[] = [result.input[0]]; for (let i = 1; i < result.input.length; i++) { const prev = merged[merged.length - 1]; const curr = result.input[i]; @@ -290,7 +301,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Convert tools format if (body.tools && Array.isArray(body.tools)) { - result.tools = body.tools.map((tool) => { + result.tools = body.tools.map((tool: unknown) => { if (tool.type === "function") { return { type: "function", diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.ts similarity index 88% rename from open-sse/translator/request/openai-to-claude.js rename to open-sse/translator/request/openai-to-claude.ts index acb40b36..77f30e63 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.ts @@ -1,17 +1,18 @@ +// @ts-nocheck import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js"; import { FORMATS } from "../formats.js"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Empty prefix matches real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. const CLAUDE_OAUTH_TOOL_PREFIX = ""; // Convert OpenAI request to Claude format -export function openaiToClaudeRequest(model, body, stream) { +export function openaiToClaudeRequest(model: unknown, body: unknown, stream: unknown) { // Tool name mapping for Claude OAuth (capitalizedName → originalName) - const toolNameMap = new Map(); - const result = { + const toolNameMap = new Map(); + const result: Record = { model: model, max_tokens: adjustMaxTokens(body), stream: stream, @@ -24,7 +25,7 @@ export function openaiToClaudeRequest(model, body, stream) { // Messages result.messages = []; - const systemParts = []; + const systemParts: unknown[] = []; if (body.messages && Array.isArray(body.messages)) { // Extract system messages @@ -37,12 +38,12 @@ export function openaiToClaudeRequest(model, body, stream) { } // Filter out system messages for separate processing - const nonSystemMessages = body.messages.filter((m) => m.role !== "system"); + const nonSystemMessages = body.messages.filter((m: unknown) => m.role !== "system"); // Process messages with merging logic // CRITICAL: tool_result must be in separate message immediately after tool_use - let currentRole; - let currentParts = []; + let currentRole: unknown; + let currentParts: unknown[] = []; const flushCurrentMessage = () => { if (currentRole && currentParts.length > 0) { @@ -54,13 +55,13 @@ export function openaiToClaudeRequest(model, body, stream) { for (const msg of nonSystemMessages) { const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; const blocks = getContentBlocksFromMessage(msg, toolNameMap); - const hasToolUse = blocks.some((b) => b.type === "tool_use"); - const hasToolResult = blocks.some((b) => b.type === "tool_result"); + const hasToolUse = blocks.some((b: unknown) => b.type === "tool_use"); + const hasToolResult = blocks.some((b: unknown) => b.type === "tool_result"); // Separate tool_result from other content if (hasToolResult) { - const toolResultBlocks = blocks.filter((b) => b.type === "tool_result"); - const otherBlocks = blocks.filter((b) => b.type !== "tool_result"); + const toolResultBlocks = blocks.filter((b: unknown) => b.type === "tool_result"); + const otherBlocks = blocks.filter((b: unknown) => b.type !== "tool_result"); flushCurrentMessage(); @@ -192,7 +193,7 @@ Respond ONLY with the JSON object, no other text.`); // When client sends reasoning_effort (OpenAI format) but no explicit thinking block, // translate to Claude's native format. if (body.reasoning_effort && !result.thinking) { - const effortToBudget = { + const effortToBudget: Record = { none: 0, low: 4096, medium: 8192, @@ -217,8 +218,8 @@ Respond ONLY with the JSON object, no other text.`); } // Get content blocks from single message -function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { - const blocks = []; +function getContentBlocksFromMessage(msg: unknown, _toolNameMap: unknown = new Map()) { + const blocks: unknown[] = []; if (msg.role === "tool") { blocks.push({ @@ -276,7 +277,7 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { }); } else if (part.type === "thinking") { // Include thinking block but strip cache_control (not allowed on thinking blocks) - const { cache_control, ...thinkingBlock } = part; + const { cache_control: _cache_control, ...thinkingBlock } = part; blocks.push(thinkingBlock); } } @@ -307,7 +308,7 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { } // Convert OpenAI tool choice to Claude format -function convertOpenAIToolChoice(choice) { +function convertOpenAIToolChoice(choice: unknown) { if (!choice) return { type: "auto" }; if (typeof choice === "object" && choice.type) return choice; if (choice === "auto" || choice === "none") return { type: "auto" }; @@ -319,19 +320,19 @@ function convertOpenAIToolChoice(choice) { } // Extract text from content -function extractTextContent(content) { +function extractTextContent(content: unknown) { if (typeof content === "string") return content; if (Array.isArray(content)) { return content - .filter((c) => c.type === "text") - .map((c) => c.text) + .filter((c: unknown) => c.type === "text") + .map((c: unknown) => c.text) .join("\n"); } return ""; } // Try parse JSON -function tryParseJSON(str) { +function tryParseJSON(str: unknown) { if (typeof str !== "string") return str; try { return JSON.parse(str); @@ -344,7 +345,7 @@ function tryParseJSON(str) { // Currently scoped to the Read tool's `pages` field (used for PDF page ranges) — if pages // arrives as "" or whitespace, drop it instead of letting Claude reject the entire tool call. // Coerce numeric string bounds for limit/offset so they can be clamped downstream. -function sanitizeToolArguments(toolName, input) { +function sanitizeToolArguments(toolName: unknown, input: unknown) { if (!input || typeof input !== "object") return input; const baseName = toolName?.startsWith(CLAUDE_OAUTH_TOOL_PREFIX) ? toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length) @@ -355,7 +356,7 @@ function sanitizeToolArguments(toolName, input) { // Drop empty/whitespace `pages` — it's optional and only meaningful for PDF. if (typeof out.pages === "string" && out.pages.trim() === "") { delete out.pages; - } else if (out.pages == null) { + } else if (out.pages === null || out.pages === undefined) { delete out.pages; } // Coerce numeric string bounds so Claude's clamp doesn't trip on "100" vs 100. @@ -369,13 +370,13 @@ function sanitizeToolArguments(toolName, input) { } // OpenAI -> Claude format for Antigravity (without system prompt modifications) -function openaiToClaudeRequestForAntigravity(model, body, stream) { +function openaiToClaudeRequestForAntigravity(model: unknown, body: unknown, stream: unknown) { const result = openaiToClaudeRequest(model, body, stream); // Remove Claude Code system prompt, keep only user's system messages if (result.system && Array.isArray(result.system)) { result.system = result.system.filter( - (block) => !block.text || !block.text.includes("You are Claude Code"), + (block: unknown) => !block.text || !block.text.includes("You are Claude Code"), ); if (result.system.length === 0) { delete result.system; @@ -384,7 +385,7 @@ function openaiToClaudeRequestForAntigravity(model, body, stream) { // Strip prefix from tool names for Antigravity (doesn't use Claude OAuth) if (result.tools && Array.isArray(result.tools)) { - result.tools = result.tools.map((tool) => { + result.tools = result.tools.map((tool: unknown) => { if (tool.name && tool.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { return { ...tool, @@ -397,12 +398,12 @@ function openaiToClaudeRequestForAntigravity(model, body, stream) { // Strip prefix from tool_use in messages if (result.messages && Array.isArray(result.messages)) { - result.messages = result.messages.map((msg) => { + result.messages = result.messages.map((msg: unknown) => { if (!msg.content || !Array.isArray(msg.content)) { return msg; } - const updatedContent = msg.content.map((block) => { + const updatedContent = msg.content.map((block: unknown) => { if ( block.type === "tool_use" && block.name && diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.ts similarity index 84% rename from open-sse/translator/request/openai-to-commandcode.js rename to open-sse/translator/request/openai-to-commandcode.ts index 2f35eec7..7bd42fe0 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * OpenAI to CommandCode request translator * @@ -12,13 +13,13 @@ import { randomUUID } from "node:crypto"; import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; -function flattenText(content) { - if (content == null) return ""; +function flattenText(content: unknown) { + if (content === null || content === undefined) return ""; if (typeof content === "string") return content; if (Array.isArray(content)) { - const parts = []; + const parts: unknown[] = []; for (const p of content) { if (typeof p === "string") parts.push(p); else if (p && typeof p === "object" && typeof p.text === "string") parts.push(p.text); @@ -28,11 +29,11 @@ function flattenText(content) { return String(content); } -function toContentBlocks(content) { - if (content == null) return [{ type: "text", text: "" }]; +function toContentBlocks(content: unknown) { + if (content === null || content === undefined) return [{ type: "text", text: "" }]; if (typeof content === "string") return [{ type: "text", text: content }]; if (Array.isArray(content)) { - const blocks = []; + const blocks: unknown[] = []; for (const part of content) { if (typeof part === "string") { blocks.push({ type: "text", text: part }); @@ -51,8 +52,8 @@ function toContentBlocks(content) { return [{ type: "text", text: String(content) }]; } -function safeParseJson(s) { - if (s == null) return {}; +function safeParseJson(s: unknown) { + if (s === null || s === undefined) return {}; if (typeof s !== "string") return s; try { return JSON.parse(s); @@ -61,9 +62,9 @@ function safeParseJson(s) { } } -function convertMessages(messages = []) { - const out = []; - const systemTexts = []; +function convertMessages(messages: unknown = []) { + const out: unknown[] = []; + const systemTexts: unknown[] = []; for (const m of messages) { if (!m) continue; @@ -119,9 +120,9 @@ function convertMessages(messages = []) { return { messages: out, system: systemTexts.join("\n\n") }; } -function convertTools(tools) { +function convertTools(tools: unknown) { if (!Array.isArray(tools) || tools.length === 0) return undefined; - const result = []; + const result: unknown[] = []; for (const t of tools) { if (!t) continue; if (t.type === "function" && t.function) { @@ -141,9 +142,13 @@ function convertTools(tools) { return result.length ? result : undefined; } -export function openaiToCommandCode(model, body, stream /* , credentials */) { +export function openaiToCommandCode( + model: unknown, + body: unknown, + stream: unknown /* , credentials */, +) { const { messages, system } = convertMessages(body.messages); - const params = { + const params: Record = { model, messages, stream: stream !== false, @@ -155,7 +160,7 @@ export function openaiToCommandCode(model, body, stream /* , credentials */) { const tools = convertTools(body.tools); if (tools) params.tools = tools; - if (body.top_p != null) params.top_p = body.top_p; + if (body.top_p !== null && body.top_p !== undefined) params.top_p = body.top_p; const today = new Date().toISOString().slice(0, 10); diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.ts similarity index 75% rename from open-sse/translator/request/openai-to-cursor.js rename to open-sse/translator/request/openai-to-cursor.ts index ba486817..4ead08ab 100644 --- a/open-sse/translator/request/openai-to-cursor.js +++ b/open-sse/translator/request/openai-to-cursor.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * OpenAI to Cursor Request Translator * Converts OpenAI messages to Cursor ask/agent format. @@ -8,32 +9,39 @@ */ import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; -function extractContent(content) { +function extractContent(content: unknown) { if (typeof content === "string") return content; if (Array.isArray(content)) { return content - .filter((part) => { + .filter((part: unknown) => { if (!part || typeof part !== "object") return false; return part.type === "text" && typeof part.text === "string"; }) - .map((part) => part.text || "") + .map((part: unknown) => part.text || "") .join(""); } return ""; } -function sanitizeToolResultText(text) { - // Strip non-printable control chars that can produce backend request errors - return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); +function sanitizeToolResultText(text: unknown) { + // Strip non-printable control chars that can produce backend request errors. + let clean = ""; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code > 8 && code !== 11 && code !== 12 && (code < 14 || code > 31) && code !== 127) { + clean += text[i]; + } + } + return clean; } -function escapeXml(text) { +function escapeXml(text: unknown) { return text.replace(/&/g, "&").replace(//g, ">"); } -function buildToolResultBlock(toolName, toolCallId, resultText) { +function buildToolResultBlock(toolName: unknown, toolCallId: unknown, resultText: unknown) { const cleanResult = sanitizeToolResultText(resultText || ""); return [ "", @@ -44,16 +52,16 @@ function buildToolResultBlock(toolName, toolCallId, resultText) { ].join("\n"); } -function normalizeToolCallId(id) { +function normalizeToolCallId(id: unknown) { return typeof id === "string" ? id.split("\n")[0] : ""; } -function convertMessages(messages) { - const result = []; +function convertMessages(messages: unknown) { + const result: unknown[] = []; // Build a map of tool_call_id -> tool name from assistant tool calls - const toolCallMetaMap = new Map(); - const rememberToolMeta = (toolCallId, toolName) => { + const toolCallMetaMap = new Map(); + const rememberToolMeta = (toolCallId: unknown, toolName: unknown) => { if (!toolCallId) return; const name = toolName || "tool"; toolCallMetaMap.set(toolCallId, { name }); @@ -102,7 +110,7 @@ function convertMessages(messages) { if (msg.role === "user" || msg.role === "assistant") { if (msg.role === "user" && Array.isArray(msg.content)) { - const parts = []; + const parts: unknown[] = []; for (const block of msg.content) { if (!block || typeof block !== "object") continue; if (block.type === "text") { @@ -129,16 +137,16 @@ function convertMessages(messages) { const content = extractContent(msg.content); if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { - const assistantMsg = { role: "assistant", content: content || "" }; - assistantMsg.tool_calls = msg.tool_calls.map((tc) => { - const { index, ...rest } = tc || {}; + const assistantMsg: Record = { role: "assistant", content: content || "" }; + assistantMsg.tool_calls = msg.tool_calls.map((tc: unknown) => { + const { index: _index, ...rest } = tc || {}; return rest; }); result.push(assistantMsg); } else if (msg.role === "assistant" && Array.isArray(msg.content)) { const extractedToolCalls = msg.content - .filter((b) => b?.type === "tool_use") - .map((b) => ({ + .filter((b: unknown) => b?.type === "tool_use") + .map((b: unknown) => ({ id: b.id || "", type: "function", function: { @@ -146,7 +154,7 @@ function convertMessages(messages) { arguments: JSON.stringify(b.input || {}), }, })) - .filter((tc) => tc.id); + .filter((tc: unknown) => tc.id); if (extractedToolCalls.length > 0) { result.push({ @@ -168,11 +176,23 @@ function convertMessages(messages) { return result; } -export function buildCursorRequest(model, body, stream, credentials) { +export function buildCursorRequest( + model: unknown, + body: unknown, + _stream: unknown, + _credentials: unknown, +) { const messages = convertMessages(body.messages || []); // Strip fields irrelevant to Cursor (OpenAI/Anthropic-specific) - const { user, metadata, tool_choice, stream_options, system, ...rest } = body; + const { + user: _user, + metadata: _metadata, + tool_choice: _tool_choice, + stream_options: _stream_options, + system: _system, + ...rest + } = body; return { ...rest, diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.ts similarity index 88% rename from open-sse/translator/request/openai-to-gemini.js rename to open-sse/translator/request/openai-to-gemini.ts index e206fc1b..068df95e 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -1,10 +1,11 @@ +// @ts-nocheck import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js"; import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE, } from "../../config/defaultThinkingSignature.js"; import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js"; function generateUUID() { @@ -26,7 +27,7 @@ import { // Sanitize function names for Gemini API. // Gemini requires: starts with [a-zA-Z_], followed by [a-zA-Z0-9_.:\-], max 64 chars. // Replace any invalid character with '_' and truncate to 64. -function sanitizeGeminiFunctionName(name) { +function sanitizeGeminiFunctionName(name: unknown) { if (!name) return "_unknown"; // Replace any char not in [a-zA-Z0-9_.:\-] with '_' let sanitized = name.replace(/[^a-zA-Z0-9_.:-]/g, "_"); @@ -39,11 +40,16 @@ function sanitizeGeminiFunctionName(name) { } // Core: Convert OpenAI request to Gemini format (base for all variants) -function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) { - const result = { +function openaiToGeminiBase( + model: unknown, + body: unknown, + stream: unknown, + signature: unknown = DEFAULT_THINKING_AG_SIGNATURE, +) { + const result: Record = { model: model, - contents: [], - generationConfig: {}, + contents: [] as unknown[], + generationConfig: {} as Record, safetySettings: DEFAULT_SAFETY_SETTINGS, }; @@ -62,7 +68,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } // Build tool_call_id -> name map - const tcID2Name = {}; + const tcID2Name: Record = {}; if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { if (msg.role === "assistant" && msg.tool_calls) { @@ -76,7 +82,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } // Build tool responses cache - const toolResponses = {}; + const toolResponses: Record = {}; if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { if (msg.role === "tool" && msg.tool_call_id) { @@ -88,7 +94,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG // Convert messages if (body.messages && Array.isArray(body.messages)) { for (let i = 0; i < body.messages.length; i++) { - const msg = body.messages[i]; + const msg = body.messages[i] as unknown; const role = msg.role; const content = msg.content; @@ -103,7 +109,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG result.contents.push({ role: "user", parts }); } } else if (role === "assistant") { - const parts = []; + const parts: unknown[] = []; // Thinking/reasoning → thought part with signature if (msg.reasoning_content) { @@ -125,7 +131,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - const toolCallIds = []; + const toolCallIds: unknown[] = []; for (const tc of msg.tool_calls) { if (tc.type !== "function") continue; @@ -146,10 +152,10 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } // Check if there are actual tool responses in the next messages - const hasActualResponses = toolCallIds.some((fid) => toolResponses[fid]); + const hasActualResponses = toolCallIds.some((fid: unknown) => toolResponses[fid]); if (hasActualResponses) { - const toolParts = []; + const toolParts: unknown[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; @@ -192,7 +198,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG // Convert tools if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { - const functionDeclarations = []; + const functionDeclarations: unknown[] = []; for (const t of body.tools) { // Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema) if (t.name && t.input_schema) { @@ -228,18 +234,24 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } // OpenAI -> Gemini (standard API) -export function openaiToGeminiRequest(model, body, stream) { +export function openaiToGeminiRequest(model: unknown, body: unknown, stream: unknown) { return openaiToGeminiBase(model, body, stream); } // OpenAI -> Gemini CLI (Cloud Code Assist) -export function openaiToGeminiCLIRequest(model, body, stream) { +export function openaiToGeminiCLIRequest(model: unknown, body: unknown, stream: unknown) { const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE); const _isClaude = model.toLowerCase().includes("claude"); // Add thinking config for CLI if (body.reasoning_effort) { - const budgetMap = { low: 1024, medium: 8192, high: 32768, xhigh: 32768, max: 65536 }; + const budgetMap: Record = { + low: 1024, + medium: 8192, + high: 32768, + xhigh: 32768, + max: 65536, + }; const budget = budgetMap[body.reasoning_effort] || 8192; gemini.generationConfig.thinkingConfig = { thinkingBudget: budget, @@ -275,10 +287,15 @@ export function openaiToGeminiCLIRequest(model, body, stream) { } // Wrap Gemini CLI format in Cloud Code wrapper -function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) { +function wrapInCloudCodeEnvelope( + model: unknown, + geminiCLI: unknown, + credentials: unknown = null, + isAntigravity: unknown = false, +) { const projectId = credentials?.projectId || generateProjectId(); - const envelope = { + const envelope: Record = { project: projectId, model: model, userAgent: isAntigravity ? "antigravity" : "gemini-cli", @@ -326,10 +343,14 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } // Wrap Claude format in Cloud Code envelope for Antigravity -function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) { +function wrapInCloudCodeEnvelopeForClaude( + model: unknown, + claudeRequest: unknown, + credentials: unknown = null, +) { const projectId = credentials?.projectId || generateProjectId(); - const envelope = { + const envelope: Record = { project: projectId, model: model, userAgent: "antigravity", @@ -346,7 +367,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu }; // Build tool_use id -> name map so functionResponse can use the correct name - const toolUseIdToName = {}; + const toolUseIdToName: Record = {}; if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { for (const msg of claudeRequest.messages) { if (Array.isArray(msg.content)) { @@ -362,7 +383,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Convert Claude messages to Gemini contents if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { for (const msg of claudeRequest.messages) { - const parts = []; + const parts: unknown[] = []; if (Array.isArray(msg.content)) { for (const block of msg.content) { @@ -380,7 +401,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu let content = block.content; if (Array.isArray(content)) { content = content - .map((c) => (c.type === "text" ? c.text : JSON.stringify(c))) + .map((c: unknown) => (c.type === "text" ? c.text : JSON.stringify(c))) .join("\n"); } // Resolve the original tool name from the id — Gemini requires it to match the functionCall name @@ -411,7 +432,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Convert Claude tools to Gemini functionDeclarations if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const functionDeclarations = []; + const functionDeclarations: unknown[] = []; for (const tool of claudeRequest.tools) { if (tool.name && tool.input_schema) { const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema); @@ -431,7 +452,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } // Add system instruction (Antigravity default - double injection + user system prompt) - const systemParts = [ + const systemParts: unknown[] = [ { text: ANTIGRAVITY_DEFAULT_SYSTEM }, { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }, ]; @@ -459,12 +480,17 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu // Detect if model should use Claude backend in Antigravity // Claude models have specific ID patterns — more reliable than caps at routing level -function isClaudeModel(model) { +function isClaudeModel(model: unknown) { return model.toLowerCase().includes("claude"); } // OpenAI -> Antigravity (Sandbox Cloud Code with wrapper) -export function openaiToAntigravityRequest(model, body, stream, credentials = null) { +export function openaiToAntigravityRequest( + model: unknown, + body: unknown, + stream: unknown, + credentials: unknown = null, +) { if (isClaudeModel(model)) { const claudeRequest = openaiToClaudeRequestForAntigravity(model, body, stream); return wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials); @@ -479,7 +505,7 @@ register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null); register( FORMATS.OPENAI, FORMATS.GEMINI_CLI, - (model, body, stream, credentials) => + (model: unknown, body: unknown, stream: unknown, credentials: unknown) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null, ); diff --git a/open-sse/translator/request/openai-to-kiro.old.js b/open-sse/translator/request/openai-to-kiro.old.js deleted file mode 100644 index 4a5edac2..00000000 --- a/open-sse/translator/request/openai-to-kiro.old.js +++ /dev/null @@ -1,301 +0,0 @@ -/** - * OpenAI to Kiro Request Translator - * Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format - */ - -import { v4 as uuidv4 } from "uuid"; -import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; - -/** - * Convert OpenAI messages to Kiro format - */ -function convertMessages(messages, tools, model) { - let history = []; - let currentMessage = null; - let systemPrompt = ""; - - const toolResultsMap = new Map(); - - for (const msg of messages) { - if (msg.role === "tool" && msg.tool_call_id) { - const content = - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map((c) => c.text || "").join("\n") - : ""; - toolResultsMap.set(msg.tool_call_id, content); - } - - if (msg.role === "user" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_result" && block.tool_use_id) { - const content = Array.isArray(block.content) - ? block.content.map((c) => c.text || "").join("\n") - : typeof block.content === "string" - ? block.content - : ""; - toolResultsMap.set(block.tool_use_id, content); - } - } - } - } - - for (const msg of messages) { - const role = msg.role; - - if (role === "tool") continue; - - const content = - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map((c) => c.text || "").join("\n") - : ""; - - if (role === "system") { - systemPrompt += (systemPrompt ? "\n" : "") + content; - continue; - } - - if (role === "user") { - let finalContent = content; - let toolResults = []; - - // Check if this user message contains tool_result blocks - if (Array.isArray(msg.content)) { - const toolResultBlocks = msg.content.filter((c) => c.type === "tool_result"); - if (toolResultBlocks.length > 0) { - toolResults = toolResultBlocks.map((block) => { - const text = Array.isArray(block.content) - ? block.content.map((c) => c.text || "").join("\n") - : typeof block.content === "string" - ? block.content - : ""; - - return { - toolUseId: block.tool_use_id, - status: "success", - content: [{ text: text }], - }; - }); - - // Set simple content when tool results exist - finalContent = content || "Continue"; - } - } - - const userMsg = { - userInputMessage: { - content: finalContent, - modelId: "", - }, - }; - - // Add tool results to userInputMessageContext - if (toolResults.length > 0) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.toolResults = toolResults; - } - - // Add tools to first user message - if (tools && tools.length > 0 && history.length === 0) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map((t) => { - const name = t.function?.name || t.name; - let description = t.function?.description || t.description || ""; - - if (!description.trim()) { - description = `Tool: ${name}`; - } - - return { - toolSpecification: { - name, - description, - inputSchema: { - json: t.function?.parameters || t.parameters || t.input_schema || {}, - }, - }, - }; - }); - } - - currentMessage = userMsg; - history.push(userMsg); - } - - if (role === "assistant") { - // Extract text content and tool uses separately from content array - let textContent = ""; - let toolUses = []; - - if (Array.isArray(msg.content)) { - const textBlocks = msg.content.filter((c) => c.type === "text"); - textContent = textBlocks - .map((b) => b.text) - .join("\n") - .trim(); - - const toolUseBlocks = msg.content.filter((c) => c.type === "tool_use"); - toolUses = toolUseBlocks; - } else if (typeof msg.content === "string") { - textContent = msg.content.trim(); - } - - // Fallback for OpenAI tool_calls format - if (msg.tool_calls && msg.tool_calls.length > 0) { - toolUses = msg.tool_calls; - } - - const assistantMsg = { - assistantResponseMessage: { - content: textContent || "Call tools", - }, - }; - - if (toolUses.length > 0) { - assistantMsg.assistantResponseMessage.toolUses = toolUses.map((tc) => { - if (tc.function) { - // OpenAI format - return { - toolUseId: tc.id || uuidv4(), - name: tc.function.name, - input: - typeof tc.function.arguments === "string" - ? JSON.parse(tc.function.arguments) - : tc.function.arguments || {}, - }; - } else { - // Anthropic format - return { - toolUseId: tc.id || uuidv4(), - name: tc.name, - input: tc.input || {}, - }; - } - }); - } - - history.push(assistantMsg); - } - } - - // If last message in history is userInputMessage, use it as currentMessage - if (history.length > 0 && history[history.length - 1].userInputMessage) { - currentMessage = history.pop(); - } - - const firstHistoryItem = history[0]; - if ( - firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools && - !currentMessage?.userInputMessage?.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryItem.userInputMessage.userInputMessageContext.tools; - } - - // Clean up history for Kiro API compatibility - history.forEach((item) => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } - - if ( - item.userInputMessage?.userInputMessageContext && - Object.keys(item.userInputMessage.userInputMessageContext).length === 0 - ) { - delete item.userInputMessage.userInputMessageContext; - } - - if (item.userInputMessage && !item.userInputMessage.modelId) { - item.userInputMessage.modelId = model; - } - }); - - // Merge consecutive user messages (Kiro requires alternating user/assistant) - const mergedHistory = []; - for (let i = 0; i < history.length; i++) { - const current = history[i]; - - if ( - current.userInputMessage && - mergedHistory.length > 0 && - mergedHistory[mergedHistory.length - 1].userInputMessage - ) { - const prev = mergedHistory[mergedHistory.length - 1]; - prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; - } else { - mergedHistory.push(current); - } - } - history = mergedHistory; - - return { history, currentMessage, systemPrompt }; -} - -/** - * Build Kiro payload from OpenAI format - */ -function buildKiroPayload(model, body, stream, credentials) { - const messages = body.messages || []; - const tools = body.tools || []; - const maxTokens = 32000; - const temperature = body.temperature; - const topP = body.top_p; - - const { history, currentMessage, systemPrompt } = convertMessages(messages, tools, model); - - const profileArn = credentials?.providerSpecificData?.profileArn || ""; - - let finalContent = currentMessage?.userInputMessage?.content || ""; - if (systemPrompt) { - finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`; - } - - const timestamp = new Date().toISOString(); - finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`; - - const payload = { - conversationState: { - chatTriggerType: "MANUAL", - conversationId: uuidv4(), - currentMessage: { - userInputMessage: { - content: finalContent, - modelId: model, - origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext, - }), - }, - }, - history: history, - }, - }; - - if (profileArn) { - payload.profileArn = profileArn; - } - - if (maxTokens || temperature !== undefined || topP !== undefined) { - payload.inferenceConfig = {}; - if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens; - if (temperature !== undefined) payload.inferenceConfig.temperature = temperature; - if (topP !== undefined) payload.inferenceConfig.topP = topP; - } - - return payload; -} - -register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null); - -export { buildKiroPayload }; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.ts similarity index 89% rename from open-sse/translator/request/openai-to-kiro.js rename to open-sse/translator/request/openai-to-kiro.ts index a7950ded..79c504c5 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * OpenAI to Kiro Request Translator * Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format @@ -5,21 +6,21 @@ import { v4 as uuidv4 } from "uuid"; import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Convert OpenAI messages to Kiro format * Rules: system/tool/user -> user role, merge consecutive same roles */ -function convertMessages(messages, tools, model) { - const history = []; - let currentMessage = null; +function convertMessages(messages: unknown, tools: unknown, model: unknown) { + const history: unknown[] = []; + let currentMessage: unknown = null; - let pendingUserContent = []; - let pendingAssistantContent = []; - let pendingToolResults = []; - let pendingImages = []; - let currentRole = null; + let pendingUserContent: unknown[] = []; + let pendingAssistantContent: unknown[] = []; + let pendingToolResults: unknown[] = []; + let pendingImages: unknown[] = []; + let currentRole: unknown = null; // Image support is pre-filtered by caps in translateRequest before reaching here const supportsImages = true; @@ -27,7 +28,7 @@ function convertMessages(messages, tools, model) { const flushPending = () => { if (currentRole === "user") { const content = pendingUserContent.join("\n\n").trim() || "continue"; - const userMsg = { + const userMsg: Record = { userInputMessage: { content: content, modelId: "", @@ -50,7 +51,7 @@ function convertMessages(messages, tools, model) { if (!userMsg.userInputMessage.userInputMessageContext) { userMsg.userInputMessage.userInputMessageContext = {}; } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map((t) => { + userMsg.userInputMessage.userInputMessageContext.tools = tools.map((t: unknown) => { const name = t.function?.name || t.name; let description = t.function?.description || t.description || ""; @@ -141,11 +142,11 @@ function convertMessages(messages, tools, model) { content = textParts.join("\n"); // Check for tool_result blocks - const toolResultBlocks = msg.content.filter((c) => c.type === "tool_result"); + const toolResultBlocks = msg.content.filter((c: unknown) => c.type === "tool_result"); if (toolResultBlocks.length > 0) { - toolResultBlocks.forEach((block) => { + toolResultBlocks.forEach((block: unknown) => { const text = Array.isArray(block.content) - ? block.content.map((c) => c.text || "").join("\n") + ? block.content.map((c: unknown) => c.text || "").join("\n") : typeof block.content === "string" ? block.content : ""; @@ -173,16 +174,16 @@ function convertMessages(messages, tools, model) { } else if (role === "assistant") { // Extract text content and tool uses let textContent = ""; - let toolUses = []; + let toolUses: unknown[] = []; if (Array.isArray(msg.content)) { - const textBlocks = msg.content.filter((c) => c.type === "text"); + const textBlocks = msg.content.filter((c: unknown) => c.type === "text"); textContent = textBlocks - .map((b) => b.text) + .map((b: unknown) => b.text) .join("\n") .trim(); - const toolUseBlocks = msg.content.filter((c) => c.type === "tool_use"); + const toolUseBlocks = msg.content.filter((c: unknown) => c.type === "tool_use"); toolUses = toolUseBlocks; } else if (typeof msg.content === "string") { textContent = msg.content.trim(); @@ -207,7 +208,7 @@ function convertMessages(messages, tools, model) { const lastMsg = history[history.length - 1]; if (lastMsg?.assistantResponseMessage) { - lastMsg.assistantResponseMessage.toolUses = toolUses.map((tc) => { + lastMsg.assistantResponseMessage.toolUses = toolUses.map((tc: unknown) => { if (tc.function) { return { toolUseId: tc.id || uuidv4(), @@ -249,7 +250,7 @@ function convertMessages(messages, tools, model) { const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools; // Clean up history for Kiro API compatibility - history.forEach((item) => { + history.forEach((item: unknown) => { if (item.userInputMessage?.userInputMessageContext?.tools) { delete item.userInputMessage.userInputMessageContext.tools; } @@ -265,7 +266,7 @@ function convertMessages(messages, tools, model) { }); // Merge consecutive user messages (Kiro requires alternating user/assistant) - const mergedHistory = []; + const mergedHistory: unknown[] = []; for (let i = 0; i < history.length; i++) { const current = history[i]; if ( @@ -298,7 +299,12 @@ function convertMessages(messages, tools, model) { /** * Build Kiro payload from OpenAI format */ -export function buildKiroPayload(model, body, stream, credentials) { +export function buildKiroPayload( + model: unknown, + body: unknown, + stream: unknown, + credentials: unknown, +) { const messages = body.messages || []; const tools = body.tools || []; const maxTokens = 32000; @@ -313,7 +319,7 @@ export function buildKiroPayload(model, body, stream, credentials) { const timestamp = new Date().toISOString(); finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`; - const payload = { + const payload: Record = { conversationState: { chatTriggerType: "MANUAL", conversationId: uuidv4(), diff --git a/open-sse/translator/request/openai-to-ollama.js b/open-sse/translator/request/openai-to-ollama.ts similarity index 86% rename from open-sse/translator/request/openai-to-ollama.js rename to open-sse/translator/request/openai-to-ollama.ts index 4a007af3..dd5c4a8b 100644 --- a/open-sse/translator/request/openai-to-ollama.js +++ b/open-sse/translator/request/openai-to-ollama.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Convert OpenAI request to Ollama format @@ -15,8 +16,8 @@ import { register } from "../index.js"; * - Multimodal images should be mapped to `message.images[]` (raw base64, no data: prefix) * - tool role maps to tool (Ollama supports tool messages) */ -export function openaiToOllamaRequest(model, body, stream) { - const result = { +export function openaiToOllamaRequest(model: unknown, body: unknown, stream: unknown) { + const result: Record = { model: model, messages: normalizeMessages(body.messages), stream: stream, @@ -59,11 +60,11 @@ export function openaiToOllamaRequest(model, body, stream) { * - tool messages: convert tool_call_id to tool_name * - assistant messages: keep tool_calls as-is */ -function normalizeMessages(messages) { +function normalizeMessages(messages: unknown) { if (!Array.isArray(messages)) return messages; - const result = []; - const toolCallMap = new Map(); // Map tool_call_id -> tool_name + const result: unknown[] = []; + const toolCallMap = new Map(); // Map tool_call_id -> tool_name // First pass: build tool_call_id -> tool_name map from assistant messages for (const msg of messages) { @@ -99,7 +100,7 @@ function normalizeMessages(messages) { const content = normalizeContent(msg.content) || ""; // Convert OpenAI tool_calls format to Ollama format - const ollamaToolCalls = msg.tool_calls.map((tc) => ({ + const ollamaToolCalls = msg.tool_calls.map((tc: unknown) => ({ type: "function", function: { index: tc.index || 0, @@ -127,7 +128,7 @@ function normalizeMessages(messages) { // Skip empty messages (except assistant) if (!content && role !== "assistant") continue; - const out = { + const out: Record = { role: role, content: content, }; @@ -146,7 +147,7 @@ function normalizeMessages(messages) { * Normalize content to string * Ollama only accepts string content */ -function normalizeContent(content) { +function normalizeContent(content: unknown) { if (typeof content === "string") { return content; } @@ -154,8 +155,8 @@ function normalizeContent(content) { if (Array.isArray(content)) { // Extract text from content array const textParts = content - .filter((block) => block && block.type === "text" && block.text) - .map((block) => block.text); + .filter((block: unknown) => block && block.type === "text" && block.text) + .map((block: unknown) => block.text); return textParts.join("\n") || ""; } @@ -169,10 +170,10 @@ function normalizeContent(content) { * { type: "image_url", image_url: { url: "data:image/png;base64,..." } } * Ollama expects raw base64 strings in message.images[]. */ -function extractImagesFromContent(content) { +function extractImagesFromContent(content: unknown) { if (!Array.isArray(content)) return []; - const images = []; + const images: unknown[] = []; for (const block of content) { if (!block || block.type !== "image_url") continue; diff --git a/open-sse/translator/request/openai-to-vertex.js b/open-sse/translator/request/openai-to-vertex.ts similarity index 78% rename from open-sse/translator/request/openai-to-vertex.js rename to open-sse/translator/request/openai-to-vertex.ts index 8ac62d34..444041b9 100644 --- a/open-sse/translator/request/openai-to-vertex.js +++ b/open-sse/translator/request/openai-to-vertex.ts @@ -1,6 +1,7 @@ +// @ts-nocheck import { DEFAULT_THINKING_VERTEX_SIGNATURE } from "../../config/defaultThinkingSignature.js"; import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; import { openaiToGeminiRequest } from "./openai-to-gemini.js"; /** @@ -9,7 +10,7 @@ import { openaiToGeminiRequest } from "./openai-to-gemini.js"; * 1. Replace all synthetic thoughtSignatures with Vertex-native signature. * 2. Strip `id` from functionCall and functionResponse (Vertex rejects these). */ -function postProcessForVertex(body) { +function postProcessForVertex(body: unknown) { if (!body?.contents) return body; for (const turn of body.contents) { @@ -34,8 +35,14 @@ function postProcessForVertex(body) { return body; } -export function openaiToVertexRequest(model, body, stream, credentials) { - const gemini = openaiToGeminiRequest(model, body, stream, credentials); +export function openaiToVertexRequest( + model: unknown, + body: unknown, + stream: unknown, + credentials: unknown, +) { + // todo(ts): request translator registry may pass credentials to JS-era translators. + const gemini = (openaiToGeminiRequest as unknown)(model, body, stream, credentials); const processed = postProcessForVertex(gemini); // Vertex AI does not accept `stream` in the request body — streaming is // controlled via the action suffix (:streamGenerateContent) and ?alt=sse. diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.ts similarity index 96% rename from open-sse/translator/response/claude-to-openai.js rename to open-sse/translator/response/claude-to-openai.ts index 48f90531..90f0bc4b 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.ts @@ -1,8 +1,9 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Create OpenAI chunk helper -function createChunk(state, delta, finishReason = null) { +function createChunk(state: unknown, delta: unknown, finishReason: unknown = null) { return { id: `chatcmpl-${state.messageId}`, object: "chat.completion.chunk", @@ -19,7 +20,7 @@ function createChunk(state, delta, finishReason = null) { } // Convert Claude stream chunk to OpenAI format -export function claudeToOpenAIResponse(chunk, state) { +export function claudeToOpenAIResponse(chunk: unknown, state: unknown) { if (!chunk) return null; const results = []; @@ -143,7 +144,7 @@ export function claudeToOpenAIResponse(chunk, state) { if (chunk.delta?.stop_reason) { state.finishReason = convertStopReason(chunk.delta.stop_reason); - const finalChunk = { + const finalChunk: Record = { id: `chatcmpl-${state.messageId}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), @@ -212,7 +213,7 @@ export function claudeToOpenAIResponse(chunk, state) { } // Convert Claude stop_reason to OpenAI finish_reason -function convertStopReason(reason) { +function convertStopReason(reason: unknown) { switch (reason) { case "end_turn": return "stop"; diff --git a/open-sse/translator/response/commandcode-to-openai.js b/open-sse/translator/response/commandcode-to-openai.ts similarity index 92% rename from open-sse/translator/response/commandcode-to-openai.js rename to open-sse/translator/response/commandcode-to-openai.ts index 8df95fef..2dc55da8 100644 --- a/open-sse/translator/response/commandcode-to-openai.js +++ b/open-sse/translator/response/commandcode-to-openai.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * CommandCode to OpenAI response translator * @@ -17,9 +18,9 @@ */ import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; -function ensureState(state, model) { +function ensureState(state: unknown, model: unknown) { if (!state.responseId) { state.responseId = `chatcmpl-${Date.now()}`; state.created = Math.floor(Date.now() / 1000); @@ -34,7 +35,7 @@ function ensureState(state, model) { } } -function makeChunk(state, delta, finishReason = null) { +function makeChunk(state: unknown, delta: unknown, finishReason: unknown = null) { return { id: state.responseId, object: "chat.completion.chunk", @@ -44,7 +45,7 @@ function makeChunk(state, delta, finishReason = null) { }; } -function mapFinishReason(reason) { +function mapFinishReason(reason: unknown) { switch (reason) { case "stop": return "stop"; @@ -62,7 +63,7 @@ function mapFinishReason(reason) { } } -export function convertCommandCodeToOpenAI(chunk, state) { +export function convertCommandCodeToOpenAI(chunk: unknown, state: unknown) { if (!chunk) return null; // Already-OpenAI chunk: pass through @@ -116,7 +117,7 @@ export function convertCommandCodeToOpenAI(chunk, state) { case "tool-input-start": { const id = event.id || event.toolCallId || `call_${Date.now()}_${state.toolIndex}`; let idx = state.toolIndexById.get(id); - if (idx == null) { + if (idx === null || idx === undefined) { idx = state.toolIndex++; state.toolIndexById.set(id, idx); } @@ -139,7 +140,7 @@ export function convertCommandCodeToOpenAI(chunk, state) { case "tool-input-delta": { const id = event.id || event.toolCallId; const idx = state.toolIndexById.get(id); - if (idx == null) break; + if (idx === null || idx === undefined) break; const delta = { tool_calls: [ { @@ -181,7 +182,7 @@ export function convertCommandCodeToOpenAI(chunk, state) { } case "finish": { const finishReason = state.finishReason || mapFinishReason(event.finishReason || "stop"); - const finalChunk = makeChunk(state, {}, finishReason); + const finalChunk: Record = makeChunk(state, {}, finishReason); const totalUsage = event.totalUsage || state.usage; if (totalUsage) { finalChunk.usage = { diff --git a/open-sse/translator/response/cursor-to-openai.js b/open-sse/translator/response/cursor-to-openai.ts similarity index 86% rename from open-sse/translator/response/cursor-to-openai.js rename to open-sse/translator/response/cursor-to-openai.ts index 92ae5676..4ebd4d5b 100644 --- a/open-sse/translator/response/cursor-to-openai.js +++ b/open-sse/translator/response/cursor-to-openai.ts @@ -1,17 +1,18 @@ +// @ts-nocheck /** * Cursor to OpenAI Response Translator * CursorExecutor already emits OpenAI format - this is a passthrough */ import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Convert Cursor response to OpenAI format * Since CursorExecutor.transformProtobufToSSE/JSON already emits OpenAI chunks, * this is a passthrough translator (similar to Kiro pattern) */ -export function convertCursorToOpenAI(chunk, state) { +export function convertCursorToOpenAI(chunk: unknown, _state: unknown) { if (!chunk) return null; // If chunk is already in OpenAI format (from executor transform), return as-is diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.ts similarity index 97% rename from open-sse/translator/response/gemini-to-openai.js rename to open-sse/translator/response/gemini-to-openai.ts index a72e74c0..23402f80 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -1,8 +1,9 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Convert Gemini response chunk to OpenAI format -export function geminiToOpenAIResponse(chunk, state) { +export function geminiToOpenAIResponse(chunk: unknown, state: unknown) { if (!chunk) return null; // Handle Antigravity wrapper @@ -230,7 +231,7 @@ export function geminiToOpenAIResponse(chunk, state) { finishReason = "tool_calls"; } - const finalChunk = { + const finalChunk: Record = { id: `chatcmpl-${state.messageId}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.ts similarity index 94% rename from open-sse/translator/response/kiro-to-openai.js rename to open-sse/translator/response/kiro-to-openai.ts index cbba11ec..e17ecd7f 100644 --- a/open-sse/translator/response/kiro-to-openai.js +++ b/open-sse/translator/response/kiro-to-openai.ts @@ -1,16 +1,17 @@ +// @ts-nocheck /** * Kiro to OpenAI Response Translator * Converts Kiro/AWS CodeWhisperer streaming events to OpenAI SSE format */ import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Parse Kiro SSE event and convert to OpenAI format * Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc. */ -export function convertKiroToOpenAI(chunk, state) { +export function convertKiroToOpenAI(chunk: unknown, state: unknown) { if (!chunk) return null; // If chunk is already in OpenAI format (from executor transform), return as-is @@ -66,7 +67,7 @@ export function convertKiroToOpenAI(chunk, state) { const content = data.assistantResponseEvent?.content || data.content || ""; if (!content) return null; - const openaiChunk = { + const openaiChunk: Record = { id: state.responseId, object: "chat.completion.chunk", created: state.created, @@ -93,7 +94,7 @@ export function convertKiroToOpenAI(chunk, state) { if (!content) return null; // Convert to thinking block format (Claude-style) - const openaiChunk = { + const openaiChunk: Record = { id: state.responseId, object: "chat.completion.chunk", created: state.created, @@ -156,7 +157,7 @@ export function convertKiroToOpenAI(chunk, state) { if (eventType === "messageStopEvent" || eventType === "done" || data.messageStopEvent) { state.finishReason = "stop"; // Mark for usage injection in stream.js - const openaiChunk = { + const openaiChunk: Record = { id: state.responseId, object: "chat.completion.chunk", created: state.created, diff --git a/open-sse/translator/response/ollama-to-openai.js b/open-sse/translator/response/ollama-to-openai.ts similarity index 90% rename from open-sse/translator/response/ollama-to-openai.js rename to open-sse/translator/response/ollama-to-openai.ts index 3640b501..cdf3375b 100644 --- a/open-sse/translator/response/ollama-to-openai.js +++ b/open-sse/translator/response/ollama-to-openai.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Convert Ollama NDJSON response to OpenAI SSE format @@ -12,7 +13,7 @@ import { register } from "../index.js"; * {"id": "...", "object": "chat.completion.chunk", "created": 123, "model": "...", * "choices": [{"index": 0, "delta": {"content": "..."}, "finish_reason": null}]} */ -export function ollamaToOpenAI(chunk, state) { +export function ollamaToOpenAI(chunk: unknown, state: unknown) { if (!chunk || typeof chunk !== "object") return null; // Initialize state on first chunk @@ -71,7 +72,7 @@ export function ollamaToOpenAI(chunk, state) { state.accumulatedThinking = (state.accumulatedThinking || "") + thinking; } - const delta = {}; + const delta: Record = {}; if (!state.ollama.sentRole) { delta.role = "assistant"; state.ollama.sentRole = true; @@ -103,7 +104,7 @@ export function ollamaToOpenAI(chunk, state) { /** * Extract usage stats from Ollama response */ -function extractUsage(ollamaChunk) { +function extractUsage(ollamaChunk: unknown) { return { prompt_tokens: ollamaChunk.prompt_eval_count || 0, completion_tokens: ollamaChunk.eval_count || 0, @@ -114,8 +115,8 @@ function extractUsage(ollamaChunk) { /** * Convert tool_calls from Ollama format to OpenAI format */ -function convertToolCalls(toolCalls) { - return toolCalls.map((tc, i) => ({ +function convertToolCalls(toolCalls: unknown) { + return toolCalls.map((tc: unknown, i: unknown) => ({ index: tc.function?.index ?? i, id: tc.id || `call_${i}_${Date.now()}`, type: "function", @@ -132,13 +133,13 @@ function convertToolCalls(toolCalls) { /** * Convert Ollama non-streaming response body to OpenAI chat.completion format */ -export function ollamaBodyToOpenAI(body) { +export function ollamaBodyToOpenAI(body: unknown) { const msg = body.message || {}; const content = msg.content || ""; const thinking = msg.thinking || ""; const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; - const message = { role: "assistant" }; + const message: Record = { role: "assistant" }; if (content) message.content = content; if (thinking) message.reasoning_content = thinking; if (toolCalls.length > 0) message.tool_calls = convertToolCalls(toolCalls); diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.ts similarity index 94% rename from open-sse/translator/response/openai-responses.js rename to open-sse/translator/response/openai-responses.ts index 216c1a8a..c724f5bf 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.ts @@ -1,26 +1,27 @@ +// @ts-nocheck /** * Translator: OpenAI Chat Completions → OpenAI Responses API (response) * Converts streaming chunks from Chat Completions to Responses API events */ import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; /** * Translate OpenAI chunk to Responses API events * @returns {Array} Array of events with { event, data } structure */ -export function openaiToOpenAIResponsesResponse(chunk, state) { +export function openaiToOpenAIResponsesResponse(chunk: unknown, state: unknown) { if (!chunk) { return flushEvents(state); } if (!chunk.choices?.length) return []; - const events = []; + const events: unknown[] = []; const nextSeq = () => ++state.seq; - const emit = (eventType, data) => { + const emit = (eventType: unknown, data: unknown) => { data.sequence_number = nextSeq(); events.push({ event: eventType, data }); }; @@ -114,7 +115,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } // Helper functions -function startReasoning(state, emit, idx) { +function startReasoning(state: unknown, emit: unknown, idx: unknown) { if (!state.reasoningId) { state.reasoningId = `rs_${state.responseId}_${idx}`; state.reasoningIndex = idx; @@ -136,7 +137,7 @@ function startReasoning(state, emit, idx) { } } -function emitReasoningDelta(state, emit, text) { +function emitReasoningDelta(state: unknown, emit: unknown, text: unknown) { if (!text) return; state.reasoningBuf += text; emit("response.reasoning_summary_text.delta", { @@ -148,7 +149,7 @@ function emitReasoningDelta(state, emit, text) { }); } -function closeReasoning(state, emit) { +function closeReasoning(state: unknown, emit: unknown) { if (state.reasoningId && !state.reasoningDone) { state.reasoningDone = true; @@ -180,7 +181,7 @@ function closeReasoning(state, emit) { } } -function emitTextContent(state, emit, idx, content) { +function emitTextContent(state: unknown, emit: unknown, idx: unknown, content: unknown) { if (!state.msgItemAdded[idx]) { state.msgItemAdded[idx] = true; const msgId = `msg_${state.responseId}_${idx}`; @@ -217,7 +218,7 @@ function emitTextContent(state, emit, idx, content) { state.msgTextBuf[idx] += content; } -function closeMessage(state, emit, idx) { +function closeMessage(state: unknown, emit: unknown, idx: unknown) { if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) { state.msgItemDone[idx] = true; const fullText = state.msgTextBuf[idx] || ""; @@ -253,7 +254,7 @@ function closeMessage(state, emit, idx) { } } -function emitToolCall(state, emit, tc) { +function emitToolCall(state: unknown, emit: unknown, tc: unknown) { // Use tc.index if provided, otherwise fallback to state's tracked toolCallIndex // This fixes Codex streaming freeze where tool calls may not have explicit index let tcIdx = tc.index; @@ -300,7 +301,7 @@ function emitToolCall(state, emit, tc) { } } -function closeToolCall(state, emit, idx) { +function closeToolCall(state: unknown, emit: unknown, idx: unknown) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { const args = state.funcArgsBuf[idx] || "{}"; @@ -329,7 +330,7 @@ function closeToolCall(state, emit, idx) { } } -function sendCompleted(state, emit) { +function sendCompleted(state: unknown, emit: unknown) { if (!state.completedSent) { state.completedSent = true; emit("response.completed", { @@ -346,12 +347,12 @@ function sendCompleted(state, emit) { } } -function flushEvents(state) { +function flushEvents(state: unknown) { if (state.completedSent) return []; - const events = []; + const events: unknown[] = []; const nextSeq = () => ++state.seq; - const emit = (eventType, data) => { + const emit = (eventType: unknown, data: unknown) => { data.sequence_number = nextSeq(); events.push({ event: eventType, data }); }; @@ -366,7 +367,7 @@ function flushEvents(state) { // currentToolCallId is intentionally sticky for the current turn so flush/completion // can still finalize as tool_calls even if the tool call was emitted before stream end. -function computeFinishReason(state) { +function computeFinishReason(state: unknown) { return state.nextToolCallIndex > 0 || state.currentToolCallId ? "tool_calls" : "stop"; } @@ -374,7 +375,7 @@ function computeFinishReason(state) { * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client */ -export function openaiResponsesToOpenAIResponse(chunk, state) { +export function openaiResponsesToOpenAIResponse(chunk: unknown, state: unknown) { if (!chunk) { // Flush: send final chunk with finish_reason if (state.finishReasonSent || !state.started) return null; @@ -384,7 +385,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.finishReasonSent = true; state.finishReason = finishReason; - const finalChunk = { + const finalChunk: Record = { id: state.chatId || `chatcmpl-${Date.now()}`, object: "chat.completion.chunk", created: state.created || Math.floor(Date.now() / 1000), @@ -581,7 +582,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.finishReasonSent = true; state.finishReason = finishReason; // Mark for usage injection in stream.js - const finalChunk = { + const finalChunk: Record = { id: state.chatId, object: "chat.completion.chunk", created: state.created, diff --git a/open-sse/translator/response/openai-to-antigravity.js b/open-sse/translator/response/openai-to-antigravity.ts similarity index 92% rename from open-sse/translator/response/openai-to-antigravity.js rename to open-sse/translator/response/openai-to-antigravity.ts index 5742460a..12ba2dba 100644 --- a/open-sse/translator/response/openai-to-antigravity.js +++ b/open-sse/translator/response/openai-to-antigravity.ts @@ -1,11 +1,12 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Convert OpenAI SSE chunk to Antigravity SSE format // Real Antigravity format: // data: {"response":{"candidates":[{"content":{"role":"model","parts":[...]}, "finishReason":"STOP"}], "usageMetadata":{...}, "modelVersion":"...", "responseId":"..."}} // Tool calls: OpenAI sends incremental args across chunks → accumulate and emit ONCE at finish -export function openaiToAntigravityResponse(chunk, state) { +export function openaiToAntigravityResponse(chunk: unknown, state: unknown) { if (!chunk) return null; const choice = chunk.choices?.[0]; @@ -83,11 +84,11 @@ export function openaiToAntigravityResponse(chunk, state) { } // Build candidate - const candidate = { content: { role: "model", parts } }; + const candidate: Record = { content: { role: "model", parts } }; // Finish reason mapping if (finishReason) { - const reasonMap = { + const reasonMap: Record = { stop: "STOP", length: "MAX_TOKENS", tool_calls: "STOP", @@ -97,7 +98,7 @@ export function openaiToAntigravityResponse(chunk, state) { } // Build response - const response = { + const response: Record = { candidates: [candidate], modelVersion: state._modelVersion, responseId: state._responseId, diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.ts similarity index 95% rename from open-sse/translator/response/openai-to-claude.js rename to open-sse/translator/response/openai-to-claude.ts index baace6c3..0827d9fe 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.ts @@ -1,11 +1,12 @@ +// @ts-nocheck import { FORMATS } from "../formats.js"; -import { register } from "../index.js"; +import { register } from "../registry.js"; // Prefix for Claude OAuth tool names (must match request translator) const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; // Helper: stop thinking block if started -function stopThinkingBlock(state, results) { +function stopThinkingBlock(state: unknown, results: unknown) { if (!state.thinkingBlockStarted) return; results.push({ type: "content_block_stop", @@ -15,7 +16,7 @@ function stopThinkingBlock(state, results) { } // Helper: stop text block if started -function stopTextBlock(state, results) { +function stopTextBlock(state: unknown, results: unknown) { if (!state.textBlockStarted || state.textBlockClosed) return; state.textBlockClosed = true; results.push({ @@ -26,7 +27,7 @@ function stopTextBlock(state, results) { } // Convert OpenAI stream chunk to Claude format -export function openaiToClaudeResponse(chunk, state) { +export function openaiToClaudeResponse(chunk: unknown, state: unknown) { if (!chunk || !chunk.choices?.[0]) return null; const results = []; @@ -214,7 +215,7 @@ export function openaiToClaudeResponse(chunk, state) { } // Convert OpenAI finish_reason to Claude stop_reason -function convertFinishReason(reason) { +function convertFinishReason(reason: unknown) { switch (reason) { case "stop": return "end_turn"; diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json new file mode 100644 index 00000000..9537948e --- /dev/null +++ b/open-sse/tsconfig.json @@ -0,0 +1,13 @@ +// Optional scoped check; root tsc remains the release source of truth. +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "plugins": [], + "jsx": "react-jsx", + "noEmit": true, + "incremental": false, + "types": ["@types/node", "@types/bun"] + }, + "include": ["./**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.ts similarity index 76% rename from open-sse/utils/bypassHandler.js rename to open-sse/utils/bypassHandler.ts index 2c3160b5..7a0966e8 100644 --- a/open-sse/utils/bypassHandler.js +++ b/open-sse/utils/bypassHandler.ts @@ -1,24 +1,51 @@ +// @ts-nocheck import { SKIP_PATTERNS } from "../config/runtimeConfig.js"; import { detectFormat } from "../services/provider.js"; import { FORMATS } from "../translator/formats.js"; import { initState, translateResponse } from "../translator/index.js"; import { formatSSE } from "./stream.js"; +type BypassResult = { success: true; response: Response }; +type JsonRecord = Record; +type TextPart = { + text?: unknown; + type?: string; +}; +type BypassMessage = { + content?: string | TextPart[]; + role?: string; +}; +type BypassBody = JsonRecord & { + messages?: BypassMessage[]; + stream?: boolean; + system?: string | TextPart[]; +}; +type OpenAIResponse = ReturnType; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + /** * Check for bypass patterns - return fake response without calling provider * Only works for Claude CLI requests */ -export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming = false) { +export function handleBypassRequest( + body: BypassBody, + model: string, + userAgent: string = "", + ccFilterNaming: boolean = false, +): BypassResult | null { if (!userAgent.includes("claude-cli")) return null; if (!body.messages?.length) return null; const messages = body.messages; - const getText = (content) => { + const getText = (content: unknown) => { if (typeof content === "string") return content; if (Array.isArray(content)) { return content - .filter((c) => c.type === "text") - .map((c) => c.text) + .filter((c): c is TextPart => asRecord(c).type === "text") + .map((c) => (typeof c.text === "string" ? c.text : "")) .join(" "); } return ""; @@ -29,7 +56,8 @@ export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming // Pattern 1: Title extraction (assistant message = "{") const lastMsg = messages[messages.length - 1]; - if (lastMsg?.role === "assistant" && lastMsg.content?.[0]?.text === "{") { + const firstContentPart = Array.isArray(lastMsg?.content) ? lastMsg.content[0] : undefined; + if (lastMsg?.role === "assistant" && firstContentPart?.text === "{") { shouldBypass = true; } @@ -66,7 +94,7 @@ export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming const systemFromBody = Array.isArray(body.system) ? body.system .filter((s) => s.type === "text") - .map((s) => s.text) + .map((s) => (typeof s.text === "string" ? s.text : "")) .join(" ") : typeof body.system === "string" ? body.system @@ -95,8 +123,8 @@ export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming } return stream - ? createStreamingResponse(sourceFormat, model) - : createNonStreamingResponse(sourceFormat, model); + ? createStreamingResponse(sourceFormat, model, undefined) + : createNonStreamingResponse(sourceFormat, model, undefined); } const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal"; @@ -104,7 +132,7 @@ const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal"; /** * Create OpenAI standard format response */ -function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) { +function createOpenAIResponse(model: string, text: string = DEFAULT_BYPASS_TEXT) { const id = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); @@ -135,7 +163,11 @@ function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) { * Create non-streaming response with translation * Use translator to convert OpenAI → sourceFormat */ -function createNonStreamingResponse(sourceFormat, model, text) { +function createNonStreamingResponse( + sourceFormat: string, + model: string, + text?: string, +): BypassResult { const openaiResponse = createOpenAIResponse(model, text); // If sourceFormat is OpenAI, return directly @@ -189,7 +221,7 @@ function createNonStreamingResponse(sourceFormat, model, text) { * Create streaming response with translation * Use translator to convert OpenAI chunks → sourceFormat */ -function createStreamingResponse(sourceFormat, model, text) { +function createStreamingResponse(sourceFormat: string, model: string, text?: string): BypassResult { const openaiResponse = createOpenAIResponse(model, text); const state = initState(sourceFormat); state.model = model; @@ -237,7 +269,7 @@ function createStreamingResponse(sourceFormat, model, text) { * Merge translated chunks into final response object (for non-streaming) * Takes the last complete chunk as the final response */ -function mergeChunksToResponse(chunks, sourceFormat) { +function mergeChunksToResponse(chunks: unknown[], sourceFormat: string) { if (!chunks || chunks.length === 0) { return createOpenAIResponse("unknown"); } @@ -248,18 +280,20 @@ function mergeChunksToResponse(chunks, sourceFormat) { // For Claude format, find the message_stop or final message if (sourceFormat === FORMATS.CLAUDE) { - const messageStop = chunks.find((c) => c.type === "message_stop"); + const messageStop = chunks.find((c) => asRecord(c).type === "message_stop"); if (messageStop) { // Reconstruct complete message from chunks - const _contentDelta = chunks.find((c) => c.type === "content_block_delta"); - const messageDelta = chunks.find((c) => c.type === "message_delta"); - const messageStart = chunks.find((c) => c.type === "message_start"); + const _contentDelta = chunks.find((c) => asRecord(c).type === "content_block_delta"); + const messageDelta = asRecord(chunks.find((c) => asRecord(c).type === "message_delta")); + const messageStart = asRecord(chunks.find((c) => asRecord(c).type === "message_start")); - if (messageStart?.message) { + if (messageStart.message) { finalChunk = messageStart.message; // Merge usage if available - if (messageDelta?.usage) { - finalChunk.usage = messageDelta.usage; + const finalChunkRecord = asRecord(finalChunk); + if (messageDelta.usage) { + finalChunkRecord.usage = messageDelta.usage; + finalChunk = finalChunkRecord; } } } @@ -271,9 +305,9 @@ function mergeChunksToResponse(chunks, sourceFormat) { /** * Create OpenAI streaming chunks from complete response */ -function createOpenAIStreamingChunks(completeResponse) { +function createOpenAIStreamingChunks(completeResponse: OpenAIResponse) { const { id, created, model, choices } = completeResponse; - const content = choices[0].message.content; + const content = choices[0]!.message.content; return [ // Chunk with content diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.ts similarity index 75% rename from open-sse/utils/claudeCloaking.js rename to open-sse/utils/claudeCloaking.ts index 36983258..815d41c3 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.ts @@ -4,9 +4,30 @@ import { CLAUDE_TOOL_SUFFIX } from "../config/appConstants.js"; const CLAUDE_VERSION = "2.1.92"; const CC_ENTRYPOINT = "sdk-cli"; +type JsonRecord = Record; + +type ClaudeTool = JsonRecord & { name: string }; + +type ClaudeContentBlock = JsonRecord & { + type?: string; + name?: string; + text?: string; +}; + +type ClaudeMessage = JsonRecord & { + content?: unknown; +}; + +type ClaudeBody = JsonRecord & { + tools?: ClaudeTool[]; + messages?: ClaudeMessage[]; + system?: unknown; + metadata?: JsonRecord & { user_id?: unknown }; +}; + // Generate billing header matching real Claude Code 2.1.92+ format: // x-anthropic-billing-header: cc_version=.; cc_entrypoint=sdk-cli; cch=; -function generateBillingHeader(payload) { +function generateBillingHeader(payload: unknown) { const content = JSON.stringify(payload); const cch = createHash("sha256").update(content).digest("hex").slice(0, 5); const buildHash = randomBytes(2).toString("hex").slice(0, 3); @@ -15,7 +36,7 @@ function generateBillingHeader(payload) { // Generate fake user ID in Claude Code 2.1.92+ JSON format: // {"device_id":"<64hex>","account_uuid":"","session_id":""} -function generateFakeUserID(sessionId) { +function generateFakeUserID(sessionId?: string | null) { const deviceId = randomBytes(32).toString("hex"); const accountUuid = randomUUID(); const sessionUuid = sessionId || randomUUID(); @@ -31,12 +52,13 @@ function generateFakeUserID(sessionId) { * @param {object} body - Claude API request body * @returns {{ body: object, toolNameMap: Map|null }} */ -export function cloakClaudeTools(body) { - const tools = body.tools; - if (!tools || tools.length === 0) return { body, toolNameMap: null }; +export function cloakClaudeTools(body: unknown) { + const bodyRecord = body as ClaudeBody; + const tools = bodyRecord.tools; + if (!tools || tools.length === 0) return { body: bodyRecord, toolNameMap: null }; - const toolNameMap = new Map(); - const clientDeclarations = []; + const toolNameMap = new Map(); + const clientDeclarations: ClaudeTool[] = []; // All client tools get renamed with suffix for (const tool of tools) { @@ -49,9 +71,9 @@ export function cloakClaudeTools(body) { const allTools = [...clientDeclarations, ...CC_DECOY_TOOLS]; // Rename tool_use in message history (all client tools get suffix) - const renamedMessages = body.messages?.map((msg) => { + const renamedMessages = bodyRecord.messages?.map((msg: ClaudeMessage) => { if (!Array.isArray(msg.content)) return msg; - const renamedContent = msg.content.map((block) => { + const renamedContent = (msg.content as ClaudeContentBlock[]).map((block) => { if (block.type === "tool_use") { return { ...block, name: `${block.name}${CLAUDE_TOOL_SUFFIX}` }; } @@ -61,7 +83,7 @@ export function cloakClaudeTools(body) { }); return { - body: { ...body, tools: allTools, messages: renamedMessages || body.messages }, + body: { ...bodyRecord, tools: allTools, messages: renamedMessages || bodyRecord.messages }, toolNameMap: toolNameMap.size > 0 ? toolNameMap : null, }; } @@ -75,31 +97,33 @@ export function cloakClaudeTools(body) { * (content[] arrays), message history, and any nested envelope a future * Claude API revision might use. */ -export function decloakToolNames(node, toolNameMap) { - if (!toolNameMap?.size || !node || typeof node !== "object") return node; +export function decloakToolNames(node: unknown, toolNameMap: unknown): unknown { + const map = toolNameMap as Map | null | undefined; + if (!map?.size || !node || typeof node !== "object") return node; if (Array.isArray(node)) { let changed = false; - const next = node.map((child) => { - const mapped = decloakToolNames(child, toolNameMap); + const next: unknown[] = node.map((child: unknown): unknown => { + const mapped: unknown = decloakToolNames(child, map); if (mapped !== child) changed = true; return mapped; }); return changed ? next : node; } - if (node.type === "tool_use" && typeof node.name === "string") { - const original = toolNameMap.get(node.name); - if (original && original !== node.name) { - return { ...node, name: original }; + const record = node as JsonRecord; + if (record.type === "tool_use" && typeof record.name === "string") { + const original = map.get(record.name); + if (original && original !== record.name) { + return { ...record, name: original }; } } let changed = false; - const next = {}; - for (const key of Object.keys(node)) { - const mapped = decloakToolNames(node[key], toolNameMap); - if (mapped !== node[key]) changed = true; + const next: JsonRecord = {}; + for (const key of Object.keys(record)) { + const mapped = decloakToolNames(record[key], map); + if (mapped !== record[key]) changed = true; next[key] = mapped; } return changed ? next : node; @@ -219,18 +243,24 @@ const CC_DECOY_TOOLS = [ * @param {string} [sessionId] - Session ID to align with X-Claude-Code-Session-Id header * @returns {object} Modified body */ -export function applyCloaking(body, apiKey, sessionId) { - if (!apiKey || !apiKey.includes("sk-ant-oat")) return body; +export function applyCloaking( + body: unknown, + apiKey: string | null | undefined, + sessionId?: string | null, +) { + const bodyRecord = body as ClaudeBody; + if (!apiKey || !apiKey.includes("sk-ant-oat")) return bodyRecord; - const result = { ...body }; + const result: ClaudeBody = { ...bodyRecord }; // Inject billing header as system[0], preserve existing system blocks - const billingText = generateBillingHeader(body); + const billingText = generateBillingHeader(bodyRecord); const billingBlock = { type: "text", text: billingText }; if (Array.isArray(result.system)) { // Skip if already injected - if (!result.system[0]?.text?.startsWith("x-anthropic-billing-header:")) { + const first = result.system[0] as ClaudeContentBlock | undefined; + if (!first?.text?.startsWith("x-anthropic-billing-header:")) { result.system = [billingBlock, ...result.system]; } } else if (typeof result.system === "string") { diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.ts similarity index 75% rename from open-sse/utils/claudeHeaderCache.js rename to open-sse/utils/claudeHeaderCache.ts index 0af05f5c..cd662ed6 100644 --- a/open-sse/utils/claudeHeaderCache.js +++ b/open-sse/utils/claudeHeaderCache.ts @@ -26,15 +26,15 @@ const CLAUDE_IDENTITY_HEADERS = [ "arch", ]; -let cachedHeaders = null; +let cachedHeaders: Record | null = null; /** * Detect if request headers look like a real Claude Code client. * @param {object} headers - Lowercase header key/value object */ -function isClaudeCodeClient(headers) { - const ua = (headers["user-agent"] || "").toLowerCase(); - const xApp = (headers["x-app"] || "").toLowerCase(); +function isClaudeCodeClient(headers: Record) { + const ua = String(headers["user-agent"] || "").toLowerCase(); + const xApp = String(headers["x-app"] || "").toLowerCase(); return ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli"; } @@ -43,14 +43,15 @@ function isClaudeCodeClient(headers) { * Called at the entry point before any translation/forwarding. * @param {object} headers - Lowercase header key/value object (from request.headers.entries()) */ -export function cacheClaudeHeaders(headers) { +export function cacheClaudeHeaders(headers: unknown) { if (!headers || typeof headers !== "object") return; - if (!isClaudeCodeClient(headers)) return; + const headerRecord = headers as Record; + if (!isClaudeCodeClient(headerRecord)) return; - const captured = {}; + const captured: Record = {}; for (const key of CLAUDE_IDENTITY_HEADERS) { - if (headers[key] !== undefined && headers[key] !== null) { - captured[key] = headers[key]; + if (headerRecord[key] !== undefined && headerRecord[key] !== null) { + captured[key] = headerRecord[key]; } } diff --git a/open-sse/utils/clientDetector.js b/open-sse/utils/clientDetector.ts similarity index 85% rename from open-sse/utils/clientDetector.js rename to open-sse/utils/clientDetector.ts index 4a3d738a..60c4dec7 100644 --- a/open-sse/utils/clientDetector.js +++ b/open-sse/utils/clientDetector.ts @@ -4,20 +4,27 @@ */ // Map of CLI tool identifiers to provider IDs they are "native" to -const NATIVE_PAIRS = { +const NATIVE_PAIRS: Record = { claude: ["claude", "anthropic"], "gemini-cli": ["gemini-cli"], antigravity: ["antigravity"], codex: ["codex"], }; +type HeaderMap = Record; + +type BodyLike = { + userAgent?: unknown; + [key: string]: unknown; +}; + /** * Detect which CLI tool is making the request. * Returns one of: "claude" | "gemini-cli" | "antigravity" | "codex" | null * @param {object} headers - Lowercase header key/value object * @param {object} body - Parsed request body */ -export function detectClientTool(headers = {}, body = {}) { +export function detectClientTool(headers: HeaderMap = {}, body: BodyLike = {}) { const ua = (headers["user-agent"] || "").toLowerCase(); const xApp = (headers["x-app"] || "").toLowerCase(); const openaiIntent = (headers["openai-intent"] || "").toLowerCase(); @@ -52,7 +59,7 @@ export function detectClientTool(headers = {}, body = {}) { * @param {string|null} clientTool - Result of detectClientTool() * @param {string} provider - Provider ID (e.g. "claude", "gemini-cli") */ -export function isNativePassthrough(clientTool, provider) { +export function isNativePassthrough(clientTool: string | null | undefined, provider: string) { if (!clientTool) return false; const nativeProviders = NATIVE_PAIRS[clientTool]; if (!nativeProviders) return false; diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.ts similarity index 83% rename from open-sse/utils/cursorChecksum.js rename to open-sse/utils/cursorChecksum.ts index 4670dc3c..ff0a8b92 100644 --- a/open-sse/utils/cursorChecksum.js +++ b/open-sse/utils/cursorChecksum.ts @@ -14,7 +14,7 @@ import { v5 as uuidv5 } from "uuid"; * @param {string} salt - Optional salt * @returns {string} - 64-character hex string */ -export function generateHashed64Hex(input, salt = "") { +export function generateHashed64Hex(input: string, salt: string = "") { return crypto .createHash("sha256") .update(input + salt) @@ -26,7 +26,7 @@ export function generateHashed64Hex(input, salt = "") { * @param {string} authToken - Auth token * @returns {string} - UUID string */ -export function generateSessionId(authToken) { +export function generateSessionId(authToken: string) { return uuidv5(authToken, uuidv5.DNS); } @@ -43,7 +43,7 @@ export function generateSessionId(authToken) { * @param {string} machineId - Machine ID from Cursor storage or generated * @returns {string} - Checksum string */ -export function generateCursorChecksum(machineId) { +export function generateCursorChecksum(machineId: string) { // Math.floor(Date.now() / 1e6) - same as Python implementation const timestamp = Math.floor(Date.now() / 1000000); @@ -60,8 +60,9 @@ export function generateCursorChecksum(machineId) { // Jyh cipher obfuscation let t = 165; for (let i = 0; i < byteArray.length; i++) { - byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff; - t = byteArray[i]; + const current = byteArray[i] as number; + byteArray[i] = ((current ^ t) + (i % 256)) & 0xff; + t = byteArray[i] as number; } // URL-safe base64 encode (without padding) @@ -69,9 +70,9 @@ export function generateCursorChecksum(machineId) { let encoded = ""; for (let i = 0; i < byteArray.length; i += 3) { - const a = byteArray[i]; - const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; - const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; + const a = byteArray[i] as number; + const b = i + 1 < byteArray.length ? (byteArray[i + 1] as number) : 0; + const c = i + 2 < byteArray.length ? (byteArray[i + 2] as number) : 0; encoded += alphabet[a >> 2]; encoded += alphabet[((a & 3) << 4) | (b >> 4)]; @@ -95,9 +96,14 @@ export function generateCursorChecksum(machineId) { * @param {boolean} ghostMode - Enable ghost mode (privacy) * @returns {Object} - Headers object */ -export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) { +export function buildCursorHeaders( + accessToken: string | undefined, + machineId: string | null | undefined = null, + ghostMode: boolean = true, +) { // Clean token if it has prefix - const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; + const token = accessToken as string; + const cleanToken = token.includes("::") ? (token.split("::")[1] as string) : token; // Generate machine ID if not provided const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId"); diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.ts similarity index 79% rename from open-sse/utils/cursorProtobuf.js rename to open-sse/utils/cursorProtobuf.ts index 4aa8616d..47761af1 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.ts @@ -7,11 +7,67 @@ import zlib from "node:zlib"; import { v4 as uuidv4 } from "uuid"; const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1"; -const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); +const log = (tag: string, ...args: unknown[]) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); const _textDecoder = new TextDecoder(); const PROTOBUF_SCHEMA_VERSION = "1.1.3"; +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +type ProtobufBytes = Uint8Array; +type ProtobufLenValue = string | Uint8Array | Buffer; +type ProtobufFieldValue = number | ProtobufLenValue | null | undefined; +type DecodedFieldValue = number | Uint8Array | null; +type DecodedField = { wireType: number | null; value: never }; +type DecodedFieldList = [DecodedField, ...DecodedField[]]; +type DecodedMessage = Map & { + get(field: number): DecodedFieldList; +}; +type CursorToolResult = { + tool_name?: string; + name?: string; + raw_args?: string; + result_content?: string; + result?: string; + tool_call_id?: string; + tool_index?: number; + index?: number; + [key: string]: unknown; +}; +type CursorMessage = { + role?: string; + content?: string; + tool_calls?: unknown[]; + tool_results?: CursorToolResult[]; + [key: string]: unknown; +}; +type CursorTool = { + function?: { + name?: string; + description?: string; + parameters?: Record; + }; + name?: string; + description?: string; + input_schema?: Record; + [key: string]: unknown; +}; +type EncodedMessage = { + content: string | undefined; + role: number; + messageId: string; + isLast: boolean; + hasTools: boolean; + toolResults: CursorToolResult[]; +}; +type MessageIdEntry = { messageId: string; role: number }; + +function fieldValue(fields: DecodedMessage, field: number): never { + return (fields.get(field) as DecodedFieldList)[0].value; +} + // ==================== SCHEMAS ==================== const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 }; @@ -188,8 +244,8 @@ const KNOWN_RESPONSE_FIELDS = new Set([ // ==================== PRIMITIVE ENCODING ==================== -export function encodeVarint(value) { - const bytes = []; +export function encodeVarint(value: number) { + const bytes: number[] = []; while (value >= 0x80) { bytes.push((value & 0x7f) | 0x80); value >>>= 7; @@ -198,12 +254,12 @@ export function encodeVarint(value) { return new Uint8Array(bytes); } -export function encodeField(fieldNum, wireType, value) { +export function encodeField(fieldNum: number, wireType: number, value: ProtobufFieldValue) { const tag = (fieldNum << 3) | wireType; const tagBytes = encodeVarint(tag); if (wireType === WIRE_TYPE.VARINT) { - const valueBytes = encodeVarint(value); + const valueBytes = encodeVarint(value as number); return concatArrays(tagBytes, valueBytes); } @@ -224,7 +280,7 @@ export function encodeField(fieldNum, wireType, value) { return new Uint8Array(0); } -function concatArrays(...arrays) { +function concatArrays(...arrays: ProtobufBytes[]) { const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const result = new Uint8Array(totalLength); let offset = 0; @@ -241,7 +297,7 @@ function concatArrays(...arrays) { * Format tool name: "toolName" → "mcp_custom_toolName" * Also handles: "mcp__server__tool" → "mcp_server_tool" */ -function formatToolName(name) { +function formatToolName(name: unknown) { const base = typeof name === "string" && name.length > 0 ? name : "tool"; if (base.startsWith("mcp__")) { @@ -262,7 +318,7 @@ function formatToolName(name) { /** * Parse formatted tool name: "mcp_server_tool" → { serverName, selectedTool } */ -function parseToolName(formattedName) { +function parseToolName(formattedName: string) { if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) { return { serverName: "custom", selectedTool: formattedName || "tool" }; } @@ -283,7 +339,7 @@ function parseToolName(formattedName) { * Parse tool_call_id into { toolCallId, modelCallId } * Cursor uses "\nmc_" delimiter for model_call_id */ -function parseToolId(id) { +function parseToolId(id: string) { const delimiter = "\nmc_"; const idx = id.indexOf(delimiter); if (idx >= 0) { @@ -295,7 +351,7 @@ function parseToolId(id) { /** * Encode MCPResult proto: { selected_tool, result } */ -function encodeMcpResult(selectedTool, resultContent) { +function encodeMcpResult(selectedTool: string, resultContent: string) { return concatArrays( encodeField(FIELD.MCPR_SELECTED_TOOL, WIRE_TYPE.LEN, selectedTool), encodeField(FIELD.MCPR_RESULT, WIRE_TYPE.LEN, resultContent), @@ -307,11 +363,11 @@ function encodeMcpResult(selectedTool, resultContent) { * Represents the result of executing a tool */ function encodeClientSideToolV2Result( - toolCallId, - modelCallId, - selectedTool, - resultContent, - toolIndex = 1, + toolCallId: string, + modelCallId: string | null, + selectedTool: string, + resultContent: string, + toolIndex: number = 1, ) { return concatArrays( encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), @@ -325,7 +381,7 @@ function encodeClientSideToolV2Result( /** * Encode MCPParams.Tool nested inside ClientSideToolV2Call */ -function encodeMcpParamsForCall(toolName, rawArgs, serverName) { +function encodeMcpParamsForCall(toolName: string, rawArgs: string, serverName: string) { const tool = concatArrays( encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName), encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, rawArgs), @@ -339,13 +395,13 @@ function encodeMcpParamsForCall(toolName, rawArgs, serverName) { * Represents a tool call definition */ function encodeClientSideToolV2Call( - toolCallId, - toolName, - selectedTool, - serverName, - rawArgs, - modelCallId, - toolIndex = 1, + toolCallId: string, + toolName: string, + selectedTool: string, + serverName: string, + rawArgs: string, + modelCallId: string | null, + toolIndex: number = 1, ) { return concatArrays( encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), @@ -366,7 +422,7 @@ function encodeClientSideToolV2Call( * Encode ConversationMessage.ToolResult with full structure * Matches Cursor proto: tool_call_id, tool_name, tool_index, raw_args, result, tool_call */ -export function encodeToolResult(toolResult) { +export function encodeToolResult(toolResult: CursorToolResult) { const originalName = toolResult.tool_name || toolResult.name || ""; const toolName = formatToolName(originalName); const rawArgs = toolResult.raw_args || "{}"; @@ -407,15 +463,16 @@ export function encodeToolResult(toolResult) { } export function encodeMessage( - content, - role, - messageId, - chatModeEnum = null, - isLast = false, - hasTools = false, - toolResults = [], - serverBubbleId = null, + content: string | undefined, + role: number, + messageId: string, + chatModeEnum: number | null = null, + isLast: boolean = false, + hasTools: boolean = false, + toolResults: CursorToolResult[] = [], + serverBubbleId: string | null = null, ) { + void chatModeEnum; const hasToolResults = toolResults.length > 0; return concatArrays( encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), @@ -442,11 +499,11 @@ export function encodeMessage( ); } -export function encodeInstruction(text) { +export function encodeInstruction(text: string) { return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); } -export function encodeModel(modelName) { +export function encodeModel(modelName: string) { return concatArrays( encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName), encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)), @@ -478,7 +535,7 @@ export function encodeMetadata() { ); } -export function encodeMessageId(messageId, role, summaryId = null) { +export function encodeMessageId(messageId: string, role: number, summaryId: string | null = null) { return concatArrays( encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), ...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), @@ -486,7 +543,7 @@ export function encodeMessageId(messageId, role, summaryId = null) { ); } -export function encodeMcpTool(tool) { +export function encodeMcpTool(tool: CursorTool) { const toolName = tool.function?.name || tool.name || ""; const toolDesc = tool.function?.description || tool.description || ""; const inputSchema = tool.function?.parameters || tool.input_schema || {}; @@ -504,29 +561,30 @@ export function encodeMcpTool(tool) { // ==================== REQUEST BUILDING ==================== export function encodeRequest( - messages, - modelName, - tools = [], - reasoningEffort = null, - forceAgentMode = false, + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null, + forceAgentMode: boolean = false, ) { const hasTools = tools?.length > 0; const isAgentic = hasTools || forceAgentMode; - const formattedMessages = []; - const messageIds = []; - const normalizedMessages = []; + const formattedMessages: EncodedMessage[] = []; + const messageIds: MessageIdEntry[] = []; + const normalizedMessages: CursorMessage[] = []; // Guardrail: split mixed assistant payload into separate assistant messages // This prevents protobuf encoding errors when tool calls and results are in same message for (let i = 0; i < messages.length; i++) { const msg = messages[i]; + if (!msg) continue; const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0; if (msg?.role === "assistant" && hasToolCalls && hasToolResults) { log( "ENCODE", - `normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})`, + `normalizing mixed assistant tool payload at msg[${i}] (calls=${(msg.tool_calls as unknown[]).length}, results=${(msg.tool_results as CursorToolResult[]).length})`, ); // Keep assistant tool call message without embedded results @@ -541,13 +599,16 @@ export function encodeRequest( nextMsg?.role === "assistant" && Array.isArray(nextMsg?.tool_results) && nextMsg.tool_results.length > 0; + const currentToolResults = msg.tool_results as CursorToolResult[]; const currentIds = new Set( - msg.tool_results.map((tr) => tr?.tool_call_id).filter((id) => typeof id === "string"), + currentToolResults + .map((tr) => tr?.tool_call_id) + .filter((id): id is string => typeof id === "string"), ); const nextIds = new Set( (nextMsg?.tool_results || []) .map((tr) => tr?.tool_call_id) - .filter((id) => typeof id === "string"), + .filter((id): id is string => typeof id === "string"), ); let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size; if (sameIds) { @@ -563,7 +624,7 @@ export function encodeRequest( normalizedMessages.push({ role: "assistant", content: "", - tool_results: msg.tool_results, + tool_results: currentToolResults, }); } @@ -576,6 +637,7 @@ export function encodeRequest( // Prepare messages for (let i = 0; i < normalizedMessages.length; i++) { const msg = normalizedMessages[i]; + if (!msg) continue; const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; const msgId = uuidv4(); const isLast = i === normalizedMessages.length - 1; @@ -660,11 +722,11 @@ export function encodeRequest( } export function buildChatRequest( - messages, - modelName, - tools = [], - reasoningEffort = null, - forceAgentMode = false, + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null, + forceAgentMode: boolean = false, ) { return encodeField( FIELD.REQUEST, @@ -678,7 +740,7 @@ export function buildChatRequest( * This is sent as a SEPARATE request frame, not inside conversation messages. * Proto: StreamUnifiedChatRequestWithTools.client_side_tool_v2_result = 2 */ -export function buildToolResultRequest(toolResult) { +export function buildToolResultRequest(toolResult: CursorToolResult) { const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || ""); const rawName = toolResult.tool_name || ""; const resultContent = toolResult.result_content || ""; @@ -687,7 +749,7 @@ export function buildToolResultRequest(toolResult) { // McpResult { selected_tool: tool_name, result } where tool_name is the mcpParams.tools[0].name // which is the name AFTER server prefix stripping (e.g. "custom_Write" -> name = "Write") // Actually cursor-api uses: name = tool_name.slice_unchecked(d+1..) → raw name without "custom_" - // So selected_tool = raw tool name without any prefix + // So selected_tool = raw tool name without a prefix const selectedTool = rawName.startsWith("mcp_custom_") ? rawName.slice("mcp_custom_".length) : rawName.startsWith("mcp_") @@ -712,7 +774,7 @@ export function buildToolResultRequest(toolResult) { return encodeField(2, WIRE_TYPE.LEN, cv2Result); } -export function wrapConnectRPCFrame(payload, compress = false) { +export function wrapConnectRPCFrame(payload: Uint8Array, compress: boolean = false) { let finalPayload = payload; let flags = 0x00; @@ -733,11 +795,11 @@ export function wrapConnectRPCFrame(payload, compress = false) { } export function generateCursorBody( - messages, - modelName, - tools = [], - reasoningEffort = null, - forceAgentMode = false, + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null, + forceAgentMode: boolean = false, ) { log( "BODY", @@ -755,20 +817,20 @@ export function generateCursorBody( * Generate a framed tool result body to send as a separate request frame. * Uses field 2 (client_side_tool_v2_result) of StreamUnifiedChatRequestWithTools. */ -export function generateToolResultBody(toolResult) { +export function generateToolResultBody(toolResult: CursorToolResult) { const protobuf = buildToolResultRequest(toolResult); return wrapConnectRPCFrame(protobuf, false); } // ==================== PRIMITIVE DECODING ==================== -export function decodeVarint(buffer, offset) { +export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] { let result = 0; let shift = 0; let pos = offset; while (pos < buffer.length) { - const b = buffer[pos]; + const b = buffer[pos] ?? 0; result |= (b & 0x7f) << shift; pos++; if (!(b & 0x80)) break; @@ -778,7 +840,10 @@ export function decodeVarint(buffer, offset) { return [result, pos]; } -export function decodeField(buffer, offset) { +export function decodeField( + buffer: Uint8Array, + offset: number, +): [number | null, number | null, DecodedFieldValue, number] { if (offset >= buffer.length) return [null, null, null, offset]; const [tag, pos1] = decodeVarint(buffer, offset); @@ -807,16 +872,16 @@ export function decodeField(buffer, offset) { return [fieldNum, wireType, value, pos]; } -export function decodeMessage(data) { - const fields = new Map(); +export function decodeMessage(data: Uint8Array): DecodedMessage { + const fields = new Map() as DecodedMessage; let pos = 0; while (pos < data.length) { const [fieldNum, wireType, value, newPos] = decodeField(data, pos); if (fieldNum === null) break; - if (!fields.has(fieldNum)) fields.set(fieldNum, []); - fields.get(fieldNum).push({ wireType, value }); + if (!fields.has(fieldNum)) fields.set(fieldNum, [] as unknown as DecodedFieldList); + (fields.get(fieldNum) as DecodedFieldList).push({ wireType, value } as DecodedField); pos = newPos; } @@ -825,11 +890,15 @@ export function decodeMessage(data) { // ==================== RESPONSE PARSING ==================== -export function parseConnectRPCFrame(buffer) { +export function parseConnectRPCFrame(buffer: Uint8Array) { if (buffer.length < 5) return null; const flags = buffer[0]; - const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4]; + const length = + ((buffer[1] ?? 0) << 24) | + ((buffer[2] ?? 0) << 16) | + ((buffer[3] ?? 0) << 8) | + (buffer[4] ?? 0); if (buffer.length < 5 + length) return null; @@ -839,15 +908,15 @@ export function parseConnectRPCFrame(buffer) { if (flags === 0x01) { try { payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload))); - } catch (err) { - log("PARSE", `Decompression failed: ${err.message}`); + } catch (err: unknown) { + log("PARSE", `Decompression failed: ${errorMessage(err)}`); } } return { flags, length, payload, consumed: 5 + length }; } -function extractToolCall(toolCallData) { +function extractToolCall(toolCallData: Uint8Array) { const toolCall = decodeMessage(toolCallData); let toolCallId = ""; let toolName = ""; @@ -856,44 +925,44 @@ function extractToolCall(toolCallData) { // Extract tool call ID if (toolCall.has(FIELD.TOOL_ID)) { - const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); - toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line + const fullId = new TextDecoder().decode(fieldValue(toolCall, FIELD.TOOL_ID)); + toolCallId = fullId.split("\n")[0] as string; // Cursor returns multi-line ID, take first line } // Extract tool name if (toolCall.has(FIELD.TOOL_NAME)) { - toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value); + toolName = new TextDecoder().decode(fieldValue(toolCall, FIELD.TOOL_NAME)); } // Extract is_last flag if (toolCall.has(FIELD.TOOL_IS_LAST)) { - isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0; + isLast = fieldValue(toolCall, FIELD.TOOL_IS_LAST) !== 0; } // Extract MCP params - nested real tool info if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { try { - const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value); + const mcpParams = decodeMessage(fieldValue(toolCall, FIELD.TOOL_MCP_PARAMS)); if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { - const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value); + const tool = decodeMessage(fieldValue(mcpParams, FIELD.MCP_TOOLS_LIST)); if (tool.has(FIELD.MCP_NESTED_NAME)) { - toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); + toolName = new TextDecoder().decode(fieldValue(tool, FIELD.MCP_NESTED_NAME)); } if (tool.has(FIELD.MCP_NESTED_PARAMS)) { - rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); + rawArgs = new TextDecoder().decode(fieldValue(tool, FIELD.MCP_NESTED_PARAMS)); } } - } catch (err) { - log("EXTRACT", `MCP parse error: ${err.message}`); + } catch (err: unknown) { + log("EXTRACT", `MCP parse error: ${errorMessage(err)}`); } } // Fallback to raw_args if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { - rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); + rawArgs = new TextDecoder().decode(fieldValue(toolCall, FIELD.TOOL_RAW_ARGS)); } if (toolCallId && toolName) { @@ -911,32 +980,32 @@ function extractToolCall(toolCallData) { return null; } -function extractTextAndThinking(responseData) { +function extractTextAndThinking(responseData: Uint8Array) { const nested = decodeMessage(responseData); let text = null; let thinking = null; // Extract text if (nested.has(FIELD.RESPONSE_TEXT)) { - text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); + text = new TextDecoder().decode(fieldValue(nested, FIELD.RESPONSE_TEXT)); } // Extract thinking if (nested.has(FIELD.THINKING)) { try { - const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value); + const thinkingMsg = decodeMessage(fieldValue(nested, FIELD.THINKING)); if (thinkingMsg.has(FIELD.THINKING_TEXT)) { - thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); + thinking = new TextDecoder().decode(fieldValue(thinkingMsg, FIELD.THINKING_TEXT)); } - } catch (err) { - log("EXTRACT", `Thinking parse error: ${err.message}`); + } catch (err: unknown) { + log("EXTRACT", `Thinking parse error: ${errorMessage(err)}`); } } return { text, thinking }; } -export function extractTextFromResponse(payload) { +export function extractTextFromResponse(payload: Uint8Array) { try { const fields = decodeMessage(payload); @@ -952,7 +1021,7 @@ export function extractTextFromResponse(payload) { // Field 1: ClientSideToolV2Call if (fields.has(FIELD.TOOL_CALL)) { - const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value); + const toolCall = extractToolCall(fieldValue(fields, FIELD.TOOL_CALL)); if (toolCall) { log("EXTRACT", `Tool call: ${toolCall.function.name}`); return { text: null, error: null, toolCall, thinking: null }; @@ -961,7 +1030,7 @@ export function extractTextFromResponse(payload) { // Field 2: StreamUnifiedChatResponse if (fields.has(FIELD.RESPONSE)) { - const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value); + const { text, thinking } = extractTextAndThinking(fieldValue(fields, FIELD.RESPONSE)); if (text || thinking) { return { text, error: null, toolCall: null, thinking }; @@ -969,15 +1038,16 @@ export function extractTextFromResponse(payload) { } return { text: null, error: null, toolCall: null, thinking: null }; - } catch (err) { - log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`); + } catch (err: unknown) { + const message = errorMessage(err); + log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${message}`); return { text: null, error: null, toolCall: null, thinking: null, raw: Buffer.from(payload).toString("base64"), - decodeError: err.message, + decodeError: message, }; } } diff --git a/open-sse/utils/debugLog.js b/open-sse/utils/debugLog.ts similarity index 90% rename from open-sse/utils/debugLog.js rename to open-sse/utils/debugLog.ts index 75c400e4..9f9ee98f 100644 --- a/open-sse/utils/debugLog.js +++ b/open-sse/utils/debugLog.ts @@ -11,7 +11,7 @@ function ts() { }); } -export function dbg(tag, msg) { +export function dbg(tag: unknown, msg: unknown) { if (!isDev) return; console.log(`[${ts()}] 🐛 [DBG:${tag}] ${msg}`); } diff --git a/open-sse/utils/error.js b/open-sse/utils/error.ts similarity index 66% rename from open-sse/utils/error.js rename to open-sse/utils/error.ts index cafbc14f..8f6e9b54 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.ts @@ -1,5 +1,32 @@ import { DEFAULT_ERROR_MESSAGES, ERROR_TYPES } from "../config/errorConfig.js"; +type ErrorTypeInfo = { type: string; code: string }; + +const ERROR_TYPES_MAP = ERROR_TYPES as Record; +const DEFAULT_ERROR_MESSAGES_MAP = DEFAULT_ERROR_MESSAGES as Record; + +export type ErrorResult = { + success: false; + status: number; + error: string; + resetsAtMs?: number | null; + response: Response; +}; + +type UpstreamParseResult = { + message?: string; + status?: number; + resetsAtMs?: number; +}; + +type UpstreamErrorExecutor = { + parseError?: (response: Response, bodyText: string) => UpstreamParseResult | null | undefined; +} | null; + +type StreamWriter = { + write: (chunk: Uint8Array) => PromiseLike; +}; + /** * Shared Access-Control-Expose-Headers value for rate-limit headers. * Defined once here to prevent drift across error.js and rateLimit/index.ts. @@ -13,16 +40,16 @@ export const RATE_LIMIT_EXPOSE_HEADERS = * @param {string} message - Error message * @returns {object} Error response object */ -export function buildErrorBody(statusCode, message) { +export function buildErrorBody(statusCode: number, message?: string | null) { const errorInfo = - ERROR_TYPES[statusCode] || + ERROR_TYPES_MAP[statusCode] || (statusCode >= 500 ? { type: "server_error", code: "internal_server_error" } : { type: "invalid_request_error", code: "" }); return { error: { - message: message || DEFAULT_ERROR_MESSAGES[statusCode] || "An error occurred", + message: message || DEFAULT_ERROR_MESSAGES_MAP[statusCode] || "An error occurred", type: errorInfo.type, param: null, code: errorInfo.code, @@ -36,7 +63,7 @@ export function buildErrorBody(statusCode, message) { * @param {string} message - Error message * @returns {Response} HTTP Response object */ -export function errorResponse(statusCode, message) { +export function errorResponse(statusCode: number, message?: string | null) { return new Response(JSON.stringify(buildErrorBody(statusCode, message)), { status: statusCode, headers: { @@ -53,7 +80,11 @@ export function errorResponse(statusCode, message) { * @param {number} statusCode - HTTP status code * @param {string} message - Error message */ -export async function writeStreamError(writer, statusCode, message) { +export async function writeStreamError( + writer: StreamWriter, + statusCode: number, + message?: string | null, +) { const errorBody = buildErrorBody(statusCode, message); const encoder = new TextEncoder(); await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`)); @@ -65,7 +96,10 @@ export async function writeStreamError(writer, statusCode, message) { * @param {object} [executor] - Optional executor with parseError() override for provider-specific parsing * @returns {Promise<{statusCode: number, message: string, resetsAtMs?: number}>} */ -export async function parseUpstreamError(response, executor = null) { +export async function parseUpstreamError( + response: Response, + executor: UpstreamErrorExecutor = null, +) { let bodyText = ""; try { bodyText = await response.text(); @@ -80,7 +114,7 @@ export async function parseUpstreamError(response, executor = null) { if (parsed && typeof parsed === "object") { const msg = parsed.message || - DEFAULT_ERROR_MESSAGES[response.status] || + DEFAULT_ERROR_MESSAGES_MAP[response.status] || `Upstream error: ${response.status}`; return { statusCode: parsed.status || response.status, @@ -93,17 +127,26 @@ export async function parseUpstreamError(response, executor = null) { } } - let message = ""; + let message: unknown = ""; try { - const json = JSON.parse(bodyText); - message = json.error?.message || json.message || json.error || bodyText; + const json = JSON.parse(bodyText) as { + error?: { message?: string } | string; + message?: string; + }; + message = + (typeof json.error === "object" && json.error !== null ? json.error.message : undefined) || + json.message || + json.error || + bodyText; } catch { message = bodyText; } const messageStr = typeof message === "string" ? message : JSON.stringify(message); const finalMessage = - messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; + messageStr || + DEFAULT_ERROR_MESSAGES_MAP[response.status] || + `Upstream error: ${response.status}`; return { statusCode: response.status, message: finalMessage }; } @@ -115,14 +158,18 @@ export async function parseUpstreamError(response, executor = null) { * @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors * @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }} */ -export function createErrorResult(statusCode, message, resetsAtMs) { +export function createErrorResult( + statusCode: number, + message: string, + resetsAtMs?: number | null, +): ErrorResult { return { success: false, status: statusCode, error: message, resetsAtMs, response: errorResponse(statusCode, message), - }; + } as ErrorResult; } /** @@ -133,14 +180,19 @@ export function createErrorResult(statusCode, message, resetsAtMs) { * @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s" * @returns {Response} */ -export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) { +export function unavailableResponse( + statusCode: number, + message: string, + retryAfter: string, + retryAfterHuman: string, +) { const retryAfterSec = Math.max( Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1, ); const msg = `${message} (${retryAfterHuman})`; const errorInfo = - ERROR_TYPES[statusCode] || + ERROR_TYPES_MAP[statusCode] || (statusCode >= 500 ? { type: "server_error", code: "internal_server_error" } : { type: "invalid_request_error", code: "unavailable" }); @@ -159,6 +211,10 @@ export function unavailableResponse(statusCode, message, retryAfter, retryAfterH ); } +function errorRecord(error: unknown): Record { + return error && typeof error === "object" ? (error as Record) : {}; +} + /** * Format provider error with context * @param {Error} error - Original error @@ -167,12 +223,22 @@ export function unavailableResponse(statusCode, message, retryAfter, retryAfterH * @param {number|string} statusCode - HTTP status code or error code * @returns {string} Formatted error message */ -export function formatProviderError(error, provider, model, statusCode) { - const code = statusCode || error.code || "FETCH_FAILED"; - const message = error.message || "Unknown error"; +export function formatProviderError( + error: unknown, + provider: unknown, + model: unknown, + statusCode: unknown, +) { + void provider; + void model; + const record = errorRecord(error); + const code = statusCode || record.code || "FETCH_FAILED"; + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; // Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures - const causeCode = error.cause?.code; - const causeMsg = error.cause?.message; + const cause = errorRecord(record.cause); + const causeCode = cause.code; + const causeMsg = cause.message; const causeStr = causeCode || causeMsg ? ` (cause: ${[causeCode, causeMsg].filter(Boolean).join(": ")})` : ""; return `[${code}]: ${message}${causeStr}`; diff --git a/open-sse/utils/ollamaTransform.js b/open-sse/utils/ollamaTransform.ts similarity index 69% rename from open-sse/utils/ollamaTransform.js rename to open-sse/utils/ollamaTransform.ts index 4b3a766f..bbe1f02f 100644 --- a/open-sse/utils/ollamaTransform.js +++ b/open-sse/utils/ollamaTransform.ts @@ -1,10 +1,15 @@ +type PendingToolCall = { + id?: string; + function: { name: string; arguments: string }; +}; + // Transform OpenAI SSE stream to Ollama JSON lines format -export function transformToOllama(response, model) { +export function transformToOllama(response: Response, model: string) { let buffer = ""; - let pendingToolCalls = {}; + let pendingToolCalls: Record = {}; - const transform = new TransformStream({ - transform(chunk, controller) { + const transform = new TransformStream({ + transform(chunk: Uint8Array, controller: TransformStreamDefaultController) { const text = new TextDecoder().decode(chunk); buffer += text; const lines = buffer.split("\n"); @@ -23,20 +28,32 @@ export function transformToOllama(response, model) { } try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(data) as { + choices?: Array<{ + delta?: { + content?: string; + tool_calls?: Array<{ + index?: number | string; + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + finish_reason?: string; + }>; + }; const delta = parsed.choices?.[0]?.delta || {}; const content = delta.content || ""; const toolCalls = delta.tool_calls; if (toolCalls) { for (const tc of toolCalls) { - const idx = tc.index; + const idx = String(tc.index); if (!pendingToolCalls[idx]) { pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } }; } - if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name; - if (tc.function?.arguments) - pendingToolCalls[idx].function.arguments += tc.function.arguments; + const slot = pendingToolCalls[idx]!; + if (tc.function?.name) slot.function.name += tc.function.name; + if (tc.function?.arguments) slot.function.arguments += tc.function.arguments; } } @@ -51,7 +68,7 @@ export function transformToOllama(response, model) { if (finishReason === "tool_calls" || finishReason === "stop") { const toolCallsArr = Object.values(pendingToolCalls); if (toolCallsArr.length > 0) { - const formattedCalls = toolCallsArr.map((tc) => ({ + const formattedCalls = toolCallsArr.map((tc: PendingToolCall) => ({ function: { name: tc.function.name, arguments: (() => { @@ -78,12 +95,12 @@ export function transformToOllama(response, model) { controller.enqueue(new TextEncoder().encode(ollamaEnd)); } } - } catch (_e) { - // Silently ignore parse errors + } catch { + // Malformed upstream chunks are ignored; later SSE frames may still be valid. } } }, - flush(controller) { + flush(controller: TransformStreamDefaultController) { const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n"; controller.enqueue(new TextEncoder().encode(ollamaEnd)); diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.ts similarity index 69% rename from open-sse/utils/proxyFetch.js rename to open-sse/utils/proxyFetch.ts index ff7c40b0..d07e1525 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.ts @@ -1,7 +1,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; const originalFetch = globalThis.fetch; -const proxyDispatchers = new Map(); +const proxyDispatchers = new Map(); // Faster fail-over for unreachable upstreams. Default undici connect timeout is ~10s // (varies by environment); 20s gives slow networks room without hanging the request @@ -11,16 +11,38 @@ const CONNECT_TIMEOUT_MS = (() => { return Number.isFinite(raw) && raw > 0 ? raw : 20_000; })(); -function normalizeString(value) { +type ProxyOptions = { + enabled?: boolean; + connectionProxyEnabled?: boolean; + url?: unknown; + connectionProxyUrl?: unknown; + noProxy?: unknown; + connectionNoProxy?: unknown; + vercelRelayUrl?: unknown; + relayAuthToken?: unknown; + upstreamTimeoutMs?: number; + strictProxy?: boolean; +} | null; + +type FetchOptions = RequestInit & { + dispatcher?: unknown; + headers?: HeadersInit & Record; +}; + +function normalizeString(value: unknown) { if (value === undefined || value === null) return ""; return String(value).trim(); } -function shouldBypassByNoProxy(targetUrl, noProxyValue) { +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function shouldBypassByNoProxy(targetUrl: string, noProxyValue: unknown) { const noProxy = normalizeString(noProxyValue); if (!noProxy) return false; - let hostname; + let hostname: string; try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { @@ -28,10 +50,10 @@ function shouldBypassByNoProxy(targetUrl, noProxyValue) { } const patterns = noProxy .split(",") - .map((p) => p.trim().toLowerCase()) + .map((p: string) => p.trim().toLowerCase()) .filter(Boolean); - return patterns.some((pattern) => { + return patterns.some((pattern: string) => { if (pattern === "*") return true; if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); return hostname === pattern || hostname.endsWith(`.${pattern}`); @@ -41,11 +63,11 @@ function shouldBypassByNoProxy(targetUrl, noProxyValue) { /** * Get proxy URL from environment */ -function getEnvProxyUrl(targetUrl) { +function getEnvProxyUrl(targetUrl: string) { const noProxy = process.env.NO_PROXY || process.env.no_proxy; if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; - let protocol; + let protocol: string; try { protocol = new URL(targetUrl).protocol; } catch { @@ -72,7 +94,7 @@ function getEnvProxyUrl(targetUrl) { /** * Normalize proxy URL (allow host:port) */ -function normalizeProxyUrl(proxyUrl) { +function normalizeProxyUrl(proxyUrl: unknown) { const normalizedInput = normalizeString(proxyUrl); if (!normalizedInput) return null; @@ -85,7 +107,7 @@ function normalizeProxyUrl(proxyUrl) { } } -function resolveConnectionProxyUrl(targetUrl, proxyOptions) { +function resolveConnectionProxyUrl(targetUrl: string, proxyOptions: ProxyOptions) { const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; if (!enabled) return null; @@ -101,13 +123,14 @@ function resolveConnectionProxyUrl(targetUrl, proxyOptions) { /** * Create proxy dispatcher lazily (undici-compatible) */ -async function getDispatcher(proxyUrl) { +async function getDispatcher(proxyUrl: unknown) { const normalized = normalizeProxyUrl(proxyUrl); if (!normalized) return null; if (!proxyDispatchers.has(normalized)) { if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { - proxyDispatchers.delete(proxyDispatchers.keys().next().value); + const oldest = proxyDispatchers.keys().next().value; + if (oldest !== undefined) proxyDispatchers.delete(oldest); } const { ProxyAgent } = await import("undici"); proxyDispatchers.set( @@ -119,15 +142,19 @@ async function getDispatcher(proxyUrl) { return proxyDispatchers.get(normalized); } -export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { - const targetUrl = typeof url === "string" ? url : url.toString(); +export async function proxyAwareFetch( + url: string | URL | Request | undefined, + options: FetchOptions = {}, + proxyOptions: ProxyOptions = null, +) { + const targetUrl = typeof url === "string" ? url : (url as URL | Request).toString(); // Vercel relay: forward request via relay headers const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); if (vercelRelayUrl) { const parsed = new URL(targetUrl); - const relayHeaders = { - ...options.headers, + const relayHeaders: Record = { + ...(options.headers as Record | undefined), "x-relay-target": `${parsed.protocol}//${parsed.host}`, "x-relay-path": `${parsed.pathname}${parsed.search}`, }; @@ -140,7 +167,7 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { // Subtract 5s from pod's timeout so relay times out first — deterministic race outcome. // Minimum 1s to avoid zero/negative timeout on very short upstream deadlines. const upstreamTimeoutMs = proxyOptions?.upstreamTimeoutMs; - if (upstreamTimeoutMs > 0) { + if (upstreamTimeoutMs !== undefined && upstreamTimeoutMs > 0) { const relayTimeoutMs = Math.max(1000, upstreamTimeoutMs - 5000); relayHeaders["x-relay-timeout"] = String(relayTimeoutMs); } @@ -154,31 +181,31 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { if (proxyUrl) { try { const dispatcher = await getDispatcher(proxyUrl); - return await originalFetch(url, { ...options, dispatcher }); - } catch (proxyError) { + return await originalFetch(url as RequestInfo, { ...options, dispatcher } as RequestInit); + } catch (proxyError: unknown) { if (proxyOptions?.strictProxy === true) { throw new Error( - `[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`, + `[ProxyFetch] Proxy required but failed (strictProxy=true): ${errorMessage(proxyError)}`, ); } console.warn("[ProxyFetch] Proxy failed, falling back to direct"); - return originalFetch(url, options); + return originalFetch(url as RequestInfo, options); } } - return originalFetch(url, options); + return originalFetch(url as RequestInfo, options); } /** * Patched global fetch with env-proxy support */ -async function patchedFetch(url, options = {}) { +async function patchedFetch(url: string | URL | Request | undefined, options: FetchOptions = {}) { return proxyAwareFetch(url, options, null); } // Idempotency guard — only patch once to avoid wrapping multiple times if (globalThis.fetch !== patchedFetch) { - globalThis.fetch = patchedFetch; + globalThis.fetch = patchedFetch as typeof globalThis.fetch; } export default patchedFetch; diff --git a/open-sse/utils/reasoningContentInjector.js b/open-sse/utils/reasoningContentInjector.js deleted file mode 100644 index 6e720edc..00000000 --- a/open-sse/utils/reasoningContentInjector.js +++ /dev/null @@ -1,80 +0,0 @@ -// Some thinking-mode providers (DeepSeek, Kimi, ...) require reasoning_content -// to be echoed back on assistant messages. Clients in OpenAI format don't send it, -// so we inject a non-empty placeholder to satisfy upstream validation. - -const PLACEHOLDER = " "; - -const DEEPSEEK_V4_PRO = "deepseek-v4-pro"; -const DEEPSEEK_V4_PRO_ALIASES = { - [`${DEEPSEEK_V4_PRO}-max`]: { - thinkingType: "enabled", - reasoningEffort: "max", - }, - [`${DEEPSEEK_V4_PRO}-none`]: { - thinkingType: "disabled", - reasoningEffort: null, - }, -}; - -// Provider-level rules: keyed by executor.provider -const PROVIDER_RULES = { - deepseek: { scope: "all" }, -}; - -// Model-level rules: matched by predicate against model id -const MODEL_RULES = [ - { match: (m) => m?.startsWith?.("kimi-"), scope: "toolCalls" }, - { match: (m) => m?.startsWith?.("deepseek-"), scope: "all" }, -]; - -function shouldInject(message, scope) { - if (message?.role !== "assistant") return false; - const rc = message.reasoning_content; - if (typeof rc === "string" && rc.length > 0) return false; - if (scope === "toolCalls") - return Array.isArray(message.tool_calls) && message.tool_calls.length > 0; - return true; -} - -function applyDeepSeekV4ProAlias({ provider, model, body }) { - const alias = DEEPSEEK_V4_PRO_ALIASES[model]; - if (provider !== "deepseek" || !alias || !body) return body; - - const nextBody = { - ...body, - model: DEEPSEEK_V4_PRO, - extra_body: { - ...(body.extra_body || {}), - thinking: { - ...(body.extra_body?.thinking || {}), - type: alias.thinkingType, - }, - }, - }; - - if (alias.reasoningEffort) { - nextBody.reasoning_effort = alias.reasoningEffort; - } else { - delete nextBody.reasoning_effort; - } - - return nextBody; -} - -function applyRule(body, rule) { - if (!rule || !body?.messages) return body; - const messages = body.messages.map((m) => - shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m, - ); - return { ...body, messages }; -} - -export function injectReasoningContent({ provider, model, body }) { - const providerRule = PROVIDER_RULES[provider]; - const modelRule = MODEL_RULES.find((r) => r.match(model)); - const rule = providerRule || modelRule; - const nextBody = applyDeepSeekV4ProAlias({ provider, model, body }); - return applyRule(nextBody, rule); -} - -export { applyDeepSeekV4ProAlias }; diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts new file mode 100644 index 00000000..2c80fe6e --- /dev/null +++ b/open-sse/utils/reasoningContentInjector.ts @@ -0,0 +1,115 @@ +// Some thinking-mode providers (DeepSeek, Kimi, ...) require reasoning_content +// to be echoed back on assistant messages. Clients in OpenAI format don't send it, +// so we inject a non-empty placeholder to satisfy upstream validation. + +const PLACEHOLDER = " "; + +const DEEPSEEK_V4_PRO = "deepseek-v4-pro"; + +type AliasConfig = { + thinkingType: string; + reasoningEffort: string | null; +}; + +const DEEPSEEK_V4_PRO_ALIASES: Record = { + [`${DEEPSEEK_V4_PRO}-max`]: { + thinkingType: "enabled", + reasoningEffort: "max", + }, + [`${DEEPSEEK_V4_PRO}-none`]: { + thinkingType: "disabled", + reasoningEffort: null, + }, +}; + +type InjectScope = "all" | "toolCalls"; + +type InjectRule = { scope: InjectScope }; + +// Provider-level rules: keyed by executor.provider +const PROVIDER_RULES: Record = { + deepseek: { scope: "all" }, +}; + +// Model-level rules: matched by predicate against model id +const MODEL_RULES: Array<{ match: (m: string | undefined) => boolean; scope: InjectScope }> = [ + { match: (m) => Boolean(m?.startsWith?.("kimi-")), scope: "toolCalls" }, + { match: (m) => Boolean(m?.startsWith?.("deepseek-")), scope: "all" }, +]; + +type ChatMessage = { + role?: string; + reasoning_content?: unknown; + tool_calls?: unknown[]; + [key: string]: unknown; +}; + +type InjectBody = { + messages?: ChatMessage[]; + model?: string; + extra_body?: { + thinking?: Record; + [key: string]: unknown; + }; + reasoning_effort?: unknown; + [key: string]: unknown; +}; + +type InjectArgs = { + provider?: string; + model?: string; + body?: unknown; +}; + +function shouldInject(message: ChatMessage | null | undefined, scope: InjectScope) { + if (message?.role !== "assistant") return false; + const rc = message.reasoning_content; + if (typeof rc === "string" && rc.length > 0) return false; + if (scope === "toolCalls") + return Array.isArray(message.tool_calls) && message.tool_calls.length > 0; + return true; +} + +function applyDeepSeekV4ProAlias({ provider, model, body }: InjectArgs) { + const bodyRecord = body as InjectBody | null | undefined; + const alias = model ? DEEPSEEK_V4_PRO_ALIASES[model] : undefined; + if (provider !== "deepseek" || !alias || !bodyRecord) return bodyRecord; + + const nextBody: InjectBody = { + ...bodyRecord, + model: DEEPSEEK_V4_PRO, + extra_body: { + ...(bodyRecord.extra_body || {}), + thinking: { + ...(bodyRecord.extra_body?.thinking || {}), + type: alias.thinkingType, + }, + }, + }; + + if (alias.reasoningEffort) { + nextBody.reasoning_effort = alias.reasoningEffort; + } else { + delete nextBody.reasoning_effort; + } + + return nextBody; +} + +function applyRule(body: InjectBody | null | undefined, rule: InjectRule | undefined) { + if (!rule || !body?.messages) return body; + const messages = body.messages.map((m: ChatMessage) => + shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m, + ); + return { ...body, messages }; +} + +export function injectReasoningContent({ provider, model, body }: InjectArgs) { + const providerRule = provider ? PROVIDER_RULES[provider] : undefined; + const modelRule = MODEL_RULES.find((r) => r.match(model)); + const rule = providerRule || modelRule; + const nextBody = applyDeepSeekV4ProAlias({ provider, model, body }); + return applyRule(nextBody, rule); +} + +export { applyDeepSeekV4ProAlias }; diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.ts similarity index 63% rename from open-sse/utils/requestLogger.js rename to open-sse/utils/requestLogger.ts index 77f8d20b..5d441b7d 100644 --- a/open-sse/utils/requestLogger.js +++ b/open-sse/utils/requestLogger.ts @@ -6,9 +6,57 @@ const isNode = const LOGGING_ENABLED = typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === "true"; -let fs = null; -let path = null; -let LOGS_DIR = null; +type FsModule = typeof import("node:fs"); +type PathModule = typeof import("node:path"); +type HeaderRecord = Record; +type HeadersLike = { + entries: () => Iterable<[string, unknown]>; +}; +type RequestLogger = { + readonly sessionPath: string | null; + appendConvertedChunk(chunk?: string | Uint8Array): void; + appendOpenAIChunk(chunk?: string | Uint8Array): void; + appendProviderChunk(chunk?: string | Uint8Array): void; + logClientRawRequest(endpoint?: unknown, body?: unknown, headers?: HeaderRecord): void; + logConvertedResponse(body?: unknown): void; + logError(error?: unknown, requestBody?: unknown): void; + logOpenAIRequest(body?: unknown): void; + logProviderResponse( + status?: unknown, + statusText?: unknown, + headers?: HeaderRecord | HeadersLike | null, + body?: unknown, + ): void; + logRawRequest(body?: unknown, headers?: HeaderRecord): void; + logTargetRequest(url?: unknown, headers?: HeaderRecord, body?: unknown): void; +}; +type ErrorLogOptions = { + error?: unknown; + model?: unknown; + requestBody?: unknown; + url?: unknown; +}; + +let fs: FsModule | null = null; +let path: PathModule | null = null; +let LOGS_DIR: string | null = null; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isHeadersLike(value: unknown): value is HeadersLike { + return ( + value !== null && + typeof value === "object" && + "entries" in value && + typeof (value as { entries?: unknown }).entries === "function" + ); +} + +function errorStack(error: unknown) { + return error instanceof Error ? error.stack : undefined; +} // Lazy load Node.js modules (avoid top-level await) async function ensureNodeModules() { @@ -25,8 +73,8 @@ async function ensureNodeModules() { } // Format timestamp for folder name: 20251228_143045_123 -function formatTimestamp(date = new Date()) { - const pad = (n) => String(n).padStart(2, "0"); +function formatTimestamp(date: Date = new Date()) { + const pad = (n: number) => String(n).padStart(2, "0"); const y = date.getFullYear(); const m = pad(date.getMonth() + 1); const d = pad(date.getDate()); @@ -38,9 +86,9 @@ function formatTimestamp(date = new Date()) { } // Create log session folder: {sourceFormat}_{targetFormat}_{model}_{timestamp} -async function createLogSession(sourceFormat, targetFormat, model) { +async function createLogSession(sourceFormat: string, targetFormat: string, model: string) { await ensureNodeModules(); - if (!fs || !LOGS_DIR) return null; + if (!fs || !path || !LOGS_DIR) return null; try { if (!fs.existsSync(LOGS_DIR)) { @@ -55,26 +103,26 @@ async function createLogSession(sourceFormat, targetFormat, model) { fs.mkdirSync(sessionPath, { recursive: true }); return sessionPath; - } catch (err) { - console.log("[LOG] Failed to create log session:", err.message); + } catch (err: unknown) { + console.log("[LOG] Failed to create log session:", errorMessage(err)); return null; } } // Write JSON file -function writeJsonFile(sessionPath, filename, data) { - if (!fs || !sessionPath) return; +function writeJsonFile(sessionPath: string | null, filename: string, data: unknown) { + if (!fs || !path || !sessionPath) return; try { const filePath = path.join(sessionPath, filename); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); - } catch (err) { - console.log(`[LOG] Failed to write ${filename}:`, err.message); + } catch (err: unknown) { + console.log(`[LOG] Failed to write ${filename}:`, errorMessage(err)); } } // Mask sensitive data in headers (DISABLED - keep full token for testing) -function maskSensitiveHeaders(headers) { +function maskSensitiveHeaders(headers: HeaderRecord | HeadersLike | null | undefined) { if (!headers) return {}; return { ...headers }; @@ -95,7 +143,7 @@ function maskSensitiveHeaders(headers) { } // No-op logger when logging is disabled -function createNoOpLogger() { +function createNoOpLogger(): RequestLogger { return { sessionPath: null, logClientRawRequest() {}, @@ -118,7 +166,11 @@ function createNoOpLogger() { * @param {string} model - Model name * @returns {Promise} Promise that resolves to logger object with methods to log each stage */ -export async function createRequestLogger(sourceFormat, targetFormat, model) { +export async function createRequestLogger( + sourceFormat: string, + targetFormat: string, + model: string, +): Promise { // Return no-op logger if logging is disabled if (!LOGGING_ENABLED) { return createNoOpLogger(); @@ -133,7 +185,7 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 1. Log client raw request (before any conversion) - logClientRawRequest(endpoint, body, headers = {}) { + logClientRawRequest(endpoint: unknown, body: unknown, headers: HeaderRecord = {}) { writeJsonFile(sessionPath, "1_req_client.json", { timestamp: new Date().toISOString(), endpoint, @@ -143,7 +195,7 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 2. Log raw request from client (after initial conversion like responsesApi) - logRawRequest(body, headers = {}) { + logRawRequest(body: unknown, headers: HeaderRecord = {}) { writeJsonFile(sessionPath, "2_req_source.json", { timestamp: new Date().toISOString(), headers: maskSensitiveHeaders(headers), @@ -152,7 +204,7 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 3. Log OpenAI intermediate format (source → openai) - logOpenAIRequest(body) { + logOpenAIRequest(body: unknown) { writeJsonFile(sessionPath, "3_req_openai.json", { timestamp: new Date().toISOString(), body, @@ -160,7 +212,7 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 4. Log target format request (openai → target) - logTargetRequest(url, headers, body) { + logTargetRequest(url: unknown, headers: HeaderRecord = {}, body: unknown) { writeJsonFile(sessionPath, "4_req_target.json", { timestamp: new Date().toISOString(), url, @@ -170,14 +222,19 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 5. Log provider response (for non-streaming or error) - logProviderResponse(status, statusText, headers, body) { + logProviderResponse( + status: unknown, + statusText: unknown, + headers: HeaderRecord | HeadersLike | null | undefined, + body: unknown, + ) { const filename = "5_res_provider.json"; writeJsonFile(sessionPath, filename, { timestamp: new Date().toISOString(), status, statusText, headers: headers - ? typeof headers.entries === "function" + ? isHeadersLike(headers) ? Object.fromEntries(headers.entries()) : headers : {}, @@ -186,29 +243,29 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 5. Append streaming chunk to provider response - appendProviderChunk(chunk) { - if (!fs || !sessionPath) return; + appendProviderChunk(chunk: string | Uint8Array = "") { + if (!fs || !path || !sessionPath) return; try { const filePath = path.join(sessionPath, "5_res_provider.txt"); fs.appendFileSync(filePath, chunk); - } catch (_err) { + } catch { // Ignore append errors } }, // 6. Append OpenAI intermediate chunks (target → openai) - appendOpenAIChunk(chunk) { - if (!fs || !sessionPath) return; + appendOpenAIChunk(chunk: string | Uint8Array = "") { + if (!fs || !path || !sessionPath) return; try { const filePath = path.join(sessionPath, "6_res_openai.txt"); fs.appendFileSync(filePath, chunk); - } catch (_err) { + } catch { // Ignore append errors } }, // 7. Log converted response to client (for non-streaming) - logConvertedResponse(body) { + logConvertedResponse(body: unknown) { writeJsonFile(sessionPath, "7_res_client.json", { timestamp: new Date().toISOString(), body, @@ -216,22 +273,22 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }, // 7. Append streaming chunk to converted response - appendConvertedChunk(chunk) { - if (!fs || !sessionPath) return; + appendConvertedChunk(chunk: string | Uint8Array = "") { + if (!fs || !path || !sessionPath) return; try { const filePath = path.join(sessionPath, "7_res_client.txt"); fs.appendFileSync(filePath, chunk); - } catch (_err) { + } catch { // Ignore append errors } }, // 6. Log error - logError(error, requestBody = null) { + logError(error: unknown, requestBody: unknown = null) { writeJsonFile(sessionPath, "6_error.json", { timestamp: new Date().toISOString(), - error: error?.message || String(error), - stack: error?.stack, + error: errorMessage(error), + stack: errorStack(error), requestBody, }); }, @@ -241,8 +298,8 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { // Legacy functions for backward compatibility export function logRequest() {} export function logResponse() {} -export function logError(provider, { error, url, model, requestBody }) { - if (!fs || !LOGS_DIR) return; +export function logError(provider: string, { error, url, model, requestBody }: ErrorLogOptions) { + if (!fs || !path || !LOGS_DIR) return; try { if (!fs.existsSync(LOGS_DIR)) { @@ -258,13 +315,13 @@ export function logError(provider, { error, url, model, requestBody }) { provider, model, url, - error: error?.message || String(error), - stack: error?.stack, + error: errorMessage(error), + stack: errorStack(error), requestBody, }; fs.appendFileSync(logPath, JSON.stringify(logEntry) + "\n"); - } catch (err) { - console.log("[LOG] Failed to write error log:", err.message); + } catch (err: unknown) { + console.log("[LOG] Failed to write error log:", errorMessage(err)); } } diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.ts similarity index 97% rename from open-sse/utils/sessionManager.js rename to open-sse/utils/sessionManager.ts index 3db31a8b..2f8cb6d9 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.ts @@ -41,7 +41,7 @@ if (cleanupInterval.unref) cleanupInterval.unref(); * @param {string} connectionId - The connection identifier (email or unique ID) * @returns {string} A stable session ID string matching binary format */ -export function deriveSessionId(connectionId) { +export function deriveSessionId(connectionId: string | null | undefined) { if (!connectionId) { return generateBinaryStyleId(); } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.ts similarity index 76% rename from open-sse/utils/stream.js rename to open-sse/utils/stream.ts index e72ac955..8721dc25 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.ts @@ -1,7 +1,9 @@ +// @ts-nocheck import { appendRequestLog, trackPendingRequest } from "@/lib/usageDb"; import { CLAUDE_TOOL_SUFFIX } from "../config/appConstants.js"; import { FORMATS } from "../translator/formats.js"; import { initState, translateResponse } from "../translator/index.js"; +import type { TranslatorState } from "../translator/registry.js"; import { decloakToolNames } from "./claudeCloaking.js"; import { fixInvalidId, formatSSE, hasValuableContent, parseSSELine } from "./streamHelpers.js"; import { @@ -16,12 +18,49 @@ import { export { COLORS, formatSSE }; -function stripClaudeToolSuffixes(node) { +type JsonRecord = Record; +type MutableStreamChunk = JsonRecord & { + choices?: Array<{ finish_reason?: unknown }>; + type?: unknown; + usage?: unknown; +}; + +type RequestLogger = { + appendConvertedChunk?: (chunk: string) => void; + appendOpenAIChunk?: (chunk: string) => void; + appendProviderChunk?: (chunk: string) => void; +}; + +type StreamCompleteHandler = ( + result: { content: string; thinking: string }, + usage: unknown, + ttftAt: number | null, +) => void; + +type SSEStreamOptions = { + apiKey?: string | null; + body?: JsonRecord | null; + connectionId?: string | null; + mode?: (typeof STREAM_MODE)[keyof typeof STREAM_MODE]; + model?: string | null; + onStreamComplete?: StreamCompleteHandler | null; + provider?: string | null; + reqLogger?: RequestLogger | null; + sourceFormat?: string | null; + targetFormat?: string | null; + toolNameMap?: unknown; +}; + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stripClaudeToolSuffixes(node: unknown): unknown { if (!node || typeof node !== "object") return node; if (Array.isArray(node)) { let changed = false; - const next = node.map((child) => { + const next = node.map((child: unknown): unknown => { const mapped = stripClaudeToolSuffixes(child); if (mapped !== child) changed = true; return mapped; @@ -30,6 +69,8 @@ function stripClaudeToolSuffixes(node) { } if ( + "type" in node && + "name" in node && node.type === "tool_use" && typeof node.name === "string" && node.name.endsWith(CLAUDE_TOOL_SUFFIX) @@ -38,16 +79,17 @@ function stripClaudeToolSuffixes(node) { } let changed = false; - const next = {}; + const record = node as JsonRecord; + const next: JsonRecord = {}; for (const key of Object.keys(node)) { - const mapped = stripClaudeToolSuffixes(node[key]); - if (mapped !== node[key]) changed = true; + const mapped = stripClaudeToolSuffixes(record[key]); + if (mapped !== record[key]) changed = true; next[key] = mapped; } return changed ? next : node; } -function decloakSSELine(line, toolNameMap, allowSuffixFallback = false) { +function decloakSSELine(line: string, toolNameMap: unknown, allowSuffixFallback = false) { if (!line.includes("tool_use")) return line; const isDataLine = line.startsWith("data:"); @@ -63,28 +105,28 @@ function decloakSSELine(line, toolNameMap, allowSuffixFallback = false) { if (decloaked === parsed) return line; return (isDataLine ? "data: " : "") + JSON.stringify(decloaked); } catch { + // Malformed SSE data should pass through unchanged. return line; } } +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + /** * Extract reasoning summary text from a reasoning_summary payload. * Supports both direct `{ content: "..." }` and nested `{ summary: { content: "..." } }` shapes. */ -function extractReasoningSummaryText(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; +function extractReasoningSummaryText(value: unknown): string | null { + if (!isRecord(value)) return null; const direct = typeof value.content === "string" ? value.content.trim() : ""; if (direct.length > 0) return direct; const nested = value.summary; const nestedContent = - nested && - typeof nested === "object" && - !Array.isArray(nested) && - typeof nested.content === "string" - ? nested.content.trim() - : ""; + isRecord(nested) && typeof nested.content === "string" ? nested.content.trim() : ""; return nestedContent.length > 0 ? nestedContent : null; } @@ -93,8 +135,8 @@ function extractReasoningSummaryText(value) { * from a reasoning_summary envelope, so clients that consume delta.reasoning_content * (rather than top-level reasoning_summary) see the final summary. */ -function buildReasoningSummaryCompatChunk(chunk, summaryText) { - const compatChunk = { +function buildReasoningSummaryCompatChunk(chunk: JsonRecord, summaryText: string) { + const compatChunk: JsonRecord = { id: typeof chunk.id === "string" && chunk.id.trim().length > 0 ? chunk.id @@ -134,7 +176,7 @@ const sharedEncoder = new TextEncoder(); const STREAM_MODE = { TRANSLATE: "translate", // Full translation between formats PASSTHROUGH: "passthrough", // No translation, normalize output, extract usage -}; +} as const; /** * Create unified SSE transform stream @@ -152,7 +194,7 @@ const STREAM_MODE = { */ const STALL_TIMEOUT_MS = 300_000; // 5 minutes -export function createSSEStream(options = {}) { +export function createSSEStream(options: SSEStreamOptions = {}) { const { mode = STREAM_MODE.TRANSLATE, targetFormat, @@ -168,57 +210,59 @@ export function createSSEStream(options = {}) { } = options; let buffer = ""; - let usage = null; - let stallTimer = null; - let stallController = null; + let usage: unknown = null; + let stallTimer: ReturnType | null = null; + let resetStallTimer: (() => void) | null = null; // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks const decoder = new TextDecoder("utf-8", { fatal: false }); - const state = + const state: TranslatorState = mode === STREAM_MODE.TRANSLATE - ? { ...initState(sourceFormat), provider, toolNameMap, model } - : null; + ? { ...initState(sourceFormat!), provider, toolNameMap, model } + : {}; let totalContentLength = 0; let accumulatedContent = ""; let accumulatedThinking = ""; - let ttftAt = null; + let ttftAt: number | null = null; let sawDone = false; - const includeUsage = body?.stream_options?.include_usage === true; + const streamOptions = isRecord(body?.stream_options) ? body.stream_options : null; + const includeUsage = streamOptions?.include_usage === true; const allowSuffixFallback = provider === "claude"; - function emit(output, controller) { + function emit(output: string, controller: TransformStreamDefaultController) { reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); } - return new TransformStream({ + return new TransformStream({ start(controller) { - stallController = controller; - const resetStall = () => { + resetStallTimer = () => { if (stallTimer) clearTimeout(stallTimer); stallTimer = setTimeout(() => { const errChunk = `data: ${JSON.stringify({ error: { message: "Stream stalled: no data received for 5 minutes", type: "stream_stall", code: "stream_stall" } })}`; try { controller.enqueue(sharedEncoder.encode(errChunk + "\n\ndata: [DONE]\n\n")); - } catch {} + } catch { + // Client may already be gone; stall cleanup remains best effort. + } try { controller.terminate(); - } catch {} + } catch { + // Stream may already be closed by client disconnect. + } }, STALL_TIMEOUT_MS); stallTimer.unref?.(); }; - resetStall(); - // Expose reset so transform can call it - stallController._resetStall = resetStall; + resetStallTimer(); }, transform(chunk, controller) { try { // Reset stall timer on each received chunk - stallController?._resetStall?.(); + resetStallTimer?.(); if (!ttftAt) { ttftAt = Date.now(); @@ -231,7 +275,7 @@ export function createSSEStream(options = {}) { buffer = lines.pop() || ""; for (let i = 0; i < lines.length; i++) { - lines[i] = decloakSSELine(lines[i], toolNameMap, allowSuffixFallback); + lines[i] = decloakSSELine(lines[i] ?? "", toolNameMap, allowSuffixFallback); } for (const line of lines) { @@ -239,7 +283,7 @@ export function createSSEStream(options = {}) { // Passthrough mode: normalize and forward if (mode === STREAM_MODE.PASSTHROUGH) { - let output; + let output = ""; let injectedUsage = false; if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") { @@ -366,7 +410,9 @@ export function createSSEStream(options = {}) { output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; } - } catch {} + } catch { + // Malformed passthrough chunks are forwarded in their original form. + } } if (!injectedUsage) { @@ -439,7 +485,7 @@ export function createSSEStream(options = {}) { if (extracted) state.usage = extracted; // Keep original usage for logging // Translate: targetFormat -> openai -> sourceFormat - const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + const translated = translateResponse(targetFormat!, sourceFormat!, parsed, state); // Log OpenAI intermediate chunks (if available) if (translated?._openaiIntermediate) { @@ -451,46 +497,55 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { + const streamItem = item as MutableStreamChunk; // Filter empty chunks - if (!hasValuableContent(item, sourceFormat)) { + if (!hasValuableContent(streamItem, sourceFormat!)) { continue; // Skip this empty chunk } // Inject estimated usage if finish chunk has no valid usage const isFinishChunk = - item.type === "message_delta" || item.choices?.[0]?.finish_reason; + streamItem.type === "message_delta" || streamItem.choices?.[0]?.finish_reason; if (includeUsage) { if ( state.finishReason && isFinishChunk && - !hasValidUsage(item.usage) && + !hasValidUsage(streamItem.usage) && totalContentLength > 0 ) { - const estimated = estimateUsage(body, totalContentLength, sourceFormat); - item.usage = filterUsageForFormat(estimated, sourceFormat); + const estimated = estimateUsage(body, totalContentLength, sourceFormat!); + streamItem.usage = filterUsageForFormat(estimated, sourceFormat!); state.usage = estimated; } else if (state.finishReason && isFinishChunk && state.usage) { const buffered = addBufferToUsage(state.usage); - item.usage = filterUsageForFormat(buffered, sourceFormat); + streamItem.usage = filterUsageForFormat(buffered, sourceFormat!); } } - emit(formatSSE(item, sourceFormat), controller); + emit(formatSSE(streamItem, sourceFormat!), controller); } } } - } catch (_transformError) { - console.error("[STREAM_TRANSFORM] Transform error; attempting graceful termination"); + } catch (transformError: unknown) { + console.error( + isAbortError(transformError) + ? "[STREAM_TRANSFORM] Transform aborted; attempting graceful termination" + : "[STREAM_TRANSFORM] Transform error; attempting graceful termination", + ); try { controller.enqueue( sharedEncoder.encode( `data: ${JSON.stringify({ error: { message: "Stream processing error", type: "server_error" } })}\n\ndata: [DONE]\n\n`, ), ); - } catch {} + } catch { + // Client may already be disconnected; error frame is best effort. + } try { controller.terminate(); - } catch {} + } catch { + // Stream may already be closed after abort/error. + } } }, @@ -499,7 +554,7 @@ export function createSSEStream(options = {}) { clearTimeout(stallTimer); stallTimer = null; } - trackPendingRequest(model, provider, connectionId, false); + trackPendingRequest(model || "", provider || "", connectionId || "", false); try { const remaining = decoder.decode(); if (remaining) buffer += remaining; @@ -523,12 +578,14 @@ export function createSSEStream(options = {}) { logUsage(provider, usage, model, connectionId, apiKey); } else { appendRequestLog({ - model, - provider, - connectionId, + model: model ?? undefined, + provider: provider ?? undefined, + connectionId: connectionId ?? undefined, tokens: null, status: "SUCCESS", - }).catch(() => {}); + }).catch(() => { + // Best-effort usage log; do not fail stream flush. + }); } if (!sawDone) emit("data: [DONE]\n\n", controller); @@ -550,7 +607,7 @@ export function createSSEStream(options = {}) { const decloaked = decloakSSELine(buffer, toolNameMap, allowSuffixFallback); const parsed = parseSSELine(decloaked.trim()); if (parsed && !parsed.done) { - const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + const translated = translateResponse(targetFormat!, sourceFormat!, parsed, state); if (translated?._openaiIntermediate) { for (const item of translated._openaiIntermediate) { @@ -561,13 +618,13 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { - emit(formatSSE(item, sourceFormat), controller); + emit(formatSSE(item, sourceFormat!), controller); } } } } - const flushed = translateResponse(targetFormat, sourceFormat, null, state); + const flushed = translateResponse(targetFormat!, sourceFormat!, null, state); if (flushed?._openaiIntermediate) { for (const item of flushed._openaiIntermediate) { @@ -578,26 +635,28 @@ export function createSSEStream(options = {}) { if (flushed?.length > 0) { for (const item of flushed) { - emit(formatSSE(item, sourceFormat), controller); + emit(formatSSE(item, sourceFormat!), controller); } } emit("data: [DONE]\n\n", controller); if (!hasValidUsage(state?.usage) && totalContentLength > 0) { - state.usage = estimateUsage(body, totalContentLength, sourceFormat); + state.usage = estimateUsage(body, totalContentLength, sourceFormat!); } if (hasValidUsage(state?.usage)) { logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); } else { appendRequestLog({ - model, - provider, - connectionId, + model: model ?? undefined, + provider: provider ?? undefined, + connectionId: connectionId ?? undefined, tokens: null, status: "SUCCESS", - }).catch(() => {}); + }).catch(() => { + // Best-effort usage log; do not fail stream flush. + }); } if (onStreamComplete) { @@ -610,7 +669,11 @@ export function createSSEStream(options = {}) { ttftAt, ); } - } catch (_error) { + } catch (error: unknown) { + if (isAbortError(error)) { + console.log("Stream flush aborted"); + return; + } console.log("Error in flush"); } }, @@ -618,16 +681,16 @@ export function createSSEStream(options = {}) { } export function createSSETransformStreamWithLogger( - targetFormat, - sourceFormat, - provider = null, - reqLogger = null, - toolNameMap = null, - model = null, - connectionId = null, - body = null, - onStreamComplete = null, - apiKey = null, + targetFormat: string, + sourceFormat: string, + provider: string | null = null, + reqLogger: RequestLogger | null = null, + toolNameMap: unknown = null, + model: string | null = null, + connectionId: string | null = null, + body: JsonRecord | null = null, + onStreamComplete: StreamCompleteHandler | null = null, + apiKey: string | null = null, ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -645,15 +708,15 @@ export function createSSETransformStreamWithLogger( } export function createPassthroughStreamWithLogger( - provider = null, - reqLogger = null, - model = null, - connectionId = null, - body = null, - onStreamComplete = null, - apiKey = null, - sourceFormat = null, - toolNameMap = null, + provider: string | null = null, + reqLogger: RequestLogger | null = null, + model: string | null = null, + connectionId: string | null = null, + body: JsonRecord | null = null, + onStreamComplete: StreamCompleteHandler | null = null, + apiKey: string | null = null, + sourceFormat: string | null = null, + toolNameMap: unknown = null, ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.ts similarity index 62% rename from open-sse/utils/streamHandler.js rename to open-sse/utils/streamHandler.ts index fed08e20..6f3e9b64 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.ts @@ -1,5 +1,28 @@ // Stream handler with disconnect detection - shared for all providers +type StreamControllerOptions = { + onDisconnect?: (info: { reason: unknown; duration: number }) => void; + onError?: (error: unknown) => void; + log?: unknown; + provider?: unknown; + model?: unknown; +}; + +export type StreamController = { + signal: AbortSignal; + startTime: number; + isConnected: () => boolean; + handleDisconnect: (reason?: unknown) => void; + handleComplete: () => void; + handleError: (error: unknown) => void; + abort: () => void; +}; + +type StreamPair = { + readable: { getReader: () => ReadableStreamDefaultReader }; + writable: { getWriter: () => { abort: (reason?: unknown) => Promise } }; +}; + // Get HH:MM:SS timestamp function getTimeString() { return new Date().toLocaleTimeString("en-US", { @@ -10,6 +33,14 @@ function getTimeString() { }); } +function errorName(error: unknown) { + return error instanceof Error ? error.name : ""; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + /** * Create stream controller with abort and disconnect detection * @param {object} options @@ -21,16 +52,16 @@ function getTimeString() { export function createStreamController({ onDisconnect, onError, - log, + log: _log, provider: _provider, model: _model, -} = {}) { +}: StreamControllerOptions = {}): StreamController { const abortController = new AbortController(); const startTime = Date.now(); let disconnected = false; - let abortTimeout = null; + let abortTimeout: ReturnType | null = null; - const logStream = (status) => { + const logStream = (status: string) => { const duration = Date.now() - startTime; console.log(`[${getTimeString()}] 🌊 [STREAM] ${duration}ms | ${status}`); }; @@ -42,7 +73,7 @@ export function createStreamController({ isConnected: () => !disconnected, // Call when client disconnects - handleDisconnect: (reason = "client_closed") => { + handleDisconnect: (reason: unknown = "client_closed") => { if (disconnected) return; disconnected = true; @@ -70,7 +101,7 @@ export function createStreamController({ }, // Call on error - handleError: (error) => { + handleError: (error: unknown) => { if (disconnected) return; disconnected = true; @@ -79,12 +110,12 @@ export function createStreamController({ abortTimeout = null; } - if (error.name === "AbortError") { + if (errorName(error) === "AbortError") { logStream("aborted"); return; } - logStream(`error: ${error.message}`); + logStream(`error: ${errorMessage(error)}`); onError?.(error); }, @@ -96,12 +127,15 @@ export function createStreamController({ * Create transform stream with disconnect detection * Wraps existing transform stream and adds abort capability */ -export function createDisconnectAwareStream(transformStream, streamController) { +export function createDisconnectAwareStream( + transformStream: StreamPair, + streamController: StreamController, +) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); return new ReadableStream({ - async pull(controller) { + async pull(controller: ReadableStreamDefaultController) { if (!streamController.isConnected()) { controller.close(); return; @@ -115,16 +149,20 @@ export function createDisconnectAwareStream(transformStream, streamController) { return; } controller.enqueue(value); - } catch (error) { + } catch (error: unknown) { streamController.handleError(error); // Cleanup reader/writer to avoid orphaned streams - reader.cancel().catch(() => {}); - writer.abort().catch(() => {}); + reader.cancel().catch(() => { + // Cleanup only; stream is already handling the read error. + }); + writer.abort().catch(() => { + // Cleanup only; stream is already handling the read error. + }); controller.error(error); } }, - cancel(reason) { + cancel(reason?: unknown) { streamController.handleDisconnect(reason || "cancelled"); reader.cancel(); writer.abort(); @@ -138,13 +176,18 @@ export function createDisconnectAwareStream(transformStream, streamController) { * @param {TransformStream} transformStream - Transform stream for SSE * @param {object} streamController - Stream controller from createStreamController */ -export function pipeWithDisconnect(providerResponse, transformStream, streamController) { - const transformedBody = providerResponse.body.pipeThrough(transformStream); +export function pipeWithDisconnect( + providerResponse: Response, + transformStream: TransformStream, + streamController: unknown, +) { + const ctrl = streamController as StreamController; + const transformedBody = (providerResponse.body as ReadableStream).pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) }, }, - streamController, + ctrl, ); } diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.ts similarity index 56% rename from open-sse/utils/streamHelpers.js rename to open-sse/utils/streamHelpers.ts index e8c1f9d2..11f0bcf4 100644 --- a/open-sse/utils/streamHelpers.js +++ b/open-sse/utils/streamHelpers.ts @@ -1,8 +1,49 @@ import { FORMATS } from "../translator/formats.js"; +type JsonRecord = Record; + +type OpenAIDelta = { + content?: string; + reasoning_content?: string; + tool_calls?: unknown[]; + role?: unknown; +}; + +type OpenAIChunk = JsonRecord & { + choices?: Array<{ + delta?: OpenAIDelta; + finish_reason?: unknown; + }>; + reasoning_summary?: unknown; +}; + +type ClaudeChunk = JsonRecord & { + type?: string; + delta?: { + text?: string; + thinking?: string; + partial_json?: string; + }; +}; + +type IdFixable = JsonRecord & { + id?: string; + extend_fields?: { requestId?: string; traceId?: string }; +}; + +type UsagePayload = JsonRecord & { + usage?: { perf_metrics?: unknown; [key: string]: unknown } | null; + response?: unknown; + done?: unknown; + event?: unknown; + data?: unknown; + type?: unknown; +}; + // Parse SSE data line -export function parseSSELine(line, format = null) { +export function parseSSELine(line: unknown, format: unknown = null) { if (!line) return null; + if (typeof line !== "string") return null; const trimmed = line.trim(); @@ -26,7 +67,7 @@ export function parseSSELine(line, format = null) { try { return JSON.parse(data); - } catch (_error) { + } catch { if (data.length > 0 && data.length < 1000) { console.log( `[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`, @@ -37,20 +78,21 @@ export function parseSSELine(line, format = null) { } // Check if chunk has valuable content (not empty) -export function hasValuableContent(chunk, format) { +export function hasValuableContent(chunk: unknown, format: unknown) { // OpenAI format if (format === FORMATS.OPENAI) { + const openaiChunk = chunk as OpenAIChunk | null; // Keep chunks that carry top-level reasoning summary envelopes, even when // `choices` is empty (Inception-style final summary chunk). if ( - chunk && - typeof chunk === "object" && - Object.prototype.hasOwnProperty.call(chunk, "reasoning_summary") + openaiChunk && + typeof openaiChunk === "object" && + Object.prototype.hasOwnProperty.call(openaiChunk, "reasoning_summary") ) { return true; } - if (!chunk.choices?.[0]?.delta) return false; - const delta = chunk.choices[0].delta; + if (!openaiChunk?.choices?.[0]?.delta) return false; + const delta = openaiChunk.choices[0].delta; if (delta.content && delta.content !== "") { const trimmed = delta.content.trim(); @@ -70,27 +112,30 @@ export function hasValuableContent(chunk, format) { (delta.content && delta.content !== "") || (delta.reasoning_content && delta.reasoning_content !== "") || (delta.tool_calls && delta.tool_calls.length > 0) || - chunk.choices[0].finish_reason || + openaiChunk.choices[0].finish_reason || delta.role ); } // Claude format if (format === FORMATS.CLAUDE) { - const isContentBlockDelta = chunk.type === "content_block_delta"; - const hasText = chunk.delta?.text && chunk.delta.text !== ""; - const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== ""; - const hasInputJson = chunk.delta?.partial_json && chunk.delta.partial_json !== ""; + const claudeChunk = chunk as ClaudeChunk; + const isContentBlockDelta = claudeChunk.type === "content_block_delta"; + const hasText = Boolean(claudeChunk.delta?.text && claudeChunk.delta.text !== ""); + const hasThinking = Boolean(claudeChunk.delta?.thinking && claudeChunk.delta.thinking !== ""); + const hasInputJson = Boolean( + claudeChunk.delta?.partial_json && claudeChunk.delta.partial_json !== "", + ); if (hasText) { - const trimmed = chunk.delta.text.trim(); + const trimmed = claudeChunk.delta!.text!.trim(); if (trimmed === "..." || trimmed === "…") { return false; } } if (hasThinking) { - const trimmed = chunk.delta.thinking.trim(); + const trimmed = claudeChunk.delta!.thinking!.trim(); if (trimmed === "..." || trimmed === "…") { return false; } @@ -106,7 +151,7 @@ export function hasValuableContent(chunk, format) { } // Fix invalid id (generic or too short) -export function fixInvalidId(parsed) { +export function fixInvalidId(parsed: IdFixable) { if (parsed.id && (parsed.id === "chat" || parsed.id === "completion" || parsed.id.length < 8)) { const fallbackId = parsed.extend_fields?.requestId || parsed.extend_fields?.traceId || Date.now().toString(36); @@ -116,19 +161,19 @@ export function fixInvalidId(parsed) { return false; } -function cleanUsagePayload(payload) { +function cleanUsagePayload(payload: unknown): unknown { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return payload; } - let cleaned = payload; + let cleaned: UsagePayload = payload as UsagePayload; if ("usage" in cleaned) { if (cleaned.usage === null) { - const { usage, ...payloadWithoutUsage } = cleaned; + const { usage: _usage, ...payloadWithoutUsage } = cleaned; cleaned = payloadWithoutUsage; } else if (typeof cleaned.usage === "object" && cleaned.usage.perf_metrics === null) { - const { perf_metrics, ...usageWithoutPerf } = cleaned.usage; + const { perf_metrics: _perf_metrics, ...usageWithoutPerf } = cleaned.usage; cleaned = { ...cleaned, usage: usageWithoutPerf }; } } @@ -148,21 +193,23 @@ function cleanUsagePayload(payload) { } // Format output as SSE -export function formatSSE(data, sourceFormat) { +export function formatSSE(data: unknown, sourceFormat: unknown) { if (data === null || data === undefined) return "data: null\n\n"; - if (data && data.done) return "data: [DONE]\n\n"; + const record = data as UsagePayload | null; + if (record && record.done) return "data: [DONE]\n\n"; // OpenAI Responses API format - if (data && data.event && data.data) { - const cleanedEventData = cleanUsagePayload(data.data); - return `event: ${data.event}\ndata: ${JSON.stringify(cleanedEventData)}\n\n`; + if (record && record.event && record.data) { + const cleanedEventData = cleanUsagePayload(record.data); + return `event: ${record.event}\ndata: ${JSON.stringify(cleanedEventData)}\n\n`; } data = cleanUsagePayload(data); + const cleaned = data as UsagePayload | null; // Claude format - if (sourceFormat === FORMATS.CLAUDE && data && data.type) { - return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`; + if (sourceFormat === FORMATS.CLAUDE && cleaned && cleaned.type) { + return `event: ${cleaned.type}\ndata: ${JSON.stringify(cleaned)}\n\n`; } return `data: ${JSON.stringify(data)}\n\n`; diff --git a/open-sse/utils/tokenBudget.js b/open-sse/utils/tokenBudget.ts similarity index 50% rename from open-sse/utils/tokenBudget.js rename to open-sse/utils/tokenBudget.ts index 2eb9c810..603117f6 100644 --- a/open-sse/utils/tokenBudget.js +++ b/open-sse/utils/tokenBudget.ts @@ -1,12 +1,25 @@ const DEFAULT_REASONING_HEADROOM_TOKENS = 4096; +type TokenBudgetBody = Record; + +type TokenBudgetLog = { + debug?: (tag: string, msg: string) => void; +}; + +type TokenBudgetOptions = { + provider?: string; + model?: string; + targetFormat?: string; + log?: TokenBudgetLog; +}; + function configuredMinimum() { const raw = Number(process.env.MIN_UPSTREAM_REASONING_TOKENS || ""); if (Number.isFinite(raw) && raw > 0) return Math.floor(raw); return DEFAULT_REASONING_HEADROOM_TOKENS; } -function shouldReserveReasoningBudget(provider, targetFormat) { +function shouldReserveReasoningBudget(provider: unknown, targetFormat: unknown) { return ( targetFormat === "openai" && typeof provider === "string" && @@ -14,7 +27,14 @@ function shouldReserveReasoningBudget(provider, targetFormat) { ); } -function raiseTokenField(body, field, minimum, log, provider, model) { +function raiseTokenField( + body: TokenBudgetBody, + field: string, + minimum: number, + log: TokenBudgetLog | undefined, + provider: unknown, + model: unknown, +) { const value = body[field]; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= minimum) return; @@ -26,12 +46,16 @@ function raiseTokenField(body, field, minimum, log, provider, model) { ); } -export function reserveReasoningTokenBudget(body, { provider, model, targetFormat, log } = {}) { +export function reserveReasoningTokenBudget( + body: unknown, + { provider, model, targetFormat, log }: TokenBudgetOptions = {}, +) { if (!body || typeof body !== "object" || Array.isArray(body)) return body; - if (!shouldReserveReasoningBudget(provider, targetFormat)) return body; + const bodyRecord = body as TokenBudgetBody; + if (!shouldReserveReasoningBudget(provider, targetFormat)) return bodyRecord; const minimum = configuredMinimum(); - raiseTokenField(body, "max_tokens", minimum, log, provider, model); - raiseTokenField(body, "max_completion_tokens", minimum, log, provider, model); - return body; + raiseTokenField(bodyRecord, "max_tokens", minimum, log, provider, model); + raiseTokenField(bodyRecord, "max_completion_tokens", minimum, log, provider, model); + return bodyRecord; } diff --git a/open-sse/utils/toolDeduper.js b/open-sse/utils/toolDeduper.ts similarity index 67% rename from open-sse/utils/toolDeduper.js rename to open-sse/utils/toolDeduper.ts index 20c47403..b5145740 100644 --- a/open-sse/utils/toolDeduper.js +++ b/open-sse/utils/toolDeduper.ts @@ -3,7 +3,14 @@ * Goal: reduce tool definitions token bloat for Claude clients. */ -const DEDUP_RULES = [ +type ToolLike = { + name?: string; + function?: { name?: string }; +}; + +type NamePattern = string | RegExp; + +const DEDUP_RULES: Array<{ triggers: NamePattern[]; strip: NamePattern[] }> = [ { // Exa MCP present → drop built-in web tools (Exa is preferred). triggers: ["mcp__exa__web_search_exa", "mcp__exa__web_fetch_exa"], @@ -21,19 +28,20 @@ const DEDUP_RULES = [ }, ]; -function getToolName(t) { +function getToolName(t: ToolLike | null | undefined) { return t?.name || t?.function?.name || ""; } -function matches(name, pattern) { +function matches(name: string, pattern: NamePattern) { if (typeof pattern === "string") return name === pattern; return pattern instanceof RegExp ? pattern.test(name) : false; } -function dedupeTools(tools) { - if (!Array.isArray(tools) || tools.length === 0) return { tools, stripped: [] }; - const names = tools.map(getToolName); - const toStrip = new Set(); +function dedupeTools(tools: unknown) { + if (!Array.isArray(tools) || tools.length === 0) return { tools, stripped: [] as string[] }; + const toolList = tools as ToolLike[]; + const names = toolList.map(getToolName); + const toStrip = new Set(); for (const rule of DEDUP_RULES) { const hasTrigger = names.some((n) => rule.triggers.some((p) => matches(n, p))); if (!hasTrigger) continue; @@ -41,8 +49,8 @@ function dedupeTools(tools) { if (rule.strip.some((p) => matches(n, p))) toStrip.add(n); } } - if (toStrip.size === 0) return { tools, stripped: [] }; - const out = tools.filter((t) => !toStrip.has(getToolName(t))); + if (toStrip.size === 0) return { tools, stripped: [] as string[] }; + const out = toolList.filter((t) => !toStrip.has(getToolName(t))); return { tools: out, stripped: Array.from(toStrip) }; } diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.ts similarity index 64% rename from open-sse/utils/usageTracking.js rename to open-sse/utils/usageTracking.ts index d65ffd61..5f468e60 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.ts @@ -3,7 +3,50 @@ */ import { appendRequestLog, saveRequestUsage } from "@/lib/usageDb"; -import { FORMATS } from "../translator/formats.js"; +import { FORMATS, type FormatId } from "../translator/formats.js"; + +type UsageRecord = Record & { + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + cached_tokens?: number; + cachedContentTokenCount?: number; + candidatesTokenCount?: number; + completion_tokens?: number; + completion_tokens_details?: Record & { reasoning_tokens?: number }; + estimated?: unknown; + eval_count?: number; + input_tokens?: number; + input_tokens_details?: Record & { cached_tokens?: number }; + output_tokens?: number; + output_tokens_details?: Record & { reasoning_tokens?: number }; + promptTokenCount?: number; + prompt_eval_count?: number; + prompt_cache_hit_tokens?: number; + prompt_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; + reasoning_tokens?: number; + thoughtsTokenCount?: number; + totalTokenCount?: number; + total_tokens?: number; + usage?: UsageRecord; +}; +type ResponseChunk = UsageRecord & { + done?: boolean; + response?: { usage?: UsageRecord; usageMetadata?: UsageRecord }; + type?: string; + usageMetadata?: UsageRecord; +}; +type TokenSummary = { + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + completion_tokens: number; + prompt_tokens: number; + reasoning_tokens: number; +}; + +function isRecord(value: unknown): value is UsageRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} // ANSI color codes export const COLORS = { @@ -30,11 +73,11 @@ function getTimeString() { /** * Add buffer tokens to usage to prevent context errors - * @param {object} usage - Usage object (any format) + * @param {object} usage - Usage object from a provider format * @returns {object} Usage with buffer added */ -export function addBufferToUsage(usage) { - if (!usage || typeof usage !== "object") return usage; +export function addBufferToUsage(usage: unknown) { + if (!isRecord(usage)) return usage; const result = { ...usage }; @@ -59,12 +102,12 @@ export function addBufferToUsage(usage) { return result; } -export function filterUsageForFormat(usage, targetFormat) { - if (!usage || typeof usage !== "object") return usage; +export function filterUsageForFormat(usage: unknown, targetFormat: FormatId | string) { + if (!isRecord(usage)) return usage; // Helper to pick only defined fields from usage - const pickFields = (fields) => { - const filtered = {}; + const pickFields = (fields: readonly string[]) => { + const filtered: Record = {}; for (const field of fields) { if (usage[field] !== undefined) { filtered[field] = usage[field]; @@ -74,7 +117,7 @@ export function filterUsageForFormat(usage, targetFormat) { }; // Define allowed fields for each format - const formatFields = { + const formatFields: Record = { [FORMATS.CLAUDE]: [ "input_tokens", "output_tokens", @@ -122,17 +165,17 @@ export function filterUsageForFormat(usage, targetFormat) { fields = formatFields.default; } - return pickFields(fields); + return pickFields(fields || formatFields.default!); } /** * Normalize usage object - ensure all values are valid numbers */ -export function normalizeUsage(usage) { - if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; +export function normalizeUsage(usage: unknown) { + if (!isRecord(usage)) return null; - const normalized = {}; - const assignNumber = (key, value) => { + const normalized: UsageRecord = {}; + const assignNumber = (key: string, value: unknown) => { if (value === undefined || value === null) return; const numeric = Number(value); if (Number.isFinite(numeric)) normalized[key] = numeric; @@ -163,8 +206,8 @@ export function normalizeUsage(usage) { * Valid = has at least one token field with value > 0 * Invalid = empty object {}, null, undefined, no token fields, or all zeros */ -export function hasValidUsage(usage) { - if (!usage || typeof usage !== "object") return false; +export function hasValidUsage(usage: unknown) { + if (!isRecord(usage)) return false; // Check for any known token field with value > 0 const tokenFields = [ @@ -187,28 +230,28 @@ export function hasValidUsage(usage) { } /** - * Extract usage from any format (Claude, OpenAI, Gemini, Responses API) + * Extract usage from provider formats (Claude, OpenAI, Gemini, Responses API) */ -export function extractUsage(chunk) { - if (!chunk || typeof chunk !== "object") return null; +export function extractUsage(chunk: unknown) { + if (!isRecord(chunk)) return null; + const typedChunk = chunk as ResponseChunk; // Claude format (message_delta event) - if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { + if (typedChunk.type === "message_delta" && isRecord(typedChunk.usage)) { return normalizeUsage({ - prompt_tokens: chunk.usage.input_tokens || 0, - completion_tokens: chunk.usage.output_tokens || 0, - cache_read_input_tokens: chunk.usage.cache_read_input_tokens, - cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, + prompt_tokens: typedChunk.usage.input_tokens || 0, + completion_tokens: typedChunk.usage.output_tokens || 0, + cache_read_input_tokens: typedChunk.usage.cache_read_input_tokens, + cache_creation_input_tokens: typedChunk.usage.cache_creation_input_tokens, }); } // OpenAI Responses API format (response.completed or response.done) if ( - (chunk.type === "response.completed" || chunk.type === "response.done") && - chunk.response?.usage && - typeof chunk.response.usage === "object" + (typedChunk.type === "response.completed" || typedChunk.type === "response.done") && + isRecord(typedChunk.response?.usage) ) { - const usage = chunk.response.usage; + const usage = typedChunk.response.usage; const cachedTokens = usage.input_tokens_details?.cached_tokens; return normalizeUsage({ prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0, @@ -220,33 +263,34 @@ export function extractUsage(chunk) { } // OpenAI format (also covers DeepSeek which uses prompt_cache_hit_tokens) - if (chunk.usage && typeof chunk.usage === "object" && chunk.usage.prompt_tokens !== undefined) { + if (isRecord(typedChunk.usage) && typedChunk.usage.prompt_tokens !== undefined) { return normalizeUsage({ - prompt_tokens: chunk.usage.prompt_tokens, - completion_tokens: chunk.usage.completion_tokens || 0, + prompt_tokens: typedChunk.usage.prompt_tokens, + completion_tokens: typedChunk.usage.completion_tokens || 0, cached_tokens: - chunk.usage.prompt_tokens_details?.cached_tokens || chunk.usage.prompt_cache_hit_tokens, - reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens, - prompt_tokens_details: chunk.usage.prompt_tokens_details, - completion_tokens_details: chunk.usage.completion_tokens_details, + typedChunk.usage.prompt_tokens_details?.cached_tokens || + typedChunk.usage.prompt_cache_hit_tokens, + reasoning_tokens: typedChunk.usage.completion_tokens_details?.reasoning_tokens, + prompt_tokens_details: typedChunk.usage.prompt_tokens_details, + completion_tokens_details: typedChunk.usage.completion_tokens_details, }); } // Ollama format (done=true chunk with prompt_eval_count/eval_count) if ( - chunk.done === true && - (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined) + typedChunk.done === true && + (typedChunk.prompt_eval_count !== undefined || typedChunk.eval_count !== undefined) ) { return normalizeUsage({ - prompt_tokens: chunk.prompt_eval_count || 0, - completion_tokens: chunk.eval_count || 0, + prompt_tokens: typedChunk.prompt_eval_count || 0, + completion_tokens: typedChunk.eval_count || 0, }); } // Gemini format (Antigravity) // Antigravity wraps usageMetadata inside response: { response: { usageMetadata: {...} } } - const usageMeta = chunk.usageMetadata || chunk.response?.usageMetadata; - if (usageMeta && typeof usageMeta === "object") { + const usageMeta = typedChunk.usageMetadata || typedChunk.response?.usageMetadata; + if (isRecord(usageMeta)) { return normalizeUsage({ prompt_tokens: usageMeta.promptTokenCount || 0, completion_tokens: usageMeta.candidatesTokenCount || 0, @@ -263,8 +307,8 @@ export function extractUsage(chunk) { * Estimate input tokens from request body * Calculate total body size for more accurate estimation */ -export function estimateInputTokens(body) { - if (!body || typeof body !== "object") return 0; +export function estimateInputTokens(body: unknown) { + if (!isRecord(body)) return 0; try { // Calculate total body size (includes messages, tools, system, thinking config, etc.) @@ -273,7 +317,7 @@ export function estimateInputTokens(body) { // Estimate: ~4 chars per token (rough average across all tokenizers) return Math.ceil(totalChars / 4); - } catch (_err) { + } catch { // Fallback if stringify fails return 0; } @@ -282,7 +326,7 @@ export function estimateInputTokens(body) { /** * Estimate output tokens from content length */ -export function estimateOutputTokens(contentLength) { +export function estimateOutputTokens(contentLength: number) { if (!contentLength || contentLength <= 0) return 0; return Math.max(1, Math.floor(contentLength / 4)); } @@ -293,7 +337,11 @@ export function estimateOutputTokens(contentLength) { * @param {number} outputTokens - Output/completion tokens * @param {string} targetFormat - Target format from FORMATS */ -export function formatUsage(inputTokens, outputTokens, targetFormat) { +export function formatUsage( + inputTokens: number, + outputTokens: number, + targetFormat: FormatId | string, +) { // Claude format uses input_tokens/output_tokens if (targetFormat === FORMATS.CLAUDE) { return addBufferToUsage({ @@ -318,15 +366,25 @@ export function formatUsage(inputTokens, outputTokens, targetFormat) { * @param {number} contentLength - Content length for output token estimation * @param {string} targetFormat - Target format from FORMATS constant */ -export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) { +export function estimateUsage( + body: unknown, + contentLength: number, + targetFormat: FormatId | string = FORMATS.OPENAI, +) { return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat); } /** * Log usage with cache info (green color) */ -export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) { - if (!usage || typeof usage !== "object") return; +export function logUsage( + provider: string | null | undefined, + usage: unknown, + model: string | null = null, + connectionId: string | null = null, + apiKey: string | null = null, +) { + if (!isRecord(usage)) return; const p = provider?.toUpperCase() || "UNKNOWN"; @@ -360,15 +418,29 @@ export function logUsage(provider, usage, model = null, connectionId = null, api console.log(msg); // Save to usage DB - const tokens = { + const tokens: TokenSummary = { prompt_tokens: inTokens, completion_tokens: outTokens, cache_read_input_tokens: cacheRead || 0, cache_creation_input_tokens: cacheCreation || 0, reasoning_tokens: reasoning || 0, }; - saveRequestUsage({ model, provider, connectionId, tokens, apiKey: apiKey || undefined }).catch( - () => {}, - ); - appendRequestLog({ model, provider, connectionId, tokens, status: "SUCCESS" }).catch(() => {}); + saveRequestUsage({ + model: model || undefined, + provider: provider || undefined, + connectionId: connectionId || undefined, + tokens, + apiKey: apiKey || undefined, + }).catch(() => { + // Best-effort usage persistence; never fail stream completion on metrics writes. + }); + appendRequestLog({ + model: model || undefined, + provider: provider || undefined, + connectionId: connectionId || undefined, + tokens, + status: "SUCCESS", + }).catch(() => { + // Best-effort request log; never fail stream completion on metrics writes. + }); } diff --git a/package.json b/package.json index a9753a1e..29920be5 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "private": true, "description": "Pod web dashboard", "scripts": { - "dev": "bun --bun next dev --turbopack --port 20128", - "build": "NODE_ENV=production bun --bun next build --turbopack && bun run gen:sw-version", - "gen:sw-version": "bun run scripts/gen-sw-version.mjs", + "dev": "bun run gen:open-sse-shims && bun run build:sw && bun --bun next dev --turbopack --port 20128", + "build": "bun run gen:open-sse-shims && bun run build:sw && NODE_ENV=production bun --bun next build --turbopack && bun run gen:sw-version", + "build:sw": "bun run scripts/build-sw.ts", + "gen:open-sse-shims": "bun run scripts/gen-open-sse-js-shims.ts", + "gen:sw-version": "bun run scripts/gen-sw-version.ts", "start": "NODE_ENV=production bun ./.next/standalone/server.js", "format": "bun x oxfmt --write .", "check": "bun x oxfmt --write . && bun x oxlint . --deny-warnings && bun x tsc --noEmit", diff --git a/postcss.config.mjs b/postcss.config.cts similarity index 60% rename from postcss.config.mjs rename to postcss.config.cts index c2ddf748..a8e92d02 100644 --- a/postcss.config.mjs +++ b/postcss.config.cts @@ -1,5 +1,7 @@ -export default { +const config = { plugins: { "@tailwindcss/postcss": {}, }, }; + +export = config; diff --git a/public/sw.js b/public/sw.js index e6bd8e6a..ffaba87a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,19 +1,17 @@ +// Generated from src/sw/sw.ts by scripts/build-sw.ts; do not edit. +const sw = self; function resolveVersion() { try { - if (typeof self !== "undefined" && self.location && self.location.href) { - const v = new URL(self.location.href).searchParams.get("v"); - if (v) return v; + if (sw.location?.href) { + const version = new URL(sw.location.href).searchParams.get("v"); + if (version) return version; } - } catch {} + } catch { + return "dev"; + } return "dev"; } - let SW_VERSION = resolveVersion(); - -// Cache names are keyed to the deploy/build hash (injected via the `?v=` query -// at registration time), NOT the release semver. A new deploy therefore gets its -// own cache namespace; `activate` evicts every prior namespace so a stale -// app-shell (which references old `_next/static` chunk hashes) is dropped. function makeCacheNames(version) { return { shell: `pod-shell-cache-${version}`, @@ -21,12 +19,9 @@ function makeCacheNames(version) { image: `pod-image-cache-${version}`, }; } - let CACHE = makeCacheNames(SW_VERSION); - const OFFLINE_FALLBACK_URL = "/offline"; const IMAGE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 31; -// ponytail: 5s is enough — 15s made cold-start pain unbearable on idle Zeabur canary const NAVIGATION_NETWORK_TIMEOUT_MS = 5000; const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico"]; const SENSITIVE_SEARCH_PARAMS = new Set([ @@ -37,8 +32,6 @@ const SENSITIVE_SEARCH_PARAMS = new Set([ "refresh_token", "session", ]); - -// ponytail: all 15 dashboard pages so alpha testers get instant nav everywhere const SHELL_ROUTES = [ "/", "/landing", @@ -59,7 +52,6 @@ const SHELL_ROUTES = [ "/basic-chat", OFFLINE_FALLBACK_URL, ]; - const STATIC_PRECACHE = [ "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png", @@ -68,28 +60,23 @@ const STATIC_PRECACHE = [ "/icon0.svg", "/apple-icon.png", ]; - function isSameOrigin(url) { - return url.origin === self.location.origin; + return url.origin === sw.location.origin; } - function isImageRequest(request, url) { if (request.destination === "image") return true; return IMAGE_EXTENSIONS.some((ext) => url.pathname.toLowerCase().endsWith(ext)); } - function isNavigationRequest(request, url) { if (request.mode !== "navigate") return false; if (!isSameOrigin(url)) return false; if (url.pathname.startsWith("/api/")) return false; return true; } - function isStaticAssetRequest(request, url) { if (!isSameOrigin(url)) return false; if (url.pathname.startsWith("/api/")) return false; if (url.pathname.startsWith("/_next/static/")) return true; - return ( request.destination === "script" || request.destination === "style" || @@ -97,28 +84,23 @@ function isStaticAssetRequest(request, url) { request.destination === "worker" ); } - function isFingerprintedAsset(url) { return url.pathname.startsWith("/_next/static/"); } - function isCacheableResponse(response) { if (!response || !response.ok) return false; return response.type === "basic" || response.type === "default"; } - function responseAllowsStorage(response) { const cacheControl = (response.headers.get("Cache-Control") || "").toLowerCase(); return !cacheControl.includes("no-store"); } - function hasSensitiveQuery(url) { for (const key of url.searchParams.keys()) { if (SENSITIVE_SEARCH_PARAMS.has(key.toLowerCase())) return true; } return false; } - function emptyAssetResponse(url) { const path = url.pathname.toLowerCase(); const isJs = path.endsWith(".js") || path.endsWith(".mjs"); @@ -127,7 +109,20 @@ function emptyAssetResponse(url) { headers: { "Content-Type": isJs ? "application/javascript" : "text/css" }, }); } - +function emptyImageResponse(url) { + const path = url.pathname.toLowerCase(); + let contentType = "image/png"; + if (path.endsWith(".svg")) contentType = "image/svg+xml"; + else if (path.endsWith(".webp")) contentType = "image/webp"; + else if (path.endsWith(".gif")) contentType = "image/gif"; + else if (path.endsWith(".jpg") || path.endsWith(".jpeg")) contentType = "image/jpeg"; + else if (path.endsWith(".avif")) contentType = "image/avif"; + else if (path.endsWith(".ico")) contentType = "image/x-icon"; + return new Response("", { + status: 200, + headers: { "Content-Type": contentType }, + }); +} async function fetchWithTimeout(request, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -137,7 +132,6 @@ async function fetchWithTimeout(request, timeoutMs) { clearTimeout(timeout); } } - async function putWithTimestamp(cache, request, response) { const headers = new Headers(response.headers); headers.set("sw-cache-time", Date.now().toString()); @@ -150,11 +144,9 @@ async function putWithTimestamp(cache, request, response) { }), ); } - async function precacheShell() { const shellCache = await caches.open(CACHE.shell); const staticCache = await caches.open(CACHE.static); - const shellResults = await Promise.all( SHELL_ROUTES.map(async (route) => { try { @@ -163,13 +155,12 @@ async function precacheShell() { await shellCache.put(route, response); return { route, ok: true }; } - return { route, ok: false, status: response ? response.status : 0 }; + return { route, ok: false, status: response?.status ?? 0 }; } catch (err) { return { route, ok: false, error: String(err) }; } }), ); - const staticResults = await Promise.all( STATIC_PRECACHE.map(async (route) => { try { @@ -178,18 +169,14 @@ async function precacheShell() { await staticCache.put(route, response); return { route, ok: true }; } - return { route, ok: false, status: response ? response.status : 0 }; + return { route, ok: false, status: response?.status ?? 0 }; } catch (err) { return { route, ok: false, error: String(err) }; } }), ); - - // Surface (do not swallow) any routes we failed to precache so the failure - // is observable and correctable via warmShellCache on activate. - const shellFailed = shellResults.filter((r) => !r.ok); - const staticFailed = staticResults.filter((r) => !r.ok); - + const shellFailed = shellResults.filter((result) => !result.ok); + const staticFailed = staticResults.filter((result) => !result.ok); if (shellFailed.length > 0) { console.warn( `[Pod SW] precacheShell: ${shellFailed.length}/${SHELL_ROUTES.length} shell routes not cached`, @@ -202,61 +189,44 @@ async function precacheShell() { staticFailed, ); } - return { shellResults, staticResults, shellFailed, staticFailed }; } - async function handleNavigationRequest(request) { - const shellCache = await caches.open(CACHE.shell); - const url = new URL(request.url); - - // ponytail: cache-first for instant navigation, bg refresh keeps cache fresh - // Network-first (the previous design) caused 3-15s cold-start delay on idle Zeabur instances - const cached = await shellCache.match(request, { ignoreSearch: true }); - if (cached) { - fetchWithTimeout(request, NAVIGATION_NETWORK_TIMEOUT_MS) - .then((response) => { - if ( - response && - isCacheableResponse(response) && - responseAllowsStorage(response) && - !hasSensitiveQuery(url) - ) { - shellCache.put(request, response.clone()); - } - }) - .catch(() => {}); - return cached; - } - try { - const response = await fetchWithTimeout(request, NAVIGATION_NETWORK_TIMEOUT_MS); - if ( - isCacheableResponse(response) && - responseAllowsStorage(response) && - !hasSensitiveQuery(url) - ) { - await shellCache.put(request, response.clone()); + const shellCache = await caches.open(CACHE.shell); + const url = new URL(request.url); + try { + const response = await fetchWithTimeout(request, NAVIGATION_NETWORK_TIMEOUT_MS); + if ( + isCacheableResponse(response) && + responseAllowsStorage(response) && + !hasSensitiveQuery(url) + ) { + try { + await shellCache.put(request, response.clone()); + } catch {} + } + return response; + } catch { + const cached = await shellCache.match(request, { ignoreSearch: true }); + if (cached) return cached; + const fallback = await shellCache.match(OFFLINE_FALLBACK_URL); + if (fallback) return fallback; + return new Response("Offline", { + status: 503, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); } - return response; } catch { - const cached = await shellCache.match(request, { ignoreSearch: true }); - if (cached) return cached; - - const fallback = await shellCache.match(OFFLINE_FALLBACK_URL); - if (fallback) return fallback; - return new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } } - async function handleStaticAssetRequest(request, url) { const staticCache = await caches.open(CACHE.static); const cached = await staticCache.match(request); - if (!isFingerprintedAsset(url)) { try { const response = await fetch(request); @@ -265,13 +235,9 @@ async function handleStaticAssetRequest(request, url) { } return response; } catch { - if (cached) return cached; - // Graceful fallback: serve any cached copy, else an empty module, instead - // of a synthetic Response.error() (which surfaces to the page as net::ERR_FAILED). return cached || emptyAssetResponse(url); } } - const networkFetch = fetch(request) .then(async (response) => { if (isCacheableResponse(response) && responseAllowsStorage(response)) { @@ -280,29 +246,22 @@ async function handleStaticAssetRequest(request, url) { return response; }) .catch(() => null); - if (cached) { networkFetch.catch(() => {}); return cached; } - const networkResponse = await networkFetch; if (networkResponse) return networkResponse; - - // Graceful fallback: a missing post-deploy chunk returns an empty module - // rather than Response.error(), so the page degrades instead of hard-failing. return cached || emptyAssetResponse(url); } - async function handleImageRequest(request) { + const url = new URL(request.url); const imageCache = await caches.open(CACHE.image); const cached = await imageCache.match(request); - if (cached) { const cacheTime = Number(cached.headers.get("sw-cache-time") || 0); if (Date.now() - cacheTime < IMAGE_MAX_AGE_MS) return cached; } - try { const response = await fetch(request); if (isCacheableResponse(response) && responseAllowsStorage(response)) { @@ -311,14 +270,12 @@ async function handleImageRequest(request) { return response; } catch { if (cached) return cached; - return Response.error(); + return emptyImageResponse(url); } } - async function purgeExpiredImages() { const imageCache = await caches.open(CACHE.image); const requests = await imageCache.keys(); - await Promise.all( requests.map(async (request) => { const cached = await imageCache.match(request); @@ -334,10 +291,7 @@ async function purgeExpiredImages() { }), ); } - async function warmShellCache() { - // ponytail: on activate, proactively (re)fill shell routes. Always overwrites - // existing entries so a stale same-version shell is corrected after a deploy. const shellCache = await caches.open(CACHE.shell); await Promise.all( SHELL_ROUTES.map(async (route) => { @@ -346,19 +300,14 @@ async function warmShellCache() { if (isCacheableResponse(response) && responseAllowsStorage(response)) { await shellCache.put(route, response); } - } catch { - // Best-effort warming — failures at this point are fine - } + } catch {} }), ); } - function registerServiceWorker() { - if (typeof self === "undefined" || typeof self.addEventListener !== "function") return; - - self.addEventListener("install", (event) => { - // ponytail: skipWaiting so canary users don't need to hard-reload for updated SW - self.skipWaiting(); + if (typeof sw === "undefined" || typeof sw.addEventListener !== "function") return; + sw.addEventListener("install", (event) => { + sw.skipWaiting(); event.waitUntil( (async () => { const result = await precacheShell(); @@ -370,56 +319,40 @@ function registerServiceWorker() { })(), ); }); - - self.addEventListener("activate", (event) => { + sw.addEventListener("activate", (event) => { event.waitUntil( (async () => { const expected = new Set([CACHE.shell, CACHE.static, CACHE.image]); const keys = await caches.keys(); - // Evict every prior deploy's cache namespace so a stale app-shell - // (old `_next/static` chunk hashes) is dropped on update. await Promise.all( keys.filter((key) => !expected.has(key)).map((key) => caches.delete(key)), ); await purgeExpiredImages(); - await self.clients.claim(); - // ponytail: warm after claim so first navigation isn't blocked + await sw.clients.claim(); warmShellCache(); })(), ); }); - - self.addEventListener("message", (_event) => { - // Reserved for future use. No auto-update message handling. - }); - - self.addEventListener("fetch", (event) => { + sw.addEventListener("message", (_event) => {}); + sw.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; - const url = new URL(request.url); if (!isSameOrigin(url)) return; - if (isNavigationRequest(request, url)) { event.respondWith(handleNavigationRequest(request)); return; } - if (isStaticAssetRequest(request, url)) { event.respondWith(handleStaticAssetRequest(request, url)); return; } - if (isImageRequest(request, url)) { event.respondWith(handleImageRequest(request)); } }); } - registerServiceWorker(); - -// Exposed for unit tests (vitest) in a Node context. Guarded so it is a no-op -// in the browser service-worker runtime where `module` is undefined. if (typeof module !== "undefined" && module.exports) { module.exports = { resolveVersion, @@ -437,9 +370,9 @@ if (typeof module !== "undefined" && module.exports) { warmShellCache, SHELL_ROUTES, STATIC_PRECACHE, - setVersionForTest: (v) => { - SW_VERSION = v; - CACHE = makeCacheNames(v); + setVersionForTest: (version) => { + SW_VERSION = version; + CACHE = makeCacheNames(version); }, }; } diff --git a/scripts/build-sw.ts b/scripts/build-sw.ts new file mode 100644 index 00000000..50b5435b --- /dev/null +++ b/scripts/build-sw.ts @@ -0,0 +1,20 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +const sourceFile = "src/sw/sw.ts"; +const outputFile = "public/sw.js"; +const generatedHeader = `// Generated from ${sourceFile} by scripts/build-sw.ts; do not edit.`; + +const source = await readFile(sourceFile, "utf8"); +const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser" }); +const compiled = transpiler + .transformSync(source) + .replace(/^\/\/\/ /dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" +fi + +# Native deps used by trustedDependencies (better-sqlite3, sharp) +if ! command -v make >/dev/null 2>&1 || ! command -v g++ >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y -qq build-essential python3 +fi + +cd /workspace +bun install --frozen-lockfile diff --git a/scripts/cloud-dev-start.sh b/scripts/cloud-dev-start.sh new file mode 100755 index 00000000..5d96ac0f --- /dev/null +++ b/scripts/cloud-dev-start.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Long-running pod dev server for Cursor Cloud agent sessions. +# Secrets must come from Cursor environment Secrets (not committed .env). +set -euo pipefail + +export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" +export PATH="$BUN_INSTALL/bin:$PATH" + +: "${JWT_SECRET:?JWT_SECRET must be set (Cursor Cloud Agents → environment Secrets)}" +: "${API_KEY_SECRET:?API_KEY_SECRET must be set (Cursor Cloud Agents → environment Secrets)}" + +export PORT="${PORT:-20128}" +export NODE_ENV="${NODE_ENV:-development}" +export NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" +export REQUIRE_API_KEY="${REQUIRE_API_KEY:-false}" +export AUTH_COOKIE_SECURE="${AUTH_COOKIE_SECURE:-false}" +export BASE_URL="${BASE_URL:-http://localhost:20128}" +export NEXT_PUBLIC_BASE_URL="${NEXT_PUBLIC_BASE_URL:-http://localhost:20128}" +export INITIAL_PASSWORD="${INITIAL_PASSWORD:-123456}" + +cd /workspace +exec bun run dev diff --git a/scripts/gen-open-sse-js-shims.ts b/scripts/gen-open-sse-js-shims.ts new file mode 100644 index 00000000..272f7a6d --- /dev/null +++ b/scripts/gen-open-sse-js-shims.ts @@ -0,0 +1,49 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", "open-sse"); +const generatedHeader = "// Generated by scripts/gen-open-sse-js-shims.ts; do not edit."; +const defaultExportPattern = /\bexport\s+default\b|export\s*\{[^}]*\bdefault\b[^}]*\}/m; + +async function collectTsFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "test") continue; + files.push(...(await collectTsFiles(path))); + continue; + } + + if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + files.push(path); + } + } + + return files; +} + +function createShim(source: string, tsFile: string): string { + const tsBasename = basename(tsFile); + const lines = [generatedHeader, `export * from "./${tsBasename}";`]; + + if (defaultExportPattern.test(source)) { + lines.push(`export { default } from "./${tsBasename}";`); + } + + return `${lines.join("\n")}\n`; +} + +const tsFiles = await collectTsFiles(root); + +for (const tsFile of tsFiles) { + const source = await readFile(tsFile, "utf8"); + const shimFile = tsFile.replace(/\.ts$/, ".js"); + await mkdir(dirname(shimFile), { recursive: true }); + await writeFile(shimFile, createShim(source, tsFile)); +} + +console.log(`Generated ${tsFiles.length} open-sse JS shims in ${relative(process.cwd(), root)}`); diff --git a/scripts/gen-sw-version.mjs b/scripts/gen-sw-version.ts similarity index 83% rename from scripts/gen-sw-version.mjs rename to scripts/gen-sw-version.ts index 06d1f14e..35cbdeba 100644 --- a/scripts/gen-sw-version.mjs +++ b/scripts/gen-sw-version.ts @@ -1,16 +1,18 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; -function readBuildId() { +function readBuildId(): string { try { if (existsSync(".next/BUILD_ID")) { const id = readFileSync(".next/BUILD_ID", "utf8").trim(); if (id) return id; } - } catch {} + } catch { + return ""; + } return ""; } -function generateId() { +function generateId(): string { return Math.random().toString(36).slice(2, 12); } diff --git a/scripts/lintfix-exhaustive-deps.mjs b/scripts/lintfix-exhaustive-deps.ts similarity index 62% rename from scripts/lintfix-exhaustive-deps.mjs rename to scripts/lintfix-exhaustive-deps.ts index 6fd1a9f0..2dca4dfc 100644 --- a/scripts/lintfix-exhaustive-deps.mjs +++ b/scripts/lintfix-exhaustive-deps.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; const OPEN = "/* eslint-disable react-hooks/exhaustive-deps */"; const CLOSE = "/* eslint-enable react-hooks/exhaustive-deps */"; @@ -26,12 +26,14 @@ const files = [ "src/shared/components/RequestLogger.tsx", ]; -function findDepArrayClose(lines, hookLine) { +function findDepArrayClose(lines: string[], hookLine: number): number { // find the closing ')' of the whole hook call: balance parens, ignoring those inside braces/strings-ish. let depth = 0; let started = false; for (let i = hookLine; i < lines.length; i++) { - for (const ch of lines[i]) { + const line = lines[i]; + if (line === undefined) continue; + for (const ch of line) { if (ch === "(") { depth++; started = true; @@ -42,28 +44,31 @@ function findDepArrayClose(lines, hookLine) { return -1; } -for (const f of files) { - const txt = execSync(`bun x oxlint ${JSON.stringify(f)} 2>/dev/null`, { encoding: "utf8" }); - const warns = []; - for (const l of txt.split("\n")) { - const m = l.match(/^\s*(\d+):\d+:\s*warning\s+react-hooks\(exhaustive-deps\)/); - if (m) warns.push(Number(m[1])); +for (const file of files) { + const txt = execSync(`bun x oxlint ${JSON.stringify(file)} 2>/dev/null`, { encoding: "utf8" }); + const warns: number[] = []; + for (const line of txt.split("\n")) { + const match = line.match(/^\s*(\d+):\d+:\s*warning\s+react-hooks\(exhaustive-deps\)/); + if (match?.[1]) warns.push(Number(match[1])); } if (warns.length === 0) { - console.log(`clean ${f}`); + console.log(`clean ${file}`); continue; } - let lines = readFileSync(f, "utf8").split("\n"); - lines = lines.filter((l) => !nextLineRe.test(l) && !disableRe.test(l) && !enableRe.test(l)); + let lines = readFileSync(file, "utf8").split("\n"); + lines = lines.filter( + (line) => !nextLineRe.test(line) && !disableRe.test(line) && !enableRe.test(line), + ); - const hookLines = []; - const seen = new Set(); - for (const wl of warns) { - const target = wl - 1; // 0-based body-line; hook keyword is at or before it + const hookLines: number[] = []; + const seen = new Set(); + for (const warningLine of warns) { + const target = warningLine - 1; // 0-based body-line; hook keyword is at or before it let best = -1; for (let i = 0; i < lines.length; i++) { - if (hookRe.test(lines[i]) && i <= target && i > best) best = i; + const line = lines[i]; + if (line !== undefined && hookRe.test(line) && i <= target && i > best) best = i; } if (best >= 0 && !seen.has(best)) { seen.add(best); @@ -71,16 +76,16 @@ for (const f of files) { } } hookLines.sort((a, b) => b - a); - for (const hl of hookLines) { - const close = findDepArrayClose(lines, hl); + for (const hookLine of hookLines) { + const close = findDepArrayClose(lines, hookLine); if (close < 0) { - console.log(`SKIP ${f}:${hl}`); + console.log(`SKIP ${file}:${hookLine}`); continue; } lines.splice(close + 1, 0, CLOSE); - lines.splice(hl, 0, OPEN); + lines.splice(hookLine, 0, OPEN); } - writeFileSync(f, lines.join("\n")); - console.log(`fixed ${f}: ${warns.length} warnings -> ${hookLines.length} hooks wrapped`); + writeFileSync(file, lines.join("\n")); + console.log(`fixed ${file}: ${warns.length} warnings -> ${hookLines.length} hooks wrapped`); } console.log("DONE"); diff --git a/src/app/(dashboard)/basic-chat/BasicChatPageClient.tsx b/src/app/(dashboard)/basic-chat/BasicChatPageClient.tsx index e9268d46..af7852ad 100644 --- a/src/app/(dashboard)/basic-chat/BasicChatPageClient.tsx +++ b/src/app/(dashboard)/basic-chat/BasicChatPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import Image from "next/image"; import { Badge, Button } from "@/shared/components"; import LucideIcon from "@/shared/components/LucideIcon"; @@ -10,33 +10,105 @@ import { isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -const STORAGE_KEYS: any = { +type StorageKeys = { + sessions: string; + activeSessionId: string; + activeProviderId: string; + draft: string; +}; + +type ChatAttachment = { + id: string; + name: string; + dataUrl: string; + type?: string; + size?: number; +}; + +type ChatMessage = { + id: string; + role: "user" | "assistant" | "system" | string; + content: string; + createdAt: string; + attachments?: ChatAttachment[]; + error?: string; + status?: string; +}; + +type ChatSession = { + id: string; + title: string; + providerId: string; + providerName: string; + modelId: string; + modelName: string; + createdAt?: string; + updatedAt: string; + messages: ChatMessage[]; +}; + +type ChatModel = { + id: string; + requestModel: string; + name: string; + providerId: string; + providerName: string; + source: string; +}; + +type ProviderGroup = { + providerId: string; + providerName: string; + providerType?: string; + connections: ProviderConnectionLite[]; + models: ChatModel[]; +}; + +type LiveModelsResult = { + connection: ProviderConnectionLite; + models: ChatModel[]; +}; + +type ProviderConnectionLite = { + id?: string; + provider: string; + providerName?: string; + name?: string | null; + isActive?: boolean; +}; + +type ContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; + +const STORAGE_KEYS: StorageKeys = { sessions: "basic-chat.sessions", activeSessionId: "basic-chat.activeSessionId", activeProviderId: "basic-chat.activeProviderId", draft: "basic-chat.draft", }; -function createId(): any { +function createId(): string { // crypto.randomUUID() is available in all modern browsers and Node 14.17+ return globalThis.crypto.randomUUID(); } -function safeParse(value: any, fallback: any): any { +function safeParse(value: string | null, fallback: T): T { try { - return JSON.parse(value); + return JSON.parse(value ?? "") as T; } catch { return fallback; } } -function textValue(value: any): any { +function textValue(value: unknown): string { if (typeof value === "string") return value; if (value === null || value === undefined) return ""; if (Array.isArray(value)) return value.map(textValue).filter(Boolean).join(" "); if (typeof value === "object") { - if (typeof value.message === "string") return value.message; - if (typeof value.error === "string") return value.error; + const record = value as Record; + if (typeof record.message === "string") return record.message; + if (typeof record.error === "string") return record.error; try { return JSON.stringify(value); } catch { @@ -46,18 +118,18 @@ function textValue(value: any): any { return String(value); } -function humanize(value: any = ""): any { +function humanize(value: unknown = ""): string { return ( String(value) .replace(/[-_]/g, " ") - .replace(/\b\w/g, (char: any): any => char.toUpperCase()) + .replace(/\b\w/g, (char) => char.toUpperCase()) .trim() || "Unknown" ); } -function formatRelativeTime(value: any): any { +function formatRelativeTime(value: unknown): string { if (!value) return "Now"; - const time = new Date(value).getTime(); + const time = new Date(String(value)).getTime(); if (Number.isNaN(time)) return "Now"; const diffMinutes = Math.max(1, Math.round((Date.now() - time) / 60000)); if (diffMinutes < 60) return `${diffMinutes}m`; @@ -66,19 +138,19 @@ function formatRelativeTime(value: any): any { return `${Math.round(diffHours / 24)}d`; } -function makeSessionTitle(text: any = ""): any { +function makeSessionTitle(text: unknown = ""): string { const normalized = textValue(text).replace(/\s+/g, " ").trim(); if (!normalized) return "New chat"; return normalized.length > 52 ? `${normalized.slice(0, 52).trimEnd()}…` : normalized; } -function buildUserContent(message: any): any { +function buildUserContent(message: ChatMessage): string | ContentPart[] { const text = textValue(message.content).trim(); const attachments = Array.isArray(message.attachments) ? message.attachments : []; if (attachments.length === 0) return text; - const content: any[] = []; + const content: ContentPart[] = []; if (text) content.push({ type: "text", text }); for (const attachment of attachments) { @@ -90,43 +162,58 @@ function buildUserContent(message: any): any { return content.length > 0 ? content : text; } -function readAssistantText(chunk: any): any { +function readAssistantText(chunk: unknown): string { if (!chunk || typeof chunk !== "object") return ""; - const choice = chunk.choices?.[0]; - const delta = choice?.delta || {}; - const pieces = [delta.content, choice?.message?.content, chunk.output_text, chunk.text] + const record = chunk as Record; + const choices = record.choices; + const choice = + Array.isArray(choices) && choices[0] && typeof choices[0] === "object" + ? (choices[0] as Record) + : undefined; + const delta = + choice && typeof choice.delta === "object" && choice.delta + ? (choice.delta as Record) + : {}; + const message = + choice && typeof choice.message === "object" && choice.message + ? (choice.message as Record) + : undefined; + const pieces = [delta.content, message?.content, record.output_text, record.text] .map(textValue) .filter(Boolean); return pieces[0] || ""; } -async function fileToDataUrl(file: any): Promise { - return await new Promise((resolve: any, reject: any): any => { +async function fileToDataUrl(file: File): Promise { + return await new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = (): any => resolve(String(reader.result || "")); - reader.onerror = (): any => reject(reader.error || new Error("Failed to read file")); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Failed to read file")); reader.readAsDataURL(file); }); } -function cloneSession(session: any): any { +function cloneSession(session: ChatSession): ChatSession { return { ...session, messages: Array.isArray(session.messages) - ? session.messages.map((message: any): any => ({ ...message })) + ? session.messages.map((message) => ({ ...message })) : [], }; } -function getProviderLabel(connection: any): any { +function getProviderLabel(connection: ProviderConnectionLite): string { return ( - connection?.providerName || - connection?.name || - humanize(connection?.provider || connection?.id || "provider") + (typeof connection.providerName === "string" && connection.providerName) || + (typeof connection.name === "string" && connection.name) || + humanize(connection.provider || connection.id || "provider") ); } -function normalizeStaticModel(model: any, connection: any): any { +function normalizeStaticModel( + model: { id?: string; name?: string }, + connection: ProviderConnectionLite, +): ChatModel | null { if (!model?.id) return null; return { id: `${connection.provider}/${model.id}`, @@ -138,12 +225,16 @@ function normalizeStaticModel(model: any, connection: any): any { }; } -function normalizeLiveModel(model: any, connection: any): any { - const rawId = typeof model === "string" ? model : model?.id || model?.name || model?.model || ""; +function normalizeLiveModel(model: unknown, connection: ProviderConnectionLite): ChatModel | null { + const modelObj = model && typeof model === "object" ? (model as Record) : null; + const rawId = + typeof model === "string" + ? model + : String(modelObj?.id || modelObj?.name || modelObj?.model || ""); if (!rawId) return null; const displayName = - typeof model === "string" ? model : model?.name || model?.displayName || rawId; + typeof model === "string" ? model : String(modelObj?.name || modelObj?.displayName || rawId); let requestModel = rawId; const isCompatible = @@ -163,16 +254,20 @@ function normalizeLiveModel(model: any, connection: any): any { }; } -function parseProviderModelsPayload(data: any): any { - if (Array.isArray(data?.models)) return data.models; - if (Array.isArray(data?.data)) return data.data; - if (Array.isArray(data?.results)) return data.results; +function parseProviderModelsPayload(data: unknown): unknown[] { + if (!data || typeof data !== "object") { + return Array.isArray(data) ? data : []; + } + const record = data as Record; + if (Array.isArray(record.models)) return record.models; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.results)) return record.results; if (Array.isArray(data)) return data; return []; } -function dedupeModels(models: any): any { - const map = new Map(); +function dedupeModels(models: (ChatModel | null | undefined)[]): ChatModel[] { + const map = new Map(); for (const model of models) { if (!model?.id) continue; if (!map.has(model.id)) map.set(model.id, model); @@ -180,67 +275,84 @@ function dedupeModels(models: any): any { return Array.from(map.values()); } -export default function BasicChatPageClient(): any { - const [providerGroups, setProviderGroups] = useState([]); +export default function BasicChatPageClient() { + const [providerGroups, setProviderGroups] = useState([]); const [loadingData, setLoadingData] = useState(true); const [loadError, setLoadError] = useState(""); - const [sessions, setSessions] = useState((): any => { + const [sessions, setSessions] = useState(() => { if (typeof window === "undefined") return []; try { - const saved = safeParse(globalThis.localStorage.getItem(STORAGE_KEYS.sessions), []); + const saved = safeParse( + globalThis.localStorage.getItem(STORAGE_KEYS.sessions), + [], + ); return Array.isArray(saved) - ? saved.map((session: any): any => ({ - ...session, - messages: Array.isArray(session.messages) ? session.messages : [], - })) + ? saved.map((session) => { + const s = ( + session && typeof session === "object" ? session : {} + ) as Partial; + return { + id: String(s.id || createId()), + title: String(s.title || "New chat"), + providerId: String(s.providerId || ""), + providerName: String(s.providerName || ""), + modelId: String(s.modelId || ""), + modelName: String(s.modelName || ""), + createdAt: s.createdAt, + updatedAt: String(s.updatedAt || new Date().toISOString()), + messages: Array.isArray(s.messages) ? (s.messages as ChatMessage[]) : [], + } satisfies ChatSession; + }) : []; } catch { return []; } }); - const [activeSessionId, setActiveSessionId] = useState((): any => { + const [activeSessionId, setActiveSessionId] = useState(() => { if (typeof window === "undefined") return ""; return globalThis.localStorage.getItem(STORAGE_KEYS.activeSessionId) || ""; }); - const [activeProviderId, setActiveProviderId] = useState((): any => { + const [activeProviderId, setActiveProviderId] = useState(() => { if (typeof window === "undefined") return ""; return globalThis.localStorage.getItem(STORAGE_KEYS.activeProviderId) || ""; }); const [activeModelId, setActiveModelId] = useState(""); - const [draft, setDraft] = useState((): any => { + const [draft, setDraft] = useState(() => { if (typeof window === "undefined") return ""; return globalThis.localStorage.getItem(STORAGE_KEYS.draft) || ""; }); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState([]); const [isSending, setIsSending] = useState(false); const [streamingMessageId, setStreamingMessageId] = useState(""); const [streamingText, setStreamingText] = useState(""); const [isHydrated, setIsHydrated] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); - const fileInputRef = useRef(null); - const abortRef = useRef(null); + const fileInputRef = useRef(null); + const abortRef = useRef(null); const initializedRef = useRef(false); - const modelMenuRef = useRef(null); - const historyMenuRef = useRef(null); + const modelMenuRef = useRef(null); + const historyMenuRef = useRef(null); - useEffect((): any => { + useEffect(() => { setIsHydrated(true); }, []); - useEffect((): any => { + useEffect(() => { let cancelled = false; - async function loadData(): Promise { + async function loadData(): Promise { setLoadingData(true); setLoadError(""); try { const providersRes = await fetch("/api/providers", { cache: "no-store" }); - const providersData = await providersRes.json().catch((): any => ({})); - const connections = Array.isArray(providersData.connections) + const providersData = (await providersRes.json().catch(() => ({}))) as { + connections?: ProviderConnectionLite[]; + }; + const connections: ProviderConnectionLite[] = Array.isArray(providersData.connections) ? providersData.connections.filter( - (connection: any): any => connection?.isActive !== false, + (connection: ProviderConnectionLite) => connection?.isActive !== false, ) : []; @@ -252,10 +364,11 @@ export default function BasicChatPageClient(): any { return; } - const providerMap = new Map(); + const providerMap = new Map(); for (const connection of connections) { - const providerId = connection.provider || connection.id; + const providerId = String(connection.provider || connection.id || ""); + if (!providerId) continue; const providerName = getProviderLabel(connection); const providerType = isOpenAICompatibleProvider(providerId) ? "openai-compatible" @@ -274,27 +387,28 @@ export default function BasicChatPageClient(): any { } const group = providerMap.get(providerId); + if (!group) continue; group.providerName = group.providerName || providerName; group.providerType = group.providerType || providerType; group.connections.push(connection); const staticModels = getModelsByProviderId(providerId) - .map((model: any): any => normalizeStaticModel(model, connection)) - .filter(Boolean); + .map((model) => normalizeStaticModel(model, connection)) + .filter((m): m is ChatModel => m !== null); group.models.push(...staticModels); } const liveResults = await Promise.all( - connections.map(async (connection: any): Promise => { + connections.map(async (connection: ProviderConnectionLite): Promise => { try { const response = await fetch(`/api/providers/${connection.id}/models`, { cache: "no-store", }); - const data = await response.json().catch((): any => ({})); + const data = await response.json().catch(() => ({})); if (!response.ok) return { connection, models: [] }; const models = parseProviderModelsPayload(data) - .map((model: any): any => normalizeLiveModel(model, connection)) - .filter(Boolean); + .map((model) => normalizeLiveModel(model, connection)) + .filter((m): m is ChatModel => m !== null); return { connection, models }; } catch { return { connection, models: [] }; @@ -303,21 +417,19 @@ export default function BasicChatPageClient(): any { ); for (const result of liveResults) { - const providerId = result.connection.provider || result.connection.id; + const providerId = String(result.connection.provider || result.connection.id || ""); const group = providerMap.get(providerId); if (!group) continue; group.models.push(...result.models); } const normalized = Array.from(providerMap.values()) - .map((group: any): any => ({ + .map((group) => ({ ...group, - models: dedupeModels(group.models).sort((a: any, b: any): any => - a.name.localeCompare(b.name), - ), + models: dedupeModels(group.models).sort((a, b) => a.name.localeCompare(b.name)), })) - .filter((group: any): any => group.models.length > 0) - .sort((a: any, b: any): any => a.providerName.localeCompare(b.providerName)); + .filter((group) => group.models.length > 0) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); if (!cancelled) { setProviderGroups(normalized); @@ -328,7 +440,8 @@ export default function BasicChatPageClient(): any { } catch (error) { if (!cancelled) { setLoadError( - textValue((error as any)?.message) || "Không thể tải danh sách provider/model.", + textValue(error instanceof Error ? error.message : error) || + "Không thể tải danh sách provider/model.", ); setProviderGroups([]); } @@ -338,26 +451,26 @@ export default function BasicChatPageClient(): any { } loadData(); - return (): any => { + return () => { cancelled = true; }; }, []); - useEffect((): any => { - const handleClickOutside = (event: any): any => { - if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modelMenuRef.current && !modelMenuRef.current.contains(event.target as Node)) { setModelMenuOpen(false); } - if (historyMenuRef.current && !historyMenuRef.current.contains(event.target)) { + if (historyMenuRef.current && !historyMenuRef.current.contains(event.target as Node)) { setHistoryOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); - return (): any => document.removeEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - const modelIndex = useMemo((): any => { + const modelIndex = useMemo(() => { const map = new Map(); for (const group of providerGroups) { for (const model of group.models) { @@ -371,18 +484,18 @@ export default function BasicChatPageClient(): any { return map; }, [providerGroups]); - const activeProviderGroup = useMemo((): any => { + const activeProviderGroup = useMemo(() => { return ( - providerGroups.find((group: any): any => group.providerId === activeProviderId) || + providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0] || null ); }, [providerGroups, activeProviderId]); - const activeModel = useMemo((): any => { + const activeModel = useMemo(() => { if (activeModelId && modelIndex.has(activeModelId)) return modelIndex.get(activeModelId); if (activeSessionId) { - const session = sessions.find((item: any): any => item.id === activeSessionId); + const session = sessions.find((item) => item.id === activeSessionId); if (session?.modelId && modelIndex.has(session.modelId)) return modelIndex.get(session.modelId); } @@ -390,21 +503,21 @@ export default function BasicChatPageClient(): any { }, [activeModelId, modelIndex, activeProviderGroup, sessions, activeSessionId]); const currentSession = useMemo( - (): any => sessions.find((session: any): any => session.id === activeSessionId) || null, + () => sessions.find((session) => session.id === activeSessionId) || null, [sessions, activeSessionId], ); const currentMessages = currentSession?.messages || []; const sessionItems = useMemo( - (): any => + () => [...sessions].sort( - (a: any, b: any): any => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), ), [sessions], ); const canSend = !isSending && !!activeModel && (draft.trim().length > 0 || attachments.length > 0); - useEffect((): any => { + useEffect(() => { if (!isHydrated) return; try { globalThis.localStorage.setItem(STORAGE_KEYS.sessions, JSON.stringify(sessions)); @@ -416,22 +529,24 @@ export default function BasicChatPageClient(): any { } }, [isHydrated, sessions, activeSessionId, activeProviderId, draft]); - useEffect((): any => { + useEffect(() => { if (!isHydrated || loadingData || initializedRef.current) return; if (providerGroups.length === 0) return; const savedProvider = - providerGroups.find((group: any): any => group.providerId === activeProviderId) || - providerGroups[0]; + providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0]; + if (!savedProvider) return; const savedModel = - activeModelId && modelIndex.has(activeModelId) + (activeModelId && modelIndex.has(activeModelId) ? modelIndex.get(activeModelId) - : savedProvider.models[0]; + : savedProvider.models[0]) || null; + if (!savedModel) return; if (sessions.length > 0) { - const session = sessions.find((item: any): any => item.id === activeSessionId) || sessions[0]; + const session = sessions.find((item) => item.id === activeSessionId) || sessions[0]; + if (!session) return; const sessionModel = - session?.modelId && modelIndex.has(session.modelId) + session.modelId && modelIndex.has(session.modelId) ? modelIndex.get(session.modelId) : savedModel; initializedRef.current = true; @@ -441,7 +556,7 @@ export default function BasicChatPageClient(): any { return; } - const session: any = { + const session: ChatSession = { id: createId(), title: "New chat", providerId: savedProvider.providerId, @@ -469,15 +584,13 @@ export default function BasicChatPageClient(): any { activeModelId, ]); - const updateSession = (sessionId: any, updater: any): any => { - setSessions((prev: any): any => - prev.map((session: any): any => - session.id === sessionId ? updater(cloneSession(session)) : session, - ), + const updateSession = (sessionId: string, updater: (session: ChatSession) => ChatSession) => { + setSessions((prev) => + prev.map((session) => (session.id === sessionId ? updater(cloneSession(session)) : session)), ); }; - const ensureSessionForModel = (model: any): any => { + const ensureSessionForModel = (model: ChatModel): ChatSession | null => { if (!model) return null; return { id: createId(), @@ -492,11 +605,11 @@ export default function BasicChatPageClient(): any { }; }; - const _handleNewChat = (): any => { + const _handleNewChat = () => { if (!activeModel) return; const session = ensureSessionForModel(activeModel); if (!session) return; - setSessions((prev: any): any => [session, ...prev]); + setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); setActiveProviderId(session.providerId); setActiveModelId(session.modelId); @@ -506,8 +619,8 @@ export default function BasicChatPageClient(): any { setStreamingText(""); }; - const handleSelectSession = (sessionId: any): any => { - const session = sessions.find((item: any): any => item.id === sessionId); + const handleSelectSession = (sessionId: string) => { + const session = sessions.find((item) => item.id === sessionId); if (!session) return; setActiveSessionId(sessionId); setActiveProviderId(session.providerId || activeProviderId); @@ -515,9 +628,9 @@ export default function BasicChatPageClient(): any { setHistoryOpen(false); }; - const handleDeleteCurrentChat = (): any => { + const handleDeleteCurrentChat = () => { if (!activeSessionId) return; - const nextSessions = sessions.filter((session: any): any => session.id !== activeSessionId); + const nextSessions = sessions.filter((session) => session.id !== activeSessionId); const fallback = nextSessions[0] || null; setSessions(nextSessions); if (fallback) { @@ -531,20 +644,21 @@ export default function BasicChatPageClient(): any { } }; - const _handleSelectProvider = (providerId: any): any => { - const group = providerGroups.find((item: any): any => item.providerId === providerId); + const _handleSelectProvider = (providerId: string) => { + const group = providerGroups.find((item) => item.providerId === providerId); if (!group || group.models.length === 0) return; const nextModel = group.models[0]; + if (!nextModel) return; - const current = sessions.find((session: any): any => session.id === activeSessionId); + const current = sessions.find((session) => session.id === activeSessionId); if (current && current.messages.length > 0) { const session = ensureSessionForModel(nextModel); if (!session) return; - setSessions((prev: any): any => [session, ...prev]); + setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); } else if (current) { - setSessions((prev: any): any => - prev.map((item: any): any => + setSessions((prev) => + prev.map((item) => item.id === current.id ? { ...item, @@ -564,19 +678,19 @@ export default function BasicChatPageClient(): any { setModelMenuOpen(false); }; - const handleSelectModel = (modelId: any): any => { + const handleSelectModel = (modelId: string) => { const model = modelIndex.get(modelId); if (!model) return; - const current = sessions.find((session: any): any => session.id === activeSessionId); + const current = sessions.find((session) => session.id === activeSessionId); if (current && current.messages.length > 0) { const session = ensureSessionForModel(model); if (!session) return; - setSessions((prev: any): any => [session, ...prev]); + setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); } else if (current) { - setSessions((prev: any): any => - prev.map((item: any): any => + setSessions((prev) => + prev.map((item) => item.id === current.id ? { ...item, @@ -592,7 +706,7 @@ export default function BasicChatPageClient(): any { } else { const session = ensureSessionForModel(model); if (!session) return; - setSessions((prev: any): any => [session, ...prev]); + setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); } @@ -601,11 +715,11 @@ export default function BasicChatPageClient(): any { setModelMenuOpen(false); }; - const handleAttachFiles = async (event: any): Promise => { + const handleAttachFiles = async (event: ChangeEvent): Promise => { const files = Array.from((event.target as HTMLInputElement).files ?? []) as File[]; if (files.length === 0) return; - const images = files.filter((file: any): any => file.type.startsWith("image/")); + const images = files.filter((file) => file.type.startsWith("image/")); if (images.length === 0) { event.target.value = ""; return; @@ -613,7 +727,7 @@ export default function BasicChatPageClient(): any { const converted = await Promise.all( images.map( - async (file: any): Promise => ({ + async (file: File): Promise => ({ id: createId(), name: file.name, type: file.type, @@ -623,30 +737,28 @@ export default function BasicChatPageClient(): any { ), ); - setAttachments((prev: any): any => [...prev, ...converted]); + setAttachments((prev) => [...prev, ...converted]); event.target.value = ""; }; - const removeAttachment = (attachmentId: any): any => { - setAttachments((prev: any): any => - prev.filter((attachment: any): any => attachment.id !== attachmentId), - ); + const removeAttachment = (attachmentId: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== attachmentId)); }; - const handleStop = (): any => { + const handleStop = () => { abortRef.current?.abort(); }; - const finalizeSessionTitle = (sessionId: any, titleSeed: any): any => { + const finalizeSessionTitle = (sessionId: string, titleSeed: string) => { const title = makeSessionTitle(titleSeed); - updateSession(sessionId, (session: any): any => ({ + updateSession(sessionId, (session) => ({ ...session, title: session.title === "New chat" ? title : session.title, updatedAt: new Date().toISOString(), })); }; - const sendMessage = async (): Promise => { + const sendMessage = async (): Promise => { const model = activeModel || activeProviderGroup?.models?.[0] || null; if (!model) return; @@ -654,20 +766,21 @@ export default function BasicChatPageClient(): any { if (!userText && attachments.length === 0) return; let sessionId = activeSessionId; - let session = sessions.find((item: any): any => item.id === sessionId); + let session: ChatSession | undefined = sessions.find((item) => item.id === sessionId); if (!session) { - session = ensureSessionForModel(model); - if (!session) return; + const created = ensureSessionForModel(model); + if (!created) return; + session = created; sessionId = session.id; - setSessions((prev: any): any => [session, ...prev]); + setSessions((prev) => [created, ...prev]); setActiveSessionId(sessionId); } - const userMessage: any = { + const userMessage: ChatMessage = { id: createId(), role: "user", content: userText, - attachments: attachments.map((attachment: any): any => ({ + attachments: attachments.map((attachment) => ({ id: attachment.id, name: attachment.name, type: attachment.type, @@ -677,7 +790,7 @@ export default function BasicChatPageClient(): any { }; const assistantMessageId = createId(); - const assistantMessage: any = { + const assistantMessage: ChatMessage = { id: assistantMessageId, role: "assistant", content: "", @@ -686,8 +799,8 @@ export default function BasicChatPageClient(): any { }; const nextMessages = [...(session.messages || []), userMessage, assistantMessage]; - setSessions((prev: any): any => - prev.map((item: any): any => + setSessions((prev) => + prev.map((item) => item.id === sessionId ? { ...item, @@ -711,10 +824,8 @@ export default function BasicChatPageClient(): any { abortRef.current = new AbortController(); const requestMessages = nextMessages - .filter( - (message: any): any => !(message.role === "assistant" && message.id === assistantMessageId), - ) - .map((message: any): any => ({ + .filter((message) => !(message.role === "assistant" && message.id === assistantMessageId)) + .map((message) => ({ role: message.role, content: message.role === "user" ? buildUserContent(message) : message.content, })); @@ -735,7 +846,7 @@ export default function BasicChatPageClient(): any { }); if (!response.ok) { - const errorData = await response.json().catch((): any => ({})); + const errorData = await response.json().catch(() => ({})); throw new Error( textValue(errorData.error || errorData.message || `Request failed (${response.status})`), ); @@ -743,7 +854,7 @@ export default function BasicChatPageClient(): any { const reader = response.body?.getReader(); if (!reader) { - const data = await response.json().catch((): any => ({})); + const data = await response.json().catch(() => ({})); const fallbackText = textValue( data?.choices?.[0]?.message?.content || data?.output_text || @@ -751,9 +862,9 @@ export default function BasicChatPageClient(): any { data?.message || "", ); - updateSession(sessionId, (currentSession: any): any => ({ + updateSession(sessionId, (currentSession) => ({ ...currentSession, - messages: currentSession.messages.map((message: any): any => + messages: currentSession.messages.map((message) => message.id === assistantMessageId ? { ...message, content: fallbackText, status: "done" } : message, @@ -789,9 +900,9 @@ export default function BasicChatPageClient(): any { assistantText += text; setStreamingText(assistantText); - updateSession(sessionId, (currentSession: any): any => ({ + updateSession(sessionId, (currentSession) => ({ ...currentSession, - messages: currentSession.messages.map((message: any): any => + messages: currentSession.messages.map((message) => message.id === assistantMessageId ? { ...message, content: assistantText, status: "streaming" } : message, @@ -804,9 +915,9 @@ export default function BasicChatPageClient(): any { } } - updateSession(sessionId, (currentSession: any): any => ({ + updateSession(sessionId, (currentSession) => ({ ...currentSession, - messages: currentSession.messages.map((message: any): any => + messages: currentSession.messages.map((message) => message.id === assistantMessageId ? { ...message, content: assistantText || message.content, status: "done" } : message, @@ -815,11 +926,14 @@ export default function BasicChatPageClient(): any { })); finalizeSessionTitle(sessionId, userText); } catch (error) { - if ((error as any).name !== "AbortError") { - const errorText = textValue((error as any)?.message || error); - updateSession(sessionId, (currentSession: any): any => ({ + if ( + !(error instanceof DOMException && error.name === "AbortError") && + !(error instanceof Error && error.name === "AbortError") + ) { + const errorText = textValue(error instanceof Error ? error.message : error); + updateSession(sessionId, (currentSession) => ({ ...currentSession, - messages: currentSession.messages.map((message: any): any => + messages: currentSession.messages.map((message) => message.id === assistantMessageId ? { ...message, content: message.content || `Error: ${errorText}`, status: "error" } : message, @@ -836,7 +950,7 @@ export default function BasicChatPageClient(): any { } }; - const handleKeyDown = (event: any): any => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); if (canSend) sendMessage(); @@ -853,7 +967,7 @@ export default function BasicChatPageClient(): any {
) : ( - sessionItems.map((session: any): any => { + sessionItems.map((session) => { const isActive = session.id === activeSessionId; const latestMessage = [...(session.messages || [])] .reverse() - .find((message: any): any => message.role === "user") || - session.messages?.[0]; + .find((message) => message.role === "user") || session.messages?.[0]; return (