From a06a4e698325b874d0cb027b50f65012e0c0a7fc Mon Sep 17 00:00:00 2001 From: Raden Salman Alfaridzi Date: Sat, 25 Jul 2026 17:30:42 +0700 Subject: [PATCH] feat(v0.7.6): Dollar Savings Tracker, Provider Leaderboard, SSE Live Dashboard, Notification System, Cross-Transport Fallback, 5 new providers, Kiro fix, Responses accumulator, dead code cleanup Features: - Dollar Savings Tracker (hero card + per-mechanism breakdown) - Provider Performance Leaderboard (sortable, TTFT/P95/cost/success) - SSE Live Dashboard (unified stream + auto-reconnect hook) - In-App Notification System (bell + unread badge + history) - Cross-Transport Fallback (OpenAI timeout -> Anthropic auto-retry) - New providers: Bynara, InxoraStudio (API + Web), Infron AI, AgentRouter - Updated: Command Code (standard API), Grok Web quota, Infron quota - Quota trackers: Grok Web rate-limits, Infron credit balance Fixes: - Kiro REQUEST_BODY_INVALID (level suffix normalization) - Responses API accumulator (alias-safe + exactly-once terminal) - Antigravity model turn 400 + tier routing + Cloud Code endpoints - Circuit breaker releaseBreakerProbe ReferenceError - Combo stickyLimit key + comboStrategies deep-merge - Notification spam (health_update filtered) + raw JSON + duplicates - hcnsec model discovery + WEB_COOKIE_PROVIDERS modelsFetcher Improvements: - Dead code cleanup (12 files, 1,705 LOC) - Junk dependency removed (fs placeholder) - Authenticated model discovery proxy Bumps package.json + cli/package.json to 0.7.6 --- CHANGELOG.md | 46 ++ cli/package.json | 2 +- open-sse/config/appConstants.js | 22 +- open-sse/config/kiroConstants.js | 78 +++ open-sse/config/providerModels.js | 19 +- open-sse/executors/antigravity.js | 11 + open-sse/executors/index.js | 8 +- open-sse/executors/inxorastudio-web.js | 314 ++++++++++ open-sse/handlers/chatCore.js | 170 +++++- .../handlers/chatCore/streamingHandler.js | 23 +- open-sse/handlers/responsesHandler.js | 99 ---- open-sse/providers/registry/agentrouter.js | 70 +++ open-sse/providers/registry/antigravity.js | 6 + open-sse/providers/registry/bynara.js | 78 +++ open-sse/providers/registry/commandcode.js | 68 ++- open-sse/providers/registry/glm.js | 5 +- open-sse/providers/registry/grok-web.js | 9 + open-sse/providers/registry/hcnsec.js | 38 +- open-sse/providers/registry/index.js | 10 + open-sse/providers/registry/infron.js | 71 +++ .../providers/registry/inxorastudio-web.js | 52 ++ open-sse/providers/registry/inxorastudio.js | 76 +++ open-sse/providers/thinkingLevels.js | 69 +++ open-sse/services/projectId.js | 19 +- open-sse/services/provider.js | 12 + open-sse/services/usage.js | 4 + open-sse/services/usage/grok-web.js | 99 ++++ open-sse/services/usage/infron.js | 68 +++ open-sse/transformer/responsesTransformer.js | 439 -------------- open-sse/transformer/streamToJsonConverter.js | 101 ++-- .../concerns/responsesAccumulator.js | 552 ++++++++++++++++++ open-sse/translator/formats/responsesApi.js | 119 +--- open-sse/translator/request/claude-to-kiro.js | 9 +- open-sse/translator/request/openai-to-kiro.js | 11 +- .../translator/response/openai-responses.js | 138 +++-- open-sse/utils/responsesStreamHelpers.js | 17 +- open-sse/utils/stream.js | 10 +- open-sse/utils/tlsImpersonate.js | 103 ---- package.json | 9 +- public/providers/agentrouter.png | Bin 0 -> 9597 bytes public/providers/bynara.svg | 22 + public/providers/infron.svg | 1 + public/providers/inxorastudio-web.svg | 4 + public/providers/inxorastudio.svg | 4 + scripts/test-combo-autoswitch.mjs | 2 +- .../combos/components/ComboTemplates.js | 138 ----- .../dashboard/overview/OverviewClient.js | 8 + .../overview/components/LeaderboardTable.js | 201 +++++++ .../overview/components/SavingsCard.js | 123 ++++ .../dashboard/providers/[id]/InxoraProfile.js | 71 +++ .../dashboard/providers/[id]/ModelRow.js | 6 + .../[id]/PassthroughModelsSection.js | 174 ------ .../[id]/components/ConnectionsCard.js | 4 + .../dashboard/providers/[id]/page.js | 12 +- .../providers/components/ProviderCardV2.js | 133 ----- .../providers/components/ProviderSection.js | 90 --- .../providers/components/ProviderSummary.js | 60 -- .../ProviderLimits/ProviderLimitCard.js | 187 ------ .../quota/components/ProviderLimits/utils.js | 39 ++ src/app/api/dashboard/stream/route.js | 101 ++++ .../providers/[id]/inxora-profile/route.js | 52 ++ src/app/api/providers/client/route.js | 2 +- .../api/providers/suggested-models/filters.js | 45 ++ .../api/providers/suggested-models/route.js | 53 +- src/app/api/providers/validate/route.js | 31 + src/app/api/usage/leaderboard/route.js | 91 +++ src/app/api/usage/meta/route.js | 19 + src/lib/db/repos/usageRepo.js | 32 +- src/lib/oauth/utils/banner.js | 63 -- src/shared/components/Header.js | 2 + src/shared/components/NotificationBell.js | 161 +++++ src/shared/hooks/useDashboardStream.js | 92 +++ src/shared/utils/providerIcon.js | 1 + src/shared/utils/providerModelsFetcher.js | 18 +- src/sse/handlers/chat.js | 2 +- src/sse/services/tokenRefresh.js | 2 +- src/store/notificationStore.js | 118 +++- src/store/settingsStore.js | 51 -- .../real/nvidia-thinking.e2e.test.js | 2 +- .../unit/kiro-thinking-normalization.test.js | 118 ++++ tests/unit/responses-accumulator.test.js | 168 ++++++ 81 files changed, 3680 insertions(+), 1877 deletions(-) create mode 100644 open-sse/executors/inxorastudio-web.js delete mode 100644 open-sse/handlers/responsesHandler.js create mode 100644 open-sse/providers/registry/agentrouter.js create mode 100644 open-sse/providers/registry/bynara.js create mode 100644 open-sse/providers/registry/infron.js create mode 100644 open-sse/providers/registry/inxorastudio-web.js create mode 100644 open-sse/providers/registry/inxorastudio.js create mode 100644 open-sse/providers/thinkingLevels.js create mode 100644 open-sse/services/usage/grok-web.js create mode 100644 open-sse/services/usage/infron.js delete mode 100644 open-sse/transformer/responsesTransformer.js create mode 100644 open-sse/translator/concerns/responsesAccumulator.js delete mode 100644 open-sse/utils/tlsImpersonate.js create mode 100644 public/providers/agentrouter.png create mode 100644 public/providers/bynara.svg create mode 100644 public/providers/infron.svg create mode 100644 public/providers/inxorastudio-web.svg create mode 100644 public/providers/inxorastudio.svg delete mode 100644 src/app/(dashboard)/dashboard/combos/components/ComboTemplates.js create mode 100644 src/app/(dashboard)/dashboard/overview/components/LeaderboardTable.js create mode 100644 src/app/(dashboard)/dashboard/overview/components/SavingsCard.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/InxoraProfile.js delete mode 100644 src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js delete mode 100644 src/app/(dashboard)/dashboard/providers/components/ProviderCardV2.js delete mode 100644 src/app/(dashboard)/dashboard/providers/components/ProviderSection.js delete mode 100644 src/app/(dashboard)/dashboard/providers/components/ProviderSummary.js delete mode 100644 src/app/(dashboard)/dashboard/quota/components/ProviderLimits/ProviderLimitCard.js create mode 100644 src/app/api/dashboard/stream/route.js create mode 100644 src/app/api/providers/[id]/inxora-profile/route.js create mode 100644 src/app/api/usage/leaderboard/route.js delete mode 100644 src/lib/oauth/utils/banner.js create mode 100644 src/shared/components/NotificationBell.js create mode 100644 src/shared/hooks/useDashboardStream.js delete mode 100644 src/store/settingsStore.js create mode 100644 tests/unit/kiro-thinking-normalization.test.js create mode 100644 tests/unit/responses-accumulator.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e5b31..0cba5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,49 @@ +# v0.7.6 (2026-07-25) + +## Features +- **Dollar Savings Tracker**: hero card di Overview menampilkan total $ Saved (lifetime) dengan per-mechanism breakdown (RTK/Headroom/Pxpipe/Cache/Caveman/Ponytail) + "With vs Without ExtremeRouter" comparison. +- **Provider Performance Leaderboard**: sortable table di Overview dengan ranking per-provider: Requests, Tokens, TTFT, P95 Latency, Success Rate, Cost. Period selector (24h/7d/30d). Custom provider names resolved dari providerNodes. +- **SSE Live Dashboard**: unified `/api/dashboard/stream` SSE endpoint (stats + breaker + health). Real-time KPI updates via `useDashboardStream` hook dengan auto-reconnect. +- **In-App Notification System**: bell icon di header dengan unread badge, dropdown feed, localStorage history persistence. Push notifications untuk provider down/recovered/health degraded/rate limited. +- **Cross-Transport Fallback**: OpenAI endpoint timeout/5xx → auto-retry Anthropic endpoint (body re-translated). Fresh AbortController per attempt. Applied ke GLM, hcnsec, Bynara, InxoraStudio, CommandCode, Infron, AgentRouter. +- **New Provider: Bynara**: multi-model router (OpenAI + Anthropic + Responses + Image gen/edits). +- **New Provider: InxoraStudio Labs (API)**: OpenAI + Anthropic multi-endpoint. +- **New Provider: InxoraStudio Labs (Web)**: JWT web chat executor (3-step flow), profile badge, auto model discovery. +- **New Provider: Infron AI**: 457+ models, OpenAI + Anthropic, quota tracker (credit balance). +- **New Provider: AgentRouter**: GLM/GPT/Claude, OpenAI + Anthropic, custom pricing model discovery. +- **Updated Provider: Command Code**: migrated dari custom `/alpha/generate` ke standard OpenAI/Anthropic provider API, live model discovery (47 models). +- **Quota Tracker: Grok Web (Subscription)**: SSO cookie rate-limits (Fast/Thinking/Heavy tiers). +- **Quota Tracker: Infron AI**: credit balance via `/v1/balance`. + +## Fixes +- **Kiro `REQUEST_BODY_INVALID`**: normalize `(level)` suffix sebelum resolving synthetic variants. Map explicit levels ke native Kiro effort fields hanya untuk supported families (Claude 5 / GPT-5.6). `thinkingLevels.js` server-side gating. +- **Responses API accumulator**: shared `ResponsesAccumulator` untuk streaming + forced non-stream. Alias-safe tool reconstruction. `preferComplete` untuk snapshot merge. Exactly-once terminal semantics. +- **Responses `incomplete`/`cancelled`**: terminal events sekarang di-recognize di `responsesStreamHelpers.js`. +- **Antigravity "model turn" 400**: strip trailing `role:"model"` turns dari `contents[]` sebelum kirim ke Google Cloud Code. +- **Antigravity tier routing**: `gemini-3.6-flash-high/medium/low` dengan `upstreamModelId: "gemini-3.6-flash-tiered(high)"` + parser di `getModelUpstreamId`. +- **Cloud Code endpoint isolation**: gemini-cli→`cloudcode-pa`, antigravity→`daily-cloudcode-pa` via per-provider `CLOUD_CODE_API` map. +- **Circuit breaker `releaseBreakerProbe`**: `monitors`→`breakers` ReferenceError fix. +- **Combo `comboStickyLimit`**: wrong key `comboStickyLimit`→`comboStickyRoundRobinLimit`. +- **Combo `comboStrategies` lost-update**: deep-merge per combo-name + null delete-signal. +- **Cross-transport abort**: fresh AbortController untuk alternate attempt. +- **Cross-transport error logging**: alternate failure error tidak lagi di-swallow silently. +- **Responses output getter**: dedup alias-registered tools + iterate string-keyed items. +- **Notification spam**: `health_update` dan `usage_update` tidak lagi jadi notification. +- **Notification raw JSON**: `formatEventMessage` return `null` untuk unknown types. +- **Notification duplicate**: `lastProcessedTs` useRef guard + dedup. +- **NotificationBell setState-during-render**: `markAllRead` deferred via `requestAnimationFrame`. +- **NotificationBell key collision**: `idCounter` seeded dari `Date.now()` + max history id. +- **hcnsec model discovery**: tambah `"openai"` filter + authenticated suggested-models proxy. +- **WEB_COOKIE_PROVIDERS modelsFetcher**: page.js sekarang include webCookie providers di model discovery chain. +- **InxoraStudio profile badge**: `InxoraProfile` component + wiring di ConnectionsCard. + +## Improvements +- **Dead code cleanup**: hapus 12 dead files (1,705 LOC) + dead `convertResponsesApiFormat` function. +- **Junk dependency removed**: `fs` (0.0.1-security placeholder package) dari dependencies. +- **Command Code executor**: hapus custom `CommandCodeExecutor`, pakai `DefaultExecutor` (OpenAI/Anthropic native). +- **Model discovery**: `suggested-models` route sekarang support `connectionId` untuk authenticated discovery. +- **Validate route**: hcnsec, InxoraStudio-web, inxorastudio (API) validate cases. + # v0.7.5 (2026-07-22) ## Features diff --git a/cli/package.json b/cli/package.json index a3bba31..3ec98e2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rsalmn/extremerouter", - "version": "0.7.5", + "version": "0.7.6", "description": "ExtremeRouter CLI - AI Gateway control plane", "bin": { "extremerouter": "./cli.js" diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index dcc786d..1845662 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -132,12 +132,28 @@ export const ANTIGRAVITY_HEADERS = { "User-Agent": `antigravity/2.1.1 ${platform()}/${arch()}` }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. +// gemini-cli uses the production host (cloudcode-pa.googleapis.com) while +// antigravity uses the sandbox host (daily-cloudcode-pa.googleapis.com) per +// decolua/9router commit 190020c. Keyed by provider id; projectId.js resolves +// the right endpoints per-connection. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + antigravity: { + loadCodeAssist: "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://daily-cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; +// Back-compat flat accessor: callers that haven't been migrated to pass a +// provider default to the gemini-cli (production) endpoints — the previous +// behavior before this map was split. +export const CLOUD_CODE_LOAD_CODEASSIST = CLOUD_CODE_API["gemini-cli"].loadCodeAssist; +export const CLOUD_CODE_ONBOARD_USER = CLOUD_CODE_API["gemini-cli"].onboardUser; + export const LOAD_CODE_ASSIST_HEADERS = { "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 3338d7d..ed571a5 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -16,6 +16,7 @@ */ import { extractThinking } from "../translator/concerns/thinkingUnified.js"; +import { parseSuffix } from "../translator/concerns/thinkingUnified.js"; import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; @@ -234,6 +235,83 @@ export function resolveKiroModel(model) { return { upstream, agentic, thinking }; } +/** + * Resolve the per-family effort path for a Kiro upstream model id. + * + * Kiro upstream (CodeWhisperer) accepts native effort fields ONLY for specific + * model families. Other families (legacy Claude 4.5, GLM, DeepSeek, Qwen, + * MiniMax) reject native effort as REQUEST_BODY_INVALID — their reasoning flows + * solely via the `` system tag injection. + * + * Returns one of: + * - "kiro-claude" → Claude 5 family (Sonnet 5, Haiku 5, Opus 5) + * - "kiro-gpt" → GPT-5.6 family (Sol, Terra, Luna) + * - null → not supported (tag-only reasoning) + * + * @param {string} upstreamModel - clean upstream id (after resolveKiroModel) + * @returns {string|null} + */ +export function resolveKiroEffortPath(upstreamModel) { + if (typeof upstreamModel !== "string" || !upstreamModel) return null; + const lower = upstreamModel.toLowerCase(); + // Claude 5 family (NOT Claude 4.x — those are legacy and reject native effort). + // Match claude-{opus,sonnet,haiku}-5 explicitly to exclude 4.5/4.6/etc. + if (/^claude-(opus|sonnet|haiku)-5(\b|$|-)/.test(lower)) return "kiro-claude"; + // GPT-5.6 family. + if (/^gpt-5\.6-/.test(lower)) return "kiro-gpt"; + return null; +} + +/** + * Resolve a Kiro model id after consuming the generic `model(level)` suffix + * that the dashboard thinking-level picker appends. + * + * The suffix is an ExtremeRouter request override (parsed by parseSuffix), + * NOT part of Kiro's upstream model id. We strip it before resolving synthetic + * variants (-thinking / -agentic) so the upstream modelId stays clean. + * + * @param {string} model + * @returns {{ model: string, upstream: string, agentic: boolean, thinking: boolean, thinkingOverride: object|null }} + */ +export function resolveKiroModelIntent(model) { + const { cleanModel, override } = parseSuffix(model); + return { + model: cleanModel, + ...resolveKiroModel(cleanModel), + thinkingOverride: override, + }; +} + +/** + * Apply a parsed `model(level)` override to a request body without mutating the + * caller's body. Translates the override into the appropriate thinking shape so + * downstream consumers (resolveKiroThinkingBudget, the system-tag injector) see + * a consistent intent regardless of whether it came from the body fields or the + * model-name suffix. + * + * @param {object} body + * @param {object|null} override - { mode: "level"|"budget"|"auto"|"none", ... } + * @returns {object} new body with thinking fields set + */ +export function applyKiroThinkingOverride(body, override) { + if (!override) return body; + + const next = { ...body }; + if (override.mode === "budget") { + delete next.output_config; + delete next.reasoning_effort; + delete next.reasoning; + next.thinking = { type: "enabled", budget_tokens: override.budget }; + return next; + } + + next.output_config = { + ...(body.output_config || {}), + effort: override.mode === "level" ? override.level : override.mode, + }; + return next; +} + /** * Build the magic system-prompt prefix that turns Kiro reasoning on. * Same shape as CLIProxyAPIPlus. diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index c4cfa41..49bd37c 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -48,7 +48,24 @@ export function getModelType(aliasOrId, modelId) { export function getModelUpstreamId(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; const found = models?.find(m => m.id === modelId); - if (found?.upstreamModelId) return found.upstreamModelId; + if (found?.upstreamModelId) { + // Tier-preset suffix support: a model entry can carry a preset tag in its + // upstreamModelId, e.g. "gemini-3.6-flash-tiered(high)" for tiered routing. + // When the caller appended an effort/tier suffix (e.g. "-high"), we merge + // the caller's suffix with the preset; otherwise the preset wins on its own. + // Port of decolua/9router commit 190020c (isolates tiered model routing). + const resolvedId = found.upstreamModelId; + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + if (presetSuffix) { + const resolvedBase = resolvedId.slice(0, presetMatch.index).trim(); + // Caller suffix (passed as modelId tail) takes precedence if present. + const callerSuffixMatch = typeof modelId === "string" ? modelId.match(/-(high|medium|low)$/i) : null; + const callerTier = callerSuffixMatch?.[0] ? `(${callerSuffixMatch[0].slice(1).toLowerCase()})` : ""; + return resolvedBase + (callerTier || presetSuffix); + } + return resolvedId; + } if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); } diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 5249a3e..b82a619 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -195,6 +195,17 @@ export class AntigravityExecutor extends BaseExecutor { return c; }); + // Google Cloud Code rejects requests where the last content turn is from + // the model ("Requests ending with a model turn are not supported"). This + // happens when the client sends a conversation history that ends with an + // assistant message (e.g. Claude Code sending the previous response as + // context). Strip trailing model turns so the last entry is always user. + if (Array.isArray(contents) && contents.length > 0) { + while (contents.length > 1 && contents[contents.length - 1]?.role === "model") { + contents.pop(); + } + } + // Sanitize tool schemas and function names before sending to Antigravity. let tools = body.request?.tools; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 9408e44..9953684 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -15,7 +15,6 @@ import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; import { OllamaLocalExecutor } from "./ollama-local.js"; -import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; @@ -30,6 +29,7 @@ import { BlackboxWebExecutor } from "./blackbox-web.js"; import { ZenmuxFreeExecutor } from "./zenmux-free.js"; import { ApiAirforceExecutor } from "./api-airforce.js"; import { FreeBuffWebExecutor } from "./freebuff-web.js"; +import { InxorastudioWebExecutor } from "./inxorastudio-web.js"; import { PerplexityAgentExecutor } from "./perplexity-agent.js"; import { QwenCloudExecutor } from "./qwencloud.js"; import { T3ChatWebExecutor } from "./t3-web.js"; @@ -73,7 +73,7 @@ const executors = { "grok-web": new GrokWebExecutor(), "perplexity-web": new PerplexityWebExecutor(), "ollama-local": new OllamaLocalExecutor(), - commandcode: new CommandCodeExecutor(), + // commandcode now uses DefaultExecutor (OpenAI/Anthropic native endpoints) "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free @@ -103,6 +103,7 @@ const executors = { "zenmux-free": new ZenmuxFreeExecutor(), "api-airforce": new ApiAirforceExecutor(), "freebuff-web": new FreeBuffWebExecutor(), + "inxorastudio-web": new InxorastudioWebExecutor(), "perplexity-agent": new PerplexityAgentExecutor(), "qwencloud": new QwenCloudExecutor(), lmarena: new LMArenaExecutor(), @@ -142,7 +143,7 @@ export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; -export { CommandCodeExecutor } from "./commandcode.js"; +// CommandCodeExecutor removed — provider now uses standard OpenAI/Anthropic endpoints (DefaultExecutor) export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; @@ -171,6 +172,7 @@ export { HuggingChatExecutor } from "./huggingchat.js"; export { ZenmuxFreeExecutor } from "./zenmux-free.js"; export { ApiAirforceExecutor } from "./api-airforce.js"; export { FreeBuffWebExecutor } from "./freebuff-web.js"; +export { InxorastudioWebExecutor } from "./inxorastudio-web.js"; export { PerplexityAgentExecutor } from "./perplexity-agent.js"; export { QwenCloudExecutor } from "./qwencloud.js"; export { LMArenaExecutor } from "./lmarena.js"; diff --git a/open-sse/executors/inxorastudio-web.js b/open-sse/executors/inxorastudio-web.js new file mode 100644 index 0000000..5e5c203 --- /dev/null +++ b/open-sse/executors/inxorastudio-web.js @@ -0,0 +1,314 @@ +import { randomUUID } from "node:crypto"; +import { BaseExecutor } from "./base.js"; +import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js"; +import { sseChunk } from "../utils/sse.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +// InxoraStudio Labs (Web) — web dashboard chat (labs.inxorastudio.com). +// +// Bridges the InxoraStudio web API to an OpenAI-compatible interface via a +// 3-step flow: +// 1. POST /api/conversations { model } → { id } (create conversation) +// 2. POST /api/conversations/{id}/messages/stream { content, model, mode, search, attachments } +// → SSE stream of { t, d } events: +// - { t:"chunk", d:"text" } → content delta +// - { t:"gen", d:{genId} } → generation start (ignored) +// - { t:"user_message", d:{...} } → echoed user message (ignored) +// - { t:"done", d:{ assistantMessage:{ tokens, inputTokens, outputTokens, ... } } } +// → terminal: usage + finish_reason: stop +// 3. Parse SSE → OpenAI chat.completion.chunk frames +// +// Auth: Bearer JWT token from labs.inxorastudio.com dashboard login. +// User pastes the Authorization header value (eyJ...). + +const API_BASE = "https://labs.inxorastudio.com"; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; + +function normalizeToken(raw) { + let v = String(raw || "").trim(); + if (v.toLowerCase().startsWith("bearer ")) v = v.slice(7).trim(); + if (v.toLowerCase().startsWith("cookie:")) v = v.replace(/^cookie:\s*/i, "").trim(); + return v; +} + +function errorResponse(status, message, code = "INXORA_ERROR") { + return new Response( + JSON.stringify({ error: { message, type: "upstream_error", code } }), + { status, headers: { "Content-Type": "application/json" } }, + ); +} + +function buildHeaders(token) { + return { + "Content-Type": "application/json", + Accept: "*/*", + Authorization: `Bearer ${token}`, + Origin: API_BASE, + Referer: `${API_BASE}/dashboard`, + "User-Agent": USER_AGENT, + }; +} + +export class InxorastudioWebExecutor extends BaseExecutor { + constructor() { + super("inxorastudio-web", null); + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const token = normalizeToken(credentials?.apiKey || ""); + if (!token) { + return { + response: errorResponse(401, "InxoraStudio: no token provided. Log in at labs.inxorastudio.com and copy the Bearer JWT from DevTools."), + url: API_BASE, headers: {}, transformedBody: body, + }; + } + + // Flatten messages into a single content string (InxoraStudio web only + // accepts one text field per message — no multi-turn history). + const messages = body?.messages || []; + const userMessages = messages.filter((m) => m.role === "user"); + const sysMessages = messages.filter((m) => m.role === "system"); + const lastUser = userMessages[userMessages.length - 1]; + const userText = typeof lastUser?.content === "string" + ? lastUser.content + : Array.isArray(lastUser?.content) + ? lastUser.content.filter((c) => c.type === "text").map((c) => c.text).join("\n") + : ""; + if (!userText.trim()) { + return { + response: errorResponse(400, "InxoraStudio: request has no user message content."), + url: API_BASE, headers: {}, transformedBody: body, + }; + } + const sysText = sysMessages.length > 0 + ? (typeof sysMessages[0].content === "string" ? sysMessages[0].content : "") + : null; + const fullText = sysText ? `${sysText}\n\n${userText}` : userText; + + const modelId = model || "ixlabs/gpt-5.5"; + const headers = buildHeaders(token); + + // Step 1: Create conversation. + let conversationId; + try { + log?.info?.("INXORA", `create conversation model=${modelId} len=${fullText.length}`); + const convRes = await proxyAwareFetch(`${API_BASE}/api/conversations`, { + method: "POST", + headers, + body: JSON.stringify({ model: modelId }), + signal, + }, proxyOptions); + + if (convRes.status === 401 || convRes.status === 403) { + return { + response: errorResponse(401, "InxoraStudio: token is invalid or expired — re-copy from labs.inxorastudio.com DevTools."), + url: `${API_BASE}/api/conversations`, headers, transformedBody: body, + }; + } + if (!convRes.ok) { + const errText = await convRes.text().catch(() => ""); + return { + response: errorResponse(convRes.status, `InxoraStudio create conversation failed: ${errText.slice(0, 300)}`), + url: `${API_BASE}/api/conversations`, headers, transformedBody: body, + }; + } + const convData = await convRes.json().catch(() => null); + conversationId = convData?.id; + if (!conversationId) { + return { + response: errorResponse(502, "InxoraStudio: conversation creation returned no id."), + url: `${API_BASE}/api/conversations`, headers, transformedBody: body, + }; + } + } catch (err) { + if (err.name === "AbortError") throw err; + return { + response: errorResponse(502, `InxoraStudio create conversation error: ${err?.message || err}`), + url: `${API_BASE}/api/conversations`, headers, transformedBody: body, + }; + } + + // Step 2: Stream message to conversation. + const streamUrl = `${API_BASE}/api/conversations/${conversationId}/messages/stream`; + const streamBody = { + content: fullText, + model: modelId, + mode: "deep", + search: false, + attachments: [], + }; + + let upstream; + try { + log?.debug?.("INXORA", `stream to conversation ${conversationId}`); + upstream = await proxyAwareFetch(streamUrl, { + method: "POST", + headers, + body: JSON.stringify(streamBody), + signal, + }, proxyOptions); + } catch (err) { + if (err.name === "AbortError") throw err; + return { + response: errorResponse(502, `InxoraStudio stream fetch failed: ${err?.message || err}`), + url: streamUrl, headers, transformedBody: streamBody, + }; + } + + if (upstream.status === 401 || upstream.status === 403) { + return { + response: errorResponse(401, "InxoraStudio: token is invalid or expired."), + url: streamUrl, headers, transformedBody: streamBody, + }; + } + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + return { + response: errorResponse(upstream.status, `InxoraStudio stream error: ${errText.slice(0, 300)}`), + url: streamUrl, headers, transformedBody: streamBody, + }; + } + if (!upstream.body) { + return { + response: errorResponse(502, "InxoraStudio: empty stream body"), + url: streamUrl, headers, transformedBody: streamBody, + }; + } + + const cid = `chatcmpl-ix-${randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + // Non-streaming: collect all text + usage, return single JSON + if (!stream) { + const { content, usage } = await collectStream(upstream.body, signal); + const promptTokens = usage?.inputTokens || Math.ceil(fullText.length / 4); + const completionTokens = usage?.outputTokens || Math.ceil(content.length / 4); + return { + response: new Response( + JSON.stringify({ + id: cid, object: "chat.completion", created, model: modelId, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + url: streamUrl, headers, transformedBody: streamBody, + }; + } + + // Streaming: translate InxoraStudio SSE → OpenAI SSE + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const responseStream = new ReadableStream({ + async start(controller) { + const reader = upstream.body.getReader(); + let buffer = ""; + + // Initial role delta + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model: modelId, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }))); + + try { + while (true) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const t = line.trim(); + if (!t.startsWith("data: ")) continue; + const raw = t.slice(6); + if (raw === "[DONE]") continue; + try { + const evt = JSON.parse(raw); + const type = evt?.t; + const data = evt?.d; + + if (type === "chunk" && typeof data === "string") { + // Content delta + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model: modelId, + choices: [{ index: 0, delta: { content: data }, finish_reason: null }], + }))); + } else if (type === "done") { + // Terminal: emit finish chunk with usage (if available) + const assistant = data?.assistantMessage; + const finalChunk = { + id: cid, object: "chat.completion.chunk", created, model: modelId, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + if (assistant?.inputTokens && assistant?.outputTokens) { + finalChunk.usage = { + prompt_tokens: assistant.inputTokens, + completion_tokens: assistant.outputTokens, + total_tokens: assistant.tokens || (assistant.inputTokens + assistant.outputTokens), + }; + } + controller.enqueue(encoder.encode(sseChunk(finalChunk))); + } + // "gen", "user_message" → ignored + } catch { /* skip malformed */ } + } + } + } catch (err) { + if (!signal?.aborted) controller.error(err); + } finally { + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } + }, + }); + + return { + response: new Response(responseStream, { status: 200, headers: { ...SSE_HEADERS_NO_BUFFER } }), + url: streamUrl, headers, transformedBody: streamBody, + }; + } +} + +/** + * Collect content + usage from an InxoraStudio SSE stream body. + * Parses { t:"chunk", d:"..." } for content and { t:"done", d:{assistantMessage:{...}} } for usage. + */ +async function collectStream(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let content = ""; + let usage = null; + try { + while (true) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const line of lines) { + const t = line.trim(); + if (!t.startsWith("data: ")) continue; + const raw = t.slice(6); + if (raw === "[DONE]") continue; + try { + const evt = JSON.parse(raw); + if (evt?.t === "chunk" && typeof evt.d === "string") { + content += evt.d; + } else if (evt?.t === "done" && evt.d?.assistantMessage) { + const a = evt.d.assistantMessage; + usage = { inputTokens: a.inputTokens, outputTokens: a.outputTokens, tokens: a.tokens }; + } + } catch {} + } + } + } finally { + reader.releaseLock(); + } + return { content, usage }; +} + +export default InxorastudioWebExecutor; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 5f7b8e0..00d1932 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,4 +1,4 @@ -import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; +import { detectFormat, getTargetFormat, resolveTransport, resolveAlternateTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; @@ -322,6 +322,77 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Execute request let providerResponse, providerUrl, providerHeaders, finalBody, executorRetryCount = 0; + let triedAlternateTransport = false; + + // Cross-transport fallback helper: if the primary endpoint (matched by + // sourceFormat) fails with a qualifying error (timeout/5xx/network), try the + // alternate endpoint (e.g. OpenAI → Claude or vice versa). The body is + // re-translated to the alternate format before retry. Exactly-once. + const tryAlternateTransport = async (failReason) => { + if (triedAlternateTransport) return false; + // H1 FIX: Bail if the client already disconnected — don't waste time on + // an alternate attempt whose signal will immediately abort. + if (streamController.signal?.aborted) return false; + const altTransport = resolveAlternateTransport(provider, runtimeTransport?.format || targetFormat); + if (!altTransport) return false; + + triedAlternateTransport = true; + log?.warn?.("TRANSPORT", `${provider.toUpperCase()} | primary ${runtimeTransport?.format || targetFormat} failed (${failReason}), retrying via alternate ${altTransport.format}`); + + // Re-translate body to the alternate format. Use the ORIGINAL body (pre- + // translation) so the translator starts from the client sourceFormat. + const altTargetFormat = altTransport.format; + let altTranslatedBody = translatedBody; + if (altTargetFormat !== targetFormat && altTargetFormat !== sourceFormat) { + altTranslatedBody = translateRequest(sourceFormat, altTargetFormat, model, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); + if (!altTranslatedBody) return false; + delete altTranslatedBody._toolNameMap; + altTranslatedBody.model = upstreamModel; + } + + // Save original transport so we can restore it if the alternate fails + // (prevents the 401-retry path from using the alternate transport with a + // primary-format body). + const originalTransport = credentials.runtimeTransport; + + // Set the alternate transport on credentials so the executor uses the + // alternate URL + auth headers automatically. + credentials.runtimeTransport = altTransport; + + // H1 FIX: Create a fresh AbortController for the alternate attempt. The + // primary attempt's signal may have been aborted by the stall watchdog or + // client disconnect, which would cause the alternate fetch to fail + // instantly with AbortError for the wrong reason. + const altController = new AbortController(); + // Forward client aborts to the alternate controller, but don't inherit + // any prior abort state from the primary's stall/disconnect handling. + if (streamController.signal) { + streamController.signal.addEventListener("abort", () => altController.abort(), { once: true }); + } + + try { + const altResult = await executor.execute({ model, body: altTranslatedBody, stream, credentials, signal: altController.signal, log, proxyOptions }); + if (altResult.response?.ok) { + providerResponse = altResult.response; + providerUrl = altResult.url; + providerHeaders = altResult.headers; + finalBody = altResult.transformedBody; + executorRetryCount += altResult.retryCount || 0; + return true; + } else { + // M1 FIX: Log the alternate's failure status so operators can debug + // "both endpoints down" scenarios instead of seeing only the primary error. + log?.warn?.("TRANSPORT", `${provider.toUpperCase()} | alternate ${altTransport.format} also failed (HTTP ${altResult.response.status})`); + } + } catch (altErr) { + // M1 FIX: Log the alternate's exception instead of swallowing it silently. + log?.warn?.("TRANSPORT", `${provider.toUpperCase()} | alternate ${altTransport.format} threw: ${altErr?.message || altErr}`); + // Restore original transport so downstream retry paths use the correct endpoint. + credentials.runtimeTransport = originalTransport; + } + return false; + }; + try { const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); providerResponse = result.response; @@ -355,24 +426,32 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); - saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: translatedBody || null, - response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, - status: "error" - })).catch(() => { }); if (error.name === "AbortError") { streamController.handleError(error); return createErrorResult(499, "Request aborted"); } - const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + + // Cross-transport fallback: network error or timeout → try alternate endpoint. + const recovered = await tryAlternateTransport(`network error: ${error.message || error}`); + if (recovered) { + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + } else { + appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: translatedBody || null, + response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, + status: "error" + })).catch(() => { }); + + const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + } } // Handle 401/403 - try token refresh (skip for noAuth providers) @@ -399,23 +478,54 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider returned error if (!providerResponse.ok) { - trackPendingRequest(model, provider, connectionId, false, true); const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); - saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null, - response: { error: message, status: statusCode, thinking: null }, - status: "error" - })).catch(() => { }); - - const errMsg = formatProviderError(new Error(message), provider, model, statusCode); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); - reqLogger.logError(new Error(message), finalBody || translatedBody); - return createErrorResult(statusCode, errMsg, resetsAtMs); + + // Cross-transport fallback: 5xx and timeout-status errors qualify. 4xx + // client errors (400/401/403/404) do NOT — they indicate the request is + // wrong, not the endpoint. + if (statusCode >= 500 || statusCode === 0) { + const recovered = await tryAlternateTransport(`HTTP ${statusCode}: ${message}`); + if (recovered) { + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + // Skip the error block below — providerResponse is now the alternate's + // successful response. Fall through to the success path. + } else { + trackPendingRequest(model, provider, connectionId, false, true); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + response: { error: message, status: statusCode, thinking: null }, + status: "error" + })).catch(() => { }); + + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + reqLogger.logError(new Error(message), finalBody || translatedBody); + return createErrorResult(statusCode, errMsg, resetsAtMs); + } + } else { + // 4xx — no transport fallback, return error immediately. + trackPendingRequest(model, provider, connectionId, false, true); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + response: { error: message, status: statusCode, thinking: null }, + status: "error" + })).catch(() => { }); + + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + reqLogger.logError(new Error(message), finalBody || translatedBody); + return createErrorResult(statusCode, errMsg, resetsAtMs); + } } const sharedCtx = { diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 9a12f48..e046f5b 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -5,6 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js"; import { PROVIDERS } from "../../config/providers.js"; import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; +import { createResponsesAccumulator } from "../../translator/concerns/responsesAccumulator.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; @@ -23,7 +24,7 @@ const CODEX_SOURCE_TO_TARGET = { /** * Determine which SSE transform stream to use based on provider/format. */ -function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { +function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responsesAccumulator }) { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; @@ -31,11 +32,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, if (needsCodexTranslation) { const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; - return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responsesAccumulator); } if (needsTranslation(targetFormat, sourceFormat)) { - return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responsesAccumulator); } return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); @@ -79,11 +80,23 @@ export async function handleStreamingResponse({ providerResponse, provider, mode }; } - const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + // Per-request Responses accumulator: shared across the transform stream + // (translator), forced non-stream converter, and abort handler. Created here + // so all consumers see the same correlation state. Only for Responses-format + // targets (passthrough or translation). + const responsesAccumulator = targetFormat === FORMATS.OPENAI_RESPONSES || sourceFormat === FORMATS.OPENAI_RESPONSES + ? createResponsesAccumulator({ model }) + : null; + + const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responsesAccumulator }); // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; - const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; + // Pass the accumulator to the abort handler so it finalizes with the + // partial output + error (preserving exactly-once terminal semantics). + const onAbortTerminal = isResponsesPassthrough + ? () => buildAbortedResponsesTerminalBytes(responsesAccumulator) + : null; const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.js deleted file mode 100644 index 8c17f98..0000000 --- a/open-sse/handlers/responsesHandler.js +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Responses API Handler for Workers - * Converts Chat Completions to Codex Responses API format - */ - -import { handleChatCore } from "./chatCore.js"; -import { convertResponsesApiFormat } from "../translator/formats/responsesApi.js"; -import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js"; -import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js"; -import { SSE_HEADERS_CORS } from "../utils/sseConstants.js"; - -/** - * 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, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, connectionId }) { - // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body); - - // Preserve client's stream preference (matches OpenClaw behavior) - // Default to false if omitted: Boolean(undefined) = false - const clientRequestedStreaming = convertedBody.stream === true; - if (convertedBody.stream === undefined) { - convertedBody.stream = false; - } - - // Call chat core handler — force sourceFormat so streaming path knows this is a Responses API client - const result = await handleChatCore({ - body: convertedBody, - modelInfo, - credentials, - log, - onCredentialsRefreshed, - onRequestSuccess, - onDisconnect, - connectionId, - sourceFormatOverride: "openai-responses" - }); - - if (!result.success || !result.response) { - return result; - } - - const response = result.response; - const contentType = response.headers.get("Content-Type") || ""; - - // Case 1: Client wants non-streaming, but got SSE (provider forced it, e.g., Codex) - if (!clientRequestedStreaming && contentType.includes("text/event-stream")) { - try { - const jsonResponse = await convertResponsesStreamToJson(response.body); - - return { - success: true, - response: new Response(JSON.stringify(jsonResponse), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache", - "Access-Control-Allow-Origin": "*" - } - }) - }; - } catch (error) { - console.error("[Responses API] Stream-to-JSON conversion failed:", error); - return { - success: false, - status: 500, - error: "Failed to convert streaming response to JSON" - }; - } - } - - // 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); - - return { - success: true, - response: new Response(transformedBody, { - status: 200, - headers: { ...SSE_HEADERS_CORS } - }) - }; - } - - // Case 3: Non-SSE response (error or non-streaming from provider) - return as-is - return result; -} - diff --git a/open-sse/providers/registry/agentrouter.js b/open-sse/providers/registry/agentrouter.js new file mode 100644 index 0000000..2d42caa --- /dev/null +++ b/open-sse/providers/registry/agentrouter.js @@ -0,0 +1,70 @@ +// AgentRouter — multi-model AI router (agentrouter.org). +// +// OpenAI-compatible + Anthropic-native gateway behind a single API key. +// Models: GLM 5.2, GPT 5.5, Claude Opus 4.6/4.7/4.8. +// Claude models support both OpenAI and Anthropic endpoint types. +// +// Multi-endpoint transport with cross-transport fallback: if the OpenAI +// endpoint times out or 5xxs, the engine retries via the Anthropic endpoint +// automatically (body is re-translated to Claude format). +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "agentrouter", + priority: 360, + alias: "agentrouter", + aliases: ["ar"], + uiAlias: "ar", + display: { + name: "AgentRouter", + icon: "hub", + color: "#F97316", + textIcon: "AR", + website: "https://agentrouter.org", + notice: { + signupUrl: "https://agentrouter.org", + apiKeyUrl: "https://agentrouter.org", + text: "AgentRouter is a multi-model AI router with OpenAI and Anthropic API support. Create an API key at agentrouter.org, then paste it here. Models: GLM 5.2, GPT 5.5, Claude Opus 4.6/4.7/4.8.", + }, + }, + category: "freeTier", + hasFree: true, + authType: "apikey", + transport: { + baseUrl: "https://agentrouter.org/v1/chat/completions", + format: "openai", + validateUrl: "https://agentrouter.org/v1/models", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Multi-endpoint: OpenAI (/v1) + Anthropic (root — SDK appends /v1/messages). + transports: [ + { + format: "openai", + baseUrl: "https://agentrouter.org/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://agentrouter.org/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + // Seed catalog (from /api/pricing). Live discovery via modelsFetcher. + models: [ + { id: "glm-5.2", name: "GLM 5.2" }, + { id: "gpt-5.5", name: "GPT 5.5" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, + ], + passthroughModels: true, + modelsFetcher: { + url: "https://agentrouter.org/api/pricing", + type: "agentrouter", + }, +}; diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 2900352..150e3bc 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -48,6 +48,12 @@ export default { clientSecret: "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", }, models: [ + // Tiered Gemini 3.6 Flash — upstream id carries a (high/medium/low) preset + // that getModelUpstreamId resolves and merges with the caller's effort + // suffix. Port of decolua/9router commit 190020c (tier routing via preset). + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, diff --git a/open-sse/providers/registry/bynara.js b/open-sse/providers/registry/bynara.js new file mode 100644 index 0000000..8f0e7ba --- /dev/null +++ b/open-sse/providers/registry/bynara.js @@ -0,0 +1,78 @@ +// Bynara — multi-model AI router (router.bynara.id). +// +// OpenAI-compatible + Anthropic-native + Responses API gateway behind a single +// API key. Supports LLM chat, image generation, and image editing. Models +// discovered live via /v1/models at runtime. +// +// Multi-endpoint transport with cross-transport fallback: if the OpenAI +// endpoint times out or 5xxs, the engine retries via the Anthropic endpoint +// automatically (body is re-translated to Claude format). +// +// Image generation uses a separate host (api-images.bynara.id). +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "bynara", + priority: 350, + alias: "bynara", + aliases: ["by"], + uiAlias: "by", + display: { + name: "Bynara", + icon: "hub", + color: "#6366F1", + textIcon: "BY", + website: "https://router.bynara.id", + notice: { + signupUrl: "https://router.bynara.id/register?ref=884C9YJM", + apiKeyUrl: "https://router.bynara.id/register?ref=884C9YJM", + text: "Bynara is a multi-model AI router with OpenAI, Anthropic, and Responses API support. Create an API key at router.bynara.id, then paste it here. Supports LLM chat, image generation, and image editing.", + }, + }, + category: "freeTier", + hasFree: true, + authType: "apikey", + transport: { + // Default = OpenAI format (most clients use this). + baseUrl: "https://router.bynara.id/v1/chat/completions", + format: "openai", + responsesUrl: "https://router.bynara.id/v1/responses", + validateUrl: "https://router.bynara.id/v1/models", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Multi-endpoint: both OpenAI and Anthropic formats supported. The engine + // picks the endpoint matching the client sourceFormat (skip translation), + // and falls back to the alternate on timeout/5xx (cross-transport fallback). + transports: [ + { + format: "openai", + baseUrl: "https://router.bynara.id/v1/chat/completions", + responsesUrl: "https://router.bynara.id/v1/responses", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://router.bynara.id/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + // Live discovery — /v1/models exposes whatever the key has access to. + models: [], + passthroughModels: true, + modelsFetcher: { + url: "https://router.bynara.id/v1/models", + type: "openai", + }, + // Image generation via separate host. + serviceKinds: ["llm", "image"], + imageConfig: { + baseUrl: "https://api-images.bynara.id/v1/images/generations", + editUrl: "https://api-images.bynara.id/v1/images/edits", + bodyFields: ["model", "prompt", "n", "size", "response_format"], + }, +}; diff --git a/open-sse/providers/registry/commandcode.js b/open-sse/providers/registry/commandcode.js index 3b21fbb..d847a16 100644 --- a/open-sse/providers/registry/commandcode.js +++ b/open-sse/providers/registry/commandcode.js @@ -1,10 +1,21 @@ +// Command Code — multi-model AI router (commandcode.ai). +// +// Originally used a custom CLI endpoint (/alpha/generate). Now supports the +// standard provider API with both OpenAI and Anthropic native formats. +// +// Multi-endpoint transport with cross-transport fallback: if the OpenAI +// endpoint times out or 5xxs, the engine retries via the Anthropic endpoint +// automatically (body is re-translated to Claude format). +// +// Models discovered live via /provider/v1/models at runtime (47 models as of +// 2026-07, including Claude, GPT, DeepSeek, GLM, Kimi, MiniMax, Qwen, etc). +import { CLAUDE_API_HEADERS } from "../shared.js"; + export default { id: "commandcode", priority: 100, alias: "commandcode", - aliases: [ - "cmc", - ], + aliases: ["cmc"], uiAlias: "cmc", display: { name: "Command Code", @@ -13,31 +24,44 @@ export default { textIcon: "CC", website: "https://commandcode.ai", notice: { - text: "Use your CommandCode CLI API key (starts with user_...) from ~/.commandcode/auth.json or commandcode.ai/studio.", + text: "Command Code is a multi-model AI router with OpenAI and Anthropic API support. Create an API key at commandcode.ai/studio, then paste it here. Supports Claude, GPT, DeepSeek, GLM, Kimi, and more.", apiKeyUrl: "https://commandcode.ai/studio", }, }, category: "apikey", + authType: "apikey", transport: { - baseUrl: "https://api.commandcode.ai/alpha/generate", - format: "commandcode", - forceStream: true, - headers: { - "x-command-code-version": "0.25.7", - "x-cli-environment": "cli", + // Default = OpenAI format (most clients use this). + baseUrl: "https://api.commandcode.ai/provider/v1/chat/completions", + format: "openai", + validateUrl: "https://api.commandcode.ai/provider/v1/models", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", }, }, - models: [ - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" }, - { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, - { id: "zai-org/GLM-5.1", name: "GLM 5.1" }, - { id: "zai-org/GLM-5", name: "GLM 5" }, - { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" }, - { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }, - { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" }, - { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }, - { id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" }, + // Multi-endpoint: both OpenAI and Anthropic formats supported. The engine + // picks the endpoint matching the client sourceFormat (skip translation), + // and falls back to the alternate on timeout/5xx (cross-transport fallback). + transports: [ + { + format: "openai", + baseUrl: "https://api.commandcode.ai/provider/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.commandcode.ai/provider/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, ], + // Live discovery — /provider/v1/models exposes the full catalog. + models: [], + passthroughModels: true, + modelsFetcher: { + url: "https://api.commandcode.ai/provider/v1/models", + type: "openai", + }, }; diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index 95e1153..2ba0749 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -33,7 +33,10 @@ export default { transports: [ { format: "openai", - baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions", + // Platform general endpoint (works with all API keys). The /coding/paas/ + // path was GLM Coding Plan specific and 503'd for general API keys. + // Per https://docs.z.ai/api-reference/introduction: general = /api/paas/v4 + baseUrl: "https://api.z.ai/api/paas/v4/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" }, }, { diff --git a/open-sse/providers/registry/grok-web.js b/open-sse/providers/registry/grok-web.js index 0fbaa45..701c544 100644 --- a/open-sse/providers/registry/grok-web.js +++ b/open-sse/providers/registry/grok-web.js @@ -20,6 +20,12 @@ export default { baseUrl: "https://grok.com/rest/app-chat/conversations/new", format: "grok-web", authType: "cookie", + // Quota Tracker — grok.com web rate-limits endpoint. Returns + // remainingQueries / totalQueries / windowSizeSeconds per model tier. + // Authenticated via the SSO cookie (same credential used for chat). + usage: { + rateLimitsUrl: "https://grok.com/rest/rate-limits", + }, }, models: [ { id: "grok-3", name: "Grok 3" }, @@ -36,4 +42,7 @@ export default { { id: "grok-4.2", name: "Grok 4.2 (4.20 Beta)" }, ], passthroughModels: true, + features: { + usage: true, + }, }; diff --git a/open-sse/providers/registry/hcnsec.js b/open-sse/providers/registry/hcnsec.js index 89d5ee0..e854abe 100644 --- a/open-sse/providers/registry/hcnsec.js +++ b/open-sse/providers/registry/hcnsec.js @@ -1,10 +1,14 @@ // Huancheng Public API (hcnsec) — Xinjiang Huancheng Cybersecurity public LLM -// API platform. OpenAI-compatible /v1 endpoints behind a single API key. +// API platform. Supports both OpenAI and Anthropic API formats behind a single +// API key. Cross-transport fallback: if OpenAI endpoint times out, the engine +// retries the Anthropic endpoint automatically. // // Free credits available with daily check-ins. Models discovered live via // /v1/models at runtime (passthroughModels + empty seed catalog). // // Port of OmniRoute commit 437ca488 (PR #6410). +import { CLAUDE_API_HEADERS } from "../shared.js"; + export default { id: "hcnsec", priority: 330, @@ -20,7 +24,7 @@ export default { notice: { signupUrl: "https://api.hcnsec.cn/sign-up?aff=ZKgv", apiKeyUrl: "https://api.hcnsec.cn/sign-up?aff=ZKgv", - text: "Xinjiang Huancheng Cybersecurity public LLM API platform. Free credits with daily check-ins. Create an API key at api.hcnsec.cn, then paste it here. OpenAI-compatible — works with any OpenAI-format client.", + text: "Xinjiang Huancheng Cybersecurity public LLM API platform. Free credits with daily check-ins. Create an API key at api.hcnsec.cn, then paste it here. Supports both OpenAI and Anthropic API formats — works with any client.", }, }, category: "freeTier", @@ -36,9 +40,33 @@ export default { scheme: "bearer", }, }, - // Live discovery only — /v1/models exposes whatever the key has access to. - // Empty seed catalog; passthroughModels allows any model id. - models: [], + // Multi-endpoint: both OpenAI and Anthropic formats supported. The engine + // picks the endpoint matching the client sourceFormat (skip translation), + // and falls back to the alternate on timeout/5xx (cross-transport fallback). + transports: [ + { + format: "openai", + baseUrl: "https://api.hcnsec.cn/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.hcnsec.cn/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + // Seed catalog — offline fallback when live /v1/models discovery fails or + // the user hasn't connected a key yet. These are the most commonly available + // models on the hcnsec public gateway (a new-api/one-api fork that exposes + // popular open-weight models via OpenAI-compatible endpoints). The live + // /v1/models fetch (via modelsFetcher + authenticated suggested-models proxy) + // supersedes this list at runtime. + models: [ + { id: "deepseek-v3", name: "DeepSeek V3" }, + { id: "deepseek-r1", name: "DeepSeek R1" }, + { id: "qwq-32b", name: "QwQ 32B" }, + ], passthroughModels: true, modelsFetcher: { url: "https://api.hcnsec.cn/v1/models", diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index c01f57e..753df4d 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -137,6 +137,11 @@ import p134 from "./qwen-cloud-token-plan.js"; import p135 from "./alibaba.js"; import p136 from "./alibaba-cn.js"; import p137 from "./hcnsec.js"; +import p138 from "./inxorastudio.js"; +import p139 from "./inxorastudio-web.js"; +import p140 from "./bynara.js"; +import p141 from "./infron.js"; +import p142 from "./agentrouter.js"; export default [ p0, @@ -277,4 +282,9 @@ export default [ p135, p136, p137, + p138, + p139, + p140, + p141, + p142, ]; diff --git a/open-sse/providers/registry/infron.js b/open-sse/providers/registry/infron.js new file mode 100644 index 0000000..e99c1b5 --- /dev/null +++ b/open-sse/providers/registry/infron.js @@ -0,0 +1,71 @@ +// Infron AI — multi-model AI router (llm.onerouter.pro). +// +// OpenAI-compatible + Anthropic-native gateway behind a single API key. +// Hosts 457+ models from Google, OpenAI, Anthropic, DeepSeek, Moonshot, etc. +// +// Multi-endpoint transport with cross-transport fallback: if the OpenAI +// endpoint times out or 5xxs, the engine retries via the Anthropic endpoint +// automatically (body is re-translated to Claude format). +// +// Models discovered live via /v1/models at runtime. +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "infron", + priority: 355, + alias: "infron", + aliases: ["ifr"], + uiAlias: "ifr", + display: { + name: "Infron AI", + icon: "hub", + color: "#06B6D4", + textIcon: "IF", + website: "https://onerouter.pro", + notice: { + signupUrl: "https://onerouter.pro", + apiKeyUrl: "https://onerouter.pro", + text: "Infron AI is a multi-model AI router with OpenAI and Anthropic API support. 457+ models including Claude, GPT, Gemini, DeepSeek, Kimi, and more. Create an API key at onerouter.pro, then paste it here.", + }, + }, + category: "freeTier", + hasFree: true, + authType: "apikey", + transport: { + baseUrl: "https://llm.onerouter.pro/v1/chat/completions", + format: "openai", + validateUrl: "https://llm.onerouter.pro/v1/models", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + usage: { + balanceUrl: "https://api.onerouter.pro/v1/balance", + }, + }, + // Multi-endpoint: both OpenAI and Anthropic formats supported. + transports: [ + { + format: "openai", + baseUrl: "https://llm.onerouter.pro/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://llm.onerouter.pro/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + ], + models: [], + passthroughModels: true, + modelsFetcher: { + url: "https://llm.onerouter.pro/v1/models", + type: "openai", + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/inxorastudio-web.js b/open-sse/providers/registry/inxorastudio-web.js new file mode 100644 index 0000000..87d5af5 --- /dev/null +++ b/open-sse/providers/registry/inxorastudio-web.js @@ -0,0 +1,52 @@ +// InxoraStudio Labs (Web) — web dashboard chat provider. +// +// Auth: Bearer JWT token from labs.inxorastudio.com dashboard login. +// The user copies the Authorization header value (eyJ...) from DevTools. +// +// Chat flow (3-step, custom executor required — NOT yet implemented): +// 1. POST /api/conversations { model } → { id } +// 2. POST /api/conversations/{id}/messages/stream { content, model, mode, search, attachments } +// → SSE stream of { t: "chunk" | "gen" | "user_message" | "done", d: ... } +// 3. Parse: t="chunk" → content delta; t="done" → usage + finish +// +// Profile: GET /api/auth/me → { user: { email, name, plan, apiKey } } +// Models: GET /api/ai/models → { models: [...], plan } (same as API provider) +// +// NOTE: The custom executor for the 3-step chat flow is pending. Until it +// lands, this provider appears in the dashboard and can be validated/profiled, +// but chat requests will not route correctly. Use the API-key provider +// ("inxorastudio") for functional chat. +export default { + id: "inxorastudio-web", + priority: 345, + alias: "ix-web", + aliases: ["ixweb"], + uiAlias: "ix-web", + display: { + name: "InxoraStudio Labs (Web)", + icon: "science", + color: "#A78BFA", + textIcon: "IX", + website: "https://labs.inxorastudio.com", + notice: { + signupUrl: "https://labs.inxorastudio.com", + text: "InxoraStudio Labs web dashboard chat. Log in at labs.inxorastudio.com, then open DevTools → Network → copy the Authorization Bearer token (eyJ...) from any API request. Paste it here.", + }, + }, + category: "webCookie", + authType: "cookie", + authHint: "Paste your Bearer JWT token (eyJ...) from labs.inxorastudio.com DevTools", + transport: { + baseUrl: "https://labs.inxorastudio.com/api/conversations", + format: "inxorastudio-web", + authType: "cookie", + }, + // Models discovered live from /api/ai/models (same endpoint as API provider). + // The JWT token authenticates the discovery fetch. + models: [], + passthroughModels: true, + modelsFetcher: { + url: "https://labs.inxorastudio.com/api/ai/models", + type: "inxora", + }, +}; diff --git a/open-sse/providers/registry/inxorastudio.js b/open-sse/providers/registry/inxorastudio.js new file mode 100644 index 0000000..26cf7ed --- /dev/null +++ b/open-sse/providers/registry/inxorastudio.js @@ -0,0 +1,76 @@ +// InxoraStudio Labs — multi-format AI gateway (OpenAI + Anthropic native). +// +// Hosts models from multiple providers (Claude, GLM, DeepSeek, etc.) behind a +// single API key. Supports BOTH: +// - OpenAI Chat Completions: POST https://labs.inxorastudio.com/v1/chat/completions +// - Anthropic Messages: POST https://labs.inxorastudio.com/v1/messages +// +// Model ids use the `ixlabs/` prefix (e.g. "ixlabs/claude-haiku-4.5"). The +// full model catalog is discovered live via /api/ai/models so new models appear +// automatically without a registry update. +// +// Multi-endpoint transport: the `transports` array lets the engine pick the +// right endpoint based on the client's sourceFormat. A Claude Code client +// hits /v1/messages directly (skip translation); an OpenAI client hits +// /v1/chat/completions. +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "inxorastudio", + priority: 340, + alias: "inxora", + aliases: ["ix"], + uiAlias: "ix", + display: { + name: "InxoraStudio Labs", + icon: "science", + color: "#8B5CF6", + textIcon: "IX", + website: "https://labs.inxorastudio.com", + notice: { + signupUrl: "https://labs.inxorastudio.com", + apiKeyUrl: "https://labs.inxorastudio.com/dashboard", + text: "InxoraStudio Labs is a multi-model AI gateway hosting Claude, GLM, DeepSeek, and more behind a single API key. Create an API key at labs.inxorastudio.com/dashboard, then paste it here. Supports both OpenAI and Anthropic API formats.", + }, + }, + category: "freeTier", + hasFree: true, + authType: "apikey", + transport: { + // Default = OpenAI format (most clients use this). + baseUrl: "https://labs.inxorastudio.com/v1/chat/completions", + format: "openai", + validateUrl: "https://labs.inxorastudio.com/api/ai/models", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Multi-endpoint: pick the transport matching the client sourceFormat. + // Claude clients → /v1/messages (Anthropic-native, skip translation). + // OpenAI clients → /v1/chat/completions (native). + transports: [ + { + format: "openai", + baseUrl: "https://labs.inxorastudio.com/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://labs.inxorastudio.com/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + // Live discovery only — /api/ai/models returns the full catalog with + // accessibility flags. Empty seed catalog; passthroughModels allows any + // model id. The modelsFetcher filter ("inxora") extracts only accessible + // chat models. + models: [], + passthroughModels: true, + modelsFetcher: { + url: "https://labs.inxorastudio.com/api/ai/models", + type: "inxora", + }, +}; diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js new file mode 100644 index 0000000..56cc828 --- /dev/null +++ b/open-sse/providers/thinkingLevels.js @@ -0,0 +1,69 @@ +// Server-side thinking-level advertisement for the dashboard picker. +// +// Returns the valid thinking levels for a model, or null when the model has no +// reasoning capability. This is the single source of truth that the dashboard +// uses to decide which levels to show in the per-model thinking picker +// (replacing the previous client-side caps.reasoning gating which was too +// broad for Kiro — it advertised native levels for legacy/unsupported models). +// +// Port of decolua/9router commit 2446f32 (normalize dashboard thinking +// intensity models). Mirrors the KIRO_NATIVE_EFFORT_PREFIXES logic but lives +// here next to capabilities.js so the dashboard can call it without importing +// Kiro internals. + +import { getCapabilitiesForModel } from "./capabilities.js"; +import { matchPattern } from "./pricing.js"; +import { resolveKiroEffortPath } from "../config/kiroConstants.js"; + +// Shared level sets (deduped) — verified against provider docs + wire in +// thinkingUnified.applyFormat. +const L = { + // Claude 5 / GPT-5.6 on Kiro accept the full discrete-level range. + KIRO_NATIVE: ["low", "medium", "high", "xhigh", "max"], + // Most OpenAI-style providers (OpenAI, DeepSeek, GLM, etc.). + EFFORT: ["minimal", "low", "medium", "high"], + // Models that explicitly support "max" (kimi-k3, gpt-5.6-sol on Kiro). + EFFORT_MAX: ["minimal", "low", "medium", "high", "max"], +}; + +// Pattern → levels mapping. Order matters: first match wins (specific → +// generic). Patterns use the same glob syntax as capabilities.js. +const PATTERN_THINKING = [ + // Kiro GPT-5.6 family supports xhigh + max. + { pattern: "gpt-5.6-*", levels: L.KIRO_NATIVE }, + // Kiro Claude 5 family. + { pattern: "claude-opus-5*", levels: L.KIRO_NATIVE }, + { pattern: "claude-sonnet-5*", levels: L.KIRO_NATIVE }, + { pattern: "claude-haiku-5*", levels: L.KIRO_NATIVE }, +]; + +/** + * Get the valid thinking levels for a model, or null when the model has no + * reasoning capability. + * + * Kiro special-case: legacy Claude (4.x) and non-Claude/GPT families (GLM, + * DeepSeek, Qwen, MiniMax) return null — they reason only via the + * `` system tag and reject native effort fields. The dashboard + * hides the picker entirely for these models so users can't generate an invalid + * `(level)` suffix. + * + * @param {string} provider - provider id (e.g. "kiro", "openai") + * @param {string} model - model id (without provider prefix) + * @returns {string[]|null} + */ +export function getThinkingLevels(provider, model) { + // Kiro gate FIRST: only Claude 5 / GPT-5.6 families advertise native levels. + // resolveKiroEffortPath returns null for everything else → hide the picker. + if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; + + const caps = getCapabilitiesForModel(provider, model); + if (!caps.reasoning) return null; + + // Pattern match for Kiro native families. + const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); + if (hit) return hit.levels; + + // Generic fallback for non-Kiro reasoning models. + if (caps.thinkingMaxEffort) return L.EFFORT_MAX; + return L.EFFORT; +} diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index f9a24e1..152211f 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -81,9 +81,12 @@ startCacheCleanup(); * * @param {string} connectionId - The connection identifier for cache keying * @param {string} accessToken - Valid OAuth access token + * @param {string} [provider="gemini-cli"] - Provider id; selects the right + * Cloud Code Assist endpoints (gemini-cli uses cloudcode-pa, antigravity + * uses daily-cloudcode-pa). See CLOUD_CODE_API in appConstants.js. * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +105,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,8 +158,9 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), @@ -185,7 +189,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints); } /** @@ -194,9 +198,10 @@ async function fetchProjectId(accessToken, signal) { * @param {string} accessToken * @param {string} tierID * @param {AbortSignal} externalSignal – propagated from the connection's AbortController + * @param {{loadCodeAssist: string, onboardUser: string}} endpoints - Cloud Code endpoints for this provider * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -213,7 +218,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1b02cd8..2b888c3 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -146,6 +146,18 @@ export function resolveTransport(provider, sourceFormat) { return transports.find(t => t.format === sourceFormat) || null; } +// Resolve the ALTERNATE transport (the one NOT matching currentFormat). +// Used for cross-transport fallback: when the primary endpoint (e.g. OpenAI) +// times out or 5xxs, the engine retries via the alternate endpoint (e.g. +// Claude) — re-translating the body to the alternate format first. +// Returns null for single-endpoint providers (no fallback possible). +export function resolveAlternateTransport(provider, currentFormat) { + const config = PROVIDERS[provider]; + const transports = config?.transports; + if (!Array.isArray(transports) || transports.length < 2) return null; + return transports.find(t => t.format !== currentFormat) || null; +} + // Check if last message is from user export function isLastMessageFromUser(body) { const messages = body.messages || body.contents; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 21cdded..01d9d6b 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -22,6 +22,8 @@ import { import { getXaiUsage } from "./usage/xai.js"; import { getTokenRouterUsage } from "./usage/tokenrouter.js"; import { getClineUsage } from "./usage/cline.js"; +import { getGrokWebUsage } from "./usage/grok-web.js"; +import { getInfronUsage } from "./usage/infron.js"; /** * Get usage data for a provider connection @@ -52,6 +54,8 @@ const USAGE_HANDLERS = { // a single handler serves both providers. cline: (c) => getClineUsage({ accessToken: c.accessToken, apiKey: c.apiKey }, c.proxyOptions), clinepass: (c) => getClineUsage({ accessToken: c.accessToken, apiKey: c.apiKey }, c.proxyOptions), + "grok-web": (c) => getGrokWebUsage({ apiKey: c.apiKey }, c.proxyOptions), + infron: (c) => getInfronUsage({ apiKey: c.apiKey }, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/grok-web.js b/open-sse/services/usage/grok-web.js new file mode 100644 index 0000000..85639a0 --- /dev/null +++ b/open-sse/services/usage/grok-web.js @@ -0,0 +1,99 @@ +// Grok Web (Subscription) usage handler for Quota Tracker. +// +// Uses the grok.com web rate-limits endpoint, authenticated via the SSO +// cookie stored as the provider's apiKey. Returns per-tier usage: +// POST https://grok.com/rest/rate-limits +// Body: { "modelName": "fast" | "thinking" | "heavy" } +// Cookie: sso= +// +// Response shape: +// { windowSizeSeconds, remainingQueries, totalQueries, +// lowEffortRateLimits, highEffortRateLimits } +// +// We poll 3 tiers (fast/thinking/heavy) in parallel and map each to a quota +// entry showing remaining/total queries in the 2-hour window. The reset time +// is computed from windowSizeSeconds (rolling window → reset = now + window). + +import { U } from "./shared.js"; +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; + +const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; + +// Model tiers to poll. Each maps to a quota row in the tracker card. The +// endpoint returns null for tiers the account doesn't have access to. +const TIERS = [ + { model: "fast", label: "Fast" }, + { model: "thinking", label: "Thinking" }, + { model: "heavy", label: "Heavy" }, +]; + +export async function getGrokWebUsage(credentials, proxyOptions = null) { + // The SSO cookie is stored as apiKey. Strip "sso=" prefix if present. + let token = credentials?.apiKey; + if (!token) return null; + if (token.startsWith("sso=")) token = token.slice(4); + + const cfg = U("grok-web"); + const url = cfg.rateLimitsUrl || "https://grok.com/rest/rate-limits"; + + const headers = { + "Content-Type": "application/json", + Accept: "*/*", + Cookie: `sso=${token}`, + Origin: "https://grok.com", + Referer: "https://grok.com/?_s=usage", + "User-Agent": USER_AGENT, + }; + + // Poll all tiers in parallel. + const results = await Promise.allSettled( + TIERS.map(async (tier) => { + const res = await proxyAwareFetch( + url, + { + method: "POST", + headers, + body: JSON.stringify({ modelName: tier.model }), + signal: AbortSignal.timeout(10000), + }, + proxyOptions, + ); + if (!res?.ok) return null; + const data = await res.json().catch(() => null); + if (!data || data.remainingQueries === undefined) return null; + return { tier, data }; + }), + ); + + const quotas = {}; + for (const r of results) { + if (r.status !== "fulfilled" || !r.value) continue; + const { tier, data } = r.value; + const remaining = data.remainingQueries || 0; + const total = data.totalQueries || 0; + const used = total - remaining; + const windowSeconds = data.windowSizeSeconds || 7200; + + quotas[tier.label] = { + used, + total, + remaining, + remainingPercentage: total > 0 ? Math.round((remaining / total) * 100) : null, + // Rolling window: reset approximation is now + windowSizeSeconds. + resetAt: new Date(Date.now() + windowSeconds * 1000).toISOString(), + }; + } + + if (Object.keys(quotas).length === 0) { + return { + quotas: {}, + plan: null, + message: "Grok rate-limits returned no data — SSO cookie may be expired.", + }; + } + + return { + quotas, + plan: null, + }; +} diff --git a/open-sse/services/usage/infron.js b/open-sse/services/usage/infron.js new file mode 100644 index 0000000..79356ca --- /dev/null +++ b/open-sse/services/usage/infron.js @@ -0,0 +1,68 @@ +// Infron AI usage handler for Quota Tracker. +// +// Uses the balance endpoint with the same API key used for chat: +// GET https://api.onerouter.pro/v1/balance +// Authorization: Bearer +// +// Response: +// { account_name: "support@onerouter.pro", credit_balance: 877.88 } +// +// The credit_balance is a dollar-denominated prepaid balance. We display it +// as a single quota row with no cap (it's a wallet, not a rate-limited plan). + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; + +const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; +const BALANCE_URL = "https://api.onerouter.pro/v1/balance"; + +export async function getInfronUsage(credentials, proxyOptions = null) { + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) return null; + + let res; + try { + res = await proxyAwareFetch( + BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "User-Agent": USER_AGENT, + }, + }, + proxyOptions, + ); + } catch { + return { quotas: {}, plan: null, message: "Failed to reach Infron balance API." }; + } + + if (!res?.ok) { + return { + quotas: {}, + plan: null, + message: `Infron balance request failed (${res?.status || "no response"})`, + }; + } + + const data = await res.json().catch(() => null); + if (!data || data.credit_balance === undefined) { + return { quotas: {}, plan: null, message: "Infron returned no balance data." }; + } + + const balance = Number(data.credit_balance) || 0; + const accountName = data.account_name || ""; + + return { + quotas: { + Credits: { + used: 0, // Prepaid wallet — no "used" concept without a starting balance + total: balance, // Current remaining balance IS the total + remaining: balance, + remainingPercentage: null, // null → bar shows as unlimited/wallet style + resetAt: null, + }, + }, + plan: accountName || null, // Show account email as plan label + }; +} diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js deleted file mode 100644 index ac84db2..0000000 --- a/open-sse/transformer/responsesTransformer.js +++ /dev/null @@ -1,439 +0,0 @@ -/** - * Responses API Transformer - * Converts OpenAI Chat Completions SSE to Codex Responses API SSE format - * Can be used in both Next.js and Cloudflare Workers - */ - -import fs from "fs"; -import path from "path"; - -// Create log directory for responses (Node.js only) -export function createResponsesLogger(model, logsDir = null) { - // Skip logging in worker environment (no fs) - if (typeof fs.mkdirSync !== "function") { - return null; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15); - const uniqueId = Math.random().toString(36).slice(2, 8); - const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : "."); - const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`); - - try { - fs.mkdirSync(logDir, { recursive: true }); - } catch { - return null; - } - - let inputEvents = []; - let outputEvents = []; - - return { - logInput: (event) => { - inputEvents.push(event); - }, - logOutput: (event) => { - outputEvents.push(event); - }, - flush: () => { - try { - fs.writeFileSync(path.join(logDir, "1_input_stream.txt"), inputEvents.join("\n")); - fs.writeFileSync(path.join(logDir, "2_output_stream.txt"), outputEvents.join("\n")); - } catch (e) { - console.log("[RESPONSES] Failed to write logs:", e.message); - } - } - }; -} - -/** - * Create TransformStream that converts Chat Completions SSE to Responses API SSE - * @param {Object} logger - Optional logger instance - * @returns {TransformStream} - */ -export function createResponsesApiTransformStream(logger = null) { - const state = { - seq: 0, - responseId: `resp_${Date.now()}`, - created: Math.floor(Date.now() / 1000), - started: false, - msgTextBuf: {}, - msgItemAdded: {}, - msgContentAdded: {}, - msgItemDone: {}, - reasoningId: "", - reasoningIndex: -1, - reasoningBuf: "", - reasoningPartAdded: false, - reasoningDone: false, - inThinking: false, - funcArgsBuf: {}, - funcNames: {}, - funcCallIds: {}, - funcArgsDone: {}, - funcItemDone: {}, - buffer: "", - completedSent: false - }; - - const encoder = new TextEncoder(); - const nextSeq = () => ++state.seq; - - const emit = (controller, eventType, data) => { - data.sequence_number = nextSeq(); - const output = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; - logger?.logOutput(output.trim()); - controller.enqueue(encoder.encode(output)); - }; - - // Helper to start reasoning - const startReasoning = (controller, idx) => { - if (!state.reasoningId) { - state.reasoningId = `rs_${state.responseId}_${idx}`; - state.reasoningIndex = idx; - - emit(controller, "response.output_item.added", { - type: "response.output_item.added", - output_index: idx, - item: { - id: state.reasoningId, - type: "reasoning", - summary: [] - } - }); - - emit(controller, "response.reasoning_summary_part.added", { - type: "response.reasoning_summary_part.added", - item_id: state.reasoningId, - output_index: idx, - summary_index: 0, - part: { type: "summary_text", text: "" } - }); - state.reasoningPartAdded = true; - } - }; - - const emitReasoningDelta = (controller, text) => { - if (!text) return; - state.reasoningBuf += text; - emit(controller, "response.reasoning_summary_text.delta", { - type: "response.reasoning_summary_text.delta", - item_id: state.reasoningId, - output_index: state.reasoningIndex, - summary_index: 0, - delta: text - }); - }; - - const closeReasoning = (controller) => { - if (state.reasoningId && !state.reasoningDone) { - state.reasoningDone = true; - - emit(controller, "response.reasoning_summary_text.done", { - type: "response.reasoning_summary_text.done", - item_id: state.reasoningId, - output_index: state.reasoningIndex, - summary_index: 0, - text: state.reasoningBuf - }); - - emit(controller, "response.reasoning_summary_part.done", { - type: "response.reasoning_summary_part.done", - item_id: state.reasoningId, - output_index: state.reasoningIndex, - summary_index: 0, - part: { type: "summary_text", text: state.reasoningBuf } - }); - - emit(controller, "response.output_item.done", { - type: "response.output_item.done", - output_index: state.reasoningIndex, - item: { - id: state.reasoningId, - type: "reasoning", - summary: [{ type: "summary_text", text: state.reasoningBuf }] - } - }); - } - }; - - const closeMessage = (controller, idx) => { - if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) { - state.msgItemDone[idx] = true; - const fullText = state.msgTextBuf[idx] || ""; - const msgId = `msg_${state.responseId}_${idx}`; - - emit(controller, "response.output_text.done", { - type: "response.output_text.done", - item_id: msgId, - output_index: parseInt(idx), - content_index: 0, - text: fullText, - logprobs: [] - }); - - emit(controller, "response.content_part.done", { - type: "response.content_part.done", - item_id: msgId, - output_index: parseInt(idx), - content_index: 0, - part: { type: "output_text", annotations: [], logprobs: [], text: fullText } - }); - - emit(controller, "response.output_item.done", { - type: "response.output_item.done", - output_index: parseInt(idx), - item: { - id: msgId, - type: "message", - content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }], - role: "assistant" - } - }); - } - }; - - const closeToolCall = (controller, idx) => { - const callId = state.funcCallIds[idx]; - if (callId && !state.funcItemDone[idx]) { - const args = state.funcArgsBuf[idx] || "{}"; - - emit(controller, "response.function_call_arguments.done", { - type: "response.function_call_arguments.done", - item_id: `fc_${callId}`, - output_index: parseInt(idx), - arguments: args - }); - - emit(controller, "response.output_item.done", { - type: "response.output_item.done", - output_index: parseInt(idx), - item: { - id: `fc_${callId}`, - type: "function_call", - arguments: args, - call_id: callId, - name: state.funcNames[idx] || "" - } - }); - - state.funcItemDone[idx] = true; - state.funcArgsDone[idx] = true; - } - }; - - const sendCompleted = (controller) => { - if (!state.completedSent) { - state.completedSent = true; - emit(controller, "response.completed", { - type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null - } - }); - } - }; - - return new TransformStream({ - transform(chunk, controller) { - const text = new TextDecoder().decode(chunk); - logger?.logInput(text.trim()); - state.buffer += text; - - const messages = state.buffer.split("\n\n"); - state.buffer = messages.pop() || ""; - - for (const msg of messages) { - if (!msg.trim()) continue; - - const dataMatch = msg.match(/^data:\s*(.+)$/m); - if (!dataMatch) continue; - - const dataStr = dataMatch[1].trim(); - if (dataStr === "[DONE]") continue; - - let parsed; - try { - parsed = JSON.parse(dataStr); - } catch { - continue; - } - - if (!parsed.choices?.length) continue; - - const choice = parsed.choices[0]; - const idx = choice.index || 0; - const delta = choice.delta || {}; - - // Emit initial events - if (!state.started) { - state.started = true; - state.responseId = parsed.id ? `resp_${parsed.id}` : state.responseId; - - emit(controller, "response.created", { - type: "response.created", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "in_progress", - background: false, - error: null, - output: [] - } - }); - - emit(controller, "response.in_progress", { - type: "response.in_progress", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "in_progress" - } - }); - } - - // Handle reasoning_content (OpenAI native format) - if (delta.reasoning_content) { - startReasoning(controller, idx); - emitReasoningDelta(controller, delta.reasoning_content); - } - - // Handle text content (may contain tags) - if (delta.content) { - let content = delta.content; - - if (content.includes("")) { - state.inThinking = true; - content = content.replace("", ""); - startReasoning(controller, idx); - } - - if (content.includes("")) { - const parts = content.split(""); - const thinkPart = parts[0]; - const textPart = parts.slice(1).join(""); - - if (thinkPart) emitReasoningDelta(controller, thinkPart); - closeReasoning(controller); - state.inThinking = false; - content = textPart; - } - - if (state.inThinking && content) { - emitReasoningDelta(controller, content); - continue; - } - - // Regular text content - if (content) { - if (!state.msgItemAdded[idx]) { - state.msgItemAdded[idx] = true; - const msgId = `msg_${state.responseId}_${idx}`; - - emit(controller, "response.output_item.added", { - type: "response.output_item.added", - output_index: idx, - item: { id: msgId, type: "message", content: [], role: "assistant" } - }); - } - - if (!state.msgContentAdded[idx]) { - state.msgContentAdded[idx] = true; - - emit(controller, "response.content_part.added", { - type: "response.content_part.added", - item_id: `msg_${state.responseId}_${idx}`, - output_index: idx, - content_index: 0, - part: { type: "output_text", annotations: [], logprobs: [], text: "" } - }); - } - - emit(controller, "response.output_text.delta", { - type: "response.output_text.delta", - item_id: `msg_${state.responseId}_${idx}`, - output_index: idx, - content_index: 0, - delta: content, - logprobs: [] - }); - - if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = ""; - state.msgTextBuf[idx] += content; - } - } - - // Handle tool_calls - if (delta.tool_calls) { - closeMessage(controller, idx); - - for (const tc of delta.tool_calls) { - const tcIdx = tc.index ?? 0; - const newCallId = tc.id; - const funcName = tc.function?.name; - - if (funcName) state.funcNames[tcIdx] = funcName; - - if (!state.funcCallIds[tcIdx] && newCallId) { - state.funcCallIds[tcIdx] = newCallId; - - emit(controller, "response.output_item.added", { - type: "response.output_item.added", - output_index: tcIdx, - item: { - id: `fc_${newCallId}`, - type: "function_call", - arguments: "", - call_id: newCallId, - name: state.funcNames[tcIdx] || "" - } - }); - } - - if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = ""; - - if (tc.function?.arguments) { - const refCallId = state.funcCallIds[tcIdx] || newCallId; - if (refCallId) { - emit(controller, "response.function_call_arguments.delta", { - type: "response.function_call_arguments.delta", - item_id: `fc_${refCallId}`, - output_index: tcIdx, - delta: tc.function.arguments - }); - } - state.funcArgsBuf[tcIdx] += tc.function.arguments; - } - } - } - - // Handle finish_reason - if (choice.finish_reason) { - for (const i in state.msgItemAdded) closeMessage(controller, i); - closeReasoning(controller); - for (const i in state.funcCallIds) closeToolCall(controller, i); - sendCompleted(controller); - } - } - }, - - flush(controller) { - for (const i in state.msgItemAdded) closeMessage(controller, i); - closeReasoning(controller); - for (const i in state.funcCallIds) closeToolCall(controller, i); - sendCompleted(controller); - - logger?.logOutput("data: [DONE]"); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - logger?.flush(); - } - }); -} - diff --git a/open-sse/transformer/streamToJsonConverter.js b/open-sse/transformer/streamToJsonConverter.js index c08acb2..021b103 100644 --- a/open-sse/transformer/streamToJsonConverter.js +++ b/open-sse/transformer/streamToJsonConverter.js @@ -1,20 +1,36 @@ /** * Stream-to-JSON Converter - * Converts Responses API SSE stream to single JSON response - * Used when client requests non-streaming but provider forces streaming (e.g., Codex) + * Converts Responses API SSE stream to single JSON response. + * Used when client requests non-streaming but provider forces streaming (e.g. Codex). + * + * Uses the shared ResponsesAccumulator (createResponsesAccumulator + + * finalizeResponsesAccumulator) for item correlation + terminal output + * reconstruction — the same accumulator the streaming translator uses, + * ensuring consistent output/usage/error handling between both paths. */ +import { + createResponsesAccumulator, + finalizeResponsesAccumulator, +} from "../translator/concerns/responsesAccumulator.js"; + +const EMPTY_RESPONSE = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + +function streamFailure(code, message) { + return { type: "stream_error", code, message }; +} + /** - * Process a single SSE message and update state accordingly. + * Process a single SSE message through the shared accumulator. */ -function processSSEMessage(msg, state) { +function processSSEMessage(msg, accumulator) { if (!msg.trim()) return; const eventMatch = msg.match(/^event:\s*(.+)$/m); const dataMatch = msg.match(/^data:\s*(.+)$/m); - if (!eventMatch || !dataMatch) return; + if (!dataMatch) return; - const eventType = eventMatch[1].trim(); + const eventType = eventMatch?.[1]?.trim(); const dataStr = dataMatch[1].trim(); if (dataStr === "[DONE]") return; @@ -22,46 +38,28 @@ function processSSEMessage(msg, state) { try { parsed = JSON.parse(dataStr); } catch { return; } - if (eventType === "response.created") { - state.responseId = parsed.response?.id || state.responseId; - state.created = parsed.response?.created_at || state.created; - } else if (eventType === "response.output_item.done") { - state.items.set(parsed.output_index ?? 0, parsed.item); - } else if (eventType === "response.completed" || eventType === "response.done") { - state.status = "completed"; - if (parsed.response?.usage) { - state.usage.input_tokens = parsed.response.usage.input_tokens || 0; - state.usage.output_tokens = parsed.response.usage.output_tokens || 0; - state.usage.total_tokens = parsed.response.usage.total_tokens || 0; - } - } else if (eventType === "response.failed") { - state.status = "failed"; - } + accumulator.ingest(eventType || parsed.type, parsed); } -const EMPTY_RESPONSE = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; - /** - * Convert Responses API SSE stream to single JSON response + * Convert Responses API SSE stream to single JSON response. * @param {ReadableStream} stream - SSE stream from provider * @returns {Promise} Final JSON response in Responses API format */ export async function convertResponsesStreamToJson(stream) { + const accumulator = createResponsesAccumulator(); + if (!stream || typeof stream.getReader !== "function") { - return { id: `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "failed", output: [], usage: { ...EMPTY_RESPONSE } }; + const terminal = finalizeResponsesAccumulator(accumulator, { + error: streamFailure("invalid_stream", "response stream is unavailable"), + }); + return { ...terminal.response, usage: { ...EMPTY_RESPONSE } }; } const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; - - const state = { - responseId: "", - created: Math.floor(Date.now() / 1000), - status: "in_progress", - usage: { ...EMPTY_RESPONSE }, - items: new Map() - }; + let readError = null; try { while (true) { @@ -73,31 +71,38 @@ export async function convertResponsesStreamToJson(stream) { buffer = messages.pop() || ""; for (const msg of messages) { - processSSEMessage(msg, state); + processSSEMessage(msg, accumulator); } } // Flush remaining buffer (last event may not end with \n\n) if (buffer.trim()) { - processSSEMessage(buffer, state); + processSSEMessage(buffer, accumulator); } + } catch (error) { + readError = error; } finally { reader.releaseLock(); } - // Build output array from accumulated items (ordered by index) - const output = []; - const maxIndex = state.items.size > 0 ? Math.max(...state.items.keys()) : -1; - for (let i = 0; i <= maxIndex; i++) { - output.push(state.items.get(i) || { type: "message", content: [], role: "assistant" }); + // Finalize: if the stream threw or closed without a terminal event, mark + // the accumulator as failed with the appropriate error. This preserves + // partial output + exactly-once failure semantics. + if (readError || !accumulator.status) { + finalizeResponsesAccumulator(accumulator, { + error: readError + ? streamFailure("stream_read_error", readError.message || "stream read failed") + : streamFailure("stream_disconnected", "stream closed before response.completed"), + status: "failed", + }); } - return { - id: state.responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - object: "response", - created_at: state.created, - status: state.status || "completed", - output, - usage: state.usage - }; + const { response } = finalizeResponsesAccumulator(accumulator); + + // Usage: from response.completed if captured, else zeros. + const usage = accumulator.usage + ? { input_tokens: accumulator.usage.input_tokens, output_tokens: accumulator.usage.output_tokens, total_tokens: accumulator.usage.total_tokens } + : { ...EMPTY_RESPONSE }; + + return { ...response, usage }; } diff --git a/open-sse/translator/concerns/responsesAccumulator.js b/open-sse/translator/concerns/responsesAccumulator.js new file mode 100644 index 0000000..cd4a85c --- /dev/null +++ b/open-sse/translator/concerns/responsesAccumulator.js @@ -0,0 +1,552 @@ +/** + * Shared Responses API Accumulator. + * + * Consumed by BOTH the streaming translator + * (openaiResponsesToOpenAIResponse) and the forced non-stream converter + * (convertResponsesStreamToJson). Previously these two paths assembled + * Responses state independently — the streaming translator tracked only one + * current tool call (a scalar), so parallel/interleaved tool calls could + * attach argument fragments to the wrong call. This class correlates + * fragmented and interleaved tool calls by output index, item id, and call id, + * and reconstructs complete terminal output + usage with exactly-once + * semantics for completed/failed/incomplete/cancelled/abort/EOF. + * + * Correlation strategy: + * - Primary key: output_index (present on all response.output_item.* and + * delta events). Falls back to item_id when output_index is absent. + * - function_call items carry call_id; delta events carry item_id which + * resolves to the call via the item map. + * - Tool call items stored in Map. + * - Message items stored in Map. + * + * Terminal status: "completed" | "failed" | "incomplete" | "cancelled" | null. + */ + +const FUNCTION_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); + +/** + * @typedef {Object} ToolEntry + * @property {string} itemId - Responses item id (e.g. "fc_abc") + * @property {string} callId - Upstream call_id + * @property {string} name - Function/tool name + * @property {string} argsBuffer - Accumulated argument fragments + * @property {boolean} done - response.output_item.done received + * @property {boolean} emitted - Already emitted to client (exactly-once) + */ + +/** + * @typedef {Object} MessageEntry + * @property {string} itemId + * @property {string} textBuffer + * @property {boolean} done + * @property {object} item - Raw item snapshot (for output reconstruction) + */ + +export class ResponsesAccumulator { + constructor() { + /** @type {Map} keyed by output_index (number) or item_id (string) */ + this._tools = new Map(); + /** @type {Map} keyed by output_index or item_id */ + this._messages = new Map(); + /** Ordered list of output keys (output_index values) in arrival order */ + this._order = []; + /** itemId → output_index (for delta correlation when only item_id is present) */ + this._itemIdToIndex = new Map(); + /** Alias maps for alias-safe tool reconstruction (9router approach) */ + this._toolsByItemId = new Map(); + this._toolsByCallId = new Map(); + /** itemId → tool emit tracking (exactly-once) */ + this._emitted = new Set(); + /** Terminal status */ + this._status = null; + /** Usage object from response.completed */ + this._usage = null; + /** Response id from response.created */ + this._responseId = null; + /** Created timestamp */ + this._created = null; + /** Whether any tool call was registered */ + this._hasTools = false; + /** Error object from failed/error events */ + this._error = null; + /** Model name (passed via factory for output reconstruction) */ + this._model = null; + /** Incomplete details (for LENGTH finish_reason mapping) */ + this._incompleteDetails = null; + /** Whether the accumulator has been finalized (exactly-once) */ + this._finalized = false; + } + + /** + * Feed any parsed Responses SSE event. Mutates internal state. + * @param {string} eventType - e.g. "response.output_item.added" + * @param {object} data - parsed JSON payload + */ + ingest(eventType, data) { + if (!eventType || !data) return; + + switch (eventType) { + case "response.created": { + this._responseId = data.response?.id || this._responseId; + this._created = data.response?.created_at || this._created; + break; + } + + case "response.output_item.added": { + const item = data.item; + if (!item) break; + const key = data.output_index ?? item.id ?? this._order.length; + this._registerKey(key); + this._itemIdToIndex.set(item.id, key); + + if (FUNCTION_CALL_TYPES.has(item.type)) { + this._hasTools = true; + // Alias-safe: check if this item was already registered under a + // different alias (item_id or call_id) — if so, remap rather than + // create a duplicate. This handles providers that send output_item. + // added with one id and deltas with another. + const existingByItemId = item.id ? this._toolsByItemId.get(item.id) : null; + const existingByCallId = item.call_id ? this._toolsByCallId.get(item.call_id) : null; + const existing = existingByItemId || existingByCallId; + + if (existing) { + // Remap: update this key to point to the existing tool entry. + this._tools.set(key, existing); + if (item.id) this._toolsByItemId.set(item.id, existing); + if (item.call_id) this._toolsByCallId.set(item.call_id, existing); + // Fill in any fields we didn't have yet. + if (!existing.name && item.name) existing.name = item.name; + if (!existing.callId && item.call_id) existing.callId = item.call_id; + if (!existing.itemId && item.id) existing.itemId = item.id; + } else if (!this._tools.has(key)) { + const entry = { + itemId: item.id || "", + callId: item.call_id || "", + name: item.name || "", + argsBuffer: "", + done: false, + emitted: false, + }; + this._tools.set(key, entry); + if (item.id) this._toolsByItemId.set(item.id, entry); + if (item.call_id) this._toolsByCallId.set(item.call_id, entry); + } + } else if (item.type === "message") { + if (!this._messages.has(key)) { + this._messages.set(key, { + itemId: item.id || "", + textBuffer: "", + done: false, + item: { ...item, content: [] }, + }); + } + } + break; + } + + case "response.output_text.delta": { + // Append to the last-known message (or a virtual message at the end). + const delta = data.delta || ""; + if (delta) { + const key = data.output_index ?? data.item_id ?? this._lastMessageKey(); + this._registerKey(key); + let msg = this._messages.get(key); + if (!msg) { + msg = { itemId: data.item_id || "", textBuffer: "", done: false, item: { type: "message", content: [], role: "assistant" } }; + this._messages.set(key, msg); + } + msg.textBuffer += delta; + } + break; + } + + case "response.function_call_arguments.delta": + case "response.custom_tool_call_input.delta": { + const delta = data.delta || ""; + if (!delta) break; + const key = this._resolveToolKey(data); + const tool = this._tools.get(key); + if (tool) { + tool.argsBuffer += delta; + } + break; + } + + case "response.output_item.done": { + const item = data.item; + if (!item) break; + const key = data.output_index ?? this._itemIdToIndex.get(item.id) ?? item.id; + if (FUNCTION_CALL_TYPES.has(item.type)) { + // Alias-safe lookup: try key, then item_id, then call_id. + const tool = this._tools.get(key) + || (item.id ? this._toolsByItemId.get(item.id) : null) + || (item.call_id ? this._toolsByCallId.get(item.call_id) : null); + if (tool) { + tool.done = true; + // preferComplete: if output_item.done carries the full arguments, + // use whichever is more complete (the snapshot or the accumulated + // buffer). This guards against corrupt fragmentation where deltas + // and snapshot disagree — we pick the one that's a valid prefix + // superset rather than blindly preferring one source. + if (item.arguments) { + const snapshot = typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments); + tool.argsBuffer = preferComplete(tool.argsBuffer, snapshot); + } + if (!tool.name && item.name) tool.name = item.name; + if (!tool.callId && item.call_id) tool.callId = item.call_id; + if (!tool.itemId && item.id) tool.itemId = item.id; + // Register alias maps for this item in case deltas come later. + if (item.id) this._toolsByItemId.set(item.id, tool); + if (item.call_id) this._toolsByCallId.set(item.call_id, tool); + } + } else if (item.type === "message") { + const msg = this._messages.get(key); + if (msg) { + msg.done = true; + msg.item = item; + } + } + break; + } + + case "response.completed": + case "response.done": { + this._status = "completed"; + const u = data.response?.usage; + if (u && typeof u === "object") { + const inputTokens = u.input_tokens || u.prompt_tokens || 0; + const outputTokens = u.output_tokens || u.completion_tokens || 0; + const cachedTokens = u.input_tokens_details?.cached_tokens || u.cache_read_input_tokens || 0; + this._usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: u.total_tokens || inputTokens + outputTokens, + cached_tokens: cachedTokens, + }; + } + // If response.completed carries a full output array, ingest it (covers + // providers that send only the terminal event with no deltas). + const output = data.response?.output; + if (Array.isArray(output)) { + for (let i = 0; i < output.length; i++) { + this._ingestFullItem(i, output[i]); + } + } + break; + } + + case "response.failed": + case "error": { + this._status = "failed"; + this._error = data.error || data.response?.error || null; + break; + } + + case "response.incomplete": { + this._status = "incomplete"; + this._incompleteDetails = data.response?.incomplete_details || null; + // Capture usage from incomplete events too (some providers send it). + const iu = data.response?.usage; + if (iu && !this._usage) { + const inputTokens = iu.input_tokens || iu.prompt_tokens || 0; + const outputTokens = iu.output_tokens || iu.completion_tokens || 0; + this._usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: iu.total_tokens || inputTokens + outputTokens, + cached_tokens: iu.input_tokens_details?.cached_tokens || iu.cache_read_input_tokens || 0, + }; + } + break; + } + + case "response.cancelled": { + this._status = "cancelled"; + break; + } + + default: + // Ignore unrecognized events (reasoning_summary_text.delta, etc.) + break; + } + } + + /** + * Ingest a complete item from a response.output array (terminal-only providers). + */ + _ingestFullItem(index, item) { + if (!item || typeof item !== "object") return; + this._registerKey(index); + this._itemIdToIndex.set(item.id, index); + if (FUNCTION_CALL_TYPES.has(item.type)) { + this._hasTools = true; + this._tools.set(index, { + itemId: item.id || "", + callId: item.call_id || "", + name: item.name || "", + argsBuffer: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || ""), + done: true, + emitted: false, + }); + } else if (item.type === "message") { + this._messages.set(index, { + itemId: item.id || "", + textBuffer: "", + done: true, + item, + }); + } + } + + /** Resolve a tool key from delta event data (item_id → output_index, or direct). */ + _resolveToolKey(data) { + if (data.output_index !== undefined) return data.output_index; + if (data.item_id) { + const idx = this._itemIdToIndex.get(data.item_id); + if (idx !== undefined) return idx; + return data.item_id; + } + // Fallback: last registered tool key. + const keys = [...this._tools.keys()]; + return keys.length > 0 ? keys[keys.length - 1] : 0; + } + + /** Track output_index arrival order for output reconstruction. */ + _registerKey(key) { + if (!this._order.includes(key)) this._order.push(key); + } + + /** Get the key of the last message (or undefined). */ + _lastMessageKey() { + const keys = [...this._messages.keys()]; + return keys.length > 0 ? keys[keys.length - 1] : 0; + } + + // ── Public getters ────────────────────────────────────────────────────── + + /** @returns {"completed"|"failed"|"incomplete"|"cancelled"|null} */ + get status() { return this._status; } + + /** @returns {{input_tokens:number,output_tokens:number,total_tokens:number,cached_tokens:number}|null} */ + get usage() { return this._usage; } + + get responseId() { return this._responseId; } + get created() { return this._created; } + get error() { return this._error || null; } + get hasTools() { return this._hasTools; } + + /** + * Ordered output array (messages + function_calls), reconstructed from items. + * Gaps in numeric indices are filled with empty message placeholders. + * + * M2 FIX: dedup by itemId/callId so alias-registered tools (same entry under + * multiple keys via output_index + item_id) are emitted exactly once. + * M3 FIX: iterate ALL keys (numeric + string) in arrival order, not just + * numeric. Previously string-keyed items were dropped when any numeric key + * existed — common for streams where text deltas omit output_index. + * + * @returns {object[]} + */ + get output() { + if (this._order.length === 0) return []; + const out = []; + const seenToolIds = new Set(); // M2: dedup by itemId + + // M3 FIX: iterate ALL keys in arrival order. For numeric keys, gap-fill + // missing indices. For string keys (item_id fallback), emit directly. + const numericKeys = this._order.filter((k) => typeof k === "number"); + const stringKeys = this._order.filter((k) => typeof k === "string"); + + // Numeric keys: iterate 0..max for gap-tolerant ordering. + if (numericKeys.length > 0) { + const maxIdx = Math.max(...numericKeys); + const usedNumericKeys = new Set(numericKeys); + for (let i = 0; i <= maxIdx; i++) { + if (!usedNumericKeys.has(i)) { + // Gap — fill with empty message placeholder. + out.push({ type: "message", content: [], role: "assistant" }); + continue; + } + const tool = this._tools.get(i); + if (tool) { + const dedupKey = tool.itemId || tool.callId || `idx-${i}`; + if (!seenToolIds.has(dedupKey)) { + seenToolIds.add(dedupKey); + out.push(this._buildToolOutput(tool)); + } + continue; + } + const msg = this._messages.get(i); + if (msg) { + out.push(this._buildMessageOutput(msg)); + continue; + } + } + } + + // String keys: append in arrival order (not gap-filled). + for (const key of stringKeys) { + const tool = this._tools.get(key); + if (tool) { + const dedupKey = tool.itemId || tool.callId || `key-${key}`; + if (!seenToolIds.has(dedupKey)) { + seenToolIds.add(dedupKey); + out.push(this._buildToolOutput(tool)); + } + continue; + } + const msg = this._messages.get(key); + if (msg) { + out.push(this._buildMessageOutput(msg)); + } + } + + return out; + } + + _buildToolOutput(tool) { + return { + type: "function_call", + id: tool.itemId, + call_id: tool.callId, + name: tool.name, + arguments: tool.argsBuffer, + }; + } + + _buildMessageOutput(msg) { + if (msg.done && msg.item) { + // Use the full item if we captured it on output_item.done. + return msg.item; + } + return { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: msg.textBuffer }], + }; + } + + // ── Streaming helpers ─────────────────────────────────────────────────── + + /** + * Get the OpenAI tool_call index for a given item_id or output_index. + * The index is the position of this tool in arrival order (0-based). + * @param {string|number} itemIdOrIndex + * @returns {number} + */ + toolCallIndexFor(itemIdOrIndex) { + const toolKeys = this._order.filter((k) => this._tools.has(k)); + const resolvedKey = this._resolveToolKey({ item_id: typeof itemIdOrIndex === "string" ? itemIdOrIndex : undefined, output_index: typeof itemIdOrIndex === "number" ? itemIdOrIndex : undefined }); + const idx = toolKeys.indexOf(resolvedKey); + return idx >= 0 ? idx : toolKeys.length; + } + + /** + * Get a tool entry by item_id or output_index. + * @param {string|number} itemIdOrIndex + * @returns {ToolEntry|undefined} + */ + getTool(itemIdOrIndex) { + const key = this._resolveToolKey({ item_id: typeof itemIdOrIndex === "string" ? itemIdOrIndex : undefined, output_index: typeof itemIdOrIndex === "number" ? itemIdOrIndex : undefined }); + return this._tools.get(key); + } + + /** Mark a tool call as emitted (exactly-once guard). */ + markToolCallEmitted(itemId) { this._emitted.add(itemId); } + + /** Has this tool call already been emitted? */ + isToolCallEmitted(itemId) { return this._emitted.has(itemId); } + + /** @returns {object|null} incomplete_details (for LENGTH finish_reason) */ + get incompleteDetails() { return this._incompleteDetails; } + + /** @returns {boolean} whether finalize() was called */ + get finalized() { return this._finalized; } + + /** + * Complete Responses API response object — single source of truth for both + * the streaming flush and the forced non-stream converter. Includes id, + * status, output, usage, error, and incomplete_details when present. + * @returns {object} + */ + get response() { + const status = this._status || "completed"; + return { + id: this._responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + object: "response", + created_at: this._created || Math.floor(Date.now() / 1000), + status, + output: this.output, + ...(this._usage ? { usage: this._usage } : {}), + ...(this._error ? { error: this._error } : {}), + ...(this._incompleteDetails ? { incomplete_details: this._incompleteDetails } : {}), + }; + } + + /** + * Finalize the accumulator: mark as finalized and optionally inject an error + * (used by abort/stream-disconnect paths). Returns the accumulator for + * chaining. Exactly-once: subsequent calls are no-ops. + * @param {{error?: object, status?: string}} [opts] + * @returns {ResponsesAccumulator} + */ + finalize(opts = {}) { + if (this._finalized) return this; + this._finalized = true; + if (opts.error && !this._error) { + this._error = opts.error; + if (!this._status) this._status = "failed"; + } + if (opts.status && !this._status) this._status = opts.status; + return this; + } +} + +// ── Functional factory API (mirrors 9router createResponsesAccumulator) ──── +// These allow per-request instantiation at the handler level and sharing +// across streaming + forced non-stream + abort paths. + +/** + * Create a per-request ResponsesAccumulator instance. + * @param {{model?: string}} [opts] + * @returns {ResponsesAccumulator} + */ +export function createResponsesAccumulator(opts = {}) { + const acc = new ResponsesAccumulator(); + if (opts.model) acc._model = opts.model; + return acc; +} + +/** + * Finalize an accumulator and return its terminal response object. + * Used by the forced non-stream converter + abort handler. + * @param {ResponsesAccumulator} acc + * @param {{error?: object, status?: string}} [opts] + * @returns {{response: object, accumulator: ResponsesAccumulator}} + */ +export function finalizeResponsesAccumulator(acc, opts = {}) { + acc.finalize(opts); + return { response: acc.response, accumulator: acc }; +} + +/** + * Prefer the more complete string between two candidates. Used when both + * streamed deltas and a terminal snapshot exist for the same field — picks + * whichever is a valid prefix superset rather than blindly preferring one. + * Port of 9router preferComplete(). + * + * @param {string} current + * @param {string} incoming + * @returns {string} + */ +function preferComplete(current, incoming) { + if (typeof incoming !== "string" || incoming === "") return current || ""; + if (!current) return incoming; + // If incoming starts with current, it's a superset → use incoming. + if (incoming.startsWith(current)) return incoming; + // If current starts with incoming, current is more complete → keep it. + if (current.startsWith(incoming)) return current; + // Neither is a prefix of the other (fragmentation corruption) → prefer + // incoming as the authoritative terminal snapshot. + return incoming; +} diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js index c41ee47..5b7da54 100644 --- a/open-sse/translator/formats/responsesApi.js +++ b/open-sse/translator/formats/responsesApi.js @@ -1,4 +1,4 @@ -import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js"; +import { ROLE, RESPONSES_ITEM } from "../schema/index.js"; /** * Normalize Responses API input to array format. @@ -22,120 +22,3 @@ export function normalizeResponsesInput(input) { } return null; } - -/** - * Convert OpenAI Responses API format to standard chat completions format - * Responses API uses: { input: [...], instructions: "..." } - * Chat API uses: { messages: [...] } - */ -export function convertResponsesApiFormat(body) { - if (!body.input) return body; - - const result = { ...body }; - result.messages = []; - - // Convert instructions to system message - if (body.instructions) { - result.messages.push({ role: ROLE.SYSTEM, content: body.instructions }); - } - - // Group items by conversation turn - let currentAssistantMsg = null; - let pendingToolCalls = []; - let pendingToolResults = []; - - const inputItems = normalizeResponsesInput(body.input); - if (!inputItems) return body; - - for (const item of inputItems) { - // Determine item type - Droid CLI sends role-based items without 'type' field - // Fallback: if no type but has role property, treat as message - const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null); - - if (itemType === RESPONSES_ITEM.MESSAGE) { - // Flush any pending assistant message with tool calls - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - currentAssistantMsg = null; - } - // Flush pending tool results - if (pendingToolResults.length > 0) { - for (const tr of pendingToolResults) { - result.messages.push(tr); - } - pendingToolResults = []; - } - - // Convert content: input_text → text, output_text → text, input_image → image_url - const content = Array.isArray(item.content) - ? item.content.map(c => { - if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; - if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; - if (c.type === RESPONSES_ITEM.INPUT_IMAGE) { - const url = c.image_url || c.file_id || ""; - return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } }; - } - return c; - }) - : item.content; - result.messages.push({ role: item.role, content }); - } - else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { - // Start or append to assistant message with tool_calls - if (!currentAssistantMsg) { - currentAssistantMsg = { - role: ROLE.ASSISTANT, - content: null, - tool_calls: [] - }; - } - // Skip items with empty/missing name — upstream APIs reject nameless tool calls (#444) - if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; - currentAssistantMsg.tool_calls.push({ - id: item.call_id, - type: OPENAI_BLOCK.FUNCTION, - function: { - name: item.name, - arguments: item.arguments - } - }); - } - else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { - // Flush assistant message first if exists - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - currentAssistantMsg = null; - } - // Add tool result - pendingToolResults.push({ - role: ROLE.TOOL, - tool_call_id: item.call_id, - content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) - }); - } - else if (itemType === RESPONSES_ITEM.REASONING) { - // Skip reasoning items - they are for display only - continue; - } - } - - // Flush remaining - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - } - if (pendingToolResults.length > 0) { - for (const tr of pendingToolResults) { - result.messages.push(tr); - } - } - - // Cleanup Responses API specific fields - delete result.input; - delete result.instructions; - delete result.include; - delete result.prompt_cache_key; - delete result.store; - delete result.reasoning; - - return result; -} diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 0a3da34..5d1f449 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -26,7 +26,8 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { v4 as uuidv4 } from "uuid"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, @@ -374,8 +375,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); // Guard 1: no client tools → flatten all tool interactions to text. if (!clientProvidedTools) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 1f4d910..e0336fa 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -7,11 +7,12 @@ import { FORMATS } from "../formats.js"; import { v4 as uuidv4 } from "uuid"; import { resolveSessionId } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, - resolveDefaultProfileArn + resolveDefaultProfileArn, } from "../../config/kiroConstants.js"; import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; @@ -519,8 +520,10 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index b933678..265982a 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -9,6 +9,7 @@ import { buildUsage } from "../concerns/usage.js"; import { fallbackToolCallId } from "../concerns/toolCall.js"; import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js"; import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js"; +import { ResponsesAccumulator } from "../concerns/responsesAccumulator.js"; /** * Translate OpenAI chunk to Responses API events @@ -361,24 +362,35 @@ function flushEvents(state) { return events; } -// currentToolCallId is intentionally sticky for the current turn so flush/completion - // can still finalize as tool_calls even if the tool call was emitted before stream end. -function computeFinishReason(state) { - return state.toolCallIndex > 0 || state.currentToolCallId - ? OPENAI_FINISH.TOOL_CALLS - : OPENAI_FINISH.STOP; +// Derive finish_reason from the shared accumulator state. Tool calls → +// TOOL_CALLS; incomplete due to max_output_tokens → LENGTH; anything else → +// STOP. Terminal failures are handled separately by the caller (they emit +// error content + STOP). +function computeFinishReason(acc) { + if (acc?.incompleteDetails?.reason === "max_output_tokens") return OPENAI_FINISH.LENGTH; + return acc && acc.hasTools ? OPENAI_FINISH.TOOL_CALLS : OPENAI_FINISH.STOP; } /** - * Translate OpenAI Responses API chunk to OpenAI Chat Completions format - * This is for when Codex returns data and we need to send it to an OpenAI-compatible client + * Translate OpenAI Responses API chunk to OpenAI Chat Completions format. + * Uses a shared ResponsesAccumulator for tool-call correlation (parallel/ + * interleaved calls), exactly-once terminal output, and usage extraction. + * + * The accumulator is lazy-initialized on state so non-Responses paths pay no + * cost. It correlates tool calls by output_index/item_id/call_id — replacing + * the previous single-scalar (currentToolCallId) model that misrouted + * interleaved argument deltas. */ export function openaiResponsesToOpenAIResponse(chunk, state) { + // Lazy-init the accumulator. + if (!state.acc) state.acc = new ResponsesAccumulator(); + const acc = state.acc; + if (!chunk) { // Flush: send final chunk with finish_reason if (state.finishReasonSent || !state.started) return null; - const finishReason = computeFinishReason(state); + const finishReason = computeFinishReason(acc); state.finishReasonSent = true; state.finishReason = finishReason; @@ -389,8 +401,14 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { finishReason ); - if (state.usage && typeof state.usage === "object") { - finalChunk.usage = state.usage; + // Usage from accumulator (captured on response.completed). + if (acc.usage) { + finalChunk.usage = buildUsage({ + promptTokens: acc.usage.input_tokens, + completionTokens: acc.usage.output_tokens, + totalTokens: acc.usage.total_tokens, + cachedTokens: acc.usage.cached_tokens, + }); } return finalChunk; @@ -400,15 +418,16 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { const eventType = chunk.type || chunk.event; const data = chunk.data || chunk; - // Initialize state + // Initialize chat stream state on first chunk if (!state.started) { state.started = true; state.chatId = `chatcmpl-${Date.now()}`; state.created = Math.floor(Date.now() / 1000); - state.toolCallIndex = 0; - state.currentToolCallId = null; } + // Feed every event into the accumulator for correlation + terminal tracking. + acc.ingest(eventType, data); + // Text content delta if (eventType === "response.output_text.delta") { const delta = data.delta || ""; @@ -425,17 +444,21 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { return null; } - // Function call started (standard function_call or custom_tool_call) + // Function call started (standard function_call or custom_tool_call). + // The accumulator registered the item on ingest; we emit the OpenAI + // tool_call header with the index resolved by correlation. if (eventType === "response.output_item.added" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) { const item = data.item; - state.currentToolCallId = item.call_id || fallbackToolCallId(); + const itemId = item.id || item.call_id || ""; + const callId = item.call_id || fallbackToolCallId(); + const tcIndex = acc.toolCallIndexFor(data.output_index ?? itemId); return buildChunk( { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, { tool_calls: [{ - index: state.toolCallIndex, - id: state.currentToolCallId, + index: tcIndex, + id: callId, type: OPENAI_BLOCK.FUNCTION, function: { name: item.name || "", arguments: "" } }] @@ -443,77 +466,78 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { ); } - // Function call arguments delta (standard or custom_tool_call variant) + // Function call arguments delta (standard or custom_tool_call variant). + // Route to the correct tool call via output_index/item_id correlation — + // the accumulator tracks which call this delta belongs to. Previously a + // single scalar counter was used, misrouting interleaved deltas. if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") { const argsDelta = data.delta || ""; if (!argsDelta) return null; + // Resolve the tool-call index for THIS delta's item, not a global counter. + const key = data.output_index ?? data.item_id; + const tcIndex = acc.toolCallIndexFor(key); + return buildChunk( { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, - { tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsDelta } }] } + { tool_calls: [{ index: tcIndex, function: { arguments: argsDelta } }] } ); } - // Function call done (standard or custom_tool_call variant) + // Function call done (standard or custom_tool_call variant). + // Mark emitted for exactly-once semantics — if the done event carries + // complete arguments we don't need to re-emit (the deltas already streamed). if (eventType === "response.output_item.done" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) { - state.toolCallIndex++; + const itemId = data.item?.id || data.item?.call_id || ""; + if (itemId) acc.markToolCallEmitted(itemId); return null; } - // Response completed + // Response completed — capture usage (already ingested) + emit final chunk. if (eventType === "response.completed" || eventType === "response.done") { - // Extract usage from response.completed event - const responseUsage = data.response?.usage; - if (responseUsage && typeof responseUsage === "object") { - const inputTokens = responseUsage.input_tokens || responseUsage.prompt_tokens || 0; - const outputTokens = responseUsage.output_tokens || responseUsage.completion_tokens || 0; - // OpenAI Responses API: input_tokens already includes cached_tokens - // Cache info is in input_tokens_details.cached_tokens - const cacheReadTokens = responseUsage.input_tokens_details?.cached_tokens || responseUsage.cache_read_input_tokens || 0; - - state.usage = buildUsage({ promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, cachedTokens: cacheReadTokens }); - } - if (!state.finishReasonSent) { - const finishReason = computeFinishReason(state); + const finishReason = computeFinishReason(acc); state.finishReasonSent = true; - state.finishReason = finishReason; // Mark for usage injection in stream.js - + state.finishReason = finishReason; + const finalChunk = buildChunk( { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, {}, finishReason ); - // Include usage in final chunk if available - if (state.usage && typeof state.usage === "object") { - finalChunk.usage = state.usage; + if (acc.usage) { + finalChunk.usage = buildUsage({ + promptTokens: acc.usage.input_tokens, + completionTokens: acc.usage.output_tokens, + totalTokens: acc.usage.total_tokens, + cachedTokens: acc.usage.cached_tokens, + }); } - + return finalChunk; } return null; } - // Error events from Responses API (e.g. model_not_found) - if (eventType === "error" || eventType === "response.failed") { - // Avoid emitting duplicate errors (error + response.failed arrive back-to-back) + // Terminal failure events (response.failed, error, response.incomplete, + // response.cancelled). Surface as an error chunk with STOP finish reason. + // Exactly-once: skip if we already sent a final chunk. + if (eventType === "error" || eventType === "response.failed" || eventType === "response.incomplete" || eventType === "response.cancelled") { if (state.finishReasonSent) return null; - const error = data.error || data.response?.error; - if (error) { - state.error = error; - state.finishReasonSent = true; + const error = acc.error || data.error || data.response?.error; + state.finishReasonSent = true; + state.finishReason = OPENAI_FINISH.STOP; - // Surface the error as an OpenAI-compatible error chunk - return buildChunk( - { id: state.chatId || `chatcmpl-${Date.now()}`, created: state.created || Math.floor(Date.now() / 1000), model: state.model || MODEL_FALLBACK }, - { content: `[Error] ${error.message || JSON.stringify(error)}` }, - OPENAI_FINISH.STOP - ); - } - return null; + const errorMsg = error?.message || (acc.status === "incomplete" ? "Response incomplete" : acc.status === "cancelled" ? "Response cancelled" : "Response failed"); + + return buildChunk( + { id: state.chatId || `chatcmpl-${Date.now()}`, created: state.created || Math.floor(Date.now() / 1000), model: state.model || MODEL_FALLBACK }, + { content: `[Error] ${errorMsg}` }, + OPENAI_FINISH.STOP + ); } // Reasoning summary delta → emit as reasoning_content for client thinking display diff --git a/open-sse/utils/responsesStreamHelpers.js b/open-sse/utils/responsesStreamHelpers.js index eaacdcd..1f71d37 100644 --- a/open-sse/utils/responsesStreamHelpers.js +++ b/open-sse/utils/responsesStreamHelpers.js @@ -7,6 +7,8 @@ const OPENAI_RESPONSES_TERMINAL_EVENTS = new Set([ "response.completed", "response.done", "response.failed", + "response.incomplete", + "response.cancelled", "error" ]); @@ -20,13 +22,22 @@ export function isOpenAIResponsesTerminalEvent(eventName, chunk) { const type = getOpenAIResponsesEventName(eventName, chunk); if (OPENAI_RESPONSES_TERMINAL_EVENTS.has(type)) return true; const status = chunk?.response?.status; - return status === "completed" || status === "failed"; + return status === "completed" || status === "failed" || status === "incomplete" || status === "cancelled"; } const sharedEncoder = new TextEncoder(); -// Encoded response.failed + [DONE] payload for aborted/stalled Responses passthrough streams -export function buildAbortedResponsesTerminalBytes() { +// Encoded response.failed + [DONE] payload for aborted/stalled Responses passthrough streams. +// When an accumulator is provided, finalize it with the stream-disconnect error +// so the accumulator's partial output + status is preserved for any consumer +// that reads it post-abort (forced non-stream converter, usage tracking, etc.). +export function buildAbortedResponsesTerminalBytes(accumulator = null) { + if (accumulator) { + accumulator.finalize({ + error: { type: "stream_error", code: "stream_disconnected", message: "stream closed before response.completed" }, + status: "failed", + }); + } return sharedEncoder.encode(`${formatIncompleteOpenAIResponsesStreamFailure()}data: [DONE]\n\n`); } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 464acde..8b53583 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -48,7 +48,8 @@ export function createSSEStream(options = {}) { connectionId = null, body = null, onStreamComplete = null, - apiKey = null + apiKey = null, + responsesAccumulator = null } = options; let buffer = ""; @@ -57,7 +58,7 @@ export function createSSEStream(options = {}) { // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks const decoder = new TextDecoder("utf-8", { fatal: false }); - const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null; + const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model, ...(responsesAccumulator ? { acc: responsesAccumulator } : {}) } : null; let totalContentLength = 0; let accumulatedContent = ""; @@ -464,7 +465,7 @@ export function createSSEStream(options = {}) { }); } -export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, responsesAccumulator = null) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, targetFormat, @@ -476,7 +477,8 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p connectionId, body, onStreamComplete, - apiKey + apiKey, + responsesAccumulator }); } diff --git a/open-sse/utils/tlsImpersonate.js b/open-sse/utils/tlsImpersonate.js deleted file mode 100644 index 0814397..0000000 --- a/open-sse/utils/tlsImpersonate.js +++ /dev/null @@ -1,103 +0,0 @@ -// TLS Impersonation — Chrome-like TLS configuration for undici Agent. -// -// Many web providers (HuggingFace, ChatGPT, Gemini, Claude) sit behind WAF -// (AWS WAF, Cloudflare) that fingerprints the TLS ClientHello (JA3/JA4 hash). -// Node.js fetch has a distinctive fingerprint that WAF blocks. This module -// configures undici's TLS options to approximate Chrome's fingerprint: -// - Chrome cipher suites order (BoringSSL) -// - X25519 as primary curve (Chrome default) -// - Chrome signature algorithms order -// - h2 ALPN preference -// -// This is NOT a perfect JA3 match (Node.js OpenSSL != Chrome BoringSSL), but -// it's close enough to bypass many WAFs that check for "obviously not a browser" -// fingerprints. For WAFs that require exact JA3 match, a binary solution -// (curl-impersonate / tls-client) is needed. -// -// Usage: -// import { getImpersonateDispatcher } from "./tlsImpersonate.js"; -// const res = await fetch(url, { dispatcher: getImpersonateDispatcher(), ... }); - -import { Agent } from "undici"; - -// Chrome 131+ cipher suites in BoringSSL preference order. -// Source: observed from Chrome TLS traces. -const CHROME_CIPHERS = [ - "TLS_AES_128_GCM_SHA256", - "TLS_AES_256_GCM_SHA384", - "TLS_CHACHA20_POLY1305_SHA256", - // TLS 1.2 fallback suites (Chrome sends these for backward compat) - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", - "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", - "TLS_RSA_WITH_AES_128_GCM_SHA256", - "TLS_RSA_WITH_AES_256_GCM_SHA384", - "TLS_RSA_WITH_AES_128_CBC_SHA", - "TLS_RSA_WITH_AES_256_CBC_SHA", -].join(":"); - -// Chrome signature algorithms in preference order. -const CHROME_SIGALGS = [ - "ecdsa_secp256r1_sha256", - "rsa_pss_rsae_sha256", - "rsa_pkcs1_sha256", - "ecdsa_secp384r1_sha384", - "rsa_pss_rsae_sha384", - "rsa_pkcs1_sha384", - "rsa_pss_rsae_sha512", - "rsa_pkcs1_sha512", -].join(":"); - -// Chrome elliptic curves in preference order. -// X25519 is Chrome's primary curve for TLS 1.3 key exchange. -const CHROME_CURVES = "X25519:secp256r1:secp384r1"; - -let cachedAgent = null; - -/** - * Get (or create) a cached undici Agent configured to approximate Chrome's - * TLS fingerprint. Reused across all impersonated requests for efficiency. - */ -export function getImpersonateDispatcher() { - if (cachedAgent) return cachedAgent; - - cachedAgent = new Agent({ - connect: { - ciphers: CHROME_CIPHERS, - sigalgs: CHROME_SIGALGS, - ecdhCurve: CHROME_CURVES, - // Chrome prefers HTTP/2 via ALPN. - ALPNProtocols: ["h2", "http/1.1"], - minVersion: "TLSv1.2", - maxVersion: "TLSv1.3", - // Don't reject unauthorized certs (we want to look like a browser). - rejectUnauthorized: true, - }, - // Keep connections alive for performance. - keepAliveTimeout: 30000, - keepAliveMaxTimeout: 60000, - }); - - return cachedAgent; -} - -/** - * Fetch with Chrome-like TLS impersonation. - * Drop-in replacement for proxyAwareFetch when you need WAF bypass. - * - * @param {string} url - * @param {object} options - standard fetch options - * @param {object} proxyOptions - optional proxy config (ignored for now; WAF - * targets typically don't go through proxies) - */ -export async function impersonateFetch(url, options = {}, proxyOptions = null) { - const dispatcher = getImpersonateDispatcher(); - return fetch(url, { ...options, dispatcher }); -} diff --git a/package.json b/package.json index 5085ddd..aa4d643 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rsalmn/extremerouter-app", - "version": "0.7.5", + "version": "0.7.6", "description": "ExtremeRouter web dashboard", "private": true, "license": "MIT", @@ -23,10 +23,10 @@ "proxy" ], "scripts": { - "dev": "next dev --webpack --port 20127", + "dev": "next dev --webpack --port 20128", "build": "next build --webpack", "start": "next start", - "dev:bun": "bun --bun next dev --webpack --port 20127", + "dev:bun": "bun --bun next dev --webpack --port 20128", "build:bun": "bun --bun next build --webpack", "start:bun": "bun ./.next/standalone/server.js", "cli:pack": "npm --prefix cli run pack:cli", @@ -44,7 +44,6 @@ "confbox": "^0.2.4", "dompurify": "^3.4.12", "express": "^5.2.1", - "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", @@ -79,4 +78,4 @@ "postcss": "^8.5.6", "tailwindcss": "^4" } -} +} \ No newline at end of file diff --git a/public/providers/agentrouter.png b/public/providers/agentrouter.png new file mode 100644 index 0000000000000000000000000000000000000000..851556f62db58d4267e096a39d2e9de88e6688c5 GIT binary patch literal 9597 zcmds-MNk}Eu!U!EcY+4j;O_435ZpZs1a}J%oZtfl4<6iI1HpZ8hr!+b<=?;cTfD{X zv%Gyzb$8XR{-V@X<0sG1->1OO-i z3epnV-X^EUKEZhN)9>@g0(a~1aH&%2STdF!8X5xI_?Q<)+i|6T@jD7~WeYP(FHt3E zu_Yw5g$qe@W&hxQ^Ug2LqmvhwfY-)K3twBxKAyLooEV$9xf%1Eti9n~3vwCj&^y|Q#(S}r9h_EIVj#PX_F&l#mBkv>Hq9P1No>P(PcOt|o41_PVK*Xsg&656D zSUW`6rleq4JHxVB^Fd(4H=vW{b6TXE2EUbJW zJthQO3Re?Pbd5iSy6*ZebX?Xh_I>LoE8TyN<<*pD9^Y$&Cqs8*fHy#S!0!<@l?XHx zaaquYW~~qB6dMv1FMsSTv)VroA-ELvrJ$%?s(;LFqBoV~uX9P@Efe zyrTA4mqx*2n%Y$Um;UA4PmuG+ZH17g{jCNUz_{`nF`qkX;t@DzDIBDvx21P*=_DE@i^BTXGVsjoMXEbOxwQKMk?rd`H%q{ zSp<-$r)^IZTydSRYmu?iKp}=#q5Q=+l3ajcf&-Fz;x;*ZQ$EmDBC>ok@jMwlQADD}(- zEJ5t;QguzSbc&qu-ivcy&tn_nu}`f%GS~Mu3e`ZF+6iB~Q$kLwi^eUfjAnvD*bzH7PMu#Q*n06S3Jy2C%DO(8 z;&I1i@@0M&2D4=wJ=YJAgxVF92y{e4PhYddk_E?G314q0{ZYMJcd*jV2)eJHFE3WAI^tec7J<_wEt$oYs;K8u0b^Nh8N$E zoE}S_@Q?bNYMl_v-N=}-50*Ixk%)V3sPkol&*<_b)g0{eCIEJTt(rdA1+U7-(jWS+ z{?k&lA@cKL;_jJUx+@}-YA~Pikr5Kug06nv%E%cAp-*iAD-EDdB+c2QN$0c;pp=2V z^l33jKc*Wjqs3UJakDt-y1fFtA4r^QT{O9aj_B(hbS8@n} zo9p(;d_^YsOXHYt8eh8~_CjczQAf2_!V!}w4GO*#kN@;`62RF9KCjU5kOVfT?2(X; z$f@+LHff2c;AR@TLM6_!ckm;@)Y#nqP$jJX6)HfG{;}KM0s?Y8{_?Zb!MK7w!hgCQ zdTeo2c34pIj8u#%z`NgnKzQ`E zWKPa8(NYky0tG$BhT^zG7dk6)*qRQOhNvEH-4?hW)F?{#F=Ac_5n7gAq84;q=|nD$ zJNi>(Y(7yxVjv%gk`Q}hhg`S;Frr-3Xth+GIl#>5Isi1`3+voWr4=P)|83NDC}@ih znV-9v1O3|^w-1c2_e)5>{rca$!-)?EQ(J(_;y_>P`2c_^;W!nfzKi2W2}gR^w+An{ zH?kyIe@HX*+ufYp&LgTPKI^?OOgy&;NaNzCW(PxtP~+Ft zVDL;Z5XSfE_s`A1hhNR8eAU}pNBsYqrc-aBdm^^?2K+lfj#!)kA-rbx=!JbTz|vPcxc=XxFy6v=%n% z#@ADVM;A3r*s~(83(Mp$JA9IC%7OxesyufUd{*!4q2SYUP0Aivc$MRHS@Nnn$zp%o zc{rzbSLxf-b2XMlYI-N3&wex831C&G*To9X%%!%}VyC(*^S=p0hYB@2V~)Z;5mX#S zz%*b$8|GRQLz~r(?evu(4)DCsEsJOPLRf-D?={h? zuRpV6NhqaAfHk_oKJpGlT}x^cYhH%^d+M#FIJcHpNXgpAu{uL=UGm?H@06bJR_}_V z@BZcfe5NfmVy&wVcew^gCE^L@qrca}11^I6*go!DKhDi*DT$`R-}L4>tuf+kmmu>i zdN~?m_QElRrU8-!)W~@miB#F zAz)vz5`6yiaxrzlpqsvKLk`jNXbN60lhwzmMqPLbqN$cnb)GkI1u2`Ke3roOGO2oi-kMtMXBlL(!ZJ7*UPh51ip z$$i+DzQ6O9;x)Z<@Cd(QHroXE#W|y03^}cDaF)&IHs0N?-W&5%qNpmdMPNH93hlPcg1xc9BV1x4WSI; zTdFP{XMsP+A0_rgmfPin^R8ycTS)tLeXCUuYg`AH2i*e^i(gZ3 zx^=UWZE+vBx3M22q3PgMbKYq&ua#RBP{+)5!$?%CFW3ON0&0co5 z*QCzdYV1wb5mfrKt@yc83A%CBJ$AC8xn9Qb9k~m96WZ*hHE$v7@OoUpg-~@rMz%Vv z7*1XZf6(0Ar4lP)AafA19-~yb?(Q+~-mV)>~8xz%1R@DII$HL#^ z_P7Q&%VJ&dbEZgg@vi*RmBmE;!P&9Yz@TqkBw?6t~wK!>Ag0CVP+_@>sZ1+LUI)ZJ zuO?k;AD$G>5wE6sGs7O3vMlj}BMH9oc){5)5GP;MCJ%shp9S>U#?HH3zvzsl;+^J) zfU@{<)$+cFNvm__zFIC!9KlpEs2wCVBi2Wn_$BK0$adxy5Mp9CwH+gPG>tU6{!8|2 zHJ?$>56o{8zJ{yzxIDgT>{W7oLTn!m?Yj_Ua~KX<%|~Cw(uvwr0$Ed~$C~}y9qCPT zXzD#N<9uw%aI;fq2@%%N(4B_EU_0s&@cnzUP%$CEn{Hw{+=n0>Wg4OsUfdsbyOQ)D zqXW*vcbG6HW#k`k4(qD|KXP~_Oh{QwFLXM_kA62X40x==yhEjIK+9W9_4rderr>L# z;Oag6o0_1Br}^$-9VfUddsH!C-)T0=?n{cjh#3FuzWy>Dmt1V>aI}Q&7k~7LABO>w$f@RfF*pmR&ExYLv>Ld)35i0+Q;mCwH~zV^oMAYR7VNm zJ78!;^u^()+-3?B#rEY6UK%HW-_Wp83>zYLe>@_b>(qHqaGQYZQst1X&@}KD zepV(ItyB|r6z>!{-p30jvY5arm^=5|yNT=jmR{3>_tIc3T%MYDSyw$(LA!A!rO=w4 z3=>mXpy%lRk|?$8)a--BQb*#c8|+*!`IjmTZH9`dX@yd=cmq3e>4#fP%u*s<<-KP) zw&OilskmYq&(iq0PLq1RHRy+d+!rbLC z5)m73sv>dnazk|Lmge#vPz{}YG+$C0{q*2X^5gYJsKEo2OjznP} zHhPn0Xip|_PiCx#bo4k?1D7}HUG{YWOK#mt-860^Ys8QfT+a+QOQl^KiInWR$kjip zyfN)$(xVJ3_PhIZe<#>MW7ux1t|eSIh>;%~oM(bwSt zdbI!LwU$4Vd>9hY>}PDR1bF%t?$NonYk&bN8*tuL|I+6`qVV9X1ATxm(OtKdZx-Mc zJTVSZ;|s@p5YV4Oyy%@ih&XZ>c(p@7h3-*Nnczq(;RQYVvE@h$Y^Z11XPy`yA_r_V zkmd!rS;>?b%Ju{i$A$&za6Dg-qw?v3Qka1UvnFC~Q^6m9%g4*E2l@No6yu>|l; z_rgIIOEZ$=PM}kxy$06TIl<*6)Yg5w6rStVO?|(l4M+J;TUDoAC9vwCVbn8m4p-usK1a}t0h)R?QWiq4TyLHms z)J*_oS1t;x=#|8iP-$TG@>j~s&d5!8O8Y24rMq5Qz}o?GiknBt=HlLtIJmgk%?^%~ z=(Y1^VmxW}Ic@cM^wi{GhB)}m)~y>!GMAqIwuBACP;9v0;a!|k@L||(WPELONcr1F z=`(zL>MTG|m;D?;mJcR1DRu~KnTWQOn1>rX+~})_vK)QxH}=^nFXDT|;SKR^Ldr5kg)`aU3i#5wZQX;j zz=dt!fnIVCDI{fVZftW{1^-dy7%KBX=;id@I|u5)U`)D~m(Rd=F;l(f7!Dh^JF!I; z;aB^?E{r@KiQL*eA54NiCJBaNZ{UxNKc*2)%1EeCg@kvy`|zzxa<|si?Q^yKi!PJ_ zEbQLlfkl~8xxu8~474*ayefe2&DhmHb9i#o3+{S_bZbxf!CA|GFVlzGO^wg`mfb16 zGwo0GtkqxxBk3`(O|y$oaJK)WO-n}le%T3# z8wKU7P#Pp?|NV#!YX=E5C3>c1Fkw<^A>9|AuHjmJAb@?A29oSIvsC;nij_!{0y1S8 z?;-A4K5~{7dY(ZYBy#$Zgr(&LLufEGS-kr2qx7wU=Op<=r4M$#v$8TdSmpV=$nC&| z892AuVTSO24|i2Ryb0)HT&6%kHiVgKbZf0Ac?&mF9gg5~ZTK`{n@Kxi2h0oP4q8g*k2 zBh;yd_7KsHea<1tyMIG-GQeMno-)NYgTcUF+gURr!RQblJncW&Lyc*w&Qbm&bZ&2wKJHsKLu}3o=%u&0YY7w9NkVta>wHb@?&-#V;21^RA{Zp{YX|Glj$G@&$ zZZ$H%xf4UV$jD#@HBGwl*UgV%Pj@eh-S3<`er5(o(BUK-Y>#`W#D%Qc8k>3t>8nj2 z>5S!*>+yta(;uQ*rX~+jO$ogLFsnVAx(xrgZtbkRAxz=>_KfdpRXE*tjarQxE)PvG zaM6jfxB1I*u#D{8bI0LmPDGvI40<6EsjYtXSn~Nps;49*yfy2A=FTRZQi$=L*uj5k z{@2{hH$P`bwXU1Ny0CCvdfubjL6Tm7L&)wgv@_|Ltb%^U5BK3jY^lDZfn29Aeft^% z@BwRM!hl?UxO^Zkt3#77Ua3+4O4RNP9J_h=7* zNXeTO&WPL5UQUdIF_lR8nz_ORXG^-yua;8?HNvulx9#6{V5-;qb4!k4>ycp}<_`g7n%65^juTaRO5SH5;SNq5sPG zct0>O$)L}x(7B^gyRv7aO3kIuRw;_%0}e|IePtC!L~F@CTPE5#(kN%JE`7k#Nd&zF zUZ9ULq&I=kw+}yY1T;`FB;l*CjgKtH#hpXpQ^M(<=wqYAVksPUin1?DIgU_i_n$0S zeSV$#6Ot7sy;Lf&zL5n{&-pm_#GAMg*qDl{bIockiq>lp%Gv3x(F_@n?6C+&7~EJW zH8{{}7*=S*9XIpgYIN$qdT+JSS{fJ4CFWo~;cDisAr~L{>yzWzE#NTwi>%?RREEK6Baa0&NA7W(Z9)Na@d~lPit>epx33-*w_~rj91BI%{~o zm+t)&%xR{Dt(z;Yfi#{qcXF!gyZ=YrY@x@@ZKsyq3V!O>$B}uStw{CeJ3nH3=VJ7b zbDiSboJCjGA;AP%U}f~amlw~vVkk&ExpkY4QvZvS;VA?n&}jWOt>hkg-d5KL(MWI2 zxJW(5i2nPxBK9*d_sg0}ZxVH49-%sC{Q?2hM*9Ww??c@f@k1&8nroctz6FtLdv8=Y_B99(gC0NuT8L zK~L%V&(lZ(AbFzt#P!;y{yf<}05t@s>jc)T#cDwYfY3a{Wf^{J+uLIZYUa;AJ9*Qh zgzDQ7ry(>Gu zy$0E%Ryw$z8R?3?tp?RoJV{YKZpilqA&3b))hjBe8b7>O9fco)&ELfyFV#!3r-7Q#Cy|rJdbADMzfIBHaK|2B>TKE&N??~XPgGNw38j|LZLBpk!jA2bu%Lvu>lO#@$dXPi;XQ-vchY3>vkB9_MZ zH?W9c1GxZVsF#lSb0BBpg4T~D9)#i*gx8Om?^Kc1DqouH;L_{Bj-$jR#tcWRR$1Kf z#1KOQjM0B@EZj~$Egc$|@}Ex=p+QV{QEmVBRAG(qzNAxsJ+2)#w=6mOn;J;myuREZ z^xYm9Vt9VSYaX|ArkIJvlZft=LjA_{g{=vppzvJ#B%2YWasGHH9hWdpw_{yqw*XR>7h7C(8&rmDQ zjb)KbA$z<^-k*|^dAx0nbTy_mkl3jIBXo@_`UNRg6-tqeeM5pDvN2_B9P@)31d5O~ z^x_w6w2RE)z<)JdlsgQKa7LZ=QNp zq!e^|SzCW6)Dh2LzBJO6R0?X-6CW6%0oRPRsWA5aXl{Rp+(ISCYef#+<;B%nBS!mk zph8H5>1dE$<6gX&a{XP})o{B38oE&aDW0AM(3BnE=adH3Ro@G*jMeF){@$rURC4vQ zw>+F=XJ7%(eUS>BHBjW58igmWu&lG`**epyE^#hT3dSJ2yE;t--w$p*{L5{Lp#**c z4}Cj)CWCxZOumQ=7|px!Q@5`C@XoImK@KlztnQ5i7J;WLd+ijdW?z(eBOSi15TzMc ztMNQaNLnv>i_!WPcTpdwEqq+vv^lVn(>pyuXtt>bCQ10$p5S@fv#L9R^Yppqb%9oJ z#iQ!6@2^+WWTaFySA=lcPZo@+vxtZ}Gw7 zl$4lcA|>uBq9_g(&euAIcP_}xKmwYmpn5#_r04i@Yu)jAJ0-G@WK*BM%?7SCv{N1G z_o_2}k@cA|*x<$*>dmogP3E(|`H*42KJQgvy460iN` zRfzB?Ak620v+Z_?BBRLV5if1FaaZ30vaH;B@J=fWnYf=|ir{`Ice0g}U&mCJIlIw;mgxfr3(K#Nul53u zbD2Kd9U1bm+Kx+atbz-ehZl}OgTJaM#^VVJ4GOs;^EqKa{rX=c3KC4)nO zk{`MTA}`SuuZYqV>4zIQj#O5N+`(U49UbPS!oSVm6Jw-0C=YVSb$oGr>Kf(TyaP+SB_0) z2Q|1x!~nG#{k)9tZxeTX3m74{X3Dsx{B^5>WIlUlXXe#rCoLlwor+4We{RG}sBZH2 zD@zS+o*~%E*TKq+Ipj@TJmCQ$7BbVs?bPW?Qj+KAQF;PiA6N5D6?X*l;wOiIfyjaCo#NsPdVm z7BPR#d~WUQ9P)9IjAjy_W0DCDf~o&1mD;+f!!Gf=woN8dX!~t?UyIJ00IFVR;pMRU zk7}0g68`{Ews4*2W#Xx?`CrmbrX*cO%Kj=#Dwrg1pj`kzsesL*=6O&YgY-&7fDt7LtXJq) z`{P$GC0MA`30P5nT@ zs_gWH`+L{$R8;sF1NOirn3FmGga-IugL1m|w5zDu{tgliqA&t4kzOK4Wgc3X?`ke) z(%Y>67GIFs`Duy*o8cE2DOIlktvV|?pqnK5Ae_yRuCg;(IC1gju$ZAedC1dXYpj9E zDTYNwhT1T^28#!?G=USglENz;L;nzlfyq^o{&09T@=q0-IYW?RLn_IPeUZ=D26BNC z*3(vHeA<(}uBz{;*JsphXa%J1YND-&V_;jjw}B1&NwPqeO{8JowZ!_a?7(31m#!bm zGX^iRO;@7d-ya>s%=E++as)e}*cRLPaKe`!*L?S<0+sw;fAC;j{uFNk45-cLhXUW# zPsZ?$6k<)07$c-7**Q-uLzPLK<6pZ~L({RNY*Qn>-LwdP6W_I6N^&3Bi#vwnrR>GVn3U4!whz%2 zqIe*a_uCTMW7)o!f|!64NmySQ`E_yWLI5a^&Gsh&PlyjJS2tr($7ub>GZAivy3?64 z;EgA?P|t!JIs2d@)D3WyJ7>p%~cunqq2 zVnW1HR4qN(7KSas)rSP}>z$9g&=Mjm=i$D@So{lgN7T;b+8g@^@to%M|Nmfl{ugxU aG3a-DviBvqQ0G6M3ZNjPDqSUM7WzNIJEblF literal 0 HcmV?d00001 diff --git a/public/providers/bynara.svg b/public/providers/bynara.svg new file mode 100644 index 0000000..a9e7316 --- /dev/null +++ b/public/providers/bynara.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/providers/infron.svg b/public/providers/infron.svg new file mode 100644 index 0000000..3690c29 --- /dev/null +++ b/public/providers/infron.svg @@ -0,0 +1 @@ +IF \ No newline at end of file diff --git a/public/providers/inxorastudio-web.svg b/public/providers/inxorastudio-web.svg new file mode 100644 index 0000000..8afe4f2 --- /dev/null +++ b/public/providers/inxorastudio-web.svg @@ -0,0 +1,4 @@ + + + IX + diff --git a/public/providers/inxorastudio.svg b/public/providers/inxorastudio.svg new file mode 100644 index 0000000..1b8e7d2 --- /dev/null +++ b/public/providers/inxorastudio.svg @@ -0,0 +1,4 @@ + + + IX + diff --git a/scripts/test-combo-autoswitch.mjs b/scripts/test-combo-autoswitch.mjs index 358ebcf..6c2269d 100644 --- a/scripts/test-combo-autoswitch.mjs +++ b/scripts/test-combo-autoswitch.mjs @@ -1,7 +1,7 @@ // Live test: combo capacity display + auto-switch routing. // Sends text / image / search requests to a combo and reports which member ran. // node scripts/test-combo-autoswitch.mjs -const BASE = process.env.BASE_URL || "http://localhost:20127"; +const BASE = process.env.BASE_URL || "http://localhost:20128"; const KEY = process.env.API_KEY || "sk-6581be4f05a82b6b-uxy6jn-c8190ea8"; const COMBO = process.env.COMBO || "haha"; diff --git a/src/app/(dashboard)/dashboard/combos/components/ComboTemplates.js b/src/app/(dashboard)/dashboard/combos/components/ComboTemplates.js deleted file mode 100644 index 7191a70..0000000 --- a/src/app/(dashboard)/dashboard/combos/components/ComboTemplates.js +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import PropTypes from "prop-types"; -import { useState } from "react"; -import { Card, Button, Badge } from "@/shared/components"; -import { COMBO_TEMPLATES } from "@/shared/constants/comboTemplates"; - -/** - * Renders a grid of prebuilt combo templates. Users can one-click apply a template - * to create a combo with the models + strategy pre-configured. Provider availability - * is checked — connected providers show green, missing show gray. - */ -export default function ComboTemplates({ combos, connections, onApply }) { - const [applying, setApplying] = useState(null); - - // Build set of connected provider IDs - const connectedProviders = new Set( - connections?.filter((c) => c.isActive !== false).map((c) => c.provider) || [] - ); - - // Build set of existing combo names (to detect duplicates) - const existingNames = new Set((combos || []).map((c) => c.name)); - - const handleApply = async (template) => { - setApplying(template.id); - try { - // 1. Create the combo - const res = await fetch("/api/combos", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: template.name, - models: template.models, - kind: null, - }), - }); - if (!res.ok) { - const err = await res.json(); - alert(err.error || "Failed to create combo from template"); - return; - } - // 2. Set the strategy - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - comboStrategies: { - [template.name]: { fallbackStrategy: template.strategy }, - }, - }), - }); - // 3. Notify parent to refresh - if (onApply) onApply(); - } catch (err) { - alert("Failed to apply template: " + (err?.message || String(err))); - } finally { - setApplying(null); - } - }; - - return ( - -
- {COMBO_TEMPLATES.map((tpl) => { - const alreadyExists = existingNames.has(tpl.name); - const connectedCount = tpl.requiredProviders.filter((p) => connectedProviders.has(p)).length; - const allConnected = connectedCount === tpl.requiredProviders.length; - - return ( -
- {/* Header */} -
-
- {tpl.icon} -
-
-

{tpl.name}

-

{tpl.description}

-
-
- - {/* Models */} -
- {tpl.models.map((m) => { - const provider = m.split("/")[0]; - const isConnected = connectedProviders.has(provider); - return ( - - - {m} - - ); - })} -
- - {/* Provider availability + apply */} -
- - {connectedCount}/{tpl.requiredProviders.length} providers connected - - {alreadyExists ? ( - Created - ) : ( - - )} -
-
- ); - })} -
-
- ); -} - -ComboTemplates.propTypes = { - combos: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string })), - connections: PropTypes.arrayOf(PropTypes.shape({ provider: PropTypes.string, isActive: PropTypes.bool })), - onApply: PropTypes.func, -}; diff --git a/src/app/(dashboard)/dashboard/overview/OverviewClient.js b/src/app/(dashboard)/dashboard/overview/OverviewClient.js index d092cde..4825014 100644 --- a/src/app/(dashboard)/dashboard/overview/OverviewClient.js +++ b/src/app/(dashboard)/dashboard/overview/OverviewClient.js @@ -3,6 +3,8 @@ import { useState, useEffect } from "react"; import { Card, PageHeader, CardSkeleton } from "@/shared/components"; import OverviewKpiCards from "./components/OverviewKpiCards"; +import SavingsCard from "./components/SavingsCard"; +import LeaderboardTable from "./components/LeaderboardTable"; import TokenSaverStatus from "./components/TokenSaverStatus"; import FreeProvidersGrid from "./components/FreeProvidersGrid"; @@ -39,6 +41,9 @@ export default function OverviewClient() { {/* Hero KPI cards */} + {/* Dollar Savings */} + + {/* Token Saver status */} + + {/* Provider Performance Leaderboard */} + ); } diff --git a/src/app/(dashboard)/dashboard/overview/components/LeaderboardTable.js b/src/app/(dashboard)/dashboard/overview/components/LeaderboardTable.js new file mode 100644 index 0000000..b6a83e7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/overview/components/LeaderboardTable.js @@ -0,0 +1,201 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import Link from "next/link"; +import Card from "@/shared/components/Card"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { getProviderIconPath } from "@/shared/utils/providerIcon"; + +function formatMs(ms) { + if (ms == null) return "—"; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatCost(cost) { + if (cost == null || cost === 0) return "$0.00"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} + +function formatTokens(n) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; + return String(n); +} + +const PERIODS = [ + { value: "24h", label: "24h" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, +]; + +const COLUMNS = [ + { key: "requests", label: "Requests", sortable: true }, + { key: "totalTokens", label: "Tokens", sortable: true }, + { key: "avgTtft", label: "TTFT", sortable: true }, + { key: "p95Latency", label: "P95", sortable: true }, + { key: "successRate", label: "Success", sortable: true }, + { key: "cost", label: "Cost", sortable: true }, +]; + +export default function LeaderboardTable() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [period, setPeriod] = useState("7d"); + const [sortKey, setSortKey] = useState("requests"); + const [sortDir, setSortDir] = useState("desc"); + + useEffect(() => { + setLoading(true); + fetch(`/api/usage/leaderboard?period=${period}`) + .then((r) => r.json()) + .then((d) => { if (!d.error) setData(d.leaderboard || []); }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [period]); + + const sorted = useMemo(() => { + const copy = [...data]; + copy.sort((a, b) => { + const av = a[sortKey] ?? -1; + const bv = b[sortKey] ?? -1; + return sortDir === "desc" ? bv - av : av - bv; + }); + return copy; + }, [data, sortKey, sortDir]); + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir((d) => (d === "desc" ? "asc" : "desc")); + } else { + setSortKey(key); + setSortDir("desc"); + } + }; + + return ( + + {PERIODS.map((p) => ( + + ))} + + } + > + {loading ? ( +
+ progress_activity + Loading leaderboard... +
+ ) : sorted.length === 0 ? ( +
+ leaderboard + No usage data for this period +
+ ) : ( +
+ + + + + {COLUMNS.map((col) => ( + + ))} + + + + {sorted.map((row, i) => { + const successColor = + row.successRate >= 95 + ? "text-emerald-500" + : row.successRate >= 80 + ? "text-amber-500" + : "text-red-500"; + + return ( + + + + + + + + + + ); + })} + +
+ Provider + col.sortable && handleSort(col.key)} + className={`px-3 py-2 text-[10px] font-medium uppercase tracking-wide ${ + col.sortable ? "cursor-pointer select-none hover:text-text-primary" : "" + } ${sortKey === col.key ? "text-primary" : "text-text-muted"}`} + > + {col.label} + {sortKey === col.key && ( + + {sortDir === "desc" ? "arrow_downward" : "arrow_upward"} + + )} +
+ + + {i + 1} + +
+ +
+ + {row.displayName} + + +
+ {row.requests.toLocaleString()} + + {formatTokens(row.totalTokens)} + + {formatMs(row.avgTtft)} + + {formatMs(row.p95Latency)} + + {row.successRate.toFixed(1)}% + + {formatCost(row.cost)} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/overview/components/SavingsCard.js b/src/app/(dashboard)/dashboard/overview/components/SavingsCard.js new file mode 100644 index 0000000..8c6a359 --- /dev/null +++ b/src/app/(dashboard)/dashboard/overview/components/SavingsCard.js @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import Card from "@/shared/components/Card"; + +function formatDollars(amount) { + if (amount === 0) return "$0.00"; + if (amount >= 1000) return `$${(amount / 1000).toFixed(1)}K`; + if (amount >= 1) return `$${amount.toFixed(2)}`; + return `$${amount.toFixed(4)}`; +} + +const MECHANISM_META = [ + { key: "rtk", label: "RTK", icon: "compress", color: "#3B82F6" }, + { key: "headroom", label: "Headroom", icon: "expand_less", color: "#10B981" }, + { key: "pxpipe", label: "Pxpipe", icon: "image_search", color: "#8B5CF6" }, + { key: "cache", label: "Cache", icon: "cached", color: "#F59E0B" }, + { key: "caveman", label: "Caveman", icon: "short_text", color: "#EF4444" }, + { key: "ponytail", label: "Ponytail", icon: "code", color: "#EC4899" }, +]; + +export default function SavingsCard({ data }) { + const [expanded, setExpanded] = useState(false); + + const costSaved = data?.costSavedLifetime || 0; + const byMechanism = data?.costSavedByMechanism || {}; + const tokensSaved = data?.tokensSavedLifetime || 0; + + // Calculate "without ExtremeRouter" cost = actual cost + saved cost + const actualCost = data?.totalCost || 0; + const withoutCost = actualCost + costSaved; + + return ( + + {/* Subtle gradient background */} +
+ +
+
+ {/* Hero icon */} +
+ + payments + +
+ +
+

+ Total Dollar Savings +

+

+ {formatDollars(costSaved)} +

+

+ {tokensSaved.toLocaleString()} tokens saved across all mechanisms +

+
+
+ + {/* Comparison badge */} +
+
+ Without ER: + + {formatDollars(withoutCost)} + +
+
+ With ER: + + {formatDollars(actualCost)} + +
+
+
+ + {/* Expandable mechanism breakdown */} + + + {expanded && ( +
+ {MECHANISM_META.map((mech) => { + const amount = byMechanism[mech.key] || 0; + const pct = costSaved > 0 ? (amount / costSaved) * 100 : 0; + return ( +
+
+ + {mech.icon} + +
+
+

+ {mech.label} +

+

+ {formatDollars(amount)} ({pct.toFixed(0)}%) +

+
+
+ ); + })} +
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/InxoraProfile.js b/src/app/(dashboard)/dashboard/providers/[id]/InxoraProfile.js new file mode 100644 index 0000000..557aa0b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/InxoraProfile.js @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Badge } from "@/shared/components"; + +// InxoraProfile — shows the connected InxoraStudio Labs user's name, email, +// plan, and API key. Fetches from /api/providers/[id]/inxora-profile on mount. +export default function InxoraProfile({ connectionId }) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!connectionId) return; + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/providers/${connectionId}/inxora-profile`, { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + if (!cancelled) setProfile(data); + } catch { + // non-fatal + } + if (!cancelled) setLoading(false); + })(); + return () => { cancelled = true; }; + }, [connectionId]); + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!profile) return null; + + return ( +
+ {/* Avatar */} +
+ {profile.name?.charAt(0)?.toUpperCase() || "I"} +
+ + {/* Info */} +
+
+

{profile.name}

+ {profile.email && ( + {profile.email} + )} +
+
+ {profile.plan && ( + + {profile.plan} + + )} + {profile.isActive === false && ( + Inactive + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js index 6b94a3b..b86bde8 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -19,6 +19,12 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test : undefined; // Whether this model supports reasoning — gating the thinking picker. + // Note: server-side getThinkingLevels() (thinkingLevels.js) is the + // authoritative gate for which levels are valid; the picker here is a + // client-side convenience. The request translator normalizes any invalid + // suffix via resolveKiroModelIntent + applyKiroThinkingOverride, so even if + // a user manually copies a "(level)" suffix for an unsupported model, the + // upstream request stays clean. const supportsThinking = !!caps?.reasoning; // The text that gets copied to clipboard. When a thinking level is selected diff --git a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js deleted file mode 100644 index 606db9e..0000000 --- a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import { useState } from "react"; -import PropTypes from "prop-types"; -import { Button } from "@/shared/components"; -import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; - -function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { - const borderColor = testStatus === "ok" - ? "border-green-500/40" - : testStatus === "error" - ? "border-red-500/40" - : "border-border"; - - const iconColor = testStatus === "ok" - ? "#22c55e" - : testStatus === "error" - ? "#ef4444" - : undefined; - - return ( -
- - {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} - - -
-

{modelId}

- -
- {fullModel} -
- - - {copied === `model-${modelId}` ? "Copied!" : "Copy"} - -
- {onTest && ( -
- - - {isTesting ? "Testing..." : "Test"} - -
- )} -
-
- - {/* Delete button */} - -
- ); -} - -PassthroughModelRow.propTypes = { - modelId: PropTypes.string.isRequired, - fullModel: PropTypes.string.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - onTest: PropTypes.func, - testStatus: PropTypes.oneOf(["ok", "error"]), - isTesting: PropTypes.bool, -}; - -export default function PassthroughModelsSection({ providerAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel }) { - const [newModel, setNewModel] = useState(""); - const [adding, setAdding] = useState(false); - - const allModels = getProviderCustomModelRows({ - customModels, - modelAliases, - providerAlias, - type: "llm", - }); - - const handleAdd = async () => { - if (!newModel.trim() || adding) return; - const modelId = newModel.trim(); - - if (allModels.some((model) => model.id === modelId)) { - alert("Model already exists for this provider."); - return; - } - - setAdding(true); - try { - await onAddCustomModel(modelId); - setNewModel(""); - } catch (error) { - console.log("Error adding model:", error); - } finally { - setAdding(false); - } - }; - - return ( -
-

- OpenRouter supports any model. Add models and create aliases for quick access. -

- - {/* Add new model */} -
-
- - setNewModel(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder="anthropic/claude-3-opus" - className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> -
- -
- - {/* Models list */} - {allModels.length > 0 && ( -
- {allModels.map(({ id, fullModel, alias, source }) => ( - source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} - /> - ))} -
- )} -
- ); -} - -PassthroughModelsSection.propTypes = { - providerAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - customModels: PropTypes.arrayOf(PropTypes.object), - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - onAddCustomModel: PropTypes.func.isRequired, - onDeleteCustomModel: PropTypes.func.isRequired, -}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsCard.js b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsCard.js index 4c3a40c..010280b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsCard.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsCard.js @@ -11,6 +11,7 @@ import VaultPoolBadge from "../VaultPoolBadge"; import FreeBuffProfile from "../FreeBuffProfile"; import V0Profile from "../V0Profile"; import QwenCloudProfile from "../QwenCloudProfile"; +import InxoraProfile from "../InxoraProfile"; import ZenmuxPlanSelector from "../ZenmuxPlanSelector"; import NoAuthProxyCard from "@/shared/components/NoAuthProxyCard"; @@ -116,6 +117,9 @@ export default function ConnectionsCard({ {providerId === "qwencloud" && connections.length > 0 && ( )} + {providerId === "inxorastudio-web" && connections.length > 0 && ( + + )}
{/* Toolbar: wraps on mobile, row on desktop */} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index cd57d24..25a4d15 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -429,12 +429,16 @@ export default function ProviderDetailPage() { fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); - // Fetch suggested models from provider's public API (if configured) + // Fetch suggested models from provider's public API (if configured). + // For key-gated catalogs (hcnsec/forge/tokenrouter), pass the first active + // connectionId so the server can authenticate the /v1/models request without + // exposing the raw API key client-side. useEffect(() => { - const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher; + const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId])?.modelsFetcher; if (!fetcher) return; - fetchSuggestedModels(fetcher).then(setSuggestedModels); - }, [providerId]); + const activeConn = connections.find((c) => c.isActive !== false); + fetchSuggestedModels(fetcher, { connectionId: activeConn?.id }).then(setSuggestedModels); + }, [providerId, connections]); const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { const fullModel = `${providerAliasOverride}/${modelId}`; diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCardV2.js b/src/app/(dashboard)/dashboard/providers/components/ProviderCardV2.js deleted file mode 100644 index b8e8687..0000000 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCardV2.js +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import Link from "next/link"; -import PropTypes from "prop-types"; -import ProviderIcon from "@/shared/components/ProviderIcon"; -import { Badge, Toggle } from "@/shared/components"; -import { cn } from "@/shared/utils/cn"; -import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; -import { getProviderIconPath } from "@/shared/utils/providerIcon"; - -/** - * Unified provider card — handles ALL provider variants: - * OAuth, API Key, Cookie, Compatible (OpenAI/Anthropic), noAuth. - * Replaces the old duplicated ProviderCard + ApiKeyProviderCard. - */ -export default function ProviderCardV2({ - providerId, - provider, - stats, - onToggle, - isNoAuth = false, - comingSoon = false, - isNew = false, -}) { - const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); - const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); - const { connected, error, errorCode, errorTime, allDisabled } = stats; - - const iconPath = getProviderIconPath(providerId, provider.apiType); - - return ( -
- -
7 ? provider.color : provider.color + "15"}` }} - > - -
-
-

- {provider.name} - {isNew && ( - NEW - )} -

-
- {allDisabled ? ( - - - pause_circle - Disabled - - - ) : isNoAuth && connected === 0 && error === 0 ? ( - Ready - ) : connected > 0 ? ( - {connected} Connected - ) : null} - {error > 0 && ( - - {errorCode ? `${error} Error (${errorCode})` : `${error} Error`} - - )} - {connected === 0 && error === 0 && !isNoAuth && stats.total === 0 && ( - No connections - )} - {comingSoon && ( - - - schedule - Coming Soon - - - )} - {isCompatible && ( - - {provider.apiType === "responses" ? "Responses" : "Chat"} - - )} - {isAnthropicCompatible && ( - Messages - )} - {errorTime && {errorTime}} -
-
- -
- {stats.total > 0 && onToggle && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onToggle(!allDisabled ? false : true); - }} - > - {}} /> -
- )} -
-
- ); -} - -ProviderCardV2.propTypes = { - providerId: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - color: PropTypes.string, - textIcon: PropTypes.string, - apiType: PropTypes.string, - }).isRequired, - stats: PropTypes.shape({ - connected: PropTypes.number, - error: PropTypes.number, - total: PropTypes.number, - errorCode: PropTypes.string, - errorTime: PropTypes.string, - allDisabled: PropTypes.bool, - }).isRequired, - onToggle: PropTypes.func, - isNoAuth: PropTypes.bool, - comingSoon: PropTypes.bool, -}; diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSection.js b/src/app/(dashboard)/dashboard/providers/components/ProviderSection.js deleted file mode 100644 index 97dc07b..0000000 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderSection.js +++ /dev/null @@ -1,90 +0,0 @@ -"use client"; - -import PropTypes from "prop-types"; -import { Button, EmptyState } from "@/shared/components"; - -/** - * Collapsible provider section — header with toggle + count + Test All button, - * body grid with cards. Auto-expands when searching. - */ -export default function ProviderSection({ - title, - icon, - count, - connectedCount, - isExpanded, - onToggle, - isSearching, - onTestAll, - testingMode, - testModeKey, - children, - hasContent = true, - emptyTitle = "No providers in this category", - emptyDescription = "Providers you add will appear here.", -}) { - const showBody = isExpanded || isSearching; - - return ( -
-
- - {onTestAll && hasContent && ( - - )} -
- {showBody && ( - hasContent ? ( -
- {children} -
- ) : ( - - ) - )} -
- ); -} - -ProviderSection.propTypes = { - title: PropTypes.string.isRequired, - icon: PropTypes.string, - count: PropTypes.number.isRequired, - connectedCount: PropTypes.number, - isExpanded: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, - isSearching: PropTypes.bool, - onTestAll: PropTypes.func, - testingMode: PropTypes.string, - testModeKey: PropTypes.string, - children: PropTypes.node, - hasContent: PropTypes.bool, - emptyTitle: PropTypes.string, - emptyDescription: PropTypes.string, -}; diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSummary.js b/src/app/(dashboard)/dashboard/providers/components/ProviderSummary.js deleted file mode 100644 index e003f1e..0000000 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderSummary.js +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import PropTypes from "prop-types"; -import { Button } from "@/shared/components"; - -/** - * Compact summary band above the provider sections: - * total providers · connected · errors + global Test All button. - */ -export default function ProviderSummary({ - totalProviders, - connectedProviders, - errorCount, - onTestAll, - testingMode, -}) { - return ( -
-
-
- dns - {totalProviders} - providers -
-
- - {connectedProviders} - connected -
- {errorCount > 0 && ( -
- - {errorCount} - errors -
- )} -
- {onTestAll && ( - - )} -
- ); -} - -ProviderSummary.propTypes = { - totalProviders: PropTypes.number, - connectedProviders: PropTypes.number, - errorCount: PropTypes.number, - onTestAll: PropTypes.func, - testingMode: PropTypes.string, -}; diff --git a/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/ProviderLimitCard.js deleted file mode 100644 index 5e07f8f..0000000 --- a/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/ProviderLimitCard.js +++ /dev/null @@ -1,187 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Card from "@/shared/components/Card"; -import ProviderIcon from "@/shared/components/ProviderIcon"; -import Badge from "@/shared/components/Badge"; -import { getProviderIconPath } from "@/shared/utils/providerIcon"; -import QuotaProgressBar from "./QuotaProgressBar"; -import { calculatePercentage } from "./utils"; - -const planVariants = { - free: "default", - pro: "primary", - ultra: "success", - enterprise: "info", -}; - -export default function ProviderLimitCard({ - provider, - name, - plan, - quotas = [], - message = null, - loading = false, - error = null, - onRefresh, -}) { - const [refreshing, setRefreshing] = useState(false); - - const handleRefresh = async () => { - if (!onRefresh || refreshing) return; - - setRefreshing(true); - try { - await onRefresh(); - } finally { - setRefreshing(false); - } - }; - - // Get provider info from config - const getProviderColor = () => { - const colors = { - github: "#000000", - antigravity: "#4285F4", - codex: "#10A37F", - kiro: "#FF9900", - qoder: "#EC4899", - claude: "#D97757", - }; - return colors[provider?.toLowerCase()] || "#6B7280"; - }; - - const providerColor = getProviderColor(); - const planVariant = planVariants[plan?.toLowerCase()] || "default"; - - return ( - - {/* Header */} -
-
- {/* Provider Logo */} -
- -
- -
-

- {name || provider} -

- {plan && ( - - {plan} - - )} -
-
- - {/* Refresh Button */} - -
- - {/* Loading State */} - {loading && ( -
-
-
-
-
-
-
-
-
-
- )} - - {/* Error State */} - {!loading && error && ( -
-
- - error - -

{error}

-
-
- )} - - {/* Info Message (for providers without API) */} - {!loading && !error && message && ( -
-
- - info - -

- {message} -

-
-
- )} - - {/* Quota Progress Bars */} - {!loading && !error && !message && quotas?.length > 0 && ( -
- {quotas.map((quota, index) => { - // For Antigravity, use remainingPercentage if available, otherwise calculate - const percentage = - quota.remainingPercentage !== undefined - ? Math.round(((quota.total - quota.used) / quota.total) * 100) - : calculatePercentage(quota.used, quota.total); - const unlimited = quota.total === 0 || quota.total === null; - - return ( - - ); - })} -
- )} - - {/* Empty State */} - {!loading && !error && !message && quotas?.length === 0 && ( -
- - data_usage - -

No quota data available

-
- )} - - ); -} diff --git a/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/utils.js index 98780c6..82cf874 100644 --- a/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/quota/components/ProviderLimits/utils.js @@ -521,6 +521,45 @@ export function parseQuotaData(provider, data) { } break; } + case "infron": { + // Infron AI — prepaid credit balance from /v1/balance. + // Single wallet row; total = current balance, no cap. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + remaining: quota.remaining, + remainingPercentage: quota.remainingPercentage, + resetAt: quota.resetAt || null, + recurring: false, + unlimited: false, + }); + }); + } + break; + } + case "grok-web": { + // Grok Web (Subscription) — per-tier rate-limits (Fast/Thinking/Heavy) + // from grok.com/rest/rate-limits. Each quota row shows + // remaining/total queries in the rolling 2-hour window. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + remaining: quota.remaining, + remainingPercentage: quota.remainingPercentage, + resetAt: quota.resetAt || null, + recurring: true, + unlimited: false, + }); + }); + } + break; + } default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/api/dashboard/stream/route.js b/src/app/api/dashboard/stream/route.js new file mode 100644 index 0000000..9793c78 --- /dev/null +++ b/src/app/api/dashboard/stream/route.js @@ -0,0 +1,101 @@ +import { statsEmitter } from "@/lib/usageDb"; +import { breakerEmitter } from "open-sse/services/circuitBreaker.js"; +import { healthEmitter } from "open-sse/services/healthMonitor.js"; +import { getMeta } from "@/lib/db/helpers/metaStore"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/dashboard/stream — Unified SSE stream for the Live Dashboard. + * + * Subscribes to three event sources: + * - statsEmitter → usage updates (request count, tokens, cost deltas) + * - breakerEmitter → circuit breaker state changes (provider down/recovered) + * - healthEmitter → provider health updates (success rate, latency) + * + * Emits an initial snapshot on connect, then live delta events. + * 25s keepalive prevents proxy/load-balancer idle timeout. + */ +export async function GET() { + const encoder = new TextEncoder(); + const state = { closed: false, keepalive: null, onStats: null, onBreaker: null, onHealth: null }; + + const stream = new ReadableStream({ + async start(controller) { + // Initial snapshot — send current lifetime totals. + try { + const [requests, saved, costSaved] = await Promise.all([ + getMeta("totalRequestsLifetime", "0"), + getMeta("tokensSavedLifetime", "0"), + getMeta("costSavedLifetime", "0"), + ]); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: "snapshot", + stats: { + totalRequests: parseInt(requests, 10) || 0, + tokensSaved: parseInt(saved, 10) || 0, + costSaved: parseFloat(costSaved) || 0, + }, + })}\n\n`)); + } catch { + // ignore + } + + const emit = (payload) => { + if (state.closed) return; + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); + } catch { + cleanup(); + } + }; + + // Stats updates → usage deltas + state.onStats = (payload) => emit({ type: "usage_update", ...payload }); + statsEmitter.on("update", state.onStats); + + // Breaker changes → provider down/recovered + state.onBreaker = (payload) => emit({ + type: payload.state === "open" ? "provider_down" : "provider_recovered", + provider: payload.provider, + state: payload.state, + failures: payload.failures, + }); + breakerEmitter.on("breaker:update", state.onBreaker); + + // Health changes → health metric update + state.onHealth = (payload) => emit({ type: "health_update", ...payload }); + healthEmitter.on("health:update", state.onHealth); + + // Keepalive + state.keepalive = setInterval(() => { + if (state.closed) { clearInterval(state.keepalive); return; } + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + cleanup(); + } + }, 25000); + }, + + cancel() { + cleanup(); + }, + }); + + function cleanup() { + state.closed = true; + if (state.onStats) statsEmitter.off("update", state.onStats); + if (state.onBreaker) breakerEmitter.off("breaker:update", state.onBreaker); + if (state.onHealth) healthEmitter.off("health:update", state.onHealth); + if (state.keepalive) clearInterval(state.keepalive); + } + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/src/app/api/providers/[id]/inxora-profile/route.js b/src/app/api/providers/[id]/inxora-profile/route.js new file mode 100644 index 0000000..4b3229b --- /dev/null +++ b/src/app/api/providers/[id]/inxora-profile/route.js @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById } from "@/lib/localDb"; + +// GET /api/providers/[id]/inxora-profile +// +// Fetches the InxoraStudio Labs user profile (name, email, plan, API key) +// by calling /api/auth/me with the connection's JWT token. Used to display +// profile info in the provider detail page. +export async function GET(_request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + let token = (connection.apiKey || "").replace(/^Bearer\s+/i, "").replace(/^cookie:\s*/i, "").trim(); + + const res = await fetch("https://labs.inxorastudio.com/api/auth/me", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + Referer: "https://labs.inxorastudio.com/dashboard", + }, + }); + + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ error: "Session expired" }, { status: 401 }); + } + + if (!res.ok) { + return NextResponse.json({ error: `InxoraStudio returned ${res.status}` }, { status: res.status }); + } + + const data = await res.json(); + if (!data?.user) { + return NextResponse.json({ error: "No user in response" }, { status: 401 }); + } + + const u = data.user; + return NextResponse.json({ + name: u.name || u.email?.split("@")[0] || "User", + email: u.email || "", + plan: u.plan || "FREE", + apiKey: u.apiKey || "", + isActive: u.isActive !== false, + }); + } catch (err) { + return NextResponse.json({ error: err?.message || "Failed to fetch profile" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index be5342c..a1a2cf5 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -45,7 +45,7 @@ function sanitize(c) { function isUsageEligible(connection) { return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && ( - connection.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(connection.provider) + connection.authType === "oauth" || connection.authType === "cookie" || USAGE_APIKEY_PROVIDERS.includes(connection.provider) ); } diff --git a/src/app/api/providers/suggested-models/filters.js b/src/app/api/providers/suggested-models/filters.js index 8299f46..d8983da 100644 --- a/src/app/api/providers/suggested-models/filters.js +++ b/src/app/api/providers/suggested-models/filters.js @@ -1,7 +1,52 @@ // Free OpenCode models that don't use the "-free" id suffix const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]; +function openaiStyleMap(models) { + return (Array.isArray(models) ? models : []) + .map((m) => { + const id = m?.id || m?.model || m?.name; + if (!id || typeof id !== "string") return null; + // Skip embedding-only entries when the catalog tags them + const kind = m?.object || m?.type || m?.capabilities?.type; + if (kind === "embedding" || /embed/i.test(id)) return null; + return { + id, + name: m?.name || m?.display_name || m?.displayName || id, + contextLength: m?.context_length || m?.contextLength || m?.max_model_len || undefined, + }; + }) + .filter(Boolean); +} + export const FILTERS = { + // Standard OpenAI /v1/models shape — used by hcnsec, forge, tokenrouter, + // featherless, venice, vercel-ai-gateway, etc. + openai: openaiStyleMap, + + // AgentRouter — /api/pricing returns { data: [{ model_name, ... }] } + agentrouter: (data) => { + const models = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); + return models + .map((m) => ({ + id: m.model_name || m.id || m.name, + name: m.model_name || m.id || m.name, + })) + .filter((m) => m.id); + }, + + // InxoraStudio Labs — /api/ai/models returns { models: [...], plan: "..." } + // with per-model accessibility + chat flags. Keep only accessible chat models. + inxora: (data) => { + const models = Array.isArray(data?.models) ? data.models : (Array.isArray(data) ? data : []); + return models + .filter((m) => m?.accessible !== false && m?.chat !== false) + .map((m) => ({ + id: m.id || m.name, + name: m.displayName || m.name || m.id, + })) + .filter((m) => m.id); + }, + "openrouter-free": (models) => models .filter( diff --git a/src/app/api/providers/suggested-models/route.js b/src/app/api/providers/suggested-models/route.js index c14f70c..2a05630 100644 --- a/src/app/api/providers/suggested-models/route.js +++ b/src/app/api/providers/suggested-models/route.js @@ -1,15 +1,31 @@ import { NextResponse } from "next/server"; +import { getProviderConnectionById } from "@/models"; import { FILTERS } from "./filters.js"; export const dynamic = "force-dynamic"; +/** + * GET /api/providers/suggested-models + * + * Query params: + * url — models endpoint URL (required unless connectionId resolves one) + * type — FILTERS key (required) + * connectionId — optional. When set, the server loads the connection's apiKey + * and sends Authorization: Bearer . The key never leaves + * the server — clients only pass the connection id. + * + * Used by the provider detail page to populate the "suggested models" list for + * providers that expose a public or key-gated /v1/models catalog + * (hcnsec, forge, tokenrouter, featherless, venice, …). + */ export async function GET(request) { const { searchParams } = new URL(request.url); - const url = searchParams.get("url"); + let url = searchParams.get("url"); const type = searchParams.get("type"); + const connectionId = searchParams.get("connectionId"); - if (!url || !type) { - return NextResponse.json({ error: "Missing url or type" }, { status: 400 }); + if (!type) { + return NextResponse.json({ error: "Missing type" }, { status: 400 }); } const filter = FILTERS[type]; @@ -17,8 +33,37 @@ export async function GET(request) { return NextResponse.json({ error: "Unknown filter type" }, { status: 400 }); } + // Resolve auth from connection when provided. API keys stay server-side — + // the client never sees or transmits the raw key (connections are sanitized + // before reaching the browser). + let authHeader = null; + if (connectionId) { + try { + const connection = await getProviderConnectionById(connectionId); + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + const token = connection.apiKey || connection.accessToken; + if (token) { + authHeader = `Bearer ${token}`; + } + } catch { + // Fall through — try unauthenticated fetch + } + } + + if (!url) { + return NextResponse.json({ error: "Missing url" }, { status: 400 }); + } + try { - const res = await fetch(url); + const headers = { Accept: "application/json" }; + if (authHeader) headers.Authorization = authHeader; + + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(12000), + }); if (!res.ok) { return NextResponse.json({ data: [] }); } diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index b9b03cf..8a6265d 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -634,6 +634,37 @@ export async function POST(request) { break; } + case "inxorastudio-web": { + // Bearer JWT from labs.inxorastudio.com dashboard. Validate via + // /api/auth/me which returns user data for valid tokens. + let token = apiKey.replace(/^Bearer\s+/i, "").replace(/^cookie:\s*/i, "").trim(); + try { + const res = await fetch("https://labs.inxorastudio.com/api/auth/me", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + Referer: "https://labs.inxorastudio.com/dashboard", + }, + }); + if (res.status === 401 || res.status === 403) { + isValid = false; + error = "Invalid or expired InxoraStudio JWT — re-copy from labs.inxorastudio.com DevTools (Authorization header)."; + } else if (!res.ok) { + isValid = false; + error = `InxoraStudio returned ${res.status}`; + } else { + const data = await res.json().catch(() => null); + isValid = !!(data?.user?.id); + if (!isValid) error = "Session accepted but no user data — token may be expired"; + } + } catch (err) { + isValid = false; + error = err.message || "Failed to validate InxoraStudio token"; + } + break; + } + case "deepseek-web": { // userToken from DeepSeek localStorage (JSON-wrapped {"value":"..."} or bare). let userToken = apiKey; diff --git a/src/app/api/usage/leaderboard/route.js b/src/app/api/usage/leaderboard/route.js new file mode 100644 index 0000000..c3f78f4 --- /dev/null +++ b/src/app/api/usage/leaderboard/route.js @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { getUsageStats, getUsageHistory } from "@/lib/usageDb"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { getProviderNodes } from "@/lib/db/repos/nodesRepo"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/usage/leaderboard?period=7d + * + * Returns provider performance ranking aggregated from usage data. + * Each row: provider, displayName, icon, color, requests, tokens, cost, + * avgTtft, avgLatency, p95Latency, successRate, errorCount. + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const period = searchParams.get("period") || "7d"; + + const [stats, history, nodes] = await Promise.all([ + getUsageStats(period), + getUsageHistory({ period }), + getProviderNodes(), + ]); + + // Build a name/icon/color map from both built-in providers AND custom nodes + // (openai-compatible-xxx, anthropic-compatible-xxx) so the leaderboard + // shows human-readable names instead of UUIDs. + const nodeMap = {}; + for (const node of nodes) { + nodeMap[node.id] = { name: node.name || node.id, color: "#6b7280" }; + } + + // Build TTFT + latency aggregates per provider from raw history. + const ttftByProvider = {}; + const latencyByProvider = {}; + const errorsByProvider = {}; + + for (const row of history) { + const p = row.provider || "unknown"; + if (row.latencyTtftMs > 0) { + if (!ttftByProvider[p]) ttftByProvider[p] = []; + ttftByProvider[p].push(row.latencyTtftMs); + } + if (row.latencyTotalMs > 0) { + if (!latencyByProvider[p]) latencyByProvider[p] = []; + latencyByProvider[p].push(row.latencyTotalMs); + } + if (row.status && row.status !== "ok") { + errorsByProvider[p] = (errorsByProvider[p] || 0) + 1; + } + } + + // Merge stats.byProvider with TTFT/latency/error data. + const byProvider = stats.byProvider || {}; + const leaderboard = Object.entries(byProvider) + .map(([id, data]) => { + const info = AI_PROVIDERS[id] || nodeMap[id] || {}; + const ttfts = ttftByProvider[id] || []; + const latencies = latencyByProvider[id] || []; + const total = data.requests || 0; + const errors = errorsByProvider[id] || 0; + + const sortedLat = [...latencies].sort((a, b) => a - b); + const p95Idx = Math.min(sortedLat.length - 1, Math.floor(sortedLat.length * 0.95)); + + return { + provider: id, + displayName: info.name || id, + icon: info.icon || "smart_toy", + color: info.color || "#6b7280", + requests: total, + promptTokens: data.promptTokens || 0, + completionTokens: data.completionTokens || 0, + totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0), + cost: data.cost || 0, + avgTtft: ttfts.length > 0 ? Math.round(ttfts.reduce((a, b) => a + b, 0) / ttfts.length) : null, + avgLatency: latencies.length > 0 ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null, + p95Latency: sortedLat.length > 0 ? sortedLat[p95Idx] : null, + successRate: total > 0 ? ((total - errors) / total) * 100 : 100, + errorCount: errors, + }; + }) + .sort((a, b) => b.requests - a.requests); + + return NextResponse.json({ leaderboard, period }); + } catch (error) { + console.error("[API] Failed to get leaderboard:", error); + return NextResponse.json({ error: "Failed to fetch leaderboard" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/meta/route.js b/src/app/api/usage/meta/route.js index 16fc703..b5c860a 100644 --- a/src/app/api/usage/meta/route.js +++ b/src/app/api/usage/meta/route.js @@ -17,6 +17,8 @@ export async function GET() { requestsRaw, savedRaw, rtkSaved, headroomSaved, pxpipeSaved, cacheSaved, cavemanSaved, ponytailSaved, cacheHitsRaw, + costSavedRaw, + costRtk, costHeadroom, costPxpipe, costCache, costCaveman, costPonytail, stats, settings, connections, ] = await Promise.all([ getMeta("totalRequestsLifetime", "0"), @@ -28,6 +30,13 @@ export async function GET() { getMeta("tokensSavedLifetime.caveman", "0"), getMeta("tokensSavedLifetime.ponytail", "0"), getMeta("semanticCacheHitsLifetime", "0"), + getMeta("costSavedLifetime", "0"), + getMeta("costSavedLifetime.rtk", "0"), + getMeta("costSavedLifetime.headroom", "0"), + getMeta("costSavedLifetime.pxpipe", "0"), + getMeta("costSavedLifetime.cache", "0"), + getMeta("costSavedLifetime.caveman", "0"), + getMeta("costSavedLifetime.ponytail", "0"), getUsageStats("all"), getSettings(), getProviderConnections(), @@ -90,6 +99,16 @@ export async function GET() { }, semanticCacheHits: parseInt(cacheHitsRaw, 10) || 0, totalCachedTokens: stats.totalCachedTokens || 0, + totalCost: stats.totalCost || 0, + costSavedLifetime: parseFloat(costSavedRaw) || 0, + costSavedByMechanism: { + rtk: parseFloat(costRtk) || 0, + headroom: parseFloat(costHeadroom) || 0, + pxpipe: parseFloat(costPxpipe) || 0, + cache: parseFloat(costCache) || 0, + caveman: parseFloat(costCaveman) || 0, + ponytail: parseFloat(costPonytail) || 0, + }, freeProviders, tokenSaverSettings: { rtkEnabled: settings.rtkEnabled !== false, diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index c61c388..365fd1f 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -2,6 +2,7 @@ import { EventEmitter } from "events"; import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { getMeta, setMeta } from "../helpers/metaStore.js"; +import { getPricingForModel } from "open-sse/providers/pricing.js"; function maskApiKey(key) { if (!key || typeof key !== "string") return null; @@ -325,12 +326,24 @@ export async function saveRequestUsage(entry) { const savedCur = db.get(`SELECT value FROM _meta WHERE key = 'tokensSavedLifetime'`); const savedNext = (savedCur ? parseInt(savedCur.value, 10) : 0) + entry.savedTokens; db.run(`INSERT INTO _meta(key, value) VALUES('tokensSavedLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(savedNext)]); + + // Dollar savings — convert saved tokens to cost using the model's pricing. + // Saved tokens are input-side (prompt compression / cache), so we use the + // input rate. Falls back to 0 if no pricing data for this model. + const savedPricing = getPricingForModel(entry.provider, entry.model); + if (savedPricing?.input) { + const costSaved = entry.savedTokens * (savedPricing.input / 1000000); + const csCur = db.get(`SELECT value FROM _meta WHERE key = 'costSavedLifetime'`); + const csNext = (csCur ? parseFloat(csCur.value) : 0) + costSaved; + db.run(`INSERT INTO _meta(key, value) VALUES('costSavedLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(csNext.toFixed(6))]); + } } // Per-mechanism lifetime breakdown (RTK, Headroom, Pxpipe, Cache, Caveman, Ponytail). // Lets the Overview dashboard attribute savings to each saver. Keys use the // `tokensSavedLifetime.` namespace inside the generic _meta table. if (entry.savedTokensByMechanism && typeof entry.savedTokensByMechanism === "object") { + const mechPricing = getPricingForModel(entry.provider, entry.model); for (const [mech, val] of Object.entries(entry.savedTokensByMechanism)) { const n = Number(val); if (!Number.isFinite(n) || n <= 0) continue; @@ -338,6 +351,15 @@ export async function saveRequestUsage(entry) { const m = db.get(`SELECT value FROM _meta WHERE key = ?`, [metaKey]); const mNext = (m ? parseInt(m.value, 10) : 0) + n; db.run(`INSERT INTO _meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [metaKey, String(mNext)]); + + // Per-mechanism dollar savings + if (mechPricing?.input) { + const mechCost = n * (mechPricing.input / 1000000); + const csKey = `costSavedLifetime.${mech}`; + const csM = db.get(`SELECT value FROM _meta WHERE key = ?`, [csKey]); + const csMNext = (csM ? parseFloat(csM.value) : 0) + mechCost; + db.run(`INSERT INTO _meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [csKey, String(csMNext.toFixed(6))]); + } } } @@ -367,16 +389,24 @@ export async function getUsageHistory(filter = {}) { if (filter.provider) { conds.push("provider = ?"); params.push(filter.provider); } if (filter.model) { conds.push("model = ?"); params.push(filter.model); } + // Fix 4.2: support period filter (convert to startDate like getUsageStats does) + if (filter.period && !filter.startDate) { + const ms = PERIOD_MS[filter.period]; + if (ms) { filter.startDate = Date.now() - ms; } + } if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); } if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); } const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; - const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ${where} ORDER BY id ASC`, params); + // Fix 4.1: include latency columns so the leaderboard can compute TTFT/P95 + const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens, latencyTtftMs, latencyTotalMs FROM usageHistory ${where} ORDER BY id ASC`, params); return rows.map((r) => ({ timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId, apiKeyMasked: maskApiKey(r.apiKey), endpoint: r.endpoint, cost: r.cost, status: r.status, tokens: parseJson(r.tokens, {}), + latencyTtftMs: r.latencyTtftMs || 0, + latencyTotalMs: r.latencyTotalMs || 0, })); } diff --git a/src/lib/oauth/utils/banner.js b/src/lib/oauth/utils/banner.js deleted file mode 100644 index a5743e2..0000000 --- a/src/lib/oauth/utils/banner.js +++ /dev/null @@ -1,63 +0,0 @@ -import figlet from "figlet"; -import gradient from "gradient-string"; -import chalkAnimation from "chalk-animation"; - -/** - * Display banner - */ -export function showBanner() { - const banner = figlet.textSync("LLM Proxy", { - font: "ANSI Shadow", - horizontalLayout: "default", - verticalLayout: "default", - }); - - console.log("\n" + gradient.pastel.multiline(banner)); - console.log(gradient.cristal(" 🚀 OAuth CLI for AI Providers\n")); -} - -/** - * Display simple banner (no animation) - */ -export function showSimpleBanner() { - const banner = figlet.textSync("EP CLI", { - font: "Standard", - horizontalLayout: "default", - }); - console.log(gradient.pastel.multiline(banner)); - console.log(gradient.cristal(" OAuth CLI for AI Providers\n")); -} - -/** - * Display success animation - */ -export async function showSuccess(message) { - return new Promise((resolve) => { - const animation = chalkAnimation.rainbow(`\n✨ ${message}\n`); - setTimeout(() => { - animation.stop(); - resolve(); - }, 1000); - }); -} - -/** - * Display loading animation - */ -export function showLoading(text) { - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let i = 0; - - const interval = setInterval(() => { - process.stdout.write(`\r${frames[i]} ${text}`); - i = (i + 1) % frames.length; - }, 80); - - return { - stop: () => { - clearInterval(interval); - process.stdout.write("\r"); - }, - }; -} - diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index ee929bc..6052898 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -6,6 +6,7 @@ import PropTypes from "prop-types"; import HeaderMenu from "@/shared/components/HeaderMenu"; import HeaderLanguage from "@/shared/components/HeaderLanguage"; import ThemeToggle from "@/shared/components/ThemeToggle"; +import NotificationBell from "@/shared/components/NotificationBell"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; const pageMap = [ @@ -95,6 +96,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
)} + diff --git a/src/shared/components/NotificationBell.js b/src/shared/components/NotificationBell.js new file mode 100644 index 0000000..3224faf --- /dev/null +++ b/src/shared/components/NotificationBell.js @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useNotificationStore } from "@/store/notificationStore"; +import { useDashboardStream } from "@/shared/hooks/useDashboardStream"; + +function timeAgo(ts) { + const diff = Date.now() - ts; + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; +} + +const TYPE_ICONS = { + error: { icon: "error", color: "#EF4444" }, + success: { icon: "check_circle", color: "#10B981" }, + warning: { icon: "warning", color: "#F59E0B" }, + info: { icon: "info", color: "#3B82F6" }, +}; + +export default function NotificationBell() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const lastProcessedTs = useRef(0); + const router = useRouter(); + const { events } = useDashboardStream(); + const { history, unreadCount, markAllRead, clearHistory } = useNotificationStore(); + + // Push server events into notification store — only process NEW events + // that are significant enough to notify the user. + const NOTIFIABLE_TYPES = new Set([ + "provider_down", + "provider_recovered", + "health_degraded", + "rate_limited", + "budget_exceeded", + ]); + + useEffect(() => { + if (events.length === 0) return; + const latest = events[0]; + // Only notify on significant events — skip health_update (too spammy) + // and usage_update (not actionable). health_update fires every 500ms per + // provider on any metric change; only health_degraded (success < 70%) + // is worth surfacing as a notification. + if (!NOTIFIABLE_TYPES.has(latest.type)) return; + if (latest.ts && latest.ts > lastProcessedTs.current) { + lastProcessedTs.current = latest.ts; + useNotificationStore.getState().addServerEvent(latest); + } + }, [events]); + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleOpen = () => { + const willOpen = !open; + setOpen(willOpen); + if (willOpen) { + // Defer markAllRead to next tick to avoid setState-during-render + requestAnimationFrame(() => markAllRead()); + } + }; + + const handleNotificationClick = (item) => { + if (item.provider) { + router.push(`/dashboard/providers/${item.provider}`); + } + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+ {/* Header */} +
+ + Notifications + + {history.length > 0 && ( + + )} +
+ + {/* List */} +
+ {history.length === 0 ? ( +
+ + notifications_off + + No notifications yet +
+ ) : ( + history.map((item) => { + const meta = TYPE_ICONS[item.type] || TYPE_ICONS.info; + return ( + + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/src/shared/hooks/useDashboardStream.js b/src/shared/hooks/useDashboardStream.js new file mode 100644 index 0000000..9f834b1 --- /dev/null +++ b/src/shared/hooks/useDashboardStream.js @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; + +/** + * useDashboardStream — SSE client for the unified dashboard stream. + * + * Connects to /api/dashboard/stream and returns: + * - connected: boolean (live status) + * - stats: latest usage stats snapshot + * - events: array of recent breaker/health events (last 20) + * - reconnect: manual reconnect function + * + * Auto-reconnects on error with exponential backoff. + */ +export function useDashboardStream() { + const [connected, setConnected] = useState(false); + const [stats, setStats] = useState(null); + const [events, setEvents] = useState([]); + const eventSourceRef = useRef(null); + const reconnectTimerRef = useRef(null); + const reconnectAttempts = useRef(0); + + const connect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + const es = new EventSource("/api/dashboard/stream"); + eventSourceRef.current = es; + + es.onopen = () => { + setConnected(true); + reconnectAttempts.current = 0; + }; + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (!data || !data.type) return; + + if (data.type === "snapshot") { + // Fetch full meta on initial snapshot for complete data shape + fetch("/api/usage/meta") + .then((r) => r.json()) + .then((d) => { if (!d.error) setStats(d); }) + .catch(() => {}); + } else if (data.type === "usage_update") { + // Re-fetch meta for fresh totals (the event itself is just a trigger) + fetch("/api/usage/meta") + .then((r) => r.json()) + .then((d) => { if (!d.error) setStats(d); }) + .catch(() => {}); + } else if (data.type === "provider_down" || data.type === "provider_recovered" || data.type === "health_update") { + setEvents((prev) => { + const next = [{ ...data, ts: Date.now() }, ...prev]; + return next.slice(0, 20); + }); + } + } catch { + // ignore malformed + } + }; + + es.onerror = () => { + setConnected(false); + es.close(); + eventSourceRef.current = null; + + // Clear any pending reconnect timer before scheduling a new one + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + + // Exponential backoff: 2s, 4s, 8s, max 30s + const delay = Math.min(2000 * Math.pow(2, reconnectAttempts.current), 30000); + reconnectAttempts.current++; + reconnectTimerRef.current = setTimeout(connect, delay); + }; + }, []); + + useEffect(() => { + connect(); + + return () => { + if (eventSourceRef.current) eventSourceRef.current.close(); + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + }; + }, [connect]); + + const clearEvents = useCallback(() => setEvents([]), []); + + return { connected, stats, events, clearEvents }; +} diff --git a/src/shared/utils/providerIcon.js b/src/shared/utils/providerIcon.js index d7c83f8..ee7c263 100644 --- a/src/shared/utils/providerIcon.js +++ b/src/shared/utils/providerIcon.js @@ -24,6 +24,7 @@ export const SVG_ICON_IDS = new Set([ "zenmux-free", "perplexity-agent", "featherless", "moonshot", "qwencloud", "devin", "forge", "tokenrouter", "qwen-cloud", "qwen-cloud-token-plan", "alibaba", "alibaba-cn", "hcnsec", + "cline", "clinepass", "grok-web", "inxorastudio", "inxorastudio-web", "bynara", "infron", ]); /** diff --git a/src/shared/utils/providerModelsFetcher.js b/src/shared/utils/providerModelsFetcher.js index 3ba1bab..27a369a 100644 --- a/src/shared/utils/providerModelsFetcher.js +++ b/src/shared/utils/providerModelsFetcher.js @@ -2,27 +2,37 @@ // Fetches via backend proxy to avoid CORS issues const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const cache = new Map(); // key: fetcher.url → { data, expiresAt } +const cache = new Map(); // key: cacheKey → { data, expiresAt } /** * Fetch suggested models for a provider using its modelsFetcher config. * Results are cached in-memory for CACHE_TTL_MS. + * * @param {{ url: string, type: string }} fetcher + * @param {{ connectionId?: string }} [options] — pass a connectionId for + * authenticated discovery (hcnsec/forge/tokenrouter gate /v1/models behind + * an API key). The server resolves the key from the connection; the raw key + * never reaches the client. * @returns {Promise>} */ -export async function fetchSuggestedModels(fetcher) { +export async function fetchSuggestedModels(fetcher, options = {}) { if (!fetcher?.url || !fetcher?.type) return []; - const cached = cache.get(fetcher.url); + const cacheKey = options.connectionId + ? `${fetcher.url}::${options.connectionId}` + : fetcher.url; + + const cached = cache.get(cacheKey); if (cached && Date.now() < cached.expiresAt) return cached.data; try { const params = new URLSearchParams({ url: fetcher.url, type: fetcher.type }); + if (options.connectionId) params.set("connectionId", options.connectionId); const res = await fetch(`/api/providers/suggested-models?${params}`); if (!res.ok) return []; const json = await res.json(); const data = json.data ?? []; - cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS }); + cache.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL_MS }); return data; } catch { return []; diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index c87e44a..6cb250c 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -358,7 +358,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Ensure real project ID is available for providers that need it (P0 fix: cold miss) if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) { - const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken); + const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider); if (pid) { refreshedCredentials.projectId = pid; // Persist to DB in background so subsequent requests have it immediately diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index d6fe288..4d0cc31 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -131,7 +131,7 @@ function _refreshProjectId(provider, connectionId, accessToken) { // Evict the stale cached entry so getProjectIdForConnection does a real fetch invalidateProjectId(connectionId); - getProjectIdForConnection(connectionId, accessToken) + getProjectIdForConnection(connectionId, accessToken, provider) .then((projectId) => { if (!projectId) return; updateProviderCredentials(connectionId, { projectId }).catch((err) => { diff --git a/src/store/notificationStore.js b/src/store/notificationStore.js index ea51715..413346c 100644 --- a/src/store/notificationStore.js +++ b/src/store/notificationStore.js @@ -1,14 +1,49 @@ /** * Notification Store — Zustand-based global toast notification system. - * Centralized feedback for dashboard actions. + * Centralized feedback for dashboard actions + server event notifications. + * + * Two layers: + * 1. `notifications[]` — ephemeral toasts (auto-dismiss) + * 2. `history[]` — persistent event log (survives page refresh via localStorage) + * Used by NotificationBell to show recent server events. */ import { create } from "zustand"; -let idCounter = 0; +let idCounter = Date.now(); + +const HISTORY_KEY = "er_notification_history"; +const MAX_HISTORY = 50; + +// Load history from localStorage on init +function loadHistory() { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(HISTORY_KEY); + const parsed = raw ? JSON.parse(raw) : []; + // Seed idCounter above any existing history ids to prevent key collisions + for (const item of parsed) { + if (typeof item.id === "number" && item.id > idCounter) idCounter = item.id; + } + return parsed; + } catch { + return []; + } +} + +function persistHistory(history) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))); + } catch { + // non-fatal + } +} export const useNotificationStore = create((set, get) => ({ notifications: [], + history: loadHistory(), + unreadCount: 0, addNotification: (notification) => { const id = ++idCounter; @@ -20,6 +55,7 @@ export const useNotificationStore = create((set, get) => ({ duration: notification.duration ?? 5000, dismissible: notification.dismissible ?? true, createdAt: Date.now(), + source: notification.source || "user", // "user" or "server" }; set((s) => ({ notifications: [...s.notifications, entry] })); @@ -32,14 +68,92 @@ export const useNotificationStore = create((set, get) => ({ return id; }, + // Add a server-originated notification (from SSE events). + // Shows as a toast AND adds to history + increments unread. + addServerEvent: (event) => { + // Skip events that produce no message (e.g. non-critical health_update). + const message = event.message || formatEventMessage(event); + if (!message) return null; + + const id = ++idCounter; + const entry = { + id, + type: event.type === "provider_down" ? "error" + : event.type === "provider_recovered" ? "success" + : event.type === "health_degraded" ? "warning" + : "info", + message, + title: event.title || formatEventTitle(event), + duration: event.duration ?? 8000, + dismissible: true, + createdAt: Date.now(), + source: "server", + eventType: event.type, + provider: event.provider, + }; + + set((s) => { + const history = [entry, ...s.history].slice(0, MAX_HISTORY); + persistHistory(history); + return { + notifications: [...s.notifications, entry], + history, + unreadCount: s.unreadCount + 1, + }; + }); + + if (entry.duration > 0) { + setTimeout(() => get().removeNotification(id), entry.duration); + } + + return id; + }, + removeNotification: (id) => { set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })); }, clearAll: () => set({ notifications: [] }), + markAllRead: () => set({ unreadCount: 0 }), + + clearHistory: () => { + persistHistory([]); + set({ history: [], unreadCount: 0 }); + }, + success: (message, title) => get().addNotification({ type: "success", message, title }), error: (message, title) => get().addNotification({ type: "error", message, title, duration: 8000 }), warning: (message, title) => get().addNotification({ type: "warning", message, title }), info: (message, title) => get().addNotification({ type: "info", message, title }), })); + +function formatEventTitle(event) { + switch (event.type) { + case "provider_down": return "Provider Down"; + case "provider_recovered": return "Provider Recovered"; + case "health_degraded": return "Health Degraded"; + case "rate_limited": return "Rate Limited"; + case "budget_exceeded": return "Budget Exceeded"; + default: return "Notification"; + } +} + +function formatEventMessage(event) { + const provider = event.provider || "Unknown"; + switch (event.type) { + case "provider_down": return `${provider} circuit breaker opened — requests blocked temporarily.`; + case "provider_recovered": return `${provider} recovered — circuit breaker closed.`; + case "health_degraded": return `${provider} success rate dropped below 70%.`; + case "rate_limited": return `${provider} hit rate limit (429/403).`; + case "budget_exceeded": return `Budget limit reached for ${provider}.`; + case "health_update": + // Health updates are too frequent to be notifications; only surface if + // success rate is critically low (< 50%). + if (event.successRate != null && event.successRate < 0.5) { + return `${provider} is unhealthy — ${Math.round(event.successRate * 100)}% success rate (${event.failures || 0} failures).`; + } + return null; // Returning null means: don't create a notification for this. + default: return null; // Unknown types → don't create notification. + } +} diff --git a/src/store/settingsStore.js b/src/store/settingsStore.js deleted file mode 100644 index e5f0a71..0000000 --- a/src/store/settingsStore.js +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { create } from "zustand"; -import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config"; - -const useSettingsStore = create((set, get) => ({ - settings: null, - loading: false, - error: null, - lastFetched: 0, - - invalidate: () => set({ lastFetched: 0 }), - - // Skips network when cache is fresh; pass {force:true} to override - fetchSettings: async ({ force = false } = {}) => { - const { lastFetched, settings } = get(); - if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings; - set({ loading: true, error: null }); - try { - const res = await fetch("/api/settings"); - const data = await res.json(); - if (res.ok) { - set({ settings: data, loading: false, lastFetched: Date.now() }); - return data; - } - set({ error: data.error, loading: false }); - } catch (e) { - set({ error: "Failed to fetch settings", loading: false }); - } - return null; - }, - - // PATCH server + merge into local cache (no extra fetch needed) - patchSettings: async (patch) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!res.ok) return null; - const updated = await res.json(); - set({ settings: updated, lastFetched: Date.now() }); - return updated; - } catch { - return null; - } - }, -})); - -export default useSettingsStore; diff --git a/tests/translator/real/nvidia-thinking.e2e.test.js b/tests/translator/real/nvidia-thinking.e2e.test.js index 2e2f67e..a2c5d67 100644 --- a/tests/translator/real/nvidia-thinking.e2e.test.js +++ b/tests/translator/real/nvidia-thinking.e2e.test.js @@ -5,7 +5,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { getApiKeys } from "../../../src/lib/db/repos/apiKeysRepo.js"; -const PORT = process.env.NV_E2E_PORT || "20127"; +const PORT = process.env.NV_E2E_PORT || "20128"; const BASE = `http://localhost:${PORT}`; const MODELS = [ "nvidia/minimaxai/minimax-m2.7", diff --git a/tests/unit/kiro-thinking-normalization.test.js b/tests/unit/kiro-thinking-normalization.test.js new file mode 100644 index 0000000..6fa4247 --- /dev/null +++ b/tests/unit/kiro-thinking-normalization.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { resolveKiroModelIntent, applyKiroThinkingOverride, resolveKiroEffortPath } from "../../open-sse/config/kiroConstants.js"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; + +describe("resolveKiroEffortPath", () => { + it("returns kiro-claude for Claude 5 family", () => { + expect(resolveKiroEffortPath("claude-sonnet-5")).toBe("kiro-claude"); + expect(resolveKiroEffortPath("claude-opus-5")).toBe("kiro-claude"); + expect(resolveKiroEffortPath("claude-haiku-5")).toBe("kiro-claude"); + }); + + it("returns null for legacy Claude 4.x (NOT native effort)", () => { + expect(resolveKiroEffortPath("claude-sonnet-4.5")).toBeNull(); + expect(resolveKiroEffortPath("claude-opus-4.6")).toBeNull(); + }); + + it("returns kiro-gpt for GPT-5.6 family", () => { + expect(resolveKiroEffortPath("gpt-5.6-sol")).toBe("kiro-gpt"); + expect(resolveKiroEffortPath("gpt-5.6-terra")).toBe("kiro-gpt"); + expect(resolveKiroEffortPath("gpt-5.6-luna")).toBe("kiro-gpt"); + }); + + it("returns null for non-Claude/GPT families", () => { + expect(resolveKiroEffortPath("glm-5")).toBeNull(); + expect(resolveKiroEffortPath("deepseek-3.2")).toBeNull(); + expect(resolveKiroEffortPath("qwen3-coder-next")).toBeNull(); + expect(resolveKiroEffortPath("MiniMax-M2.5")).toBeNull(); + }); +}); + +describe("resolveKiroModelIntent", () => { + it("strips (high) suffix before resolving synthetic variants", () => { + const intent = resolveKiroModelIntent("claude-sonnet-4.5-thinking-agentic(high)"); + expect(intent.model).toBe("claude-sonnet-4.5-thinking-agentic"); + expect(intent.upstream).toBe("claude-sonnet-4.5"); + expect(intent.agentic).toBe(true); + expect(intent.thinking).toBe(true); + expect(intent.thinkingOverride).toEqual({ mode: "level", level: "high" }); + }); + + it("strips (medium) suffix for glm models", () => { + const intent = resolveKiroModelIntent("glm-5-thinking-agentic(medium)"); + expect(intent.upstream).toBe("glm-5"); + expect(intent.agentic).toBe(true); + expect(intent.thinking).toBe(true); + expect(intent.thinkingOverride).toEqual({ mode: "level", level: "medium" }); + }); + + it("returns null override when no suffix present", () => { + const intent = resolveKiroModelIntent("claude-sonnet-5"); + expect(intent.thinkingOverride).toBeNull(); + expect(intent.upstream).toBe("claude-sonnet-5"); + }); + + it("handles budget suffix like (8192)", () => { + const intent = resolveKiroModelIntent("claude-sonnet-5(8192)"); + expect(intent.thinkingOverride).toEqual({ mode: "budget", budget: 8192 }); + expect(intent.upstream).toBe("claude-sonnet-5"); + }); + + it("handles none/off suffix", () => { + const intent = resolveKiroModelIntent("claude-sonnet-5(none)"); + expect(intent.thinkingOverride).toEqual({ mode: "none" }); + }); +}); + +describe("applyKiroThinkingOverride", () => { + it("returns body unchanged when override is null", () => { + const body = { messages: [] }; + expect(applyKiroThinkingOverride(body, null)).toBe(body); + }); + + it("sets output_config.effort for level override", () => { + const body = { messages: [] }; + const result = applyKiroThinkingOverride(body, { mode: "level", level: "high" }); + expect(result.output_config).toEqual({ effort: "high" }); + }); + + it("merges with existing output_config", () => { + const body = { messages: [], output_config: { foo: "bar" } }; + const result = applyKiroThinkingOverride(body, { mode: "level", level: "medium" }); + expect(result.output_config).toEqual({ foo: "bar", effort: "medium" }); + }); + + it("sets thinking budget for budget override", () => { + const body = { messages: [], reasoning_effort: "high" }; + const result = applyKiroThinkingOverride(body, { mode: "budget", budget: 8192 }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 8192 }); + expect(result.reasoning_effort).toBeUndefined(); + }); + + it("sets effort to mode name for auto/none", () => { + const result = applyKiroThinkingOverride({}, { mode: "auto" }); + expect(result.output_config.effort).toBe("auto"); + }); +}); + +describe("getThinkingLevels for Kiro", () => { + it("does not advertise native intensity for legacy Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-4.5")).toBeNull(); + expect(getThinkingLevels("kiro", "glm-5")).toBeNull(); + expect(getThinkingLevels("kiro", "deepseek-3.2")).toBeNull(); + }); + + it("advertises native levels for Claude 5 family", () => { + const levels = getThinkingLevels("kiro", "claude-sonnet-5"); + expect(levels).toContain("high"); + expect(levels).toContain("medium"); + expect(levels).toContain("low"); + }); + + it("advertises native levels for GPT-5.6 family", () => { + const levels = getThinkingLevels("kiro", "gpt-5.6-sol"); + expect(levels).toContain("high"); + expect(levels).toContain("xhigh"); + expect(levels).toContain("max"); + }); +}); diff --git a/tests/unit/responses-accumulator.test.js b/tests/unit/responses-accumulator.test.js new file mode 100644 index 0000000..4454feb --- /dev/null +++ b/tests/unit/responses-accumulator.test.js @@ -0,0 +1,168 @@ +// Tests for the shared ResponsesAccumulator — correlation, terminal output, +// usage extraction, exactly-once semantics. The accumulator is a standalone +// class (no transitive imports into proxyFetch/ssrfGuard), so we can test it +// directly without the full translator registration chain. +import { describe, it, expect } from "vitest"; +import { ResponsesAccumulator } from "../../open-sse/translator/concerns/responsesAccumulator.js"; + +// ── Accumulator unit tests ──────────────────────────────────────────────── + +describe("ResponsesAccumulator — single tool call", () => { + it("tracks a sequential tool call via output_index", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "get_weather" } }); + acc.ingest("response.function_call_arguments.delta", { output_index: 0, item_id: "fc_1", delta: '{"city":"NYC"}' }); + acc.ingest("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "get_weather", arguments: '{"city":"NYC"}' } }); + acc.ingest("response.completed", { response: { usage: { input_tokens: 10, output_tokens: 5 } } }); + + expect(acc.hasTools).toBe(true); + expect(acc.status).toBe("completed"); + expect(acc.usage).toEqual({ input_tokens: 10, output_tokens: 5, total_tokens: 15, cached_tokens: 0 }); + expect(acc.toolCallIndexFor(0)).toBe(0); + expect(acc.output).toHaveLength(1); + expect(acc.output[0]).toMatchObject({ type: "function_call", name: "get_weather", call_id: "call_1", arguments: '{"city":"NYC"}' }); + }); +}); + +describe("ResponsesAccumulator — parallel/interleaved tool calls", () => { + it("correlates interleaved argument deltas by output_index", () => { + const acc = new ResponsesAccumulator(); + // Two tool calls registered. + acc.ingest("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_a", call_id: "call_a", name: "search" } }); + acc.ingest("response.output_item.added", { output_index: 1, item: { type: "function_call", id: "fc_b", call_id: "call_b", name: "write" } }); + // Interleaved deltas — without correlation these would misroute. + acc.ingest("response.function_call_arguments.delta", { output_index: 0, item_id: "fc_a", delta: '{"q":"test"}' }); + acc.ingest("response.function_call_arguments.delta", { output_index: 1, item_id: "fc_b", delta: '{"path":"/x"}' }); + acc.ingest("response.function_call_arguments.delta", { output_index: 0, item_id: "fc_a", delta: '{"extra":1}' }); + acc.ingest("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_a" } }); + acc.ingest("response.output_item.done", { output_index: 1, item: { type: "function_call", id: "fc_b" } }); + acc.ingest("response.completed", { response: {} }); + + // Tool A at index 0, Tool B at index 1. + expect(acc.toolCallIndexFor(0)).toBe(0); + expect(acc.toolCallIndexFor(1)).toBe(1); + // Arguments routed correctly despite interleaving. + expect(acc.output[0].arguments).toBe('{"q":"test"}{"extra":1}'); + expect(acc.output[1].arguments).toBe('{"path":"/x"}'); + }); + + it("correlates by item_id when output_index is absent", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_x", call_id: "call_x", name: "fn" } }); + // Delta carries only item_id, no output_index. + acc.ingest("response.function_call_arguments.delta", { item_id: "fc_x", delta: '{"a":1}' }); + acc.ingest("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_x" } }); + acc.ingest("response.completed", { response: {} }); + + expect(acc.output[0].arguments).toBe('{"a":1}'); + }); +}); + +describe("ResponsesAccumulator — terminal events", () => { + it("captures completed status + usage", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.completed", { response: { usage: { input_tokens: 100, output_tokens: 50, input_tokens_details: { cached_tokens: 20 } } } }); + expect(acc.status).toBe("completed"); + expect(acc.usage.total_tokens).toBe(150); + expect(acc.usage.cached_tokens).toBe(20); + }); + + it("captures failed status + error", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.failed", { response: { error: { message: "model overload" } } }); + expect(acc.status).toBe("failed"); + expect(acc.error.message).toBe("model overload"); + }); + + it("captures incomplete status", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.incomplete", { response: {} }); + expect(acc.status).toBe("incomplete"); + }); + + it("captures cancelled status", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.cancelled", { response: {} }); + expect(acc.status).toBe("cancelled"); + }); + + it("treats generic error event as failed", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("error", { error: { message: "timeout" } }); + expect(acc.status).toBe("failed"); + }); +}); + +describe("ResponsesAccumulator — exactly-once emit tracking", () => { + it("marks and checks emitted tool calls", () => { + const acc = new ResponsesAccumulator(); + expect(acc.isToolCallEmitted("fc_1")).toBe(false); + acc.markToolCallEmitted("fc_1"); + expect(acc.isToolCallEmitted("fc_1")).toBe(true); + }); +}); + +describe("ResponsesAccumulator — output reconstruction", () => { + it("fills gaps with empty message placeholders", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.output_item.added", { output_index: 0, item: { type: "message", id: "m_1" } }); + // Index 1 missing — gap. + acc.ingest("response.output_item.added", { output_index: 2, item: { type: "function_call", id: "fc_1", call_id: "c1", name: "fn" } }); + acc.ingest("response.completed", { response: {} }); + + expect(acc.output).toHaveLength(3); + expect(acc.output[1]).toMatchObject({ type: "message", content: [] }); + expect(acc.output[2]).toMatchObject({ type: "function_call", name: "fn" }); + }); + + it("ingests full output array from response.completed (terminal-only providers)", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.completed", { + response: { + output: [ + { type: "message", id: "m_1", content: [{ type: "output_text", text: "Hello" }] }, + { type: "function_call", id: "fc_1", call_id: "c1", name: "fn", arguments: '{"a":1}' }, + ], + usage: { input_tokens: 5, output_tokens: 3 }, + }, + }); + expect(acc.output).toHaveLength(2); + expect(acc.output[0]).toMatchObject({ type: "message" }); + expect(acc.output[1]).toMatchObject({ type: "function_call", arguments: '{"a":1}' }); + }); + + it("handles abort mid-stream (no terminal event)", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_1", call_id: "c1", name: "fn" } }); + acc.ingest("response.function_call_arguments.delta", { output_index: 0, delta: '{"partial' }); + // Stream closes — no response.completed. + expect(acc.status).toBeNull(); + // Partial output still available. + expect(acc.output).toHaveLength(1); + expect(acc.output[0].arguments).toBe('{"partial'); + }); +}); + +describe("ResponsesAccumulator — usage edge cases", () => { + it("returns null usage when no response.completed seen", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.output_text.delta", { delta: "hi" }); + expect(acc.usage).toBeNull(); + }); + + it("captures cached_tokens from input_tokens_details", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.completed", { + response: { usage: { input_tokens: 100, output_tokens: 50, input_tokens_details: { cached_tokens: 40 } } }, + }); + expect(acc.usage.cached_tokens).toBe(40); + }); + + it("falls back to cache_read_input_tokens field", () => { + const acc = new ResponsesAccumulator(); + acc.ingest("response.completed", { + response: { usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 30 } }, + }); + expect(acc.usage.cached_tokens).toBe(30); + }); +});