diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb1a186..0d729f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,116 @@ +# v0.5.59 (2026-08-29) + +## Features +- **Search**: new web search providers — Antigravity (Google Search grounding + on the existing OAuth account pool, citations keyed and merged by URL) and + Xquik (X search with `x-api-key` auth, cursor pagination, credit-based + usage), both on `POST /v1/search`. Based on #3437 by @Nautilaceae +- **Search**: ollama-search and zai-search borrow a chat provider's API key + instead of requiring their own connection, driven by a new + `credentialFallback` registry field. zai-search later folded into the `glm` + provider itself so the web search page shows the shared connection +- **Models**: daily background sync of model capabilities from models.dev — + modalities keyed by model id (majority of sources must declare one), + context/output limits keyed by provider + model, strictly additive and + sitting below the hand-written tables. ETag + mtime cache, 60s startup + delay, `MODEL_CATALOG_SYNC=off` to disable +- **Models**: add GLM-5.3-Flash (1M context, natively multimodal), DeepSeek + V4 Vision, Grok 4.5/4.6 (500k context); correct glm-4.6v/4.5v video input + and output limits, backfill glm-4.6v on glm-cn +- **Usage**: show the Zed plan quota on the dashboard — plan, edit + predictions, hosted model requests and billing-cycle reset; unlimited rows + render as "N used · Unlimited" +- **Usage**: track GPT-5.3-Codex-Spark quota windows (spark_session / + spark_weekly) from the Codex usage response (#3431) +- **Antigravity**: quota-aware routing — on 409/429 fetch live quota for the + exact per-model resetAt and skip only the exhausted account/model pair; + report the earliest reset when every account is blocked (#3561) +- **Antigravity**: map image `size` to the aspect-ratio model suffix (-WxH); + add the Gemini 3.7 Flash tiers to MITM defaultModels so they show up in + the dashboard model-mapping table +- **Dashboard**: bulk import Grok CLI accounts from JSON — paste an array or + drag-drop multiple .json files, all OAuth connections created in a single + call, mirroring the codex flow +- **CLI tools**: endpoint presets shared across every tool card through one + live-resyncing store, instead of per-card localStorage copies that never + saw each other's saved endpoints +- **Token Saver**: configurable compression timeout (`headroomTimeoutMs`) — + the fixed 3000 ms made busy machines time out and send inconsistently + compressed bodies, hurting prompt caching +- **i18n**: pt-BR expanded to 1132 terms + +## Fixes +- **Stream**: record usage when a client closes on the terminal event — the + Responses API has no [DONE] sentinel, so codex closed the socket on + `response.completed` and cancelled the reader before flush() ran its usage + side effects; the tail now lives in a once-guarded finalizeStream(). Also + stop logging a disconnect for every completed Responses call +- **Stream**: parse the trailing NDJSON line an Ollama stream leaves behind + without a closing newline — the final chunk carrying `done_reason` and the + token counts was dropped +- **Session**: read the Claude Code session id from the + `x-claude-code-session-id` header — `metadata.user_id` is dropped by + Responses translation, splitting one conversation across several + `prompt_cache_key` values and missing the upstream prefix cache +- **Usage**: preserve nested `cached_tokens` — the top-level-only read + persisted `cached_tokens: 0` for every Responses-format provider (codex, + grok-cli, …), billing cache hits at the full input rate +- **Usage**: GLM quotas accept CREDIT_LIMIT plans and multi-interval windows + (5h session / 7d weekly) instead of overwriting a single "session" key +- **Models**: the catalog sync no longer erases its own output — deltas were + measured against the previous run's writes (the second run cut `providers` + from 20 entries to 5); one vote per provider in the modality tally, ETag + restored from file on startup, and the worker thread dropped after the + bundler rewrote its path into a module-not-found error +- **Executor**: CommandCode returns errors as a `type:"error"` event inside + an HTTP 200 NDJSON stream — peek the first events before committing, abort + and return a real 4xx/5xx so combo/account fallback triggers instead of + streaming the error text as content +- **Search**: scope failure locks on the credential-fallback path — a failing + search locked `modelLock___all` and took the shared glm key offline for + chat as well; locks are now attributed to the connection's owner and + scoped to `websearch:` +- **Providers**: connection tests get a 15s AbortSignal timeout instead of + hanging and exhausting the browser socket pool; guard undefined provider + names on the providers page +- **Antigravity**: sanitize competing-client branding via a config-driven + rule table (Zed's Claude-agent prompt, opencode → antigravity) — upstream + answers 429 Quota Exhausted. Applied in the executor so the shared + openai-to-gemini translator leaves gemini/vertex/zed untouched +- **MiniMax**: preserve images on the sourceFormat-matched OpenAI transport + — MiniMax-M3 resolved a Claude-shaped body posted to the OpenAI endpoint, + silently dropping `image_url` blocks (#3418) +- **Claude**: decloak tool names in same-format streaming passthrough — + OAuth-cloaked names (CLAUDE_TOOL_SUFFIX) leaked to the client and every + tool call was rejected as unknown +- **Tools**: default a missing `tools[].type` to "custom" on Claude-format + requests — strict Anthropic-compatible gateways (MiniMax) reject the + request with 400 otherwise +- **Translator**: zai thinkingFormat sends the top-level `reasoning_effort` + object GLM-5.2+ requires — every GLM-5.x request ran at the model default + (max); gated on GLM-5.2+ since older GLM does not read it (#2721) +- **RTK**: system prompt injection matches each target wire format + (Chat/Responses/Claude/Gemini/Kiro) and is exact-idempotent across retries, + so distinct prompts sharing a long prefix are no longer collapsed (#3202). + Also set the diagnostic before the silent null return on Responses + translation failure so the panel is no longer blank +- **OpenCode**: route muse-spark through /zen/v1/responses (it 500s on + chat/completions), normalizing the Chat fields the Responses API rejects + and clamping max/ultra effort to xhigh +- **CLI**: install better-sqlite3 without build tools on Node 22+ (N-API + 13.0.3 ships per-platform prebuilds, `--ignore-scripts` skips the implicit + node-gyp build); Node < 22 stays on 12.6.2, working installs untouched +- **CLI tools**: send the API key Codex actually reads — + `[model_providers.9router.http_headers]` instead of auth.json (which left + every request 401 and clobbered an existing ChatGPT login); subagent model + moved to `agents.default_subagent_model` +- **OAuth**: refresh Cline tokens with the extension JSON contract +- **Dashboard**: clamp the API key mask length — keys shorter than 8 chars + threw RangeError and crashed the media-provider detail page +- **UI**: wait for the Material Symbols font itself before revealing icons — + `document.fonts.ready` resolved before the 4MB woff2 even started loading, + leaving icons blank until a second load + # v0.5.55 (2026-08-14) ## Features diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js index feca2f59..3cb286e7 100644 --- a/cli/hooks/sqliteRuntime.js +++ b/cli/hooks/sqliteRuntime.js @@ -6,7 +6,13 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const BETTER_SQLITE3_VERSION = "12.6.2"; +// Gate the pinned version by Node major, mirroring src/lib/db/driver.js gating +// style: 13.x is N-API and ships per-platform prebuilds inside the package, so +// it needs no ABI-specific download. It requires Node >= 22; older runtimes stay +// on 12.6.2, which fetches an ABI-specific binary via prebuild-install. +const [NODE_MAJOR] = process.versions.node.split(".").map(Number); +const USE_NAPI_BUILD = NODE_MAJOR >= 22; +const BETTER_SQLITE3_VERSION = USE_NAPI_BUILD ? "13.0.3" : "12.6.2"; const SQL_JS_VERSION = "1.14.1"; function getDataDir() { @@ -45,9 +51,23 @@ function hasModule(name) { return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json")); } +function isGlibcRuntime() { + try { return Boolean(process.report?.getReport()?.header?.glibcVersionRuntime); } catch { return true; } +} + +// 12.x compiles/downloads into build/Release; 13.x ships prebuilds/-.node. +function getBetterSqliteBinary() { + const root = path.join(getRuntimeNodeModules(), "better-sqlite3"); + const platform = process.platform === "linux" && !isGlibcRuntime() ? "linuxmusl" : process.platform; + return [ + path.join(root, "build", "Release", "better_sqlite3.node"), + path.join(root, "prebuilds", `${platform}-${process.arch}.node`), + ].find((file) => fs.existsSync(file)); +} + function isBetterSqliteBinaryValid() { - const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node"); - if (!fs.existsSync(binary)) return false; + const binary = getBetterSqliteBinary(); + if (!binary) return false; try { const fd = fs.openSync(binary, "r"); const buf = Buffer.alloc(4); @@ -91,6 +111,7 @@ function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) { function npmInstall(pkgs, opts = {}) { const cwd = ensureRuntimeDir(); const extra = opts.optional ? ["--no-save"] : []; + if (opts.ignoreScripts) extra.push("--ignore-scripts"); if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)..."); const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 }); if (!res.ok && !opts.silent) { @@ -129,7 +150,10 @@ function ensureSqliteRuntime({ silent = false } = {}) { return { betterSqlite: true, sqlJs: sqlJsOk }; } - const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent }); + // npm injects an implicit `node-gyp rebuild` for any package carrying a + // binding.gyp, which would demand build tools even though 13.x already bundles + // the binary — skip scripts so the bundled prebuild is used as-is. + const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent, ignoreScripts: USE_NAPI_BUILD }); return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), sqlJs: sqlJsOk, diff --git a/cli/package.json b/cli/package.json index 2fe55c9c..a6618ce8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.55", + "version": "0.5.59", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 5ddf95ac..3e18633e 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -171,6 +171,13 @@ export const LOAD_CODE_ASSIST_METADATA = { // System prompts export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; +// Rewrite rules applied to Antigravity system prompts: competing-client branding +// makes the backend flag the request and answer 429 Quota Exhausted. +export const ANTIGRAVITY_PROMPT_REWRITES = [ + { from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" }, + { from: /opencode/gi, to: (m) => (m === "OpenCode" ? "Antigravity" : m === "OPENCODE" ? "ANTIGRAVITY" : "antigravity") } +]; + export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"; // Derive từ registry oauth.refreshLeadMs diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 07bbb4fc..35ec1006 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -1,7 +1,7 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; +import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX, ANTIGRAVITY_PROMPT_REWRITES } from "../config/appConstants.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; @@ -246,13 +246,13 @@ export class AntigravityExecutor extends BaseExecutor { const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; stripBlacklisted(requestWithoutTools); - // Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from - // flagging the request and immediately blocking it with a 429 Quota Exhausted response. + // Rewrite competing-client branding in system prompts (e.g. Zed's Claude prompt, + // OpenCode naming) so Antigravity doesn't flag the request with a 429 Quota Exhausted. if (requestWithoutTools.systemInstruction?.parts) { - const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; for (const part of requestWithoutTools.systemInstruction.parts) { - if (typeof part.text === "string" && part.text.includes(oldText)) { - part.text = part.text.split(oldText).join(""); + if (typeof part.text !== "string") continue; + for (const { from, to } of ANTIGRAVITY_PROMPT_REWRITES) { + part.text = part.text.replaceAll(from, to); } } } diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js index aad40439..f694e61b 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.js @@ -42,12 +42,235 @@ export class CommandCodeExecutor extends BaseExecutor { async execute(opts) { const result = await super.execute(opts); if (!result?.response?.ok || !result.response.body) return result; - result.response = wrapNdjsonAsOpenAISse(result.response, opts.model); + result.response = await inspectAndWrapCommandCodeResponse(result.response, opts.model); return result; } + + parseError(response, bodyText) { + let parsed = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + const errObj = parsed?.error || parsed; + const msg = errObj?.message || parsed?.message || bodyText || response.statusText; + const status = Number(errObj?.code || errObj?.statusCode || response.status) || response.status; + return { + status, + message: msg || `CommandCode upstream error: ${response.status}`, + }; + } } -function wrapNdjsonAsOpenAISse(originalResponse, model) { +export function parseCommandCodeError(event) { + if (!event || typeof event !== "object") { + return { + statusCode: 503, + message: "CommandCode upstream error", + type: "server_error", + }; + } + + const errVal = event.error ?? event.message ?? "unknown"; + let message = ""; + let statusCode = null; + let type = "server_error"; + + if (typeof errVal === "object" && errVal !== null) { + message = errVal.message || errVal.error || JSON.stringify(errVal); + if (errVal.statusCode && Number.isInteger(Number(errVal.statusCode))) { + statusCode = Number(errVal.statusCode); + } else if (errVal.status && Number.isInteger(Number(errVal.status))) { + statusCode = Number(errVal.status); + } + if (errVal.type) type = errVal.type; + } else if (typeof errVal === "string") { + message = errVal; + } else { + message = JSON.stringify(errVal); + } + + if (event.statusCode && Number.isInteger(Number(event.statusCode))) { + statusCode = Number(event.statusCode); + } + + if (!statusCode || statusCode < 400 || statusCode > 599) { + const lower = message.toLowerCase(); + if (lower.includes("rate limit") || lower.includes("too many requests")) { + statusCode = 429; + type = "rate_limit_error"; + } else if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) { + statusCode = 401; + type = "authentication_error"; + } else if (lower.includes("payment required") || lower.includes("billing")) { + statusCode = 402; + type = "billing_error"; + } else if (lower.includes("quota") || lower.includes("forbidden") || lower.includes("permission")) { + statusCode = 403; + type = "permission_error"; + } else if (lower.includes("not found")) { + statusCode = 404; + type = "invalid_request_error"; + } else if (lower.includes("unavailable") || lower.includes("overloaded") || lower.includes("server error")) { + statusCode = 503; + type = "server_error"; + } else { + statusCode = 503; + } + } + + return { statusCode, message, type }; +} + +export async function inspectAndWrapCommandCodeResponse(originalResponse, model) { + const reader = originalResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const bufferedLines = []; + let detectedError = null; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + const trimmed = buffer.trim(); + if (trimmed) { + try { + const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed; + const parsed = JSON.parse(jsonStr); + if (parsed?.type === "error") { + detectedError = parsed; + } else { + bufferedLines.push(trimmed); + } + } catch { + bufferedLines.push(trimmed); + } + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + let stopLoop = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed; + if (!jsonStr || jsonStr === "[DONE]") { + bufferedLines.push(trimmed); + stopLoop = true; + break; + } + + let event; + try { + event = JSON.parse(jsonStr); + } catch { + bufferedLines.push(trimmed); + continue; + } + + if (event?.type === "error") { + detectedError = event; + stopLoop = true; + break; + } + + bufferedLines.push(trimmed); + + if ( + event?.type === "text-delta" || + event?.type === "reasoning-delta" || + event?.type === "tool-input-start" || + event?.type === "tool-call" || + event?.type === "finish" || + event?.type === "finish-step" + ) { + stopLoop = true; + break; + } + } + + if (stopLoop) break; + } + } catch { + try { reader.releaseLock(); } catch { /* ignore */ } + return originalResponse; + } + + if (detectedError) { + try { await reader.cancel(); } catch { /* ignore */ } + const { statusCode, message, type } = parseCommandCodeError(detectedError); + return new Response( + JSON.stringify({ + error: { + message: `[CommandCode error: ${message}]`, + type, + code: statusCode, + }, + }), + { + status: statusCode, + statusText: statusCode === 503 ? "Service Unavailable" : (statusCode === 429 ? "Too Many Requests" : "Bad Gateway"), + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + } + ); + } + + const combinedStream = createReplayedStream(bufferedLines, buffer, reader); + return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse); +} + +function createReplayedStream(bufferedLines, remainingBuffer, reader) { + const encoder = new TextEncoder(); + let replayed = false; + + return new ReadableStream({ + async pull(controller) { + if (!replayed) { + replayed = true; + let prefix = bufferedLines.join("\n"); + if (prefix && remainingBuffer) { + prefix += "\n" + remainingBuffer; + } else if (remainingBuffer) { + prefix = remainingBuffer; + } else if (prefix) { + prefix += "\n"; + } + if (prefix) { + controller.enqueue(encoder.encode(prefix)); + } + } + + try { + const { value, done } = await reader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + } catch (err) { + controller.error(err); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } catch { + /* ignore */ + } + }, + }); +} + +function wrapNdjsonAsOpenAISse(streamBody, model, originalResponse = null) { const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; @@ -70,7 +293,6 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) { for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); } }, @@ -83,11 +305,17 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) { }, }); - const newBody = originalResponse.body.pipeThrough(transform); + const newBody = streamBody.pipeThrough(transform); return new Response(newBody, { - status: originalResponse.status, - statusText: originalResponse.statusText, - headers: originalResponse.headers, + status: originalResponse?.status || 200, + statusText: originalResponse?.statusText || "OK", + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + ...(originalResponse?.headers ? Object.fromEntries(originalResponse.headers.entries()) : {}), + "content-type": "text/event-stream", + }, }); } diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 1bdeb318..381c47e8 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,11 +1,13 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; const OPENCODE_UA = "opencode"; -const MESSAGES_MODELS = new Set(); +// Models served by /zen/v1/responses; every other model stays on /chat/completions. +const RESPONSES_MODELS = new Set(["muse-spark-1.2-contributor-free"]); function generateRequestId() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; @@ -15,19 +17,47 @@ function generateSessionId() { return `ses_${crypto.randomUUID().replace(/-/g, "")}`; } -// Normalize any resolved id into opencode's ses_ format (stable per-conversation) -function toOpencodeSession(id) { - const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, ""); - return stripped ? `ses_${stripped}` : null; +// Strip the thinking suffix "model(level)" so registry lookups hit the base id. +function baseModelId(model) { + return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); +} + +function isResponsesModel(model) { + return RESPONSES_MODELS.has(baseModelId(model)); } function resolveOpencodeSession(body, credentials) { - return toOpencodeSession(resolveSessionId({ - headers: credentials?.rawHeaders, + const headers = credentials?.rawHeaders || {}; + return resolveSessionId({ + headers, body, connectionId: credentials?.connectionId, scope: "opencode", - })); + generate: generateSessionId, + }); +} + +function normalizeOpencodeReasoning(model, body) { + const current = body.reasoning; + const currentReasoning = current && typeof current === "object" && !Array.isArray(current) + ? current + : null; + const requestedEffort = typeof body.reasoning_effort === "string" + ? body.reasoning_effort + : currentReasoning?.effort; + if (typeof requestedEffort !== "string") return; + + const cleanModel = baseModelId(model || body.model); + const supportedLevels = getThinkingLevels("opencode", cleanModel); + let effort = requestedEffort.toLowerCase().trim(); + if ((effort === "max" || effort === "ultra") && supportedLevels?.length && !supportedLevels.includes(effort)) { + if (effort === "ultra" && supportedLevels.includes("max")) effort = "max"; + else if (supportedLevels.includes("xhigh")) effort = "xhigh"; + } + + body.reasoning = { ...currentReasoning, effort }; + if (!body.reasoning.summary) body.reasoning.summary = "auto"; + delete body.reasoning_effort; } // OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish @@ -44,13 +74,24 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { this._currentSessionId = resolveOpencodeSession(body, credentials); + if (isResponsesModel(model)) { + // Responses API names the output cap max_output_tokens and takes thinking + // as reasoning:{effort,summary} — normalize the Chat fields at this boundary. + if (body.max_output_tokens === undefined) { + if (body.max_completion_tokens !== undefined) body.max_output_tokens = body.max_completion_tokens; + else if (body.max_tokens !== undefined) body.max_output_tokens = body.max_tokens; + } + delete body.max_tokens; + delete body.max_completion_tokens; + normalizeOpencodeReasoning(model, body); + } return injectReasoningContent({ provider: this.provider, model, body }); } buildUrl(model) { const base = this.config.baseUrl; - return MESSAGES_MODELS.has(model) - ? `${base}/zen/v1/messages` + return isResponsesModel(model) + ? `${base}/zen/v1/responses` : `${base}/zen/v1/chat/completions`; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index c41d2d15..5b1d4024 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -28,6 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; +import { defaultClaudeToolType } from "../translator/concerns/toolCall.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js"; @@ -63,7 +64,7 @@ export function stripContinuityFields(body) { return body; } -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); // Stable per-session color so all lines of one CLI conversation share a tag @@ -96,7 +97,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // differ — kimi/glm only do /chat/completions). Undeclared models keep the // upstream default (use the transport), preserving behavior for glm/deepseek/... const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null; - const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials); + // A source-format-matched endpoint keeps the request lossless. Prefer it + // over a model-level targetFormat, which is only the fallback for clients + // whose wire format has no supported transport (for example MiniMax-M3: + // OpenAI clients should stay on /chat/completions; other clients can fall + // back to its declared Claude target). + const targetFormat = useTransport?.format || modelTargetFormat || getTargetFormat(provider, credentials); if (useTransport && credentials) credentials.runtimeTransport = useTransport; const stripList = getModelStrip(alias, model); const upstreamModel = getModelUpstreamId(alias, model); @@ -241,6 +247,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred delete translatedBody.tools; } + // Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax) + // reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing. + if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) { + translatedBody.tools = defaultClaudeToolType(translatedBody.tools); + } + // Per-request opt-out: client can bypass all token savers via header const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off"; @@ -251,7 +263,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Headroom: optional external proxy compression; fail open if proxy is absent. const headroomDiagnostics = {}; - const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics }); + const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, timeoutMs: headroomTimeoutMs, diagnostics: headroomDiagnostics }); const headroomLine = formatHeadroomLog(headroomStats); const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics); if (headroomLine) { diff --git a/open-sse/handlers/imageProviders/antigravity.js b/open-sse/handlers/imageProviders/antigravity.js index a1f90519..4d4dc367 100644 --- a/open-sse/handlers/imageProviders/antigravity.js +++ b/open-sse/handlers/imageProviders/antigravity.js @@ -1,6 +1,6 @@ // Antigravity image adapter - delegates to the executor for correct request // envelope (project, model, requestType, sessionId) and auth headers. -import { nowSec } from "./_base.js"; +import { nowSec, sizeToAspectRatio } from "./_base.js"; import { getExecutor } from "../../executors/index.js"; // Convert image input (data URI or raw base64) to Gemini inlineData part @@ -31,6 +31,19 @@ export default { const executor = getExecutor("antigravity"); if (!executor) throw new Error("Antigravity executor not found"); + // Ensure we use an image model for image generation + const isImageModel = (m) => /image|imagen|image-generation/i.test(m || ""); + let targetModel = isImageModel(model) ? model : "gemini-3.1-flash-image"; + + // If body.size is provided, resolve aspect ratio and append to model + if (body.size && typeof body.size === "string") { + const ratio = sizeToAspectRatio(body.size); + const suffix = ratio.replace(":", "x"); + if (!targetModel.includes(suffix)) { + targetModel = `${targetModel}-${suffix}`; + } + } + // Build parts: text prompt + optional input image for editing const parts = [{ text: body.prompt }]; const imageInput = body.image || (Array.isArray(body.images) && body.images[0]); @@ -44,7 +57,7 @@ export default { }; const result = await executor.execute({ - model, + model: targetModel, body: chatBody, stream: false, credentials, diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 3c02828e..5e3b3c09 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -347,6 +347,81 @@ function buildSearxngRequest(config, params) { }; } +function buildXquikRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("Xquik requires an API key"); + + const queryType = getProviderSetting(params, "queryType"); + if (queryType && !["Latest", "Top"].includes(queryType)) { + throw new Error("Xquik queryType must be Latest or Top"); + } + + const qp = new URLSearchParams({ + q: params.query, + limit: String(params.maxResults), + }); + const cursor = getProviderSetting(params, "cursor"); + if (cursor) qp.set("cursor", cursor); + if (queryType) qp.set("queryType", queryType); + if (params.language) qp.set("language", params.language); + + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "x-api-key": apiKey }, + }, + }; +} + +// ── Ollama Cloud web_search ────────────────────────────────────────────── +// POST https://ollama.com/api/web_search { query, max_results } +// Response: { results: [{ title, url, content, published_at? }] } +function buildOllamaSearchRequest(config, params) { + const body = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.language = params.language; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify(body), + }, + }; +} + +// ── GLM Coding plan MCP web_search_prime ────────────────────────────────── +// POST https://api.z.ai/api/mcp/web_search_prime/mcp +// JSON-RPC envelope: { jsonrpc, id, method: "tools/call", +// params: { name: "web_search_prime", arguments: { search_query, count } } } +// Response: { result: { content: [{ type: "text", text: "" }] } } +function buildGlmSearchRequest(config, params) { + const body = { + jsonrpc: "2.0", + id: `9r-${Date.now()}`, + method: "tools/call", + params: { + name: "web_search_prime", + arguments: { search_query: params.query, count: params.maxResults }, + }, + }; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify(body), + }, + }; +} + // ── Dispatcher ────────────────────────────────────────────────────────── const BUILDERS = { @@ -360,6 +435,9 @@ const BUILDERS = { "searchapi": buildSearchApiRequest, "youcom": buildYouComRequest, "searxng": buildSearxngRequest, + "xquik": buildXquikRequest, + "ollama-search": buildOllamaSearchRequest, + "glm": buildGlmSearchRequest, }; /** diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js index c5bfb3ad..75f5e02b 100644 --- a/open-sse/handlers/search/chatSearch.js +++ b/open-sse/handlers/search/chatSearch.js @@ -1,8 +1,10 @@ /** * Wrap chat-completions endpoints (with built-in web search) into the unified - * /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity. + * /v1/search response format. Supports gemini, antigravity, openai, xai, kimi, + * minimax, perplexity. */ import { PROVIDER_MEDIA } from "../../providers/index.js"; +import { ANTIGRAVITY_IDE_USER_AGENT } from "../../providers/shared.js"; // Default search model + endpoint derive from registry searchViaChat (single source) const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel; @@ -28,13 +30,37 @@ function toResult(c, index, provider, retrievedAt) { score: null, published_at: null, favicon_url: null, - content: null, + content: c.content || null, metadata: {}, citation: { provider, retrieved_at: retrievedAt, rank: index + 1 }, provider_raw: null }; } +// Antigravity search request envelope (mirrors the IDE client) +const AG_CLIENT_NAME = "antigravity"; +const AG_SEARCH_GENERATION_CONFIG = { temperature: 1.0, maxOutputTokens: 8192 }; +const AG_CONTEXT_BEFORE = 150; +const AG_CONTEXT_AFTER = 250; + +/** Widen a grounded segment to its surrounding sentence(s) in the answer text. */ +function expandSegment(text, segment) { + const { startIndex, endIndex } = segment || {}; + if (!text || !Number.isInteger(startIndex) || !Number.isInteger(endIndex)) return ""; + const start = Math.max(0, startIndex - AG_CONTEXT_BEFORE); + const end = Math.min(text.length, endIndex + AG_CONTEXT_AFTER); + let out = text.slice(start, end).trim(); + // Drop the partial words the window cut off at either edge + if (start > 0) out = `...${out.replace(/^\S+/, "")}`; + if (end < text.length) out = `${out.replace(/\S+$/, "")}...`; + return out.trim(); +} + +/** Join deduped grounding pieces, skipping empties. */ +function joinPieces(set, sep) { + return [...(set || [])].filter(Boolean).join(sep).trim(); +} + /** Coerce a citation that might be a raw URL string or an object. */ function normalizeCitation(c) { if (!c) return null; @@ -46,6 +72,8 @@ function normalizeCitation(c) { /** * Provider-specific configuration map. All providers must implement: * { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer } + * Optional: requireCredentials(credentials) → error string when a provider needs + * more than a token (returns null when satisfied). */ const CHAT_SEARCH_CONFIG = { gemini: { @@ -73,6 +101,71 @@ const CHAT_SEARCH_CONFIG = { } }, + antigravity: { + endpoint: () => searchEndpoint("antigravity"), + // Upstream 403s on a missing or fabricated project — surface the real cause + requireCredentials: (credentials) => + credentials?.projectId ? null : "Antigravity account has no projectId — reconnect the account", + buildBody: (query, model, credentials) => ({ + project: credentials.projectId, + model, + userAgent: AG_CLIENT_NAME, + requestType: "search", + request: { + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ googleSearch: {} }], + generationConfig: AG_SEARCH_GENERATION_CONFIG + } + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": ANTIGRAVITY_IDE_USER_AGENT + }), + extractAnswer: (data) => { + // Antigravity wraps the Gemini payload in { response: {...} } + const response = data?.response || data; + const candidate = response?.candidates?.[0]; + const parts = candidate?.content?.parts || []; + const text = parts.map((p) => p?.text || "").filter(Boolean).join(""); + const grounding = candidate?.groundingMetadata || {}; + const chunks = grounding.groundingChunks || []; + const supports = grounding.groundingSupports || []; + + // Upstream repeats the same source across chunks — key by URL so it stays one citation. + // Map, not a plain object: both the index and the URL come from upstream. + const sources = new Map(); + const byIndex = chunks.map((ch) => { + const web = ch?.web; + const url = web?.uri || web?.url || ""; + if (!url) return null; + if (!sources.has(url)) sources.set(url, { title: web.title || "", snippets: new Set(), contexts: new Set() }); + return sources.get(url); + }); + + // Each support ties a sentence of the answer back to the chunks that grounded it + for (const s of supports) { + const segment = s?.segment; + const grounded = segment?.text || ""; + const expanded = expandSegment(text, segment) || grounded; + for (const idx of s?.groundingChunkIndices || []) { + const source = Number.isInteger(idx) ? byIndex[idx] : null; + if (!source) continue; + if (grounded) source.snippets.add(grounded); + if (expanded) source.contexts.add(expanded); + } + } + + const citations = [...sources].map(([url, src]) => { + const snippet = joinPieces(src.snippets, " | ") || src.title; + return { url, title: src.title, snippet, content: joinPieces(src.contexts, "\n\n") || snippet }; + }); + + const tokens = response?.usageMetadata?.totalTokenCount || 0; + return { text, citations, tokens }; + } + }, + openai: { endpoint: () => searchEndpoint("openai"), buildBody: (query, model) => { @@ -366,13 +459,18 @@ export async function handleChatSearch({ }; } + const credentialError = cfg.requireCredentials?.(credentials); + if (credentialError) { + return { success: false, status: 401, error: credentialError }; + } + const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : DEFAULT_MAX_RESULTS; const useModel = model || searchModel(provider); const url = cfg.endpoint(useModel); - const body = cfg.buildBody(query, useModel); + const body = cfg.buildBody(query, useModel, credentials); const headers = cfg.buildHeaders(token); const controller = new AbortController(); diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js index f5815471..662b293e 100644 --- a/open-sse/handlers/search/index.js +++ b/open-sse/handlers/search/index.js @@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType); const results = normalized.results.slice(0, params.maxResults); const duration = Date.now() - startTime; + const usage = { + queries_used: 1, + search_cost_usd: providerConfig.costPerQuery ?? null, + }; + if (Number.isFinite(providerConfig.creditsPerResult)) { + usage.provider_credits_used = results.length * providerConfig.creditsPerResult; + } return { success: true, @@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential query: params.query, results, answer: null, - usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 }, + usage, + ...(normalized.pagination ? { pagination: normalized.pagination } : {}), metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults }, errors: [] } diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index 898b271f..3b415ae5 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -199,6 +199,89 @@ function normalizeSearxng(data, _query, _searchType) { return { results, totalResults: results.length }; } +function normalizeXquik(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.tweets) ? data.tweets : []; + const results = items.map((item, idx) => { + const username = typeof item?.author?.username === "string" ? item.author.username : ""; + const authorName = typeof item?.author?.name === "string" ? item.author.name : ""; + const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || ""); + const url = username && tweetId + ? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}` + : tweetId + ? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}` + : ""; + const author = username ? `@${username}` : authorName || null; + const title = author ? `${author} on X` : "X post"; + const imageUrl = Array.isArray(item?.media) + ? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl + : null; + + return makeResult("xquik", { + title, + url, + snippet: typeof item?.text === "string" ? item.text : "", + published_at: typeof item?.createdAt === "string" ? item.createdAt : null, + author, + image_url: imageUrl || null, + source_type: "x_post", + full_text: typeof item?.text === "string" ? item.text : undefined, + text_format: "text", + }, idx, now); + }); + const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null; + return { + results, + totalResults: null, + pagination: { + has_more: data.has_next_page === true, + next_cursor: nextCursor, + }, + }; +} + +function normalizeOllamaSearch(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data?.results) ? data.results : (Array.isArray(data) ? data : []); + const results = items.map((item, idx) => + makeResult("ollama-search", { + title: item.title, + url: item.url, + snippet: item.content || item.snippet || "", + full_text: item.content, + text_format: "text", + published_at: item.published_at || null, + source_type: item.source || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeGlmSearch(data, _query, _searchType) { + const now = new Date().toISOString(); + // MCP envelope: { result: { content: [{ type: "text", text: "" }] } } + let payload = data; + const textContent = data?.result?.content?.[0]?.text; + if (typeof textContent === "string") { + try { payload = JSON.parse(textContent); } catch { payload = {}; } + } + const items = Array.isArray(payload?.results) ? payload.results + : Array.isArray(payload?.news) ? payload.news + : Array.isArray(payload) ? payload + : []; + const results = items.map((item, idx) => + makeResult("glm", { + title: item.title, + url: item.link || item.url, + snippet: item.content || "", + published_at: item.publish_date || item.published_at || null, + favicon_url: item.icon || null, + source_type: item.media || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + const NORMALIZERS = { "serper": normalizeSerper, "brave-search": normalizeBrave, @@ -210,11 +293,14 @@ const NORMALIZERS = { "searchapi": normalizeSearchApi, "youcom": normalizeYouCom, "searxng": normalizeSearxng, + "xquik": normalizeXquik, + "ollama-search": normalizeOllamaSearch, + "glm": normalizeGlmSearch, }; /** * Dispatch to the appropriate normalizer based on providerId. - * @returns {{results: Array, totalResults: number|null}} + * @returns {{results: Array, totalResults: number|null, pagination?: object}} */ export function normalizeSearchResponse(providerId, data, query, searchType) { const fn = NORMALIZERS[providerId]; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 24a03b04..dcdac7ea 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -6,6 +6,16 @@ // 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic // 4. DEFAULT_CAPABILITIES — safe floor (always returned) // +// Two extra layers then refine the result, and neither can override the hand +// written tables above (steps 1-2 short-circuit before they are consulted): +// • the synced catalog — modalities keyed by model, limits keyed by provider +// + model, refreshed from models.dev in the background. It reads a file, so +// the server installs it via setCatalogSource(); this module stays free of +// node:fs because the dashboard bundles it into the browser too. +// • visionPatterns.js — name-based vision detection, last resort so a model +// nobody has catalogued yet still accepts images. +// Both only ever turn a capability ON. +// // ── HOW TO ADD / UPDATE A MODEL ────────────────────────────────────── // Authoritative data source: https://models.dev/api.json (145 providers, 4000+ // models, MIT). Each model exposes the exact fields we map below: @@ -23,6 +33,7 @@ // 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json import { matchPattern } from "./pricing.js"; +import { looksLikeVisionModel } from "./visionPatterns.js"; /** * Safe floor — every resolved result is merged over this so consumers @@ -46,6 +57,7 @@ export const DEFAULT_CAPABILITIES = { thinkingFormat: null, thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable) thinkingRange: null, // { min, max } for budget formats; null = no clamp + thinkingEffortSupported: false, // zai format only: model accepts a reasoning_effort level (GLM-5.2+; older GLM ignores it) // limits (tokens) contextWindow: 200000, maxOutput: 64000, @@ -94,8 +106,14 @@ export const MODEL_CAPABILITIES = { // Gemini image-gen / OpenAI image / xai image variants "gpt-image-1": { imageOutput: true, tools: false }, - // GLM vision variant (text GLM has no vision) - "glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 }, + // GLM vision variants (text GLM has no vision) — 5.3-Flash and 5V-Turbo are + // natively multimodal per z.ai, and 5.3-Flash carries the full 1M window. + "glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "zai", contextWindow: 1000000, maxOutput: 131072 }, + "glm-4.6v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000, maxOutput: 32768 }, + "glm-4.5v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 64000, maxOutput: 16384 }, + + // DeepSeek's first V4 model with image input; text limits match V4-Flash. + "deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, @@ -108,6 +126,8 @@ export const MODEL_CAPABILITIES = { "kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + // OpenCode Free Muse Spark — OpenAI Responses reasoning supports up to xhigh. + "muse-spark-1.2-contributor-free": { reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; @@ -234,6 +254,8 @@ export const PATTERN_CAPABILITIES = [ // ── Grok (vision + Live Search) ────────────────────────────────── { pattern: "*grok*image*", caps: { imageOutput: true } }, { pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } }, + // Grok 4.6: 500k context, no text output limit (docs.x.ai/developers/grok-4-6) + { pattern: "*grok-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 500000 } }, // Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models { pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } }, { pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, @@ -261,6 +283,10 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } }, // ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─ + // reasoning_effort is only read by z.ai from GLM-5.2 onward (docs.z.ai/guides/capabilities/thinking) — + // older GLM (4.x, 5.0, 5.1, 5-turbo, 5v-turbo) ignore it, so gate it per exact version, not the "*glm-5*" catch-all. + { pattern: "*glm-5.3*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } }, + { pattern: "*glm-5.2*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } }, { pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } }, { pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } }, { pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } }, @@ -325,6 +351,46 @@ export const PATTERN_CAPABILITIES = [ * @param {string} model * @returns {object} full capabilities object */ +const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; + +// Catalog lookups, installed by the server at startup. Left as no-ops in the +// browser bundle, where there is no file to read. +let catalogSource = null; + +/** + * Install the synced catalog reader (server only). + * @param {{ getModalities: Function, getLimits: Function } | null} source + */ +export function setCatalogSource(source) { + catalogSource = source; +} + +// Apply the synced catalog + name heuristic on top of a table-resolved result. +// Strictly additive: a capability already true stays true, and a false one only +// flips when an outside source positively declares support. +function refine(base, provider, model) { + const result = { ...DEFAULT_CAPABILITIES, ...base }; + + if (catalogSource) { + const modalities = catalogSource.getModalities(model); + if (modalities) { + for (const key of MODALITY_KEYS) { + if (modalities[key] === true) result[key] = true; + } + } + + const limits = catalogSource.getLimits(provider, model); + if (limits) { + if (limits.contextWindow > 0) result.contextWindow = limits.contextWindow; + if (limits.maxOutput > 0) result.maxOutput = limits.maxOutput; + } + } + + if (!result.vision && looksLikeVisionModel(model)) result.vision = true; + + return result; +} + export function getCapabilitiesForModel(provider, model) { if (!model) return { ...DEFAULT_CAPABILITIES }; @@ -342,13 +408,13 @@ export function getCapabilitiesForModel(provider, model) { if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] }; if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] }; - // 3. Pattern match (first match wins) + // 3. Pattern match (first match wins), refined by catalog + name heuristic for (const { pattern, caps } of PATTERN_CAPABILITIES) { if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { - return { ...DEFAULT_CAPABILITIES, ...caps }; + return refine(caps, provider, model); } } // 4. Floor - return { ...DEFAULT_CAPABILITIES }; + return refine(null, provider, model); } diff --git a/open-sse/providers/catalogOverride.js b/open-sse/providers/catalogOverride.js new file mode 100644 index 00000000..12914b7d --- /dev/null +++ b/open-sse/providers/catalogOverride.js @@ -0,0 +1,72 @@ +// Read side of the model catalog synced from models.dev. +// +// The file is the source of truth; the only thing held in memory is a parsed +// copy dropped as soon as the file's mtime changes. getCapabilitiesForModel is +// synchronous and runs per request, so the hot path is one stat (~1us) and the +// parse (~0.1ms on a ~18KB file) only reruns after a sync. + +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +export const CATALOG_FILE = path.join(DATA_DIR, "model-catalog.json"); +// Trimmed upstream catalog, read by the add-models skill (not by the router). +export const CATALOG_RAW_FILE = path.join(DATA_DIR, "model-catalog-raw.json"); + +const EMPTY = { models: {}, providers: {} }; +let cache = EMPTY; +let cachedMtime = -1; + +// "zai-org/GLM-4.6V:free" -> "glm-4.6v" +function baseId(model) { + if (!model) return ""; + const withoutVendor = model.includes("/") ? model.split("/").pop() : model; + return withoutVendor.toLowerCase().split(":")[0]; +} + +function load() { + let mtime; + try { + mtime = fs.statSync(CATALOG_FILE).mtimeMs; + } catch { + cache = EMPTY; + cachedMtime = -1; + return cache; + } + if (mtime === cachedMtime) return cache; + + cachedMtime = mtime; + try { + const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")); + cache = { models: parsed?.models || {}, providers: parsed?.providers || {} }; + } catch { + cache = EMPTY; + } + return cache; +} + +// Modality is a property of the model itself — any gateway serving it inherits +// the same image/video/pdf support, so this is keyed by model id alone. +export function getCatalogModalities(model) { + return load().models[baseId(model)] || null; +} + +// Context and output limits are a property of the gateway, not the model: each +// one truncates differently, so these stay keyed by provider + model. +export function getCatalogLimits(provider, model) { + const byProvider = provider && load().providers[provider]; + if (!byProvider) return null; + return byProvider[model] || byProvider[baseId(model)] || null; +} + +// Force a re-read on the next lookup (called right after a sync writes the file). +export function invalidateCatalog() { + cachedMtime = -1; +} + +// Hand the reader to capabilities.js. That module is bundled into the browser +// too, so it cannot import this file directly — the server pushes it in. +export async function installCatalogSource() { + const { setCatalogSource } = await import("./capabilities.js"); + setCatalogSource({ getModalities: getCatalogModalities, getLimits: getCatalogLimits }); +} diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 2666552b..1f14f415 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -17,7 +17,7 @@ export default { deprecationNotice: "RISK_NOTICE", }, category: "oauth", - serviceKinds: ["llm", "image"], + serviceKinds: ["llm", "image", "webSearch"], transport: { baseUrls: [ANTIGRAVITY_IDE_BASE_URL], format: "antigravity", @@ -82,6 +82,11 @@ export default { loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT, refreshLeadMs: 300000, }, + searchViaChat: { + defaultModel: "gemini-2.5-flash", + endpoint: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:generateContent`, + freeTier: "Free — Google Search grounding through an Antigravity OAuth account.", + }, features: { usage: true, }, diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 86123b28..bb8015b0 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -45,6 +45,7 @@ export default { { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" }, { id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)" }, { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js index cff9eb92..73189464 100644 --- a/open-sse/providers/registry/glm-cn.js +++ b/open-sse/providers/registry/glm-cn.js @@ -22,10 +22,12 @@ export default { }, models: [ { id: "glm-5.3", name: "GLM 5.3" }, + { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, { id: "glm-4.7", name: "GLM-4.7" }, + { id: "glm-4.6v", name: "GLM 4.6V (Vision)" }, { id: "glm-4.6", name: "GLM-4.6" }, { id: "glm-4.5-air", name: "GLM-4.5-Air" }, ], diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index 9bc099b2..6c5f0f6e 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -46,12 +46,27 @@ export default { ], models: [ { id: "glm-5.3", name: "GLM 5.3" }, + { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, { id: "glm-4.7", name: "GLM 4.7" }, { id: "glm-4.6v", name: "GLM 4.6V (Vision)" }, ], + serviceKinds: ["llm", "webSearch"], + // Coding plan bundles web search on the same API key as chat. + searchConfig: { + baseUrl: "https://api.z.ai/api/mcp/web_search_prime/mcp", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 50, + timeoutMs: 10000, + cacheTTLMs: 300000, + }, features: { usage: true, usageApikey: true, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 1e102b7f..02b94667 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -120,6 +120,8 @@ import p117 from "./selfhosted-tts.js"; import p118 from "./selfhosted-embedding.js"; import p119 from "./fish-audio.js"; import p120 from "./alitp-intl.js"; +import p121 from "./xquik.js"; +import p122 from "./ollama-search.js"; export default [ p0, @@ -243,4 +245,6 @@ export default [ p118, p119, p120, + p121, + p122, ]; diff --git a/open-sse/providers/registry/ollama-search.js b/open-sse/providers/registry/ollama-search.js new file mode 100644 index 00000000..f6bceaf9 --- /dev/null +++ b/open-sse/providers/registry/ollama-search.js @@ -0,0 +1,35 @@ +export default { + id: "ollama-search", + alias: "ollama-search", + display: { + name: "Ollama Search", + icon: "cloud", + color: "#ffffff", + textIcon: "OL", + website: "https://ollama.com", + notice: { + text: "Web search via Ollama Cloud subscription. Reuses the API key from the Ollama (chat) provider.", + apiKeyUrl: "https://ollama.com/settings/keys", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + serviceKinds: ["webSearch"], + // Credential fallback: reuses the API key registered under the `ollama` + // chat provider — one key, chat + search. + credentialFallback: "ollama", + searchConfig: { + baseUrl: "https://ollama.com/api/web_search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + freeMonthlyQuota: 1000, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 10, + timeoutMs: 10000, + cacheTTLMs: 300000, + }, +}; diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index 4b189ba8..0dad175f 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -31,12 +31,14 @@ export default { { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, ], models: [ + { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] }, { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index e83ad3a7..469c64ca 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -19,7 +19,11 @@ export default { }, noAuth: true, }, - models: [], + models: [ + // Only this model is served by /zen/v1/responses; the rest stay on + // /chat/completions, so the format is declared per-model, not per-provider. + { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" }, + ], modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, passthroughModels: true, }; diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index 53a73c07..e9cbb6c2 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -27,6 +27,8 @@ export default { refreshUrl: "https://auth.x.ai/oauth2/token", }, models: [ + { id: "grok-4.6", name: "Grok 4.6" }, + { id: "grok-4.5", name: "Grok 4.5" }, { id: "grok-4", name: "Grok 4" }, { id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" }, { id: "grok-code-fast-1", name: "Grok Code Fast" }, diff --git a/open-sse/providers/registry/xquik.js b/open-sse/providers/registry/xquik.js new file mode 100644 index 00000000..bdac9bab --- /dev/null +++ b/open-sse/providers/registry/xquik.js @@ -0,0 +1,35 @@ +export default { + id: "xquik", + alias: "xquik", + display: { + name: "Xquik", + icon: "tag", + color: "#5C3327", + textIcon: "XQ", + website: "https://docs.xquik.com/api-reference/x/search-tweets", + notice: { + apiKeyUrl: "https://xquik.com", + text: "Searches public X posts. Billing uses 1 Xquik credit per returned post." + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://xquik.com/api/v1/x/tweets/search", + validateUrl: "https://xquik.com/api/v1/credits", + method: "GET", + authType: "apikey", + authHeader: "x-api-key", + searchTypes: [ + "x" + ], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10000, + cacheTTLMs: 60000, + creditsPerResult: 1 + } +}; diff --git a/open-sse/providers/visionPatterns.js b/open-sse/providers/visionPatterns.js new file mode 100644 index 00000000..3c93afe6 --- /dev/null +++ b/open-sse/providers/visionPatterns.js @@ -0,0 +1,42 @@ +// Name-based vision detection — last resort when neither the catalog file nor +// the capability tables know a model. Vendors put the modality in the id +// ("qwen3-vl-plus", "glm-4.6v", "deepseek-v4-flash-vision-exp"), so a custom or +// freshly released model still gets image input instead of silently dropping it. +// +// Only ever turns vision ON. Never used to turn a declared capability off. + +const SEP = "[-_/:.]"; + +// Image GENERATION, video generation, and non-chat models also carry these +// words but take no image input — checked first so they can never match. +const NOT_VISION = new RegExp( + [ + `(^|${SEP})(image|img)(${SEP}|$)`, + "stable-image", "gen[0-9]_image", "nanobanana", "imagine", + "t2v", "i2v", "flux", "dall", "sdxl", "diffusion", + "embed", "rerank", "guard", "moderation", + "tts", "stt", "whisper", "voice", "speech", "audio", + ].join("|"), + "i" +); + +// Explicit modality words, plus the "v" suffix vendors use for vision +// variants (glm-4.6v, glm-5v-turbo). The digit-v branch requires a dotted +// version so the never-shipped `gpt-4v` cannot match. +const VISION_NAME = new RegExp( + [ + `(^|${SEP})(vision|vl|vlm|multimodal|omni|visual)(${SEP}|$)`, + `[0-9]\\.[0-9]+v(${SEP}|$)`, + `(^|${SEP})glm-[0-9]+v(${SEP}|$)`, + "(^|[-_/:.])(llava|pixtral|internvl|cogvlm|minicpm-v|moondream|idefics|fuyu)", + ].join("|"), + "i" +); + +// Does this model id look like a vision model? Name signal only. +export function looksLikeVisionModel(modelId) { + if (!modelId) return false; + const id = String(modelId).toLowerCase(); + if (NOT_VISION.test(id)) return false; + return VISION_NAME.test(id); +} diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js index 2b15eed2..05004ac4 100644 --- a/open-sse/rtk/headroom.js +++ b/open-sse/rtk/headroom.js @@ -7,6 +7,12 @@ import { const DEFAULT_TIMEOUT_MS = 3000; +function normalizeTimeout(value) { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_TIMEOUT_MS; +} + function jsonBytes(value) { try { return new TextEncoder().encode(JSON.stringify(value) || "").length; @@ -240,6 +246,7 @@ async function callCompress(url, messages, model, timeoutMs, compressUserMessage // /v1/compress only understands OpenAI shape, so Claude bodies are translated // to OpenAI, compressed, then translated back using 9Router's own translators. export async function compressWithHeadroom(body, { enabled, url, model, format, compressUserMessages, timeoutMs = DEFAULT_TIMEOUT_MS, diagnostics = null } = {}) { + timeoutMs = normalizeTimeout(timeoutMs); if (!enabled) { setDiagnostic(diagnostics, "disabled"); return null; @@ -281,7 +288,10 @@ export async function compressWithHeadroom(body, { enabled, url, model, format, return null; } const oai = openaiResponsesToOpenAIRequest(model, body, false); - if (!Array.isArray(oai?.messages)) return null; + if (!Array.isArray(oai?.messages)) { + setDiagnostic(diagnostics, "openai-responses request did not translate to messages[]"); + return null; + } const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); if (!data) return null; // input: undefined so the translator rebuilds input from the compressed diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js index 0d5af728..b60e15d3 100644 --- a/open-sse/rtk/systemInject.js +++ b/open-sse/rtk/systemInject.js @@ -3,96 +3,335 @@ // native-passthrough flows. Used by caveman.js and ponytail.js. import { FORMATS } from "../translator/formats.js"; +import { OPENAI_BLOCK, CLAUDE_BLOCK, RESPONSES_ITEM } from "../translator/schema/blocks.js"; +import { ROLE } from "../translator/schema/roles.js"; const SEP = "\n\n"; export function injectSystemPrompt(body, format, prompt) { - if (!body || !prompt) return; + try { + if (!body || !prompt) return; + if (typeof body !== "object") return; - switch (format) { - case FORMATS.CLAUDE: + // Kiro wire shape is unique (conversationState/systemPrompt) — handle directly. + if (isKiroBody(body) || format === FORMATS.KIRO) { + injectKiroSystem(body, prompt); + return; + } + + // Claude/Gemini own a dedicated system field, yet their bodies also carry + // messages[]/contents[] — decide by format label before the shape sniff below. + // Anthropic rejects a "system" role inside messages[] (no such input role). + if (format === FORMATS.CLAUDE) { injectClaudeSystem(body, prompt); return; - case FORMATS.GEMINI: - case FORMATS.GEMINI_CLI: - case FORMATS.VERTEX: - case FORMATS.ANTIGRAVITY: + } + if (format === FORMATS.GEMINI || format === FORMATS.GEMINI_CLI + || format === FORMATS.VERTEX || format === FORMATS.ANTIGRAVITY) { // Antigravity wraps Gemini shape in body.request → injectGeminiSystem handles it injectGeminiSystem(body, prompt); return; - default: - // OpenAI and OpenAI-shaped formats (responses/codex/cursor/kiro/ollama) - injectMessagesSystem(body, prompt); - } -} - -// OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string) -function injectMessagesSystem(body, prompt) { - // OpenAI Responses API: top-level string field - if (typeof body.instructions === "string") { - body.instructions = body.instructions - ? `${body.instructions}${SEP}${prompt}` - : prompt; - return; - } - - const arr = Array.isArray(body.messages) ? body.messages - : Array.isArray(body.input) ? body.input - : null; - if (!arr) return; - - const idx = arr.findIndex(m => m && (m.role === "system" || m.role === "developer")); - if (idx >= 0) { - appendToOpenAIMessage(arr[idx], prompt); - } else { - arr.unshift({ role: "system", content: prompt }); - } -} - -function appendToOpenAIMessage(msg, prompt) { - if (typeof msg.content === "string") { - msg.content = `${msg.content}${SEP}${prompt}`; - } else if (Array.isArray(msg.content)) { - // Responses-style array of parts {type:"input_text"|"text", text} - msg.content.push({ type: "input_text", text: prompt }); - } else { - msg.content = prompt; - } -} - -// Claude shape: body.system as string | array of {type:"text", text} -// Insert before the last cache_control block to keep injection inside the cached prefix. -function injectClaudeSystem(body, prompt) { - if (typeof body.system === "string" && body.system.length > 0) { - body.system = `${body.system}${SEP}${prompt}`; - return; - } - if (Array.isArray(body.system)) { - const block = { type: "text", text: prompt }; - let lastCacheIdx = -1; - for (let i = body.system.length - 1; i >= 0; i--) { - if (body.system[i]?.cache_control) { lastCacheIdx = i; break; } } - if (lastCacheIdx >= 0) { - body.system.splice(lastCacheIdx, 0, block); + + // Dispatch by actual wire shape for OpenAI-shaped formats. + // instructions string takes precedence; messages[] means Chat; input[] means Responses. + if (typeof body.instructions === "string") { + injectInstructionsSystem(body, prompt); + return; + } + if (Array.isArray(body.messages)) { + injectChatSystem(body, prompt); + return; + } + if (Array.isArray(body.input)) { + // Responses input[]: empty array already normalized elsewhere; string stays untouched here + injectResponsesInputSystem(body, prompt); + return; + } + if (typeof body.input === "string") { + // string input must stay untouched + return; + } + + // OpenAI-shaped but no array (e.g. empty body) — no-op + } catch (_) { + // fail-open + } +} + +function isKiroBody(body) { + if (!body || typeof body !== "object") return false; + if (typeof body.systemPrompt !== "string") return false; + const cs = body.conversationState; + if (!cs || typeof cs !== "object") return false; + return Array.isArray(cs.history) || !!(cs.currentMessage && typeof cs.currentMessage === "object"); +} + +// Exact idempotency: prompt present as its own SEP-delimited segment (or the +// whole string), not as a substring of unrelated text. +function hasPrompt(haystack, prompt) { + if (!haystack || typeof haystack !== "string") return false; + if (haystack === prompt) return true; + return haystack.split(SEP).includes(prompt); +} + +function dedupStringAppend(curr, prompt) { + if (!curr) return prompt; + if (hasPrompt(curr, prompt)) return curr; + return `${curr}${SEP}${prompt}`; +} + +// ---- OpenAI instructions string ---- +function injectInstructionsSystem(body, prompt) { + try { + const curr = body.instructions; + if (typeof curr !== "string") return; + if (hasPrompt(curr, prompt)) return; + const next = curr ? `${curr}${SEP}${prompt}` : prompt; + try { body.instructions = next; } catch (_) { /* frozen/proxy fail-open */ } + } catch (_) {} +} + +// ---- Chat messages[] ---- +function injectChatSystem(body, prompt) { + try { + const arr = body.messages; + if (!Array.isArray(arr)) return; + // Exact idempotency: scan existing system/developer content for full prompt + if (containsPromptInMessages(arr, prompt)) return; + let idx = -1; + try { idx = arr.findIndex(m => m && (m.role === ROLE.SYSTEM || m.role === ROLE.DEVELOPER)); } catch (_) { return; } + if (idx >= 0) { + appendToChatMessage(arr[idx], prompt); } else { - body.system.push(block); + // create typed system message at index 0; fail-open on frozen/proxy + try { arr.unshift({ role: ROLE.SYSTEM, content: prompt }); } catch (_) {} } - return; - } - body.system = prompt; + } catch (_) {} } -// Gemini shape: body.system_instruction | body.systemInstruction | body.request.systemInstruction -// Each shape: { parts: [{ text }] } +function containsPromptInMessages(arr, prompt) { + try { + for (const m of arr) { + if (!m || (m.role !== ROLE.SYSTEM && m.role !== ROLE.DEVELOPER)) continue; + const c = m.content; + if (typeof c === "string" && hasPrompt(c, prompt)) return true; + if (Array.isArray(c)) { + for (const part of c) { + if (part && typeof part.text === "string" && hasPrompt(part.text, prompt)) return true; + } + } + } + } catch (_) {} + return false; +} + +function appendToChatMessage(msg, prompt) { + try { + if (!msg || typeof msg !== "object") return; + const c = msg.content; + if (typeof c === "string") { + const next = dedupStringAppend(c, prompt); + if (next === c) return; + // avoid partial mutation: try assignment, bail if setter throws + try { msg.content = next; } catch (_) {} + return; + } + if (Array.isArray(c)) { + // already deduped at message level; but guard block-level too + try { + if (c.some(b => b && b.text === prompt)) return; + } catch (_) {} + try { c.push({ type: OPENAI_BLOCK.TEXT, text: prompt }); } catch (_) {} + return; + } + try { msg.content = prompt; } catch (_) {} + } catch (_) {} +} + +// ---- Responses input[] ---- +function injectResponsesInputSystem(body, prompt) { + try { + const arr = body.input; + if (!Array.isArray(arr)) return; + // instructions already handled above + if (containsPromptInResponsesInput(arr, prompt)) return; + // find system/developer message items only (type === message) + let idx = -1; + try { + idx = arr.findIndex(m => m && m.type === RESPONSES_ITEM.MESSAGE && (m.role === ROLE.SYSTEM || m.role === ROLE.DEVELOPER)); + } catch (_) { return; } + if (idx >= 0) { + appendToResponsesMessage(arr[idx], prompt); + } else { + const msg = { type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }] }; + try { arr.unshift(msg); } catch (_) {} + } + } catch (_) {} +} + +function containsPromptInResponsesInput(arr, prompt) { + try { + for (const item of arr) { + if (!item || item.type !== RESPONSES_ITEM.MESSAGE) continue; + if (item.role !== ROLE.SYSTEM && item.role !== ROLE.DEVELOPER) continue; + const c = item.content; + if (typeof c === "string" && hasPrompt(c, prompt)) return true; + if (Array.isArray(c)) { + for (const part of c) { + if (part && typeof part.text === "string" && hasPrompt(part.text, prompt)) return true; + } + } + } + } catch (_) {} + return false; +} + +function appendToResponsesMessage(msg, prompt) { + try { + if (!msg || typeof msg !== "object") return; + const c = msg.content; + if (typeof c === "string") { + const next = dedupStringAppend(c, prompt); + if (next === c) return; + try { msg.content = next; } catch (_) {} + return; + } + if (Array.isArray(c)) { + try { if (c.some(b => b && b.text === prompt)) return; } catch (_) {} + try { c.push({ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }); } catch (_) {} + return; + } + try { msg.content = [{ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }]; } catch (_) {} + } catch (_) {} +} + +// ---- Claude ---- +function injectClaudeSystem(body, prompt) { + try { + const sys = body.system; + if (typeof sys === "string") { + if (hasPrompt(sys, prompt)) return; + const next = sys.length > 0 ? `${sys}${SEP}${prompt}` : prompt; + try { body.system = next; } catch (_) {} + return; + } + if (Array.isArray(sys)) { + try { if (sys.some(b => b && b.text === prompt)) return; } catch (_) {} + const block = { type: CLAUDE_BLOCK.TEXT, text: prompt }; + let lastCacheIdx = -1; + try { + for (let i = sys.length - 1; i >= 0; i--) { + if (sys[i]?.cache_control) { lastCacheIdx = i; break; } + } + } catch (_) {} + try { + if (lastCacheIdx >= 0) sys.splice(lastCacheIdx, 0, block); + else sys.push(block); + } catch (_) {} + return; + } + // absent/null + try { body.system = prompt; } catch (_) {} + } catch (_) {} +} + +// ---- Gemini ---- function injectGeminiSystem(body, prompt) { - const target = body.request && typeof body.request === "object" ? body.request : body; - const useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction"); - const key = useSnake ? "system_instruction" : "systemInstruction"; - const sys = target[key]; - if (sys && Array.isArray(sys.parts)) { - sys.parts.push({ text: prompt }); - return; - } - target[key] = { parts: [{ text: prompt }] }; + try { + let target = body; + try { + if (body.request && typeof body.request === "object") target = body.request; + } catch (_) {} + let useSnake = false; + try { useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction"); } catch (_) {} + const key = useSnake ? "system_instruction" : "systemInstruction"; + let sys; + try { sys = target[key]; } catch (_) { sys = undefined; } + if (sys && Array.isArray(sys.parts)) { + try { if (sys.parts.some(p => p && p.text === prompt)) return; } catch (_) {} + try { sys.parts.push({ text: prompt }); } catch (_) {} + return; + } + try { target[key] = { parts: [{ text: prompt }] }; } catch (_) {} + } catch (_) {} +} + +// ---- Kiro ---- +// Updates top-level systemPrompt and only the mirrored leading prefix of the +// first user history turn, else current user. next = old + SEP + prompt. +// Replace old leading prefix only; preserve time context and user tail. +function injectKiroSystem(body, prompt) { + try { + let oldPrompt = typeof body.systemPrompt === "string" ? body.systemPrompt : ""; + // Repair path: a previous partial write left systemPrompt updated but user + // content still mirroring the pre-write prefix. Re-derive the effective old + // prefix from content so this pass converges instead of early-returning. + const cs0 = body.conversationState; + let firstUser0 = cs0 && Array.isArray(cs0.history) + ? (cs0.history.find(it => it && it.userInputMessage)?.userInputMessage ?? null) + : null; + if (!firstUser0 && cs0?.currentMessage?.userInputMessage) firstUser0 = cs0.currentMessage.userInputMessage; + + if (firstUser0 && typeof firstUser0.content === "string" && oldPrompt && !hasPrompt(oldPrompt, prompt)) { + const c0 = firstUser0.content; + if (c0 === oldPrompt || (c0.startsWith(oldPrompt) && !c0.startsWith(`${oldPrompt}${SEP}`))) { + // systemPrompt advanced past mirrored prefix → stale; treat as un-mirrored + oldPrompt = ""; + } + } + if (oldPrompt && hasPrompt(oldPrompt, prompt)) return; + const next = oldPrompt ? `${oldPrompt}${SEP}${prompt}` : prompt; + + // Atomicity: write user content first, then systemPrompt only if content + // write succeeded (or was a no-op). If systemPrompt write then fails, the + // repair heuristic above re-derives from content on retry — no permanent + // half-applied state. + const cs = body.conversationState; + let targetMsg = null; + try { + const hist = Array.isArray(cs?.history) ? cs.history : null; + if (hist) { + for (const item of hist) { + if (item && item.userInputMessage) { targetMsg = item.userInputMessage; break; } + } + } + if (!targetMsg && cs?.currentMessage?.userInputMessage) { + targetMsg = cs.currentMessage.userInputMessage; + } + } catch (_) { targetMsg = null; } + + let sysWritten = false; + try { body.systemPrompt = next; sysWritten = true; } catch (_) {} + + const applyContent = () => { + const content = typeof targetMsg.content === "string" ? targetMsg.content : ""; + if (oldPrompt === "") { + // Empty old prompt: prepend unless already at head (exact, not substring) + if (content.startsWith(prompt) || content.startsWith(next)) return; + const newContent = content ? `${next}${SEP}${content}` : next; + try { targetMsg.content = newContent; } catch (_) {} + return; + } + if (!content.startsWith(oldPrompt)) return; // not mirrored at head — leave alone + if (content.startsWith(next)) return; // already applied → idempotent + const tail = content.slice(oldPrompt.length); + try { targetMsg.content = `${next}${tail}`; } catch (_) {} + }; + + try { + if (targetMsg) applyContent(); + } catch (_) {} + if (sysWritten && targetMsg) { + // verify convergence: content should now start with next (or be un-mirrored) + let ok = false; + try { + const c = targetMsg.content; + ok = typeof c !== "string" || c.startsWith(next) || !c.startsWith(oldPrompt); + } catch (_) {} + if (!ok) { + try { body.systemPrompt = oldPrompt; } catch (_) {} // rollback + } + } + } catch (_) {} } diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 3160f4a7..dbf11ac2 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -4,6 +4,7 @@ import { refreshXaiToken, refreshAccessToken, refreshKimiToken, + refreshClineToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, @@ -23,6 +24,7 @@ import { export { refreshAccessToken, refreshKimiToken, + refreshClineToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, @@ -145,6 +147,7 @@ const REFRESH_HANDLERS = { "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + cline: (c, log) => refreshClineToken(c.refreshToken, log), zed: () => refreshZedToken(), windsurf: (c, log) => refreshWindsurfToken(c, log), // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 40f27f51..ca313923 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -147,6 +147,53 @@ export async function refreshKimiToken(refreshToken, credentials, log) { return refreshAccessToken("kimi", refreshToken, credentials, log); } +export async function refreshClineToken(refreshToken, log) { + if (!refreshToken) return null; + + return dedupRefresh("cline", refreshToken, async () => { + try { + const response = await fetch(PROVIDERS.cline?.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + refreshToken, + grantType: "refresh_token", + clientType: "extension", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", { + status: response.status, + error: errorText, + }); + return null; + } + + const body = await response.json(); + const tokens = body?.data || body; + if (!tokens?.accessToken) return null; + + const expiresIn = tokens.expiresAt + ? Math.max(1, Math.floor((new Date(tokens.expiresAt).getTime() - Date.now()) / 1000)) + : (tokens.expiresIn || tokens.expires_in || 3600); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || refreshToken, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Cline token: ${error.message}`); + return null; + } + }, log); +} + // Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { return refreshAccessToken("claude", refreshToken, {}, log); diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 10bb4bdc..5d39547a 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -15,11 +15,12 @@ import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; import { getFreebuffUsage } from "./usage/freebuff.js"; +import { getZedUsage } from "./usage/zed.js"; import { resolveQoderCredentials } from "./qoderModels.js"; +import { getGlmUsage } from "./usage/glm.js"; import { getIflowUsage, getOllamaUsage, - getGlmUsage, getVercelAiGatewayUsage, getQoderUsage, } from "./usage/misc.js"; @@ -56,6 +57,7 @@ const USAGE_HANDLERS = { kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js index 960af333..64d3cbbc 100644 --- a/open-sse/services/usage/codex.js +++ b/open-sse/services/usage/codex.js @@ -80,6 +80,23 @@ function getCodexReviewRateLimit(data) { }) || null; } +function getCodexSparkRateLimit(data) { + if (data.spark_rate_limit || data.gpt_5_3_codex_spark_rate_limit) { + return data.spark_rate_limit || data.gpt_5_3_codex_spark_rate_limit; + } + + const byLimitId = data.rate_limits_by_limit_id; + if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) { + return byLimitId["gpt-5.3-codex-spark"] || byLimitId.gpt_5_3_codex_spark || byLimitId.spark || null; + } + + const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []; + return additional.find((entry) => { + const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase(); + return id.includes("spark") || id.includes("5.3-codex-spark"); + }) || null; +} + export async function getCodexUsage(accessToken, proxyOptions = null) { try { const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { @@ -97,16 +114,19 @@ export async function getCodexUsage(accessToken, proxyOptions = null) { const data = await response.json(); const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {}; const reviewRateLimit = getCodexReviewRateLimit(data); + const sparkRateLimit = getCodexSparkRateLimit(data); const availableResetCredits = Math.max(0, toFiniteNumber(data.rate_limit_reset_credits?.available_count, 0)); const quotas = {}; appendCodexQuotaWindows(quotas, "", normalRateLimit); appendCodexQuotaWindows(quotas, "review", reviewRateLimit); + appendCodexQuotaWindows(quotas, "spark", sparkRateLimit); return { plan: data.plan_type || data.summary?.plan || "unknown", limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false, reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false, + sparkLimitReached: getCodexRateLimitBody(sparkRateLimit)?.limit_reached || false, resetCredits: { availableCount: availableResetCredits }, quotas, }; diff --git a/open-sse/services/usage/glm.js b/open-sse/services/usage/glm.js new file mode 100644 index 00000000..f4064af6 --- /dev/null +++ b/open-sse/services/usage/glm.js @@ -0,0 +1,88 @@ +/** + * GLM Coding Plan usage (international + China regions) + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U } from "./shared.js"; + +// GLM quota endpoints (region-aware) — url from registry transport.usage +const GLM_QUOTA_URLS = { + international: U("glm").url, + china: U("glm-cn").url, +}; + +/** + * GLM Coding Plan usage (international + China regions) + * Supports both TOKENS_LIMIT and CREDIT_LIMIT and dynamic intervals (e.g. session 5h, weekly 7d). + */ +export async function getGlmUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "GLM API key not available." }; + } + + const region = provider === "glm-cn" ? "china" : "international"; + const quotaUrl = GLM_QUOTA_URLS[region]; + + try { + const response = await proxyAwareFetch( + quotaUrl, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (!response.ok) { + if (response.status === 401) { + return { message: "GLM API key invalid or expired." }; + } + return { message: `GLM quota API error (${response.status}).` }; + } + + const json = await response.json(); + const data = json?.data && typeof json.data === "object" ? json.data : {}; + const limits = Array.isArray(data.limits) ? data.limits : []; + const quotas = {}; + + for (const limit of limits) { + // 1. Accept both TOKENS_LIMIT and CREDIT_LIMIT from GLM API + if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue; + const usedPercent = Number(limit.percentage) || 0; + const resetMs = Number(limit.nextResetTime) || 0; + const remaining = Math.max(0, 100 - usedPercent); + + // 2. Map key dynamically based on type and period (unit) to avoid overwriting + let key = "session"; + if (limit.unit === 3) { + key = `Session (${limit.number}h)`; + } else if (limit.unit === 6) { + key = "Weekly (7d)"; + } else if (limit.type === "TOKENS_LIMIT") { + key = "Tokens"; + } else { + key = `Limit (${limit.number})`; + } + + quotas[key] = { + used: usedPercent, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, + unlimited: false, + }; + } + + const levelRaw = typeof data.level === "string" ? data.level : ""; + const plan = levelRaw + ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() + : "Unknown"; + + return { plan, quotas }; + } catch (error) { + return { message: `GLM error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index fc133eff..e4b04589 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -5,11 +5,8 @@ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { U } from "./shared.js"; -// GLM quota endpoints (region-aware) — url from registry transport.usage -const GLM_QUOTA_URLS = { - international: U("glm").url, - china: U("glm-cn").url, -}; +export { getGlmUsage } from "./glm.js"; + // Vercel AI Gateway credits endpoint // Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). @@ -112,63 +109,7 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions } } -/** - * GLM Coding Plan usage (international + China regions) - */ -export async function getGlmUsage(apiKey, provider, proxyOptions = null) { - if (!apiKey) { - return { message: "GLM API key not available." }; - } - const region = provider === "glm-cn" ? "china" : "international"; - const quotaUrl = GLM_QUOTA_URLS[region]; - - try { - const response = await proxyAwareFetch(quotaUrl, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }, proxyOptions); - - if (!response.ok) { - if (response.status === 401) { - return { message: "GLM API key invalid or expired." }; - } - return { message: `GLM quota API error (${response.status}).` }; - } - - const json = await response.json(); - const data = json?.data && typeof json.data === "object" ? json.data : {}; - const limits = Array.isArray(data.limits) ? data.limits : []; - const quotas = {}; - - for (const limit of limits) { - if (!limit || limit.type !== "TOKENS_LIMIT") continue; - const usedPercent = Number(limit.percentage) || 0; - const resetMs = Number(limit.nextResetTime) || 0; - const remaining = Math.max(0, 100 - usedPercent); - - quotas["session"] = { - used: usedPercent, - total: 100, - remaining, - remainingPercentage: remaining, - resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, - unlimited: false, - }; - } - - const levelRaw = typeof data.level === "string" ? data.level : ""; - const plan = levelRaw - ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() - : "Unknown"; - - return { plan, quotas }; - } catch (error) { - return { message: `GLM error: ${error.message}` }; - } -} /** * Vercel AI Gateway usage — credit balance for the API key diff --git a/open-sse/services/usage/zed.js b/open-sse/services/usage/zed.js new file mode 100644 index 00000000..c81e7cd5 --- /dev/null +++ b/open-sse/services/usage/zed.js @@ -0,0 +1,222 @@ +/** + * Zed usage — GET https://cloud.zed.dev/client/users/me + * Auth: Authorization: {user_id} {access_token} + * + * Quota rows are derived from plan.usage (edit_predictions, optional model_requests) + * and subscription_period.ended_at for billing-cycle reset. + */ + +import { fetchZedAuthenticatedUser } from "../../shared/zedAuth.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; + +/** Map plan_v3 ids to dashboard labels (CodexBar-compatible). */ +export function formatZedPlanLabel(rawPlan) { + const raw = String(rawPlan || "").trim(); + if (!raw) return "Zed"; + switch (raw.toLowerCase()) { + case "zed_free": + return "Zed Free"; + case "zed_pro": + return "Zed Pro"; + case "zed_pro_trial": + return "Zed Pro Trial"; + case "zed_student": + return "Zed Student"; + case "zed_business": + return "Zed Business"; + default: + return raw + .replace(/_/g, " ") + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + } +} + +/** + * Parse Zed UsageLimit JSON: "unlimited", a number, or { limited: N }. + */ +export function parseZedUsageLimit(limit) { + if (limit == null) return { unlimited: false, total: 0 }; + + if (limit === "unlimited" || limit?.unlimited === true) { + return { unlimited: true, total: 0 }; + } + + if (typeof limit === "number" && Number.isFinite(limit)) { + return { unlimited: false, total: Math.max(0, limit) }; + } + + if (typeof limit === "string") { + const trimmed = limit.trim(); + if (trimmed === "unlimited") return { unlimited: true, total: 0 }; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return { unlimited: false, total: Math.max(0, parsed) }; + } + + const limited = limit.limited ?? limit.Limited; + if (typeof limited === "number" && Number.isFinite(limited)) { + return { unlimited: false, total: Math.max(0, limited) }; + } + + return { unlimited: false, total: 0 }; +} + +/** limit `{ limited: 0 }` on Pro/Student means token billing, not a 0-cap request quota. */ +export function isZedTokenBillingModelRequestsLimit(limitRaw) { + const info = parseZedUsageLimit(limitRaw); + return !info.unlimited && info.total === 0; +} + +function makeZedQuotaRow(name, usedRaw, limitRaw, resetAt = null) { + const used = Math.max(0, toFiniteNumber(usedRaw, 0)); + const limitInfo = parseZedUsageLimit(limitRaw); + + if (limitInfo.unlimited) { + return { + used, + total: 0, + remainingPercentage: 100, + resetAt: resetAt || null, + unlimited: true, + }; + } + + const total = limitInfo.total; + if (total <= 0) { + return { + used, + total: 0, + remainingPercentage: 0, + resetAt: resetAt || null, + unlimited: false, + }; + } + + const clampedUsed = Math.min(used, total); + const remaining = Math.max(0, total - clampedUsed); + return { + used: clampedUsed, + total, + remainingPercentage: (remaining / total) * 100, + resetAt: resetAt || null, + unlimited: false, + }; +} + +function usageBucketLimit(bucket) { + if (!bucket || typeof bucket !== "object") return null; + if (bucket.limit != null) return bucket.limit; + return bucket; +} + +/** + * Map /client/users/me JSON → { plan, quotas, message } for the dashboard. + */ +export function parseZedAuthenticatedUserUsage(userInfo) { + const plan = userInfo?.plan || {}; + const planId = + plan.plan_v3 || plan.plan_v2 || plan.plan || userInfo?.plan_v3 || null; + const resetAt = + parseResetTime(plan.subscription_period?.ended_at) || + parseResetTime(plan.subscriptionPeriod?.endedAt) || + null; + + const quotas = {}; + const usage = plan.usage || {}; + + const editPredictions = usage.edit_predictions || usage.editPredictions; + if (editPredictions) { + quotas["Edit Predictions"] = makeZedQuotaRow( + "Edit Predictions", + editPredictions.used, + editPredictions.limit, + resetAt, + ); + } + + const modelRequests = usage.model_requests || usage.modelRequests; + if (modelRequests) { + const limitRaw = + modelRequests.limit != null + ? modelRequests.limit + : usageBucketLimit(modelRequests)?.limit; + const limitInfo = parseZedUsageLimit(limitRaw); + // Token-billed plans report model_requests.limit=0 — not a request quota. + if (limitInfo.unlimited || limitInfo.total > 0) { + quotas["Hosted Model Requests"] = makeZedQuotaRow( + "Hosted Model Requests", + modelRequests.used, + limitRaw, + resetAt, + ); + } + } + + const tokenBillingNote = + modelRequests && + isZedTokenBillingModelRequestsLimit( + modelRequests.limit ?? usageBucketLimit(modelRequests)?.limit, + ) + ? "Hosted AI models are billed per token (not request count). Edit Predictions are tracked below. Token spend is on dashboard.zed.dev." + : null; + + let planLabel = formatZedPlanLabel(planId); + if (plan.trial_started_at || plan.trialStartedAt) { + if (!/trial/i.test(planLabel)) planLabel = `${planLabel} (Trial active)`; + } + + let message = tokenBillingNote; + if (plan.has_overdue_invoices || plan.hasOverdueInvoices) { + message = "This Zed account has overdue invoices. Usage may be blocked until billing is resolved."; + } + + return { + plan: planLabel, + quotas, + message, + hasOverdueInvoices: !!(plan.has_overdue_invoices || plan.hasOverdueInvoices), + trialStarted: !!(plan.trial_started_at || plan.trialStartedAt), + planId: planId || null, + resetAt, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {object|null|undefined} providerSpecificData + * @param {object|null|undefined} proxyOptions + */ +export async function getZedUsage( + accessToken = null, + providerSpecificData = {}, + proxyOptions = null, +) { + const psd = providerSpecificData || {}; + const userId = psd.userId; + + if (!accessToken || typeof accessToken !== "string" || !accessToken.trim()) { + return { message: "Zed access token not available. Re-connect Zed to view quota." }; + } + if (!userId) { + return { message: "Zed credential is missing user id. Re-connect Zed to view quota." }; + } + + const credentials = { + accessToken: accessToken.trim(), + providerSpecificData: psd, + }; + + try { + const userInfo = await fetchZedAuthenticatedUser(credentials, { proxyOptions }); + return parseZedAuthenticatedUserUsage(userInfo); + } catch (error) { + const status = error?.status; + if (status === 401 || status === 403) { + return { + message: "Zed authentication failed. Sign in again from the dashboard or Zed editor.", + }; + } + return { message: `Zed error: ${error.message || "Failed to fetch quota"}` }; + } +} diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index aa3337d7..e3d8371a 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -172,8 +172,8 @@ function getSystemId(credentials) { ); } -async function fetchJson(url, options) { - const res = await proxyAwareFetch(url, options); +async function fetchJson(url, options, proxyOptions = null) { + const res = await proxyAwareFetch(url, options, proxyOptions); const text = await res.text(); let data = null; if (text) { @@ -203,11 +203,15 @@ export async function fetchZedAuthenticatedUser(credentials, options = {}) { const systemId = getSystemId(credentials); if (systemId) headers[ZED_HEADERS.systemId] = systemId; - return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { - method: "GET", - headers, - signal: options.signal ?? undefined, - }); + return fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), + { + method: "GET", + headers, + signal: options.signal ?? undefined, + }, + options.proxyOptions ?? null, + ); } function normalizeOrganizationId(value) { diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index d18f47b6..9902c314 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -58,6 +58,15 @@ export function extractThinking(body) { return { mode: "level", level: e }; } + // OpenAI chat / Responses shape — check effort first (zai sends both thinking object and reasoning.effort) + const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null); + if (typeof effort === "string" && effort) { + const e = effort.toLowerCase(); + if (e === "none" || e === "off") return { mode: "none" }; + if (e === "auto") return { mode: "auto" }; + return { mode: "level", level: e }; + } + // Claude shape const t = body.thinking; if (t && typeof t === "object") { @@ -69,15 +78,6 @@ export function extractThinking(body) { } } - // OpenAI chat / Responses shape - const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null); - if (typeof effort === "string" && effort) { - const e = effort.toLowerCase(); - if (e === "none" || e === "off") return { mode: "none" }; - if (e === "auto") return { mode: "auto" }; - return { mode: "level", level: e }; - } - // Gemini shape (top-level, generationConfig, or request envelope) const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig; if (tc && typeof tc === "object") { @@ -270,6 +270,18 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { // Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off. if (none && canDisable) { body.enable_thinking = false; delete body.thinking; break; } body.thinking = { type: "enabled" }; + // reasoning_effort is only read by z.ai from GLM-5.2 onward — older GLM ignores it + // (see thinkingEffortSupported in capabilities.js). Skip on unsupported models so we + // don't send a field the API doesn't recognize. + if (caps.thinkingEffortSupported) { + const zaiLvl = toLevel(eff); + // GLM-5.3 only accepts exactly low|high|max (anything else errors); GLM-5.2 accepts + // a wider set but z.ai maps low/medium->high and xhigh->max server-side anyway, so + // this 3-value mapping matches both. + body.reasoning_effort = (zaiLvl === "low" || zaiLvl === "minimal") ? "low" + : (zaiLvl === "high" || zaiLvl === "medium") ? "high" + : "max"; + } break; } case "qwen": { diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js index 8de82f71..958764dd 100644 --- a/open-sse/translator/concerns/toolCall.js +++ b/open-sse/translator/concerns/toolCall.js @@ -151,3 +151,17 @@ export function fixMissingToolResponses(body) { return body; } +// Default `type: "custom"` on Claude-format tools that arrive without one. +// Anthropic's Claude tool schema requires `type` to be explicitly set; strict gateways +// (e.g., MiniMax Anthropic-compatible endpoint, error 2013) reject legacy payloads that +// omit it with HTTP 400. Tools that already carry a truthy `type` (e.g., `computer_use`, +// `bash`, `web_search_20250305`) are passed through untouched. +// +// Spread order matters: `{ ...tool, type: "custom" }` (spread first, override last) +// ensures that falsy `type` values (null, undefined, "") in the original tool don't +// overwrite the default. `{ type: "custom", ...tool }` would let `type: null` survive. +export function defaultClaudeToolType(tools) { + if (!Array.isArray(tools)) return tools; + return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" }); +} + diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e2f45339..2fcbcbf8 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -1,7 +1,7 @@ import { FORMATS } from "./formats.js"; import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js"; import { prepareClaudeRequest } from "./formats/claude.js"; -import { cloakClaudeTools } from "../utils/claudeCloaking.js"; +import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js"; import { filterToOpenAIFormat } from "./formats/openai.js"; import { normalizeThinkingConfig } from "../services/provider.js"; import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js"; @@ -133,7 +133,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId); } - // Claude cloaking: rename client tools with _cc suffix (anti-ban) + // Claude cloaking: rename client tools with CLAUDE_TOOL_SUFFIX (anti-ban) // quirk: only providers flagged cloakToolsOnOAuth, and only with an OAuth token if (PROVIDERS[provider]?.quirks?.cloakToolsOnOAuth) { const apiKey = credentials?.accessToken || credentials?.apiKey || null; @@ -161,9 +161,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Translate response chunk: target -> openai -> source export function translateResponse(targetFormat, sourceFormat, chunk, state) { ensureInitialized(); - // If same format, return as-is + // If same format, return as-is — except the tool name may still be cloaked: + // translateRequest() suffixes client tools for OAuth-cloaked Claude providers + // even when no format conversion is needed, so streamed tool_use blocks must + // be decloaked here or the client sees an unknown ("_ide"-suffixed) tool. if (sourceFormat === targetFormat) { - return [chunk]; + return [decloakStreamChunk(chunk, state?.toolNameMap)]; } let results = [chunk]; diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 29e43152..cf5bc529 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -300,7 +300,16 @@ function buildReasoningInputItem(msg) { */ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) { // Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[]) - if (body.input) return { ...body, model, stream: true }; + if (body.input) { + const out = { ...body, model, stream: true }; + if (out.max_output_tokens === undefined) { + if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens; + else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens; + } + delete out.max_tokens; + delete out.max_completion_tokens; + return out; + } const result = { model, @@ -416,7 +425,13 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Pass through other relevant fields if (body.temperature !== undefined) result.temperature = body.temperature; - if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens; + if (body.max_output_tokens !== undefined) { + result.max_output_tokens = body.max_output_tokens; + } else if (body.max_completion_tokens !== undefined) { + result.max_output_tokens = body.max_completion_tokens; + } else if (body.max_tokens !== undefined) { + result.max_output_tokens = body.max_tokens; + } if (body.top_p !== undefined) result.top_p = body.top_p; if (body.reasoning !== undefined) result.reasoning = body.reasoning; if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 46a44e4f..4f43a670 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -31,8 +31,8 @@ function generateFakeUserID(sessionId, apiKey) { /** * Cloak tools before sending to Claude provider (anti-ban): - * - Rename non-CC client tools with _cc suffix in tools[] and messages[] - * - Skip tools that are already CC default names (they become decoys as-is) + * - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[] + * - Skip tools that carry a `type` (server-side built-ins) — sent as-is * - Inject CC_DECOY_TOOLS after client tools * Returns { body, toolNameMap } where toolNameMap maps suffixed → original * @param {object} body - Claude API request body @@ -101,6 +101,33 @@ export function decloakToolNames(body, toolNameMap) { return { ...body, content }; } +/** + * Decloak the tool name inside a single streamed Claude SSE event. + * + * Streaming counterpart of decloakToolNames(). Required for claude→claude + * proxying: translateResponse() returns same-format chunks untouched, so + * without this the client receives the cloaked ("_ide"-suffixed) tool name + * and rejects the call as an unknown tool. In a Claude SSE stream a tool + * name appears exactly once per call — on the content_block_start event of + * a tool_use block; argument deltas carry no name. + * + * Unknown names (e.g. a CC decoy tool the model called anyway) pass through + * unchanged, matching the non-streaming decloak behavior. + * + * @param {object|null} chunk - Parsed SSE event (may be null on stream flush) + * @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools() + * @returns {object|null} The chunk, with the tool_use name restored when cloaked + */ +export function decloakStreamChunk(chunk, toolNameMap) { + if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk; + if (chunk.type !== "content_block_start") return chunk; + const block = chunk.content_block; + if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk; + const original = toolNameMap.get(block.name); + if (!original) return chunk; + return { ...chunk, content_block: { ...block, name: original } }; +} + // CC decoy tools — Claude Code native tool names, marked unavailable const CC_DECOY_TOOLS = [ { name: "Task", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, diff --git a/open-sse/utils/claudeToolTypeSelfCheck.mjs b/open-sse/utils/claudeToolTypeSelfCheck.mjs new file mode 100644 index 00000000..5b3b2a6c --- /dev/null +++ b/open-sse/utils/claudeToolTypeSelfCheck.mjs @@ -0,0 +1,117 @@ +// Claude tool type default self-check. +// Run: node open-sse/utils/claudeToolTypeSelfCheck.mjs +// No framework, no deps. Uses assert. Mirrors toolPairingSelfCheck.mjs style. +import { defaultClaudeToolType } from "../translator/concerns/toolCall.js"; + +const results = []; +function run(name, fn) { + try { + fn(); + results.push({ name, ok: true }); + } catch (err) { + results.push({ name, ok: false, err: err.message }); + } +} +const assert = { + equal(a, b, msg) { if (a !== b) throw new Error(`${msg || ""} expected ${b}, got ${a}`); }, + ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); }, +}; + +// 1. Tool without `type` property → defaults to "custom" +run("Tool without type property defaults to custom", () => { + const tools = [{ name: "foo", description: "bar", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "type defaulted"); + assert.equal(out[0].name, "foo", "other fields preserved"); +}); + +// 2. Tool with type:null → defaults to "custom" (the spread-order bug case) +run("Tool with type:null defaults to custom", () => { + const tools = [{ name: "foo", type: null, input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "null type overwritten to custom"); +}); + +// 3. Tool with type:undefined → defaults to "custom" +run("Tool with type:undefined defaults to custom", () => { + const tools = [{ name: "foo", type: undefined, input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "undefined type overwritten to custom"); +}); + +// 4. Tool with type:"" (empty string) → defaults to "custom" +run("Tool with type:empty-string defaults to custom", () => { + const tools = [{ name: "foo", type: "", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "empty-string type overwritten to custom"); +}); + +// 5. Built-in tool with type:"computer_use" → passed through untouched +run("Built-in tool (computer_use) passed through", () => { + const tools = [{ type: "computer_use", name: "computer", display_width: 1024 }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "computer_use", "built-in type preserved"); + assert.equal(out[0], tools[0], "same reference — not cloned"); +}); + +// 6. Tool already with type:"custom" → passed through untouched +run("Tool already with type:custom passed through", () => { + const tools = [{ type: "custom", name: "foo", input_schema: {} }]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "existing custom type preserved"); + assert.equal(out[0], tools[0], "same reference — not cloned"); +}); + +// 7. Mixed: built-in + function tool → only function tool gets default +run("Mixed: built-in kept, function tool defaulted", () => { + const tools = [ + { type: "computer_use", name: "computer", display_width: 1024 }, + { name: "search", description: "search the web", input_schema: {} }, + { type: "web_search_20250305", name: "web_search" }, + ]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "computer_use", "built-in preserved"); + assert.equal(out[1].type, "custom", "function tool defaulted"); + assert.equal(out[2].type, "web_search_20250305", "web_search preserved"); +}); + +// 8. Non-array input → returned unchanged +run("Non-array input returned unchanged", () => { + assert.equal(defaultClaudeToolType(null), null, "null returned as-is"); + assert.equal(defaultClaudeToolType(undefined), undefined, "undefined returned as-is"); + assert.equal(defaultClaudeToolType("not array"), "not array", "string returned as-is"); +}); + +// 9. Empty array → empty array +run("Empty array returns empty array", () => { + const out = defaultClaudeToolType([]); + assert.equal(Array.isArray(out), true, "returns array"); + assert.equal(out.length, 0, "empty array"); +}); + +// 10. Original tools not mutated by reference (new objects for defaulted tools) +run("Original tools not mutated by reference", () => { + const original = { name: "foo", input_schema: {} }; + const tools = [original]; + defaultClaudeToolType(tools); + assert.equal(original.type, undefined, "original tool not mutated"); + assert.ok(!("type" in original), "type property not added to original"); +}); + +// 11. Array with null entry → defaults to { type: "custom" } (optional chaining guard) +// tool?.type returns undefined for null, and { ...null, type: "custom" } === { type: "custom" } +run("Array with null entry defaults to custom", () => { + const tools = [null]; + const out = defaultClaudeToolType(tools); + assert.equal(out[0].type, "custom", "null tool gets type custom"); + assert.equal(Object.keys(out[0]).length, 1, "no other keys from spread of null"); +}); + +// Summary +const passed = results.filter(r => r.ok).length; +const total = results.length; +for (const r of results) { + console.log(`${r.ok ? "ok" : "FAIL"} - ${r.name}${r.ok ? "" : ` :: ${r.err}`}`); +} +console.log(`\n${passed}/${total} checks passed`); +if (passed !== total) process.exit(1); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index b6f16f1a..4f701b88 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -94,6 +94,7 @@ const MAX_CONTINUATION_SESSIONS = 5000; // Client headers/body fields that carry an upstream session id (priority order) const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"]; const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/; +const CLAUDE_CODE_SESSION_HEADER = "x-claude-code-session-id"; function sha16(text) { return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16); @@ -135,7 +136,10 @@ function extractAntigravitySession(body) { } function extractClientSessionId(headers, body, scope = "") { - const claude = extractClaudeCodeSession(body?.metadata?.user_id); + // Claude Code sends the session in a header AND in metadata.user_id; the header + // survives translation to formats that drop metadata (e.g. Responses API). + const claude = extractClaudeCodeSession(body?.metadata?.user_id) + || headerValue(headers, CLAUDE_CODE_SESSION_HEADER); if (claude) return `claude:${claude}`; const antigravity = extractAntigravitySession(body); if (antigravity) return `antigravity:${antigravity}`; diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 87681089..8fa8c11f 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -75,6 +75,35 @@ export function createSSEStream(options = {}) { let openAIResponsesTerminalSeen = false; let openAIResponsesDoneSent = false; let streamDoneSent = false; // track duplicate [DONE] across transform + flush + let finalized = false; + + // Usage/logging tail, callable from transform() as well as flush(): a client that + // closes right after the terminal event cancels the reader, and flush() never runs. + const finalizeStream = () => { + if (finalized) return; + finalized = true; + + const isPassthrough = mode === STREAM_MODE.PASSTHROUGH; + let finalUsage = isPassthrough ? usage : state?.usage; + + if (!hasValidUsage(finalUsage) && totalContentLength > 0) { + finalUsage = estimateUsage(body, totalContentLength, isPassthrough ? FORMATS.OPENAI : sourceFormat); + if (isPassthrough) usage = finalUsage; else state.usage = finalUsage; + } + + if (hasValidUsage(finalUsage)) { + logUsage(isPassthrough ? provider : (state?.provider || targetFormat), finalUsage, model, connectionId, apiKey); + } else { + appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); + } + + if (onStreamComplete) { + onStreamComplete({ + content: accumulatedContent, + thinking: accumulatedThinking + }, finalUsage, ttftAt); + } + }; return new TransformStream({ transform(chunk, controller) { @@ -105,6 +134,7 @@ export function createSSEStream(options = {}) { if (mode === STREAM_MODE.PASSTHROUGH) { let output; let injectedUsage = false; + let responsesTerminal = false; if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") { try { @@ -168,6 +198,8 @@ export function createSSEStream(options = {}) { usage = mergeUsage(usage, extracted); } + responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed); + const isFinishChunk = parsed.choices?.[0]?.finish_reason; if (isFinishChunk && !hasValidUsage(parsed.usage)) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); @@ -202,6 +234,8 @@ export function createSSEStream(options = {}) { reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); + // Responses clients (codex CLI) close on response.completed instead of [DONE] + if (responsesTerminal) finalizeStream(); continue; } @@ -292,6 +326,8 @@ export function createSSEStream(options = {}) { controller.enqueue(sharedEncoder.encode(output)); currentOpenAIResponsesEvent = null; sseEmittedCount++; + // Responses clients (codex) close on response.completed instead of [DONE] + if (openAIResponsesTerminalSeen) finalizeStream(); continue; } @@ -353,16 +389,6 @@ export function createSSEStream(options = {}) { controller.enqueue(sharedEncoder.encode(output)); } - if (!hasValidUsage(usage) && totalContentLength > 0) { - usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI); - } - - if (hasValidUsage(usage)) { - logUsage(provider, usage, model, connectionId, apiKey); - } else { - appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); - } - // IMPORTANT: In passthrough mode we still must terminate the SSE stream. // Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel: // data: [DONE]\n\n @@ -375,18 +401,26 @@ export function createSSEStream(options = {}) { controller.enqueue(sharedEncoder.encode(doneOutput)); } - if (onStreamComplete) { - onStreamComplete({ - content: accumulatedContent, - thinking: accumulatedThinking - }, usage, ttftAt); - } + finalizeStream(); return; } if (buffer.trim()) { - const parsed = parseSSELine(buffer.trim()); - if (parsed && !parsed.done) { + // Same parse as the transform loop: without targetFormat this only + // accepts "data: " lines, so an NDJSON provider (Ollama) lost whatever + // arrived without its closing newline. + const parsed = parseSSELine(buffer.trim(), targetFormat); + // parseSSELine turns the SSE sentinel "data: [DONE]" into { done: true }, + // which must not be translated. An Ollama chunk also carries done:true, + // but it is the real final chunk — it holds finish_reason and the token + // counts — so it has to go through. + const isDoneSentinel = parsed?.done && targetFormat !== FORMATS.OLLAMA; + if (parsed && !isDoneSentinel) { + // Same accumulation the transform loop does, so finalizeStream() can + // log a tail chunk's tokens instead of falling back to null. + const extracted = extractUsage(parsed); + if (extracted) state.usage = mergeUsage(state.usage, extracted); + const translated = translateResponse(targetFormat, sourceFormat, parsed, state); if (translated?._openaiIntermediate) { @@ -442,24 +476,10 @@ export function createSSEStream(options = {}) { streamDoneSent = true; } - if (!hasValidUsage(state?.usage) && totalContentLength > 0) { - state.usage = estimateUsage(body, totalContentLength, sourceFormat); - } - - if (hasValidUsage(state?.usage)) { - logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); - } else { - appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); - } - - if (onStreamComplete) { - onStreamComplete({ - content: accumulatedContent, - thinking: accumulatedThinking - }, state?.usage, ttftAt); - } + finalizeStream(); } catch (error) { console.log("Error in flush:", error); + finalizeStream(); } } }); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index e76dce90..6846c557 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -41,7 +41,8 @@ export function createStreamController({ onDisconnect, onError, log, provider, m if (disconnected) return; disconnected = true; - logStream("⚡", `DISCONNECT: ${reason}`); + // Debug-only: Responses API has no [DONE] sentinel, so codex/droid close the + // socket on every completed request. "📊 done" is the authoritative outcome line. dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`); // Delay abort to allow cleanup diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 24518ef3..0d66f6f3 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -190,7 +190,10 @@ export function canonicalizeUsage(usage) { prompt = prompt + cached + cacheCreation; } else { // OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens. - cached = num(usage.cached_tokens); + // Mirror the cacheCreation fallback above: buildUsage() only ever emits the + // nested prompt_tokens_details.cached_tokens shape, so without this the + // cache-read count is silently dropped on every buildUsage()-derived usage. + cached = num(usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens); } const result = { diff --git a/package.json b/package.json index f931b4ea..b36fa38c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.55", + "version": "0.5.59", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index a4186636..9c928d60 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -4,12 +4,14 @@ "(PXPIPE)": "(PXPIPE)", "(Ponytail)": "(Ponytail)", "(RTK)": "(RTK)", + "9Remote": "9Remote", "9Router (Entry)": "9Router (Inicial)", "API": "API", "API Endpoint": "Endpoint da API", "API Key": "Chave API", "API Key (for Check)": "Chave API (para Verificação)", "API Key Created": "Chave API Criada", + "API Key Name": "Nome da chave de API", "API Keys": "Chaves de API", "API Token": "Token de API", "API Tokens": "Tokens de API", @@ -23,6 +25,7 @@ "AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)", "About": "Sobre", "Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...", + "Access your terminal, desktop & files from anywhere": "Acesse seu terminal, desktop e arquivos de qualquer lugar", "Account": "Conta", "Account ID": "ID da Conta", "Account Resources": "Recursos da Conta", @@ -30,6 +33,7 @@ "Action": "Ação", "Actions": "Ações", "Activate": "Ativar", + "Activated": "Ativado", "Active": "Ativo", "Active All": "Ativar Todos", "Active:": "Ativo:", @@ -46,6 +50,7 @@ "Add Model to Combo": "Adicionar Modelo ao Combo", "Add New Provider": "Adicionar Novo Provedor", "Add OpenAI Compatible": "Adicionar Compatível com OpenAI", + "Add Pool": "Adicionar Pool", "Add Provider": "Adicionar Provedor", "Add Proxy Pool": "Adicionar Pool de Proxy", "Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.", @@ -55,6 +60,7 @@ "After PXPIPE": "Após PXPIPE", "After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.", "After installation, run": "Após a instalação, execute", + "Agent Skills": "Agent Skills", "All": "Todos", "All AI Providers": "Todos os Provedores de IA", "All Providers": "Todos os Provedores", @@ -70,6 +76,7 @@ "Apply Proxy": "Aplicar Proxy", "Applying...": "Aplicando...", "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Audio": "Áudio", "Audio File": "Arquivo de Áudio", "Auth Mode": "Modo de Autenticação", "Authenticate": "Autenticar", @@ -100,6 +107,7 @@ "Batch Size": "Tamanho do lote", "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar", "Binary File": "Arquivo Binário", + "Browse & edit files": "Navegue e edite arquivos", "Browse MCP Marketplace": "Explorar Marketplace MCP", "Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.", "Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)", @@ -109,6 +117,7 @@ "Cache Creation": "Criação de Cache", "Cache Creation:": "Criação de Cache:", "Cached": "Em Cache", + "Cached Cost": "Custo em cache", "Cached Tokens": "Tokens em Cache", "Cached Tokens:": "Tokens em Cache:", "Cached:": "Em Cache:", @@ -118,6 +127,7 @@ "Capacity auto-switch": "Troca automática de capacidade", "Cert": "Certificado", "Change Log": "Registro de Alterações", + "Change the default dashboard password before activating the tunnel.": "Altere a senha padrão do painel antes de ativar o túnel.", "Changelog": "Registro de Alterações", "Chat": "Chat", "Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.", @@ -172,6 +182,8 @@ "Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual", "Codex CLI not detected locally": "Codex CLI não detectado localmente", "Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex", + "Combo": "Combo", + "Combo & Vision Adapter": "Combo e Adaptador de Visão", "Combo Name": "Nome do Combo", "Combo Round Robin": "Combo Round Robin", "Combo Sticky Limit": "Limite Fixo do Combo", @@ -181,6 +193,7 @@ "Company": "Empresa", "Compress LLM output": "Comprimir saída do LLM", "Compress context": "Comprimir contexto", + "Compress prompts and outputs to save tokens": "Comprimir prompts e saídas para economizar tokens", "Compress prompts as images": "Comprimir prompts como imagens", "Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo", "Compress tool output": "Comprimir saída da ferramenta", @@ -199,6 +212,7 @@ "Connect Cursor IDE": "Conectar Cursor IDE", "Connect GitLab Duo": "Conectar GitLab Duo", "Connect Kiro": "Conectar Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "Conectar-se aos provedores via OAuth para acompanhar seus limites de cota e uso da API.", "Connect with OAuth2": "Conectar com OAuth2", "Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.", "Connected": "Conectado", @@ -208,6 +222,7 @@ "Connections": "Conexões", "Console Log": "Log do console", "Content": "Conteúdo", + "Context window": "Janela de contexto", "Continue": "Continuar", "Continue to summary": "Continuar para resumo", "Continue with GitHub": "Continuar com GitHub", @@ -218,6 +233,7 @@ "Copied!": "Copiado!", "Copy": "Copiar", "Copy This URL": "Copiar Esta URL", + "Copy a link and paste to your AI to use 9Router — no install needed": "Copiar um link e cole no seu IA para usar o 9Router — sem precisar instalar", "Copy combo name": "Copiar nome do combo", "Copy install command": "Copiar comando de instalação", "Copy link": "Copiar link", @@ -232,6 +248,7 @@ "Create Combo": "Criar Combo", "Create Cowork Combo": "Criar Combo Cowork", "Create Key": "Criar Chave", + "Create Pool": "Criar Pool", "Create Provider": "Criar Provedor", "Create Token": "Criar Token", "Create a": "Criar um(a)", @@ -263,6 +280,7 @@ "Date": "Data", "DateTime": "Data e Hora", "Deactivate": "Desativar", + "Deactivated": "Desativado", "Debug": "Depuração", "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual", @@ -270,6 +288,8 @@ "Default": "Padrão", "Default Model": "Modelo Padrão", "Delete": "Excluir", + "Delete Proxy Pool": "Excluir Pool de Proxy", + "Delete Proxy Pools": "Excluir Pools de Proxy", "Delete connection": "Excluir conexão", "Delete saved endpoint": "Excluir endpoint salvo", "Delete selected preset": "Excluir predefinição selecionada", @@ -284,11 +304,13 @@ "Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP", "Deployment Name": "Nome da Implantação", "Description": "Descrição", + "Desktop": "Desktop", "Detail": "Detalhe", "Details": "Detalhes", "Dimensions": "Dimensões", "Disable": "Desativar", "Disable All": "Desativar Todos", + "Disable Dead Proxies": "Desativar Proxies Inativos", "Disable Tailscale": "Desativar Tailscale", "Disable Tunnel": "Desativar Túnel", "Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual", @@ -312,8 +334,10 @@ "Edit connection": "Editar conexão", "Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:", "Email": "E-mail", + "Embedding": "Embedding", "Embeddings": "Embeddings", "Enable": "Ativar", + "Enable \"Require login\" and set a custom password before activating the tunnel.": "Ativar a opção \"Exigir login\" e defina uma senha personalizada antes de ativar o túnel.", "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", "Enable Observability": "Ativar observabilidade", "Enable Tunnel": "Ativar Túnel", @@ -331,6 +355,7 @@ "Enter password": "Digite a senha", "Enter sudo password": "Digite a senha sudo", "Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "Digite o ID do modelo exatamente como seu endpoint compatível espera. Este modelo será salvo como padrão da conexão.", "Enter your API key": "Digite sua chave de API", "Enter your password to access the dashboard": "Digite sua senha para acessar o painel", "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", @@ -351,12 +376,21 @@ "Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente", "Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.", "Failed": "Falha", + "Failed to delete proxy pool": "Falha ao excluir o pool de proxy", + "Failed to fetch chart data:": "Falha ao buscar dados do gráfico:", + "Failed to fetch quota": "Falha ao buscar a cota", "Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.", + "Failed to reset Codex limit": "Falha ao redefinir o limite do Codex", + "Failed to save proxy pool": "Falha ao salvar o pool de proxy", "Failed to start proxy": "Falha ao iniciar proxy", "Failed to start server": "Falha ao iniciar o servidor", "Failed to stop server": "Falha ao parar o servidor", + "Failed to test proxy": "Falha ao testar o proxy", + "Failed to update active state": "Falha ao atualizar o estado ativo", "Fallback": "Fallback", + "Fallback — try in order": "Fallback — tentar em ordem", "Features": "Recursos", + "Files": "Arquivos", "Filter": "Filtrar", "Filter accounts by status": "Filtrar contas por status", "Filter naming": "Filtrar nomenclatura", @@ -374,7 +408,9 @@ "Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia", "Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge", "Fresh API key obtained": "Nova chave de API obtida", + "Full shell access": "Acesso completo ao shell", "Fusion": "Fusão", + "Fusion — panel + judge": "Fusion — painel + juiz", "General": "Geral", "Get 9Remote": "Obter 9Remote", "Get API Key": "Obter Chave de API", @@ -415,6 +451,7 @@ "How to get cookie:": "Como obter o cookie:", "ID:": "ID:", "Image Generation": "Geração de Imagens", + "Image to Text": "Imagem para Texto", "Images": "Imagens", "Import": "Importar", "Import Backup": "Importar backup", @@ -426,6 +463,7 @@ "Info": "Informações", "Initializing...": "Inicializando...", "Input": "Entrada", + "Input Cost": "Custo de entrada (input)", "Input Tokens": "Tokens de Entrada", "Input Tokens:": "Tokens de Entrada:", "Input:": "Entrada:", @@ -449,9 +487,12 @@ "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", "Invalid": "Inválido", "Issuer URL": "URL do Emissor", + "JSON (Base64)": "JSON (Base64)", "JSON Response": "Resposta JSON", "Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router. Código aberto e gratuito para começar.", "Judge": "Julgador", + "Just now": "Agora mesmo", "KeepAlive": "KeepAlive", "Key Name": "Nome da Chave", "Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?", @@ -462,6 +503,7 @@ "Label": "Rótulo", "Language": "Idioma", "Last Page": "Última Página", + "Last Used": "Último uso", "Latency": "Latência", "Latency:": "Latência:", "Lazy senior dev": "Dev sênior preguiçoso", @@ -469,6 +511,7 @@ "Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente", "Leave empty for public PKCE app": "Deixe vazio para app PKCE público", "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "Campos de proxy manual legados ainda são aceitos pela API para compatibilidade inversa.", "Legal": "Legal", "Light": "Claro", "Live server console output": "Saída do console do servidor ao vivo", @@ -497,11 +540,23 @@ "MITM": "MITM", "MITM Server": "Servidor MITM", "MITM Tools": "Ferramentas MITM", + "MP3 (Binary)": "MP3 (Binário)", "Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...", "Main Model": "Modelo Principal", "Manage": "Gerenciar", "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Manage your Embedding providers": "Gerencie seus provedores de Embedding", + "Manage your Image to Text providers": "Gerencie seus provedores de Imagem para Texto", + "Manage your Music providers": "Gerencie seus provedores de Música", + "Manage your Speech To Text providers": "Gerencie seus provedores de Fala para Texto", + "Manage your Text To Speech providers": "Gerencie seus provedores de Texto para Fala", + "Manage your Text to Image providers": "Gerencie seus provedores de Texto para Imagem", + "Manage your Video providers": "Gerencie seus provedores de Vídeo", + "Manage your Web Fetch providers": "Gerencie seus provedores de Fetch Web", + "Manage your Web Search providers": "Gerencie seus provedores de Busca Web", "Manage your preferences": "Gerenciar suas preferências", + "Manage your proxy pool configurations": "Gerencie suas configurações de pools de proxy", + "Manage your web providers": "Gerencie seus provedores web", "Manual / current endpoint": "Endpoint manual / atual", "Manual Callback Required": "Callback Manual Necessário", "Manual Config": "Configuração Manual", @@ -533,25 +588,32 @@ "More on GitHub": "Mais no GitHub", "Move down": "Mover para baixo", "Move up": "Mover para cima", + "Music": "Música", "My Profile": "Meu Perfil", + "N/A": "N/D", "NPM": "NPM", "Name": "Nome", "Navigate to home": "Navegar para início", "Network": "Rede", + "Never": "Nunca", + "Never tested": "Nunca testado", "New": "Novo", "New Password": "Nova senha", "New password": "Nova senha", "Next": "Próximo", "Next accounts page": "Próxima página de contas", "No": "Não", + "No API key usage recorded yet.": "Nenhum uso de chave de API registrado ainda.", "No API keys yet": "Nenhuma chave de API ainda", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", "No MCPs added": "Nenhum MCP adicionado", "No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda", "No Providers Connected": "Nenhum Provedor Conectado", "No Proxy": "Sem proxy", + "No account-specific usage recorded yet.": "Nenhum uso específico de conta registrado ainda.", "No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.", "No active proxy pools available.": "Nenhum pool de proxy ativo disponível.", + "No active proxy pools available. Create one in Proxy Pools page first.": "Nenhum pool de proxy ativo disponível. Crie um na página Pools de Proxy primeiro.", "No authentication required": "Nenhuma autenticação necessária", "No combos yet": "Nenhum combo ainda", "No combos yet.": "Nenhum combo ainda.", @@ -560,6 +622,7 @@ "No console logs yet.": "Nenhum log de console ainda.", "No conversations yet.": "Nenhuma conversa ainda.", "No data for this period": "Nenhum dado para este período", + "No endpoint usage recorded yet.": "Nenhum uso de endpoint registrado ainda.", "No install log yet.": "Nenhum log de instalação ainda.", "No key configured": "Nenhuma chave configurada", "No language selected": "Nenhum idioma selecionado", @@ -570,9 +633,11 @@ "No models found": "Nenhum modelo encontrado", "No models selected": "Nenhum modelo selecionado", "No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)", + "No port forwarding needed": "Sem necessidade de redirecionamento de porta", "No pricing data available": "Nenhum dado de preço disponível", "No providers connected": "Nenhum provedor conectado", "No providers match your search": "Nenhum provedor corresponde à sua pesquisa", + "No providers support": "Nenhum provedor suporta", "No providers yet.": "Nenhum provedor ainda.", "No providers.": "Nenhum provedor.", "No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda", @@ -582,6 +647,8 @@ "No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.", "No servers match filter": "Nenhum servidor corresponde ao filtro", "No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.", + "No usage recorded yet": "Nenhum uso registrado ainda", + "No usage recorded yet.": "Nenhum uso registrado ainda.", "No usage yet.": "Nenhum uso ainda.", "None": "Nenhum", "None (unbind all)": "Nenhum (desvincular todos)", @@ -595,17 +662,21 @@ "OAuth Providers": "Provedores OAuth", "OIDC": "OIDC", "OIDC Dashboard Login": "Login no Painel via OIDC", + "OIDC login is currently active. Password login is disabled until you switch back.": "O login OIDC está ativo no momento. O login por senha está desabilitado até você voltar.", "OK": "OK", "Observability": "Observabilidade", "Office Proxy": "Proxy de Escritório", "Offline": "Offline", "Ollama Host URL": "URL do Host Ollama", + "One PAT per line. Format:": "Um PAT por linha. Formato:", "One key per line. Format:": "Uma chave por linha. Formato:", "One-to-one (rotate)": "Um-para-um (rotacionar)", "Online": "Online", "Only from connected providers": "Apenas de provedores conectados", "Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .", + "Only letters, numbers, -, _ and . allowed": "Apenas letras, números, -, _ e . permitidos", "Open": "Abrir", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "Abra Claude Desktop → Ajuda → Solução de Problemas → Ative o Modo Desenvolvedor → Configure inferência de terceiros e depois volte aqui.", "Open Claw - Manual Configuration": "Open Claw - Configuração Manual", "Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente", "Open Dashboard": "Abrir Painel", @@ -613,6 +684,8 @@ "Open Headroom Dashboard": "Abrir Painel Headroom", "Open Logs": "Abrir Logs", "Open menu": "Abrir menu", + "Open platform.iflow.cn in your browser": "Abra platform.iflow.cn no seu navegador", + "Open settings": "Abrir configurações", "Open source": "Código aberto", "Open source and free to start.": "Código aberto e gratuito para começar.", "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.", @@ -634,10 +707,12 @@ "Out": "Saída", "Outbound Proxy": "Proxy de saída", "Output": "Saída", + "Output Cost": "Custo de saída (output)", "Output Format": "Formato de Saída", "Output Tokens": "Tokens de Saída", "Output Tokens:": "Tokens de Saída:", "Output:": "Saída:", + "Overview": "Visão geral", "PATH": "PATH", "PXPIPE": "PXPIPE", "PXPIPE Dashboard": "Painel PXPIPE", @@ -650,16 +725,20 @@ "Paid": "Pago", "Partial preview": "Visualização parcial", "Password": "Senha", + "Password and OIDC login are both active.": "O login por senha e OIDC estão ambos ativos.", + "Password and OIDC login are both enabled.": "O login por senha e OIDC estão ambos habilitados.", "Password updated successfully": "Senha atualizada com sucesso", "Passwords do not match": "As senhas não correspondem", "Paste": "Colar", "Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "Cole o JSON de autenticação external_idp do login CLIProxyAPI/Kiro Microsoft.", "Paste it below": "Cole abaixo", "Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.", "Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.", "Paste this to your AI:": "Cole isto em sua IA:", "Paste your Kiro API key...": "Cole sua chave de API Kiro...", "Paused": "Pausado", + "Period": "Período", "Permissions": "Permissões", "Personal Access Token": "Token de Acesso Pessoal", "Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel", @@ -667,11 +746,13 @@ "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", "Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.", "Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128", + "Popup was blocked. After authorizing in the browser, paste the full callback URL here:": "O pop-up foi bloqueado. Depois de autorizar no navegador, cole a URL completa de callback aqui:", "Port 443 Already In Use": "Porta 443 já está em uso", "Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:", "Powerful Features": "Recursos Poderosos", "Prefix": "Prefixo", "Preset": "Predefinição", + "Prev": "Ant", "Previous": "Anterior", "Previous accounts page": "Página anterior de contas", "Price": "Preço", @@ -699,14 +780,20 @@ "Proxy URL": "URL do Proxy", "Proxy disabled": "Proxy desativado", "Proxy enabled": "Proxy ativado", + "Proxy pool created": "Pool de proxy criado", + "Proxy pool deleted": "Pool de proxy excluído", + "Proxy pool updated": "Pool de proxy atualizado", "Proxy settings applied": "Configurações de proxy aplicadas", "Proxy test OK": "Teste de proxy OK", "Proxy test failed": "Falha no teste de proxy", + "Proxy test passed": "Teste de proxy aprovado", "Purpose:": "Propósito:", "Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.", "Quota Tracker": "Rastreador de cota", "Read Documentation": "Ler Documentação", "Read this skill and use it:": "Leia esta skill e use-a:", + "Reading from AWS SSO cache": "Lendo do cache AWS SSO", + "Reading from Cursor IDE database": "Lendo do banco de dados do IDE Cursor", "Ready": "Pronto", "Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?", "Reasoning": "Raciocínio", @@ -714,6 +801,7 @@ "Recent Requests": "Requisições Recentes", "Recent chats": "Chats recentes", "Recheck": "Verificar novamente", + "Recommended for most users. Free AWS account required.": "Recomendado para a maioria dos usuários. Conta AWS gratuita necessária.", "Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs", "Redirect URI": "URI de Redirecionamento", "Redo": "Refazer", @@ -749,6 +837,7 @@ "Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor", "Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS", "Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "Requer ID da Conta Cloudflare e Token de API Workers (permissão Editar Workers)", "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.", "Reset": "Redefinir", "Reset Codex limit?": "Redefinir limite do Codex?", @@ -764,8 +853,11 @@ "Restore model": "Restaurar modelo", "Retry": "Tentar novamente", "Risk Notice": "Aviso de Risco", + "Rotate providers across requests instead of strict fallback order.": "Rotacione provedores nas requisições em vez de estrita ordem de fallback.", "Rotation Strategy": "Estratégia de Rotação", + "Round": "Rodada", "Round Robin": "Round Robin", + "Round Robin — rotate": "Round Robin — rotacionar", "Route Requests": "Rotear Requisições", "Routing Strategy": "Estratégia de roteamento", "Rows:": "Linhas:", @@ -782,7 +874,9 @@ "Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador", "Save this key now!": "Salve esta chave agora!", "Saved": "Salvo", + "Scan QR to connect instantly": "Escaneie o QR para conectar instantaneamente", "Scopes": "Escopos", + "Screen sharing": "Compartilhamento de tela", "Scroll down to": "Role para baixo até", "Search": "Pesquisar", "Search by name or description...": "Pesquisar por nome ou descrição...", @@ -791,6 +885,9 @@ "Search providers...": "Pesquisar provedores...", "Search...": "Pesquisar...", "Security": "Segurança", + "Security required: Change the default dashboard password before activating the tunnel.": "Segurança necessária: altere a senha padrão do painel antes de ativar o túnel.", + "Security required: Enable \"Require API key\" before activating the tunnel.": "Segurança necessária: ative a opção \"Exigir chave de API\" antes de ativar o túnel.", + "Security required: Enable \"Require login\" and set a custom password before activating the tunnel.": "Segurança necessária: ative a opção \"Exigir login\" e defina uma senha personalizada antes de ativar o túnel.", "Security risk: no password set.": "Risco de segurança: nenhuma senha definida.", "Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.", "Select": "Selecionar", @@ -832,10 +929,13 @@ "Show this quota row": "Mostrar esta linha de cota", "Shutdown": "Desligar", "Sign in with OIDC": "Entrar com OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "Interface de chat simples para interagir com qualquer modelo de IA dos provedores conectados. Selecione um modelo e comece a conversar!", "Single": "Único", + "Skills": "Skills", "Sort": "Classificar", "Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante", "Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota", + "Speech To Text": "Fala para Texto", "Speech-to-Text": "Fala-para-Texto", "StandardErrorPath": "Caminho do Erro Padrão", "StandardOutPath": "Caminho da Saída Padrão", @@ -879,10 +979,12 @@ "Tailscale": "Tailscale", "Tailscale Funnel": "Tailscale Funnel", "Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "O Tailscale Funnel será parado. O acesso remoto via URL Tailscale parará de funcionar.", "Tailscale installed": "Tailscale instalado", "Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.", "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", "Temperature": "Temperatura", + "Terminal": "Terminal", "Terms of Service": "Termos de Serviço", "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)", "Test Example": "Exemplo de Teste", @@ -894,29 +996,46 @@ "Test connection": "Testar conexão", "Test proxy": "Testar proxy", "Test proxy URL": "Testar URL do proxy", + "Text To Speech": "Texto para Fala", + "Text to Image": "Texto para Imagem", "Text-to-Speech": "Texto-para-Fala", "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", "The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "O túnel Cloudflare será desconectado. O acesso remoto via URL do túnel deixará de funcionar.", "The proxy server has been stopped.": "O servidor proxy foi parado.", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "A requisição é atendida por OpenAI, Anthropic, Gemini ou outros instantaneamente.", "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.", "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.", "Theme": "Tema", "Thinking Process": "Processo de Raciocínio", "This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "Este provedor está pronto para uso. Opcionalmente, envie requisições por um pool de proxy para burlar limites por IP.", + "This value is write-only after saving.": "Este valor é somente gravação após salvar.", "Time": "Hora", "Timestamp": "Timestamp", "Timestamp:": "Timestamp:", + "Today": "Hoje", "Toggle auto-ping": "Alternar ping automático", "Token Saver": "Economizador de Tokens", "Token Saver settings": "Configurações do Economizador de Tokens", "Token Types:": "Tipos de Token:", + "Token auto-detected from Kiro IDE successfully!": "Token auto-detectado do Kiro IDE com sucesso!", + "Token is used once for deployment and not stored.": "O token é usado uma vez para implantação e não é armazenado.", + "Token is used once for deployment, not stored. Found in Organization Settings.": "O token é usado uma vez para implantação, não armazenado. Encontrado nas Configurações da Organização.", + "Token savings (estimated)": "Economia de tokens (estimada)", + "Token will be auto-filled...": "O token será preenchido automaticamente...", "Tokens": "Tokens", + "Tokens auto-detected from Cursor IDE successfully!": "Tokens auto-detectados do IDE Cursor com sucesso!", + "Tool not found or disabled.": "Ferramenta não encontrada ou desabilitada.", "Tools": "Ferramentas", "Total": "Total", + "Total Cost": "Custo total", "Total Input Tokens": "Total de Tokens de Entrada", "Total Models": "Total de Modelos", "Total Requests": "Total de Requisições", + "Total Tokens": "Total de tokens", "Total:": "Total:", + "Track and manage your API quota limits": "Acompanhe e gerencie seus limites de cota da API", "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", "Translator Debug": "Depuração do Tradutor", "Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.", @@ -934,6 +1053,11 @@ "Undo": "Desfazer", "Uninstall": "Desinstalar", "Uninstalling…": "Desinstalando…", + "Unknown": "Desconhecido", + "Unknown Account": "Conta desconhecida", + "Unknown Endpoint": "Endpoint desconhecido", + "Unknown Key": "Chave desconhecida", + "Unknown Model": "Modelo desconhecido", "Update": "Atualizar", "Update 9Router": "Atualizar 9Router", "Update Password": "Atualizar senha", @@ -943,19 +1067,29 @@ "Uptime": "Tempo de atividade", "Usage": "Uso", "Usage Logs": "Logs de Uso", + "Usage by API Key": "Uso por chave de API", + "Usage by Account": "Uso por conta", + "Usage by Endpoint": "Uso por endpoint", + "Usage by Model": "Uso por modelo", "Usage:": "Uso:", "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", "Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab", "Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "Use uma chave de API xAI direta de console.x.ai. Isso é separado do OAuth do Grok Build.", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "Use um proxy local para Iniciar/Parar, ou um sidecar Docker externo como http://headroom:8787.", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "Use uma chave de API Kiro/CodeWhisperer duradoura (autenticação headless).", "Valid": "Válido", "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…", "Vercel API Token": "Token de API Vercel", "Vercel Relay": "Vercel Relay", "Version": "Versão", + "Video": "Vídeo", "View": "Visualizar", "View Codex reset credit expiry": "Ver expiração de crédito do Codex", "View Full Details": "Ver Detalhes Completos", "View on GitHub": "Ver no GitHub", + "Vision": "Visão", + "Vision Adapter": "Adaptador de Visão", "Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:", "Voice": "Voz", "Voice ID": "ID de Voz", @@ -963,6 +1097,7 @@ "Waiting for authorization...": "Aguardando autorização...", "Warning": "Aviso", "Web Fetch": "Fetch Web", + "Web Fetch & Search": "Web Fetch e Busca", "Web Search": "Busca Web", "What is Cloudflare Relay?": "O que é Cloudflare Relay?", "What is Deno Relay?": "O que é Deno Relay?", @@ -971,13 +1106,16 @@ "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM", "Worker Name": "Nome do Worker", + "Works on any device": "Funciona em qualquer dispositivo", "Writes to": "Grava em", "Yes": "Sim", "You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.", "Your Account Name": "Nome da Sua Conta", "Your Code": "Seu Código", "Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth", + "Your model can't read image/audio? Auto-switches to a model in the pool below.": "Seu modelo não lê imagem/áudio? Alterna automaticamente para um modelo do pool abaixo.", "Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "Suas requisições começam das suas ferramentas favoritas ou do nosso SDK unificado. Apenas mude a URL base.", "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?", "e.g. a warm, gentle voice, speaking slowly with a British accent": "ex.: voz quente e suave, falando devagar com sotaque britânico", "extras status failed": "falha no status dos extras", @@ -985,6 +1123,12 @@ "not installed": "não instalado", "sk_9router (default)": "sk_9router (padrão)", "tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código", + "web": "web", + "— audio input": "— entrada de áudio", + "— images (png, jpg, webp, …)": "— imagens (png, jpg, webp, …)", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— consulta todos os modelos em paralelo e um juiz sintetiza uma resposta. Melhor qualidade, porém o maior custo: cada requisição cobra todos os modelos do painel + o juiz (N+1 chamadas)", + "— rotates models across requests to spread load": "— rotaciona os modelos entre as requisições para distribuir a carga", + "— tries models in order (next on failure)": "— tenta os modelos em ordem (próximo em caso de falha)", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco." } diff --git a/public/providers/xquik.png b/public/providers/xquik.png new file mode 100644 index 00000000..8f359751 Binary files /dev/null and b/public/providers/xquik.png differ diff --git a/skills/9router-web-search/SKILL.md b/skills/9router-web-search/SKILL.md index ebd3003d..afb0adea 100644 --- a/skills/9router-web-search/SKILL.md +++ b/skills/9router-web-search/SKILL.md @@ -1,6 +1,6 @@ --- name: 9router-web-search -description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity. Use when the user wants to search the web, look up information, find articles, or query a search engine. +description: Web and X search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity / Xquik. Use when the user wants to search the web, find articles, or search public X posts. --- # 9Router — Web Search @@ -26,7 +26,7 @@ IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain p | `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) | | `query` | yes | search query | | `max_results` | no | default 5 | -| `search_type` | no | `web` (default) / `news` | +| `search_type` | no | `web` (default) / `news` / `x` for Xquik | | `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent | ## Examples @@ -49,6 +49,26 @@ const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, { console.log(await r.json()); ``` +X search with Xquik: + +```bash +curl -X POST $NINEROUTER_URL/v1/search \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"xquik","query":"from:github release","max_results":10,"provider_options":{"queryType":"Latest"}}' +``` + +Add the Xquik API key in 9Router's provider settings. Xquik charges 1 credit per returned post. Continue a search by passing `pagination.next_cursor` as `provider_options.cursor`. + +Xquik responses include provider pagination and credit usage: + +```json +{ + "pagination": { "has_more": true, "next_cursor": "cursor-2" }, + "usage": { "queries_used": 1, "search_cost_usd": null, "provider_credits_used": 10 } +} +``` + ## Response shape ```json @@ -87,5 +107,6 @@ All accept `query` + `max_results`. Optional fields vary: | `searchapi` | country, language, pagination | — | | `youcom` | country, language, time_range, domain_filter, full_page | — | | `searxng` | language, time_range | Self-hosted, **noAuth** | +| `xquik` | X/Twitter search operators, language, cursor pagination | `queryType: Latest/Top`, `cursor` (options) | Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`. diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index 58482a9b..52ca8b09 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { UPDATER_CONFIG } from "@/shared/constants/config"; +import { readPresets, upsertPreset, deletePreset, subscribePresets, stripSlash } from "./cliEndpointPresets"; -const STORAGE_KEY = "9router.cliToolEndpointPresets"; const CUSTOM_VALUE = "__custom__"; const SAVE_VALUE = "__save__"; @@ -13,22 +13,6 @@ const ensureV1 = (url) => { return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; }; -const readSavedPresets = () => { - if (typeof window === "undefined") return []; - try { - const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); - if (!Array.isArray(raw)) return []; - return raw.filter((p) => p?.name && p?.baseUrl); - } catch { - return []; - } -}; - -const writeSavedPresets = (presets) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); -}; - const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => { const opts = []; const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); @@ -66,14 +50,34 @@ export default function BaseUrlSelect({ cloudEnabled = false, cloudUrl = "", withV1 = true, + currentUrl = "", }) { const [savedPresets, setSavedPresets] = useState([]); + const [presetsLoaded, setPresetsLoaded] = useState(false); const [mode, setMode] = useState(""); const [customInput, setCustomInput] = useState(""); const initializedRef = useRef(false); + const customInputRef = useRef(""); useEffect(() => { - setSavedPresets(readSavedPresets()); + const sync = () => { + const presets = readPresets(); + setSavedPresets(presets); + // A preset saved elsewhere (e.g. on Apply) takes over the custom slot + setMode((prev) => { + if (prev !== CUSTOM_VALUE) return prev; + const typed = stripSlash(customInputRef.current); + if (!typed) return prev; + const match = presets.find((p) => { + const saved = stripSlash(p.baseUrl); + return saved === typed || saved === ensureV1(typed); + }); + return match ? `saved:${match.name}` : prev; + }); + }; + sync(); + setPresetsLoaded(true); + return subscribePresets(sync); }, []); const options = useMemo( @@ -81,19 +85,23 @@ export default function BaseUrlSelect({ [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Always default to first option (127.0.0.1) on mount, ignore persisted value + // Prefer a saved preset matching the currently configured URL, else first option useEffect(() => { if (initializedRef.current) return; - if (options.length === 0) return; + if (!presetsLoaded || options.length === 0) return; initializedRef.current = true; - const first = options.find((o) => o.value !== CUSTOM_VALUE); - if (first) { - setMode(first.value); - onChange(first.url); + const current = stripSlash(currentUrl); + const matched = current + ? options.find((o) => o.saved && stripSlash(o.url) === current) + : null; + const target = matched || options.find((o) => o.value !== CUSTOM_VALUE); + if (target) { + setMode(target.value); + onChange(target.url); } else { setMode(CUSTOM_VALUE); } - }, [options, onChange]); + }, [presetsLoaded, options, onChange, currentUrl]); const handleSelect = (e) => { const next = e.target.value; @@ -103,11 +111,8 @@ export default function BaseUrlSelect({ let defaultName = trimmed; try { defaultName = new URL(trimmed).host; } catch {} const name = window.prompt("Save endpoint as:", defaultName); - if (!name?.trim()) return; - const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }] - .sort((a, b) => a.name.localeCompare(b.name)); - setSavedPresets(updated); - writeSavedPresets(updated); + const saved = name?.trim() ? upsertPreset(trimmed, name.trim()) : null; + if (saved) setMode(`saved:${saved}`); return; } setMode(next); @@ -122,19 +127,23 @@ export default function BaseUrlSelect({ const handleCustomInput = (e) => { const v = e.target.value; + customInputRef.current = v; setCustomInput(v); onChange(v); }; const handleDeleteSaved = () => { if (!mode.startsWith("saved:")) return; - const name = mode.slice(6); - const updated = savedPresets.filter((p) => p.name !== name); - setSavedPresets(updated); - writeSavedPresets(updated); - setMode(CUSTOM_VALUE); + deletePreset(mode.slice(6)); setCustomInput(""); - onChange(""); + const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode); + if (fallback) { + setMode(fallback.value); + onChange(fallback.url); + } else { + setMode(CUSTOM_VALUE); + onChange(""); + } }; const isSaved = mode.startsWith("saved:"); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 7ee5ad2e..912114d6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -53,6 +54,8 @@ export default function ClaudeToolCard({ const [maxContextTokens, setMaxContextTokens] = useState(""); const hasInitializedModels = useRef(false); + const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || ""; + const getConfigStatus = () => { if (!claudeStatus?.installed) return null; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; @@ -189,6 +192,8 @@ export default function ClaudeToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); } else { @@ -334,6 +339,7 @@ export default function ClaudeToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js index a88f79e6..b1deb02f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -50,6 +51,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; + const currentBaseUrl = status?.settings?.openAiBaseUrl || ""; + const getConfigStatus = () => { if (!status?.installed) return null; if (!status.has9Router) return "not_configured"; @@ -94,6 +97,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -226,6 +231,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 3e706996..6fed52de 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -6,6 +6,7 @@ import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; +import { rememberEndpoint } from "./cliEndpointPresets"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -57,17 +58,22 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api if (modelMatch) setSelectedModel(modelMatch[1]); // Parse subagent settings - const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m); + const subagentModelMatch = codexStatus.config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); } }, [codexStatus]); + const getCurrentBaseUrl = () => { + const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/); + return parsed ? parsed[1] : ""; + }; + + const currentBaseUrl = getCurrentBaseUrl(); + const getConfigStatus = () => { if (!codexStatus?.installed) return null; if (!codexStatus.config) return "not_configured"; - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentUrl = parsed ? parsed[1] : ""; - return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; + return matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -114,6 +120,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkCodexStatus(); } else { @@ -172,24 +180,18 @@ name = "9Router" base_url = "${getEffectiveBaseUrl()}" wire_api = "responses" -[agents.subagent] -model = "${effectiveSubagentModel}" -`; +[model_providers.9router.http_headers] +Authorization = "Bearer ${keyToUse}" - const authContent = JSON.stringify({ - auth_mode: "apikey", - OPENAI_API_KEY: keyToUse - }, null, 2); +[agents] +default_subagent_model = "${effectiveSubagentModel}" +`; return [ { filename: "~/.codex/config.toml", content: configContent, }, - { - filename: "~/.codex/auth.json", - content: authContent, - }, ]; }; @@ -254,7 +256,7 @@ model = "${effectiveSubagentModel}"

After installation, run codex to verify.

- Codex uses ~/.codex/auth.json with OPENAI_API_KEY. + Codex reads custom providers from ~/.codex/config.toml. Click "Apply" to auto-configure.

@@ -279,13 +281,12 @@ model = "${effectiveSubagentModel}" tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> {/* Current configured */} {codexStatus?.config && (() => { - const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); - const currentBaseUrl = parsed ? parsed[1] : null; return currentBaseUrl ? (
Current diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index 90f96f94..43242938 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -77,6 +78,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a } }; + const currentBaseUrl = status?.currentUrl || ""; + const getConfigStatus = () => { if (!status) return null; if (!status.has9Router) return "not_configured"; @@ -123,6 +126,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." }); checkStatus(); } else { @@ -230,6 +235,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js index 2d1fb820..367ef5db 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; const ENDPOINT = "/api/cli-tools/cowork-settings"; @@ -110,6 +111,8 @@ export default function CoworkToolCard({ const getEffectiveBaseUrl = () => ensureV1(customBaseUrl); + const currentBaseUrl = status?.cowork?.baseUrl || ""; + const getConfigStatus = () => { if (!status?.installed) return null; const url = status?.cowork?.baseUrl; @@ -148,6 +151,8 @@ export default function CoworkToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." }); checkStatus(); } else { @@ -306,6 +311,7 @@ export default function CoworkToolCard({ tailscaleUrl={tailscaleUrl} cloudEnabled={cloudEnabled} cloudUrl={cloudUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js index ad84a4b7..db051f6b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function DeepSeekTuiToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = deepseekStatus?.settings?.["providers.openai"]?.base_url || ""; + const getConfigStatus = () => { if (!deepseekStatus?.installed) return null; const openaiSection = deepseekStatus.settings?.["providers.openai"]; @@ -128,6 +131,8 @@ export default function DeepSeekTuiToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -263,6 +268,7 @@ model = "${selectedModel || "provider/model-id"}" tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index adc2a7ae..d6068f11 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -39,6 +40,8 @@ export default function DroidToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = droidStatus?.settings?.customModels?.find((m) => m.id?.startsWith("custom:9Router"))?.baseUrl || ""; + const getConfigStatus = () => { if (!droidStatus?.installed) return null; // Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...) @@ -154,6 +157,8 @@ export default function DroidToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkDroidStatus(); } else { @@ -299,6 +304,7 @@ export default function DroidToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js index f1ea3c22..fde57a38 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js @@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp import { useModelCaps } from "@/shared/hooks/useModelCaps"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -96,6 +97,7 @@ export default function GrokBuildToolCard({ const hasFetchedStatus = useRef(Boolean(initialStatus)); const configuredModel = grokStatus?.settings?.model; + const currentBaseUrl = configuredModel?.base_url || ""; const configStatus = !grokStatus?.installed ? null : !configuredModel?.base_url @@ -184,6 +186,8 @@ export default function GrokBuildToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Main and subagent models applied successfully!" }); checkStatus(); } else { @@ -310,7 +314,7 @@ export default function GrokBuildToolCard({
Select Endpoint arrow_forward - +
{configuredModel?.base_url && ( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 9ef6cddf..bf5c25d1 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function HermesToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = hermesStatus?.settings?.model?.base_url || ""; + const getConfigStatus = () => { if (!hermesStatus?.installed) return null; const cfg = hermesStatus.settings?.model; @@ -128,6 +131,8 @@ export default function HermesToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -242,6 +247,7 @@ export default function HermesToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js index c4544fa3..c616980c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -35,6 +36,8 @@ export default function JcodeToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = jcodeStatus?.config?.providers?.["9router"]?.base_url || ""; + const getConfigStatus = () => { if (!jcodeStatus?.installed) return null; if (!jcodeStatus?.has9Router) return "not_configured"; @@ -140,6 +143,8 @@ export default function JcodeToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkJcodeStatus(); } else { @@ -295,6 +300,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`; tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js index be348595..87f14f54 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -88,6 +89,8 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index b646a6eb..8e5cb8c2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -37,6 +38,8 @@ export default function OpenClawToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const hasInitializedModel = useRef(false); + const currentBaseUrl = openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl || ""; + const getConfigStatus = () => { if (!openclawStatus?.installed) return null; const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; @@ -146,6 +149,8 @@ export default function OpenClawToolCard({ }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkOpenclawStatus(); } else { @@ -291,6 +296,7 @@ export default function OpenClawToolCard({ tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index c99edc98..5c800bc2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; @@ -94,6 +95,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, } }; + const currentBaseUrl = status?.config?.provider?.["9router"]?.options?.baseURL || ""; + const getConfigStatus = () => { if (!status?.installed) return null; if (!status.config) return "not_configured"; @@ -145,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, }); const data = await res.json(); if (res.ok) { + // Remember the endpoint so it stays selectable next time + rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl }); setMessage({ type: "success", text: "Settings applied successfully!" }); checkStatus(); } else { @@ -297,6 +302,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} + currentUrl={currentBaseUrl} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js new file mode 100644 index 00000000..53cd0d43 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js @@ -0,0 +1,71 @@ +import { UPDATER_CONFIG } from "@/shared/constants/config"; + +// Browser-local endpoint presets shared by every CLI tool card +const STORAGE_KEY = "9router.cliToolEndpointPresets"; +const CHANGE_EVENT = "9router:endpoint-presets-changed"; + +const stripSlash = (url) => (url || "").replace(/\/+$/, ""); + +export function readPresets() { + if (typeof window === "undefined") return []; + try { + const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); + if (!Array.isArray(raw)) return []; + return raw.filter((p) => p?.name && p?.baseUrl); + } catch { + return []; + } +} + +function writePresets(presets) { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); +} + +export function subscribePresets(handler) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, handler); + return () => window.removeEventListener(CHANGE_EVENT, handler); +} + +function defaultNameFor(url) { + try { return new URL(url).host; } catch { return url; } +} + +// Adds or replaces a preset; returns the stored name, or null when skipped +export function upsertPreset(baseUrl, name) { + const url = stripSlash(baseUrl); + if (!url) return null; + + const presets = readPresets(); + const existing = presets.find((p) => stripSlash(p.baseUrl) === url); + if (existing && !name) return existing.name; + + const finalName = (name || defaultNameFor(url)).trim(); + if (!finalName) return null; + + const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }] + .sort((a, b) => a.name.localeCompare(b.name)); + writePresets(next); + return finalName; +} + +// Save an applied endpoint unless it exactly matches a built-in dropdown option +export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) { + const url = stripSlash(baseUrl); + if (!url) return null; + + const builtIns = [`http://127.0.0.1:${UPDATER_CONFIG.appPort}`, tunnelPublicUrl, tailscaleUrl, cloudUrl] + .filter(Boolean) + .flatMap((u) => [stripSlash(u), `${stripSlash(u)}/v1`]); + if (builtIns.includes(url)) return null; + + return upsertPreset(url); +} + +export function deletePreset(name) { + writePresets(readPresets().filter((p) => p.name !== name)); +} + +export { stripSlash }; diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index 76c571b8..ff30dcb2 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -275,7 +275,7 @@ export function GenericExampleCard({ providerId, kind }) { {/* API Key */} - {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : No key configured} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js index 18f2a4f6..3cffdc42 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js @@ -157,7 +157,7 @@ export function SttExampleCard({ providerId }) { {/* API Key */} - {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : No key configured} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js index a3fb1d32..e67a2594 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js @@ -274,7 +274,7 @@ export function TtsExampleCard({ providerId }) { {apiKey - ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` + ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : connectionCount > 0 ? Using stored key(s) · {connectionCount} connection{connectionCount > 1 ? "s" : ""} : No key configured} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js b/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js new file mode 100644 index 00000000..10628eb1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js @@ -0,0 +1,284 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Modal, Button } from "@/shared/components"; +import { translate } from "@/i18n/runtime"; + +const PLACEHOLDER = `[ + { + "access_token": "eyJ0eXAiOiJhdCtqd3Qi...", + "refresh_token": "LZhriF9bf88pPykpXCuZ9...", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...", + "email": "account1@example.com" + }, + { + "access_token": "eyJ0eXAiOiJhdCtqd3Qi...", + "refresh_token": "LZhriF9bf88pPykpXCuZ9...", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...", + "email": "account2@example.com" + } +]`; + +function parseAccountsInput(rawText) { + const trimmed = rawText.trim(); + if (!trimmed) return []; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (initialErr) { + // If direct parse failed, try handling concatenated or comma-separated JSON objects + try { + let fixed = trimmed; + if (!fixed.startsWith("[")) { + fixed = fixed.replace(/\}\s*,\s*\{/g, "},{").replace(/\}\s*\{/g, "},{"); + if (fixed.endsWith(",")) fixed = fixed.slice(0, -1); + fixed = `[${fixed}]`; + } + parsed = JSON.parse(fixed); + } catch { + throw initialErr; + } + } + + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed.accounts)) return parsed.accounts; + return [parsed]; + } + + throw new Error("Input must be a JSON object or array of objects"); +} + +export default function BulkImportGrokCliModal({ isOpen, onClose, onSuccess }) { + const [jsonText, setJsonText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [parseError, setParseError] = useState(""); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [fileCountInfo, setFileCountInfo] = useState(null); + const fileInputRef = useRef(null); + + const handleClose = () => { + if (submitting) return; + setJsonText(""); + setParseError(""); + setResult(null); + setFileCountInfo(null); + setIsDragging(false); + onClose(); + }; + + const processFiles = async (files) => { + if (!files || files.length === 0) return; + setParseError(""); + const jsonFiles = Array.from(files).filter( + (file) => file.name.endsWith(".json") || file.type === "application/json" || file.type === "" + ); + + if (jsonFiles.length === 0) { + setParseError(translate("Please select valid .json files")); + return; + } + + try { + const allAccounts = []; + for (const file of jsonFiles) { + const text = await file.text(); + const accountsFromFile = parseAccountsInput(text); + if (Array.isArray(accountsFromFile)) { + allAccounts.push(...accountsFromFile); + } else if (accountsFromFile) { + allAccounts.push(accountsFromFile); + } + } + + if (allAccounts.length === 0) { + setParseError(translate("No accounts found in selected files")); + return; + } + + setJsonText(JSON.stringify(allAccounts, null, 2)); + setFileCountInfo({ + filesCount: jsonFiles.length, + accountsCount: allAccounts.length, + }); + } catch (err) { + setParseError(`${translate("Error reading files")}: ${err.message}`); + } + }; + + const handleFileInputChange = (e) => { + processFiles(e.target.files); + if (e.target) e.target.value = ""; + }; + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer?.files?.length > 0) { + processFiles(e.dataTransfer.files); + } + }; + + const handleSubmit = async () => { + setParseError(""); + setResult(null); + + let accounts; + try { + accounts = parseAccountsInput(jsonText); + } catch (err) { + setParseError(`${translate("Invalid JSON")}: ${err.message}`); + return; + } + + if (!accounts || accounts.length === 0) { + setParseError(translate("No accounts found in input")); + return; + } + + setSubmitting(true); + try { + const res = await fetch("/api/oauth/grok-cli/bulk-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accounts }), + }); + const data = await res.json(); + + if (!res.ok) { + setParseError(data?.error || `Request failed: ${res.status}`); + return; + } + + setResult(data); + if (data.success > 0 && typeof onSuccess === "function") { + onSuccess(); + } + } catch (err) { + setParseError(err.message || translate("Request failed")); + } finally { + setSubmitting(false); + } + }; + + const failedItems = result?.results?.filter((r) => !r.ok) || []; + + return ( + +
+
+

+ {translate("Upload multiple .json files or paste JSON array / object.")} +

+ + +
+ +
+