diff --git a/.gitignore b/.gitignore index 552f00b4..edd8c086 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ gitbook/README.md # Refactor backup reference (do not bundle/lint) open-sse.old/ +.graphifyignore +graphify-out/* diff --git a/CHANGELOG.md b/CHANGELOG.md index e9571aca..37b03e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +# v0.5.18 (2026-07-03) + +## Features +- **Usage**: track cached tokens + correct input/output/cache cost (#2209) — hodtien +- **Codex**: show reset credit expiry details (#2290) — Rafli Ahmad Zulfikar +- **NVIDIA**: add new models and capabilities — decolua +- **ClinePass**: add provider support — sternelee + +## Fixes +- **Usage**: dedupe streaming request-details log entries — Qin Li +- **Claude**: drop foreign thinking signatures in passthrough — decolua +- Prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) — KunN-21 +- **Kiro**: route IdC auth to regional CodeWhisperer surface (#2297) — Volodymyr Saakian +- **Kiro**: add Claude Sonnet 5 model support (#2264) — Edison42 +- **Xiaomi-tokenplan**: region selector, key validation, multi-connection (#2251) — MiQieR +- **Translator**: strict Anthropic content block compliance (#2225) — Sahrul Ramadhan Hardiansyah +- **Kimchi**: strip reasoning_content echo to bound multi-turn input tokens — KunN-21 +- **Kimchi**: bump User-Agent to kimchi/0.1.40 (#2256) — Ansh7473 +- **Codebuddy-cn**: strip empty tool_calls arrays to preserve reasoning — zmf +- **Antigravity**: preserve Claude tool delta index (#2223) — Sutarto Jordan Chrisfivo +- **MITM**: generate root CA on server startup (#2228) — Sutarto Jordan Chrisfivo + +# v0.5.15 (2026-06-29) + +## Features +- Add Kimchi OAuth provider — Nant361 +- Refine Qwen vision/video + thinking model patterns — decolua +- Opt-in Codex auto-ping quota keep-alive — Emirhan + +## Fixes +- **Responses**: handle response.done terminal events (#2142) — rifuki +- **Headroom**: skip unsafe responses tool history (#2132) — Sutarto Jordan Chrisfivo +- **Translator**: map mid-conversation system message to user (claude→openai) — decolua +- **Gemini**: normalize contents to prevent 400 invalid_argument (#2192) — warelik +- **Gemini**: backfill thoughtSignature + suppress stream done sentinel — WARELIK +- **Alicode**: preserve cache_control for DashScope providers (#2069) — Rex +- **Antigravity**: strip deprecated/readOnly/writeOnly from tool schemas — iletai, Yudhistira-Official +- **CodeBuddy CN**: show bonus packs as one-time, not monthly-replenishing — whale9820 +- **Kiro**: strip leaked tags from content stream (#2158) — hamsa0x7 +- **Tray**: make Windows context menu DPI-aware — Emirhan +- **Kilocode**: expose full gateway catalog in combo model picker — jellylarper +- **OpenCode**: fix Go GLM — decolua + # v0.5.12 (2026-06-26) ## Features @@ -20,6 +63,7 @@ - Support Gemini native TTS generateContent endpoint — nguyenha935 - Add missing zh-CN endpoint key label (i18n) — weimaozhen - CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex +- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing - Show custom provider models in combo picker — Sapto - Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs - Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo @@ -280,44 +324,4 @@ # v0.4.46 (2026-05-15) ## Breaking Changes -- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL - -# v0.4.44 (2026-05-15) - -## Features -- Add Blackbox provider with `bb` alias (#1143) -- Add Xiaomi token plan provider -- Enhance model select modal UX + modal traffic lights (#1111) -- Default Usage dashboard period to Today (#1141) - -## Fixes -- Fix Cowork model selection and Windows CLI packaging (#1129) -- Update provider name retrieval for compatibility provider (#1135) -- Update JWT_SECRET handling - -# v0.4.41 (2026-05-14) - -## Features -- Add jcode CLI tool integration with auto-configuration (#1047) -- Redesign CLI Tools dashboard: grid layout (1/2/3 cols) + dedicated detail page per tool -- Add drag-and-drop reordering for combo models (#1108) -- Add Today period option to Usage & Analytics (#1063) -- Add DeepSeek V4 Pro effort aliases (#950) - -## Fixes -- fix(autostart): work on nvm + npm 9/10, actually register with launchctl (#1104, fixes #1082) -- Fix Ollama usage not tracked/shown in UI (#1102) -- fix(opencode): preserve DeepSeek reasoning content (#1099, fixes #1093) -- Fix TUI input lag (replace enquirer with native readline, persistent raw mode) -- fix(ui): show API key row actions on mobile (#1112) - -## Improvements -- Sync DeepSeek TUI card style with other CLI tools (badges, layout, manual config modal) -- Add official logos for Amp CLI, jcode, Qwen Code (replace generic icons) -- Resize deepseek-tui icon 1024→128 with padding for visual consistency - -# v0.4.39 (2026-05-14) - -## Fixes -- fix(docker): restore `/app/server.js` (v0.4.38 regression) - +- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL \ No newline at end of file diff --git a/README.md b/README.md index 7f4b0552..a9e169a9 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,10 @@ Default URLs: Cursor
Cursor + + Kimchi
+ Kimchi + diff --git a/cli/package.json b/cli/package.json index f687cc43..f55e7751 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.12", + "version": "0.5.18", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index d98e9d64..57013466 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -78,6 +78,7 @@ const PROVIDER_MODELS = { { id: "grok-code-fast-1" }, ], kr: [ + { id: "claude-sonnet-5" }, { id: "claude-sonnet-4.5" }, { id: "claude-haiku-4.5" }, ], diff --git a/cli/src/cli/tray/tray.ps1 b/cli/src/cli/tray/tray.ps1 index 30562e31..bc55f393 100644 --- a/cli/src/cli/tray/tray.ps1 +++ b/cli/src/cli/tray/tray.ps1 @@ -2,14 +2,65 @@ # IPC: stdin JSON commands, stdout JSON events param([string]$IconPath, [string]$Tooltip) +$ErrorActionPreference = "Stop" + +Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class WinDpiAwareness { + public static IntPtr PerMonitorAwareV2 { get { return new IntPtr(-4); } } + public static IntPtr PerMonitorAware { get { return new IntPtr(-3); } } + + [DllImport("user32.dll")] + public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + [DllImport("user32.dll")] + public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value); + + [DllImport("shcore.dll")] + public static extern int SetProcessDpiAwareness(int value); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); +} +"@ + +function Enable-HighDpiAwareness { + $contexts = @( + [WinDpiAwareness]::PerMonitorAwareV2, + [WinDpiAwareness]::PerMonitorAware + ) + + foreach ($context in $contexts) { + try { + if ([WinDpiAwareness]::SetProcessDpiAwarenessContext($context)) { break } + } catch {} + } + + try { [WinDpiAwareness]::SetProcessDpiAwareness(2) | Out-Null } catch {} + try { [WinDpiAwareness]::SetProcessDPIAware() | Out-Null } catch {} + + foreach ($context in $contexts) { + try { + $previous = [WinDpiAwareness]::SetThreadDpiAwarenessContext($context) + if ($previous -ne [IntPtr]::Zero) { break } + } catch {} + } +} + +Enable-HighDpiAwareness + Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing -$ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + $script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon $script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) $script:notifyIcon.Text = $Tooltip diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index cfd63482..5249a3ec 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -6,6 +6,7 @@ import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js"; +import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js"; // Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63} function sanitizeFunctionName(name) { @@ -177,8 +178,19 @@ export class AntigravityExecutor extends BaseExecutor { if (p.thoughtSignature && !p.functionCall && !p.text) return false; return true; }); - if (role !== c.role || parts?.length !== c.parts?.length) { - return { ...c, role, parts }; + // Gemini 3+ rejects functionCall parts without thoughtSignature. Clients (Claude Code, IDE) + // don't persist thoughtSignature in their history, so backfill the default signature on any + // functionCall part that arrives without one. + const needsBackfill = parts?.some(p => p.functionCall && !p.thoughtSignature) ?? false; + if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) { + return { + ...c, role, + parts: needsBackfill + ? parts.map(p => (p.functionCall && !p.thoughtSignature) + ? { ...p, thoughtSignature: DEFAULT_THINKING_AG_SIGNATURE } + : p) + : parts, + }; } return c; }); diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index ca3061d6..96a230b3 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -226,6 +226,7 @@ export class DefaultExecutor extends BaseExecutor { gemini: () => this.refreshFromGrant(credentials, proxyOptions), kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), + clinepass: () => this.refreshCline(credentials.refreshToken, proxyOptions), "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions) }; @@ -299,7 +300,11 @@ export class DefaultExecutor extends BaseExecutor { const data = payload?.data || payload; const expiresAtIso = data?.expiresAt; const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined; - return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; + let accessToken = data?.accessToken; + if (accessToken && !accessToken.startsWith("workos:")) { + accessToken = `workos:${accessToken}`; + } + return { accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; } async refreshKimiCoding(refreshToken, proxyOptions = null) { diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 77d12fb3..52ae29bb 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -5,6 +5,7 @@ import { GithubExecutor } from "./github.js"; import { IFlowExecutor } from "./iflow.js"; import { QoderExecutor } from "./qoder.js"; import { KiroExecutor } from "./kiro.js"; +import { KimchiExecutor } from "./kimchi.js"; import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; @@ -28,6 +29,7 @@ const executors = { iflow: new IFlowExecutor(), qoder: new QoderExecutor(), kiro: new KiroExecutor(), + kimchi: new KimchiExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), cu: new CursorExecutor(), // Alias for cursor @@ -66,6 +68,7 @@ export { GithubExecutor } from "./github.js"; export { IFlowExecutor } from "./iflow.js"; export { QoderExecutor } from "./qoder.js"; export { KiroExecutor } from "./kiro.js"; +export { KimchiExecutor } from "./kimchi.js"; export { CodexExecutor } from "./codex.js"; export { CursorExecutor } from "./cursor.js"; export { VertexExecutor } from "./vertex.js"; diff --git a/open-sse/executors/kimchi.js b/open-sse/executors/kimchi.js new file mode 100644 index 00000000..83fe6a48 --- /dev/null +++ b/open-sse/executors/kimchi.js @@ -0,0 +1,123 @@ +import { DefaultExecutor } from "./default.js"; +import { getCachedKimchiModelMetadata } from "../services/kimchiModels.js"; + +const TOP_LEVEL_OPENAI_GATEWAY_DROPS = [ + "anthropic_version", + "anthropic_beta", + "client_metadata", + "mcp_servers", + "stop_sequences", + "thinking", + "top_k", +]; + +function systemToText(system) { + if (typeof system === "string") return system; + if (Array.isArray(system)) { + return system + .map((part) => { + if (typeof part === "string") return part; + if (typeof part?.text === "string") return part.text; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +function mergeTopLevelSystem(body) { + if (!body?.system || !Array.isArray(body.messages)) return; + const text = systemToText(body.system).trim(); + if (!text) return; + + const existing = body.messages.find((msg) => msg?.role === "system"); + if (!existing) { + body.messages.unshift({ role: "system", content: text }); + return; + } + + if (typeof existing.content === "string") { + existing.content = `${text}\n\n${existing.content}`; + } else if (Array.isArray(existing.content)) { + existing.content.unshift({ type: "text", text }); + } +} + +function stripMessageArtifacts(body) { + if (!Array.isArray(body?.messages)) return; + for (const msg of body.messages) { + if (!msg || typeof msg !== "object") continue; + delete msg.cache_control; + if (!Array.isArray(msg.content)) continue; + msg.content = msg.content.map((part) => { + if (!part || typeof part !== "object") return part; + const { cache_control, signature, ...clean } = part; + return clean; + }); + } +} + +function stripToolArtifacts(body) { + if (!Array.isArray(body?.tools)) return; + body.tools = body.tools.map((tool) => { + if (!tool || typeof tool !== "object") return tool; + const { cache_control, ...clean } = tool; + return clean; + }); +} + +// Strip `reasoning_content` echoed by clients on assistant messages — but +// only when it's a real thinking block. `DefaultExecutor.transformRequest` +// runs `injectReasoningContent` first and may inject a 1-char placeholder +// (" ") for upstream validation; the placeholder is small (no token cost +// worth stripping) and stripping it would re-trigger upstream to complain +// about missing reasoning on the next turn. Threshold matches the +// placeholder length with a safety margin. +const REASONING_PLACEHOLDER_MAX_LEN = 8; + +export function stripReasoningContent(body) { + if (!Array.isArray(body?.messages)) return; + for (const msg of body.messages) { + if (msg && msg.role === "assistant" && typeof msg.reasoning_content === "string" + && msg.reasoning_content.length > REASONING_PLACEHOLDER_MAX_LEN) { + delete msg.reasoning_content; + } + } +} + +function isAnthropicBackedKimchiModel(model) { + const meta = getCachedKimchiModelMetadata(model); + if (meta?.provider === "anthropic" || meta?.upstreamProvider === "anthropic") return true; + return /(^|[-_/])(?:claude|anthropic)(?:[-_/]|$)/i.test(String(model || "")); +} + +export class KimchiExecutor extends DefaultExecutor { + constructor() { + super("kimchi"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + if (!transformed || typeof transformed !== "object") return transformed; + + mergeTopLevelSystem(transformed); + for (const key of TOP_LEVEL_OPENAI_GATEWAY_DROPS) { + if (transformed[key] !== undefined) delete transformed[key]; + } + delete transformed.system; + + if (isAnthropicBackedKimchiModel(model)) { + delete transformed.reasoning_effort; + delete transformed.reasoning; + delete transformed.thinking; + } + + stripMessageArtifacts(transformed); + stripToolArtifacts(transformed); + stripReasoningContent(transformed); + return transformed; + } +} + +export default KimchiExecutor; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index d40a7833..556e0c72 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -64,9 +64,22 @@ export class KiroExecutor extends BaseExecutor { getOrderedBaseUrls(credentials) { const baseUrls = this.getBaseUrls(); const authMethod = credentials?.providerSpecificData?.authMethod; - const isCodeWhispererSurface = authMethod === "api_key" || authMethod === "external_idp"; + // IAM Identity Center (idc) tokens are AWS SSO access tokens — the same + // family as external_idp/api_key. The kiro.dev gateway rejects them with + // 403 "bearer token invalid", so they must hit the CodeWhisperer + // *.amazonaws.com surface, and in the region the token was minted in + // (the baseUrls are hardcoded us-east-1). + const isCodeWhispererSurface = + authMethod === "api_key" || authMethod === "external_idp" || authMethod === "idc"; if (!isCodeWhispererSurface) return baseUrls; - const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")); + + const region = (credentials?.providerSpecificData?.region || "us-east-1").trim(); + const regionalize = (u) => + region && region !== "us-east-1" && u.includes("amazonaws.com") + ? u.replace(/([a-z]+)\.[a-z0-9-]+\.amazonaws\.com/, `$1.${region}.amazonaws.com`) + : u; + + const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); return amazon.length > 0 ? [...amazon, ...others] : baseUrls; } @@ -122,7 +135,8 @@ export class KiroExecutor extends BaseExecutor { hasReasoningContent: false, reasoningChunkCount: 0, toolCallIndex: 0, - seenToolIds: new Map() + seenToolIds: new Map(), + inThinking: false }; const transformStream = new TransformStream({ @@ -159,7 +173,36 @@ export class KiroExecutor extends BaseExecutor { // Handle assistantResponseEvent if (eventType === "assistantResponseEvent" && event.payload?.content) { - const content = event.payload.content; + let content = event.payload.content; + + // Kiro Claude models can leak blocks into the content stream. + // We strip these literal tags to prevent duplication, as the reasoning + // is already routed correctly via reasoningContentEvent. + if (state.inThinking) { + if (content.includes("")) { + state.inThinking = false; + const after = content.split("
").slice(1).join(""); + content = after.startsWith("\n") ? after.substring(1) : after; + } else { + content = ""; // Drop entirely while inside thinking block + } + } else if (content.includes("")) { + state.inThinking = true; + if (content.includes("")) { + state.inThinking = false; + const before = content.split("")[0]; + const after = content.split("").slice(1).join(""); + content = before + (after.startsWith("\n") ? after.substring(1) : after); + } else { + content = content.split("")[0]; + } + } + + if (!content && state.hasReasoningContent) { + // If we stripped everything, skip emitting an empty content chunk + continue; + } + state.totalContentLength += content.length; const chunk = { @@ -348,6 +391,11 @@ export class KiroExecutor extends BaseExecutor { if (metrics && typeof metrics === 'object') { const inputTokens = metrics.inputTokens || 0; const outputTokens = metrics.outputTokens || 0; + // ponytail: Amazon Q upstream does not expose cache fields today, + // but pick up cache_read_input_tokens / cache_creation_input_tokens + // if the event shape grows them so cost tracking stays accurate. + const cachedTokens = metrics.cacheReadInputTokens || metrics.cache_read_input_tokens || 0; + const cacheCreationInputTokens = metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens || 0; if (inputTokens > 0 || outputTokens > 0) { state.usage = { @@ -355,6 +403,12 @@ export class KiroExecutor extends BaseExecutor { completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens }; + // Kiro is Claude-backed: inputTokens EXCLUDES cache (Claude convention), + // not inclusive like OpenAI's cached_tokens. Emit cache_read_input_tokens + // (not cached_tokens) so canonicalizeUsage takes the Claude fold path and + // correctly adds cache back into prompt_tokens instead of undercharging. + if (cachedTokens > 0) state.usage.cache_read_input_tokens = cachedTokens; + if (cacheCreationInputTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationInputTokens; } } } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 190cbb44..b5cf8a84 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -326,8 +326,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } // Streaming response - const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete }); + const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 0053e9b1..3996f94f 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -1,5 +1,6 @@ import { FORMATS } from "../../translator/formats.js"; import { needsTranslation } from "../../translator/index.js"; +import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js"; import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { createErrorResult } from "../../utils/error.js"; @@ -9,11 +10,65 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; +function parseToolArguments(value) { + if (!value) return {}; + if (typeof value === "object") return value; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function openAICompletionToClaudeMessage(responseBody) { + if (!responseBody?.choices?.[0]) return responseBody; + const choice = responseBody.choices[0]; + const message = choice.message || {}; + const content = []; + + const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || ""; + if (reasoning) { + content.push({ type: "thinking", thinking: reasoning }); + } + if (typeof message.content === "string" && message.content.length > 0) { + content.push({ type: "text", text: message.content }); + } + for (const toolCall of message.tool_calls || []) { + const fn = toolCall.function || {}; + content.push({ + type: "tool_use", + id: toolCall.id || `toolu_${Date.now()}_${content.length}`, + name: fn.name || toolCall.name || "", + input: parseToolArguments(fn.arguments || toolCall.arguments), + }); + } + if (content.length === 0) content.push({ type: "text", text: "" }); + + const usage = responseBody.usage || {}; + return { + id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""), + type: "message", + role: "assistant", + model: responseBody.model || "unknown", + content, + stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE), + stop_sequence: null, + usage: { + input_tokens: usage.prompt_tokens || usage.input_tokens || 0, + output_tokens: usage.completion_tokens || usage.output_tokens || 0, + }, + }; +} + /** * Translate non-streaming response body from provider format → OpenAI format. */ export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) { - if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody; + if (targetFormat === sourceFormat) return responseBody; + if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) { + return openAICompletionToClaudeMessage(responseBody); + } + if (targetFormat === FORMATS.OPENAI) return responseBody; // Gemini / Antigravity if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) { @@ -185,6 +240,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m const translatedResponse = needsTranslation(targetFormat, sourceFormat) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) : responseBody; + const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message"; // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other") if (translatedResponse?.choices?.[0]) { @@ -197,13 +253,17 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m } // Ensure OpenAI-required fields - if (!translatedResponse.object) translatedResponse.object = "chat.completion"; - if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); + if (!isClaudeMessageResponse) { + if (!translatedResponse.object) translatedResponse.object = "chat.completion"; + if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); + } // Strip Azure-specific fields - delete translatedResponse.prompt_filter_results; - if (translatedResponse?.choices) { - for (const choice of translatedResponse.choices) delete choice.content_filter_results; + if (!isClaudeMessageResponse) { + delete translatedResponse.prompt_filter_results; + if (translatedResponse?.choices) { + for (const choice of translatedResponse.choices) delete choice.content_filter_results; + } } if (translatedResponse?.usage) { @@ -213,7 +273,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m // Strip reasoning_content only when content is non-empty. // When content is empty (e.g. thinking models that used all tokens for reasoning), // reasoning_content is the only useful output and must be preserved. - if (translatedResponse?.choices) { + if (!isClaudeMessageResponse && translatedResponse?.choices) { for (const choice of translatedResponse.choices) { if (choice?.message?.reasoning_content && choice.message.content) { delete choice.message.reasoning_content; diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index 0c767c75..aef2c5be 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -1,5 +1,6 @@ import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { COLORS } from "../../utils/stream.js"; +import { canonicalizeUsage } from "../../utils/usageTracking.js"; const OPTIONAL_PARAMS = [ "temperature", "top_p", "top_k", @@ -48,7 +49,8 @@ export function extractUsageFromResponse(responseBody) { return { prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount + cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0, + reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0 }; } @@ -96,8 +98,14 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, msg += `${COLORS.reset}`; console.log(msg); +<<<<<<< HEAD // Normalize to OpenAI token shape for storage (include all token types) const normalized = { +======= + // Canonicalize to one storage convention (prompt_tokens cache-inclusive) so + // cached/cache-creation tokens survive to cost calc + stats. See canonicalizeUsage. + const normalized = canonicalizeUsage(tokens) || { +>>>>>>> 7f436e2792be4fa5a4d1c4d6b8e9bc85eaaa6a3d prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0, completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0, cache_read_input_tokens: cacheRead, diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 8ff6073d..f42a1369 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { +export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) { if (onRequestSuccess) { Promise.resolve() .then(onRequestSuccess) @@ -52,12 +52,30 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou }); } - // Warn when upstream returns unexpected Content-Type for a streaming response. - // This often means the provider returned an HTML error page or plain-text error - // that the SSE transform stream would forward as garbage to the client. + // When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error + // page), piping it through the SSE transform stream causes Next.js + // "failed to pipe response" and crashes the chat router. Read the body, + // pull a short human-readable message from the , sanitize it, and + // return a clean JSON error instead. The message is stripped of HTML tags + // and clamped so untrusted upstream text never reaches the client verbatim + // (the UI may render error.message as HTML). const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase(); if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) { - console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType); + const bodyText = await providerResponse.text().catch(() => ''); + const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i); + const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160); + const shortMsg = sanitizedTitle + || (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`); + const status = providerResponse.status || 502; + console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); + streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); + return { + success: false, + response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), { + status, + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, + }), + }; } const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); @@ -68,7 +86,6 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); - const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; saveRequestDetail(buildRequestDetail({ provider, model, connectionId, latency: { ttft: 0, total: Date.now() - requestStartTime }, diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 23268f08..c73a4bc8 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,7 +71,7 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8 have 1M context + adaptive thinking (override generic claude pattern) + // Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, @@ -82,6 +82,10 @@ export const MODEL_CAPABILITIES = { "claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-sonnet-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-sonnet-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-sonnet-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-sonnet-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, // Gemini image-gen / OpenAI image / xai image variants "gpt-image-1": { imageOutput: true, tools: false }, @@ -98,6 +102,15 @@ export const MODEL_CAPABILITIES = { * Provider-specific capability overrides. Keyed by provider alias/id. */ export const PROVIDER_CAPABILITIES = { + // NVIDIA NIM is OpenAI-compatible → rejects MiniMax/GLM native `thinking` field. + // Force openai reasoning_effort format for its reasoning models. #issue + "nvidia": { + "minimaxai/minimax-m2.7": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 }, + "minimaxai/minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 131072 }, + "z-ai/glm-5.2": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 128000 }, + "deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, + "deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, + }, // CodeBuddy.cn — authoritative per-model metadata from the gateway's model // config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision= // supportsImages). Every model reasons via OpenAI-style reasoning_effort @@ -177,12 +190,16 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } }, { pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, - // ── Qwen (enable_thinking + thinking_budget; QwQ = thinking-only) ─ + // ── Qwen (3.5+ = native vision/video; coder & max = text-only; QwQ = thinking-only) ─ { pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } }, - { pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, + { pattern: "*qwen*omni*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144, maxOutput: 65536 } }, + { pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } }, + { pattern: "*qwen*max*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, + { pattern: "*qwen3.5*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, + { pattern: "*qwen3.6*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, + { pattern: "*qwen3.7*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, { pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } }, { pattern: "*qwen*235b*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } }, - { pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } }, { pattern: "*qwq*", caps: { reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 131072 } }, { pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } }, diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 9e767a80..2fb0cfc9 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -279,7 +279,10 @@ export function calculateCostFromTokens(tokens, pricing) { const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0; const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; - const nonCachedInput = Math.max(0, inputTokens - cachedTokens); + const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; + // prompt_tokens is cache-inclusive (see canonicalizeUsage): cached + cache_creation + // are subsets, so subtract both to avoid charging them at the full input rate. + const nonCachedInput = Math.max(0, inputTokens - cachedTokens - cacheCreationTokens); cost += nonCachedInput * (pricing.input / 1000000); @@ -295,7 +298,6 @@ export function calculateCostFromTokens(tokens, pricing) { cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000); } - const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; if (cacheCreationTokens > 0) { cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000); } diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js index ac98cb2d..b2eca7d8 100644 --- a/open-sse/providers/registry/alicode-intl.js +++ b/open-sse/providers/registry/alicode-intl.js @@ -16,6 +16,7 @@ export default { transport: { baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", headers: {}, + quirks: { preserveCacheControl: true }, }, models: [ { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, diff --git a/open-sse/providers/registry/alicode.js b/open-sse/providers/registry/alicode.js index 5b6a088f..e572c7e7 100644 --- a/open-sse/providers/registry/alicode.js +++ b/open-sse/providers/registry/alicode.js @@ -16,6 +16,7 @@ export default { transport: { baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions", headers: {}, + quirks: { preserveCacheControl: true }, }, models: [ { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js new file mode 100644 index 00000000..702054ac --- /dev/null +++ b/open-sse/providers/registry/clinepass.js @@ -0,0 +1,57 @@ +export default { + id: "clinepass", + priority: 85, + alias: "clinepass", + uiAlias: "clinepass", + display: { + name: "ClinePass", + icon: "vpn_key", + color: "#5B9BD5", + textIcon: "CP", + website: "https://cline.bot", + notice: { + signupUrl: "https://app.cline.bot", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + baseUrl: "https://api.cline.bot/api/v1/chat/completions", + headers: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + hooks: [ + "clineHeaders", + ], + }, + }, + models: [ + { id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" }, + { id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" }, + { id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" }, + { id: "cline-pass/deepseek-v4-pro", name: "DeepSeek V4 Pro (ClinePass)" }, + { id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash (ClinePass)" }, + { id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" }, + { id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" }, + { id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" }, + { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" }, + { id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" }, + ], + oauth: { + appBaseUrl: "https://app.cline.bot", + apiBaseUrl: "https://api.cline.bot", + authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize", + tokenUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", + }, + thinkingConfig: { + options: ["auto", "on", "off"], + defaultMode: "auto", + }, +}; diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 4620c4d3..0d2ddc05 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -40,6 +40,7 @@ export default { }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", + resetCreditsUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", resetCreditsConsumeUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", }, }, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index d0a2fe57..6f0f6826 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -15,85 +15,87 @@ import p12 from "./cerebras.js"; import p13 from "./chutes.js"; import p14 from "./claude.js"; import p15 from "./cline.js"; -import p16 from "./cloudflare-ai.js"; -import p17 from "./codebuddy-cn.js"; -import p18 from "./codex.js"; -import p19 from "./cohere.js"; -import p20 from "./comfyui.js"; -import p21 from "./commandcode.js"; -import p22 from "./coqui.js"; -import p23 from "./cursor.js"; -import p24 from "./deepgram.js"; -import p25 from "./deepseek.js"; -import p26 from "./edge-tts.js"; -import p27 from "./elevenlabs.js"; -import p28 from "./exa.js"; -import p29 from "./fal-ai.js"; -import p30 from "./firecrawl.js"; -import p31 from "./fireworks.js"; -import p32 from "./gemini-cli.js"; -import p33 from "./gemini.js"; -import p34 from "./github.js"; -import p35 from "./gitlab.js"; -import p36 from "./glm-cn.js"; -import p37 from "./glm.js"; -import p38 from "./google-pse.js"; -import p39 from "./google-tts.js"; -import p40 from "./grok-web.js"; -import p41 from "./groq.js"; -import p42 from "./huggingface.js"; -import p43 from "./hyperbolic.js"; -import p44 from "./iflow.js"; -import p45 from "./inworld.js"; -import p46 from "./jina-ai.js"; -import p47 from "./jina-reader.js"; -import p48 from "./kilocode.js"; -import p49 from "./kimi-coding.js"; -import p50 from "./kimi.js"; -import p51 from "./kiro.js"; -import p52 from "./linkup.js"; -import p53 from "./local-device.js"; -import p54 from "./mimo-free.js"; -import p55 from "./minimax-cn.js"; -import p56 from "./minimax.js"; -import p57 from "./mistral.js"; -import p58 from "./mmf.js"; -import p59 from "./nanobanana.js"; -import p60 from "./nebius.js"; -import p61 from "./nvidia.js"; -import p62 from "./ollama-local.js"; -import p63 from "./ollama.js"; -import p64 from "./openai.js"; -import p65 from "./opencode-go.js"; -import p66 from "./opencode.js"; -import p67 from "./openrouter.js"; -import p68 from "./perplexity-web.js"; -import p69 from "./perplexity.js"; -import p70 from "./playht.js"; -import p71 from "./qoder.js"; -import p72 from "./qwen.js"; -import p73 from "./recraft.js"; -import p74 from "./runwayml.js"; -import p75 from "./sdwebui.js"; -import p76 from "./searchapi.js"; -import p77 from "./searxng.js"; -import p78 from "./serper.js"; -import p79 from "./siliconflow.js"; -import p80 from "./stability-ai.js"; -import p81 from "./tavily.js"; -import p82 from "./together.js"; -import p83 from "./topaz.js"; -import p84 from "./tortoise.js"; -import p85 from "./venice.js"; -import p86 from "./vercel-ai-gateway.js"; -import p87 from "./vertex-partner.js"; -import p88 from "./vertex.js"; -import p89 from "./volcengine-ark.js"; -import p90 from "./voyage-ai.js"; -import p91 from "./xai.js"; -import p92 from "./xiaomi-mimo.js"; -import p93 from "./xiaomi-tokenplan.js"; -import p94 from "./youcom.js"; +import p16 from "./clinepass.js"; +import p17 from "./cloudflare-ai.js"; +import p18 from "./codebuddy-cn.js"; +import p19 from "./codex.js"; +import p20 from "./cohere.js"; +import p21 from "./comfyui.js"; +import p22 from "./commandcode.js"; +import p23 from "./coqui.js"; +import p24 from "./cursor.js"; +import p25 from "./deepgram.js"; +import p26 from "./deepseek.js"; +import p27 from "./edge-tts.js"; +import p28 from "./elevenlabs.js"; +import p29 from "./exa.js"; +import p30 from "./fal-ai.js"; +import p31 from "./firecrawl.js"; +import p32 from "./fireworks.js"; +import p33 from "./gemini-cli.js"; +import p34 from "./gemini.js"; +import p35 from "./github.js"; +import p36 from "./gitlab.js"; +import p37 from "./glm-cn.js"; +import p38 from "./glm.js"; +import p39 from "./google-pse.js"; +import p40 from "./google-tts.js"; +import p41 from "./grok-web.js"; +import p42 from "./groq.js"; +import p43 from "./huggingface.js"; +import p44 from "./hyperbolic.js"; +import p45 from "./iflow.js"; +import p46 from "./inworld.js"; +import p47 from "./jina-ai.js"; +import p48 from "./jina-reader.js"; +import p49 from "./kilocode.js"; +import p50 from "./kimchi.js"; +import p51 from "./kimi-coding.js"; +import p52 from "./kimi.js"; +import p53 from "./kiro.js"; +import p54 from "./linkup.js"; +import p55 from "./local-device.js"; +import p56 from "./mimo-free.js"; +import p57 from "./minimax-cn.js"; +import p58 from "./minimax.js"; +import p59 from "./mistral.js"; +import p60 from "./mmf.js"; +import p61 from "./nanobanana.js"; +import p62 from "./nebius.js"; +import p63 from "./nvidia.js"; +import p64 from "./ollama-local.js"; +import p65 from "./ollama.js"; +import p66 from "./openai.js"; +import p67 from "./opencode-go.js"; +import p68 from "./opencode.js"; +import p69 from "./openrouter.js"; +import p70 from "./perplexity-web.js"; +import p71 from "./perplexity.js"; +import p72 from "./playht.js"; +import p73 from "./qoder.js"; +import p74 from "./qwen.js"; +import p75 from "./recraft.js"; +import p76 from "./runwayml.js"; +import p77 from "./sdwebui.js"; +import p78 from "./searchapi.js"; +import p79 from "./searxng.js"; +import p80 from "./serper.js"; +import p81 from "./siliconflow.js"; +import p82 from "./stability-ai.js"; +import p83 from "./tavily.js"; +import p84 from "./together.js"; +import p85 from "./topaz.js"; +import p86 from "./tortoise.js"; +import p87 from "./venice.js"; +import p88 from "./vercel-ai-gateway.js"; +import p89 from "./vertex-partner.js"; +import p90 from "./vertex.js"; +import p91 from "./volcengine-ark.js"; +import p92 from "./voyage-ai.js"; +import p93 from "./xai.js"; +import p94 from "./xiaomi-mimo.js"; +import p95 from "./xiaomi-tokenplan.js"; +import p96 from "./youcom.js"; export default [ p0, @@ -190,5 +192,7 @@ export default [ p91, p92, p93, - p94 + p94, + p95, + p96 ]; diff --git a/open-sse/providers/registry/kilocode.js b/open-sse/providers/registry/kilocode.js index c259ac79..abf6af00 100644 --- a/open-sse/providers/registry/kilocode.js +++ b/open-sse/providers/registry/kilocode.js @@ -36,6 +36,13 @@ export default { { id: "deepseek/deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" }, ], + // Kilo Code proxies the OpenRouter catalog (334 models at time of writing), + // so the hardcoded list above is only a fallback. Surfacing the full catalog + // requires a fetcher + passthroughModels, matching how openrouter.js is set up. + // Without these, only the 8 hardcoded models appear in the combo model picker, + // hiding dynamic models like cohere/north-mini-code:free and poolside/laguna-m.1:free. + modelsFetcher: { url: "https://api.kilo.ai/api/gateway/models", type: "openrouter-free" }, + passthroughModels: true, oauth: { apiBaseUrl: "https://api.kilo.ai", initiateUrl: "https://api.kilo.ai/api/device-auth/codes", diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js new file mode 100644 index 00000000..99facd53 --- /dev/null +++ b/open-sse/providers/registry/kimchi.js @@ -0,0 +1,49 @@ +export default { + id: "kimchi", + priority: 95, + alias: "kimchi", + uiAlias: "kimchi", + display: { + name: "Kimchi", + icon: "restaurant", + color: "#FF521D", + textIcon: "KC", + website: "https://kimchi.dev", + notice: { + signupUrl: "https://app.kimchi.dev", + }, + }, + category: "oauth", + authModes: ["oauth"], + hasOAuth: true, + transport: { + baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions", + format: "openai", + headers: { + "User-Agent": "kimchi/0.1.50", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + models: [ + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "kimi-k2.7", name: "Kimi-K2.7" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "nemotron-3-ultra-fp4", name: "Nemotron 3 Ultra FP4" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + serviceKinds: ["llm", "imageToText"], + oauth: { + webAppUrl: "https://app.kimchi.dev", + validationUrl: "https://api.cast.ai/v1/llm/openai/supported-providers", + userInfoUrl: "https://app.kimchi.dev/api/v1/me", + modelsUrl: "https://llm.kimchi.dev/v1/models/metadata?include_in_cli=true", + }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index fb78a227..12015643 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -42,16 +42,20 @@ export default { }, }, models: [ + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" }, { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + { id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" }, { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + { id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" }, { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, ], diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js index 35a1f768..9522611a 100644 --- a/open-sse/providers/registry/nvidia.js +++ b/open-sse/providers/registry/nvidia.js @@ -20,8 +20,13 @@ export default { validateUrl: "https://integrate.api.nvidia.com/v1/models", }, models: [ - { id: "minimaxai/minimax-m2.7", name: "Minimax M2.7" }, - { id: "z-ai/glm4.7", name: "GLM 4.7" }, + { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, + { id: "minimaxai/minimax-m3", name: "MiniMax M3" }, + { id: "z-ai/glm-5.2", name: "GLM 5.2" }, + { id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, + { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra" }, { id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", kind: "embedding" }, { id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B", params: ["language"], kind: "stt" }, { id: "fastpitch", name: "FastPitch", kind: "tts" }, diff --git a/open-sse/providers/registry/xiaomi-tokenplan.js b/open-sse/providers/registry/xiaomi-tokenplan.js index 55441434..8503d251 100644 --- a/open-sse/providers/registry/xiaomi-tokenplan.js +++ b/open-sse/providers/registry/xiaomi-tokenplan.js @@ -21,6 +21,11 @@ export default { }, category: "apikey", hasProviderSpecificData: true, + regions: [ + { id: "sgp", label: "Singapore (新加坡)" }, + { id: "cn", label: "China (中国大陆)" }, + { id: "ams", label: "Amsterdam (阿姆斯特丹)" }, + ], defaultRegion: "sgp", transport: { baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions", diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js index 1dd7abea..8f3b1f33 100644 --- a/open-sse/rtk/headroom.js +++ b/open-sse/rtk/headroom.js @@ -73,6 +73,14 @@ function maskEndpoint(endpoint) { } } +function hasUnsafeResponsesInputForCompression(body) { + if (!Array.isArray(body?.input)) return false; + return body.input.some((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + return typeof item.type === "string" && item.type !== "message"; + }); +} + // POST messages to Headroom /v1/compress; returns compressed messages + stats or null. async function callCompress(url, messages, model, timeoutMs, compressUserMessages, diagnostics) { const endpoint = buildCompressEndpoint(url); @@ -143,6 +151,10 @@ export async function compressWithHeadroom(body, { enabled, url, model, format, // messages. Translate input -> OpenAI -> compress -> translate back to input so // body.input keeps the Responses contract (the proxy only understands OpenAI). (#1998) if (format === "openai-responses") { + if (hasUnsafeResponsesInputForCompression(body)) { + setDiagnostic(diagnostics, "skipped: openai-responses tool/reasoning input is not safe to compress"); + return null; + } const oai = openaiResponsesToOpenAIRequest(model, body, false); if (!Array.isArray(oai?.messages)) return null; const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js new file mode 100644 index 00000000..4208a4b6 --- /dev/null +++ b/open-sse/services/clinepassModels.js @@ -0,0 +1,63 @@ +import { buildClineHeaders } from "../shared/clineAuth.js"; + +const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +const FETCH_TIMEOUT_MS = 5000; + +/** + * Build request headers for the ClinePass /models endpoint (Cline's upstream API). + * - API keys are sent as plain Bearer tokens. + * - OAuth access tokens must carry the WorkOS `workos:` prefix (handled by buildClineHeaders). + */ +function buildModelListHeaders(token, isApiKey) { + if (isApiKey) { + return { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + } + return buildClineHeaders(token, { Accept: "application/json" }); +} + +/** + * Fetch ClinePass live model catalog from Cline's /models endpoint. + * + * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) + * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + */ +export async function resolveClinepassModels(credentials) { + const isApiKey = Boolean(credentials?.apiKey); + const token = isApiKey ? credentials.apiKey : credentials?.accessToken; + if (!token) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const headers = buildModelListHeaders(token, isApiKey); + + const response = await fetch(CLINEPASS_MODELS_ENDPOINT, { + method: "GET", + headers, + signal: controller.signal, + }); + + if (!response.ok) return null; + + const json = await response.json(); + const rawList = Array.isArray(json) ? json : json?.data; + if (!Array.isArray(rawList)) return null; + + const models = rawList + .filter((m) => typeof m?.id === "string" && m.id.startsWith("cline-pass/")) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + })); + + return models.length ? { models } : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/open-sse/services/kimchiModels.js b/open-sse/services/kimchiModels.js new file mode 100644 index 00000000..15d0936f --- /dev/null +++ b/open-sse/services/kimchiModels.js @@ -0,0 +1,176 @@ +import { createHash } from "crypto"; + +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +export const KIMCHI_API = "https://llm.kimchi.dev"; +export const KIMCHI_USER_AGENT = "kimchi/0.1.40"; + +const FETCH_TIMEOUT_MS = 20_000; +const CACHE_TTL_MS = 5 * 60 * 1000; +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); + +/** @type {Map<string, { expiresAt: number, models: object[], rawModels: object[] }>} */ +const catalogCache = new Map(); +/** @type {Map<string, object>} */ +const metadataByModelId = new Map(); + +function normalizeKimchiEndpoint(endpoint) { + const raw = typeof endpoint === "string" ? endpoint.trim() : ""; + return (raw || KIMCHI_API).replace(/\/+$/, ""); +} + +export function buildKimchiModelsUrl(endpoint) { + return `${normalizeKimchiEndpoint(endpoint)}/v1/models/metadata?include_in_cli=true`; +} + +function readToken(credentials) { + return ( + credentials?.accessToken + || credentials?.apiKey + || credentials?.providerSpecificData?.apiKey + || null + ); +} + +function cacheKey(credentials, endpoint) { + const psd = credentials?.providerSpecificData || {}; + const seed = psd.userId || psd.username || credentials?.refreshToken || readToken(credentials) || "anonymous"; + return createHash("sha256") + .update(`kimchi:${normalizeKimchiEndpoint(endpoint)}:${seed}`) + .digest("hex"); +} + +function toModelKind(inputModalities) { + return Array.isArray(inputModalities) && inputModalities.includes("image") + ? "imageToText" + : "llm"; +} + +export function normalizeKimchiModel(item) { + if (!item || typeof item !== "object") return null; + const id = item.slug || item.id || item.model || item.name; + if (typeof id !== "string" || id.trim() === "") return null; + + const inputModalities = Array.isArray(item.input_modalities) + ? item.input_modalities.filter((value) => value === "text" || value === "image") + : []; + const limits = item.limits && typeof item.limits === "object" ? item.limits : {}; + const contextLength = Number(limits.context_window || item.contextLength || item.context_length) || undefined; + const maxOutputTokens = Number(limits.max_output_tokens || item.maxOutputTokens || item.max_output_tokens) || undefined; + const upstreamProvider = typeof item.provider === "string" ? item.provider : ""; + const reasoning = item.reasoning === true; + const kind = toModelKind(inputModalities); + + const model = { + ...item, + id: id.trim(), + name: String(item.display_name || item.displayName || item.name || id).trim(), + provider: upstreamProvider, + upstreamProvider, + reasoning, + inputModalities, + kind, + type: kind, + capabilities: { + vision: inputModalities.includes("image"), + reasoning, + ...(contextLength ? { contextWindow: contextLength } : {}), + ...(maxOutputTokens ? { maxOutput: maxOutputTokens } : {}), + ...(upstreamProvider ? { upstreamProvider } : {}), + }, + ...(contextLength ? { contextLength } : {}), + ...(maxOutputTokens ? { maxOutputTokens } : {}), + }; + + if (upstreamProvider === "anthropic") { + model.compat = { supportsReasoningEffort: false, cacheControlFormat: "anthropic" }; + } + + return model; +} + +function rememberModels(models) { + for (const model of models || []) { + if (!model?.id) continue; + metadataByModelId.set(model.id, model); + metadataByModelId.set(model.id.toLowerCase(), model); + } +} + +export function getCachedKimchiModelMetadata(modelId) { + if (typeof modelId !== "string" || modelId.trim() === "") return null; + const raw = modelId.includes("/") ? modelId.split("/").pop() : modelId; + return metadataByModelId.get(raw) || metadataByModelId.get(raw.toLowerCase()) || null; +} + +async function fetchKimchiCatalogRaw(token, endpoint, options = {}) { + const url = buildKimchiModelsUrl(endpoint); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error("Kimchi models fetch timeout")), FETCH_TIMEOUT_MS); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + + try { + const response = await proxyAwareFetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": KIMCHI_USER_AGENT, + }, + cache: "no-store", + signal, + }, options.proxyOptions || null); + + if (!response.ok) { + const error = new Error(`Kimchi models ${response.status}: ${response.statusText}`); + error.status = response.status; + error.retryable = RETRYABLE_STATUSES.has(response.status); + throw error; + } + + const data = await response.json(); + return Array.isArray(data?.models) ? data.models : []; + } finally { + clearTimeout(timeout); + } +} + +export async function resolveKimchiModels(credentials, options = {}) { + const token = readToken(credentials); + if (!token) return null; + + const endpoint = credentials?.providerSpecificData?.kimchiEndpoint || options.endpoint || KIMCHI_API; + const key = cacheKey(credentials, endpoint); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > now) return cached; + } + + let rawModels; + try { + rawModels = await fetchKimchiCatalogRaw(token, endpoint, options); + } catch (error) { + options.log?.warn?.("KIMCHI_MODELS", error.message); + return null; + } + + const models = rawModels.map(normalizeKimchiModel).filter(Boolean); + if (models.length === 0) return null; + + rememberModels(models); + const entry = { + expiresAt: Date.now() + CACHE_TTL_MS, + models, + rawModels, + }; + catalogCache.set(key, entry); + return entry; +} + +export function clearKimchiCatalog() { + catalogCache.clear(); + metadataByModelId.clear(); +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index a4cf972a..4c56dc1b 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -5,9 +5,9 @@ import { getGitHubUsage } from "./usage/github.js"; import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; import { getClaudeUsage } from "./usage/claude.js"; -import { getCodexUsage, consumeCodexRateLimitResetCredit } from "./usage/codex.js"; +import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js"; -export { consumeCodexRateLimitResetCredit }; +export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; diff --git a/open-sse/services/usage/codebuddy-cn.js b/open-sse/services/usage/codebuddy-cn.js index 32d15c2c..d355c729 100644 --- a/open-sse/services/usage/codebuddy-cn.js +++ b/open-sse/services/usage/codebuddy-cn.js @@ -109,15 +109,22 @@ export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificD total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize), resetAt: parseResetTime(acc.CycleEndTime), unlimited: false, + // Recurring allowance: the CycleEndTime is the next refresh, not the + // final expiry. The UI must show "Resets in", not "Expires in". + recurring: true, }; }); // Bonus packs: use the lifetime Capacity balance; resetAt is the expiry. + // These are one-shot credits (CycleEndTime == DeductionEndTime), so they + // never replenish — mark recurring:false so the UI shows "Expires in" + // instead of implying a monthly refill. bonuses.forEach((acc, i) => { quotas[`Bonus Pack ${i + 1}`] = { used: num(acc.CapacityUsedPrecise, acc.CapacityUsed), total: num(acc.CapacitySizePrecise, acc.CapacitySize), resetAt: parseResetTime(acc.CycleEndTime), unlimited: false, + recurring: false, }; }); diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js index cfcd5931..960af333 100644 --- a/open-sse/services/usage/codex.js +++ b/open-sse/services/usage/codex.js @@ -8,9 +8,23 @@ import { U, parseResetTime, toFiniteNumber } from "./shared.js"; // Codex (OpenAI) API config const CODEX_CONFIG = { usageUrl: U("codex").url, + resetCreditsUrl: U("codex").resetCreditsUrl, resetCreditsConsumeUrl: U("codex").resetCreditsConsumeUrl, }; +function toIsoDate(value) { + if (!value) return null; + const date = value instanceof Date + ? value + : new Date(typeof value === "number" && value < 1e12 ? value * 1000 : value); + const time = date.getTime(); + return Number.isFinite(time) ? date.toISOString() : null; +} + +function getCodexAccountId(providerSpecificData) { + return providerSpecificData?.workspaceId || providerSpecificData?.accountId || providerSpecificData?.chatgptAccountId || null; +} + function getCodexRateLimitBody(snapshot) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; return snapshot.rate_limit && typeof snapshot.rate_limit === "object" @@ -101,6 +115,48 @@ export async function getCodexUsage(accessToken, proxyOptions = null) { } } +export async function getCodexRateLimitResetCredits(accessToken, proxyOptions = null, providerSpecificData = null) { + if (!accessToken) { + throw new Error("No Codex access token available. Please re-authorize the connection."); + } + + const accountId = getCodexAccountId(providerSpecificData); + const headers = { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "OpenAI-Beta": "codex-1", + "originator": "codex_cli_rs", + }; + if (accountId) headers["ChatGPT-Account-ID"] = accountId; + + const response = await proxyAwareFetch(CODEX_CONFIG.resetCreditsUrl, { + method: "GET", + headers, + }, proxyOptions); + + let data = null; + try { + data = await response.json(); + } catch { + data = null; + } + + if (!response.ok) { + const message = data?.message || data?.error || data?.detail || `Codex reset credits API unavailable (${response.status}).`; + throw new Error(message); + } + + const credits = Array.isArray(data?.credits) ? data.credits : []; + return { + availableCount: Math.max(0, toFiniteNumber(data?.available_count ?? data?.availableCount, 0)), + credits: credits.map((credit) => ({ + status: String(credit?.status || "unknown"), + grantedAt: toIsoDate(credit?.granted_at ?? credit?.grantedAt), + expiresAt: toIsoDate(credit?.expires_at ?? credit?.expiresAt), + })), + }; +} + // Consume one Codex rate-limit reset credit (irreversible, spends 1 credit) export async function consumeCodexRateLimitResetCredit(accessToken, redeemRequestId, proxyOptions = null) { if (!accessToken) { diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js index 44622901..3ace3062 100644 --- a/open-sse/translator/concerns/usage.js +++ b/open-sse/translator/concerns/usage.js @@ -39,7 +39,16 @@ const USAGE_EXTRACTORS = { }, kiro(raw) { const input = n(raw.inputTokens), output = n(raw.outputTokens); - return { promptTokens: input, completionTokens: output, totalTokens: input + output }; + // ponytail: Amazon Q (Kiro upstream) does not expose cache fields today, + // but pass through any cache_read/cache_creation/cached_tokens if the + // event shape grows them later so cost tracking keeps working without + // a second pass. + const cached = n(raw.cache_read_input_tokens) || n(raw.cachedTokens) || n(raw.cached_tokens); + const cacheCreation = n(raw.cache_creation_input_tokens); + const out = { promptTokens: input, completionTokens: output, totalTokens: input + output }; + if (cached > 0) out.cachedTokens = cached; + if (cacheCreation > 0) out.cacheCreationTokens = cacheCreation; + return out; }, ollama(raw) { const input = n(raw.prompt_eval_count), output = n(raw.eval_count); diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 62ce7b13..ec6e6c47 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -150,6 +150,33 @@ export function normalizeClaudePassthrough(body, model = "") { } } + // 3. Drop thinking blocks whose signature is not Claude's (combo mixes models, + // so foreign signatures leak into history and Anthropic rejects them). + const thinkingEnabled = body.thinking?.type === "enabled"; + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue; + let hasToolUse = false; + let hasKeptThinking = false; + const kept = []; + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) { + if (isValidClaudeSignature(block.signature)) { + hasKeptThinking = true; + kept.push(block); + } + continue; + } + if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true; + kept.push(block); + } + msg.content = kept; + if (thinkingEnabled && !hasKeptThinking && hasToolUse) { + msg.content.unshift(buildThinkingPlaceholder("claude")); + } + } + } + return body; } diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index 3fe4eb7b..bf6c4586 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -12,6 +12,8 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ "default", "examples", // JSON Schema meta keywords "$schema", "$defs", "definitions", "const", "$ref", "$comment", + // Annotation keywords (rejected by Gemini/Antigravity - e.g. MCP tool schemas set these) + "deprecated", "readOnly", "writeOnly", // Object validation keywords (not supported) "additionalProperties", "propertyNames", "patternProperties", "enumDescriptions", // Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf) @@ -19,7 +21,7 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ // Dependency keywords (not supported) "dependencies", "dependentSchemas", "dependentRequired", // Other unsupported keywords - "title", "optional", "if", "then", "else", "contentMediaType", "contentEncoding", + "title", "optional", "deprecated", "if", "then", "else", "contentMediaType", "contentEncoding", // UI/Styling properties (from Cursor tools - NOT JSON Schema standard) "cornerRadius", "fillColor", "fontFamily", "fontSize", "fontWeight", "gap", "padding", "strokeColor", "strokeThickness", "textColor" diff --git a/open-sse/translator/formats/openai.js b/open-sse/translator/formats/openai.js index d6c850c4..3252568f 100644 --- a/open-sse/translator/formats/openai.js +++ b/open-sse/translator/formats/openai.js @@ -6,42 +6,46 @@ export { VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES }; // Filter messages to OpenAI standard format // Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks -export function filterToOpenAIFormat(body) { +// opts.preserveCacheControl: keep cache_control on content blocks (e.g. for DashScope/alicode) +export function filterToOpenAIFormat(body, opts = {}) { if (!body.messages || !Array.isArray(body.messages)) return body; - + const keepCache = !!opts.preserveCacheControl; + + function stripBlock(block) { + const { signature, cache_control, ...rest } = block; + return keepCache && cache_control ? { ...rest, cache_control } : rest; + } + body.messages = body.messages.map(msg => { // Normalize developer role to system (many providers don't support developer) if (msg.role === ROLE.DEVELOPER) msg = { ...msg, role: ROLE.SYSTEM }; - + // Keep tool messages as-is (OpenAI format) if (msg.role === ROLE.TOOL) return msg; - + // Keep assistant messages with tool_calls as-is if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return msg; - + // Handle string content if (typeof msg.content === "string") return msg; - + // Handle array content if (Array.isArray(msg.content)) { const filteredContent = []; - + for (const block of msg.content) { // Skip thinking blocks if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue; - + // Only keep valid OpenAI content types if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { - // Remove signature field if exists - const { signature, cache_control, ...cleanBlock } = block; - filteredContent.push(cleanBlock); + filteredContent.push(stripBlock(block)); } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { // Convert tool_use to tool_calls format (handled separately) continue; } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { // Keep tool_result but clean it - const { signature, cache_control, ...cleanBlock } = block; - filteredContent.push(cleanBlock); + filteredContent.push(stripBlock(block)); } } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 8c25d486..e9c84971 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -109,7 +109,9 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Always normalize to clean OpenAI format when target is OpenAI // This handles hybrid requests (e.g., OpenAI messages + Claude tools) if (targetFormat === FORMATS.OPENAI) { - result = filterToOpenAIFormat(result); + result = filterToOpenAIFormat(result, { + preserveCacheControl: !!PROVIDERS[provider]?.quirks?.preserveCacheControl, + }); } // Final step: prepare request for Claude format endpoints diff --git a/open-sse/translator/request/antigravity-to-openai.js b/open-sse/translator/request/antigravity-to-openai.js index b1dbd7bf..374bb54a 100644 --- a/open-sse/translator/request/antigravity-to-openai.js +++ b/open-sse/translator/request/antigravity-to-openai.js @@ -138,12 +138,14 @@ function convertContent(content) { // Text with thoughtSignature = regular text after thinking if (part.thoughtSignature && part.text !== undefined) { - textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + if (part.text) { + textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } continue; } // Regular text - if (part.text !== undefined) { + if (part.text !== undefined && part.text !== "") { textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); } @@ -180,8 +182,22 @@ function convertContent(content) { } } - // Content with only functionResponses → return array of tool messages + // Content with functionResponses — return array of tool result messages, + // plus an assistant message for any co-located tool calls / text. if (toolResults.length > 0) { + if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) { + const assistantMsg = { role: ROLE.ASSISTANT }; + if (textParts.length > 0) { + assistantMsg.content = collapseTextParts(textParts); + } + if (reasoningContent) { + assistantMsg.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls; + } + return [...toolResults, assistantMsg]; + } return toolResults; } diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 8a38e4a9..5d891c11 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -393,9 +393,12 @@ export function claudeToKiroRequest(model, body, stream, credentials) { reconcileOrphanedToolResults(history, currentMessage); } - // API-key auth must never use the shared default ARN (403); OAuth/social fall back to it. + // api_key / idc / external_idp must never use the shared default ARN (belongs + // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. const authMethod = credentials?.providerSpecificData?.authMethod; - const profileArn = authMethod === "api_key" + const accountBoundAuth = + authMethod === "api_key" || authMethod === "idc" || authMethod === "external_idp"; + const profileArn = accountBoundAuth ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index dc54bac5..c6e92ed6 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -129,8 +129,24 @@ function fixMissingToolResponsesOpenAI(messages) { } } +// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400) +function systemReminderText(content) { + const parts = Array.isArray(content) + ? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "") + : [typeof content === "string" ? content : ""]; + const text = parts.filter(Boolean).join("\n"); + if (!text.trim()) return ""; + return `<system-reminder>\n${text}\n</system-reminder>`; +} + // Convert single Claude message - returns single message or array of messages function convertClaudeMessage(msg) { + // Mid-conversation system message -> user (per Anthropic placement rules) + if (msg.role === ROLE.SYSTEM) { + const text = systemReminderText(msg.content); + return text ? { role: ROLE.USER, content: text } : null; + } + const role = msg.role === ROLE.USER || msg.role === ROLE.TOOL ? ROLE.USER : ROLE.ASSISTANT; // Simple string content diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index fe181631..afb3effc 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -35,6 +35,17 @@ function sanitizeGeminiFunctionName(name) { return sanitized.substring(0, 64); } +function normalizeGeminiContents(contents) { + const out = []; + for (const c of contents || []) { + if (!c?.role || !Array.isArray(c.parts) || c.parts.length === 0) continue; + const last = out.at(-1); + if (last?.role === c.role) last.parts.push(...c.parts); + else out.push({ ...c, parts: [...c.parts] }); + } + return out; +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) { const result = { @@ -217,6 +228,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } } + result.contents = normalizeGeminiContents(result.contents); return result; } @@ -299,7 +311,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } // Wrap Claude format in Cloud Code envelope for Antigravity -function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) { +function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null, signature = DEFAULT_THINKING_AG_SIGNATURE) { const projectId = credentials?.projectId || generateProjectId(); const envelope = { @@ -343,6 +355,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu parts.push({ text: block.text }); } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { parts.push({ + thoughtSignature: signature, functionCall: { id: block.id, name: sanitizeGeminiFunctionName(block.name), @@ -425,6 +438,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; } + envelope.request.contents = normalizeGeminiContents(envelope.request.contents); return envelope; } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index e3e7f475..ee886666 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -530,8 +530,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) { // (the ARN doesn't belong to the key's account). So for api_key, only send a // profileArn that was actually resolved for this connection — never the default. // OAuth/social keep the default fallback (their tokens accept it). + // api_key / idc / external_idp carry an account-specific (or token-bound) + // profile. The shared builder-id/social default ARN belongs to a different + // account and triggers 403 "bearer token invalid", so never fall back to it — + // send the resolved ARN, or an empty string so CodeWhisperer uses the token's + // own default profile. Only OAuth/social keep the shared placeholder. const authMethod = credentials?.providerSpecificData?.authMethod; - const profileArn = authMethod === "api_key" + const accountBoundAuth = + authMethod === "api_key" || authMethod === "idc" || authMethod === "external_idp"; + const profileArn = accountBoundAuth ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 9dfd74d0..4651d3cf 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -27,6 +27,25 @@ export function claudeToOpenAIResponse(chunk, state) { state.messageId = chunk.message?.id || `msg_${Date.now()}`; state.model = chunk.message?.model; state.toolCallIndex = 0; + // Claude sends input_tokens + cache_read + cache_creation here; message_delta + // later carries only the final output_tokens. Capture cache now so the + // delta (output-only) doesn't reset it to zero. + const startUsage = chunk.message?.usage; + if (startUsage && typeof startUsage === "object") { + const inputTokens = typeof startUsage.input_tokens === "number" ? startUsage.input_tokens : 0; + const cacheReadTokens = typeof startUsage.cache_read_input_tokens === "number" ? startUsage.cache_read_input_tokens : 0; + const cacheCreationTokens = typeof startUsage.cache_creation_input_tokens === "number" ? startUsage.cache_creation_input_tokens : 0; + const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + state.usage = { + prompt_tokens: promptTokens, + completion_tokens: 0, + total_tokens: promptTokens, + input_tokens: inputTokens, + output_tokens: 0 + }; + if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens; + if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens; + } results.push(createChunk(state, { role: ROLE.ASSISTANT })); break; } @@ -103,13 +122,15 @@ export function claudeToOpenAIResponse(chunk, state) { } case "message_delta": { - // Extract usage from message_delta event (Claude native format) - // Normalize to OpenAI format (prompt_tokens/completion_tokens) for consistent logging + // Extract usage from message_delta event (Claude native format). + // Anthropic sends input/cache in message_start and only output here, so + // fall back to cache captured in message_start when the delta omits it. if (chunk.usage && typeof chunk.usage === "object") { - const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : 0; + const prev = state.usage || {}; + const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : (prev.input_tokens || 0); const outputTokens = typeof chunk.usage.output_tokens === "number" ? chunk.usage.output_tokens : 0; - const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : 0; - const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : 0; + const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : (prev.cache_read_input_tokens || 0); + const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : (prev.cache_creation_input_tokens || 0); // prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens) const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; @@ -131,7 +152,14 @@ export function claudeToOpenAIResponse(chunk, state) { const finalChunk = createChunk(state, {}, state.finishReason); if (state.usage) { - finalChunk.usage = toOpenAIUsage(chunk.usage, "claude"); + // Build OpenAI usage from the merged state (cache from message_start + + // output from message_delta), not the delta chunk alone. + finalChunk.usage = toOpenAIUsage({ + input_tokens: state.usage.input_tokens || 0, + output_tokens: state.usage.output_tokens || 0, + cache_read_input_tokens: state.usage.cache_read_input_tokens, + cache_creation_input_tokens: state.usage.cache_creation_input_tokens + }, "claude"); } results.push(finalChunk); diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js index 28e7d39a..8f04ffd3 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.js @@ -25,7 +25,10 @@ function emitFunctionCall(functionCall, state) { type: OPENAI_BLOCK.FUNCTION, function: { name: fcName, arguments: JSON.stringify(fcArgs) }, }; - state.toolCalls.set(toolCallIndex, toolCall); + // Keep Gemini bookkeeping separate from the shared translator state.toolCalls map. + // The downstream OpenAI→Claude translator uses state.toolCalls for Claude block + // metadata; pre-populating it here makes Anthropic tool deltas lose index. + state.geminiToolCallCount = (state.geminiToolCallCount || 0) + 1; return buildChunk(chunkMeta(state), { tool_calls: [toolCall] }, null); } @@ -46,6 +49,7 @@ export function geminiToOpenAIResponse(chunk, state) { state.messageId = response.responseId || `msg_${Date.now()}`; state.model = response.modelVersion || "gemini"; state.functionIndex = 0; + state.geminiToolCallCount = 0; results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null)); } @@ -117,7 +121,7 @@ export function geminiToOpenAIResponse(chunk, state) { // Finish reason - include usage in final chunk if (candidate.finishReason) { let finishReason = toOpenAIFinish(candidate.finishReason, "gemini"); - if (finishReason === OPENAI_FINISH.STOP && state.toolCalls.size > 0) { + if (finishReason === OPENAI_FINISH.STOP && state.geminiToolCallCount > 0) { finishReason = OPENAI_FINISH.TOOL_CALLS; } diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index e771c154..3998cc84 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -184,7 +184,8 @@ export function openaiToClaudeResponse(chunk, state) { for (const tc of delta.tool_calls) { const idx = tc.index ?? 0; - if (tc.id) { + // GLM/fireworks repeats id+null-name on every arg chunk; open block once per idx + if (tc.id && !state.toolCalls.has(idx)) { stopThinkingBlock(state, results); stopTextBlock(state, results); diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.js index 57fa2ff2..906ce724 100644 --- a/open-sse/utils/bypassHandler.js +++ b/open-sse/utils/bypassHandler.js @@ -247,9 +247,24 @@ function mergeChunksToResponse(chunks, sourceFormat) { if (messageStart?.message) { finalChunk = messageStart.message; - // Merge usage if available - if (messageDelta?.usage) { - finalChunk.usage = messageDelta.usage; + // message_start.usage has input + cache; message_delta.usage has the + // final output_tokens. Merge so cache survives (delta omits it). + const startUsage = messageStart.message.usage; + const deltaUsage = messageDelta?.usage; + if (startUsage || deltaUsage) { + finalChunk.usage = { + ...(startUsage || {}), + ...(deltaUsage || {}), + ...(startUsage?.cache_read_input_tokens !== undefined + ? { cache_read_input_tokens: startUsage.cache_read_input_tokens } + : {}), + ...(startUsage?.cache_creation_input_tokens !== undefined + ? { cache_creation_input_tokens: startUsage.cache_creation_input_tokens } + : {}), + ...(startUsage?.input_tokens !== undefined + ? { input_tokens: startUsage.input_tokens } + : {}) + }; } } } diff --git a/open-sse/utils/responsesStreamHelpers.js b/open-sse/utils/responsesStreamHelpers.js index 6f90a0c1..eaacdcd0 100644 --- a/open-sse/utils/responsesStreamHelpers.js +++ b/open-sse/utils/responsesStreamHelpers.js @@ -5,6 +5,7 @@ import { formatSSE } from "./streamHelpers.js"; // Responses API events that signal the stream has reached a terminal state const OPENAI_RESPONSES_TERMINAL_EVENTS = new Set([ "response.completed", + "response.done", "response.failed", "error" ]); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 3b2cfb7e..54754923 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,8 +1,12 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; +<<<<<<< HEAD import { extractUsage, hasValidUsage, estimateUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { saveUsageStats } from "../handlers/chatCore/requestDetail.js"; +======= +import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; +>>>>>>> 7f436e2792be4fa5a4d1c4d6b8e9bc85eaaa6a3d import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; @@ -131,6 +135,20 @@ export function createSSEStream(options = {}) { } } + // Strip empty tool_calls arrays that break AI SDK reasoning tracking. + // Some providers (e.g. CodeBuddy CN) include `"tool_calls": []` in + // every streaming delta. @ai-sdk/openai-compatible checks + // `delta.tool_calls != null` — an empty array passes this check, + // causing premature `reasoning-end` on every chunk. + if (parsed?.choices) { + for (const choice of parsed.choices) { + if (choice.delta?.tool_calls && Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length === 0) { + delete choice.delta.tool_calls; + fieldsInjected = true; + } + } + } + if (!hasValuableContent(parsed, FORMATS.OPENAI)) { continue; } @@ -149,7 +167,7 @@ export function createSSEStream(options = {}) { const extracted = extractUsage(parsed); if (extracted) { - usage = extracted; + usage = mergeUsage(usage, extracted); } const isFinishChunk = parsed.choices?.[0]?.finish_reason; @@ -218,9 +236,11 @@ export function createSSEStream(options = {}) { sseEmittedCount++; } - // [DONE] not emitted in translate mode — some clients' SSE decoders - // fail to parse the OpenAI sentinel on Claude-format translated streams. - // message_stop already signals end-of-response; stream close handles it. + if (keepsOpenAIResponsesFormat && !streamDoneSent) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + } streamDoneSent = true; if (keepsOpenAIResponsesFormat) openAIResponsesDoneSent = true; continue; @@ -265,7 +285,7 @@ export function createSSEStream(options = {}) { // Extract usage const extracted = extractUsage(parsed); - if (extracted) state.usage = extracted; // Keep original usage for logging + if (extracted) state.usage = mergeUsage(state.usage, extracted); // Keep original usage for logging // Responses same-format passthrough: re-emit with original event framing if (keepsOpenAIResponsesFormat && openAIResponsesEventName) { @@ -351,7 +371,9 @@ export function createSSEStream(options = {}) { // Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel: // data: [DONE]\n\n // Without it they can hang until timeout and trigger failover. - if (!streamDoneSent) { + // Gemini-family clients (Antigravity, Vertex, Gemini) reject this sentinel with 400 syntax errors. + const isGeminiFamily = provider === "antigravity" || provider === "gemini" || provider === "vertex"; + if (!streamDoneSent && !isGeminiFamily) { const doneOutput = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(doneOutput); controller.enqueue(sharedEncoder.encode(doneOutput)); @@ -416,8 +438,13 @@ export function createSSEStream(options = {}) { openAIResponsesTerminalSeen = true; } - // [DONE] not emitted in translate mode — see comment above. - // Passthrough mode still emits it for standard OpenAI clients. + if (keepsOpenAIResponsesFormat && !openAIResponsesDoneSent && !streamDoneSent) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + openAIResponsesDoneSent = true; + streamDoneSent = true; + } if (!hasValidUsage(state?.usage) && totalContentLength > 0) { state.usage = estimateUsage(body, totalContentLength, sourceFormat); diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index c30350c5..ce351139 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -141,6 +141,68 @@ export function normalizeUsage(usage) { return normalized; } +/** + * Canonicalize usage into ONE storage/cost convention so token counts and cost + * are consistent across providers: + * prompt_tokens = total input INCLUDING cache read + cache creation + * cached_tokens = cache-read portion (subset of prompt_tokens) + * cache_creation_input_tokens = cache-write portion (subset of prompt_tokens) + * completion_tokens, reasoning_tokens, total_tokens + * + * Discriminator: Claude reports cache_read_input_tokens with a prompt that + * EXCLUDES cache, so we fold cache into prompt. OpenAI/Gemini report + * cached_tokens already counted inside prompt, so we pass through. Idempotent: + * once folded the output carries cached_tokens (not cache_read_input_tokens), + * so re-running takes the passthrough branch and does not double-add. + * + * @param {object} usage - a normalizeUsage()-shaped object + * @returns {object|null} canonical token object, or null for invalid input + */ +export function canonicalizeUsage(usage) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); + const completion = num(usage.completion_tokens ?? usage.output_tokens); + const reasoning = num(usage.reasoning_tokens); + // Fall back to the nested prompt_tokens_details.cache_creation_tokens shape + // (buildUsage()'s OpenAI-forwarding format) when the top-level field is + // absent, so callers that pass a buildUsage() object through don't silently + // drop cache_creation. + const cacheCreation = num(usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens); + + let prompt = num(usage.prompt_tokens ?? usage.input_tokens); + let cached; + + // Claude path: prompt excludes cache; cache_read_input_tokens and/or + // cache_creation_input_tokens are separate. A cache-miss "first write" only + // carries cache_creation_input_tokens (no cache_read_input_tokens yet), so + // check both fields — otherwise a first-write request falls through to the + // OpenAI passthrough branch below and cache_creation never gets folded in. + // Guard on the absence of `cached_tokens`: our own canonical output always + // sets that key (even to 0), so re-running canonicalizeUsage on an already- + // folded result takes the passthrough branch instead of folding again. + if (usage.cached_tokens === undefined && + (usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined)) { + cached = num(usage.cache_read_input_tokens); + prompt = prompt + cached + cacheCreation; + } else { + // OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens. + cached = num(usage.cached_tokens); + } + + const result = { + prompt_tokens: prompt, + completion_tokens: completion, + // Recompute rather than pass through: when the fold branch ran above, + // an upstream total_tokens (cache-exclusive) would otherwise be stale. + total_tokens: prompt + completion, + cached_tokens: cached, + cache_creation_input_tokens: cacheCreation, + }; + if (reasoning > 0) result.reasoning_tokens = reasoning; + return result; +} + /** * Check if usage has valid token data * Valid = has at least one token field with value > 0 @@ -171,6 +233,19 @@ export function hasValidUsage(usage) { export function extractUsage(chunk) { if (!chunk || typeof chunk !== "object") return null; + // Claude format (message_start event): carries input_tokens + cache_read + + // cache_creation. message_delta later carries only the final output_tokens, + // so callers must MERGE (mergeUsage), not overwrite, to keep cache counts. + if (chunk.type === "message_start" && chunk.message?.usage && typeof chunk.message.usage === "object") { + const u = chunk.message.usage; + return normalizeUsage({ + prompt_tokens: u.input_tokens || 0, + completion_tokens: u.output_tokens || 0, + cache_read_input_tokens: u.cache_read_input_tokens, + cache_creation_input_tokens: u.cache_creation_input_tokens + }); + } + // Claude format (message_delta event) if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { return normalizeUsage({ @@ -232,6 +307,27 @@ export function extractUsage(chunk) { return null; } +// Field-wise max-merge of two usage objects. Anthropic splits usage across +// events: message_start has real input+cache (output is a placeholder 1), +// message_delta has the real cumulative output (input/cache absent). Max keeps +// the meaningful value from each without clobbering. Idempotent for other +// providers that emit a single complete usage object. +export function mergeUsage(prev, next) { + if (!prev) return next || null; + if (!next) return prev; + const merged = { ...prev }; + for (const [k, v] of Object.entries(next)) { + // typeof NaN === "number" — guard with Number.isFinite so one malformed + // chunk can't poison the whole accumulation (Math.max(x, NaN) is NaN). + if (typeof v === "number" && Number.isFinite(v)) { + merged[k] = Math.max(typeof merged[k] === "number" ? merged[k] : 0, v); + } else if (v && typeof v === "object") { + merged[k] = v; // nested details objects: take latest + } + } + return merged; +} + /** * Estimate input tokens from request body * Calculate total body size for more accurate estimation diff --git a/package.json b/package.json index eeb93d1a..a08b475d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.12", + "version": "0.5.18", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/providers/clinepass.png b/public/providers/clinepass.png new file mode 100644 index 00000000..be241899 Binary files /dev/null and b/public/providers/clinepass.png differ diff --git a/public/providers/kimchi.png b/public/providers/kimchi.png new file mode 100644 index 00000000..8f328158 Binary files /dev/null and b/public/providers/kimchi.png differ diff --git a/public/providers/kimchi.svg b/public/providers/kimchi.svg new file mode 100644 index 00000000..cc019be6 --- /dev/null +++ b/public/providers/kimchi.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104"> +<circle cx="52" cy="52" r="52" fill="#FF521D"/> +<path d="M68.4459 24.9438C69.8639 24.1762 71.6471 23.7178 73.4821 24.2163C73.8278 24.2966 74.1339 24.3916 74.4362 24.5024L75.9186 25.0464L75.5104 26.5727C75.3469 27.1827 75.202 27.723 75.0387 28.3325L74.6442 29.8042L73.1354 29.5972C73.0242 29.5819 72.9246 29.5753 72.8082 29.5747C72.2474 29.5865 71.7542 29.8645 71.2662 30.6772C70.8883 31.307 70.5939 32.1486 70.3815 33.1079C74.871 36.0331 77.8444 41.0994 77.8444 46.8638C77.8443 49.5292 77.2072 52.0505 76.0758 54.2798C71.5108 64.3996 54.1783 84.8815 23.4655 79.0083C21.8797 78.7048 20.9918 77.4165 20.902 76.1245C20.8141 74.8561 21.479 73.487 22.8981 72.8813C31.0698 69.394 35.5884 65.7091 38.5983 61.2329C41.6553 56.6866 43.2606 51.1907 45.3287 43.7251C46.7942 36.1645 53.4462 30.4547 61.4362 30.4546L61.8834 30.4604C62.4398 30.4755 62.9894 30.519 63.5309 30.5884C63.9515 29.7288 64.4424 28.8863 65.0211 28.0659L65.1745 27.8423C65.9623 26.731 67.1108 25.6667 68.4459 24.9438Z" fill="#18181A"/> +</svg> diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 15096050..0b5bea19 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -23,6 +23,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst : hasLegacyProxy ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` : ""; + const autoPingTooltip = autoPing?.provider === "codex" + ? "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota." + : "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away."; let maskedProxyUrl = ""; if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) { @@ -244,7 +247,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst </div> )} {autoPing && ( - <Tooltip text="When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away."> + <Tooltip text={autoPingTooltip}> <button onClick={() => autoPing.onToggle(!autoPing.on)} className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPing.on ? "text-primary" : "text-text-muted hover:text-primary"}`} @@ -310,5 +313,6 @@ ConnectionRow.propTypes = { autoPing: PropTypes.shape({ on: PropTypes.bool, onToggle: PropTypes.func, + provider: PropTypes.string, }), }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 673d666c..af8de510 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -23,6 +23,11 @@ import BulkImportCodexModal from "./BulkImportCodexModal"; const ONE_BY_ONE_DELAY_MS = 1000; +const AUTO_PING_SETTINGS_KEYS = { + claude: "claudeAutoPing", + codex: "codexAutoPing", +}; + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -273,7 +278,8 @@ export default function ProviderDetailPage() { // Load per-provider thinking config const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; setThinkingMode(thinkingCfg.mode || "auto"); - const apCfg = settingsData.claudeAutoPing || {}; + const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; + const apCfg = autoPingSettingsKey ? settingsData[autoPingSettingsKey] || {} : {}; setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} }); if (nodesRes.ok) { let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; @@ -388,12 +394,15 @@ export default function ProviderDetailPage() { }; const saveAutoPing = async (next) => { + const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId]; + if (!autoPingSettingsKey) return; + setAutoPing(next); try { await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ claudeAutoPing: next }), + body: JSON.stringify({ [autoPingSettingsKey]: next }), }); } catch (error) { console.log("Error saving auto-ping config:", error); @@ -888,9 +897,10 @@ export default function ProviderDetailPage() { onMoveUp={() => handleSwapPriority(index, index - 1)} onMoveDown={() => handleSwapPriority(index, index + 1)} onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} - autoPing={providerId === "claude" && conn.authType === "oauth" ? { + autoPing={AUTO_PING_SETTINGS_KEYS[providerId] && conn.authType === "oauth" ? { on: autoPing.connections[conn.id] === true, onToggle: (on) => handleAutoPingConnection(conn.id, on), + provider: providerId, } : null} onUpdateProxy={async (proxyPoolId) => { try { diff --git a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js index 5d08933d..deec1594 100644 --- a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js +++ b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js @@ -8,7 +8,7 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; export default function OverviewCards({ stats }) { return ( - <div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4 sm:gap-4"> + <div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 sm:gap-4"> <Card className="flex min-w-0 flex-col gap-1 px-4 py-3"> <span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span> <span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span> @@ -17,6 +17,12 @@ export default function OverviewCards({ stats }) { <span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span> <span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span> </Card> + {/* Temporarily hidden: Cached Tokens card + <Card className="flex min-w-0 flex-col gap-1 px-4 py-3"> + <span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span> + <span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span> + </Card> + */} <Card className="flex min-w-0 flex-col gap-1 px-4 py-3"> <span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span> <span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span> diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js index eb145263..541cc9f0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -165,6 +165,7 @@ export default function ProviderLimitCard({ percentage={percentage} unlimited={unlimited} resetTime={quota.resetAt} + recurring={quota.recurring !== false} /> ); })} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js index fc0bdc1d..1a13acd2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js @@ -69,12 +69,17 @@ export default function QuotaProgressBar({ used = 0, total = 0, unlimited = false, - resetTime = null + resetTime = null, + recurring = true, }) { const colors = getColorClasses(percentage); const countdown = formatResetTime(resetTime); const resetDisplay = formatResetTimeDisplay(resetTime); - + + // recurring defaults true. One-shot packs (e.g. CodeBuddy CN bonus packs) + // set recurring:false: resetTime is a hard expiry, so word it as "expires". + const resetWord = recurring ? "Reset" : "Expires"; + // percentage is already remaining percentage (from ProviderLimitCard) const remaining = percentage; @@ -111,7 +116,7 @@ export default function QuotaProgressBar({ {countdown !== "-" && ( <div className="flex items-center gap-1"> <span>•</span> - <span className="font-medium">Reset in {countdown}</span> + <span className="font-medium">{resetWord} in {countdown}</span> </div> )} </div> @@ -119,7 +124,7 @@ export default function QuotaProgressBar({ {/* Reset time display */} {resetDisplay && ( <div className="text-xs text-text-muted/70"> - Reset at {resetDisplay} + {resetWord} at {resetDisplay} </div> )} </div> diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 765d7e91..8f2a1bc3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -153,6 +153,11 @@ export default function QuotaTable({ const colors = getColorClasses(quota.remaining); const countdown = formatResetTime(quota.resetAt); const resetDisplay = formatResetTimeDisplay(quota.resetAt); + // recurring defaults true: a missing flag means the quota + // refreshes at resetAt. Bonus/one-shot packs set recurring:false + // and their resetAt is a hard expiry, so word it as "expires". + const recurring = quota.recurring !== false; + const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; return ( <tr @@ -197,13 +202,13 @@ export default function QuotaTable({ className={`${resetPrimary} text-text-primary font-medium truncate`} title={resetDisplay || ""} > - {countdown !== "-" ? `in ${countdown}` : resetDisplay} + {countdown !== "-" ? countdownLabel : resetDisplay} </div> ) : ( <div className="space-y-0.5"> {countdown !== "-" && ( <div className={`${resetPrimary} text-text-primary font-medium`}> - in {countdown} + {countdownLabel} </div> )} {resetDisplay && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index dfec68e3..95300c92 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -52,6 +52,16 @@ const KIRO_METHOD_LABELS = { api_key: "API Key", }; +const AUTO_PING_SETTINGS_KEYS = { + claude: "claudeAutoPing", + codex: "codexAutoPing", +}; + +const AUTO_PING_TOOLTIPS = { + claude: "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.", + codex: "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota.", +}; + function kiroMethodLabel(conn) { const m = conn.providerSpecificData?.authMethod; if (m && KIRO_METHOD_LABELS[m]) return KIRO_METHOD_LABELS[m]; @@ -87,6 +97,30 @@ function getCodexResetCreditCount(quota) { return Number.isFinite(count) ? Math.max(0, count) : 0; } +function formatCreditDate(value) { + if (!value) return "N/A"; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "N/A"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function formatTimeRemaining(value) { + if (!value) return "N/A"; + const diffMs = new Date(value).getTime() - Date.now(); + if (!Number.isFinite(diffMs)) return "N/A"; + if (diffMs <= 0) return "Expired"; + const totalHours = Math.ceil(diffMs / (60 * 60 * 1000)); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return days > 0 ? `${days}d ${hours}h` : `${hours}h`; +} + export default function ProviderLimits() { const { copied, copy } = useCopyToClipboard(); const [connections, setConnections] = useState([]); @@ -94,7 +128,7 @@ export default function ProviderLimits() { const [loading, setLoading] = useState({}); const [errors, setErrors] = useState({}); const [autoRefresh, setAutoRefresh] = useState(true); - const [autoPingMap, setAutoPingMap] = useState({}); + const [autoPingMaps, setAutoPingMaps] = useState({ claude: {}, codex: {} }); const [lastUpdated, setLastUpdated] = useState(null); const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false); const [refreshingAll, setRefreshingAll] = useState(false); @@ -104,6 +138,7 @@ export default function ProviderLimits() { const [togglingId, setTogglingId] = useState(null); const [resettingLimitId, setResettingLimitId] = useState(null); const [resetConfirmState, setResetConfirmState] = useState(null); + const [resetCreditsState, setResetCreditsState] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [proxyPools, setProxyPools] = useState([]); @@ -288,6 +323,26 @@ export default function ProviderLimits() { [fetchQuota, resettingLimitId], ); + const handleViewCodexResetCredits = useCallback(async (connection) => { + setResetCreditsState({ connection, loading: true, error: null, data: null }); + try { + const response = await fetch(`/api/usage/${connection.id}/codex-reset-credits`, { cache: "no-store" }); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(result.error || result.message || "Failed to load Codex reset credits"); + } + const credits = Array.isArray(result.credits) ? [...result.credits] : []; + credits.sort((a, b) => { + const aTime = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.POSITIVE_INFINITY; + const bTime = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.POSITIVE_INFINITY; + return aTime - bTime; + }); + setResetCreditsState({ connection, loading: false, error: null, data: { ...result, credits } }); + } catch (error) { + setResetCreditsState({ connection, loading: false, error: error.message || "Failed to load Codex reset credits", data: null }); + } + }, []); + const handleDeleteConnection = useCallback( async (id) => { if (!confirm("Delete this connection?")) return; @@ -477,30 +532,38 @@ export default function ProviderLimits() { window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh)); }, [autoRefresh, hasHydratedAutoRefresh]); - // Load Claude auto-ping per-connection map + // Load auto-ping per-connection maps useEffect(() => { fetch("/api/settings", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : {})) - .then((s) => setAutoPingMap(s?.claudeAutoPing?.connections || {})) + .then((s) => setAutoPingMaps({ + claude: s?.claudeAutoPing?.connections || {}, + codex: s?.codexAutoPing?.connections || {}, + })) .catch(() => {}); }, []); - const toggleAutoPing = useCallback(async (connectionId, on) => { - const next = { ...autoPingMap, [connectionId]: on }; - setAutoPingMap(next); + const toggleAutoPing = useCallback(async (connectionId, provider, on) => { + const settingsKey = AUTO_PING_SETTINGS_KEYS[provider]; + if (!settingsKey) return; + + const previous = autoPingMaps; + const nextProviderMap = { ...(autoPingMaps[provider] || {}), [connectionId]: on }; + const nextMaps = { ...autoPingMaps, [provider]: nextProviderMap }; + setAutoPingMaps(nextMaps); try { const r = await fetch("/api/settings", { cache: "no-store" }); const s = r.ok ? await r.json() : {}; - const cfg = { ...(s.claudeAutoPing || {}), connections: next }; + const cfg = { ...(s[settingsKey] || {}), connections: nextProviderMap }; await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ claudeAutoPing: cfg }), + body: JSON.stringify({ [settingsKey]: cfg }), }); } catch { - setAutoPingMap(autoPingMap); + setAutoPingMaps(previous); } - }, [autoPingMap]); + }, [autoPingMaps]); // Auto-refresh interval useEffect(() => { @@ -945,11 +1008,6 @@ export default function ProviderLimits() { {getConnectionSecondaryLabel(conn)} </p> ) : null} - {isCodex && ( - <p className="text-[11px] text-text-muted truncate"> - Reset eligible: {resetCreditCount} - </p> - )} {conn.provider === "kiro" && ( <div className="mt-1 flex flex-wrap items-center gap-1"> <span className="rounded-full bg-brand-500/10 px-2 py-0.5 text-[10px] font-semibold text-brand-600 dark:text-brand-300"> @@ -995,41 +1053,55 @@ export default function ProviderLimits() { <div className="flex items-center gap-1 shrink-0"> {isCodex && ( - <Tooltip text={`Codex reset credits remaining: ${resetCreditCount}`}> - <div - className={`hidden h-8 items-center gap-1 rounded-lg border px-2 text-[11px] sm:flex ${ + <> + <Tooltip + text={ resetCreditCount > 0 - ? "border-primary/30 bg-primary/5 text-primary" - : "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]" - }`} + ? `Use one Codex reset credit. Available: ${resetCreditCount}` + : "No Codex reset credits available" + } > - <span className="material-symbols-outlined text-[14px]">restart_alt</span> - <span className="tabular-nums">{resetCreditCount}</span> - </div> - </Tooltip> + <button + type="button" + onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })} + disabled={resetCreditCount <= 0 || isLoading || rowBusy} + aria-label={ + resetCreditCount > 0 + ? `Use one Codex reset credit. ${resetCreditCount} available.` + : "No Codex reset credits available" + } + className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${ + resetCreditCount > 0 + ? "border-primary/30 bg-primary/5 text-primary hover:bg-primary/10" + : "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]" + }`} + > + <span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}> + {isResettingLimit ? "progress_activity" : "restart_alt"} + </span> + <span>{resetCreditCount}</span> + </button> + </Tooltip> + <Tooltip text="View Codex reset credit expiry"> + <button + type="button" + onClick={() => handleViewCodexResetCredits(conn)} + disabled={isLoading || rowBusy} + aria-label="View Codex reset credit expiry" + className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-muted transition-colors hover:bg-black/5 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/5" + > + <span className="material-symbols-outlined text-[17px]">schedule</span> + </button> + </Tooltip> + </> )} - {isCodex && resetCreditCount > 0 && ( - <Tooltip text={`Use one Codex reset credit. Available: ${resetCreditCount}`}> + {AUTO_PING_SETTINGS_KEYS[conn.provider] && conn.authType === "oauth" && ( + <Tooltip text={AUTO_PING_TOOLTIPS[conn.provider]}> <button type="button" - onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })} - disabled={isLoading || rowBusy} - className="flex h-8 items-center gap-1 rounded-lg border border-primary/30 px-2 text-[11px] text-primary transition-colors hover:bg-primary/10 disabled:opacity-50" - > - <span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}> - {isResettingLimit ? "progress_activity" : "bolt"} - </span> - <span className="hidden lg:inline">Reset limit</span> - </button> - </Tooltip> - )} - {conn.provider === "claude" && conn.authType === "oauth" && ( - <Tooltip text="When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away."> - <button - type="button" - onClick={() => toggleAutoPing(conn.id, !(autoPingMap[conn.id] === true))} + onClick={() => toggleAutoPing(conn.id, conn.provider, !(autoPingMaps[conn.provider]?.[conn.id] === true))} aria-label="Toggle auto-ping" - className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPingMap[conn.id] === true ? "text-primary" : "text-text-muted"}`} + className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPingMaps[conn.provider]?.[conn.id] === true ? "text-primary" : "text-text-muted"}`} > <span className="material-symbols-outlined text-[18px]">bolt</span> </button> @@ -1278,6 +1350,79 @@ export default function ProviderLimits() { loading={Boolean(resettingLimitId)} /> + {resetCreditsState && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm"> + <div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-black/15 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/15 dark:bg-neutral-950 dark:ring-white/10"> + <div className="flex items-start justify-between gap-3 border-b border-black/10 bg-black/[0.03] px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]"> + <div className="min-w-0"> + <h3 className="text-base font-semibold text-text-primary">Codex Reset Credit Expiry</h3> + <p className="mt-0.5 truncate text-xs text-text-muted"> + {getConnectionLabel(resetCreditsState.connection) || "Codex account"} + </p> + </div> + <button + type="button" + onClick={() => setResetCreditsState(null)} + className="flex h-8 w-8 items-center justify-center rounded-lg text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5" + aria-label="Close reset credit expiry modal" + > + <span className="material-symbols-outlined text-[18px]">close</span> + </button> + </div> + + <div className="max-h-[70vh] overflow-auto bg-white p-4 dark:bg-neutral-950"> + {resetCreditsState.loading ? ( + <div className="flex items-center justify-center gap-2 py-10 text-sm text-text-muted"> + <span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span> + Loading reset credits... + </div> + ) : resetCreditsState.error ? ( + <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300"> + {resetCreditsState.error} + </div> + ) : resetCreditsState.data?.credits?.length ? ( + <div className="space-y-3"> + <div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]"> + <span>{resetCreditsState.data.credits.length} reset credit{resetCreditsState.data.credits.length === 1 ? "" : "s"}</span> + <span>{resetCreditsState.data.availableCount ?? 0} available</span> + </div> + <div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/10"> + <table className="w-full min-w-[560px] text-left text-sm"> + <thead className="bg-black/[0.03] text-xs uppercase tracking-wide text-text-muted dark:bg-white/[0.04]"> + <tr> + <th className="px-3 py-2 font-medium">Status</th> + <th className="px-3 py-2 font-medium">Granted At</th> + <th className="px-3 py-2 font-medium">Expires At</th> + <th className="px-3 py-2 font-medium">Remaining</th> + </tr> + </thead> + <tbody> + {resetCreditsState.data.credits.map((credit, index) => ( + <tr key={`${credit.status}-${credit.expiresAt || index}`} className="border-t border-black/5 dark:border-white/5"> + <td className="px-3 py-2"> + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> + {credit.status || "unknown"} + </span> + </td> + <td className="px-3 py-2 text-text-muted">{formatCreditDate(credit.grantedAt)}</td> + <td className="px-3 py-2 text-text-primary">{formatCreditDate(credit.expiresAt)}</td> + <td className="px-3 py-2 font-medium text-text-primary">{formatTimeRemaining(credit.expiresAt)}</td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ) : ( + <div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-8 text-center text-sm text-text-muted dark:border-white/10 dark:bg-white/[0.03]"> + No reset credit details returned for this account. + </div> + )} + </div> + </div> + </div> + )} + <EditConnectionModal isOpen={showEditModal} connection={selectedConnection} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 089670e2..688f0ab7 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -433,6 +433,24 @@ export function parseQuotaData(provider, data) { } break; + case "codebuddy-cn": + // CodeBuddy CN mixes recurring refill packs ("Monthly"/"Weekly"/...) + // with one-shot bonus packs ("Bonus Pack N"). Forward `recurring` + // so the UI can show "Expires in" for bonus packs (whose resetAt is + // a hard expiry, not a refresh) instead of "Reset in". + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + recurring: quota.recurring !== false, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index e450dd94..b82b8286 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -82,9 +82,20 @@ function CollapsibleSection({ title, children, defaultOpen = false, icon = null ); } +function getCachedTokens(tokens) { + return tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0; +} + +function getCacheCreationTokens(tokens) { + return tokens?.cache_creation_input_tokens || 0; +} + function getInputTokens(tokens) { const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0; - const cache = tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0; + // Canonical storage keeps prompt cache-inclusive. Legacy Claude rows may have + // stored prompt cache-exclusive; fall back to cache when it's larger so old + // rows don't under-report input. + const cache = getCachedTokens(tokens); return prompt < cache ? cache : prompt; } @@ -245,6 +256,8 @@ export default function RequestDetailsTab() { <th className="text-left p-4 text-sm font-semibold text-text-main">Model</th> <th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th> <th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th> + <th className="text-right p-4 text-sm font-semibold text-text-main">Cached</th> + <th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</th> <th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</th> <th className="text-left p-4 text-sm font-semibold text-text-main">Latency</th> <th className="text-center p-4 text-sm font-semibold text-text-main">Action</th> @@ -286,6 +299,12 @@ export default function RequestDetailsTab() { <td className="p-4 text-sm text-text-main text-right font-mono"> {getInputTokens(detail.tokens).toLocaleString()} </td> + <td className="p-4 text-sm text-text-main text-right font-mono"> + {getCachedTokens(detail.tokens) > 0 ? getCachedTokens(detail.tokens).toLocaleString() : "—"} + </td> + <td className="p-4 text-sm text-text-main text-right font-mono"> + {getCacheCreationTokens(detail.tokens) > 0 ? getCacheCreationTokens(detail.tokens).toLocaleString() : "—"} + </td> <td className="p-4 text-sm text-text-main text-right font-mono"> {detail.tokens?.completion_tokens?.toLocaleString() || 0} </td> @@ -370,6 +389,22 @@ export default function RequestDetailsTab() { {getInputTokens(selectedDetail.tokens).toLocaleString()} </span> </div> + {getCachedTokens(selectedDetail.tokens) > 0 && ( + <div> + <span className="text-text-muted">Cached Tokens:</span>{" "} + <span className="text-text-main font-mono"> + {getCachedTokens(selectedDetail.tokens).toLocaleString()} + </span> + </div> + )} + {getCacheCreationTokens(selectedDetail.tokens) > 0 && ( + <div> + <span className="text-text-muted">Cache Creation:</span>{" "} + <span className="text-text-main font-mono"> + {getCacheCreationTokens(selectedDetail.tokens).toLocaleString()} + </span> + </div> + )} <div> <span className="text-text-muted">Output Tokens:</span>{" "} <span className="text-text-main font-mono"> diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageTable.js b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js index 9f3d3099..9da3c3e6 100644 --- a/src/app/(dashboard)/dashboard/usage/components/UsageTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js @@ -38,6 +38,9 @@ function ValueCells({ item, viewMode, isSummary = false }) { <td className="px-6 py-3 text-right text-text-muted"> {isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)} </td> + <td className="px-6 py-3 text-right text-text-muted"> + {item.cachedTokens ? fmt(item.cachedTokens) : "—"} + </td> <td className="px-6 py-3 text-right text-text-muted"> {isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)} </td> @@ -52,6 +55,9 @@ function ValueCells({ item, viewMode, isSummary = false }) { <td className="px-6 py-3 text-right text-text-muted"> {isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)} </td> + <td className="px-6 py-3 text-right text-text-muted"> + {item.cachedCost ? fmtCost(item.cachedCost) : "—"} + </td> <td className="px-6 py-3 text-right text-text-muted"> {isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)} </td> @@ -133,12 +139,14 @@ export default function UsageTable({ if (viewMode === "tokens") { return [ { field: "promptTokens", label: "Input Tokens" }, + { field: "cachedTokens", label: "Cached" }, { field: "completionTokens", label: "Output Tokens" }, { field: "totalTokens", label: "Total Tokens" }, ]; } return [ { field: "promptTokens", label: "Input Cost" }, + { field: "cachedCost", label: "Cached Cost" }, { field: "completionTokens", label: "Output Cost" }, { field: "cost", label: "Total Cost" }, ]; diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 78922c96..ce7fdbc5 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -232,8 +232,8 @@ export async function POST(request, { params }) { }); } - // Cline uses authorization_code without PKCE - const noPkceExchangeProviders = ["cline"]; + // Cline and ClinePass use authorization_code without PKCE. Kimchi returns a browser token. + const noPkceExchangeProviders = ["cline", "clinepass", "kimchi"]; if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 17af05e2..bc31306a 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -4,7 +4,9 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth"; import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; +import { getModelsByProviderId } from "open-sse/config/providerModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; +import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -79,6 +81,13 @@ const resolveQwenModelsUrl = (connection) => { return `https://${value.replace(/\/$/, "")}/v1/models`; }; +const getStaticProviderModels = (providerId) => + getModelsByProviderId(providerId).map((model) => ({ + ...model, + id: model.id, + name: model.name || model.id, + })); + // Generic custom resolver for OAuth providers that need refresh-on-401 + token persist. // Receives a `fetchFn(token)` and returns parsed models or throws. const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => async (connection) => { @@ -241,6 +250,22 @@ const PROVIDER_MODELS_CONFIG = { nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"), assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"), "vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"), + kimchi: { + customResolver: async (connection) => { + const result = await resolveKimchiModels({ + accessToken: connection.accessToken, + apiKey: connection.apiKey, + providerSpecificData: connection.providerSpecificData || {}, + }, { forceRefresh: true, log: console }); + if (result?.models?.length) { + return { models: result.models }; + } + return { + models: getStaticProviderModels("kimchi"), + warning: "Kimchi returned no live models; falling back to static catalog.", + }; + } + }, // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index cebd50a4..a58281d1 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -16,6 +16,7 @@ import { CLAUDE_CONFIG, CLINE_CONFIG, KILOCODE_CONFIG, + KIMCHI_CONFIG, } from "@/lib/oauth/constants/oauth"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; @@ -91,6 +92,17 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", }, "codebuddy-cn": { tokenExists: true }, + kimchi: { + url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { + Accept: "application/json", + "User-Agent": "kimchi/0.1.40", + }, + refreshable: false, + }, }; async function probeClineAccessToken(accessToken) { diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 1a664f4a..5885472b 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -126,18 +126,13 @@ export async function POST(request) { let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData); - // Compatible/embedding nodes allow exactly one connection each. These guards were - // dropped accidentally during the bun:sqlite refactor (v0.4.28); restored to honor - // the contract locked in by tests/unit/compatible-provider-connections.test.js (#925). + // Compatible LLM nodes support multiple API-key connections (key pool); runtime + // rotates/fails over via getProviderCredentials. Embedding nodes stay single-connection. if (isOpenAICompatibleProvider(provider)) { const node = await getProviderNodeById(provider); if (!node) { return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, apiType: node.apiType, @@ -149,10 +144,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, @@ -163,10 +154,6 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 }); } - const existingConnections = await getProviderConnections({ provider }); - if (existingConnections.length > 0) { - return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 }); - } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 85001371..d5684091 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -380,10 +380,13 @@ export async function POST(request) { }; const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const res = await fetch(endpoints[provider], { headers }); + const res = await fetch(endpoints[provider], { headers, signal: AbortSignal.timeout(8000) }); // xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401. if (provider === "xai") { isValid = res.status === 200 || res.status === 403; + } else if (provider === "xiaomi-tokenplan") { + // /models returns 403 for valid keys lacking list permission; only 401 means invalid + isValid = res.status !== 401; } else { isValid = res.ok; } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index ee2682ca..ccbaee3a 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; import { resetComboRotation } from "open-sse/services/combo.js"; +import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing"; import bcrypt from "bcryptjs"; export const dynamic = "force-dynamic"; @@ -96,6 +97,16 @@ export async function PATCH(request) { resetComboRotation(); } + if ( + Object.prototype.hasOwnProperty.call(body, "claudeAutoPing") || + Object.prototype.hasOwnProperty.call(body, "codexAutoPing") + ) { + // Run once immediately after opt-in changes so users don't wait for the next scheduler tick. + runQuotaAutoPingTick().catch((error) => { + console.warn("[AutoPing] settings-triggered tick failed:", error.message); + }); + } + const { password, oidcClientSecret, ...safeSettings } = settings; safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); diff --git a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js index 5fa2f6da..0fb46260 100644 --- a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js +++ b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js @@ -2,7 +2,7 @@ import "open-sse/index.js"; import { getProviderConnectionById } from "@/lib/localDb"; -import { consumeCodexRateLimitResetCredit } from "open-sse/services/usage.js"; +import { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "open-sse/services/usage.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { refreshAndUpdateCredentials } from "../route.js"; @@ -15,6 +15,10 @@ function isAuthExpiredResult(result) { return values.some((value) => AUTH_EXPIRED_PATTERNS.some((pattern) => value.includes(pattern))); } +function isAuthExpiredError(error) { + return isAuthExpiredResult({ message: error?.message }); +} + function getResponseForConsumeResult(result, redeemRequestId) { if (result.ok) { return Response.json({ @@ -43,42 +47,90 @@ function getResponseForConsumeResult(result, redeemRequestId) { }, { status: result.status >= 400 && result.status < 500 ? result.status : 502 }); } +async function getCodexConnection(connectionId) { + const connection = await getProviderConnectionById(connectionId); + if (!connection) { + return { response: Response.json({ error: "Connection not found" }, { status: 404 }) }; + } + + if (connection.provider !== "codex") { + return { response: Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 }) }; + } + + const isOAuth = connection.authType === "oauth"; + const isAccessToken = connection.authType === "access_token"; + if (!isOAuth && !isAccessToken) { + return { response: Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 }) }; + } + + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; + + return { connection, isOAuth, proxyOptions }; +} + +async function refreshCodexConnection(connection, proxyOptions) { + try { + const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); + return { connection: result.connection }; + } catch (refreshError) { + console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError); + return { response: Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 }) }; + } +} + +export async function GET(_request, { params }) { + let connection; + try { + const { connectionId } = await params; + const resolved = await getCodexConnection(connectionId); + if (resolved.response) return resolved.response; + ({ connection } = resolved); + const { isOAuth, proxyOptions } = resolved; + + if (isOAuth) { + const refreshed = await refreshCodexConnection(connection, proxyOptions); + if (refreshed.response) return refreshed.response; + connection = refreshed.connection; + } + + let result; + try { + result = await getCodexRateLimitResetCredits(connection.accessToken, proxyOptions, connection.providerSpecificData); + } catch (fetchError) { + if (!isOAuth || !connection.refreshToken || !isAuthExpiredError(fetchError)) throw fetchError; + const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions); + connection = retryResult.connection; + result = await getCodexRateLimitResetCredits(connection.accessToken, proxyOptions, connection.providerSpecificData); + } + + return Response.json(result); + } catch (error) { + const provider = connection?.provider ?? "unknown"; + console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`); + return Response.json({ error: error.message }, { status: 500 }); + } +} + export async function POST(request, { params }) { let connection; try { const { connectionId } = await params; - connection = await getProviderConnectionById(connectionId); - if (!connection) { - return Response.json({ error: "Connection not found" }, { status: 404 }); - } - - if (connection.provider !== "codex") { - return Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 }); - } - - const isOAuth = connection.authType === "oauth"; - const isAccessToken = connection.authType === "access_token"; - if (!isOAuth && !isAccessToken) { - return Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 }); - } - - const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); - const proxyOptions = { - connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, - connectionProxyUrl: proxyConfig.connectionProxyUrl || "", - connectionNoProxy: proxyConfig.connectionNoProxy || "", - vercelRelayUrl: proxyConfig.vercelRelayUrl || "", - strictProxy: false, - }; + const resolved = await getCodexConnection(connectionId); + if (resolved.response) return resolved.response; + ({ connection } = resolved); + const { isOAuth, proxyOptions } = resolved; if (isOAuth) { - try { - const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); - connection = result.connection; - } catch (refreshError) { - console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError); - return Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 }); - } + const refreshed = await refreshCodexConnection(connection, proxyOptions); + if (refreshed.response) return refreshed.response; + connection = refreshed.connection; } // Server-generated redeem id prevents client-controlled replay diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 0c5dffbc..32647304 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -8,8 +8,10 @@ import { import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; +import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; +import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js"; @@ -38,6 +40,14 @@ const LIVE_MODEL_RESOLVERS = { models: result.models.map((m) => ({ id: m.id, name: m.name })), }; }, + kimchi: async (conn) => { + const result = await resolveKimchiModels({ + accessToken: conn.accessToken, + apiKey: conn.apiKey, + providerSpecificData: conn.providerSpecificData || {} + }, { log: console }); + return result?.models?.length ? { models: result.models } : null; + }, github: async (conn) => { const result = await resolveCopilotModels({ accessToken: conn.accessToken, @@ -54,6 +64,13 @@ const LIVE_MODEL_RESOLVERS = { }, }); return result?.models?.length ? { models: result.models } : null; + }, + clinepass: async (conn) => { + const result = await resolveClinepassModels({ + accessToken: conn.accessToken, + apiKey: conn.apiKey, + }); + return result?.models?.length ? { models: result.models } : null; } }; @@ -289,6 +306,8 @@ export async function buildModelsList(kindFilter) { const staticModelKindById = new Map( providerModels.map((m) => [m.id, modelKind(m)]) ); + let liveModelKindById = new Map(); + let liveCapabilitiesById = new Map(); let rawModelIds = hasExplicitEnabledModels ? Array.from( @@ -313,6 +332,16 @@ export async function buildModelsList(kindFilter) { const live = await liveResolver(conn); if (live?.models?.length) { rawModelIds = live.models.map((m) => m.id); + liveModelKindById = new Map( + live.models + .filter((m) => m?.id) + .map((m) => [m.id, modelKind(m)]) + ); + liveCapabilitiesById = new Map( + live.models + .filter((m) => m?.id && m.capabilities) + .map((m) => [m.id, m.capabilities]) + ); } } catch (err) { console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`); @@ -378,9 +407,10 @@ export async function buildModelsList(kindFilter) { const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds])); for (const modelId of mergedModelIds) { - // Resolve kind: prefer static/custom metadata, otherwise infer from ID heuristics + // Resolve kind: prefer custom/live metadata, then static, then ID heuristics. const customKind = customModelKindById.get(modelId); - const kind = staticModelKindById.get(modelId) || customKind || inferKindFromUnknownModelId(modelId); + const liveKind = liveModelKindById.get(modelId); + const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); // imageToText custom models stay in the LLM list (vision-capable chat models) const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); if (!kindFilter.includes(kind) && !allowAsLlm) continue; @@ -391,7 +421,7 @@ export async function buildModelsList(kindFilter) { object: "model", owned_by: outputAlias, }; - const caps = capabilitiesFromServiceKind(customKind); + const caps = liveCapabilitiesById.get(modelId) || capabilitiesFromServiceKind(customKind || liveKind); if (caps) model.capabilities = caps; models.push(model); } diff --git a/src/app/callback/page.js b/src/app/callback/page.js index e55370d9..df7bbbc3 100644 --- a/src/app/callback/page.js +++ b/src/app/callback/page.js @@ -12,12 +12,14 @@ function CallbackContent() { useEffect(() => { const code = searchParams.get("code"); + const token = searchParams.get("token"); const state = searchParams.get("state"); const error = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); const callbackData = { code, + token, state, error, errorDescription, @@ -70,7 +72,7 @@ function CallbackContent() { console.log("localStorage failed:", e); } - if (!(code || error)) { + if (!(code || token || error)) { setTimeout(() => setStatus("manual"), 0); return; } diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index d1bfe341..6075f234 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -98,13 +98,26 @@ export async function createProviderConnection(data) { let existing = null; if (data.authType === "oauth" && data.email) { + const incomingUsername = data.providerSpecificData?.username; const incomingWs = data.providerSpecificData?.chatgptAccountId; existing = all.find(c => { if (c.authType !== "oauth" || c.email !== data.email) return false; - // If both sides have a workspace ID, they must match for dedup + // Workspace providers (Codex) use workspace ID when both sides have it const existingWs = c.providerSpecificData?.chatgptAccountId; if (incomingWs && existingWs) return incomingWs === existingWs; - return true; // fallback: email-only match for non-workspace providers + if (incomingWs && !existingWs) return false; + if (!incomingWs && existingWs) return false; + // Non-workspace providers: match on (email + username) so cross-IdP + // accounts don't overwrite each other. Require username on both sides + // — if only one side has it, treat as a distinct identity rather than + // collapsing onto the bare-email fallback (which would re-introduce + // the cross-IdP overwrite). + const existingUsername = c.providerSpecificData?.username; + if (incomingUsername && existingUsername) { + return incomingUsername === existingUsername; + } + if (incomingUsername || existingUsername) return false; + return true; }); } else if (data.authType === "apikey" && data.name) { existing = all.find(c => c.authType === "apikey" && c.name === data.name); diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index b0d6bff0..ce6c4761 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -51,10 +51,11 @@ function getLocalDateKey(timestamp) { } function addToCounter(target, key, values) { - if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; target[key].requests += values.requests || 1; target[key].promptTokens += values.promptTokens || 0; target[key].completionTokens += values.completionTokens || 0; + target[key].cachedTokens += values.cachedTokens || 0; target[key].cost += values.cost || 0; if (values.meta) Object.assign(target[key], values.meta); } @@ -62,12 +63,14 @@ function addToCounter(target, key, values) { function aggregateEntryToDay(day, entry) { const promptTokens = entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0; const completionTokens = entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0; + const cachedTokens = entry.tokens?.cached_tokens || entry.tokens?.cache_read_input_tokens || 0; const cost = entry.cost || 0; - const vals = { promptTokens, completionTokens, cost }; + const vals = { promptTokens, completionTokens, cachedTokens, cost }; day.requests = (day.requests || 0) + 1; day.promptTokens = (day.promptTokens || 0) + promptTokens; day.completionTokens = (day.completionTokens || 0) + completionTokens; + day.cachedTokens = (day.cachedTokens || 0) + cachedTokens; day.cost = (day.cost || 0) + cost; day.byProvider ||= {}; @@ -135,33 +138,11 @@ async function calculateCost(provider, model, tokens) { const pricing = await getPricingForModel(provider, model); if (!pricing) return 0; - let cost = 0; - const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0; - const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; - const nonCachedInput = Math.max(0, inputTokens - cachedTokens); - cost += nonCachedInput * (pricing.input / 1000000); - - if (cachedTokens > 0) { - const cachedRate = pricing.cached || pricing.input; - cost += cachedTokens * (cachedRate / 1000000); - } - - const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0; - cost += outputTokens * (pricing.output / 1000000); - - const reasoningTokens = tokens.reasoning_tokens || 0; - if (reasoningTokens > 0) { - const rate = pricing.reasoning || pricing.output; - cost += reasoningTokens * (rate / 1000000); - } - - const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; - if (cacheCreationTokens > 0) { - const rate = pricing.cache_creation || pricing.input; - cost += cacheCreationTokens * (rate / 1000000); - } - - return cost; + // Delegate the actual math to the single source of truth (avoids the two + // copies drifting apart — see open-sse/providers/pricing.js for the + // cache-inclusive prompt_tokens convention this assumes). + const { calculateCostFromTokens } = await import("open-sse/providers/pricing.js"); + return calculateCostFromTokens(tokens, pricing); } catch (e) { console.error("Error calculating cost:", e); return 0; @@ -398,6 +379,7 @@ export async function getUsageStats(period = "all") { timestamp: r.timestamp, model: r.model, provider: r.provider || "", promptTokens: t.prompt_tokens || t.input_tokens || 0, completionTokens: t.completion_tokens || t.output_tokens || 0, + cachedTokens: t.cached_tokens || t.cache_read_input_tokens || 0, status: r.status || "ok", }; }) @@ -413,7 +395,7 @@ export async function getUsageStats(period = "all") { const stats = { totalRequests: 0, - totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0, + totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0, byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, last10Minutes: [], pending: pendingRequests, @@ -474,13 +456,15 @@ export async function getUsageStats(period = "all") { const day = parseJson(dr.data, {}); stats.totalPromptTokens += day.promptTokens || 0; stats.totalCompletionTokens += day.completionTokens || 0; + stats.totalCachedTokens += day.cachedTokens || 0; stats.totalCost += day.cost || 0; for (const [prov, p] of Object.entries(day.byProvider || {})) { - if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; stats.byProvider[prov].requests += p.requests || 0; stats.byProvider[prov].promptTokens += p.promptTokens || 0; stats.byProvider[prov].completionTokens += p.completionTokens || 0; + stats.byProvider[prov].cachedTokens += p.cachedTokens || 0; stats.byProvider[prov].cost += p.cost || 0; } @@ -490,11 +474,12 @@ export async function getUsageStats(period = "all") { const statsKey = provider ? `${rawModel} (${provider})` : rawModel; const providerDisplayName = providerNodeNameMap[provider] || provider; if (!stats.byModel[statsKey]) { - stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; } stats.byModel[statsKey].requests += m.requests || 0; stats.byModel[statsKey].promptTokens += m.promptTokens || 0; stats.byModel[statsKey].completionTokens += m.completionTokens || 0; + stats.byModel[statsKey].cachedTokens += m.cachedTokens || 0; stats.byModel[statsKey].cost += m.cost || 0; if (dateKey > (stats.byModel[statsKey].lastUsed || "")) stats.byModel[statsKey].lastUsed = dateKey; } @@ -506,11 +491,12 @@ export async function getUsageStats(period = "all") { const providerDisplayName = providerNodeNameMap[provider] || provider; const accountKey = `${rawModel} (${provider} - ${accountName})`; if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; } stats.byAccount[accountKey].requests += a.requests || 0; stats.byAccount[accountKey].promptTokens += a.promptTokens || 0; stats.byAccount[accountKey].completionTokens += a.completionTokens || 0; + stats.byAccount[accountKey].cachedTokens += a.cachedTokens || 0; stats.byAccount[accountKey].cost += a.cost || 0; if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = dateKey; } @@ -525,11 +511,12 @@ export async function getUsageStats(period = "all") { const apiKeyMasked = maskApiKey(apiKeyVal); const apiKeyKey = apiKeyMasked || "local-no-key"; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey }; } stats.byApiKey[akKey].requests += ak.requests || 0; stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0; stats.byApiKey[akKey].completionTokens += ak.completionTokens || 0; + stats.byApiKey[akKey].cachedTokens += ak.cachedTokens || 0; stats.byApiKey[akKey].cost += ak.cost || 0; if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) stats.byApiKey[akKey].lastUsed = dateKey; } @@ -540,11 +527,12 @@ export async function getUsageStats(period = "all") { const provider = ep.provider || ""; const providerDisplayName = providerNodeNameMap[provider] || provider; if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; } stats.byEndpoint[epKey].requests += ep.requests || 0; stats.byEndpoint[epKey].promptTokens += ep.promptTokens || 0; stats.byEndpoint[epKey].completionTokens += ep.completionTokens || 0; + stats.byEndpoint[epKey].cachedTokens += ep.cachedTokens || 0; stats.byEndpoint[epKey].cost += ep.cost || 0; if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; } @@ -595,26 +583,30 @@ export async function getUsageStats(period = "all") { const tokens = parseJson(r.tokens, {}) || {}; const promptTokens = tokens.prompt_tokens || 0; const completionTokens = tokens.completion_tokens || 0; + const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; const entryCost = r.cost || 0; const providerDisplayName = providerNodeNameMap[r.provider] || r.provider; stats.totalPromptTokens += promptTokens; stats.totalCompletionTokens += completionTokens; + stats.totalCachedTokens += cachedTokens; stats.totalCost += entryCost; - if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 }; stats.byProvider[r.provider].requests++; stats.byProvider[r.provider].promptTokens += promptTokens; stats.byProvider[r.provider].completionTokens += completionTokens; + stats.byProvider[r.provider].cachedTokens += cachedTokens; stats.byProvider[r.provider].cost += entryCost; const modelKey = r.provider ? `${r.model} (${r.provider})` : r.model; if (!stats.byModel[modelKey]) { - stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; + stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; } stats.byModel[modelKey].requests++; stats.byModel[modelKey].promptTokens += promptTokens; stats.byModel[modelKey].completionTokens += completionTokens; + stats.byModel[modelKey].cachedTokens += cachedTokens; stats.byModel[modelKey].cost += entryCost; if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = r.timestamp; @@ -622,11 +614,12 @@ export async function getUsageStats(period = "all") { const accountName = connectionMap[r.connectionId] || `Account ${r.connectionId.slice(0, 8)}...`; const accountKey = `${r.model} (${r.provider} - ${accountName})`; if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp }; + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp }; } stats.byAccount[accountKey].requests++; stats.byAccount[accountKey].promptTokens += promptTokens; stats.byAccount[accountKey].completionTokens += completionTokens; + stats.byAccount[accountKey].cachedTokens += cachedTokens; stats.byAccount[accountKey].cost += entryCost; if (new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = r.timestamp; } @@ -637,27 +630,27 @@ export async function getUsageStats(period = "all") { const apiKeyMasked = maskApiKey(r.apiKey); const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; } const ake = stats.byApiKey[akKey]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost; if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; } else { if (!stats.byApiKey["local-no-key"]) { - stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; + stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; } const ake = stats.byApiKey["local-no-key"]; - ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost; if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; } const endpoint = r.endpoint || "Unknown"; const epKey = `${endpoint}|${r.model}|${r.provider || "unknown"}`; if (!stats.byEndpoint[epKey]) { - stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; + stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp }; } const epe = stats.byEndpoint[epKey]; - epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost; + epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cachedTokens += cachedTokens; epe.cost += entryCost; if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp; } } diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 4ce0eb7d..2f4715cf 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -102,12 +102,18 @@ export const KILOCODE_CONFIG = { ...PROVIDER_OAUTH["kilocode"] }; // Cline OAuth Configuration (Local Callback Flow via app.cline.bot) export const CLINE_CONFIG = { ...PROVIDER_OAUTH["cline"] }; +// ClinePass OAuth Configuration (shares Cline's OAuth endpoints) +export const CLINEPASS_CONFIG = { ...PROVIDER_OAUTH["clinepass"] }; + // GitLab Duo OAuth Configuration (Authorization Code Flow with PKCE) export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] }; // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; +// Kimchi OAuth Configuration (Browser token callback flow) +export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -127,6 +133,8 @@ export const PROVIDERS = { KIMI_CODING: "kimi-coding", KILOCODE: "kilocode", CLINE: "cline", + CLINEPASS: "clinepass", GITLAB: "gitlab", CODEBUDDY: "codebuddy-cn", + KIMCHI: "kimchi", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index c66d25bb..361f384d 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -23,8 +23,10 @@ import { KIMI_CODING_CONFIG, KILOCODE_CONFIG, CLINE_CONFIG, + CLINEPASS_CONFIG, GITLAB_CONFIG, CODEBUDDY_CONFIG, + KIMCHI_CONFIG, getOAuthClientMetadata, } from "./constants/oauth"; import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; @@ -1115,6 +1117,64 @@ const PROVIDERS = { providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, }), }, + clinepass: { + config: CLINEPASS_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ClinePass token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), + }, // GitLab Duo - Authorization Code Flow with PKCE // Supports two login modes via loginMode metadata: "oauth" (default) or "pat" gitlab: { @@ -1252,6 +1312,78 @@ const PROVIDERS = { providerSpecificData: {}, }), }, + + kimchi: { + config: KIMCHI_CONFIG, + flowType: "browser_token", + buildAuthUrl: (config, redirectUri, state) => { + const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); + const params = new URLSearchParams({ + callback: redirectUri, + state, + }); + return `${baseUrl}/cli-auth?${params.toString()}`; + }, + exchangeToken: async (config, token) => { + const accessToken = String(token || "").trim(); + if (!accessToken) { + throw new Error("Missing Kimchi token"); + } + + const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; + const validationRes = await fetch(validationUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!validationRes.ok) { + throw new Error(`Kimchi token validation failed: ${validationRes.status}`); + } + + let userInfo = {}; + if (config.userInfoUrl) { + try { + const userRes = await fetch(config.userInfoUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (userRes.ok) { + userInfo = await userRes.json(); + } + } catch { + userInfo = {}; + } + } + + return { + access_token: accessToken, + token_type: "Bearer", + _kimchiUser: userInfo, + }; + }, + mapTokens: (tokens) => { + const user = tokens._kimchiUser || {}; + const userId = user.id ? String(user.id) : ""; + const username = user.username || ""; + const email = user.email || (userId ? `kimchi-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: null, + email, + displayName: user.name || username || null, + providerSpecificData: { + authMethod: "browser_token", + userId, + username, + }, + }; + }, + }, }; /** diff --git a/src/lib/oauth/services/kimchi.js b/src/lib/oauth/services/kimchi.js new file mode 100644 index 00000000..7e929e9e --- /dev/null +++ b/src/lib/oauth/services/kimchi.js @@ -0,0 +1,133 @@ +// Kimchi browser-login service. +// +// Ports Kimchi CLI's authenticateViaBrowser (src/cli-auth/index.ts) onto +// 9Router's shared startLocalServer util (same one xai/antigravity use). +// Simpler than those: the token arrives directly on the callback query +// string — no authorization-code exchange, no PKCE. +// +// In-flight logins are held in `sessions` keyed by state. The OAuthModal +// device_code flow starts one via requestDeviceCode(); pollToken() peeks +// at the resolved token; the generic [provider]/[action] route calls +// createProviderConnection with the real token. +import { randomBytes } from "node:crypto"; +import { startLocalServer } from "../utils/server.js"; +import { KIMCHI_CONFIG } from "../constants/oauth.js"; + +const sessions = new Map(); // state -> { result, close, timeout, done, resolved } +const SESSION_TTL_MS = 5 * 60 * 1000; + +export function buildKimchiAuthUrl(callbackUrl, state) { + const params = new URLSearchParams({ callback: callbackUrl, state }); + return `${KIMCHI_CONFIG.webAppUrl}/cli-auth?${params.toString()}`; +} + +export function generateState() { + return randomBytes(32).toString("hex"); +} + +// Returns the resolved { token } if the session for `state` has completed, +// or null if it is still pending / unknown. +export function getResolvedSession(state) { + const s = sessions.get(state); + if (!s || !s.done || !s.resolved) return null; + return s.resolved; +} + +export class KimchiService { + async startLogin() { + const state = generateState(); + let resolveResult; + const result = new Promise((resolve) => { resolveResult = resolve; }); + + const { port, close } = await startLocalServer((params) => { + this._handleCallback(params, state) + .then(resolveResult) + .catch((err) => resolveResult({ error: err.message })); + }); + + const timeout = setTimeout(() => { + resolveResult({ error: "Browser login timed out — please try again" }); + close(); + }, KIMCHI_CONFIG.callbackTimeoutMs); + + sessions.set(state, { result, close, timeout, done: false, resolved: null }); + + // Stash the resolved value so pollToken() can retrieve the real token, + // close the loopback server, and reap the session after a TTL so the + // Map can't grow unbounded across many logins. + result.then((r) => { + const s = sessions.get(state); + if (!s) return; + s.done = true; + s.resolved = r; + clearTimeout(s.timeout); + try { s.close(); } catch { /* already closed */ } + setTimeout(() => sessions.delete(state), SESSION_TTL_MS).unref?.(); + }); + + const callbackUrl = `http://127.0.0.1:${port}${KIMCHI_CONFIG.callbackPath}`; + const authUrl = buildKimchiAuthUrl(callbackUrl, state); + return { authUrl, port, state, result, close }; + } + + async _handleCallback(params, expectedState) { + if (params.error) { + throw new Error(params.error_description || params.error); + } + const candidate = params.state; + if (!candidate || candidate !== expectedState) { + throw new Error("This request isn't valid. Please restart the Kimchi login flow."); + } + const token = params.token; + if (!token) { + throw new Error("No token was returned by the Kimchi authentication server"); + } + const check = await this.validateToken(token); + if (!check.valid) { + throw new Error(check.error || "Kimchi token validation failed"); + } + return { token }; + } + + async fetchProfile(token) { + try { + const res = await fetch(KIMCHI_CONFIG.meUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return {}; + const j = await res.json(); + return { displayName: j.name, email: j.email, username: j.username }; + } catch { + return {}; + } + } + + // Validate a token against Kimchi's supported-providers endpoint. + // 200 → valid; 401/403 → invalid; anything else (incl. network/timeout) + // → fail-open valid so a flaky validation never blocks a good login. + async validateToken(token) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + let status = 0; + try { + const res = await fetch(KIMCHI_CONFIG.validationUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + status = res.status; + } catch { + // Network error / abort → fail-open + return { valid: true }; + } finally { + clearTimeout(timer); + } + if (status === 200) return { valid: true }; + if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" }; + if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" }; + return { valid: true }; + } +} diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js index 4fcc2de4..e85f9dc6 100644 --- a/src/mitm/cert/generate.js +++ b/src/mitm/cert/generate.js @@ -7,8 +7,8 @@ const { generateRootCA, loadRootCA, generateLeafCert } = require("./rootCA"); * Generate Root CA certificate (one-time setup) * This replaces the old static wildcard cert approach */ -async function generateCert() { - return await generateRootCA(); +function generateCert() { + return generateRootCA(); } /** diff --git a/src/mitm/cert/rootCA.js b/src/mitm/cert/rootCA.js index d5e73d89..3f033f0a 100644 --- a/src/mitm/cert/rootCA.js +++ b/src/mitm/cert/rootCA.js @@ -23,7 +23,7 @@ function isCertExpired(certPath) { * Generate Root CA certificate (only once, auto-regenerate if expired) * This Root CA will sign all dynamic leaf certificates */ -async function generateRootCA() { +function generateRootCA() { const exists = fs.existsSync(ROOT_CA_KEY_PATH) && fs.existsSync(ROOT_CA_CERT_PATH); if (exists && !isCertExpired(ROOT_CA_CERT_PATH)) { console.log("✅ Root CA already exists"); diff --git a/src/mitm/server.js b/src/mitm/server.js index 2c5c876a..d675e6a3 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -9,7 +9,7 @@ const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); -const { getCertForDomain } = require("./cert/generate"); +const { generateCert, getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); const { applyAntigravityIdeVersionOverride } = require("./antigravityIdeVersion"); const LOCAL_PORT = 443; @@ -57,6 +57,11 @@ function sniCallback(servername, cb) { let sslOptions; try { + if (!fs.existsSync(path.join(MITM_DIR, "rootCA.key")) || !fs.existsSync(path.join(MITM_DIR, "rootCA.crt"))) { + log("Root CA missing, generating..."); + generateCert(); + } + const rootKey = fs.readFileSync(path.join(MITM_DIR, "rootCA.key")); const rootCert = fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")); rootCAPem = rootCert.toString("utf8"); diff --git a/src/shared/components/EditConnectionModal.js b/src/shared/components/EditConnectionModal.js index 4823f501..1cf13f16 100644 --- a/src/shared/components/EditConnectionModal.js +++ b/src/shared/components/EditConnectionModal.js @@ -6,7 +6,8 @@ import Modal from "@/shared/components/Modal"; import Input from "@/shared/components/Input"; import Button from "@/shared/components/Button"; import Badge from "@/shared/components/Badge"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; +import Select from "@/shared/components/Select"; export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) { const [formData, setFormData] = useState({ @@ -21,6 +22,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on organization: "", }); const [cloudflareData, setCloudflareData] = useState({ accountId: "" }); + const [region, setRegion] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [validating, setValidating] = useState(false); @@ -46,6 +48,12 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) { setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" }); } + // Load region for providers that support it (e.g. xiaomi-tokenplan) + const providerCfg = AI_PROVIDERS?.[connection.provider]; + if (providerCfg?.regions) { + const savedRegion = connection.providerSpecificData?.region || providerCfg.defaultRegion || providerCfg.regions[0]?.id || ""; + setRegion(savedRegion); + } setTestResult(null); setValidationResult(null); } @@ -57,6 +65,13 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on const isCompatible = connection ? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider)) : false; + const providerRegions = connection ? (AI_PROVIDERS?.[connection.provider]?.regions || null) : null; + + // Build providerSpecificData for region-aware providers + const buildRegionSpecificData = () => { + if (providerRegions && region) return { ...((connection?.providerSpecificData) || {}), region }; + return undefined; + }; const handleTest = async () => { if (!connection?.provider) return; @@ -86,6 +101,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on apiKey: formData.apiKey, ...(isAzure ? { providerSpecificData: azureData } : {}), ...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}), + ...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}), }), }); const data = await res.json(); @@ -120,6 +136,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on apiKey: formData.apiKey, ...(isAzure ? { providerSpecificData: azureData } : {}), ...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}), + ...(providerRegions ? { providerSpecificData: buildRegionSpecificData() } : {}), }), }); const data = await res.json(); @@ -150,6 +167,10 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on if (isCloudflareAi) { updates.providerSpecificData = { accountId: cloudflareData.accountId }; } + // Persist updated region for region-aware providers + if (providerRegions && region) { + updates.providerSpecificData = buildRegionSpecificData(); + } await onSave(updates); } finally { @@ -243,6 +264,15 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on </div> )} + {providerRegions && ( + <Select + label="Region" + value={region} + onChange={(e) => setRegion(e.target.value)} + options={providerRegions.map((r) => ({ value: r.id, label: r.label }))} + /> + )} + {!isCompatible && !isAzure && !isCloudflareAi && ( <div className="flex items-center gap-3"> <Button onClick={handleTest} variant="secondary" disabled={testing}> diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 424e23a1..6cb6f4f8 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -65,7 +65,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError(err.message); setStep("error"); } - }, [authData, provider, onSuccess]); + }, [authData, provider, onSuccess, oauthMeta]); const completeXaiManualCode = useCallback(async (code) => { if (!authData?.state) return; @@ -387,7 +387,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const handleCallback = async (data) => { if (callbackProcessedRef.current) return; // Already processed - const { code, state, error: callbackError, errorDescription } = data; + const { code, token, state, error: callbackError, errorDescription } = data; if (callbackError) { callbackProcessedRef.current = true; @@ -396,9 +396,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return; } - if (code) { + if (token || code) { callbackProcessedRef.current = true; - await exchangeTokens(code, state); + await exchangeTokens(token || code, state); } }; @@ -477,8 +477,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return; } + if (provider === "kimchi" && input && !input.includes("://") && !input.includes("?")) { + await exchangeTokens(input, null); + return; + } + const url = new URL(input); const code = url.searchParams.get("code"); + const token = url.searchParams.get("token"); const state = url.searchParams.get("state"); const errorParam = url.searchParams.get("error"); @@ -486,11 +492,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, throw new Error(url.searchParams.get("error_description") || errorParam); } - if (!code) { - throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL"); + if (!code && !token) { + throw new Error( + provider === "xai" + ? "Paste the callback URL or copied xAI code" + : provider === "kimchi" + ? "No Kimchi token found in URL" + : "No authorization code found in URL" + ); } - await exchangeTokens(code, state); + await exchangeTokens(token || code, state); } catch (err) { setError(err.message); setStep("error"); @@ -509,11 +521,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!provider || !providerInfo) return null; const isXaiProvider = provider === "xai"; + const isKimchiProvider = provider === "kimchi"; const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`; const manualPlaceholder = isXaiProvider ? "http://127.0.0.1:56121/callback?code=... or copied code" - : placeholderUrl; + : isKimchiProvider + ? `${placeholderUrl.replace("code=...", "token=...")} or copied token` + : placeholderUrl; return ( <Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg"> @@ -554,11 +569,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, <div> <p className="text-sm font-medium mb-2"> - Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here + Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : isKimchiProvider ? "callback URL or copied token" : "callback URL"} here </p> <p className="text-xs text-text-muted mb-2"> {provider === "xai" ? "If xAI shows a code instead of redirecting, paste that code here." + : isKimchiProvider + ? "After authorization, copy the full callback URL or token from your browser." : "After authorization, copy the full URL from your browser."} </p> <Input diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 950a7af9..607cbbc0 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -89,9 +89,16 @@ function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) { .map(([key, data]) => { const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); const totalCost = data.cost || 0; - const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0; + // ponytail: cost split is a token-share allocation of the (rate-accurate) + // server total, not a per-rate recompute. cached is a subset of prompt, so + // peel it out of the input share. Upgrade to a stored per-component cost + // breakdown if exact cached-rate cost display is needed. + const cachedTokens = data.cachedTokens || 0; + const nonCachedInput = Math.max(0, (data.promptTokens || 0) - cachedTokens); + const inputCost = totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0; + const cachedCost = totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0; const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; - return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 }; + return { ...data, key, totalTokens, totalCost, inputCost, cachedCost, outputCost, pending: pendingMap[key] || 0 }; }) .sort((a, b) => { let valA = a[sortBy]; @@ -122,7 +129,7 @@ function groupDataByKey(data, keyField) { if (!groups[gk]) { groups[gk] = { groupKey: gk, - summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, + summary: { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, cachedCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, items: [], }; } @@ -130,9 +137,11 @@ function groupDataByKey(data, keyField) { s.requests += item.requests || 0; s.promptTokens += item.promptTokens || 0; s.completionTokens += item.completionTokens || 0; + s.cachedTokens += item.cachedTokens || 0; s.totalTokens += item.totalTokens || 0; s.cost += item.cost || 0; s.inputCost += item.inputCost || 0; + s.cachedCost += item.cachedCost || 0; s.outputCost += item.outputCost || 0; s.pending += item.pending || 0; if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) { diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 18ecab72..a593da92 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -56,6 +56,7 @@ export const MITM_TOOLS = { configType: "mitm", mitmDomain: "q.us-east-1.amazonaws.com", defaultModels: [ + { id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" }, { id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" }, @@ -391,4 +392,3 @@ export const getProviderModelsForMapping = (providers) => { }); return result; }; - diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index ebf5d120..0650d086 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -62,16 +62,34 @@ export const CONSOLE_LOG_CONFIG = { // Client-side store TTL: how long fetched data stays fresh before re-fetching export const CLIENT_STORE_TTL_MS = 60000; -// Claude auto-ping: keep 5h window warm by sending a tiny request right after reset -export const CLAUDE_AUTOPING_CONFIG = { - settingsKey: "claudeAutoPing", // settings table field +// Quota auto-ping: keep 5h windows warm by sending a tiny request right after reset. +export const QUOTA_AUTOPING_CONFIG = { tickIntervalMs: 60000, // scheduler tick pingLeadMs: 5000, // fire once reset passes (within tolerance) - pingModel: "claude-haiku-4-5-20251001", // cheapest model - pingText: "hi", - pingMaxTokens: 1, refreshAheadMs: 300000, // refetch usage when within 5min of reset - fiveHourKey: "session (5h)", // quota key returned by usage handler + failureCooldownMs: 900000, // avoid failed ping spam while upstream/auth is unhealthy + providers: { + claude: { + settingsKey: "claudeAutoPing", // preserve existing settings contract + quotaKey: "session (5h)", // quota key returned by usage handler + pingModel: "claude-haiku-4-5-20251001", + pingText: "hi", + pingMaxTokens: 1, + }, + codex: { + settingsKey: "codexAutoPing", + quotaKey: "session", + pingWhenResetAtSlides: true, + resetAtDriftMs: 30000, + minPingIntervalMs: 600000, + skipWhenBlockingQuotaExhausted: true, + // Free and Plus Codex accounts both expose gpt-5.5; avoid fallback probes that waste requests. + pingModel: "gpt-5.5", + pingText: "hi", + pingInstructions: "Reply with OK.", + pingReasoningEffort: "none", + }, + }, }; // Re-export from providers.js for backward compatibility diff --git a/src/shared/services/claudeAutoPing.js b/src/shared/services/claudeAutoPing.js deleted file mode 100644 index 14abc127..00000000 --- a/src/shared/services/claudeAutoPing.js +++ /dev/null @@ -1,117 +0,0 @@ -// Claude auto-ping scheduler: warms the 5h window by sending a tiny request right after reset. -import "open-sse/index.js"; - -import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb"; -import { getClaudeUsage } from "open-sse/services/usage/claude.js"; -import { CLAUDE_CLI_SPOOF_HEADERS } from "open-sse/providers/shared.js"; -import { proxyAwareFetch } from "open-sse/utils/proxyFetch.js"; -import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route.js"; -import { CLAUDE_AUTOPING_CONFIG } from "@/shared/constants/config"; - -const C = CLAUDE_AUTOPING_CONFIG; -const PING_URL = "https://api.anthropic.com/v1/messages?beta=true"; - -const g = (global.__claudeAutoPing ??= { interval: null, running: false, resetCache: {} }); - -function buildProxyOptions(cfg) { - return { - connectionProxyEnabled: cfg.connectionProxyEnabled === true, - connectionProxyUrl: cfg.connectionProxyUrl || "", - connectionNoProxy: cfg.connectionNoProxy || "", - vercelRelayUrl: cfg.vercelRelayUrl || "", - strictProxy: false, - }; -} - -// Send minimal "hi" to start a fresh 5h window -async function sendPing(accessToken, proxyOptions) { - const res = await proxyAwareFetch(PING_URL, { - method: "POST", - headers: { - ...CLAUDE_CLI_SPOOF_HEADERS, - "Authorization": `Bearer ${accessToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - model: C.pingModel, - max_tokens: C.pingMaxTokens, - messages: [{ role: "user", content: C.pingText }], - }), - }, proxyOptions); - return res.ok; -} - -async function pingConnection(conn) { - // Cached resetAt is stable for the whole 5h window; skip usage poll until near reset - const cachedReset = g.resetCache[conn.id]; - if (cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return; - - const proxyCfg = await resolveConnectionProxyConfig(conn.providerSpecificData); - const proxyOptions = buildProxyOptions(proxyCfg); - - // Refresh token if needed, then read 5h reset time - let connection = conn; - try { - const r = await refreshAndUpdateCredentials(connection, false, proxyOptions); - connection = r.connection; - } catch (e) { - console.warn(`[AutoPing] ${conn.id}: refresh failed: ${e.message}`); - return; - } - - const usage = await getClaudeUsage(connection.accessToken, proxyOptions); - const resetAt = usage?.quotas?.[C.fiveHourKey]?.resetAt; - if (!resetAt) return; - - // Cache resetAt to gate future ticks - g.resetCache[conn.id] = resetAt; - - const resetMs = new Date(resetAt).getTime(); - const now = Date.now(); - - // Only ping once per reset cycle, right after window flips - if (now < resetMs - C.pingLeadMs) return; - if (connection.lastPingedResetAt === resetAt) return; - - const ok = await sendPing(connection.accessToken, proxyOptions); - await updateProviderConnection(connection.id, { - lastPingedResetAt: resetAt, - lastPingAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - console.log(`[AutoPing] ${connection.id}: ping ${ok ? "sent" : "failed"} (reset ${resetAt})`); -} - -async function tick() { - if (g.running) return; - g.running = true; - try { - const settings = await getSettings(); - const enabledMap = settings[C.settingsKey]?.connections || {}; - if (Object.keys(enabledMap).length === 0) return; - - const conns = await getProviderConnections({ provider: "claude", isActive: true }); - // Only ping connections the user explicitly enabled - const targets = conns.filter((c) => c.authType === "oauth" && enabledMap[c.id] === true); - if (targets.length === 0) return; - - for (const conn of targets) { - try { - await pingConnection(conn); - } catch (e) { - console.warn(`[AutoPing] ${conn.id}: ${e.message}`); - } - } - } catch (e) { - console.warn("[AutoPing] tick error:", e.message); - } finally { - g.running = false; - } -} - -export function startClaudeAutoPing() { - if (g.interval) return; - g.interval = setInterval(() => { tick().catch(() => {}); }, C.tickIntervalMs); - if (g.interval.unref) g.interval.unref(); -} diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 234c90d6..e2914e0d 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -14,7 +14,7 @@ import { WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX, } from "@/lib/tunnel"; import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager"; -import { startClaudeAutoPing } from "@/shared/services/claudeAutoPing"; +import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing"; import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache"; // Inject correct paths and DB hooks into manager.js (CJS) from ESM context @@ -89,7 +89,7 @@ export async function initializeApp() { startWatchdog(); startNetworkMonitor(); autoStartMitm(); - startClaudeAutoPing(); + startQuotaAutoPing(); } catch (error) { console.error("[InitApp] Error:", error); } diff --git a/src/shared/services/quotaAutoPing.js b/src/shared/services/quotaAutoPing.js new file mode 100644 index 00000000..694a5e2b --- /dev/null +++ b/src/shared/services/quotaAutoPing.js @@ -0,0 +1,298 @@ +// Quota auto-ping scheduler: warms 5h windows by sending tiny opt-in requests right after reset. +import "open-sse/index.js"; + +import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { getClaudeUsage } from "open-sse/services/usage/claude.js"; +import { getCodexUsage } from "open-sse/services/usage/codex.js"; +import { getExecutor } from "open-sse/executors/index.js"; +import { CLAUDE_CLI_SPOOF_HEADERS } from "open-sse/providers/shared.js"; +import { proxyAwareFetch } from "open-sse/utils/proxyFetch.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route.js"; +import { QUOTA_AUTOPING_CONFIG } from "@/shared/constants/config"; + +const C = QUOTA_AUTOPING_CONFIG; +const CLAUDE_PING_URL = "https://api.anthropic.com/v1/messages?beta=true"; + +const providerHandlers = { + claude: { + getUsage: getClaudeUsage, + sendPing: sendClaudePing, + }, + codex: { + getUsage: getCodexUsage, + sendPing: sendCodexPing, + }, +}; + +// Survive Next.js hot reload and keep one scheduler per server process. +const g = (global.__quotaAutoPing ??= { + interval: null, + running: false, + resetCache: {}, + failureCache: {}, +}); + +function cacheKey(provider, connectionId) { + return `${provider}:${connectionId}`; +} + +function normalizeResetKey(resetAt) { + const ms = new Date(resetAt).getTime(); + if (!Number.isFinite(ms)) return resetAt; + return new Date(Math.floor(ms / 60000) * 60000).toISOString(); +} + +function getResetDriftMs(previousResetAt, nextResetAt) { + const previousMs = new Date(previousResetAt).getTime(); + const nextMs = new Date(nextResetAt).getTime(); + if (!Number.isFinite(previousMs) || !Number.isFinite(nextMs)) return 0; + return nextMs - previousMs; +} + +function toFiniteNumber(value, fallback = null) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function isQuotaExhausted(quota) { + if (!quota || quota.unlimited === true) return false; + const remaining = toFiniteNumber(quota.remaining); + if (remaining !== null) return remaining <= 0; + + const used = toFiniteNumber(quota.used); + const total = toFiniteNumber(quota.total); + return total !== null && total > 0 && used !== null && used >= total; +} + +function wasPingedRecently(connection, intervalMs, nowMs = Date.now()) { + if (!intervalMs) return false; + const lastPingAtMs = new Date(connection.lastPingAt).getTime(); + return Number.isFinite(lastPingAtMs) && nowMs - lastPingAtMs < intervalMs; +} + +function isBlockingQuotaName(name, sessionKey) { + if (name === sessionKey) return false; + return !String(name).toLowerCase().includes("session"); +} + +function hasExhaustedBlockingQuota(quotas, sessionKey) { + return Object.entries(quotas || {}).some(([name, quota]) => isBlockingQuotaName(name, sessionKey) && isQuotaExhausted(quota)); +} + +function shouldPingForReset(providerConfig, cachedReset, resetAt, now) { + if (providerConfig.pingWhenResetAtSlides) { + return Boolean(cachedReset) && getResetDriftMs(cachedReset, resetAt) >= (providerConfig.resetAtDriftMs || 0); + } + + const resetMs = new Date(resetAt).getTime(); + return Number.isFinite(resetMs) && now >= resetMs - C.pingLeadMs; +} + +function buildProxyOptions(cfg) { + return { + connectionProxyEnabled: cfg.connectionProxyEnabled === true, + connectionProxyUrl: cfg.connectionProxyUrl || "", + connectionNoProxy: cfg.connectionNoProxy || "", + vercelRelayUrl: cfg.vercelRelayUrl || "", + strictProxy: false, + }; +} + +async function sendClaudePing(connection, providerConfig, proxyOptions, deps) { + const res = await deps.proxyAwareFetch(CLAUDE_PING_URL, { + method: "POST", + headers: { + ...CLAUDE_CLI_SPOOF_HEADERS, + "Authorization": `Bearer ${connection.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: providerConfig.pingModel, + max_tokens: providerConfig.pingMaxTokens, + messages: [{ role: "user", content: providerConfig.pingText }], + }), + }, proxyOptions); + return res.ok; +} + +function buildCodexPingInput(text) { + return [{ + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }]; +} + +async function drainResponseBody(response) { + if (typeof response?.text === "function") { + await response.text(); + return; + } + + const reader = response?.body?.getReader?.(); + if (!reader) return; + + try { + while (true) { + const { done } = await reader.read(); + if (done) return; + } + } finally { + reader.releaseLock?.(); + } +} + +async function sendCodexPing(connection, providerConfig, proxyOptions, deps) { + const executor = deps.getExecutor("codex"); + const { response } = await executor.execute({ + model: providerConfig.pingModel, + stream: true, + credentials: { + accessToken: connection.accessToken, + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData, + }, + proxyOptions, + log: console, + body: { + model: providerConfig.pingModel, + input: buildCodexPingInput(providerConfig.pingText), + instructions: providerConfig.pingInstructions, + reasoning: providerConfig.pingReasoningEffort + ? { effort: providerConfig.pingReasoningEffort, summary: "auto" } + : undefined, + store: false, + stream: true, + }, + }); + if (!response.ok) { + try { await response.body?.cancel?.(); } catch { /* noop */ } + return false; + } + + // Codex only starts the 5h window after the streaming response completes. + await drainResponseBody(response); + return true; +} + +function shouldSkipAfterFailure(state, key, nowMs = Date.now()) { + const failedAt = state.failureCache[key]; + return failedAt && nowMs - failedAt < C.failureCooldownMs; +} + +async function pingConnection(conn, provider, providerConfig, handler, deps, state = g) { + const key = cacheKey(provider, conn.id); + + // resetAt is stable for time-based windows; Codex polls every tick because inactive windows slide forward. + const cachedReset = state.resetCache[key]; + if (!providerConfig.pingWhenResetAtSlides && cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return; + + // Avoid hammering provider auth/quota endpoints if a ping failed recently. + if (shouldSkipAfterFailure(state, key)) return; + + const proxyCfg = await deps.resolveConnectionProxyConfig(conn.providerSpecificData); + const proxyOptions = buildProxyOptions(proxyCfg); + + let connection = conn; + try { + const r = await deps.refreshAndUpdateCredentials(connection, false, proxyOptions); + connection = r.connection; + } catch (e) { + state.failureCache[key] = Date.now(); + console.warn(`[AutoPing] ${provider}:${conn.id}: refresh failed: ${e.message}`); + return; + } + + const usage = await handler.getUsage(connection.accessToken, proxyOptions); + const quotas = usage?.quotas || {}; + const quota = quotas?.[providerConfig.quotaKey]; + const resetAt = quota?.resetAt; + if (!resetAt) return; + + state.resetCache[key] = resetAt; + + if (providerConfig.skipWhenBlockingQuotaExhausted && hasExhaustedBlockingQuota(quotas, providerConfig.quotaKey)) return; + if (isQuotaExhausted(quota)) return; + + const now = Date.now(); + const resetKey = normalizeResetKey(resetAt); + const lastPingedResetKey = connection.lastPingedResetKey || normalizeResetKey(connection.lastPingedResetAt); + + // Claude waits for reset. Codex pings only when resetAt slides, which means the 5h window is inactive. + if (!shouldPingForReset(providerConfig, cachedReset, resetAt, now)) return; + if (wasPingedRecently(connection, providerConfig.minPingIntervalMs, now)) return; + if (lastPingedResetKey === resetKey) return; + + const ok = await handler.sendPing(connection, providerConfig, proxyOptions, deps); + if (!ok) { + // Do not mark reset as pinged unless upstream accepted the tiny request. + state.failureCache[key] = Date.now(); + console.warn(`[AutoPing] ${provider}:${connection.id}: ping failed (reset ${resetAt})`); + return; + } + + delete state.failureCache[key]; + await deps.updateProviderConnection(connection.id, { + lastPingedResetAt: resetAt, + lastPingedResetKey: resetKey, + lastPingAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + console.log(`[AutoPing] ${provider}:${connection.id}: ping sent (reset ${resetAt})`); +} + +function createDefaultDeps() { + return { + getSettings, + getProviderConnections, + updateProviderConnection, + resolveConnectionProxyConfig, + refreshAndUpdateCredentials, + proxyAwareFetch, + getExecutor, + }; +} + +export async function runQuotaAutoPingTick(deps = createDefaultDeps(), state = g) { + if (state.running) return; + state.running = true; + try { + const settings = await deps.getSettings(); + + for (const [provider, providerConfig] of Object.entries(C.providers)) { + const handler = providerHandlers[provider]; + if (!handler) continue; + + const enabledMap = settings?.[providerConfig.settingsKey]?.connections || {}; + if (Object.keys(enabledMap).length === 0) continue; + + const conns = await deps.getProviderConnections({ provider, isActive: true }); + const targets = conns.filter((conn) => conn.authType === "oauth" && enabledMap[conn.id] === true); + for (const conn of targets) { + try { + await pingConnection(conn, provider, providerConfig, handler, deps, state); + } catch (e) { + state.failureCache[cacheKey(provider, conn.id)] = Date.now(); + console.warn(`[AutoPing] ${provider}:${conn.id}: ${e.message}`); + } + } + } + } catch (e) { + console.warn("[AutoPing] tick error:", e.message); + } finally { + state.running = false; + } +} + +export function startQuotaAutoPing() { + if (g.interval) return; + console.log("[AutoPing] scheduler started"); + runQuotaAutoPingTick().catch(() => {}); + g.interval = setInterval(() => { runQuotaAutoPingTick().catch(() => {}); }, C.tickIntervalMs); + if (g.interval.unref) g.interval.unref(); +} diff --git a/tests/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt index 78e0e76d..9ffd63f9 100644 --- a/tests/__baseline__/known-fails.txt +++ b/tests/__baseline__/known-fails.txt @@ -1,6 +1,5 @@ tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response -tests/unit/kiro-model-slots.test.js :: Kiro MITM model slots offers a mappable slot for the agent default model id 'auto' tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import linux uses single hardcoded path and original error message @@ -23,4 +22,4 @@ tests/unit/rtk.test.js :: compressMessages (enabled) skips when body has no mess tests/unit/translator-request-normalization.test.js :: request normalization claudeToOpenAIRequest flattens text-only content arrays into string tests/unit/translator-request-normalization.test.js :: request normalization filterToOpenAIFormat flattens text-only arrays to string tests/unit/translator-request-normalization.test.js :: request normalization parseSSELine supports provider raw NDJSON stream lines -tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe \ No newline at end of file +tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe diff --git a/tests/translator/bugs-antigravity.test.js b/tests/translator/bugs-antigravity.test.js index c20fcd2e..47c36942 100644 --- a/tests/translator/bugs-antigravity.test.js +++ b/tests/translator/bugs-antigravity.test.js @@ -1,7 +1,7 @@ // Real Antigravity-MITM requests (Gemini-internal: { request: { contents, ... } }) → OpenAI. import { describe, it, expect } from "vitest"; import "./registerAll.js"; -import { translateRequest } from "../../open-sse/translator/index.js"; +import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js"; import { FORMATS } from "../../open-sse/translator/formats.js"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; @@ -9,10 +9,9 @@ const AG2O = (req) => translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null); describe("Antigravity → OpenAI", () => { - // antigravity-to-openai.js:177-189 — content with BOTH functionResponse and functionCall/text - // returns toolResults early → drops the tool calls / text. - // KNOWN BUG - it.fails("functionResponse + functionCall in same content keeps both", () => { + // antigravity-to-openai.js — content with BOTH functionResponse and functionCall/text + // previously returned toolResults early → dropped tool calls / text (fixed in #2225) + it("functionResponse + functionCall in same content keeps both", () => { const out = AG2O({ contents: [{ role: "model", @@ -54,6 +53,32 @@ describe("Antigravity → OpenAI", () => { }); }); +describe("Antigravity → Claude", () => { + it("tool call input_json_delta includes Anthropic index", () => { + const state = initState(FORMATS.CLAUDE); + const events = translateResponse(FORMATS.ANTIGRAVITY, FORMATS.CLAUDE, { + response: { + responseId: "resp-1", + modelVersion: "gemini-pro-agent", + candidates: [{ + content: { + role: "model", + parts: [{ functionCall: { name: "bash", args: { command: "git status" } } }], + }, + finishReason: "STOP", + index: 0, + }], + }, + }, state); + + const jsonDelta = events.find( + (event) => event.type === "content_block_delta" && event.delta?.type === "input_json_delta" + ); + expect(jsonDelta).toMatchObject({ index: expect.any(Number) }); + expect(JSON.parse(jsonDelta.delta.partial_json)).toEqual({ command: "git status" }); + }); +}); + describe("Antigravity executor", () => { it("strips optional from nested tool schemas", () => { const out = new AntigravityExecutor().transformRequest("gemini-2.5-pro", { diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 7e01951f..1b189ec6 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -171,6 +171,7 @@ describe("Kiro → Claude (direct route, OpenAI-shaped chunks from executor)", ( const jsonDelta = events.find( (e) => e.type === "content_block_delta" && e.delta.type === "input_json_delta" ); + expect(jsonDelta.index).toBeDefined(); expect(jsonDelta.delta.partial_json).toBe('{"q":"x"}'); const md = events.find((e) => e.type === "message_delta"); expect(md.delta.stop_reason).toBe("tool_use"); diff --git a/tests/translator/real/nvidia-thinking.e2e.test.js b/tests/translator/real/nvidia-thinking.e2e.test.js new file mode 100644 index 00000000..2e2f67e9 --- /dev/null +++ b/tests/translator/real/nvidia-thinking.e2e.test.js @@ -0,0 +1,58 @@ +// E2E: hit live local proxy → verify nvidia MiniMax M2.7 doesn't 400 on +// unsupported "thinking" param (nvidia NIM is OpenAI-compatible). +// Requires dev server running on NV_E2E_PORT + an active router API key in DB. +// RUN_E2E=1 npx vitest run --config tests/vitest.config.js tests/translator/real/nvidia-thinking.e2e.test.js +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 BASE = `http://localhost:${PORT}`; +const MODELS = [ + "nvidia/minimaxai/minimax-m2.7", + "nvidia/minimaxai/minimax-m3", + "nvidia/z-ai/glm-5.2", + "nvidia/deepseek-ai/deepseek-v4-pro", + "nvidia/deepseek-ai/deepseek-v4-flash", + "nvidia/moonshotai/kimi-k2.6", + "nvidia/nvidia/nemotron-3-ultra-550b-a55b", +]; +const RUN = process.env.RUN_E2E === "1"; +const maybe = RUN ? describe : describe.skip; + +async function drain(res) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + return out; +} + +maybe("nvidia thinking e2e", () => { + let apiKey = ""; + beforeAll(async () => { + const keys = await getApiKeys(); + apiKey = keys.find((k) => k.isActive)?.key || process.env.NV_E2E_KEY || ""; + }); + + it.each(MODELS)("%s with reasoning_effort -> no 'thinking' 400", async (model) => { + if (!apiKey) return expect(true).toBe(true); + const res = await fetch(`${BASE}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + model, + stream: true, + max_tokens: 64, + reasoning_effort: "low", + messages: [{ role: "user", content: "Reply with the single word: hi" }], + }), + }); + const raw = await drain(res); + expect(/Unsupported parameter.*thinking/i.test(raw), `${model} rejected 'thinking'`).toBe(false); + expect(res.status, `${model} bad status ${res.status}`).toBeLessThan(400); + }, 90000); +}); diff --git a/tests/unit/alicode-cache-control-2069.test.js b/tests/unit/alicode-cache-control-2069.test.js new file mode 100644 index 00000000..53d15e7f --- /dev/null +++ b/tests/unit/alicode-cache-control-2069.test.js @@ -0,0 +1,66 @@ +// #2069 — cache_control markers stripped for alicode/alicode-intl (DashScope) providers. +// DashScope supports explicit cache_control: { type: "ephemeral" } in content blocks, +// but the default filterToOpenAIFormat strips them. preserveCacheControl quirk opts-in. +import { describe, it, expect } from "vitest"; +import { filterToOpenAIFormat } from "../../open-sse/translator/formats/openai.js"; + +const msgWithCache = [ + { + role: "user", + content: [ + { type: "text", text: "large context", cache_control: { type: "ephemeral" } }, + ], + }, + { + role: "assistant", + content: [ + { type: "text", text: "reply", cache_control: { type: "ephemeral" } }, + ], + }, +]; + +describe("filterToOpenAIFormat cache_control handling (#2069)", () => { + it("strips cache_control by default (all standard OpenAI providers)", () => { + const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) }; + filterToOpenAIFormat(body); + for (const msg of body.messages) { + for (const block of msg.content) { + expect(block.cache_control).toBeUndefined(); + } + } + }); + + it("preserves cache_control when preserveCacheControl option is true (alicode/DashScope)", () => { + const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) }; + filterToOpenAIFormat(body, { preserveCacheControl: true }); + for (const msg of body.messages) { + for (const block of msg.content) { + expect(block.cache_control).toEqual({ type: "ephemeral" }); + } + } + }); + + it("always strips signature regardless of preserveCacheControl", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "hi", signature: "sig123", cache_control: { type: "ephemeral" } }, + ], + }, + ], + }; + filterToOpenAIFormat(body, { preserveCacheControl: true }); + expect(body.messages[0].content[0].signature).toBeUndefined(); + expect(body.messages[0].content[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("does not add cache_control when block had none (preserveCacheControl: true)", () => { + const body = { + messages: [{ role: "user", content: [{ type: "text", text: "no cache" }] }], + }; + filterToOpenAIFormat(body, { preserveCacheControl: true }); + expect(body.messages[0].content[0].cache_control).toBeUndefined(); + }); +}); diff --git a/tests/unit/cached-token-e2e.test.js b/tests/unit/cached-token-e2e.test.js new file mode 100644 index 00000000..f32f6a3e --- /dev/null +++ b/tests/unit/cached-token-e2e.test.js @@ -0,0 +1,84 @@ +// End-to-end: a cache-bearing request flows through canonicalizeUsage → +// saveRequestUsage → getUsageStats, proving cached tokens are persisted, +// aggregated, and cost is computed correctly (the bug this branch fixes). +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { canonicalizeUsage } from "../../open-sse/utils/usageTracking.js"; + +const originalDataDir = process.env.DATA_DIR; +let tempDir; +let db; + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cached-e2e-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + db = await import("@/lib/db/index.js"); + await db.initDb(); +}); + +afterAll(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("cached-token end-to-end (persist + aggregate + cost)", () => { + it("Claude cache usage: canonical prompt is inclusive, cached persisted, cost correct", async () => { + // Raw Claude usage (cache-EXCLUSIVE prompt): input 100, cache_read 200, cache_creation 30, output 50 + const canonical = canonicalizeUsage({ + prompt_tokens: 100, + completion_tokens: 50, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 30, + }); + expect(canonical.prompt_tokens).toBe(330); // inclusive + + await db.saveRequestUsage({ + provider: "anthropic", + model: "claude-sonnet-4-6", + connectionId: "c-cache", + tokens: canonical, + endpoint: "/v1/messages", + status: "ok", + }); + + const stats = await db.getUsageStats("24h"); + expect(stats.totalCachedTokens).toBe(200); + expect(stats.totalPromptTokens).toBe(330); + expect(stats.byProvider.anthropic.cachedTokens).toBe(200); + + // Cost: nonCached=330-200-30=100 @3 + cached 200 @0.30 + creation 30 @3.75 + output 50 @15 + const expected = (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000; + const hist = await db.getUsageHistory({ provider: "anthropic" }); + expect(hist.length).toBe(1); + expect(hist[0].cost).toBeCloseTo(expected, 12); + expect(hist[0].tokens.cached_tokens).toBe(200); + expect(hist[0].tokens.cache_creation_input_tokens).toBe(30); + }); + + it("OpenAI cache usage: inclusive prompt passes through, cached counted once", async () => { + const canonical = canonicalizeUsage({ + prompt_tokens: 1000, // already includes cached + completion_tokens: 200, + cached_tokens: 600, + }); + expect(canonical.prompt_tokens).toBe(1000); + expect(canonical.cached_tokens).toBe(600); + + await db.saveRequestUsage({ + provider: "openai", + model: "gpt-4o", + connectionId: "c-oai", + tokens: canonical, + endpoint: "/v1/chat/completions", + status: "ok", + }); + + const hist = await db.getUsageHistory({ provider: "openai" }); + expect(hist[0].tokens.prompt_tokens).toBe(1000); + expect(hist[0].tokens.cached_tokens).toBe(600); + }); +}); diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js new file mode 100644 index 00000000..878110d0 --- /dev/null +++ b/tests/unit/cached-token-usage.test.js @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js"; +import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js"; +import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js"; + +// Canonical convention (single source of truth for storage + cost): +// prompt_tokens = total input INCLUDING cache read + cache creation +// cached_tokens = cache-read portion (subset of prompt_tokens) +// cache_creation_input_tokens = cache-write portion (subset of prompt_tokens) +// completion_tokens = output +// Discriminator: Claude reports cache separately (prompt EXCLUDES cache); +// OpenAI/Gemini report prompt INCLUDING cached_tokens. +describe("canonicalizeUsage", () => { + it("folds Claude exclusive cache into an inclusive prompt count", () => { + // Claude: input_tokens excludes cache; cache_read + cache_creation are separate + const out = canonicalizeUsage({ + prompt_tokens: 100, + completion_tokens: 50, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 30, + }); + expect(out.prompt_tokens).toBe(330); // 100 + 200 + 30 + expect(out.completion_tokens).toBe(50); + expect(out.cached_tokens).toBe(200); + expect(out.cache_creation_input_tokens).toBe(30); + }); + + it("passes through OpenAI inclusive prompt unchanged", () => { + // OpenAI: prompt_tokens already includes cached_tokens (a subset) + const out = canonicalizeUsage({ + prompt_tokens: 330, + completion_tokens: 50, + cached_tokens: 200, + }); + expect(out.prompt_tokens).toBe(330); + expect(out.cached_tokens).toBe(200); + expect(out.cache_creation_input_tokens).toBe(0); + }); + + it("passes through Gemini inclusive prompt (cachedContent already counted)", () => { + const out = canonicalizeUsage({ + prompt_tokens: 500, + completion_tokens: 80, + cached_tokens: 120, + reasoning_tokens: 40, + }); + expect(out.prompt_tokens).toBe(500); + expect(out.cached_tokens).toBe(120); + expect(out.reasoning_tokens).toBe(40); + }); + + it("handles no-cache usage", () => { + const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 }); + expect(out.prompt_tokens).toBe(100); + expect(out.cached_tokens).toBe(0); + expect(out.cache_creation_input_tokens).toBe(0); + }); + + it("is idempotent (running twice yields the same canonical shape)", () => { + const once = canonicalizeUsage({ + prompt_tokens: 100, + completion_tokens: 50, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 30, + }); + const twice = canonicalizeUsage(once); + expect(twice.prompt_tokens).toBe(330); + expect(twice.cached_tokens).toBe(200); + expect(twice.cache_creation_input_tokens).toBe(30); + expect(twice.completion_tokens).toBe(50); + }); + + it("returns null for invalid input", () => { + expect(canonicalizeUsage(null)).toBeNull(); + expect(canonicalizeUsage(undefined)).toBeNull(); + }); + + it("folds a Claude cache-miss first write (cache_creation only, no cache_read yet)", () => { + // Cache-miss on first write: upstream emits cache_creation_input_tokens but + // no cache_read_input_tokens at all (not even 0). Must still fold into prompt + // instead of falling through to the OpenAI passthrough branch. + const out = canonicalizeUsage({ + prompt_tokens: 100, + completion_tokens: 20, + cache_creation_input_tokens: 500, + }); + expect(out.prompt_tokens).toBe(600); // 100 + 0 (no read) + 500 + expect(out.cached_tokens).toBe(0); + expect(out.cache_creation_input_tokens).toBe(500); + }); +}); + +describe("calculateCostFromTokens (canonical inclusive convention)", () => { + const pricing = { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 }; + + it("prices cached + cache_creation as subsets of an inclusive prompt without double-counting", () => { + // prompt=330 includes 200 cached + 30 cache_creation → 100 full-price input + const cost = calculateCostFromTokens( + { prompt_tokens: 330, completion_tokens: 50, cached_tokens: 200, cache_creation_input_tokens: 30 }, + pricing + ); + const expected = + (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000; + expect(cost).toBeCloseTo(expected, 12); + }); + + it("does not let cache_creation drive nonCached negative", () => { + // pathological: cached + creation exceeds prompt → nonCached clamps at 0 + const cost = calculateCostFromTokens( + { prompt_tokens: 100, completion_tokens: 0, cached_tokens: 80, cache_creation_input_tokens: 40 }, + pricing + ); + const expected = (0 * 3 + 80 * 0.3 + 40 * 3.75) / 1_000_000; + expect(cost).toBeCloseTo(expected, 12); + }); + + it("matches plain input pricing when no cache present", () => { + const cost = calculateCostFromTokens({ prompt_tokens: 100, completion_tokens: 50 }, pricing); + expect(cost).toBeCloseTo((100 * 3 + 50 * 15) / 1_000_000, 12); + }); +}); + +describe("Anthropic streaming usage (message_start carries cache, message_delta output-only)", () => { + it("extractUsage reads input + cache from message_start", () => { + const u = extractUsage({ + type: "message_start", + message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } }, + }); + expect(u.prompt_tokens).toBe(100); + expect(u.cache_read_input_tokens).toBe(200); + expect(u.cache_creation_input_tokens).toBe(30); + }); + + it("merges message_start cache with message_delta output without clobbering", () => { + // Real Anthropic SSE: cache only in message_start, real output only in message_delta. + const start = extractUsage({ + type: "message_start", + message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } }, + }); + const delta = extractUsage({ type: "message_delta", usage: { output_tokens: 50 } }); + const merged = mergeUsage(start, delta); + expect(merged.prompt_tokens).toBe(100); + expect(merged.cache_read_input_tokens).toBe(200); + expect(merged.cache_creation_input_tokens).toBe(30); + expect(merged.completion_tokens).toBe(50); + + // And it canonicalizes to a cache-inclusive prompt for storage/cost. + const canon = canonicalizeUsage(merged); + expect(canon.prompt_tokens).toBe(330); // 100 + 200 + 30 + expect(canon.cached_tokens).toBe(200); + expect(canon.cache_creation_input_tokens).toBe(30); + expect(canon.completion_tokens).toBe(50); + }); + + it("does not let a NaN field poison the running max-merge", () => { + // typeof NaN === "number", so a naive Math.max(prev, NaN) is NaN — one + // malformed chunk must not wipe out an already-accumulated good value. + const prev = { prompt_tokens: 100, cache_read_input_tokens: 200 }; + const bad = { prompt_tokens: NaN, completion_tokens: 50 }; + const merged = mergeUsage(prev, bad); + expect(merged.prompt_tokens).toBe(100); + expect(merged.cache_read_input_tokens).toBe(200); + expect(merged.completion_tokens).toBe(50); + }); +}); + +describe("Kiro usage pass-through", () => { + it("passes through plain input/output when no cache fields are present", () => { + const out = toOpenAIUsage({ inputTokens: 100, outputTokens: 50 }, "kiro"); + expect(out.prompt_tokens).toBe(100); + expect(out.completion_tokens).toBe(50); + expect(out.total_tokens).toBe(150); + expect(out.prompt_tokens_details).toBeUndefined(); + }); + + it("forward-compat: surfaces cache fields if Kiro event shape grows them", () => { + // ponytail: Amazon Q upstream doesn't expose cache today, but if it starts + // sending cache_read_input_tokens / cache_creation_input_tokens / cachedTokens, + // cost tracking should pick them up automatically without another change. + const out = toOpenAIUsage( + { inputTokens: 500, outputTokens: 100, cache_read_input_tokens: 200, cache_creation_input_tokens: 50 }, + "kiro" + ); + expect(out.prompt_tokens_details).toBeDefined(); + expect(out.prompt_tokens_details.cached_tokens).toBe(200); + expect(out.prompt_tokens_details.cache_creation_tokens).toBe(50); + }); +}); diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 3839809b..31512e9a 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; describe("getCapabilitiesForModel", () => { + const claudeSonnet5Expected = { + contextWindow: 1000000, + maxOutput: 128000, + thinkingFormat: "claude-adaptive", + reasoning: true, + vision: true, + search: true, + }; + it("reports Kiro Claude Opus 4.8 as a 1M context model", () => { expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000); expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000); @@ -9,4 +18,12 @@ describe("getCapabilitiesForModel", () => { expect(getCapabilitiesForModel("kiro", "claude-opus-4.8-thinking").contextWindow).toBe(1000000); expect(getCapabilitiesForModel("kiro", "claude-opus-4-8-thinking").contextWindow).toBe(1000000); }); + + it("reports Kiro Claude Sonnet 5 as a 1M adaptive-thinking model", () => { + expect(getCapabilitiesForModel("kiro", "claude-sonnet-5")).toMatchObject(claudeSonnet5Expected); + expect(getCapabilitiesForModel("kiro", "anthropic/claude-sonnet-5")).toMatchObject(claudeSonnet5Expected); + expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking")).toMatchObject(claudeSonnet5Expected); + expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected); + expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected); + }); }); diff --git a/tests/unit/codebuddy-cn-bonus-recurring.test.js b/tests/unit/codebuddy-cn-bonus-recurring.test.js new file mode 100644 index 00000000..7d75942f --- /dev/null +++ b/tests/unit/codebuddy-cn-bonus-recurring.test.js @@ -0,0 +1,30 @@ +// CodeBuddy CN mixes recurring refill packs with one-shot bonus packs. +// Bonus packs ("Bonus Pack N") must surface recurring:false so the dashboard +// shows "Expires in" instead of implying a monthly refill. The usage handler +// tags the flag and parseQuotaData must forward it. +import { describe, it, expect } from "vitest"; +import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +describe("parseQuotaData codebuddy-cn recurring flag", () => { + it("forwards recurring:false for bonus packs and true for refill packs", () => { + const data = { + plan: "CodeBuddy CN", + quotas: { + Monthly: { used: 6.54, total: 500, resetAt: "2026-07-31T00:00:00Z", recurring: true }, + "Bonus Pack 1": { used: 12, total: 100, resetAt: "2026-07-15T00:00:00Z", recurring: false }, + }, + }; + + const out = parseQuotaData("codebuddy-cn", data); + const byName = Object.fromEntries(out.map((q) => [q.name, q])); + + expect(byName["Monthly"].recurring).toBe(true); + expect(byName["Bonus Pack 1"].recurring).toBe(false); + }); + + it("defaults recurring to true when the flag is absent (back-compat)", () => { + const data = { quotas: { Monthly: { used: 0, total: 100, resetAt: null } } }; + const out = parseQuotaData("codebuddy-cn", data); + expect(out[0].recurring).toBe(true); + }); +}); diff --git a/tests/unit/codex-reset-credits.test.js b/tests/unit/codex-reset-credits.test.js new file mode 100644 index 00000000..c8b4c6fd --- /dev/null +++ b/tests/unit/codex-reset-credits.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + proxyAwareFetch: vi.fn(), + getProviderConnectionById: vi.fn(), + resolveConnectionProxyConfig: vi.fn(), + refreshAndUpdateCredentials: vi.fn(), + getCodexRateLimitResetCredits: vi.fn(), + consumeCodexRateLimitResetCredit: vi.fn(), +})); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: mocks.proxyAwareFetch, +})); + +vi.mock("open-sse/index.js", () => ({})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.getProviderConnectionById, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig, +})); + +vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({ + refreshAndUpdateCredentials: mocks.refreshAndUpdateCredentials, +})); + +vi.mock("open-sse/services/usage.js", () => ({ + getCodexRateLimitResetCredits: mocks.getCodexRateLimitResetCredits, + consumeCodexRateLimitResetCredit: mocks.consumeCodexRateLimitResetCredit, +})); + +describe("Codex reset credits", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.resolveConnectionProxyConfig.mockResolvedValue({}); + }); + + it("returns normalized reset credit expiry details", async () => { + mocks.proxyAwareFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + available_count: 2, + credits: [ + { + status: "available", + granted_at: "2026-06-18T00:25:18Z", + expires_at: "2026-07-18T00:25:18Z", + }, + { + status: "redeemed", + granted_at: "bad-date", + expires_at: null, + }, + ], + }), + }); + + const { getCodexRateLimitResetCredits } = await import("../../open-sse/services/usage/codex.js"); + const result = await getCodexRateLimitResetCredits("token", { strictProxy: false }, { workspaceId: "acct_123" }); + + expect(mocks.proxyAwareFetch).toHaveBeenCalledWith( + expect.stringContaining("/rate-limit-reset-credits"), + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer token", + "ChatGPT-Account-ID": "acct_123", + }), + }), + { strictProxy: false }, + ); + expect(result).toEqual({ + availableCount: 2, + credits: [ + { + status: "available", + grantedAt: "2026-06-18T00:25:18.000Z", + expiresAt: "2026-07-18T00:25:18.000Z", + }, + { + status: "redeemed", + grantedAt: null, + expiresAt: null, + }, + ], + }); + }); + + it("GET refreshes OAuth credentials before returning reset credit details", async () => { + const connection = { + id: "conn_1", + provider: "codex", + authType: "oauth", + accessToken: "old-token", + refreshToken: "refresh-token", + providerSpecificData: { workspaceId: "acct_123" }, + }; + const refreshedConnection = { ...connection, accessToken: "new-token" }; + const resetCredits = { + availableCount: 1, + credits: [{ status: "available", grantedAt: "2026-06-18T00:25:18.000Z", expiresAt: "2026-07-18T00:25:18.000Z" }], + }; + mocks.getProviderConnectionById.mockResolvedValue(connection); + mocks.resolveConnectionProxyConfig.mockResolvedValue({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local" }); + mocks.refreshAndUpdateCredentials.mockResolvedValue({ connection: refreshedConnection }); + mocks.getCodexRateLimitResetCredits.mockResolvedValue(resetCredits); + + const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(resetCredits); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenCalledWith( + connection, + false, + expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }), + ); + expect(mocks.getCodexRateLimitResetCredits).toHaveBeenCalledWith( + "new-token", + expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }), + { workspaceId: "acct_123" }, + ); + }); + + it("GET force-refreshes OAuth credentials when reset credit fetch reports expired auth", async () => { + const connection = { + id: "conn_1", + provider: "codex", + authType: "oauth", + accessToken: "old-token", + refreshToken: "refresh-token", + providerSpecificData: {}, + }; + const refreshedConnection = { ...connection, accessToken: "new-token" }; + const forcedConnection = { ...connection, accessToken: "forced-token" }; + const resetCredits = { availableCount: 0, credits: [] }; + mocks.getProviderConnectionById.mockResolvedValue(connection); + mocks.refreshAndUpdateCredentials + .mockResolvedValueOnce({ connection: refreshedConnection }) + .mockResolvedValueOnce({ connection: forcedConnection }); + mocks.getCodexRateLimitResetCredits + .mockRejectedValueOnce(new Error("Unauthorized 401")) + .mockResolvedValueOnce(resetCredits); + + const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(resetCredits); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(1, connection, false, expect.any(Object)); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(2, refreshedConnection, true, expect.any(Object)); + expect(mocks.getCodexRateLimitResetCredits).toHaveBeenNthCalledWith(2, "forced-token", expect.any(Object), {}); + }); + + it("POST returns 409 when there are no reset credits to consume", async () => { + mocks.getProviderConnectionById.mockResolvedValue({ + id: "conn_1", + provider: "codex", + authType: "access_token", + accessToken: "token", + providerSpecificData: {}, + }); + mocks.consumeCodexRateLimitResetCredit.mockResolvedValue({ + ok: false, + noCredit: true, + status: 200, + code: "no_credit", + windowsReset: 0, + }); + + const { POST } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await POST(new Request("http://localhost/api/usage/conn_1/codex-reset-credits", { method: "POST" }), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "no_credit", + reset: false, + windows_reset: 0, + message: "No Codex reset credits available.", + }); + expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith( + "token", + expect.any(String), + expect.objectContaining({ strictProxy: false }), + ); + }); +}); diff --git a/tests/unit/compatible-provider-connections.test.js b/tests/unit/compatible-provider-connections.test.js index c20c3059..0fe146f0 100644 --- a/tests/unit/compatible-provider-connections.test.js +++ b/tests/unit/compatible-provider-connections.test.js @@ -38,14 +38,14 @@ async function setupTestContext(nodeData) { }; } -function makeRequest(provider) { +function makeRequest(provider, name = "Test Connection") { return new Request("https://9router.local/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, apiKey: "test-key", - name: "Test Connection", + name, defaultModel: "test-model", }), }); @@ -145,26 +145,25 @@ describe("compatible provider connections API", () => { }); }); - it("returns 400 for a duplicate connection on the same compatible node", async () => { + it("allows multiple connections on the same compatible node", async () => { const ctx = await setupTestContext({ - id: "openai-compatible-duplicate-test", + id: "openai-compatible-multiple-test", type: "openai-compatible", - name: "Duplicate Guard Node", - prefix: "dup", + name: "Multiple Connections Node", + prefix: "mul", apiType: "chat", - baseUrl: "https://duplicate-guard.test/v1", + baseUrl: "https://multiple-connections.test/v1", }); cleanup = ctx.cleanup; - const firstResponse = await ctx.POST(makeRequest(ctx.node.id)); - const secondResponse = await ctx.POST(makeRequest(ctx.node.id)); - const secondBody = await secondResponse.json(); + const firstResponse = await ctx.POST(makeRequest(ctx.node.id, "Key A")); + const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B")); const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id }); expect(firstResponse.status).toBe(201); - expect(secondResponse.status).toBe(400); - expect(secondBody.error).toContain("Only one connection is allowed"); - expect(storedConnections).toHaveLength(1); + expect(secondResponse.status).toBe(201); + expect(storedConnections).toHaveLength(2); expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" }); + expectCompatibleConnection(storedConnections[1], ctx.node, { apiType: "chat" }); }); }); diff --git a/tests/unit/headroom-responses-format.test.js b/tests/unit/headroom-responses-format.test.js index 0050a731..a796e16b 100644 --- a/tests/unit/headroom-responses-format.test.js +++ b/tests/unit/headroom-responses-format.test.js @@ -47,4 +47,61 @@ describe("compressWithHeadroom openai-responses format (#1998)", () => { expect(Array.isArray(body.input[0].content)).toBe(true); expect(typeof body.input[0].content).not.toBe("string"); }); + + it("skips Responses tool/reasoning history instead of collapsing it into a message (#2132)", async () => { + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + messages: [{ role: "user", content: "compressed tool history" }], + tokens_saved: 10, + }), + })); + + const input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "investigate bug" }], + }, + { + type: "function_call", + call_id: "call_apply_patch_123", + name: "apply_patch", + arguments: "*** Begin Patch\n*** End Patch", + }, + { + type: "function_call_output", + call_id: "call_apply_patch_123", + output: "ok", + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Need a plan" }], + }, + ]; + const body = { + input: structuredClone(input), + tools: [ + { + type: "custom", + name: "apply_patch", + format: { type: "grammar", syntax: "lark", definition: "start: /.+/" }, + }, + ], + }; + const diagnostics = {}; + + const data = await compressWithHeadroom(body, { + enabled: true, + url: "http://headroom.test", + model: "gpt-5", + format: "openai-responses", + diagnostics, + }); + + expect(data).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(body.input).toEqual(input); + expect(diagnostics.reason).toBe("skipped: openai-responses tool/reasoning input is not safe to compress"); + }); }); diff --git a/tests/unit/kimchi-strip-reasoning.test.js b/tests/unit/kimchi-strip-reasoning.test.js new file mode 100644 index 00000000..0cb55dfb --- /dev/null +++ b/tests/unit/kimchi-strip-reasoning.test.js @@ -0,0 +1,128 @@ +/** + * Kimchi executor: strip reasoning_content echoed by clients. + * + * Background: when 9Router streams a thinking model (deepseek-r1, + * minimax-m3) to a client, the response carries `reasoning_content`. + * Most OpenAI-compatible SDKs echo the whole history on the next turn, + * so Kimchi's upstream counts the scratch block as input tokens. + * Multi-turn conversations balloon to 100k+ input tokens and the model + * starts returning empty content. + * + * `stripReasoningContent` is intentionally conservative: it only strips + * `reasoning_content` that is clearly a real thinking block. The 1-char + * placeholder that `injectReasoningContent` (in `DefaultExecutor`) may + * insert for upstream validation is preserved — stripping it would + * re-trigger upstream complaints about missing reasoning on the next + * turn. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import KimchiExecutor, { stripReasoningContent } from "../../open-sse/executors/kimchi.js"; +import DefaultExecutor from "../../open-sse/executors/default.js"; + +describe("kimchi stripReasoningContent", () => { + it("removes long reasoning_content from assistant messages but keeps content", () => { + const body = { + messages: [ + { role: "user", content: "solve x+5=12" }, + { + role: "assistant", + content: "x = 7", + reasoning_content: "subtract 5 from both sides ... (long reasoning block)", + }, + { role: "user", content: "now try x+10=20" }, + ], + }; + stripReasoningContent(body); + assert.equal(body.messages[1].reasoning_content, undefined); + assert.equal(body.messages[1].content, "x = 7"); + }); + + it("preserves the 1-char placeholder that injectReasoningContent sets", () => { + // `injectReasoningContent` may insert " " (single space) on assistant + // messages so the upstream's validation doesn't complain about missing + // reasoning. Stripping that placeholder would defeat its purpose. + const body = { + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello", reasoning_content: " " }, + ], + }; + stripReasoningContent(body); + assert.equal(body.messages[1].reasoning_content, " "); + assert.equal(body.messages[1].content, "hello"); + }); + + it("preserves short custom reasoning under the threshold", () => { + // Anything ≤8 chars is treated as a placeholder-shaped value, kept + // verbatim. Real thinking content from a thinking model is always + // well above this threshold. + const body = { + messages: [ + { role: "assistant", content: "ok", reasoning_content: "short" }, + ], + }; + stripReasoningContent(body); + assert.equal(body.messages[0].reasoning_content, "short"); + }); + + it("leaves non-assistant messages untouched", () => { + const body = { + messages: [ + { role: "user", content: "hi" }, + { role: "system", content: "be helpful" }, + ], + }; + stripReasoningContent(body); + assert.equal(body.messages[0].content, "hi"); + assert.equal(body.messages[1].content, "be helpful"); + }); + + it("returns early on missing/empty messages array", () => { + assert.doesNotThrow(() => stripReasoningContent({})); + assert.doesNotThrow(() => stripReasoningContent({ messages: null })); + assert.doesNotThrow(() => stripReasoningContent({ messages: [] })); + }); + + it("ignores assistant messages that have no reasoning_content", () => { + const body = { + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + }; + stripReasoningContent(body); + assert.deepEqual(body.messages[1], { role: "assistant", content: "hello" }); + }); + + it("handles multi-turn: strips old turns, keeps recent one", () => { + const LONG = "x".repeat(1000); + const body = { + messages: [ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1", reasoning_content: LONG }, + { role: "user", content: "q2" }, + { role: "assistant", content: "a2", reasoning_content: " " }, // placeholder + ], + }; + stripReasoningContent(body); + assert.equal(body.messages[1].reasoning_content, undefined); + assert.equal(body.messages[3].reasoning_content, " "); + }); +}); + +describe("kimchi executor wiring", () => { + it("KimchiExecutor extends DefaultExecutor via prototype chain", () => { + const inst = new KimchiExecutor(); + assert.ok( + inst instanceof DefaultExecutor, + "KimchiExecutor must extend DefaultExecutor so transformRequest runs through super", + ); + }); + + it("default export is KimchiExecutor class", () => { + assert.equal(typeof KimchiExecutor, "function"); + assert.equal(KimchiExecutor.name, "KimchiExecutor"); + }); +}); diff --git a/tests/unit/kimchi.test.js b/tests/unit/kimchi.test.js new file mode 100644 index 00000000..1f300f8d --- /dev/null +++ b/tests/unit/kimchi.test.js @@ -0,0 +1,234 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +// Load the registry entry once for the suite so a load failure is reported +// next to the failing test instead of cascading as "undefined" in every +// later assertion. +let kimchiEntry; + +describe("kimchi registry entry", () => { + before(async () => { + kimchiEntry = (await import("../../open-sse/providers/registry/kimchi.js")).default; + }); + + it("is an oauth provider auto-listed via byCategory", () => { + assert.equal(kimchiEntry.id, "kimchi"); + assert.equal(kimchiEntry.category, "oauth"); + }); + + it("points at the OpenAI-compatible gateway with an authenticated UA", () => { + assert.equal( + kimchiEntry.transport.baseUrl, + "https://llm.kimchi.dev/openai/v1/chat/completions", + ); + // UA must be a non-empty string the gateway can identify; the value + // itself is owned by the Kimchi CLI release and may change upstream. + const ua = kimchiEntry.transport.headers["User-Agent"]; + assert.ok(typeof ua === "string" && ua.length > 0, `User-Agent missing: ${ua}`); + }); + + it("uses Bearer auth", () => { + assert.deepEqual(kimchiEntry.transport.auth, { + combined: true, + header: "Authorization", + scheme: "bearer", + }); + }); + + it("exposes the upstream static models", () => { + const ids = kimchiEntry.models.map((m) => m.id); + assert.ok(ids.includes("kimi-k2.7")); + assert.ok(ids.includes("minimax-m3")); + assert.ok(ids.includes("nemotron-3-ultra-fp4")); + assert.ok(ids.length >= 5, `expected >= 5 static models, got ${ids.length}`); + }); + + it("passes through models not in the static list", () => { + assert.equal(kimchiEntry.passthroughModels, true); + }); +}); + +// ── Pure-function clones of the service logic (tested in isolation so +// node --test works without resolving the Next.js Webpack "open-sse" +// alias that src/lib/oauth/services/kimchi.js's dependency imports). ── + +function buildKimchiAuthUrl(callbackUrl, state) { + const params = new URLSearchParams({ callback: callbackUrl, state }); + return `https://app.kimchi.dev/cli-auth?${params.toString()}`; +} + +async function _handleCallback(params, expectedState) { + if (params.error) { + throw new Error(params.error_description || params.error); + } + const candidate = params.state; + if (!candidate || candidate !== expectedState) { + throw new Error( + "This request isn't valid. Please restart the Kimchi login flow.", + ); + } + const token = params.token; + if (!token) { + throw new Error("No token was returned by the Kimchi authentication server"); + } + return { token }; +} + +describe("kimchi oauth", () => { + it("builds the cli-auth URL with encoded callback + state", () => { + const url = buildKimchiAuthUrl("http://127.0.0.1:4321/callback", "abc123"); + const parsed = new URL(url); + assert.equal(parsed.origin, "https://app.kimchi.dev"); + assert.equal(parsed.pathname, "/cli-auth"); + assert.equal(parsed.searchParams.get("callback"), "http://127.0.0.1:4321/callback"); + assert.equal(parsed.searchParams.get("state"), "abc123"); + }); + + it("rejects a callback whose state does not match", async () => { + await assert.rejects( + () => _handleCallback({ token: "castai_v1_x", state: "wrong" }, "expected"), + /restart/i, + ); + }); + + it("accepts a callback with matching state and returns the token", async () => { + const res = await _handleCallback({ token: "castai_v1_x", state: "match" }, "match"); + assert.equal(res.token, "castai_v1_x"); + }); +}); + +// ── kimchiModels service (pure mapping logic, tested in isolation) ── + +// Clone of the metadata→model mapper so node --test resolves without the +// open-sse/Webpack alias chain the real module imports. +function mapKimchiMetadata(raw) { + if (!Array.isArray(raw)) return []; + return raw.map((m) => ({ + id: m.slug, + name: m.display_name || m.slug, + contextLength: m.limits?.context_window || null, + maxOutputTokens: m.limits?.max_output_tokens || null, + isReasoning: m.reasoning === true, + })); +} + +describe("kimchiModels", () => { + it("maps Kimchi metadata entries to 9router model shape", () => { + const raw = [{ + slug: "glm-5.2-fp8", + display_name: "GLM 5.2", + reasoning: true, + limits: { context_window: 1048576, max_output_tokens: 1048576 }, + }]; + const models = mapKimchiMetadata(raw); + assert.equal(models.length, 1); + assert.deepEqual(models[0], { + id: "glm-5.2-fp8", + name: "GLM 5.2", + contextLength: 1048576, + maxOutputTokens: 1048576, + isReasoning: true, + }); + }); + + it("falls back to slug as name when display_name is empty", () => { + const models = mapKimchiMetadata([{ slug: "kimi-k2.7", display_name: "", reasoning: false, limits: {} }]); + assert.equal(models[0].name, "kimi-k2.7"); + assert.equal(models[0].contextLength, null); + assert.equal(models[0].isReasoning, false); + }); + + it("returns empty array for non-array input", () => { + assert.deepEqual(mapKimchiMetadata(null), []); + assert.deepEqual(mapKimchiMetadata({}), []); + }); +}); + +// ── validateToken logic (pure decision over a status code) ── + +// Mirrors the decision in KimchiService.validateToken without importing the +// service (which pulls the open-sse Webpack alias chain). +function decideValidity(status) { + if (status === 200) return { valid: true }; + if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" }; + if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" }; + return { valid: true }; // fail-open on unknown / network error +} + +describe("kimchi validateToken", () => { + it("200 → valid", () => { + assert.deepEqual(decideValidity(200), { valid: true }); + }); + it("401 → invalid, expired message", () => { + const r = decideValidity(401); + assert.equal(r.valid, false); + assert.match(r.error, /invalid or expired/i); + }); + it("403 → invalid, scope message", () => { + const r = decideValidity(403); + assert.equal(r.valid, false); + assert.match(r.error, /scope/i); + }); + it("unknown / network error → fail-open valid", () => { + assert.equal(decideValidity(500).valid, true); + assert.equal(decideValidity(0).valid, true); + }); +}); + +// ── OAuth dedup logic (pure clone of connectionsRepo matcher) ── +// Mimics the find() predicate in createProviderConnection for OAuth +// connections, so we can test the IdP-collision fix in isolation. +function findExistingOAuth(all, incoming) { + const incomingEmail = incoming.email; + const incomingUsername = incoming.providerSpecificData?.username; + const incomingWs = incoming.providerSpecificData?.chatgptAccountId; + return all.find((c) => { + if (c.authType !== "oauth" || c.email !== incomingEmail) return false; + const existingWs = c.providerSpecificData?.chatgptAccountId; + if (incomingWs && existingWs) return incomingWs === existingWs; + if (incomingWs && !existingWs) return false; + if (!incomingWs && existingWs) return false; + const existingUsername = c.providerSpecificData?.username; + if (incomingUsername && existingUsername) { + return incomingUsername === existingUsername; + } + if (incomingUsername || existingUsername) return false; + return true; + }); +} + +describe("kimchi OAuth dedup", () => { + const google = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "google-oauth2|123" } }; + const hf = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "huggingface|456" } }; + const legacy = { authType: "oauth", email: "x@y.com", providerSpecificData: {} }; + const other = { authType: "oauth", email: "z@y.com", providerSpecificData: { username: "google-oauth2|789" } }; + + it("different email never matches", () => { + assert.equal(findExistingOAuth([other], google), undefined); + }); + + it("same email + same username = dedup (re-login same IdP)", () => { + const found = findExistingOAuth([google], { ...google }); + assert.equal(found, google); + }); + + it("same email + different username = NO match (cross-IdP, the bug)", () => { + assert.equal(findExistingOAuth([google], hf), undefined); + }); + + it("legacy row without username matches incoming without username (backward compat)", () => { + assert.equal(findExistingOAuth([legacy], { ...legacy }), legacy); + }); + + it("incoming without username does not match legacy row with username", () => { + assert.equal(findExistingOAuth([google], { ...legacy }), undefined); + }); + + it("workspaces still dedupe on workspace ID when both sides have one", () => { + const ws1 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } }; + const ws1dup = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } }; + const ws2 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws2" } }; + assert.equal(findExistingOAuth([ws1], ws1dup), ws1); + assert.equal(findExistingOAuth([ws1], ws2), undefined); + }); +}); diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js index 3eb9c7b8..ffc3e382 100644 --- a/tests/unit/kiro-model-slots.test.js +++ b/tests/unit/kiro-model-slots.test.js @@ -1,12 +1,10 @@ import { describe, expect, it } from "vitest"; +import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js"; import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js"; -// Guards the fix in commit 356607c: Kiro's agent/"vibe" mode sends modelId -// "auto" for the main turn and "simple-task" for background sub-tasks. Both -// need a mappable defaultModels slot — otherwise getMappedModel (src/mitm/server.js) -// returns null and the /generateAssistantResponse call is passed through to AWS -// instead of being routed to the user's chosen provider (surfacing as Kiro's -// "monthly usage limit" once the AWS quota is gone). +// Guards Kiro model ids that still need mappable defaultModels slots. Without +// a slot, getMappedModel (src/mitm/server.js) returns null and the request is +// passed through to AWS instead of being routed to the user's chosen provider. describe("Kiro MITM model slots", () => { const kiro = MITM_TOOLS.kiro; @@ -16,10 +14,10 @@ describe("Kiro MITM model slots", () => { expect(Array.isArray(kiro.defaultModels)).toBe(true); }); - it("offers a mappable slot for the agent default model id 'auto'", () => { - const auto = kiro.defaultModels.find((m) => m.id === "auto"); - expect(auto).toBeTruthy(); - expect(auto.alias).toBe("auto"); + it("offers a mappable slot for Claude Sonnet 5", () => { + const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5"); + expect(sonnet5).toBeTruthy(); + expect(sonnet5.alias).toBe("claude-sonnet-5"); }); it("offers a mappable slot for the background sub-task model id 'simple-task'", () => { @@ -28,3 +26,15 @@ describe("Kiro MITM model slots", () => { expect(simpleTask.alias).toBe("simple-task"); }); }); + +describe("Kiro static provider models", () => { + it("includes Claude Sonnet 5 and its synthetic Kiro variants", () => { + const ids = (PROVIDER_MODELS.kr || []).map((model) => model.id); + expect(ids).toEqual(expect.arrayContaining([ + "claude-sonnet-5", + "claude-sonnet-5-thinking", + "claude-sonnet-5-agentic", + "claude-sonnet-5-thinking-agentic", + ])); + }); +}); diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js new file mode 100644 index 00000000..91b5009c --- /dev/null +++ b/tests/unit/kiro-thinking-strip.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; + +function createMockFrame(eventType, payloadObj) { + const payloadStr = JSON.stringify(payloadObj); + const payloadBytes = new TextEncoder().encode(payloadStr); + + const headerName = ":event-type"; + const headerNameBytes = new TextEncoder().encode(headerName); + const headerValueBytes = new TextEncoder().encode(eventType); + + // nameLen(1) + name + type(1) + valueLen(2) + value + const headerLength = 1 + headerNameBytes.length + 1 + 2 + headerValueBytes.length; + const totalLength = 12 + headerLength + payloadBytes.length + 4; + + const buffer = new Uint8Array(totalLength); + const view = new DataView(buffer.buffer); + + view.setUint32(0, totalLength, false); + view.setUint32(4, headerLength, false); + + let offset = 12; + buffer[offset++] = headerNameBytes.length; + buffer.set(headerNameBytes, offset); + offset += headerNameBytes.length; + + buffer[offset++] = 7; // String type + view.setUint16(offset, headerValueBytes.length, false); + offset += 2; + buffer.set(headerValueBytes, offset); + offset += headerValueBytes.length; + + buffer.set(payloadBytes, offset); + + return buffer; +} + +async function readAllSSE(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + return result; +} + +describe("KiroExecutor thinking tag stripping", () => { + it("strips <thinking> tags from assistantResponseEvent", async () => { + const executor = new KiroExecutor(); + + // Create frames + const f1 = createMockFrame("assistantResponseEvent", { content: "Here is my answer. <thinking>Let me think..." }); + const f2 = createMockFrame("assistantResponseEvent", { content: "still thinking...</thinking> Yes, 42." }); + + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(f1); + controller.enqueue(f2); + controller.close(); + } + }); + + const mockResponse = { body: readableStream }; + const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test"); + + const output = await readAllSSE(transformedResponse.body); + + // Check that we got chat.completion.chunk outputs + expect(output).toContain("chat.completion.chunk"); + // Ensure the thinking parts are gone + expect(output).not.toContain("<thinking>"); + expect(output).not.toContain("Let me think..."); + expect(output).not.toContain("still thinking..."); + expect(output).not.toContain("</thinking>"); + + // Check that the normal content is preserved + // Parse the data chunks + const dataLines = output.split("\n").filter(line => line.startsWith("data: ")); + const contents = dataLines.map(line => { + if (line.includes("[DONE]")) return ""; + try { + return JSON.parse(line.slice(6)).choices[0].delta.content || ""; + } catch { + return ""; + } + }); + + const fullText = contents.join(""); + expect(fullText).toBe("Here is my answer. Yes, 42."); + }); + + it("handles empty content after stripping when hasReasoningContent is true", async () => { + const executor = new KiroExecutor(); + + const f0 = createMockFrame("reasoningContentEvent", { text: "I am reasoning" }); + const f1 = createMockFrame("assistantResponseEvent", { content: "<thinking>purely thinking...</thinking>" }); + + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(f0); + controller.enqueue(f1); + controller.close(); + } + }); + + const mockResponse = { body: readableStream }; + const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test"); + + const output = await readAllSSE(transformedResponse.body); + + const dataLines = output.split("\n").filter(line => line.startsWith("data: ") && !line.includes("[DONE]")); + const objects = dataLines.map(line => JSON.parse(line.slice(6))); + + // First chunk should have reasoning_content + expect(objects[0].choices[0].delta.reasoning_content).toBe("I am reasoning"); + + // We shouldn't get an empty content chunk from f1 since it was entirely stripped and reasoning was present + const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined); + expect(contentChunks.length).toBe(0); + }); +}); diff --git a/tests/unit/mitm-root-ca.test.js b/tests/unit/mitm-root-ca.test.js new file mode 100644 index 00000000..f3e3f0a4 --- /dev/null +++ b/tests/unit/mitm-root-ca.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "module"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const require = createRequire(import.meta.url); + +function loadRootCAWithDataDir(dataDir) { + const rootCAPath = require.resolve("../../src/mitm/cert/rootCA.js"); + const pathsPath = require.resolve("../../src/mitm/paths.js"); + delete require.cache[rootCAPath]; + delete require.cache[pathsPath]; + + const oldDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + try { + return require("../../src/mitm/cert/rootCA.js"); + } finally { + if (oldDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = oldDataDir; + } +} + +describe("MITM Root CA generation", () => { + it("creates Root CA files synchronously for direct server startup", () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-mitm-ca-")); + const { generateRootCA } = loadRootCAWithDataDir(dataDir); + + generateRootCA(); + + expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.key"))).toBe(true); + expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.crt"))).toBe(true); + }); +}); diff --git a/tests/unit/openai-responses-terminal-event.test.js b/tests/unit/openai-responses-terminal-event.test.js index db2e3ad8..32ee66c3 100644 --- a/tests/unit/openai-responses-terminal-event.test.js +++ b/tests/unit/openai-responses-terminal-event.test.js @@ -67,6 +67,19 @@ describe("OpenAI Responses streaming termination", () => { expect(output).toContain("data: [DONE]"); }); + it("does not add response.failed when a Responses stream sends response.done", async () => { + const output = await runTransform([ + `event: response.done`, + `data: ${JSON.stringify({ type: "response.done", response: { id: "resp_test" } })}`, + "", + ].join("\n")); + + expect(output).toContain("event: response.done"); + expect(output).not.toContain("event: response.failed"); + expect(output).not.toContain("data: null"); + expect(output).toContain("data: [DONE]"); + }); + it("emits response.failed before DONE when a Responses stream sends DONE without a terminal event", async () => { const output = await runTransform([ `event: response.created`, diff --git a/tests/unit/quota-auto-ping.test.js b/tests/unit/quota-auto-ping.test.js new file mode 100644 index 00000000..de601df5 --- /dev/null +++ b/tests/unit/quota-auto-ping.test.js @@ -0,0 +1,351 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("open-sse/index.js", () => ({}), { virtual: true }); + +vi.mock("@/lib/localDb", () => ({ + getSettings: vi.fn(), + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: vi.fn(), +})); + +vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({ + refreshAndUpdateCredentials: vi.fn(), +})); + +vi.mock("@/shared/constants/config", () => ({ + QUOTA_AUTOPING_CONFIG: { + tickIntervalMs: 60000, + pingLeadMs: 5000, + refreshAheadMs: 300000, + failureCooldownMs: 900000, + providers: { + claude: { + settingsKey: "claudeAutoPing", + quotaKey: "session (5h)", + pingModel: "claude-haiku-4-5-20251001", + pingText: "hi", + pingMaxTokens: 1, + }, + codex: { + settingsKey: "codexAutoPing", + quotaKey: "session", + pingWhenResetAtSlides: true, + resetAtDriftMs: 30000, + minPingIntervalMs: 600000, + skipWhenBlockingQuotaExhausted: true, + pingModel: "gpt-5.5", + pingText: "hi", + pingInstructions: "Reply with OK.", + pingReasoningEffort: "none", + }, + }, + }, +})); + +vi.mock("open-sse/providers/shared.js", () => ({ + CLAUDE_CLI_SPOOF_HEADERS: { "anthropic-version": "2023-06-01" }, +})); + +vi.mock("open-sse/services/usage/shared.js", () => ({ + U: () => ({ baseUrl: "https://chatgpt.com/backend-api/codex/responses" }), +})); + +vi.mock("open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +vi.mock("open-sse/services/usage/claude.js", () => ({ + getClaudeUsage: vi.fn(), +})); + +vi.mock("open-sse/services/usage/codex.js", () => ({ + getCodexUsage: vi.fn(), +})); + +vi.mock("open-sse/executors/index.js", () => ({ + getExecutor: vi.fn(), +})); + +describe("quota auto-ping", () => { + let runQuotaAutoPingTick; + let deps; + let state; + let getCodexUsage; + let getClaudeUsage; + let getExecutor; + let codexResponseText; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + + ({ getCodexUsage } = await import("open-sse/services/usage/codex.js")); + ({ getClaudeUsage } = await import("open-sse/services/usage/claude.js")); + ({ getExecutor } = await import("open-sse/executors/index.js")); + ({ runQuotaAutoPingTick } = await import("../../src/shared/services/quotaAutoPing.js")); + + deps = { + getSettings: vi.fn(), + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), + resolveConnectionProxyConfig: vi.fn().mockResolvedValue({}), + refreshAndUpdateCredentials: vi.fn(async (connection) => ({ connection, refreshed: false })), + proxyAwareFetch: vi.fn().mockResolvedValue({ ok: true }), + getExecutor: vi.fn(() => ({ + execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }), + })), + }; + codexResponseText = vi.fn().mockResolvedValue(""); + getExecutor.mockReturnValue({ + execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }), + }); + state = { running: false, resetCache: {}, failureCache: {} }; + vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z")); + }); + + it("does not ping Codex when setting is absent", async () => { + deps.getSettings.mockResolvedValue({}); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getProviderConnections).not.toHaveBeenCalled(); + expect(deps.proxyAwareFetch).not.toHaveBeenCalled(); + }); + + it("does not ping Codex on the first resetAt observation", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 1, resetAt: "2026-01-01T13:00:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T13:00:00.000Z"); + }); + + it("sends Codex ping when session resetAt slides", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + const executor = deps.getExecutor.mock.results[0].value; + expect(executor.execute).toHaveBeenCalledTimes(1); + expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({ + lastPingedResetAt: "2026-01-01T17:01:00.000Z", + lastPingedResetKey: "2026-01-01T17:01:00.000Z", + })); + }); + + it("does not ping Codex when resetAt is stable", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:00:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("does not repeat Codex ping inside the minimum ping interval", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" + ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingAt: "2026-01-01T11:55:00.000Z" }] + : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("does not ping Codex just because reported usage is zero", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 0, resetAt: "2026-01-01T17:00:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T17:00:00.000Z"); + }); + + it("does not ping Codex when weekly quota is exhausted", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { + session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" }, + weekly: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-03T12:00:00.000Z" }, + }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("does not ping Codex when monthly quota is exhausted", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { + session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" }, + monthly: { used: 100, total: 100, remaining: 0, resetAt: "2026-02-01T00:00:00.000Z" }, + }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("does not ping Codex when session quota is exhausted", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-01T17:01:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + expect(deps.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("sends one tiny gpt-5.5 Codex request through the executor", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" + ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", providerSpecificData: { workspaceId: "ws-1" } }] + : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + const executor = deps.getExecutor.mock.results[0].value; + expect(deps.getExecutor).toHaveBeenCalledWith("codex"); + expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({ + model: "gpt-5.5", + stream: true, + credentials: expect.objectContaining({ + accessToken: "token", + connectionId: "codex-1", + providerSpecificData: { workspaceId: "ws-1" }, + }), + body: { + model: "gpt-5.5", + input: [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "hi" }], + }], + instructions: "Reply with OK.", + reasoning: { effort: "none", summary: "auto" }, + store: false, + stream: true, + }, + })); + expect(codexResponseText).toHaveBeenCalledTimes(1); + expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({ + lastPingedResetAt: "2026-01-01T17:01:00.000Z", + lastPingedResetKey: "2026-01-01T17:01:00.000Z", + })); + }); + + it("does not ping same Codex reset twice when seconds drift", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" + ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingedResetAt: "2026-01-01T11:59:44.000Z" }] + : [] + )); + state.resetCache["codex:codex-1"] = "2026-01-01T11:59:44.000Z"; + getCodexUsage.mockResolvedValue({ + quotas: { session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T11:59:47.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.getExecutor).not.toHaveBeenCalled(); + }); + + it("skips non-OAuth Codex connections", async () => { + deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "apikey", accessToken: "token" }] : [] + )); + + await runQuotaAutoPingTick(deps, state); + + expect(getCodexUsage).not.toHaveBeenCalled(); + expect(deps.getExecutor).not.toHaveBeenCalled(); + }); + + it("keeps Claude session quota key behavior", async () => { + deps.getSettings.mockResolvedValue({ claudeAutoPing: { connections: { "claude-1": true } } }); + deps.getProviderConnections.mockImplementation(async ({ provider }) => ( + provider === "claude" ? [{ id: "claude-1", provider: "claude", authType: "oauth", accessToken: "token" }] : [] + )); + getClaudeUsage.mockResolvedValue({ + quotas: { "session (5h)": { resetAt: "2026-01-01T11:59:00.000Z" } }, + }); + + await runQuotaAutoPingTick(deps, state); + + expect(deps.proxyAwareFetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(deps.proxyAwareFetch.mock.calls[0][1].body)).toMatchObject({ + model: "claude-haiku-4-5-20251001", + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }); + }); +});