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