diff --git a/.gitignore b/.gitignore index 0a48e000..46c33e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,18 @@ graphify-out/* # Kiro local workspace state .kiro/ +9router-* + +# Local sensitive / temp files +.engine-token.txt +.tmp-prov.json +.tmp-providers.json +.oauth-session.json +.dev-server.log +.dev-server-err.log +.start-dev.ps1 +start-dev-silent.cjs +debug.log # CommandCode CLI local state (auth/taste/projects) .commandcode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a79c3171..a2fd0297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# v0.5.75 (2026-09-10) + +## Features +- **Video**: add OpenRouter and Vertex AI (Veo) video generation on `/v1/videos/*` via a provider adapter layer; poll requests resolve their provider from `x-connection-id` or `?provider=` +- **Antigravity**: add weekly quota tracking (Gemini weekly / Claude & GPT weekly) and free-tier handling from `retrieveUserQuotaSummary` (#3892) +- **Codex**: add GPT Image 2.5, Flare and Sunburst image models with multi-image support; add the same ids to the OpenAI catalog +- **Qoder**: surface usage to all clients and stop inlining large attachments β€” images upload through `/api/v2/image/upload` like qodercli, oversized file blocks become stubs, context tier auto-escalates +- **OpenCode Go**: add newly published models (glm-5.3, kimi-k3, deepseek-flash, longcat-2.0, hy4-preview, hy3 on chat/completions; qwen3.8-max, qwen3.8-flash on `/messages`; grok-4.6, gpt-5.6-luna on Responses) and list `deepseek-v4.1-flash` first in the catalog +- **CLI tools**: group the model selector by provider with full-text search and manual custom model ID entry +- **CodeBuddy-CN**: replace `deepseek-v4-flash` with `deepseek-v4.1-flash` + +## Fixes +- **Tools**: scope Claude tool type defaulting to gateways declaring `requireClaudeToolType` β€” the global default broke Anthropic-compatible endpoints that only accept the legacy typeless tool shape (#3905) +- **Claude**: cap re-anchored `cache_control` at the 4-marker budget so a spent budget no longer 400s and triggers a full combo failover; wrap bare single-object content turns before the mid-conversation-system fold +- **Cline / Airforce**: unwrap the `{"success":true,"data":…}` envelope on non-stream chat completions (#3644); add the live Cline/ClinePass model catalog and refresh Airforce free models +- **Cline**: stop `workos:`-prefixing ClinePass API keys (401 on every request, #2333) and add clinepass token refresh +- **Kiro**: never send a top-level `systemPrompt` (`400 REQUEST_BODY_INVALID`); route requests through current runtime surfaces (#3776) +- **Codex**: strip Unicode-property tool schema patterns the validator rejects (#3922); restore the `Version` header and single-source the CLI version +- **DeepSeek**: keep Anthropic-only tool types when forwarding to `/anthropic/v1/messages` +- **Qoder**: drop the Responses usage plumbing from shared translator/handler code, which changed token accounting for every provider, not just Qoder +- **Antigravity**: normalize contents and handle intermediate tool responses; protect the OAuth token-refresh path from Google anti-abuse rate limits (#3813) +- **Providers**: clear stale connection health state (`modelLock_*`, `backoffLevel`, `rateLimitedUntil`, `errorCode`) when a connection is re-validated (#3810, #3830); remove the duplicate `qwen` provider that shadowed `alims-intl` +- **Video / Vertex**: reject job ids and model ids that would escape the request URL path (SSRF) +- **Usage**: parse the Fable weekly limit from `limits[]` instead of fabricating a row (#3847) +- **Auth**: set a 24h `maxAge` on the dashboard session cookie + # v0.5.70 (2026-09-08) ## Features diff --git a/cli/package.json b/cli/package.json index 200eaba0..58b9dfa8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.70", + "version": "0.5.75", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js index 438220d1..a2bd3fdc 100644 --- a/cli/src/cli/utils/modelSelector.js +++ b/cli/src/cli/utils/modelSelector.js @@ -55,7 +55,7 @@ async function getAvailableModelsGrouped() { } /** - * Display model list and prompt for selection + * Display model list and prompt for selection with provider grouping & search * @param {string} title - Title to display * @param {string} currentValue - Current selected value (optional) * @param {Object} options - { excludeCombos?: boolean } @@ -70,62 +70,199 @@ async function selectModelFromList(title, currentValue = "", options = {}) { if (totalModels === 0) { return null; } - - // Build flat list for selection - const allModels = []; - - // Display - clearScreen(); - console.log(`\n🎯 ${title}`); - console.log("=".repeat(50)); - if (currentValue) { - console.log(`Current: ${currentValue}\n`); - } else { - console.log(); - } - - let idx = 1; - - // Combos first (skipped when excludeCombos is true) + + // All models for flat search + const allModelsList = [ + ...combos, + ...Object.values(groups).flat() + ]; + + // Build category list + const categories = []; if (combos.length > 0) { - console.log("[Combos]"); - combos.forEach(combo => { - console.log(` ${idx}. ${combo}`); - allModels.push(combo); - idx++; + categories.push({ + id: "combos", + name: "[Combos]", + models: combos }); - console.log(); } - - // Provider groups in order (by alias) + const sortedProviders = Object.keys(groups).sort((a, b) => { const idxA = PROVIDER_ALIAS_ORDER.indexOf(a); const idxB = PROVIDER_ALIAS_ORDER.indexOf(b); return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); }); - - sortedProviders.forEach(provider => { + + sortedProviders.forEach((provider) => { const providerName = PROVIDER_ALIAS_NAMES[provider] || provider; - console.log(`[${providerName}]`); - groups[provider].forEach(model => { - console.log(` ${idx}. ${model}`); - allModels.push(model); - idx++; + categories.push({ + id: provider, + name: providerName, + models: groups[provider] }); - console.log(); }); - - console.log(" 0. Cancel\n"); - - // Prompt for number input - const input = await prompt("Enter number: "); - const num = parseInt(input, 10); - - if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) { - return null; + + let filterQuery = null; + + while (true) { + clearScreen(); + console.log(`\n🎯 ${title}`); + console.log("=".repeat(50)); + if (currentValue) { + console.log(`Current: ${currentValue}\n`); + } else { + console.log(); + } + + // Active search view + if (filterQuery !== null) { + const q = filterQuery.toLowerCase().trim(); + const matched = allModelsList.filter((m) => m.toLowerCase().includes(q)); + + console.log(`πŸ” Search results for "${filterQuery}": (${matched.length} found)\n`); + if (matched.length === 0) { + console.log(" No matching models found.\n"); + console.log(" 0. ← Back to providers"); + console.log(" s. Search again\n"); + const act = await prompt("Select option: "); + if (act.toLowerCase() === "s") { + const newQ = await prompt("Enter search keyword: "); + filterQuery = newQ.trim() || null; + } else { + filterQuery = null; + } + continue; + } + + matched.forEach((m, i) => { + console.log(` ${i + 1}. ${m}`); + }); + console.log("\n 0. ← Back to providers"); + console.log(" s. Search again\n"); + + const input = await prompt("Enter number to select (or 0/s): "); + if (input.toLowerCase() === "s") { + const newQ = await prompt("Enter search keyword: "); + filterQuery = newQ.trim() || null; + continue; + } + const num = parseInt(input, 10); + if (isNaN(num) || num === 0) { + filterQuery = null; + continue; + } + if (num > 0 && num <= matched.length) { + return matched[num - 1]; + } + continue; + } + + // If only 1 category exists, jump straight into its model list + if (categories.length === 1) { + const singleCategory = categories[0]; + console.log(`[${singleCategory.name}]`); + singleCategory.models.forEach((m, i) => { + console.log(` ${i + 1}. ${m}`); + }); + console.log(); + console.log(" s. πŸ” Search models"); + console.log(" m. ✍️ Enter custom model ID"); + console.log(" 0. Cancel\n"); + + const input = await prompt("Enter choice (number / s / m / 0): "); + const trimmed = input.trim(); + if (!trimmed || trimmed === "0") return null; + + const lower = trimmed.toLowerCase(); + if (lower === "s") { + const q = await prompt("Enter search keyword: "); + if (q.trim()) filterQuery = q.trim(); + continue; + } + if (lower === "m") { + const customModel = await prompt("Enter custom model ID: "); + if (customModel.trim()) return customModel.trim(); + continue; + } + + const num = parseInt(trimmed, 10); + if (!isNaN(num) && num > 0 && num <= singleCategory.models.length) { + return singleCategory.models[num - 1]; + } + filterQuery = trimmed; + continue; + } + + // Multiple categories view + console.log("[Providers & Groups]"); + categories.forEach((cat, i) => { + console.log(` ${i + 1}. ${cat.name} (${cat.models.length} models)`); + }); + + console.log(); + console.log(" s. πŸ” Search models"); + console.log(" m. ✍️ Enter custom model ID"); + console.log(" 0. Cancel\n"); + + const input = await prompt("Enter choice (number / keyword / s / m): "); + const trimmed = input.trim(); + + if (!trimmed || trimmed === "0") { + return null; + } + + const lower = trimmed.toLowerCase(); + if (lower === "s") { + const q = await prompt("Enter search keyword: "); + if (q.trim()) { + filterQuery = q.trim(); + } + continue; + } + + if (lower === "m") { + const customModel = await prompt("Enter custom model ID: "); + if (customModel.trim()) { + return customModel.trim(); + } + continue; + } + + const num = parseInt(trimmed, 10); + // Selected a category + if (!isNaN(num) && num > 0 && num <= categories.length) { + const selectedCategory = categories[num - 1]; + + while (true) { + clearScreen(); + console.log(`\n🎯 ${title} > ${selectedCategory.name}`); + console.log("=".repeat(50)); + if (currentValue) { + console.log(`Current: ${currentValue}\n`); + } else { + console.log(); + } + + selectedCategory.models.forEach((m, i) => { + console.log(` ${i + 1}. ${m}`); + }); + console.log("\n 0. ← Back\n"); + + const modelChoice = await prompt("Enter number to select (0 to back): "); + const modelNum = parseInt(modelChoice, 10); + if (isNaN(modelNum) || modelNum === 0) { + break; + } + if (modelNum > 0 && modelNum <= selectedCategory.models.length) { + return selectedCategory.models[modelNum - 1]; + } + } + continue; + } + + // User typed text directly -> treat as search query + filterQuery = trimmed; } - - return allModels[num - 1]; } module.exports = { diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 3e18633e..4df1463e 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -7,6 +7,9 @@ import { createRequire } from "module"; export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion; export const GEMINI_CLI_API_CLIENT = PROVIDERS["gemini-cli"]?.apiClient; +// === Codex CLI === derive tα»« registry codex.transport +export const CODEX_CLI_VERSION = PROVIDERS["codex"]?.cliVersion; + // Map Node arch to Gemini CLI arch string (x64/x86/arm64/...) function geminiCLIArch() { const a = arch(); diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index f2ad630a..fb7beee0 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -5,7 +5,7 @@ import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX, import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { resolveSessionId, toNumericSessionId } from "../utils/sessionManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js"; +import { cleanJSONSchemaForAntigravity, normalizeGeminiContents } from "../translator/formats/gemini.js"; import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js"; import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js"; @@ -193,7 +193,7 @@ export class AntigravityExecutor extends BaseExecutor { // ─── Standard (non-image) request ─── // Fix contents for Claude models via Antigravity - const contents = body.request?.contents?.map(c => { + const rawContents = (body.request?.contents || []).map(c => { let role = c.role; // functionResponse must be role "user" for Claude models if (c.parts?.some(p => p.functionResponse)) { @@ -226,15 +226,13 @@ export class AntigravityExecutor extends BaseExecutor { return p; }); - const partsChanged = parts?.length !== c.parts?.length || modifiedParts?.some((p, idx) => p !== c.parts[idx]); - if (role !== c.role || partsChanged) { - return { - ...c, role, - parts: modifiedParts || parts, - }; - } - return c; + return { + ...c, + role, + parts: modifiedParts || parts || [], + }; }); + const contents = normalizeGeminiContents(rawContents); // Sanitize tool schemas and function names before sending to Antigravity. let tools = body.request?.tools; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index 4d9acbd2..de2af822 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -12,6 +12,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { dbg } from "../utils/debugLog.js"; import { resolveSessionId } from "../utils/sessionManager.js"; +import { stripCodexUnsupportedPatterns } from "../utils/codexToolSchema.js"; // SSE error patterns inside 200-OK bodies. Some retry same account first; capacity rotates accounts. const CODEX_SSE_RETRY_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; @@ -72,6 +73,9 @@ function stripStoredItemReferences(body) { function normalizeCodexTools(body) { if (!Array.isArray(body.tools)) return; const validNames = new Set(); + // Codex's schema validator has no Unicode property escapes; a `pattern` + // carrying `\p{...}` 400s the whole request on every account (#3922). + const patternStats = { removed: 0 }; body.tools = body.tools.filter((tool) => { if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; const type = typeof tool.type === "string" ? tool.type : ""; @@ -80,6 +84,9 @@ function normalizeCodexTools(body) { for (const st of tool.tools) { const n = typeof st?.name === "string" ? st.name.trim().slice(0, 128) : ""; if (n) validNames.add(n); + if (st?.parameters && typeof st.parameters === "object") { + st.parameters = stripCodexUnsupportedPatterns(st.parameters, patternStats); + } } } return true; @@ -101,10 +108,13 @@ function normalizeCodexTools(body) { tool.type = "function"; tool.name = name.slice(0, 128); if (description) tool.description = description; - tool.parameters = parameters; + tool.parameters = stripCodexUnsupportedPatterns(parameters, patternStats); validNames.add(name); return true; }); + if (patternStats.removed > 0) { + dbg("CODEX", `stripped ${patternStats.removed} unsupported tool schema pattern(s)`); + } // Drop tool_choice if it references an unknown function name if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) { if (body.tool_choice.type === "function") { diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 8dd03421..f48a8ecb 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -17,6 +17,7 @@ import { PerplexityWebExecutor } from "./perplexity-web.js"; import { OllamaLocalExecutor } from "./ollama-local.js"; import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +import { XiaomiMimoExecutor } from "./xiaomi-mimo.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; @@ -50,6 +51,7 @@ const executors = { "ollama-local": new OllamaLocalExecutor(), commandcode: new CommandCodeExecutor(), "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), + "xiaomi-mimo": new XiaomiMimoExecutor(), "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), @@ -93,6 +95,7 @@ export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +export { XiaomiMimoExecutor } from "./xiaomi-mimo.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 77616618..9d5d754f 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -127,12 +127,18 @@ async function readResponsePrefix(response, signal, maxBytes, timeoutMs) { return decoder.decode(concatChunks(chunks, totalBytes)); } +// The instruction goes into the current user turn, never into a top-level +// `systemPrompt`: kiro.dev answers any body carrying that field with +// 400 REQUEST_BODY_INVALID, so writing it here turned every repair retry into +// a hard failure. function appendRepairInstruction(body, kind) { const repaired = structuredClone(body || {}); const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response."; - repaired.systemPrompt = repaired.systemPrompt - ? `${repaired.systemPrompt}\n\n${instruction}` - : instruction; + const msg = repaired?.conversationState?.currentMessage?.userInputMessage; + if (msg) { + const content = typeof msg.content === "string" ? msg.content : ""; + msg.content = content ? `${content}\n\n${instruction}` : instruction; + } return repaired; } @@ -259,6 +265,19 @@ export class KiroExecutor extends BaseExecutor { } } + // CLIRO parity for the Amazon surfaces: the Kiro runtime accepts the + // SSO bearer header + agent-mode marker. Without these the deprecated + // path gateway answers REQUEST_BODY_INVALID for modern payloads. + if (credentials?.accessToken) { + headers["x-amz-sso-bearer"] = credentials.accessToken; + } + headers["x-amzn-kiro-agent-mode"] = "spec"; + headers["x-amzn-codewhisperer-machine-id"] = "kiro-desktop"; + const profileArn = credentials?.providerSpecificData?.profileArn; + if (profileArn) { + headers["x-amzn-codewhisperer-profile-arn"] = profileArn; + } + return headers; } @@ -285,9 +304,13 @@ export class KiroExecutor extends BaseExecutor { // 403 "bearer token invalid", so they must hit the CodeWhisperer // *.amazonaws.com surface, and in the region the token was minted in // (the baseUrls are hardcoded us-east-1). - const isCodeWhispererSurface = - authMethod === "api_key" || authMethod === "external_idp" || authMethod === "idc"; - if (!isCodeWhispererSurface) return baseUrls; + // Kiro deprecated the legacy path-style GenerateAssistantResponse on + // runtime.*.kiro.dev (IDE 1.0.228+ moved to POST / + x-amz-target). The + // path gateway now answers valid modern payloads with 400 + // REQUEST_BODY_INVALID, and 400 is terminal in BaseExecutor, so kiro.dev + // must never be the first surface for any auth method. Amazon surfaces + // reject foreign tokens with 401/403, which DO fall through, so trying + // q/codewhisperer first is safe for every auth method (CLIRO parity). const region = (credentials?.providerSpecificData?.region || "us-east-1").trim(); const regionalize = (u) => @@ -297,20 +320,17 @@ export class KiroExecutor extends BaseExecutor { const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); - if (authMethod === "api_key") { - const q = amazon.filter((u) => u.includes("://q.")); - const remaining = amazon.filter((u) => !u.includes("://q.")); - return q.length > 0 - ? [...q, ...remaining, ...others] - : [...amazon, ...others]; - } - - return amazon.length > 0 ? [...amazon, ...others] : baseUrls; + const q = amazon.filter((u) => u.includes("://q.")); + const remaining = amazon.filter((u) => !u.includes("://q.")); + return q.length > 0 + ? [...q, ...remaining, ...others] + : [...amazon, ...others]; } buildUrl(model, stream, urlIndex = 0, credentials = null) { const baseUrls = this.getOrderedBaseUrls(credentials); - return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; + const url = baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; + return url; } // Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 8dd8f044..10dc42bf 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -32,14 +32,16 @@ import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { resolveProviderTimeoutMs } from "../services/providerTimeout.js"; import { - QODER_CHAT_URL_ENCODED, - QODER_CHAT_BASE_ALT, QODER_CHAT_SIG_PATH, - QODER_MODEL_MAP, + QODER_CONTEXT_TIER_ENV, + qoderInferenceBase, } from "../shared/qoder/constants.js"; import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js"; import { OPENAI_BLOCK, CLAUDE_BLOCK } from "../translator/schema/blocks.js"; import { encodeDataUri } from "../translator/concerns/image.js"; +import { createQoderSseCoalescer } from "../shared/qoder/sse.js"; +import { rewriteQoderMessageAttachments } from "../shared/qoder/attachments.js"; +import { resolveQoderContextTier, applyQoderContextTier } from "../shared/qoder/contextTier.js"; /** * Hoist role:"system" messages out of the messages array (Qoder rejects @@ -71,15 +73,16 @@ function normalizeMessages(messages) { * * Text-only content is flattened to a plain string (Qoder's historical * shape). When images are present the content stays an array and image - * blocks are kept as OpenAI-style `image_url` parts β€” verified against the - * upstream: it accepts both http(s) URLs and inline base64 data: URIs - * directly, no pre-upload to the /image/upload OSS flow required (that is - * a qodercli client-side choice, not a protocol requirement). The legacy + * blocks are kept as OpenAI-style `image_url` parts. Native qodercli + * uploads inlined bytes to `/api/v2/image/upload` first and then sends + * the OSS URL β€” `buildQoderRequestBody` does that rewrite before this + * runs. Tiny leftover data URIs are still accepted. The legacy * top-level `image_urls` / `chat_context.imageUrls` slots stay null β€” * qodercli leaves them null too. * * Claude-style `{type:"image", source:{...}}` blocks are converted to - * `image_url` so claude-format clients also round-trip. + * `image_url`. File/document blocks that survived rewrite become short + * stubs so 30MB PDFs never land in agent_chat_generation. */ function normalizeContent(content) { if (typeof content === "string") return content; @@ -89,10 +92,24 @@ function normalizeContent(content) { const blocks = []; const textParts = []; let hasImage = false; + + const pushText = (text) => { + if (!text) return; + if (hasImage || blocks.length) blocks.push({ type: OPENAI_BLOCK.TEXT, text }); + else textParts.push(text); + }; + + const imageUrlOf = (item) => { + if (typeof item.image_url === "string" && item.image_url) return item.image_url; + if (typeof item.image_url?.url === "string" && item.image_url.url) return item.image_url.url; + return null; + }; + for (const item of content) { if (!item || typeof item !== "object") continue; - if (item.type === OPENAI_BLOCK.IMAGE_URL && typeof item.image_url?.url === "string" && item.image_url.url) { - blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: item.image_url.url } }); + const imageUrl = item.type === OPENAI_BLOCK.IMAGE_URL ? imageUrlOf(item) : null; + if (imageUrl) { + blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: imageUrl } }); hasImage = true; } else if (item.type === CLAUDE_BLOCK.IMAGE && item.source) { // Claude base64/url image β†’ OpenAI image_url equivalent. @@ -104,13 +121,14 @@ function normalizeContent(content) { blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } }); hasImage = true; } + } else if (item.type === OPENAI_BLOCK.FILE) { + const name = item.file?.filename || item.file?.name || "file"; + pushText(`[file omitted: ${name} β€” Qoder reads documents via its file API, not inlined bytes]`); + } else if (item.type === CLAUDE_BLOCK.DOCUMENT) { + const name = item.title || "document"; + pushText(`[file omitted: ${name} β€” Qoder reads documents via its file API, not inlined bytes]`); } else if (typeof item.text === "string" && item.text) { - if (hasImage || blocks.length) { - // Keep ordering faithful once images are in play. - blocks.push({ type: OPENAI_BLOCK.TEXT, text: item.text }); - } else { - textParts.push(item.text); - } + pushText(item.text); } } @@ -190,7 +208,7 @@ function truncate(s, n) { /** * Map the OpenAI-style request body into the exact shape Qoder expects. */ -async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) { +async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null }) { const qoderKey = String(model || "").replace(/^qoder\//, ""); // Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP. @@ -209,7 +227,30 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio modelConfig = { ...retried, key: qoderKey }; } - const { messages, systemText } = normalizeMessages(body.messages || []); + const incoming = Array.isArray(body.messages) + ? body.messages.map((m) => { + if (!m || typeof m !== "object") return m; + return { + ...m, + content: Array.isArray(m.content) + ? m.content.map((b) => (b && typeof b === "object" ? { ...b } : b)) + : m.content, + }; + }) + : []; + try { + await rewriteQoderMessageAttachments(incoming, { + credentials, + log, + proxyOptions, + signal, + uploadFn, + }); + } catch (err) { + log?.warn?.("QODER", `attachment rewrite failed: ${err.message}`); + } + + const { messages, systemText } = normalizeMessages(incoming); const tools = body.tools; const isReasoning = !!modelConfig.is_reasoning; const maxOutputTokens = Number(modelConfig.max_output_tokens) || 0; @@ -228,7 +269,21 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio const sessionId = stableHash("qoder-session", psd.userId, qoderKey); const recordId = stableChatRecordId(qoderKey, messages, tools, maxTokens); - return { + // Context-window tier (200K/400K/1M): the IDE picks one from model_config.context_config; + // qodercli-style requests default to the smallest. Escalate when the prompt no longer fits. + const tierChoice = resolveQoderContextTier( + modelConfig, + { system: systemText, messages, tools }, + { preference: process.env[QODER_CONTEXT_TIER_ENV] }, + ); + if (tierChoice) { + log?.info?.( + "QODER", + `context tier ${tierChoice.tier.name} (${tierChoice.tier.tokenCount} tokens, ${tierChoice.reason}) for ~${tierChoice.estimatedTokens} prompt tokens`, + ); + } + + const built = { qoderKey, payload: { request_id: uuidv4(), @@ -276,6 +331,8 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio }, modelConfig, }; + if (tierChoice) applyQoderContextTier(built.payload, tierChoice.tier); + return built; } /** @@ -339,6 +396,11 @@ async function peekFirstQoderFrame(reader, decoder) { * response.text() which hangs until the socket closes β€” so on terminal * events we cancel the upstream reader and close our stream immediately. * + * Usage: Qoder puts finish_reason on `delta` and sends token counts on a + * later `choices: []` frame. Downstream OpenAI/Claude clients only read + * usage from the finish chunk, so we coalesce those two frames (see + * createQoderSseCoalescer) before forwarding. + * * NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl). * If detected, return 403 response so chatCore marks connection unavailable * and triggers combo fallback instead of leaking error text into chat. @@ -365,6 +427,11 @@ async function wrapQoderSSE(response, model) { const upstreamDrained = peek.upstreamDone === true; const encoder = new TextEncoder(); let doneEmitted = false; + const coalescer = createQoderSseCoalescer({ model, encoder, sseDone: SSE_DONE }); + + const syncDone = () => { + if (coalescer.doneEmitted) doneEmitted = true; + }; // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { @@ -375,15 +442,17 @@ async function wrapQoderSSE(response, model) { const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { - controller.enqueue(encoder.encode(SSE_DONE)); - doneEmitted = true; + coalescer.flush(controller); + syncDone(); return; } let envelope; try { envelope = JSON.parse(data); } catch { return; } const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; - const inner = typeof envelope.body === "string" ? envelope.body : ""; + const inner = typeof envelope.body === "string" + ? envelope.body + : envelope.body != null ? JSON.stringify(envelope.body) : ""; if (statusVal !== 200) { const msg = inner || `upstream status ${statusVal}`; const errChunk = JSON.stringify({ @@ -399,14 +468,8 @@ async function wrapQoderSSE(response, model) { return; } if (!inner) return; - if (inner === "[DONE]") { - controller.enqueue(encoder.encode(SSE_DONE)); - doneEmitted = true; - return; - } - // Strip embedded newlines so the SSE frame stays a single event. - const sanitized = inner.replace(/\r?\n/g, ""); - controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); + coalescer.handleInner(inner, controller); + syncDone(); }; const stream = new ReadableStream({ @@ -465,7 +528,7 @@ async function wrapQoderSSE(response, model) { } finally { if (!doneEmitted) { try { - controller.enqueue(encoder.encode(SSE_DONE)); + coalescer.flush(controller); doneEmitted = true; } catch { /* already closed */ } } @@ -494,13 +557,7 @@ export class QoderExecutor extends BaseExecutor { } buildUrl(credentials) { - // Job-token (jt-...) traffic must hit api2.qoder.sh β€” api3 rejects jt- - // with "Login expired" (403). Device tokens (dt-...) stay on api3. - const raw = credentials?.apiKey || credentials?.accessToken; - if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) { - return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; - } - return QODER_CHAT_URL_ENCODED; + return `${qoderInferenceBase(credentials)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; } // Override execute entirely β€” Qoder needs: diff --git a/open-sse/executors/xiaomi-mimo.js b/open-sse/executors/xiaomi-mimo.js new file mode 100644 index 00000000..8b69412a --- /dev/null +++ b/open-sse/executors/xiaomi-mimo.js @@ -0,0 +1,99 @@ +import { DefaultExecutor } from "./default.js"; +import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js"; + +// Desktop-exclusive Preview models. These are served by the account service's +// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key). +// See shared/mimoAccount.js for the session handshake. +const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]); + +// Session cookie resolved in execute() (async) and read back by buildHeaders() +// (sync β€” BaseExecutor.execute does not await it). Carried on the per-request +// credentials object, same as runtimeTransport. +const COOKIE_KEY = "__mimoAccountCookie"; + +// Upstream calls may hand us either the bare id or a `provider/model` ref. +function bareModel(model) { + const s = String(model || ""); + const i = s.indexOf("/"); + return i >= 0 ? s.slice(i + 1) : s; +} + +export class XiaomiMimoExecutor extends DefaultExecutor { + constructor() { + super("xiaomi-mimo"); + } + + static isPreviewModel(model) { + return PREVIEW_MODELS.has(bareModel(model)); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + // Preview models live on the account-service route, which is not one of the + // declared transports β€” resolve it before the default runtimeTransport path. + if (XiaomiMimoExecutor.isPreviewModel(model)) { + return `${MIMO_API_BASE}/api/route/chat/completions`; + } + // Cloud API models keep default handling, so a Claude-format client reaches + // the /anthropic/v1/messages transport. + return super.buildUrl(model, stream, urlIndex, credentials); + } + + buildHeaders(credentials, stream = true, url, model) { + if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) { + // Preview models authenticate with the account-session cookie, not the key. + return { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + "User-Agent": MIMO_API_UA, + Cookie: credentials[COOKIE_KEY], + }; + } + return super.buildHeaders(credentials, stream, url, model); + } + + transformRequest(model, body, stream, credentials) { + // super runs stripUnsupportedParams, which flattens Preview content-part + // arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js). + const out = super.transformRequest(model, body, stream, credentials); + + // Preview models: thinking/params get defaults only β€” never override what the + // caller set explicitly. (body.model is already `xiaomi/` via upstreamModelId.) + if (XiaomiMimoExecutor.isPreviewModel(model)) { + if (out.thinking == null) out.thinking = { type: "enabled" }; + if (out.temperature == null) out.temperature = 1.0; + if (out.top_p == null) out.top_p = 0.95; + if (!out.max_tokens) out.max_tokens = 4096; + } + + return out; + } + + async execute(args) { + const { model, credentials, proxyOptions = null } = args; + if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args); + + const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions); + if (!cookie) { + throw new Error( + "Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.", + ); + } + credentials[COOKIE_KEY] = cookie; + const result = await super.execute(args); + + // A cached session can expire early β€” drop it and retry once with a fresh one. + if (result.response.status === 401) { + invalidateMimoAccountCookieCache(); + const fresh = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions).catch(() => null); + if (fresh) { + credentials[COOKIE_KEY] = fresh; + return super.execute(args); + } + } + return result; + } +} + +export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY }; + +export default XiaomiMimoExecutor; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 82a2b9f7..76d599da 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -28,7 +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 { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js"; @@ -246,7 +246,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // 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)) { + // Provider-scoped via quirks (shouldDefaultClaudeToolType): only gateways that declare + // requireClaudeToolType get the explicit type. Applying it unconditionally breaks + // Claude-format endpoints that only accept the legacy typeless tool shape β€” DeepSeek's + // Anthropic-compatible endpoint 400s with "unknown variant `custom`" (#3905). + if (shouldDefaultClaudeToolType(provider, finalFormat, translatedBody.tools, PROVIDERS)) { translatedBody.tools = defaultClaudeToolType(translatedBody.tools); } diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index bc1fc858..2174f959 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -6,6 +6,7 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; +import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine, tokensForDetail, shouldPersistRequestDetail } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js"; @@ -305,6 +306,11 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m } } + // Unwrap before any consumer reads choices/usage so non-stream clients get a + // bare OpenAI body and usage tracking sees data.usage. No-op unless the + // provider opts in via transport.quirks.clineEnvelope. + responseBody = unwrapClineEnvelope(responseBody, provider); + reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody); if (onRequestSuccess) { Promise.resolve() diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 218302ab..afaf8e97 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -2,13 +2,21 @@ import { randomUUID } from "node:crypto"; import { nowSec } from "./_base.js"; import { PROVIDERS } from "../../config/providers.js"; +import { CODEX_CLI_VERSION } from "../../config/appConstants.js"; const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl; -const CODEX_USER_AGENT = "codex_cli_rs/0.136.0"; -const CODEX_VERSION = "0.136.0"; +const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLI_VERSION}`; const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_MODEL_SUFFIX = "-image"; const CODEX_REF_DETAIL = "high"; +const CODEX_IMAGES_MAIN_MODEL = "gpt-5.5"; +const CODEX_TOOL_IMAGE_MODELS = new Set([ + "gpt-image-1.5", + "gpt-image-2", + "gpt-image-2.5", + "gpt-image-2.5-flare", + "gpt-image-2.5-sunburst", +]); function decodeAccountId(idToken) { try { @@ -27,6 +35,13 @@ function stripImageSuffix(model) { return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model; } +function resolveCodexImageModels(model) { + if (CODEX_TOOL_IMAGE_MODELS.has(model)) { + return { responsesModel: CODEX_IMAGES_MAIN_MODEL, toolModel: model }; + } + return { responsesModel: stripImageSuffix(model), toolModel: null }; +} + function toDataUrl(input) { if (!input || typeof input !== "string") return null; if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input; @@ -157,7 +172,7 @@ export default { "originator": CODEX_ORIGINATOR, "session_id": randomUUID(), "user-agent": CODEX_USER_AGENT, - "version": CODEX_VERSION, + "version": CODEX_CLI_VERSION, "x-client-request-id": randomUUID(), }; }, @@ -167,21 +182,26 @@ export default { const single = toDataUrl(body.image); if (single) refs.push(single); const detail = body.image_detail || CODEX_REF_DETAIL; + const { responsesModel, toolModel } = resolveCodexImageModels(model); const imgTool = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase() }; + if (toolModel) { + imgTool.action = refs.length > 0 ? "edit" : "generate"; + imgTool.model = toolModel; + } if (body.size && body.size !== "") imgTool.size = body.size; if (body.quality && body.quality !== "") imgTool.quality = body.quality; if (body.background && body.background !== "") imgTool.background = body.background; return { - model: stripImageSuffix(model), + model: responsesModel, instructions: "", input: [{ type: "message", role: "user", content: buildContent(body.prompt, refs, detail) }], tools: [imgTool], - tool_choice: "auto", + tool_choice: toolModel ? { type: "image_generation" } : "auto", parallel_tool_calls: false, prompt_cache_key: randomUUID(), stream: true, store: false, - reasoning: null, + reasoning: toolModel ? { effort: "medium", summary: "auto" } : null, }; }, // Custom: codex parses SSE β†’ either pipe to client or collect b64 diff --git a/open-sse/handlers/videoCore.js b/open-sse/handlers/videoCore.js index 98d60157..45f14d86 100644 --- a/open-sse/handlers/videoCore.js +++ b/open-sse/handlers/videoCore.js @@ -2,6 +2,7 @@ import { createErrorResult } from "../utils/error.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { refreshTokenByProvider } from "../services/tokenRefresh.js"; import { PROVIDER_MEDIA } from "../providers/index.js"; +import { getVideoAdapter } from "./videoProviders/index.js"; // Upstream fetch deadline for video job submission/polling (the job itself is // async upstream β€” this only bounds the HTTP round-trip, not video rendering). @@ -94,21 +95,49 @@ export async function handleVideoProxyCore({ return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`); } - const method = requestId ? "GET" : "POST"; - const url = buildUpstreamUrl(config, action, requestId); + const adapter = getVideoAdapter(provider); const fetchSignal = combineSignals(signal, timeoutMs); - const doFetch = (token) => - fetch(url, { + // Default (xAI shape) request plan; adapters override URL/method/headers/body. + const defaultPlan = () => { + const method = requestId ? "GET" : "POST"; + return { method, - headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }), + url: buildUpstreamUrl(config, action, requestId), + headers: buildHeaders({ + token: credentials?.accessToken || credentials?.apiKey, + contentType: method === "POST" ? contentType : null, + idempotencyKey: method === "POST" ? idempotencyKey : null, + }), body: method === "POST" ? rawBody : undefined, - signal: fetchSignal, - }); + }; + }; + // Rebuilt per attempt so the auth retry below picks up the refreshed token. + const doFetch = async () => { + const plan = adapter + ? await adapter.buildRequest({ + config, action, requestId, rawBody, contentType, idempotencyKey, credentials, log, + token: credentials?.accessToken || credentials?.apiKey, + }) + : defaultPlan(); + if (plan.error) return { planError: plan.error }; + return { + response: await fetch(plan.url, { + method: plan.method, + headers: plan.headers, + body: plan.body, + signal: fetchSignal, + }), + }; + }; + + const method = requestId ? "GET" : "POST"; let upstream; try { - upstream = await doFetch(credentials?.accessToken || credentials?.apiKey); + const first = await doFetch(); + if (first.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${first.planError}`); + upstream = first.response; } catch (error) { if (error?.name === "AbortError" || error?.name === "TimeoutError") { return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`); @@ -136,7 +165,9 @@ export async function handleVideoProxyCore({ await upstream.body?.cancel?.(); } catch { /* noop */ } try { - upstream = await doFetch(credentials.accessToken || credentials.apiKey); + const retry = await doFetch(); + if (retry.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${retry.planError}`); + upstream = retry.response; } catch (error) { return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials)); } @@ -152,13 +183,25 @@ export async function handleVideoProxyCore({ return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`); } - // Success: pass the upstream JSON through untouched (request_id / status / video.url). + // Success: pass the upstream JSON through untouched (request_id / status / video.url), + // unless the adapter maps a provider-native shape onto it (Vertex operations). + let outBody = bodyText; + let outType = upstream.headers.get("content-type") || "application/json"; + if (adapter?.transformResponse) { + try { + outBody = JSON.stringify(adapter.transformResponse(JSON.parse(bodyText))); + outType = "application/json"; + } catch { + // Non-JSON or unexpected shape β€” fall back to the raw upstream body. + } + } + return { success: true, - response: new Response(bodyText, { + response: new Response(outBody, { status: upstream.status, headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", + "Content-Type": outType, "Access-Control-Allow-Origin": "*", }, }), diff --git a/open-sse/handlers/videoProviders/index.js b/open-sse/handlers/videoProviders/index.js new file mode 100644 index 00000000..28173972 --- /dev/null +++ b/open-sse/handlers/videoProviders/index.js @@ -0,0 +1,13 @@ +// Video provider adapters. +// +// Default (no adapter) = xAI shape: raw body forwarded to {baseUrl}/{action}, +// polled at {baseUrl}/{id}, upstream JSON passed through verbatim. +// A provider only needs an adapter when its wire format differs from that. +import openrouter from "./openrouter.js"; +import vertex from "./vertex.js"; + +const ADAPTERS = { openrouter, vertex }; + +export function getVideoAdapter(provider) { + return ADAPTERS[provider] || null; +} diff --git a/open-sse/handlers/videoProviders/openrouter.js b/open-sse/handlers/videoProviders/openrouter.js new file mode 100644 index 00000000..90198a39 --- /dev/null +++ b/open-sse/handlers/videoProviders/openrouter.js @@ -0,0 +1,39 @@ +// OpenRouter video jobs β€” https://openrouter.ai/docs/api/api-reference/videos +// +// Same async shape as xAI (POST β†’ { id, status }, GET β†’ status/unsigned_urls), +// two differences only: creation POSTs to the collection root (no `/generations` +// suffix) and the account headers come from the registry entry. +// Response bodies are passed through verbatim. + +// ponytail: generations only β€” OpenRouter has no edits/extensions endpoint today. +const SUPPORTED_ACTIONS = new Set(["generations"]); + +function headers(config, token) { + return { + Accept: "application/json", + ...(config.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +export default { + buildRequest({ config, action, requestId, rawBody, contentType, token }) { + const base = config.baseUrl.replace(/\/$/, ""); + + if (requestId) { + return { method: "GET", url: `${base}/${encodeURIComponent(requestId)}`, headers: headers(config, token) }; + } + if (!SUPPORTED_ACTIONS.has(action)) { + return { error: `OpenRouter video supports 'generations' only (got '${action}')` }; + } + if (contentType && !contentType.includes("application/json")) { + return { error: "OpenRouter video requires an application/json body" }; + } + return { + method: "POST", + url: base, + headers: { ...headers(config, token), "Content-Type": "application/json" }, + body: rawBody, + }; + }, +}; diff --git a/open-sse/handlers/videoProviders/vertex.js b/open-sse/handlers/videoProviders/vertex.js new file mode 100644 index 00000000..a4ff66c6 --- /dev/null +++ b/open-sse/handlers/videoProviders/vertex.js @@ -0,0 +1,159 @@ +// Vertex AI (Veo) video jobs. +// +// Vertex does NOT speak the OpenAI-ish /v1/videos shape, so unlike OpenRouter +// this adapter translates both directions: +// create β†’ POST {model}:predictLongRunning { instances[], parameters{} } β†’ { name } +// poll β†’ POST {model}:fetchPredictOperation { operationName } β†’ { done, response } +// Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation +// +// The operation name is a resource path (contains "/"), so it is base64url-encoded +// into the job id returned to the client β€” GET /v1/videos/{id} stays a flat path. +import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefresh.js"; + +const DEFAULT_LOCATION = "us-central1"; + +const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url"); + +// Operation name shape: projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}. +// Anchored and single-segment-per-field so a decoded path can never carry `..` or a +// host-changing prefix into the request URL. +const OPERATION_NAME_RE = /^projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/models\/[^/]+\/operations\/[^/]+$/; + +function modelPathOf(operationName) { + return operationName.slice(0, operationName.indexOf("/operations/")); +} + +function decodeJobId(id) { + const raw = String(id ?? ""); + // Buffer.from(x, "base64url") silently drops invalid characters instead of + // throwing, so only ids that re-encode byte-for-byte are accepted. + if (!raw || raw.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null; + const decoded = Buffer.from(raw, "base64url").toString("utf8"); + if (Buffer.from(decoded, "utf8").toString("base64url") !== raw) return null; + return OPERATION_NAME_RE.test(decoded) ? decoded : null; +} + +async function resolveAuth(credentials, log) { + const saJson = parseVertexSaJson(credentials?.apiKey); + const projectId = + saJson?.project_id || + credentials?.projectId || + credentials?.providerSpecificData?.projectId; + const location = credentials?.providerSpecificData?.location || DEFAULT_LOCATION; + + if (!projectId) { + return { error: "Vertex video requires a project_id β€” use Service Account JSON or set providerSpecificData.projectId" }; + } + + let token = credentials?.accessToken; + if (saJson) { + const minted = await refreshVertexToken(saJson, log); + if (!minted?.accessToken) return { error: "Vertex video: failed to mint access token from service account JSON" }; + token = minted.accessToken; + } + if (!token) return { error: "Vertex video requires Service Account JSON or an OAuth access token (raw API keys are not supported)" }; + + return { token, projectId, location }; +} + +/** OpenAI-ish video body β†’ Vertex predictLongRunning body. */ +function toVertexBody(body) { + const instance = { prompt: body.prompt }; + // Image-to-video: accept the Vertex-native shape or a bare data URL / base64 string. + const image = body.image ?? body.image_url; + if (image && typeof image === "object") { + instance.image = image; + } else if (typeof image === "string") { + const match = image.match(/^data:([^;]+);base64,(.*)$/s); + instance.image = match + ? { bytesBase64Encoded: match[2], mimeType: match[1] } + : { gcsUri: image }; + } + if (body.video && typeof body.video === "object") instance.video = body.video; + + const parameters = {}; + if (body.n != null) parameters.sampleCount = Number(body.n); + if (body.duration != null) parameters.durationSeconds = Number(body.duration); + if (body.aspect_ratio) parameters.aspectRatio = body.aspect_ratio; + if (body.resolution) parameters.resolution = body.resolution; + if (body.seed != null) parameters.seed = body.seed; + if (body.negative_prompt) parameters.negativePrompt = body.negative_prompt; + // Without storageUri Vertex returns inline base64 bytes; a GCS bucket keeps + // the poll response small and is what production callers want. + if (body.storage_uri) parameters.storageUri = body.storage_uri; + if (body.generate_audio != null) parameters.generateAudio = !!body.generate_audio; + + return { instances: [instance], ...(Object.keys(parameters).length ? { parameters } : {}) }; +} + +/** Vertex operation β†’ the async-job shape 9Router clients already poll for. */ +function fromVertexOperation(json) { + if (!json?.name) return json; + const id = encodeJobId(json.name); + if (json.error) { + return { id, request_id: id, status: "failed", error: json.error }; + } + if (!json.done) { + return { id, request_id: id, status: "pending" }; + } + const samples = + json.response?.videos || + json.response?.generateVideoResponse?.generatedSamples || + []; + const videos = samples.map((s) => ({ + url: s.gcsUri || s.video?.uri || s.uri || null, + b64_json: s.bytesBase64Encoded || s.video?.bytesBase64Encoded || null, + mime_type: s.mimeType || s.video?.mimeType || "video/mp4", + })); + return { id, request_id: id, status: "completed", video: videos[0] || null, videos }; +} + +export default { + async buildRequest({ config, action, requestId, rawBody, contentType, credentials, log }) { + if (contentType && !contentType.includes("application/json")) { + return { error: "Vertex video requires an application/json body" }; + } + + const auth = await resolveAuth(credentials, log); + if (auth.error) return { error: auth.error }; + const { token, projectId, location } = auth; + const base = (config.baseUrl || "https://aiplatform.googleapis.com").replace(/\/$/, ""); + const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` }; + + if (requestId) { + const operationName = decodeJobId(requestId); + if (!operationName) return { error: "Invalid Vertex video job id" }; + return { + method: "POST", + url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`, + headers, + body: JSON.stringify({ operationName }), + }; + } + + if (action !== "generations") { + // ponytail: Veo extend/edit go through generations with `video`/`image` in the body. + return { error: `Vertex video supports 'generations' only (got '${action}')` }; + } + + let body; + try { + body = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8")); + } catch { + return { error: "Invalid JSON body" }; + } + if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" }; + // Plain model id only β€” a path segment carrying "/" or ".." would rewrite the URL. + if (!/^[A-Za-z0-9._-]+$/.test(body.model)) return { error: "Invalid Vertex video model id" }; + if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" }; + + return { + method: "POST", + url: `${base}/v1/projects/${projectId}/locations/${location}/publishers/google/models/${body.model}:predictLongRunning`, + headers, + body: JSON.stringify(toVertexBody(body)), + }; + }, + + transformResponse: fromVertexOperation, +}; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 38309d39..a3ad42f6 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -209,7 +209,10 @@ export const PROVIDER_CAPABILITIES = { "glm-5.3-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 32000 }, "kimi-k3-1": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 32000 }, "deepseek-v4-pro": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 50000 }, - "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 50000 }, + // deepseek-v4.1-flash replaces v4-flash (dropped from the server list; + // the old endpoint still answers 200 but the published list is the + // contract). maxOutput 128000 per the server's product-config payload. + "deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 }, }, // Qoder β€” upstream exposes opaque internal ids (dfmodel, kmodel, …); the // registry `name` is display-only and capability lookup matches on the raw @@ -219,9 +222,9 @@ export const PROVIDER_CAPABILITIES = { // windows (GLM-5.3 / Kimi-K3 / Qwen3.8-Max claim 180K but accept more). // max_output_tokens arrives as 0 for every model, so outputs are // best-guess from the real model family. Vision tags below follow the - // upstream is_vl flag per explicit request, even though the executor - // currently sends image_urls:null (image pass-through over the agent_chat - // SSE protocol is unverified). reasoning:true on all of them β€” every model can + // upstream is_vl flag. The executor uploads inlined images to + // /api/v2/image/upload and leaves image_urls/chat_context.imageUrls null + // (same as qodercli). reasoning:true on all of them β€” every model can // reason; the upstream is_reasoning flag only drives model_config selection. // thinkingFormat keeps the true-model family for documentation/UI, but // thinkingCanDisable:false everywhere: the executor only forwards diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index ab1ee574..eba858e8 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -37,6 +37,7 @@ export default { }, usage: { quotaApiUrl: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:fetchAvailableModels`, + quotaSummaryApiUrl: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:retrieveUserQuotaSummary`, loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", }, diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js index 16ed8b3e..1f98bc20 100644 --- a/open-sse/providers/registry/api-airforce.js +++ b/open-sse/providers/registry/api-airforce.js @@ -20,6 +20,8 @@ export default { authModes: [ "apikey", ], + passthroughModels: true, + modelsFetcher: { url: "https://api.airforce/v1/models", type: "airforce-free" }, transport: { baseUrl: "https://api.airforce/v1/chat/completions", validateUrl: "https://api.airforce/v1/models", @@ -27,10 +29,11 @@ export default { "HTTP-Referer": "https://endpoint-proxy.local", "X-Title": "Endpoint Proxy", }, + forceStream: true, }, models: [ - { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, - { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, - { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + { id: "gpt-oss-120b", name: "GPT-OSS 120B (Free)", contextLength: 131072 }, + { id: "gpt-oss-20b", name: "GPT-OSS 20B (Free)", contextLength: 131072 }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code (Free)", contextLength: 262144 }, ], }; diff --git a/open-sse/providers/registry/cline.js b/open-sse/providers/registry/cline.js index cfa788c4..90b16c4b 100644 --- a/open-sse/providers/registry/cline.js +++ b/open-sse/providers/registry/cline.js @@ -14,12 +14,16 @@ export default { }, }, category: "oauth", + authModes: ["oauth"], + hasOAuth: true, transport: { baseUrl: "https://api.cline.bot/api/v1/chat/completions", headers: { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", }, + // Non-stream chat completions come back wrapped in {"success":true,"data":{...}} + quirks: { clineEnvelope: true }, tokenUrl: "https://api.cline.bot/api/v1/auth/token", refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", auth: { diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index 702054ac..fc21590f 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -14,7 +14,10 @@ export default { }, }, category: "oauth", - authModes: ["oauth", "apikey"], + // ClinePass authenticates with a plain API key from app.cline.bot/settings/api-keys + // (category "apikey"). The OAuth extension flow used by Cline does not issue + // tokens that the ClinePass API consumer endpoint accepts (HTTP 401) β€” see #2333. + authModes: ["apikey", "oauth"], hasOAuth: true, transport: { baseUrl: "https://api.cline.bot/api/v1/chat/completions", @@ -22,6 +25,8 @@ export default { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", }, + // Non-stream chat completions come back wrapped in {"success":true,"data":{...}} + quirks: { clineEnvelope: true }, auth: { combined: true, header: "Authorization", diff --git a/open-sse/providers/registry/codebuddy-cn.js b/open-sse/providers/registry/codebuddy-cn.js index fdb53e68..672a05d8 100644 --- a/open-sse/providers/registry/codebuddy-cn.js +++ b/open-sse/providers/registry/codebuddy-cn.js @@ -58,7 +58,9 @@ export default { // (endpoint returns 11102 "model service info not found"), plus // glm-5.0-turbo / minimax-m2.7 / kimi-k2.5 / hy3-preview / // deepseek-v3-2-volc (absent from the server list, though still answering - // 200) and hy3-x (paid tier, not used here). + // 200) and hy3-x (paid tier, not used here). deepseek-v4-flash removed + // 2026-09: replaced server-side by deepseek-v4.1-flash (same low/high/ + // xhigh efforts; endpoint still answers 200 but the list is the contract). // "-x" suffix = paid tier of the same model (free id rides the promo quota). { id: "hy3", name: "Hy3" }, { id: "hy4-preview", name: "Hy4-Preview" }, @@ -66,7 +68,7 @@ export default { { id: "glm-5.3-flash", name: "GLM-5.3-Flash" }, { id: "kimi-k3-1", name: "Kimi-K3" }, { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v4.1-flash", name: "DeepSeek-V4.1-Flash" }, ], oauth: { baseUrl: "https://copilot.tencent.com", diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 1909fe97..9eaafe74 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -1,5 +1,9 @@ import { withCodexReviewModels } from "../models/helpers.js"; +// Codex CLI version seen by OpenAI's backend β€” single source for the Version / +// User-Agent identity headers. Bump when the installed codex CLI is upgraded. +const CODEX_CLI_VERSION = "0.154.0"; + export default { id: "codex", priority: 30, @@ -34,9 +38,10 @@ export default { baseUrl: "https://chatgpt.com/backend-api/codex/responses", format: "openai-responses", forceStream: true, + cliVersion: CODEX_CLI_VERSION, headers: { originator: "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.136.0", + "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`, }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", @@ -60,6 +65,11 @@ export default { { id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, { id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" }, + { id: "gpt-image-2.5", name: "GPT Image 2.5", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, + { id: "gpt-image-2.5-flare", name: "GPT Image 2.5 Flare", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, + { id: "gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, + { id: "gpt-image-2", name: "GPT Image 2", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, + { id: "gpt-image-1.5", name: "GPT Image 1.5", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, { id: "gpt-5.6-sol-image", name: "GPT 5.6 Sol Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, { id: "gpt-5.6-terra-image", name: "GPT 5.6 Terra Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, { id: "gpt-5.6-luna-image", name: "GPT 5.6 Luna Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index bb8015b0..2c3a3e73 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -25,6 +25,21 @@ export default { reasoningInject: { scope: "all", }, + quirks: { + // DeepSeek's Anthropic-compatible endpoint + // (https://api.deepseek.com/anthropic/v1/messages) accepts ONLY the + // built-in web_search_* tools and rejects client-defined `custom` tools + // (MCP / Read / Bash / etc.) with HTTP 400 + // "tools[0]: unknown variant `custom`, expected + // `web_search_20250305` or `web_search_20260209`". + // + // Declaring this whitelist makes prepareClaudeRequest() forward only + // web_search_* tools and strip everything else before sending, so MCP / + // function tools are dropped instead of failing the whole request. + // DeepSeek's OpenAI-compatible transport is unaffected (targetFormat + // there is "openai", not "claude", so prepareClaudeRequest is not run). + claudeSupportedToolTypes: ["web_search_20250305", "web_search_20260209"], + }, }, // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. transports: [ diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index ab71e2e7..fb355d6d 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -123,7 +123,6 @@ import p119 from "./selfhosted-embedding.js"; import p120 from "./fish-audio.js"; import p121 from "./alitp-intl.js"; import p122 from "./xquik.js"; - export default [ p0, p1, diff --git a/open-sse/providers/registry/minimax-cn.js b/open-sse/providers/registry/minimax-cn.js index 19aa3127..268a7344 100644 --- a/open-sse/providers/registry/minimax-cn.js +++ b/open-sse/providers/registry/minimax-cn.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + requireClaudeToolType: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/providers/registry/minimax.js b/open-sse/providers/registry/minimax.js index 47b82c89..66c00958 100644 --- a/open-sse/providers/registry/minimax.js +++ b/open-sse/providers/registry/minimax.js @@ -22,6 +22,7 @@ export default { headers: { ...CLAUDE_API_HEADERS }, quirks: { dropOutputConfig: true, + requireClaudeToolType: true, }, reasoningInject: { scope: "all", diff --git a/open-sse/providers/registry/openai.js b/open-sse/providers/registry/openai.js index 9a1ca57b..4a5f0eb1 100644 --- a/open-sse/providers/registry/openai.js +++ b/open-sse/providers/registry/openai.js @@ -57,6 +57,9 @@ export default { { id: "whisper-1", name: "Whisper 1", params: ["language","response_format","temperature","prompt"], kind: "stt" }, { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe", params: ["language","response_format","temperature","prompt"], kind: "stt" }, { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "gpt-image-2.5", name: "GPT Image 2.5", params: ["n","size","quality","response_format"], kind: "image" }, + { id: "gpt-image-2.5-flare", name: "GPT Image 2.5 Flare", params: ["n","size","quality","response_format"], kind: "image" }, + { id: "gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst", params: ["n","size","quality","response_format"], kind: "image" }, { id: "gpt-image-1", name: "GPT Image 1", params: ["n","size","quality","response_format"], kind: "image" }, { id: "dall-e-3", name: "DALL-E 3", params: ["size","quality","style","response_format"], kind: "image" }, { id: "dall-e-2", name: "DALL-E 2", params: ["n","size","response_format"], kind: "image" }, diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c6673223..1d1d12c0 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -33,25 +33,36 @@ export default { { format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, ], + // supportedFormats follow the endpoint table in https://opencode.ai/docs/go/ models: [ + { id: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] }, + { id: "glm-5.3", name: "GLM 5.3", 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: "kimi-k3", name: "Kimi K3", 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: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] }, { 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"] }, { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.8-max", name: "Qwen 3.8 Max", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.8-flash", name: "Qwen 3.8 Flash", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, - // Muse Spark is served by /zen/go/v1/responses only β€” responses-only entry forces - // chatCore past the sourceFormat-matched transports into translation (see chatCore guard). + { id: "hy4-preview", name: "Hy4 Preview", supportedFormats: ["openai"] }, + { id: "hy3", name: "Hy3", supportedFormats: ["openai"] }, + // Served by /zen/go/v1/responses only β€” the responses-only entry forces chatCore + // past the sourceFormat-matched transports into translation (see chatCore guard). + { id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, ], diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index a0df2a52..68a92857 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -40,8 +40,11 @@ export default { { id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", params: ["n","size","quality","response_format"], kind: "image" }, { id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", params: ["n","size"], kind: "image" }, { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", params: ["n","size"], kind: "image" }, + { id: "google/veo-3.1", name: "Veo 3.1 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" }, + { id: "openai/sora-2-pro", name: "Sora 2 Pro (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" }, + { id: "bytedance/seedance-2.0", name: "Seedance 2.0 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" }, ], - serviceKinds: ["llm","embedding","tts","imageToText"], + serviceKinds: ["llm","embedding","tts","imageToText","video"], ttsConfig: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", defaultModel: "openai/gpt-4o-mini-tts", @@ -57,6 +60,12 @@ export default { baseUrl: "https://openrouter.ai/api/v1/images/generations", headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"}, }, + // Async video jobs (POST /videos β†’ { id, status }, GET /videos/{id} polls). + // Docs: https://openrouter.ai/docs/api/api-reference/videos + videoConfig: { + baseUrl: "https://openrouter.ai/api/v1/videos", + headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"}, + }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, }; diff --git a/open-sse/providers/registry/vertex.js b/open-sse/providers/registry/vertex.js index b8765de3..a1c60e60 100644 --- a/open-sse/providers/registry/vertex.js +++ b/open-sse/providers/registry/vertex.js @@ -27,6 +27,13 @@ export default { { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "veo-3.1-generate-preview", name: "Veo 3.1 (Preview)", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" }, + { id: "veo-3.1-fast-generate-preview", name: "Veo 3.1 Fast (Preview)", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" }, + { id: "veo-3.0-generate-001", name: "Veo 3", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" }, + { id: "veo-2.0-generate-001", name: "Veo 2", params: ["duration","aspect_ratio","negative_prompt","seed","storage_uri"], kind: "video" }, ], - serviceKinds: ["llm","imageToText"], + serviceKinds: ["llm","imageToText","video"], + // Veo via predictLongRunning + fetchPredictOperation (adapter: handlers/videoProviders/vertex.js). + // Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation + videoConfig: { baseUrl: "https://aiplatform.googleapis.com" }, }; diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js index 49465f43..cb0139b3 100644 --- a/open-sse/providers/registry/xiaomi-mimo.js +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -1,11 +1,19 @@ import { CLAUDE_API_HEADERS } from "../shared.js"; +// Dual auth (same pattern as kimi): +// - API key (sk-...) β†’ cloud API on api.xiaomimimo.com +// - Desktop account/OAuth β†’ same cloud host, plus the Desktop-exclusive Preview +// models served by the account-service route on mimo-server-cn.xiaomimimo.com +// (authorized by a Xiaomi account session cookie, not the key). +// Endpoint is picked per model in the executor, same as opencode-go's /responses split. export default { id: "xiaomi-mimo", priority: 290, alias: "xiaomi-mimo", aliases: [ "mimo", + "mimo-desktop", + "xmd", ], uiAlias: "mimo", display: { @@ -16,9 +24,12 @@ export default { website: "https://xiaomimimo.com", notice: { apiKeyUrl: "https://platform.xiaomimimo.com/console/api-keys", + signupUrl: "https://mimo.xiaomimimo.com/desktop/invite/", }, }, - category: "apikey", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, serviceKinds: ["llm", "tts"], transport: { baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", @@ -39,6 +50,11 @@ export default { }, ], models: [ + // Desktop-exclusive β€” served by the account-service route, which only accepts + // OpenAI format, so supportedFormats pins them to the openai transport. + { id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] }, + { id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] }, + // Cloud API models (api.xiaomimimo.com/v1) { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, { id: "mimo-v2.5", name: "MiMo V2.5" }, { id: "mimo-v2-omni", name: "MiMo V2 Omni" }, @@ -51,4 +67,18 @@ export default { authHeader: "bearer", format: "xiaomi-mimo-tts", }, + features: { + usage: true, + usageApikey: true, + }, + // Custom OAuth β€” non-standard ECDH encrypted-callback flow. + // Handled by the Xiaomi MiMo OAuth service, not the generic PKCE pipeline. + oauth: { + custom: true, + authorizeUrl: "https://platform.xiaomimimo.com/authorize", + // The callback carries ?u= instead of ?code=. + // Decryption yields { uid, sk, url }. + callbackParam: "u", + kn: "mimocode", + }, }; diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js index b60e15d3..b4e744f8 100644 --- a/open-sse/rtk/systemInject.js +++ b/open-sse/rtk/systemInject.js @@ -13,7 +13,7 @@ export function injectSystemPrompt(body, format, prompt) { if (!body || !prompt) return; if (typeof body !== "object") return; - // Kiro wire shape is unique (conversationState/systemPrompt) β€” handle directly. + // Kiro wire shape is unique (conversationState) β€” handle directly. if (isKiroBody(body) || format === FORMATS.KIRO) { injectKiroSystem(body, prompt); return; @@ -61,10 +61,13 @@ export function injectSystemPrompt(body, format, prompt) { 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"); + // A top-level `systemPrompt` used to be the marker, but the Kiro translator no + // longer emits it (kiro.dev rejects the field), so gate on the turn shape. + const historyTurn = Array.isArray(cs.history) + && cs.history.some(it => it && (it.userInputMessage || it.assistantResponseMessage)); + return historyTurn || !!(cs.currentMessage && cs.currentMessage.userInputMessage); } // Exact idempotency: prompt present as its own SEP-delimited segment (or the @@ -258,80 +261,33 @@ function injectGeminiSystem(body, prompt) { } // ---- 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. +// The prompt is appended to the first user turn's content β€” the same place the +// Kiro translator already mirrors the system text via its contentPrefix. +// +// A top-level `systemPrompt` is deliberately NOT written: the kiro.dev gateway +// answers any body carrying that field with +// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"} +// The translator stopped emitting it in v0.5.59, but this injector kept adding +// it back, so every kr/ model failed whenever an RTK prompt (caveman, ponytail) +// was active. 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 + 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; + } + if (!targetMsg) return; + + const content = typeof targetMsg.content === "string" ? targetMsg.content : ""; + const next = dedupStringAppend(content, prompt); + if (next === content) return; // already injected β€” idempotent across retries + try { targetMsg.content = next; } catch (_) { /* frozen/proxy fail-open */ } } catch (_) {} } diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 4208a4b6..0aa96ffe 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -19,12 +19,10 @@ function buildModelListHeaders(token, isApiKey) { } /** - * Fetch ClinePass live model catalog from Cline's /models endpoint. - * - * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) - * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + * Internal: fetch the raw model list from Cline's /models endpoint. + * Returns the parsed array or null on any failure. */ -export async function resolveClinepassModels(credentials) { +async function fetchClineRawModels(credentials) { const isApiKey = Boolean(credentials?.apiKey); const token = isApiKey ? credentials.apiKey : credentials?.accessToken; if (!token) return null; @@ -45,19 +43,53 @@ export async function resolveClinepassModels(credentials) { const json = await response.json(); const rawList = Array.isArray(json) ? json : json?.data; - if (!Array.isArray(rawList)) return null; - - const models = rawList - .filter((m) => typeof m?.id === "string" && m.id.startsWith("cline-pass/")) - .map((m) => ({ - id: m.id, - name: m.name || m.id, - })); - - return models.length ? { models } : null; + return Array.isArray(rawList) ? rawList : null; } catch { return null; } finally { clearTimeout(timer); } } + +/** + * Fetch ClinePass live model catalog from Cline's /models endpoint. + * Returns only models with the cline-pass/ prefix. + * + * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) + * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + */ +export async function resolveClinepassModels(credentials) { + const rawList = await fetchClineRawModels(credentials); + if (!rawList) return null; + + const models = rawList + .filter((m) => typeof m?.id === "string" && m.id.startsWith("cline-pass/")) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + })); + + return models.length ? { models } : null; +} + +/** + * Fetch Cline live model catalog from Cline's /models endpoint. + * Unlike resolveClinepassModels, this returns ALL models (including + * free-tier models like z-ai/glm-5.3-flash) without the cline-pass/ prefix filter. + * + * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) + * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + */ +export async function resolveClineModels(credentials) { + const rawList = await fetchClineRawModels(credentials); + if (!rawList) return null; + + const models = rawList + .filter((m) => typeof m?.id === "string" && m.id.trim() !== "") + .map((m) => ({ + id: m.id, + name: m.name || m.id, + })); + + return models.length ? { models } : null; +} diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index 572931e5..e9a7879b 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -343,6 +343,30 @@ export async function resolveQoderModels(credentials, options = {}) { } } +/** + * Every model key the chat endpoint accepts for this credential: the IDE-visible + * models first, then catalog entries flagged `enable:false` (hidden in the IDE + * picker, e.g. by an account policy, but still served by agent_chat_generation β€” + * see fetchQoderCatalogRaw). /v1/models uses this so the advertised list matches + * what the router will actually route instead of collapsing to one or two keys. + */ +export function routableQoderModels(catalog) { + if (!catalog) return []; + const out = []; + const seen = new Set(); + for (const m of catalog.models || []) { + if (!m?.id || seen.has(m.id)) continue; + seen.add(m.id); + out.push({ id: m.id, name: m.name || m.id, hidden: false }); + } + for (const [key, cfg] of catalog.rawConfigs || []) { + if (!key || seen.has(key)) continue; + seen.add(key); + out.push({ id: key, name: cfg?.display_name || key, hidden: true }); + } + return out; +} + export function invalidateQoderCatalog(credentials) { if (!credentials) return; catalogCache.delete(cacheKey(credentials)); diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index dbf11ac2..ed1f4f38 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -148,6 +148,8 @@ const REFRESH_HANDLERS = { "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), cline: (c, log) => refreshClineToken(c.refreshToken, log), + // ClinePass shares Cline's WorkOS auth endpoints, so the same refresh works. + clinepass: (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/usage.js b/open-sse/services/usage.js index 99085beb..4f458895 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -19,6 +19,7 @@ import { getCommandCodeUsage } from "./usage/commandcode.js"; import { getOpenCodeGoUsage } from "./usage/opencode-go.js"; import { getGroqUsage } from "./usage/groq.js"; import { getZedUsage } from "./usage/zed.js"; +import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js"; import { resolveQoderCredentials } from "./qoderModels.js"; import { getGlmUsage } from "./usage/glm.js"; import { @@ -64,6 +65,7 @@ const USAGE_HANDLERS = { commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + "xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { diff --git a/open-sse/services/usage/antigravity-weekly.js b/open-sse/services/usage/antigravity-weekly.js new file mode 100644 index 00000000..db92a224 --- /dev/null +++ b/open-sse/services/usage/antigravity-weekly.js @@ -0,0 +1,150 @@ +/** + * Antigravity weekly quota β€” best-effort retrieval from retrieveUserQuotaSummary. + * Failure never breaks existing per-model quota display. + */ + +import { U, parseResetTime, fetchWithTimeout } from "./shared.js"; +import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION } from "../../providers/shared.js"; + +// β€” Weekly quota summary config β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +const WEEKLY_CONFIG = { + ...U("antigravity"), + userAgent: ANTIGRAVITY_IDE_USER_AGENT, +}; + +// β€” Cache: TTL + in-flight dedup per project β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +const WEEKLY_CACHE_TTL_MS = 180_000; // 3 minutes +const weeklyCache = new Map(); // cacheKey -> { result, expiresAt } | { promise } + +function cacheKey(accessToken, projectId) { + return `${accessToken}::${projectId || ""}`; +} + +// Exported for tests only +export function _clearWeeklyCache() { + weeklyCache.clear(); +} + +// β€” Group-name to stable key mapping β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +const GROUP_MATCHERS = [ + { pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" }, + { pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" }, +]; + +/** + * Parse a retrieveUserQuotaSummary response into normalized weekly quotas. + * Pure function β€” safe to unit-test without network. + * + * @param {Object|null} data Raw JSON response + * @returns {Object} e.g. { gemini_weekly: { used, total, ... }, claude_gpt_weekly: { ... } } + */ +export function parseWeeklyQuotaSummary(data) { + if (!data || typeof data !== "object") return {}; + + // Groups may live at data.groups or data.quotaSummary.groups + const groups = Array.isArray(data.groups) + ? data.groups + : Array.isArray(data.quotaSummary?.groups) + ? data.quotaSummary.groups + : null; + + if (!groups) return {}; + + const result = {}; + + for (const group of groups) { + if (!group || typeof group !== "object") continue; + const displayName = group.displayName || ""; + + const buckets = Array.isArray(group.buckets) ? group.buckets : []; + for (const bucket of buckets) { + if (!bucket || typeof bucket !== "object") continue; + + // Identify weekly buckets by checking bucketId + displayName for "weekly" + const bucketText = `${bucket.bucketId || ""} ${bucket.displayName || ""}`.toLowerCase(); + if (!bucketText.includes("weekly")) continue; + + // Skip disabled buckets + if (bucket.disabled === true) continue; + + const remainingFraction = Number(bucket.remainingFraction); + if (!Number.isFinite(remainingFraction)) continue; + + // Match group to a known family + for (const matcher of GROUP_MATCHERS) { + if (matcher.pattern.test(displayName)) { + const total = 1000; + const remaining = Math.round(total * remainingFraction); + const used = Math.max(0, total - remaining); + + result[matcher.key] = { + used, + total, + resetAt: parseResetTime(bucket.resetTime), + remainingPercentage: remainingFraction * 100, + unlimited: false, + displayName: matcher.displayName, + }; + break; // first matching bucket per family wins + } + } + } + } + + return result; +} + +/** + * Fetch weekly quota summary β€” cached, deduped, never throws. + */ +export async function fetchAntigravityWeeklyQuota(accessToken, projectId, proxyOptions = null) { + const key = cacheKey(accessToken, projectId); + + // Serve in-flight or cached + const hit = weeklyCache.get(key); + if (hit?.promise) return hit.promise; + if (hit && hit.expiresAt > Date.now()) return hit.result; + + const promise = (async () => { + try { + const url = WEEKLY_CONFIG.quotaSummaryApiUrl; + if (!url) return {}; + + const response = await fetchWithTimeout(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": WEEKLY_CONFIG.userAgent, + "Content-Type": "application/json", + "X-Client-Name": "antigravity", + "X-Client-Version": ANTIGRAVITY_IDE_VERSION, + }, + body: JSON.stringify({ + ...(projectId ? { project: projectId } : {}), + }), + }, 10000, proxyOptions); + + if (!response.ok) return {}; + + const data = await response.json(); + return parseWeeklyQuotaSummary(data); + } catch { + return {}; + } + })(); + + weeklyCache.set(key, { promise }); + + try { + const result = await promise; + if (result && Object.keys(result).length > 0) { + weeklyCache.set(key, { result, expiresAt: Date.now() + WEEKLY_CACHE_TTL_MS }); + } else { + weeklyCache.delete(key); + } + return result; + } catch { + weeklyCache.delete(key); + return {}; + } +} diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index d45731ab..d93a0682 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.js @@ -102,32 +102,28 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { quotas["weekly (7d)"] = createQuotaObject(data.seven_day); } - // Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus, seven_day_fable) - const MODEL_DISPLAY_NAMES = { - fable_5_1: "fable", - fable_5: "fable", - }; - + // Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus) for (const [key, value] of Object.entries(data)) { if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) { - const rawName = key.replace("seven_day_", ""); - const modelName = MODEL_DISPLAY_NAMES[rawName] || rawName; + const modelName = key.replace("seven_day_", ""); quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value); - } else if ((key === "fable" || key === "fable_5" || key === "fable_5_1") && hasUtilization(value)) { - quotas["weekly fable (7d)"] = createQuotaObject(value); } } - // Fallback: surface Fable quota row if weekly window exists but Fable was not returned yet - if (!quotas["weekly fable (7d)"] && hasUtilization(data.seven_day)) { - quotas["weekly fable (7d)"] = { - used: 0, - total: 100, - remaining: 100, - remainingPercentage: 100, - resetAt: parseResetTime(data.seven_day.resets_at), - unlimited: false, - }; + // Model-scoped weekly limits (e.g. Fable) arrive in limits[], not as + // seven_day_* keys: { kind: "weekly_scoped", percent, resets_at, + // scope: { model: { display_name: "Fable" } } }. No limits entry means + // the account has no such window β€” omit the row, never fabricate one. + if (Array.isArray(data.limits)) { + for (const limit of data.limits) { + if (limit?.kind !== "weekly_scoped") continue; + const modelName = String(limit?.scope?.model?.display_name || "").trim().toLowerCase(); + if (!modelName || typeof limit.percent !== "number") continue; + quotas[`weekly ${modelName} (7d)`] = createQuotaObject({ + utilization: Math.max(0, Math.min(100, limit.percent)), + resets_at: limit.resets_at, + }); + } } return { diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index 9afbe4ca..736722a7 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -5,6 +5,7 @@ import { CLIENT_METADATA } from "../../config/appConstants.js"; import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION, ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js"; import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js"; +import { fetchAntigravityWeeklyQuota } from "./antigravity-weekly.js"; // Antigravity API config (from Quotio) β€” urls from registry, oauth client + dynamic UA kept here const ANTIGRAVITY_CONFIG = { @@ -157,8 +158,15 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro const data = await response.json(); const quotas = {}; - // Parse model quotas (inspired by vscode-antigravity-cockpit) - if (data.models) { + // Detect tier: free-tier accounts only have weekly quotas (no separate 5h window). + // On free-tier, fetchAvailableModels returns misleading per-model quota info + // (missing remainingFraction defaults to 0, or reflects the weekly limit not a 5h window). + const paidTierId = subscriptionInfo?.paidTier?.id; + const isFreeTier = !paidTierId || paidTierId === "free-tier"; + + // Parse model quotas only for paid-tier accounts. + // Free-tier accounts skip this β€” their only meaningful quota is the weekly limit. + if (!isFreeTier && data.models) { // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) const importantModels = [ 'gemini-3.8-flash-high', @@ -212,6 +220,56 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro } } + // Best-effort weekly quota overlay β€” never blocks or breaks per-model results + try { + const weeklyQuotas = await fetchAntigravityWeeklyQuota( + accessToken, + projectId, + proxyOptions + ); + + // Reconcile weekly quota against model family status: + // If every model in a family is locked/exhausted (remainingPercentage === 0) + // until a future reset time, the weekly limit cannot be 100% available. + // On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports + // remainingFraction: 1 even after the starter quota is depleted and all models 429. + const entries = Object.entries(quotas); + const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image")); + const claudeModels = entries.filter(([k]) => k.startsWith("claude-")); + + if (weeklyQuotas.gemini_weekly && geminiModels.length > 0) { + const allGeminiExhausted = geminiModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0); + if (allGeminiExhausted && weeklyQuotas.gemini_weekly.remainingPercentage > 0) { + const maxResetAt = geminiModels.reduce((max, [, q]) => + !max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null + ); + weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total; + weeklyQuotas.gemini_weekly.remainingPercentage = 0; + if (maxResetAt) { + weeklyQuotas.gemini_weekly.resetAt = maxResetAt; + } + } + } + + if (weeklyQuotas.claude_gpt_weekly && claudeModels.length > 0) { + const allClaudeExhausted = claudeModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0); + if (allClaudeExhausted && weeklyQuotas.claude_gpt_weekly.remainingPercentage > 0) { + const maxResetAt = claudeModels.reduce((max, [, q]) => + !max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null + ); + weeklyQuotas.claude_gpt_weekly.used = weeklyQuotas.claude_gpt_weekly.total; + weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0; + if (maxResetAt) { + weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt; + } + } + } + + Object.assign(quotas, weeklyQuotas); + } catch { + // Silently ignore β€” weekly is best-effort + } + return { plan: subscriptionInfo?.currentTier?.name || "Unknown", quotas, diff --git a/open-sse/services/usage/xiaomi-mimo.js b/open-sse/services/usage/xiaomi-mimo.js new file mode 100644 index 00000000..9d42fe37 --- /dev/null +++ b/open-sse/services/usage/xiaomi-mimo.js @@ -0,0 +1,125 @@ +/** + * Xiaomi MiMo usage β€” weekly quota from the Xiaomi account session. + * + * Primary path: GET {mimo-server}/api/user/usage authorized by the account-session + * cookie (see shared/mimoAccount.js). Response: { code: 0, data: { percent (remaining + * %), resetDate, resetAt } }. + * + * Fallback: the sk- API key cannot read the quota, so when no account session is + * available we surface a graceful message instead of failing. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { getMimoAccountUsage } from "../../shared/mimoAccount.js"; + +const USAGE_URL = "https://aistudio.xiaomimimo.com/open-apis/v1/user/usage"; + +/** + * @param {string|null|undefined} accessToken - sk- API key + * @param {object|null} providerSpecificData - may contain mimoPassToken, uid, etc. + * @param {object|null} proxyOptions + */ +export async function getXiaomiMimoUsage(accessToken = null, providerSpecificData = null, proxyOptions = null) { + // Preferred path: the weekly quota comes from the account service session + // (mimo-server /api/user/usage), which the sk- key cannot reach. The session is + // derived from MiMo Desktop's persisted passToken via the SSO/sts handshake. + const account = await getMimoAccountUsage(providerSpecificData, proxyOptions); + if (typeof account.percent === "number" && Number.isFinite(account.percent)) { + const remaining = Math.max(0, Math.min(100, Math.round(account.percent))); + const used = 100 - remaining; + let resetAt = null; + if (typeof account.resetAt === "number" && account.resetAt > 0) { + resetAt = new Date(account.resetAt * 1000).toISOString(); + } else if (typeof account.resetDate === "string") { + const parsed = new Date(`${account.resetDate}T00:00:00Z`); + if (!Number.isNaN(parsed.getTime())) resetAt = parsed.toISOString(); + } + return { + plan: "Xiaomi MiMo Desktop", + quotas: { + Weekly: { used, total: 100, remainingPercentage: remaining, resetAt, unlimited: false }, + }, + }; + } + + // Fallback: no account session available (Desktop never logged in, or its cookie + // store is locked). The sk- key cannot read the quota, so surface a clear message. + const key = accessToken || providerSpecificData?.apiKey; + if (!key || typeof key !== "string" || !key.trim()) { + return { message: "Xiaomi MiMo Desktop not connected. Add credentials to view usage." }; + } + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${key.trim()}`, + "X-Mimo-Source": "mimocode-cli", + Accept: "application/json", + }, + signal: AbortSignal.timeout(10000), + }, + proxyOptions, + ); + + if (response.status === 401) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Weekly quota requires Xiaomi account session. API key alone is insufficient.", + }; + } + + if (!response.ok) { + return { + plan: "Xiaomi MiMo Desktop", + message: `Usage API error (${response.status})`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || data.code !== 0 || !data.data) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Usage endpoint returned unexpected response.", + }; + } + + const { percent, resetDate } = data.data; + if (typeof percent !== "number" || !Number.isFinite(percent)) { + return { + plan: "Xiaomi MiMo Desktop", + message: "Usage data missing percent field.", + }; + } + + // percent = remaining percentage (e.g. 94 means 94% remaining) + const remaining = Math.max(0, Math.min(100, Math.round(percent))); + const used = 100 - remaining; + + // Parse resetDate β€” expected format "2026-09-16" + let resetAt = null; + if (resetDate && typeof resetDate === "string") { + const parsed = new Date(`${resetDate}T00:00:00Z`); + if (!Number.isNaN(parsed.getTime())) { + resetAt = parsed.toISOString(); + } + } + + return { + plan: "Xiaomi MiMo Desktop", + quotas: { + Weekly: { + used, + total: 100, + remainingPercentage: remaining, + resetAt, + unlimited: false, + }, + }, + }; + } catch (error) { + return { message: `Xiaomi MiMo Desktop usage error: ${error.message}` }; + } +} diff --git a/open-sse/shared/clineAuth.js b/open-sse/shared/clineAuth.js index 1b2b7df6..541b060d 100644 --- a/open-sse/shared/clineAuth.js +++ b/open-sse/shared/clineAuth.js @@ -6,7 +6,14 @@ export function getClineAccessToken(token) { if (typeof token !== "string") return ""; const trimmed = token.trim(); if (!trimmed) return ""; - return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`; + if (trimmed.toLowerCase().startsWith("workos:")) return trimmed; + // Cline OAuth access tokens are WorkOS JWTs (base64url `eyJ…` header). + // ClinePass API keys (category "apikey", e.g. `clp_…`) are NOT JWTs and must + // be sent verbatim β€” prefixing them with `workos:` makes the Cline API reject + // the request with HTTP 401 ("Please make sure you're using the latest + // version of Cline and re-authenticate your Cline account."). + const isWorkOsJwt = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(trimmed); + return isWorkOsJwt ? `workos:${trimmed}` : trimmed; } export function getClineAuthorizationHeader(token) { diff --git a/open-sse/shared/clineEnvelope.js b/open-sse/shared/clineEnvelope.js new file mode 100644 index 00000000..3873b746 --- /dev/null +++ b/open-sse/shared/clineEnvelope.js @@ -0,0 +1,19 @@ +import { PROVIDERS } from "../providers/index.js"; + +/** + * Unwrap Cline's non-stream envelope: {"success":true,"data":{...choices...}}. + * + * Scoped to providers opting in via `transport.quirks.clineEnvelope` so no other + * provider's body is ever rewritten. The error envelope ({"success":false,...}) + * never matches and passes through untouched. + * + * @param {object} body - Parsed upstream response body + * @param {string} provider - Provider id or alias + * @returns {object} The inner `data` object, or `body` unchanged + */ +export function unwrapClineEnvelope(body, provider) { + if (!provider || !PROVIDERS[provider]?.quirks?.clineEnvelope) return body; + const { success, data } = body || {}; + if (success !== true || !data || typeof data !== "object" || Array.isArray(data)) return body; + return data; +} diff --git a/open-sse/shared/mimoAccount.js b/open-sse/shared/mimoAccount.js new file mode 100644 index 00000000..996f38df --- /dev/null +++ b/open-sse/shared/mimoAccount.js @@ -0,0 +1,264 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +/** + * Xiaomi MiMo account-session helpers (used for weekly quota). + * + * The weekly quota endpoint lives on the account service domain and is authorized + * by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors + * MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via + * the passportapi SSO, then authorized for the `mimopc` service, and finally stamped + * by the mimo-server /api/sts callback into a `serviceToken` cookie. + * + * Flow (verified against MiMo Desktop traffic): + * 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc) + * 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity + * 3. GET {location}&clientSign=... -> account-level serviceToken + * 4. GET account /pass/serviceLogin?sid=mimopc&callback=&_json=true + * 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope) + */ + +const API_BASE = "https://mimo-server-cn.xiaomimimo.com"; +const ACCOUNT_HOST = "account.xiaomi.com"; +const API_UA = + "miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"; +const SSO_UA = "MiClaw/1.0"; +const COOKIE_TTL_MS = 30 * 60 * 1000; + +// Per-account session caches (keyed by passToken hash) so multiple Xiaomi +// accounts / connections can rotate without clobbering each other. +const _cache = new Map(); // key -> { cookie, at } +const _inflight = new Map(); // key -> Promise + +function desktopCookiePath() { + const home = os.homedir(); + if (process.platform === "win32") { + return path.join(home, "AppData", "Roaming", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); + } + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); + } + return path.join(home, ".config", "Xiaomi MiMo", "Partitions", "xiaomi-account", "Network", "Cookies"); +} + +/** + * Read the persisted Xiaomi account cookies from MiMo Desktop's Electron profile. + * The Chromium cookie DB is held with an exclusive lock while Desktop runs, so we + * copy it first and bail (return null) if that fails. + * @returns {Promise|null>} + */ +async function readDesktopAccountCookies() { + const src = desktopCookiePath(); + if (!fs.existsSync(src)) return null; + const tmp = path.join(os.tmpdir(), `9r-mimo-cookies-${process.pid}-${crypto.randomBytes(4).toString("hex")}.db`); + try { + fs.copyFileSync(src, tmp); + } catch { + return null; // locked by a running Desktop + } + try { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(tmp, { readOnly: true }); + const rows = db.prepare("SELECT name, value FROM cookies WHERE host_key = ?").all("." + ACCOUNT_HOST); + db.close(); + const jar = Object.fromEntries(rows.map((r) => [r.name, r.value])); + return jar.passToken ? jar : null; + } catch { + return null; + } finally { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + } +} + +/** + * Read just the passToken + identity cookies from Desktop's profile. + * Exported so the connect flow can persist a per-account passToken into the + * connection's providerSpecificData β€” this is what enables multi-account rotation. + * @returns {Promise<{passToken:string, userId:string|null, cUserId:string|null}|null>} + */ +export async function readDesktopPassToken() { + try { + const jar = await readDesktopAccountCookies(); + if (!jar?.passToken) return null; + return { passToken: jar.passToken, userId: jar.userId || null, cUserId: jar.cUserId || null }; + } catch { + return null; + } +} + +function signatureClientSign(nonce, ssecurity) { + const input = `nonce=${nonce}` + (ssecurity && ssecurity.trim() ? `&${ssecurity}` : ""); + return encodeURIComponent(crypto.createHash("sha1").update(input).digest("base64")); +} + +function absorbSetCookie(jar, res) { + for (const c of res.headers.getSetCookie?.() || []) { + const m = /^([^=]+)=([^;]*)/.exec(c.trim()); + if (m && m[2]) jar[m[1]] = m[2]; + } +} + +function cookieHeader(jar) { + return Object.entries(jar) + .filter(([, v]) => v) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +/** + * Exchange a passToken for a mimo-server service session cookie. + * @returns {Promise} Cookie header value, or null on failure. + */ +async function acquireServiceCookie(passJar, proxyOptions) { + const jar = { ...passJar }; + const ck = () => cookieHeader(jar); + + // 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc) + const r1 = await proxyAwareFetch( + `${API_BASE}/api/user/xiaomi/me`, + { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, + proxyOptions, + ); + const redirect = r1.headers.get("location"); + if (!redirect) return null; + const stsCallback = new URL(redirect).searchParams.get("callback"); + if (!stsCallback) return null; + + // 2. passportapi SSO phase 1 -> nonce + ssecurity + const sso1 = await proxyAwareFetch( + `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`, + { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, + proxyOptions, + ); + const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, "")); + const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null); + if (!nonce || !j1.location) return null; + + // 3. passportapi SSO phase 2 -> account-level serviceToken + const sso2 = await proxyAwareFetch( + `${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`, + { redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } }, + proxyOptions, + ); + absorbSetCookie(jar, sso2); + + // 4. mimopc SSO -> sts callback carrying a ticket + const sso3 = await proxyAwareFetch( + `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`, + { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, + proxyOptions, + ); + const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, "")); + absorbSetCookie(jar, sso3); + if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null; + + // 5. sts callback -> Set-Cookie: serviceToken (mimopc scope) + const sts = await proxyAwareFetch( + j3.location, + { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, + proxyOptions, + ); + absorbSetCookie(jar, sts); + + const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"]; + if (!jar.serviceToken) return null; + const out = {}; + for (const k of needed) if (jar[k]) out[k] = jar[k]; + return cookieHeader(out); +} + +/** + * Get (and cache) the mimo-server account cookie. + * @param {object|null} providerSpecificData - may carry `mimoPassToken` override + */ +async function getServiceCookie(providerSpecificData, proxyOptions) { + const passJar = providerSpecificData?.mimoPassToken + ? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId } + : await readDesktopAccountCookies(); + if (!passJar) return { cookie: null, reason: "no-pass-token" }; + + // One cached session per passToken β€” accounts/connections rotate independently. + const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex"); + + const cached = _cache.get(key); + if (cached && Date.now() - cached.at < COOKIE_TTL_MS) { + return { cookie: cached.cookie }; + } + + // De-dupe concurrent handshakes for the same account: a burst of requests must + // not each run the full 5-step SSO chain. + const inflight = _inflight.get(key); + if (inflight) { + const cookie = await inflight; + return cookie ? { cookie } : { cookie: null, reason: "sso-failed" }; + } + + const promise = (async () => { + try { + return await acquireServiceCookie(passJar, proxyOptions); + } catch { + return null; // network/parse failure β€” callers degrade, never throw + } finally { + _inflight.delete(key); + } + })(); + _inflight.set(key, promise); + + const cookie = await promise; + if (!cookie) return { cookie: null, reason: "sso-failed" }; + _cache.set(key, { cookie, at: Date.now() }); + return { cookie }; +} + +/** Drop cached sessions so the next call re-runs the handshake (e.g. after a 401). */ +export function invalidateMimoAccountCookieCache() { + _cache.clear(); +} + +/** mimo-server account API base + the User-Agent its backend expects. */ +export const MIMO_API_BASE = API_BASE; +export const MIMO_API_UA = API_UA; + +/** + * Resolve the mimo-server account-session cookie, for upstream /api/route/* calls. + * @returns {Promise} Cookie header value, or null when unavailable. + */ +export async function getMimoAccountCookie(providerSpecificData = null, proxyOptions = null) { + try { + const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions); + return cookie; + } catch { + return null; + } +} + +/** + * Fetch the weekly quota from the account service. + * @returns {Promise<{percent?:number, resetDate?:string, resetAt?:number, error?:string}>} + */ +export async function getMimoAccountUsage(providerSpecificData = null, proxyOptions = null) { + const { cookie, reason } = await getServiceCookie(providerSpecificData, proxyOptions); + if (!cookie) { + return { error: reason === "no-pass-token" ? "no-session" : "session-failed" }; + } + try { + const res = await proxyAwareFetch( + `${API_BASE}/api/user/usage`, + { headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) }, + proxyOptions, + ); + if (!res.ok) return { error: `http-${res.status}` }; + const data = await res.json().catch(() => null); + if (!data || data.code !== 0 || !data.data) return { error: "bad-response" }; + return { percent: data.data.percent, resetDate: data.data.resetDate, resetAt: data.data.resetAt }; + } catch (e) { + return { error: e.message }; + } +} diff --git a/open-sse/shared/qoder/attachments.js b/open-sse/shared/qoder/attachments.js new file mode 100644 index 00000000..d2024529 --- /dev/null +++ b/open-sse/shared/qoder/attachments.js @@ -0,0 +1,341 @@ +/** + * Native qodercli does NOT stuff image/PDF bytes into agent_chat_generation. + * It PUTs them to /algo/api/v2/image/upload (COSY-signed multipart) and then + * sends the returned OSS URL. Agents like Claude Code send OpenAI/Claude + * data-URIs instead, which 9router previously forwarded verbatim β€” 10MB + * images become 30MB+ JSON and upstream 413s even though the model window + * is ~200k tokens. + * + * This module: + * 1. Uploads inlined images to Qoder's file API (cached by sha256). + * 2. Replaces huge non-image file blocks with a short stub. + * 3. Caps leftover data-URIs so the chat JSON stays small. + */ + +import { createHash } from "crypto"; +import { v4 as uuidv4 } from "uuid"; + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseDataUri } from "../../translator/concerns/image.js"; +import { OPENAI_BLOCK, CLAUDE_BLOCK } from "../../translator/schema/blocks.js"; +import { MAX_IMAGE_BYTES } from "../../config/mediaConfig.js"; +import { buildCosyHeaders } from "./cosy.js"; +import { + QODER_IMAGE_UPLOAD_SIG_PATH, + QODER_INLINE_FALLBACK_MAX_BYTES, + QODER_MAX_PAYLOAD_BYTES, + qoderInferenceBase, +} from "./constants.js"; + +const IMAGE_MIME_RE = /^image\//i; +const DATA_URI_RE = /data:[^;]+;base64,[A-Za-z0-9+/=\s]+/g; + +function mimeExt(mime) { + const m = String(mime || "").toLowerCase(); + if (m.includes("png")) return "png"; + if (m.includes("jpeg") || m.includes("jpg")) return "jpg"; + if (m.includes("gif")) return "gif"; + if (m.includes("webp")) return "webp"; + if (m.includes("bmp")) return "bmp"; + if (m.includes("pdf")) return "pdf"; + return "bin"; +} + +function decodedBytes(b64) { + if (typeof b64 !== "string" || !b64) return 0; + const compact = b64.replace(/\s/g, ""); + return Math.floor(compact.length * 3 / 4); +} + +function stubText({ name, mime, bytes, reason }) { + const label = name || mime || "attachment"; + const size = bytes ? `, ${bytes} bytes` : ""; + return `[file omitted: ${label}${size} β€” ${reason}]`; +} + +export function buildMultipartFile(buffer, { fieldName = "file", fileName, mediaType } = {}) { + const boundary = `----9routerQoder${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`; + const filename = fileName || `upload.${mimeExt(mediaType)}`; + const head = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${mediaType || "application/octet-stream"}\r\n\r\n`, + ); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`); + const body = Buffer.concat([head, buffer, tail]); + return { boundary, body }; +} + +function extractUrlFromUploadResponse(json) { + if (!json || typeof json !== "object") return null; + const result = json.result && typeof json.result === "object" ? json.result : json; + const arrays = [result.imageUrls, result.image_urls, json.imageUrls, json.image_urls]; + for (const arr of arrays) { + if (Array.isArray(arr) && typeof arr[0] === "string" && arr[0]) return arr[0]; + } + const keys = ["imageUrl", "image_url", "url", "ossUrl", "oss_url", "originalUrl", "originUrl", "link", "image"]; + for (const key of keys) { + const v = result[key] ?? json[key]; + if (typeof v === "string" && v) return v; + } + if (typeof json.body === "string") { + try { return extractUrlFromUploadResponse(JSON.parse(json.body)); } catch { /* ignore */ } + } + return null; +} + +async function defaultUploadImage({ buffer, mediaType, credentials, proxyOptions, signal }) { + const requestId = uuidv4(); + const url = `${qoderInferenceBase(credentials)}${`/algo${QODER_IMAGE_UPLOAD_SIG_PATH}`}?request_id=${requestId}`; + const { boundary, body } = buildMultipartFile(buffer, { + fileName: `image.${mimeExt(mediaType)}`, + mediaType: mediaType || "application/octet-stream", + }); + const psd = credentials?.providerSpecificData || {}; + const cosyHeaders = buildCosyHeaders(body, url, { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }); + const headers = { + ...cosyHeaders, + Accept: "application/json", + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Length": String(body.length), + "AI-CLIENT-TIMESTAMP": String(Math.floor(Date.now() / 1000)), + "Accept-Encoding": "identity", + }; + const res = await proxyAwareFetch( + url, + { method: "PUT", headers, body, signal }, + proxyOptions, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`); + } + const json = await res.json().catch(() => null); + const uploaded = extractUrlFromUploadResponse(json); + if (!uploaded) throw new Error("upload response missing url"); + return uploaded; +} + +async function uploadImageData({ base64, mediaType, credentials, proxyOptions, signal, log, uploadFn, cache }) { + const compact = String(base64 || "").replace(/\s/g, ""); + if (!compact) return null; + const bytes = decodedBytes(compact); + if (bytes > MAX_IMAGE_BYTES) { + log?.warn?.("QODER", `image ${bytes} bytes exceeds upload cap, stubbing`); + return { stub: true, bytes, mime: mediaType }; + } + let buffer; + try { + buffer = Buffer.from(compact, "base64"); + } catch { + return { stub: true, bytes, mime: mediaType }; + } + const digest = createHash("sha256").update(buffer).digest("hex"); + if (cache?.has(digest)) return { url: cache.get(digest), bytes, mime: mediaType }; + + const doUpload = uploadFn || defaultUploadImage; + try { + const url = await doUpload({ buffer, mediaType, credentials, proxyOptions, signal }); + if (typeof url === "string" && url) { + cache?.set(digest, url); + return { url, bytes, mime: mediaType }; + } + } catch (err) { + log?.warn?.("QODER", `image upload failed (${err.message}); ${bytes <= QODER_INLINE_FALLBACK_MAX_BYTES ? "keeping inline" : "stubbing"}`); + } + if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return { keep: true, bytes, mime: mediaType }; + return { stub: true, bytes, mime: mediaType }; +} + +function imageUrlBlock(url) { + return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } }; +} + +async function rewriteBlock(block, ctx) { + if (!block || typeof block !== "object") return block; + + if (block.type === OPENAI_BLOCK.IMAGE_URL) { + const raw = typeof block.image_url === "string" ? block.image_url : block.image_url?.url; + if (typeof raw !== "string" || !raw) return null; + if (raw.startsWith("http://") || raw.startsWith("https://")) return imageUrlBlock(raw); + const parsed = parseDataUri(raw); + if (!parsed) return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "attachment", reason: "unreadable data URI" }) }; + if (!IMAGE_MIME_RE.test(parsed.mimeType)) { + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "file", mime: parsed.mimeType, bytes: decodedBytes(parsed.base64), reason: "non-image bytes are not inlined into Qoder context" }) }; + } + const up = await uploadImageData({ ...ctx, base64: parsed.base64, mediaType: parsed.mimeType }); + if (up?.url) return imageUrlBlock(up.url); + if (up?.keep) return imageUrlBlock(raw); + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", mime: parsed.mimeType, bytes: up?.bytes, reason: "upload failed; not inlined" }) }; + } + + if (block.type === OPENAI_BLOCK.IMAGE || block.type === CLAUDE_BLOCK.IMAGE) { + const src = block.source || {}; + if (src.type === "url" && typeof src.url === "string") return imageUrlBlock(src.url); + if (src.type === "base64" && src.data) { + const mime = src.media_type || "image/png"; + const up = await uploadImageData({ ...ctx, base64: src.data, mediaType: mime }); + if (up?.url) return imageUrlBlock(up.url); + if (up?.keep) return imageUrlBlock(`data:${mime};base64,${src.data}`); + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", mime, bytes: up?.bytes, reason: "upload failed; not inlined" }) }; + } + } + + if (block.type === OPENAI_BLOCK.FILE && block.file) { + const file = block.file; + const name = file.filename || file.name || "file"; + const dataUri = typeof file.file_data === "string" ? file.file_data : null; + const parsed = dataUri ? parseDataUri(dataUri) : null; + const b64 = parsed?.base64 || (typeof file.file_data === "string" && !file.file_data.startsWith("data:") ? file.file_data : null); + const mime = parsed?.mimeType || file.format || "application/octet-stream"; + if (b64 && IMAGE_MIME_RE.test(mime)) { + const up = await uploadImageData({ ...ctx, base64: b64, mediaType: mime }); + if (up?.url) return imageUrlBlock(up.url); + } + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name, mime, bytes: decodedBytes(b64 || ""), reason: "Qoder reads documents via its file API, not inlined bytes" }) }; + } + + if (block.type === CLAUDE_BLOCK.DOCUMENT && block.source) { + const src = block.source; + const name = block.title || "document"; + if (src.type === "base64" && src.data) { + const mime = src.media_type || "application/pdf"; + if (IMAGE_MIME_RE.test(mime)) { + const up = await uploadImageData({ ...ctx, base64: src.data, mediaType: mime }); + if (up?.url) return imageUrlBlock(up.url); + } + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name, mime, bytes: decodedBytes(src.data), reason: "Qoder reads documents via its file API, not inlined bytes" }) }; + } + } + + if (typeof block.text === "string" && block.text.includes("data:") && block.text.length > 8192) { + const next = block.text.replace(DATA_URI_RE, (m) => { + const parsed = parseDataUri(m.trim()); + const bytes = parsed ? decodedBytes(parsed.base64) : m.length; + if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return m; + return stubText({ mime: parsed?.mimeType, bytes, reason: "inlined data URI stripped from Qoder context" }); + }); + return { ...block, text: next }; + } + + return block; +} + +async function rewriteContent(content, ctx) { + if (typeof content === "string") { + if (content.includes("data:") && content.length > 8192) { + return content.replace(DATA_URI_RE, (m) => { + const parsed = parseDataUri(m.trim()); + const bytes = parsed ? decodedBytes(parsed.base64) : m.length; + if (bytes <= QODER_INLINE_FALLBACK_MAX_BYTES) return m; + return stubText({ mime: parsed?.mimeType, bytes, reason: "inlined data URI stripped from Qoder context" }); + }); + } + return content; + } + if (!Array.isArray(content)) return content; + const out = []; + for (const block of content) { + const next = await rewriteBlock(block, ctx); + if (next == null) continue; + out.push(next); + } + return out.length ? out : ""; +} + +function payloadBytes(messages) { + try { + return Buffer.byteLength(JSON.stringify(messages), "utf8"); + } catch { + return 0; + } +} + +function stripRemainingDataUris(messages) { + for (const msg of messages || []) { + if (typeof msg?.content === "string" && msg.content.includes("data:")) { + msg.content = msg.content.replace(DATA_URI_RE, (m) => + stubText({ bytes: m.length, reason: "payload over Qoder size budget" }), + ); + } else if (Array.isArray(msg?.content)) { + msg.content = msg.content.map((block) => { + if (block?.type === OPENAI_BLOCK.IMAGE_URL) { + const raw = typeof block.image_url === "string" ? block.image_url : block.image_url?.url; + if (typeof raw === "string" && raw.startsWith("data:")) { + return { type: OPENAI_BLOCK.TEXT, text: stubText({ name: "image", reason: "payload over Qoder size budget" }) }; + } + } + if (typeof block?.text === "string" && block.text.includes("data:")) { + return { ...block, text: block.text.replace(DATA_URI_RE, (m) => + stubText({ bytes: m.length, reason: "payload over Qoder size budget" }), + ) }; + } + return block; + }); + } + } +} + +/** + * Rewrite OpenAI-shaped messages in place: upload images, stub huge files. + * @returns {Promise<{imageUrls: string[], uploaded: number, stubbed: number}>} + */ +export async function rewriteQoderMessageAttachments(messages, { + credentials, + log, + proxyOptions = null, + signal = null, + uploadFn = null, +} = {}) { + const stats = { imageUrls: [], uploaded: 0, stubbed: 0 }; + if (!Array.isArray(messages) || messages.length === 0) return stats; + + const ctx = { credentials, log, proxyOptions, signal, uploadFn, cache: new Map() }; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + if (Array.isArray(msg.images)) { + // Ollama-style sidecar; fold into content so normalizeMessages can see them. + const extras = msg.images.map((url) => imageUrlBlock(String(url))); + msg.content = Array.isArray(msg.content) + ? [...msg.content, ...extras] + : [{ type: OPENAI_BLOCK.TEXT, text: typeof msg.content === "string" ? msg.content : "" }, ...extras]; + delete msg.images; + } + msg.content = await rewriteContent(msg.content, ctx); + } + + // Collect surviving http(s) image URLs for callers that want image_urls. + for (const msg of messages) { + if (!Array.isArray(msg?.content)) continue; + for (const block of msg.content) { + const url = block?.type === OPENAI_BLOCK.IMAGE_URL + ? (typeof block.image_url === "string" ? block.image_url : block.image_url?.url) + : null; + if (typeof url === "string" && /^https?:\/\//i.test(url)) stats.imageUrls.push(url); + if (block?.type === OPENAI_BLOCK.TEXT && typeof block.text === "string" && block.text.startsWith("[file omitted:")) stats.stubbed += 1; + } + } + stats.uploaded = stats.imageUrls.length; + + if (payloadBytes(messages) > QODER_MAX_PAYLOAD_BYTES) { + log?.warn?.("QODER", `request still ${payloadBytes(messages)} bytes after rewrite; stripping leftover data URIs`); + stripRemainingDataUris(messages); + } + + return stats; +} + +/** Test helper kept for callers; upload memo is now per-request. */ +export function clearQoderUploadCache() {} + +export const __test__ = { + extractUrlFromUploadResponse, + decodedBytes, + stubText, + payloadBytes, +}; diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 861e8f30..849f67ed 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -33,6 +33,39 @@ export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generatio export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`; export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`; +// Official qodercli uploads images here (COSY-signed PUT multipart, field "file") +// instead of inlining base64 into agent_chat_generation. +export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload"; + +// Drop remaining inlined binaries if the Qoder JSON body would still exceed this. +// 30MB+ payloads are what blow past Claude-Code's ~200k context on the wire. +export const QODER_MAX_PAYLOAD_BYTES = 6 * 1024 * 1024; +// If OSS upload fails, keep tiny data-URIs; anything larger is stubbed. +export const QODER_INLINE_FALLBACK_MAX_BYTES = 512 * 1024; + +// Context-window tier selection (see shared/qoder/contextTier.js). The IDE exposes the +// model's context_config tiers (200K/400K/1M); we auto-escalate when the estimated prompt +// (+ headroom, tokenizer variance) no longer fits the current max_input_tokens. +export const QODER_CONTEXT_TIER_HEADROOM = 0.15; +export const QODER_CONTEXT_TIER_ENV = "QODER_CONTEXT_TIER"; +export const QODER_CONTEXT_TIER_MODES = Object.freeze({ AUTO: "auto", MAX: "max", DEFAULT: "default" }); + +/** + * Job-token (jt-...) traffic must hit api2.qoder.sh β€” api3 rejects jt- with + * "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...) + * are exchanged for jt- before this is consulted. + */ +export function qoderInferenceBase(credentials) { + const raw = credentials?.apiKey || credentials?.accessToken; + if ( + typeof raw === "string" && + !raw.startsWith("pt-") && + (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-")) + ) { + return QODER_CHAT_BASE_ALT; + } + return QODER_CHAT_BASE; +} // COSY header constants. These are not arbitrary β€” the upstream signature // validation matches them against the values used at signing time. diff --git a/open-sse/shared/qoder/contextTier.js b/open-sse/shared/qoder/contextTier.js new file mode 100644 index 00000000..523c2fe5 --- /dev/null +++ b/open-sse/shared/qoder/contextTier.js @@ -0,0 +1,160 @@ +/** + * Qoder context-window tiers. + * + * Each Qoder model_config ships a `context_config` list (e.g. 200K / 400K / 1M for + * qmodel_38max) while `max_input_tokens` only carries the tier the IDE currently has + * selected (~180K by default). The Qoder IDE lets the user switch tiers from the model + * picker; a qodercli-style client (which is what 9router impersonates) has no picker, + * so a long Claude-Code / Codex session that grew past the default tier is rejected + * upstream even though the model itself supports 1M. + * + * This module emulates the IDE: estimate the prompt size, pick the smallest advertised + * tier that fits (never below the model's current default), and mirror the choice into + * the same three places the IDE writes: + * parameters.context_length + * chat_context.extra.ideModelConfigOverride.max_input_tokens + * model_config.max_input_tokens + * + * Override with QODER_CONTEXT_TIER = auto (default) | max | default | . + * Pure functions, no I/O β€” the executor wires them into buildQoderRequestBody. + */ + +import { QODER_CONTEXT_TIER_HEADROOM, QODER_CONTEXT_TIER_MODES } from "./constants.js"; + +const UNIT = { K: 1_000, M: 1_000_000 }; + +/** "200K" | "1M" | "204800" | 204800 β†’ integer token count (0 when unparseable). */ +export function parseTierTokenCount(value) { + if (typeof value === "number") return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + if (typeof value !== "string") return 0; + const m = value.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*([KM])?$/); + if (!m) return 0; + const n = Number(m[1]) * (UNIT[m[2]] || 1); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +function tierName(entry, tokenCount) { + const raw = entry.name ?? entry.label ?? entry.display_name ?? entry.displayName ?? entry.key ?? entry.id; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (tokenCount >= UNIT.M && tokenCount % UNIT.M === 0) return `${tokenCount / UNIT.M}M`; + if (tokenCount >= UNIT.K && tokenCount % UNIT.K === 0) return `${tokenCount / UNIT.K}K`; + return String(tokenCount); +} + +/** + * Normalize a model_config into sorted tiers: [{ name, tokenCount, isDefault }] ascending. + * Accepts snake_case and camelCase shapes; returns [] when the model has no tiers. + */ +export function getQoderContextTiers(modelConfig) { + const list = modelConfig?.context_config ?? modelConfig?.contextConfig; + if (!Array.isArray(list)) return []; + const byCount = new Map(); + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const tokenCount = parseTierTokenCount( + entry.tokenCount ?? entry.token_count ?? entry.max_input_tokens ?? entry.maxInputTokens ?? entry.contextLength ?? entry.context_length, + ); + if (!tokenCount) continue; + const isDefault = entry.isDefault === true || entry.is_default === true || entry.default === true; + const prev = byCount.get(tokenCount); + byCount.set(tokenCount, { + name: tierName(entry, tokenCount), + tokenCount, + isDefault: (prev?.isDefault || false) || isDefault, + }); + } + return [...byCount.values()].sort((a, b) => a.tokenCount - b.tokenCount); +} + +const CJK_RE = /[\u1100-\u11ff\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g; + +/** + * Rough prompt-size estimate in tokens. CJK characters count ~1 token each, everything + * else ~4 chars/token β€” the plain chars/4 rule underestimates Chinese/Japanese by up to + * 4x, which is exactly when a tier decision matters. + */ +export function estimateQoderPromptTokens({ system, messages, tools } = {}) { + let text = ""; + try { + text = JSON.stringify({ system: system || "", messages: messages || [], tools: tools || [] }) || ""; + } catch { + return 0; + } + const cjk = (text.match(CJK_RE) || []).length; + return Math.ceil(cjk + (text.length - cjk) / 4); +} + +function normalizeMode(preference) { + const p = String(preference ?? "").trim(); + return p ? p : QODER_CONTEXT_TIER_MODES.AUTO; +} + +function findNamedTier(tiers, name) { + const wanted = name.replace(/\s+/g, "").toUpperCase(); + const asCount = parseTierTokenCount(wanted); + return tiers.find((t) => t.name.replace(/\s+/g, "").toUpperCase() === wanted || (asCount && t.tokenCount === asCount)) || null; +} + +/** + * Decide which tier a request should run under. + * + * @param {object} modelConfig raw Qoder model_config (has context_config + max_input_tokens) + * @param {{system?: string, messages?: any[], tools?: any[]}} prompt what will be sent + * @param {{preference?: string, headroom?: number}} [options] + * @returns {{ tier: {name, tokenCount, isDefault}, estimatedTokens: number, reason: string } | null} + * null β†’ leave the payload exactly as before (no tiers, or the default already fits). + */ +export function resolveQoderContextTier(modelConfig, prompt, options = {}) { + const tiers = getQoderContextTiers(modelConfig); + if (!tiers.length) return null; + + const mode = normalizeMode(options.preference); + const largest = tiers[tiers.length - 1]; + const defaultTier = tiers.find((t) => t.isDefault) || tiers[0]; + const estimatedTokens = estimateQoderPromptTokens(prompt); + const headroom = typeof options.headroom === "number" ? options.headroom : QODER_CONTEXT_TIER_HEADROOM; + const need = Math.ceil(estimatedTokens * (1 + headroom)); + + if (mode.toLowerCase() === QODER_CONTEXT_TIER_MODES.MAX) { + return { tier: largest, estimatedTokens, reason: "forced:max" }; + } + if (mode.toLowerCase() === QODER_CONTEXT_TIER_MODES.DEFAULT) { + return { tier: defaultTier, estimatedTokens, reason: "forced:default" }; + } + if (mode.toLowerCase() !== QODER_CONTEXT_TIER_MODES.AUTO) { + const named = findNamedTier(tiers, mode); + if (named) return { tier: named, estimatedTokens, reason: `forced:${named.name}` }; + // Unknown tier name β†’ fall through to auto rather than silently breaking requests. + } + + // auto: keep the upstream default (current behaviour) while the prompt fits in it. + const currentMax = parseTierTokenCount(modelConfig?.max_input_tokens ?? modelConfig?.maxInputTokens); + const currentLimit = currentMax || defaultTier.tokenCount; + if (need <= currentLimit) return null; + + const fits = tiers.find((t) => t.tokenCount >= need && t.tokenCount > currentLimit); + const tier = fits || largest; + if (tier.tokenCount <= currentLimit) return null; // nothing bigger to escalate to + return { tier, estimatedTokens, reason: fits ? "auto:fits" : "auto:largest" }; +} + +/** + * Write the chosen tier into a Qoder chat payload (mutates + returns it). + * Mirrors the IDE: parameters.context_length, ideModelConfigOverride, model_config. + */ +export function applyQoderContextTier(payload, tier) { + if (!payload || !tier?.tokenCount) return payload; + payload.parameters = { ...(payload.parameters || {}), context_length: tier.tokenCount }; + payload.chat_context = payload.chat_context || {}; + payload.chat_context.extra = { + ...(payload.chat_context.extra || {}), + ideModelConfigOverride: { + ...(payload.chat_context.extra?.ideModelConfigOverride || {}), + max_input_tokens: tier.tokenCount, + }, + }; + if (payload.model_config && typeof payload.model_config === "object") { + payload.model_config = { ...payload.model_config, max_input_tokens: tier.tokenCount }; + } + return payload; +} diff --git a/open-sse/shared/qoder/sse.js b/open-sse/shared/qoder/sse.js new file mode 100644 index 00000000..ad30785e --- /dev/null +++ b/open-sse/shared/qoder/sse.js @@ -0,0 +1,208 @@ +/** + * Qoder SSE is OpenAI-shaped inside `{statusCodeValue, body}` envelopes, but + * usage arrives on a later `choices: []` frame β€” after finish_reason, which + * itself often lives on `delta.finish_reason` rather than the choice. + * + * Downstream (Claude translator, OpenAI clients, Claude Code) look for usage + * on the finish chunk or drop `choices: []` entirely. 9router's own dashboard + * still sees tokens because extractUsage runs on every forwarded frame. + * + * Coalesce: hold empty finish + usage-only frames, then emit one OpenAI + * include_usage-style chunk: `{choices:[{delta:{}, finish_reason}], usage}`. + */ + +function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Normalize Qoder/OpenAI usage into the shape stream.js + Claude translation + * already understand (prompt_tokens + prompt_tokens_details.cached_tokens). + */ +export function canonicalizeQoderUsage(usage) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + + const prompt = num(usage.prompt_tokens ?? usage.input_tokens); + const completion = num(usage.completion_tokens ?? usage.output_tokens); + if (prompt == null && completion == null) return null; + + const details = (usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object") + ? { ...usage.prompt_tokens_details } + : {}; + const cached = num( + details.cached_tokens ?? + usage.cached_tokens ?? + usage.prompt_cache_hit_tokens ?? + usage.cache_read_input_tokens, + ); + const cacheCreation = num( + details.cache_creation_tokens ?? + usage.cache_creation_input_tokens, + ); + + const promptTokens = prompt || 0; + const completionTokens = completion || 0; + const out = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: num(usage.total_tokens) ?? (promptTokens + completionTokens), + }; + + if (cached != null) { + out.cached_tokens = cached; + details.cached_tokens = cached; + } + if (cacheCreation != null) { + details.cache_creation_tokens = cacheCreation; + } + if (Object.keys(details).length) out.prompt_tokens_details = details; + + if (usage.completion_tokens_details && typeof usage.completion_tokens_details === "object") { + out.completion_tokens_details = usage.completion_tokens_details; + } + const reasoning = num(usage.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens); + if (reasoning != null) out.reasoning_tokens = reasoning; + + return out; +} + +function finishReasonOf(parsed) { + const choice = parsed?.choices?.[0]; + return choice?.finish_reason || choice?.delta?.finish_reason || parsed?.finish_reason || null; +} + +function hasValuableDelta(parsed) { + const delta = parsed?.choices?.[0]?.delta; + if (!delta || typeof delta !== "object") return false; + if (typeof delta.content === "string" && delta.content.length > 0) return true; + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true; + if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true; + if (delta.role) return true; + return false; +} + +function parseInner(inner) { + if (inner == null || inner === "") return { raw: false, parsed: null }; + if (inner === "[DONE]") return { done: true }; + if (typeof inner !== "string") { + if (typeof inner === "object") return { parsed: inner }; + return { raw: true, text: String(inner) }; + } + try { + return { parsed: JSON.parse(inner) }; + } catch { + return { raw: true, text: inner }; + } +} + +/** + * @param {object} opts + * @param {string} opts.model + * @param {TextEncoder} opts.encoder + * @param {string} opts.sseDone "data: [DONE]\\n\\n" + */ +export function createQoderSseCoalescer({ model, encoder, sseDone }) { + let pendingFinish = null; + let pendingUsage = null; + let lastMeta = { id: null, created: null, model }; + let doneEmitted = false; + let finishAlreadyForwarded = false; + + const emitJson = (controller, obj) => { + const sanitized = JSON.stringify(obj).replace(/\r?\n/g, ""); + controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); + }; + + const emitRaw = (controller, text) => { + controller.enqueue(encoder.encode(`data: ${String(text).replace(/\r?\n/g, "")}\n\n`)); + }; + + const emitDone = (controller) => { + if (doneEmitted) return; + controller.enqueue(encoder.encode(sseDone)); + doneEmitted = true; + }; + + const emitTerminal = (controller) => { + if (!pendingFinish && !pendingUsage) return; + emitJson(controller, { + id: lastMeta.id || `qoder-${Date.now()}`, + object: "chat.completion.chunk", + created: lastMeta.created || Math.floor(Date.now() / 1000), + model: lastMeta.model || model, + choices: [{ index: 0, delta: {}, finish_reason: pendingFinish || "stop" }], + ...(pendingUsage ? { usage: pendingUsage } : {}), + }); + pendingFinish = null; + pendingUsage = null; + }; + + const flush = (controller) => { + if (doneEmitted) return; + if (pendingUsage || (pendingFinish && !finishAlreadyForwarded)) { + emitTerminal(controller); + } + emitDone(controller); + }; + + const handleInner = (inner, controller) => { + if (doneEmitted) return { terminal: true }; + + const parsedInner = parseInner(inner); + if (parsedInner.done) { + flush(controller); + return { terminal: true }; + } + if (parsedInner.raw) { + emitRaw(controller, parsedInner.text); + return {}; + } + const parsed = parsedInner.parsed; + if (!parsed || typeof parsed !== "object") return {}; + + if (typeof parsed.id === "string" && parsed.id) lastMeta.id = parsed.id; + if (typeof parsed.created === "number") lastMeta.created = parsed.created; + if (typeof parsed.model === "string" && parsed.model) lastMeta.model = parsed.model; + + const usage = canonicalizeQoderUsage(parsed.usage); + if (usage) pendingUsage = usage; + + const finish = finishReasonOf(parsed); + if (hasValuableDelta(parsed)) { + // Stream content as-is (preserves upstream JSON for tests/clients). + emitRaw(controller, typeof inner === "string" ? inner : JSON.stringify(parsed)); + if (finish) { + finishAlreadyForwarded = true; + // Keep finish around only if we still need a usage trailer. + pendingFinish = pendingUsage ? finish : null; + } + if (pendingFinish && pendingUsage) { + emitTerminal(controller); + emitDone(controller); + return { terminal: true }; + } + return {}; + } + + if (finish) pendingFinish = finish; + + // Empty finish and/or usage-only: emit as soon as we have both (Qoder + // order is finish then usage). Don't wait for the later [DONE]/keepalive. + if ((pendingFinish || finishAlreadyForwarded) && pendingUsage) { + if (!pendingFinish) pendingFinish = "stop"; + emitTerminal(controller); + emitDone(controller); + return { terminal: true }; + } + return {}; + }; + + return { + handleInner, + flush, + get doneEmitted() { + return doneEmitted; + }, + }; +} diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index e222b23f..863b627e 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -14,6 +14,9 @@ const STRIP_RULES = [ { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) { provider: "cloudflare-ai", flattenContent: true }, + // MiMo Desktop Preview models (account-service route): content must be plain string, + // rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal). + { provider: "xiaomi-mimo", match: /preview/i, flattenContent: true }, { provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true }, // VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's // advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144), diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js index 958764dd..251850c1 100644 --- a/open-sse/translator/concerns/toolCall.js +++ b/open-sse/translator/concerns/toolCall.js @@ -1,5 +1,7 @@ // Tool call helper functions for translator +import { FORMATS } from "../formats.js"; + // Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$ const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -165,3 +167,16 @@ export function defaultClaudeToolType(tools) { return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" }); } +// Whether Claude-format tools need explicit `type` defaulting before dispatch. +// Only gateways that declare the `requireClaudeToolType` quirk (MiniMax) reject typeless +// tools. Applying the default globally breaks Claude-format endpoints that only accept the +// legacy typeless tool shape β€” DeepSeek's Anthropic-compatible endpoint answers HTTP 400 +// "unknown variant `custom`" and every Claude Code request routed there fails (#3905). +export function shouldDefaultClaudeToolType(provider, finalFormat, tools, PROVIDERS) { + return ( + finalFormat === FORMATS.CLAUDE + && Array.isArray(tools) + && PROVIDERS?.[provider]?.quirks?.requireClaudeToolType === true + ); +} + diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 62a0531a..f57972da 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -27,6 +27,14 @@ export function lastCacheableToolIndex(tools) { // Check if message has valid non-empty content export function hasValidContent(msg) { if (typeof msg.content === "string" && msg.content.trim()) return true; + if (msg.content && typeof msg.content === "object" && !Array.isArray(msg.content)) { + const block = msg.content; + return !!((block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) || + block.type === CLAUDE_BLOCK.TOOL_USE || + block.type === CLAUDE_BLOCK.TOOL_RESULT || + block.type === CLAUDE_BLOCK.IMAGE || + block.type === CLAUDE_BLOCK.DOCUMENT); + } if (Array.isArray(msg.content)) { return msg.content.some(block => (block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) || @@ -38,6 +46,60 @@ export function hasValidContent(msg) { } return false; } +// Content may arrive as a single content block object (spec allows string | array; +// some clients send the bare object). Wrap it as a one-block array and strip any +// client-placed cache_control: a bare-object marker must never survive +// normalization, on any path, guard or no guard. +function normalizeMessageContent(msg) { + const c = msg?.content; + if (c && typeof c === "object" && !Array.isArray(c)) { + delete c.cache_control; + msg.content = [c]; + } + return msg; +} + +// Total blocks carrying cache_control across system, tools, and messages β€” the +// upstream Messages API allows at most 4 markers per request. +function countCacheControlBlocks(body) { + let n = 0; + if (Array.isArray(body?.system)) for (const b of body.system) if (b?.cache_control) n++; + if (Array.isArray(body?.tools)) for (const t of body.tools) if (t?.cache_control) n++; + if (Array.isArray(body?.messages)) { + for (const m of body.messages) { + if (Array.isArray(m?.content)) { + for (const b of m.content) if (b?.cache_control) n++; + } else if (m?.content && typeof m.content === "object" && m.content.cache_control) n++; + } + } + return n; +} +// Trim every marker past the 4-marker budget. The head anchors (last system +// block, last cacheable tool) are held; the remaining slots go to the tail-most +// of the other markers in document order. A plain "keep the last 4 in document +// order" rule would drop the head anchors first β€” they lead document order, yet +// they are exactly what re-anchoring exists to pin. +function capCacheControlBlocks(body) { + const isHead = (b) => { + const sys = Array.isArray(body?.system) ? body.system : []; + if (sys.length && sys[sys.length - 1] === b) return true; + const tools = Array.isArray(body?.tools) ? body.tools : []; + const lastTool = lastCacheableToolIndex(tools); + return lastTool >= 0 && tools[lastTool] === b; + }; + const marked = []; + if (Array.isArray(body?.system)) for (const b of body.system) if (b?.cache_control) marked.push(b); + if (Array.isArray(body?.tools)) for (const t of body.tools) if (t?.cache_control) marked.push(t); + if (Array.isArray(body?.messages)) { + for (const m of body.messages) { + if (Array.isArray(m?.content)) for (const b of m.content) if (b?.cache_control) marked.push(b); + } + } + const head = marked.filter(isHead); + const rest = marked.filter(b => !isHead(b)); + const keep = Math.max(0, 4 - head.length); + for (const b of rest.slice(0, Math.max(0, rest.length - keep))) delete b.cache_control; +} // Fix tool_use/tool_result ordering for Claude API // 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow) @@ -136,8 +198,9 @@ function hasForeignServerToolUseId(block) { // Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject: // 1. thinking.type "adaptive" β†’ unsupported on Haiku // 2. output_config.effort β†’ unsupported on Haiku -// 3. role "system" messages (mid-conversation-system beta) β†’ only top-level system is allowed -// 4. server_tool_use blocks carrying a foreign (non-srvtoolu_) id β†’ rejected outright +// 3. bare content-block objects (content: {block} instead of [{block}]) β†’ wrapped first +// 4. role "system" messages (mid-conversation-system beta) β†’ only top-level system is allowed +// 5. server_tool_use blocks carrying a foreign (non-srvtoolu_) id β†’ rejected outright export function normalizeClaudePassthrough(body, model = "") { if (!body || typeof body !== "object") return body; @@ -152,7 +215,15 @@ export function normalizeClaudePassthrough(body, model = "") { if (Object.keys(body.output_config).length === 0) delete body.output_config; } - // 2. Fold mid-conversation system messages into the neighbouring turn. + // 3. Wrap bare content-block objects as one-element arrays before folding. + // Some clients send content: {block} instead of content: [{block}]; the + // mid-conversation-system fold below assumes the array shape, so it must + // run first β€” a bare-object neighbor would otherwise be zeroed to []. + if (Array.isArray(body.messages)) { + for (const msg of body.messages) normalizeMessageContent(msg); + } + + // 4. Fold mid-conversation system messages into the neighbouring turn. // Hoisting them into body.system would insert volatile content (token counters, // reminders) ahead of the whole conversation and invalidate the prefix cache on // every request. Folding in place keeps the cached prefix stable. @@ -186,7 +257,7 @@ export function normalizeClaudePassthrough(body, model = "") { body.messages = messages; } - // 3. Drop thinking blocks whose signature is not Claude's (combo mixes models, + // 5. Drop thinking blocks whose signature is not Claude's (combo mixes models, // so foreign signatures leak into history and Anthropic rejects them). const thinkingEnabled = body.thinking?.type === "enabled"; const droppedServerToolUseIds = new Set(); @@ -233,7 +304,7 @@ export function normalizeClaudePassthrough(body, model = "") { } } - // 5. Drop empty text blocks and any message left with no content at all. + // 6. Drop empty text blocks and any message left with no content at all. // Anthropic rejects `messages.N.content` blocks with empty text (400 // "text content blocks must be non-empty"); a message whose blocks were all // stripped above must be dropped, not padded with an empty placeholder. @@ -271,7 +342,22 @@ function markLastCacheableBlock(msg) { // (normalize, tool dedupe, token savers) β€” otherwise the anchor drifts off the tail. export function anchorClaudeCache(body) { if (!body || typeof body !== "object") return body; + if (Array.isArray(body.messages)) { + for (const msg of body.messages) normalizeMessageContent(msg); + } + // Invalid markers first, whatever the budget: Anthropic rejects a tool that + // carries BOTH defer_loading and cache_control (#3567). The re-anchor path + // below strips them anyway; the over-budget early return used to forward + // them untouched. + if (Array.isArray(body.tools)) { + for (const t of body.tools) { + if (t?.defer_loading === true) delete t.cache_control; + } + } + // Head anchors first, before any budget guard: the 1h TTL on system/tools is + // the point of re-anchoring, and skipping it because the client spent its + // budget would silently downgrade a cache hit to the 5m default. if (Array.isArray(body.system)) { const last = body.system.length - 1; body.system.forEach((block, i) => { @@ -289,6 +375,15 @@ export function anchorClaudeCache(body) { }); } + // Budget guard AFTER the head anchors: with the last system block and last + // tool pinned, at most 2 slots remain. At >= 4 markers the client has spent + // the rest of the budget and every remaining marker is itself a valid + // breakpoint β€” re-anchoring the tail could only exceed 4, so trim instead. + if (countCacheControlBlocks(body) >= 4) { + capCacheControlBlocks(body); + return body; + } + if (Array.isArray(body.messages)) { let anchored = null; for (let i = body.messages.length - 1; i >= 0; i--) { @@ -368,6 +463,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Pass 1: remove cache_control + filter empty messages for (let i = 0; i < len; i++) { const msg = body.messages[i]; + normalizeMessageContent(msg); // Remove cache_control from content blocks if (Array.isArray(msg.content)) { @@ -461,8 +557,21 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Strip built-in tools (e.g. web_search_20250305) and normalize to Anthropic-native shape // (drop `type` field, fold `function.{name,description,parameters}`) for non-Anthropic providers if (provider !== "claude") { + // Provider-specific whitelist of Anthropic tool `type` values that the + // upstream actually accepts. When the provider declares it + // (e.g. DeepSeek β€” only web_search_*), keep only listed types; otherwise + // keep the prior behaviour of dropping every non-function tool, which is + // correct for OpenAI-compatible targets reached through this Claude-format + // pass (their tools get normalized below to function-style). + const supportedTypes = PROVIDERS[provider]?.quirks?.claudeSupportedToolTypes; + const hasWhitelist = Array.isArray(supportedTypes); body.tools = body.tools - .filter(tool => !tool.type || tool.type === "function") + .filter(tool => { + const t = tool?.type; + if (!t || t === "function") return true; + if (hasWhitelist) return supportedTypes.includes(t); + return false; + }) .map(tool => { if (tool.function) { return { @@ -471,6 +580,13 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne input_schema: tool.function.parameters, }; } + // When the provider declared a supportedToolTypes whitelist, keep + // the surviving tools' `type` field intact β€” the upstream + // Anthropic-compatible endpoint (e.g. DeepSeek) requires it to + // route built-ins like web_search_* correctly. Without a + // whitelist, preserve prior behaviour and strip `type` so the + // tool is normalized to plain Anthropic shape. + if (hasWhitelist) return tool; const { type, ...rest } = tool; return rest; }); diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index 8a965fee..729e4c10 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -432,3 +432,21 @@ export function cleanJSONSchemaForAntigravity(schema) { return cleaned; } +// Merge adjacent same-role messages, strip empty parts, ensure initial user turn +export function normalizeGeminiContents(contents) { + const out = []; + for (const c of contents || []) { + if (!c?.role || !Array.isArray(c.parts)) continue; + const parts = c.parts.filter(p => p && Object.keys(p).length > 0); + if (parts.length === 0) continue; + const last = out.at(-1); + if (last?.role === c.role) last.parts.push(...parts); + else out.push({ ...c, parts: [...parts] }); + } + if (out.length > 0 && out[0].role !== "user") { + out.unshift({ role: "user", parts: [{ text: "..." }] }); + } + return out; +} + + diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 3cfad109..ef2dd6c5 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -242,9 +242,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) { ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); - // Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a - // content fallback too because the CodeWhisperer surface does not always - // enforce top-level systemPrompt for direct calls. + // The system prompt travels inside the first user turn's content (contentPrefix): + // the CodeWhisperer surface rejects a top-level `systemPrompt` with + // 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key. const timestamp = new Date().toISOString(); const systemPromptParts = []; if (thinkingBudget !== null && !usesNativeGptEffort) { @@ -316,14 +316,11 @@ export function claudeToKiroRequest(model, body, stream, credentials) { conversationState: { chatTriggerType: "MANUAL", conversationId, - agentContinuationId: continuationId, - agentTaskType: "vibe", currentMessage: { userInputMessage, }, history: canonical.history, }, - agentMode: "vibe", }; if (profileArn) payload.profileArn = profileArn; diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index 3956f828..38976226 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -142,6 +142,13 @@ function systemReminderText(content) { // Convert single Claude message - returns single message or array of messages function convertClaudeMessage(msg) { + // Some clients send content as a single block object; normalize to the + // one-element array every branch below (the system-reminder fold included) + // expects. Must run BEFORE the role branch: systemReminderText only reads + // arrays and strings, so a bare-object system turn was dropped outright. + if (msg.content && typeof msg.content === "object" && !Array.isArray(msg.content)) { + msg.content = [msg.content]; + } // Mid-conversation system message -> user (per Anthropic placement rules) if (msg.role === ROLE.SYSTEM) { const text = systemReminderText(msg.content); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 2b05670d..24e7f262 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -15,7 +15,8 @@ import { generateRequestId, generateSessionId, generateProjectId, - cleanJSONSchemaForAntigravity + cleanJSONSchemaForAntigravity, + normalizeGeminiContents } from "../formats/gemini.js"; import { deriveSessionId, toNumericSessionId } from "../../utils/sessionManager.js"; import { ROLE, GEMINI_ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; @@ -35,17 +36,6 @@ function sanitizeGeminiFunctionName(name) { return sanitized.substring(0, 64); } -function normalizeGeminiContents(contents) { - const out = []; - for (const c of contents || []) { - if (!c?.role || !Array.isArray(c.parts) || c.parts.length === 0) continue; - const last = out.at(-1); - if (last?.role === c.role) last.parts.push(...c.parts); - else out.push({ ...c, parts: [...c.parts] }); - } - return out; -} - // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE, sessionId = null) { const result = { @@ -163,12 +153,14 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } // Check if there are actual tool responses in the next messages - const hasActualResponses = toolCallIds.some(fid => toolResponses[fid]); + const isIntermediate = i < body.messages.length - 1; + const hasActualResponses = toolCallIds.some(fid => toolResponses[fid] !== undefined); - if (hasActualResponses) { + if (hasActualResponses || isIntermediate) { const toolParts = []; for (const fid of toolCallIds) { - if (!toolResponses[fid]) continue; + let resp = toolResponses[fid]; + if (resp === undefined) resp = ""; let name = tcID2Name[fid]; if (!name) { @@ -180,7 +172,6 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } } - let resp = toolResponses[fid]; let parsedResp = tryParseJSON(resp); if (parsedResp === null) { parsedResp = { result: resp }; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 1d9bedec..dbaefbae 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -340,9 +340,9 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const timestamp = new Date().toISOString(); - // Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback - // too because the CodeWhisperer surface does not always enforce top-level - // systemPrompt for direct calls. + // The system prompt travels inside the first user turn's content (contentPrefix): + // the CodeWhisperer surface rejects a top-level `systemPrompt` with + // 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key. const systemPromptParts = []; if (thinkingBudget !== null && !usesNativeGptEffort) { systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); @@ -397,8 +397,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) { conversationState: { chatTriggerType: "MANUAL", conversationId, - agentContinuationId: continuationId, - agentTaskType: "vibe", currentMessage: { userInputMessage: { content: replayCurrent.content || "", @@ -414,7 +412,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) { }, history: canonical.history }, - agentMode: "vibe", }; if (profileArn) { diff --git a/open-sse/utils/codexToolSchema.js b/open-sse/utils/codexToolSchema.js new file mode 100644 index 00000000..0c017b61 --- /dev/null +++ b/open-sse/utils/codexToolSchema.js @@ -0,0 +1,80 @@ +// Codex-specific tool JSON Schema compatibility. +// +// `https://chatgpt.com/backend-api/codex/responses` validates every function +// tool's `parameters` with a regex engine that does not implement Unicode +// property escapes. A `pattern` such as +// +// "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./\\[\\]]{1,200}$" +// +// is a perfectly valid ECMAScript `u`-mode regex, but Codex answers +// +// 400 Invalid schema for function 'Artifact': '^\p{Cc}...' is not a 'regex' +// param: tools[0].parameters +// +// The request is deterministically malformed for this provider, so every +// account fails identically and the combo pays a full failover before landing +// somewhere that accepts it (#3922). +// +// Scope guardrail (#3667): this is NOT a global schema sanitizer. Providers +// that do support `\p{...}` keep the constraint untouched β€” the strip runs only +// on the Codex dispatch path, and only on `pattern` strings that actually +// contain a property escape. Everything else in the schema (including valid +// patterns) passes through byte-identical. + +// `\p{...}` / `\P{...}` with an odd number of preceding backslashes β€” an even +// count means the backslash itself is escaped, so `\\p{Cc}` is a literal "p". +const UNICODE_PROPERTY_ESCAPE = /(^|[^\\])(\\\\)*\\[pP]\{/; + +export function hasUnicodePropertyEscape(pattern) { + return typeof pattern === "string" && UNICODE_PROPERTY_ESCAPE.test(pattern); +} + +// Copy-on-write walk: returns the original reference when nothing changed, so +// untouched schemas keep object identity and callers can cheaply detect a no-op. +// `properties` is special-cased because its keys are arbitrary property *names* +// (which may themselves be "pattern" or "properties") and must never be read as +// schema keywords; every other key recurses as an ordinary schema node. +function stripNode(node, stats) { + if (Array.isArray(node)) { + let changed = false; + const next = node.map((item) => { + const cleaned = stripNode(item, stats); + if (cleaned !== item) changed = true; + return cleaned; + }); + return changed ? next : node; + } + if (!node || typeof node !== "object") return node; + + let changed = false; + const next = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "pattern" && hasUnicodePropertyEscape(value)) { + stats.removed++; + changed = true; + continue; + } + if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { + let propsChanged = false; + const props = {}; + for (const [propName, propSchema] of Object.entries(value)) { + const cleaned = stripNode(propSchema, stats); + if (cleaned !== propSchema) propsChanged = true; + props[propName] = cleaned; + } + if (propsChanged) changed = true; + next[key] = propsChanged ? props : value; + continue; + } + const cleaned = stripNode(value, stats); + if (cleaned !== value) changed = true; + next[key] = cleaned; + } + return changed ? next : node; +} + +// Remove only the `pattern` constraints Codex's validator rejects. +// Returns the same reference when the schema is already compatible. +export function stripCodexUnsupportedPatterns(schema, stats = { removed: 0 }) { + return stripNode(schema, stats); +} diff --git a/package.json b/package.json index 6907c9b7..557a349c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.70", + "version": "0.5.75", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index fad05f9e..f49d8a38 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -7,19 +7,25 @@ import BaseUrlSelect from "./BaseUrlSelect"; import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; +import { stripModelContextMarker } from "open-sse/utils/modelMarkers.js"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; -// Context window presets. UI shows the round number; the value written is nudged -// down 2K to stay safely under the upstream hard cap. +// Auto-compact window presets (CLAUDE_CODE_AUTO_COMPACT_WINDOW, valid 100K–1M). +// UI shows the round number; the value written is nudged down 2K to stay safely +// under the upstream hard cap. const CONTEXT_OPTIONS = [ { label: "Default", value: "" }, { label: "200K", value: "198000" }, { label: "300K", value: "298000" }, { label: "500K", value: "498000" }, - { label: "1M", value: "998000" }, + { label: "700K", value: "698000" }, ]; +// Claude Code assumes a model's window is 200K unless the name carries the `[1m]` +// marker, which is why the 1M auto-compact preset only takes effect once the +// marker is applied. + export default function ClaudeToolCard({ tool, isExpanded, @@ -51,9 +57,28 @@ export default function ClaudeToolCard({ const [customBaseUrl, setCustomBaseUrl] = useState(""); const [ccFilterNaming, setCcFilterNaming] = useState(false); const [exaMcpEnabled, setExaMcpEnabled] = useState(false); - const [maxContextTokens, setMaxContextTokens] = useState(""); + const [autoCompactWindow, setAutoCompactWindow] = useState(""); + const [oneMContext, setOneMContext] = useState(false); const hasInitializedModels = useRef(false); + // Claude Code only string-matches the marker against the model name, so it + // applies to any id β€” the user decides which models are worth declaring as 1M. + // Stripping first keeps repeated toggles from stacking `[1m][1m]`. + const withContextMarker = (value, enabled) => { + const { model } = stripModelContextMarker(value); + return enabled ? `${model}[1m]` : model; + }; + + // Rewrite the mappings in place on toggle, so the inputs show what will be + // written without waiting for Apply. + const handleOneMContextToggle = (enabled) => { + setOneMContext(enabled); + tool.defaultModels.forEach((model) => { + const current = modelMappings[model.alias]; + if (current) onModelMappingChange(model.alias, withContextMarker(current, enabled)); + }); + }; + const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || ""; const getConfigStatus = () => { @@ -80,9 +105,15 @@ export default function ClaudeToolCard({ }, [initialStatus]); useEffect(() => { - const v = claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS; - setMaxContextTokens(v || ""); - }, [claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS]); + const v = claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + setAutoCompactWindow(v || ""); + }, [claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW]); + + useEffect(() => { + const env = claudeStatus?.settings?.env; + if (!env) return; + setOneMContext(tool.defaultModels.some((model) => env[model.envKey]?.endsWith("[1m]"))); + }, [claudeStatus?.settings?.env, tool.defaultModels]); useEffect(() => { if (isExpanded) { @@ -124,6 +155,8 @@ export default function ClaudeToolCard({ tool.defaultModels.forEach((model) => { if (model.envKey) { + // Kept verbatim (marker included) so the input matches what is on disk; + // withContextMarker strips before appending, so re-applying cannot double it. const value = env[model.envKey] || model.defaultValue || ""; // Only sync initial values from file once if (value) { @@ -180,15 +213,17 @@ export default function ClaudeToolCard({ tool.defaultModels.forEach((model) => { const targetModel = modelMappings[model.alias]; + // Written verbatim β€” the input may hold a marker typed by hand, and the + // toggle already decided the marker when it was flipped. if (targetModel && model.envKey) env[model.envKey] = targetModel; }); - if (maxContextTokens) { - env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + if (autoCompactWindow) { + env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow; } const res = await fetch("/api/cli-tools/claude-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env, exaMcpEnabled, maxContextTokens }), + body: JSON.stringify({ env, exaMcpEnabled, autoCompactWindow }), }); const data = await res.json(); if (res.ok) { @@ -217,7 +252,8 @@ export default function ClaudeToolCard({ tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); setSelectedApiKey(""); setExaMcpEnabled(false); - setMaxContextTokens(""); + setAutoCompactWindow(""); + setOneMContext(false); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); } @@ -247,8 +283,8 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); - if (maxContextTokens) { - env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + if (autoCompactWindow) { + env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow; } return [ @@ -374,17 +410,30 @@ export default function ClaudeToolCard({ ))} - {/* Context Window */} + {/* Auto-compact window */}
- Context window + Auto-compact arrow_forward - setAutoCompactWindow(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"> {CONTEXT_OPTIONS.map((opt) => ( ))}
+ {/* 1M context */} +
+ 1M context + arrow_forward + +
+ {/* CC Filter Naming */}
Filter naming diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index a8e0e33a..26db0546 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getThinkingLevels, BASE_THINKING_LEVELS } from "open-sse/providers/thinkingLevels.js"; @@ -44,6 +44,7 @@ export default function ProviderDetailPage() { const [providerNode, setProviderNode] = useState(null); const [proxyPools, setProxyPools] = useState([]); const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showXiaomiMimoModal, setShowXiaomiMimoModal] = useState(false); const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [addConnectionError, setAddConnectionError] = useState(""); @@ -93,6 +94,7 @@ export default function ProviderDetailPage() { const [testAllModelsRunning, setTestAllModelsRunning] = useState(false); const [testAllModelsSummary, setTestAllModelsSummary] = useState(null); const [cleaningFailedModels, setCleaningFailedModels] = useState(false); + const [importingClineModels, setImportingClineModels] = useState(false); const { copied, copy } = useCopyToClipboard(); const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; @@ -109,6 +111,11 @@ export default function ProviderDetailPage() { return; } } + // Xiaomi Desktop: auto-import local credentials first, OAuth as fallback + if (providerId === "xiaomi-mimo") { + setShowXiaomiMimoModal(true); + return; + } if (isOAuth) { openOAuthConnection(); return; @@ -701,6 +708,53 @@ export default function ProviderDetailPage() { setImportingQoderModels(false); } }; + // Fetch the live Cline /models catalog and add every model not yet present. + // Cline and ClinePass share the same catalog endpoint (api.cline.bot/api/v1/models). + const handleImportClineModels = async () => { + if (importingClineModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) { + alert(translate("Please add an active Cline connection first")); + return; + } + setImportingClineModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || translate("Failed to fetch models")); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert(translate("No models returned")); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name; + if (!modelId) continue; + const alreadyExists = customModels.some( + (entry) => entry.providerAlias === providerStorageAlias && entry.id === modelId && (entry.kind || entry.type || "llm") === "llm" + ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${modelId}`); + if (alreadyExists) { + continue; + } + await handleAddCustomModel(modelId, "llm", providerStorageAlias); + importedCount += 1; + } + if (importedCount === 0) { + alert(translate("All models already exist, no new models added")); + } else { + alert(translate("Successfully added") + ` ${importedCount} ` + translate("models")); + } + } catch (error) { + console.log("Error importing Cline models:", error); + alert(translate("Error fetching models") + ": " + error.message); + } finally { + setImportingClineModels(false); + } + }; // Pull the node's upstream /models list and store each entry as a custom // model under this node's storage alias (the node id for compatible nodes). @@ -1618,6 +1672,20 @@ export default function ProviderDetailPage() { )} + {/* Import Cline /models catalog button β€” only show for cline and clinepass providers */} + {(providerId === "cline" || providerId === "clinepass") && connections.some((conn) => conn.isActive !== false) && ( + + )} + {/* Suggested models from provider API β€” show only models not yet added */} {suggestedModels.length > 0 && (() => { const addedFullModels = new Set([ @@ -2426,6 +2494,13 @@ export default function ProviderDetailPage() { onClose={() => setShowOAuthModal(false)} /> )} + + {/* Xiaomi Desktop: auto-import local credentials modal */} + setShowXiaomiMimoModal(false)} + /> {providerId === "iflow" && ( k.startsWith("gemini-") && !k.includes("image")); const claudeModels = entries.filter(([k]) => k.startsWith("claude-")); const imageModels = entries.filter(([k]) => k.includes("image")); - const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image")); + const weeklyModels = entries.filter(([k]) => weeklyKeys.has(k)); + const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !weeklyKeys.has(k)); if (geminiModels.length > 0) { const rep = geminiModels.reduce((min, cur) => @@ -409,6 +411,17 @@ export function parseQuotaData(provider, data) { }); } + weeklyModels.forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + imageModels.forEach(([modelKey, quota]) => { normalizedQuotas.push({ name: quota.displayName || modelKey, diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index 76ba9232..c7087577 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -123,7 +123,7 @@ export async function GET() { // POST - Backup old fields and write new settings export async function POST(request) { try { - const { env, exaMcpEnabled, maxContextTokens } = await request.json(); + const { env, exaMcpEnabled, autoCompactWindow } = await request.json(); if (!env || typeof env !== "object") { return NextResponse.json( @@ -166,12 +166,13 @@ export async function POST(request) { }, }; - // CLAUDE_CODE_MAX_CONTEXT_TOKENS β€” only set when a concrete value is chosen; - // "Default" removes the key so Claude Code falls back to the model's window. - if (maxContextTokens) { - newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContextTokens); + // CLAUDE_CODE_AUTO_COMPACT_WINDOW β€” the token threshold that triggers + // auto-compact. Only set when a concrete value is chosen; "Default" removes + // the key so Claude Code derives the window from the model. + if (autoCompactWindow) { + newSettings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(autoCompactWindow); } else { - delete newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS; + delete newSettings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; } // Write new settings @@ -203,7 +204,7 @@ const RESET_ENV_KEYS = [ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "API_TIMEOUT_MS", - "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", ]; // DELETE - Reset settings (remove env fields) diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 32372207..48b880a0 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -1,4 +1,6 @@ import { getApiKeys } from "@/lib/localDb"; +import { resolveProviderId } from "@/shared/constants/providers.js"; +import { unwrapClineEnvelope } from "open-sse/shared/clineEnvelope.js"; import { UPDATER_CONFIG } from "@/shared/constants/config"; import { getConsistentMachineId } from "@/shared/utils/machineId"; @@ -158,6 +160,11 @@ export async function pingModelByKind( let parsed = null; try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + // Unwrap before the choices checks below. No-op for providers that do not + // opt in via transport.quirks.clineEnvelope. + const providerId = resolveProviderId(String(model).split("/")[0]); + parsed = unwrapClineEnvelope(parsed, providerId); + if (!res.ok) { const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 824cad8b..12fb9d66 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { NextResponse } from "next/server"; import { getProvider, @@ -7,6 +8,7 @@ import { pollForToken } from "@/lib/oauth/providers"; import { createProviderConnection } from "@/models"; +import { readDesktopPassToken } from "open-sse/shared/mimoAccount.js"; import { startCodexProxy, stopCodexProxy, @@ -33,6 +35,11 @@ import { registerZedSession, getZedSessionStatus, clearZedSession, + startXiaomiMimoProxy, + stopXiaomiMimoProxy, + registerXiaomiMimoSession, + getXiaomiMimoSessionStatus, + clearXiaomiMimoSession, } from "@/lib/oauth/utils/server"; import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect"; import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; @@ -89,6 +96,32 @@ export async function GET(request, { params }) { const { searchParams } = new URL(request.url); if (action === "authorize") { + // Xiaomi Desktop: custom ECDH flow β€” generate keypair, start proxy, return authorize URL + if (provider === "xiaomi-mimo") { + const { generateKeyPair, buildAuthorizeUrl, getKeyName } = await import("@/lib/oauth/providers/xiaomi-mimo"); + const { publicKey, privateKeyDer } = generateKeyPair(); + const state = searchParams.get("state") || crypto.randomUUID(); + + // Start the callback proxy (or reuse if already running) + const proxyResult = await startXiaomiMimoProxy(); + if (!proxyResult.success) { + return NextResponse.json({ error: `Failed to start callback server: ${proxyResult.reason}` }, { status: 500 }); + } + + // Register the session with the private key for decryption + registerXiaomiMimoSession({ state, privateKeyDer }); + + const redirectUri = proxyResult.callbackUrl; + const authorizeUrl = buildAuthorizeUrl(publicKey, redirectUri, getKeyName()); + + return NextResponse.json({ + state, + authorizeUrl, + redirectUri, + port: proxyResult.port, + }); + } + const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback"; // Collect provider-specific meta params (e.g. gitlab passes baseUrl, clientId, clientSecret) const reservedParams = new Set(["redirect_uri"]); @@ -120,6 +153,10 @@ export async function GET(request, { params }) { const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort); return NextResponse.json(result); } + if (provider === "xiaomi-mimo") { + const result = await startXiaomiMimoProxy(); + return NextResponse.json(result); + } if (!["codex", "xai"].includes(provider)) { return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); } @@ -153,10 +190,21 @@ export async function GET(request, { params }) { else if (provider === "zed") session = getZedSessionStatus(state); else if (provider === "xai") session = getXaiSessionStatus(state); else if (provider === "codex") session = getCodexSessionStatus(state); - else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); + else if (provider === "xiaomi-mimo") session = getXiaomiMimoSessionStatus(state); + else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed/xiaomi-mimo" }, { status: 400 }); if (!session) return NextResponse.json({ status: "unknown" }); if (session.status === "done" || session.status === "error") { const payload = { ...session }; + if (provider === "xiaomi-mimo") { + // Unlike the others this does not auto-exchange server-side, so a + // finished session must survive until the client POSTs /exchange β€” + // that call clears it. A failed one is cleared here instead. + if (session.status === "error") { + clearXiaomiMimoSession(state); + stopXiaomiMimoProxy(); + } + return NextResponse.json(payload); + } if (provider === "trae") clearTraeSession(state); else if (provider === "windsurf") clearWindsurfSession(state); else if (provider === "zed") clearZedSession(state); @@ -173,7 +221,8 @@ export async function GET(request, { params }) { else if (provider === "zed") stopZedProxy(); else if (provider === "xai") stopXaiProxy(); else if (provider === "codex") stopCodexProxy(); - else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); + else if (provider === "xiaomi-mimo") stopXiaomiMimoProxy(); + else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed/xiaomi-mimo" }, { status: 400 }); return NextResponse.json({ success: true }); } @@ -268,6 +317,68 @@ export async function POST(request, { params }) { if (action === "exchange") { const { code, redirectUri, codeVerifier, state, meta } = body; + // Xiaomi MiMo: no token exchange needed β€” the callback already decrypted the sk. + // Just read the session result and create the connection. + if (provider === "xiaomi-mimo") { + if (!state) { + return NextResponse.json({ error: "Missing state" }, { status: 400 }); + } + const session = getXiaomiMimoSessionStatus(state); + if (!session || session.status !== "done" || !session.result) { + return NextResponse.json( + { error: session?.error || "OAuth session not completed. Please restart the login flow." }, + { status: 400 }, + ); + } + const { uid, accessToken, baseUrl } = session.result; + + // Desktop-exclusive Preview models authenticate with the account-session + // passToken, which only lives in MiMo Desktop's cookie store β€” attach it + // to the connection so those models work right after OAuth. + let passToken = null; + try { + passToken = await readDesktopPassToken(); + } catch { + // Desktop not installed / cookie DB locked β€” preview models stay unavailable. + } + + try { + const connection = await createProviderConnection({ + provider: "xiaomi-mimo", + authType: "oauth", + accessToken, + refreshToken: null, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: uid ? `${uid}@xiaomi` : null, + displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo", + providerSpecificData: { + uid: uid || null, + baseUrl: baseUrl || "https://api.xiaomimimo.com/v1", + authMethod: "oauth", + mimoPassToken: passToken?.passToken || null, + mimoUserId: passToken?.userId || null, + mimoCUserId: passToken?.cUserId || null, + }, + testStatus: "active", + }); + clearXiaomiMimoSession(state); + stopXiaomiMimoProxy(); + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (err) { + clearXiaomiMimoSession(state); + stopXiaomiMimoProxy(); + return NextResponse.json({ error: err.message }, { status: 500 }); + } + } + // Trae/Windsurf: code is either a raw callback URL or a pasted token. // exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction. if (provider === "trae" || provider === "windsurf") { diff --git a/src/app/api/oauth/xiaomi-mimo/api-key/route.js b/src/app/api/oauth/xiaomi-mimo/api-key/route.js new file mode 100644 index 00000000..d8ceee97 --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/api-key/route.js @@ -0,0 +1,136 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; + +/** + * POST /api/oauth/xiaomi-mimo/api-key + * Import a Xiaomi MiMo API key manually (or from auto-import). + * The key is validated against the models endpoint, then stored. + * + * Body: { apiKey, uid?, baseUrl? } + */ +export async function POST(request) { + try { + const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json(); + + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return NextResponse.json( + { error: "API key is required" }, + { status: 400 }, + ); + } + + const key = apiKey.trim(); + if (!key.startsWith("sk-")) { + return NextResponse.json( + { error: "Invalid key format β€” expected sk- prefix" }, + { status: 400 }, + ); + } + + const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, ""); + + // Validate the key against the models endpoint + let validated = false; + let modelCount = 0; + try { + const resp = await fetch(`${effectiveBaseUrl}/models`, { + method: "GET", + headers: { + Authorization: `Bearer ${key}`, + "X-Mimo-Source": "mimocode-cli", + }, + signal: AbortSignal.timeout(10000), + }); + if (resp.ok) { + const data = await resp.json(); + modelCount = Array.isArray(data?.data) ? data.data.length : 0; + validated = true; + } + } catch { + // Network error β€” still allow import (key may be valid but network blocked) + } + + if (!validated) { + // Soft-fail: store the key but mark as untested + console.log("[xiaomi-mimo] key validation failed, storing as untested"); + } + + // Dedup: if a connection with the same uid or same key already exists, update it + const { getProviderConnections, updateProviderConnection } = await import("@/models"); + const existing = (await getProviderConnections()).find( + (c) => c.provider === "xiaomi-mimo" && ( + (uid && c.email === `${uid}@xiaomi`) || + c.accessToken === key + ), + ); + if (existing) { + const updated = await updateProviderConnection(existing.id, { + accessToken: key, + providerSpecificData: { + ...existing.providerSpecificData, + uid: uid || existing.providerSpecificData?.uid || null, + baseUrl: effectiveBaseUrl, + // Per-account session credential β€” enables multi-account rotation. + mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null, + mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null, + mimoCUserId: mimoCUserId || existing.providerSpecificData?.mimoCUserId || null, + modelCount, + }, + testStatus: validated ? "active" : existing.testStatus, + }); + return NextResponse.json({ + success: true, + validated, + modelCount, + updated: true, + connection: { + id: existing.id, + provider: existing.provider, + email: existing.email, + displayName: existing.displayName, + }, + }); + } + + const connection = await createProviderConnection({ + provider: "xiaomi-mimo", + authType: "api_key", + accessToken: key, + refreshToken: null, + // API keys don't expire on a fixed schedule; use a long horizon + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: uid ? `${uid}@xiaomi` : null, + displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo", + providerSpecificData: { + uid: uid || null, + baseUrl: effectiveBaseUrl, + authMethod: "api_key", + provider: "API Key", + modelCount, + // Per-account session credential β€” enables multi-account rotation. + mimoPassToken: mimoPassToken || null, + mimoUserId: mimoUserId || null, + mimoCUserId: mimoCUserId || null, + }, + testStatus: validated ? "active" : "untested", + }); + + return NextResponse.json({ + success: true, + validated, + modelCount, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (error) { + console.log("Xiaomi MiMo API key import error:", error); + return NextResponse.json( + { error: "API key import failed" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/oauth/xiaomi-mimo/auto-import/route.js b/src/app/api/oauth/xiaomi-mimo/auto-import/route.js new file mode 100644 index 00000000..0508c222 --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/auto-import/route.js @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { readFile, access, constants } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import { readDesktopPassToken } from "open-sse/shared/mimoAccount.js"; + +/** + * GET /api/oauth/xiaomi-mimo/auto-import + * Auto-detect Xiaomi MiMo credentials from local auth.json. + * + * Sources (in priority order): + * 1. ~/.local/share/mimocode/auth.json β†’ xiaomi field + * 2. %APPDATA%/Xiaomi MiMo/... β†’ (future: Desktop keychain) + * + * auth.json shape: + * { + * "xiaomi": { + * "type": "api", + * "key": "sk-xxxx", + * "metadata": { "uid": "...", "base_url": "https://api.xiaomimimo.com/v1" } + * } + * } + */ + +function getCandidatePaths() { + const home = homedir(); + const paths = []; + + // MiMoCode / MiMo Desktop shared data dir (cross-platform XDG) + paths.push(join(home, ".local", "share", "mimocode", "auth.json")); + + // Windows: also check USERPROFILE-based XDG + if (process.platform === "win32") { + const appData = process.env.APPDATA || join(home, "AppData", "Roaming"); + // Desktop's own storage (may have separate credentials in the future) + paths.push(join(appData, "Xiaomi MiMo", "auth.json")); + } + + // macOS + if (process.platform === "darwin") { + paths.push( + join(home, "Library", "Application Support", "mimocode", "auth.json"), + ); + } + + return paths; +} + +/** + * GET /api/oauth/xiaomi-mimo/auto-import + */ +export async function GET() { + try { + const candidates = getCandidatePaths(); + + let authPath = null; + for (const candidate of candidates) { + try { + await access(candidate, constants.R_OK); + authPath = candidate; + break; + } catch { + // Try next candidate + } + } + + if (!authPath) { + return NextResponse.json({ + found: false, + error: `Xiaomi MiMo Desktop auth file not found. Checked:\n${candidates.join("\n")}\n\nMake sure Xiaomi MiMo Desktop is installed and you are signed in.`, + }); + } + + const raw = await readFile(authPath, "utf-8"); + let auth; + try { + auth = JSON.parse(raw); + } catch { + return NextResponse.json({ + found: false, + error: "auth.json is not valid JSON. Please sign in to Xiaomi MiMo Desktop again.", + }); + } + + const xiaomi = auth?.xiaomi; + if (!xiaomi || !xiaomi.key) { + return NextResponse.json({ + found: false, + error: "No Xiaomi credentials found in auth.json. Please sign in to Xiaomi MiMo Desktop.", + }); + } + + // Validate key format + const key = String(xiaomi.key).trim(); + if (!key.startsWith("sk-")) { + return NextResponse.json({ + found: false, + error: "Xiaomi key does not appear to be a valid API key (expected sk- prefix).", + }); + } + + const metadata = xiaomi.metadata || {}; + const uid = metadata.uid || null; + const baseUrl = metadata.base_url || "https://api.xiaomimimo.com/v1"; + + // Account-session passToken from Desktop's cookie store. Persisting it per + // connection is what lets multiple Xiaomi accounts rotate independently. + // (null while Desktop is running β€” its cookie DB is exclusively locked.) + let mimoPassToken = null; + let mimoUserId = null; + let mimoCUserId = null; + try { + const pt = await readDesktopPassToken(); + if (pt) { + mimoPassToken = pt.passToken; + mimoUserId = pt.userId; + mimoCUserId = pt.cUserId; + } + } catch (e) { + console.log("[xiaomi-mimo] passToken read failed (non-fatal):", e.message); + } + + return NextResponse.json({ + found: true, + apiKey: key, + uid, + baseUrl, + source: authPath, + mimoPassToken, + mimoUserId, + mimoCUserId, + }); + } catch (error) { + console.log("Xiaomi MiMo auto-import error:", error); + return NextResponse.json( + { found: false, error: error.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d73ec11d..605bc5ca 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveClineModels, resolveClinepassModels } from "open-sse/services/clinepassModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -287,6 +288,37 @@ const PROVIDER_MODELS_CONFIG = { }, }, + // Cline/ClinePass share api.cline.bot/api/v1/models. The service layer already + // handles Bearer-vs-`workos:` auth and swallows failures into null, so these follow + // the cursor direct pattern (no refreshFn) and only differ in filtering: + // cline returns the whole catalog verbatim, clinepass keeps cline-pass/* only. + cline: { + customResolver: async (connection) => { + const result = await resolveClineModels({ + accessToken: connection.accessToken, + apiKey: connection.apiKey, + }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("cline"), + warning: "Cline returned no live models; falling back to static catalog.", + }; + }, + }, + clinepass: { + customResolver: async (connection) => { + const result = await resolveClinepassModels({ + accessToken: connection.accessToken, + apiKey: connection.apiKey, + }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("clinepass"), + warning: "ClinePass returned no live models; falling back to static catalog.", + }; + }, + }, + // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { customResolver: async (connection) => { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index bd0c4782..9572e69e 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -4,6 +4,7 @@ import { testProxyUrl } from "@/lib/network/proxyTest"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost, PROVIDERS } from "open-sse/config/providers.js"; +import { CODEX_CLI_VERSION } from "open-sse/config/appConstants.js"; import { refreshProviderCredentials, shouldRefreshCredentials, @@ -27,7 +28,7 @@ const OAUTH_TEST_CONFIG = { method: "POST", authHeader: "Authorization", authPrefix: "Bearer ", - extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": "codex_cli_rs/0.136.0" }, + extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}` }, // Minimal invalid body β€” triggers fast 400 without consuming quota body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }), // 400 (bad request) means auth succeeded; only 401/403 means token is bad diff --git a/src/app/api/providers/suggested-models/filters.js b/src/app/api/providers/suggested-models/filters.js index 13454757..8babbb9b 100644 --- a/src/app/api/providers/suggested-models/filters.js +++ b/src/app/api/providers/suggested-models/filters.js @@ -26,4 +26,10 @@ export const FILTERS = { (Array.isArray(models) ? models : []) .filter((m) => m.id?.startsWith("mimo") || m.name?.toLowerCase().includes("mimo")) .map((m) => ({ id: m.id, name: m.name || m.id })), + + "airforce-free": (models) => + (Array.isArray(models) ? models : []) + .filter((m) => (m.tier === "free" || m.id?.endsWith(":free")) && m.supports_chat === true && (!m.media_type || m.media_type === "chat" || m.media_type === "text")) + .map((m) => ({ id: m.id, name: m.name || m.id, contextLength: m.context_length })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))), }; diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 66f0258c..753ca602 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -10,9 +10,9 @@ import { getApiKeyRecord } from "@/sse/services/auth.js"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; -import { resolveQoderModels } from "open-sse/services/qoderModels.js"; +import { resolveQoderModels, routableQoderModels } from "open-sse/services/qoderModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; -import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; +import { resolveClinepassModels, resolveClineModels } from "open-sse/services/clinepassModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js"; @@ -35,15 +35,18 @@ const LIVE_MODEL_RESOLVERS = { qoder: async (conn) => { const result = await resolveQoderModels({ accessToken: conn.accessToken, + // PAT (pt-...) connections keep the token in apiKey; without it the live + // catalog silently fails and /v1/models falls back to the static list. + apiKey: conn.apiKey, refreshToken: conn.refreshToken, email: conn.email, displayName: conn.displayName, providerSpecificData: conn.providerSpecificData || {} }); - if (!result?.models?.length) return null; - return { - models: result.models.map((m) => ({ id: m.id, name: m.name })), - }; + // Visible + hidden (enable:false) catalog keys β€” chat routes all of them. + const models = routableQoderModels(result); + if (!models.length) return null; + return { models: models.map((m) => ({ id: m.id, name: m.name })) }; }, kimchi: async (conn) => { const result = await resolveKimchiModels({ @@ -77,6 +80,13 @@ const LIVE_MODEL_RESOLVERS = { }); return result?.models?.length ? { models: result.models } : null; }, + cline: async (conn) => { + const result = await resolveClineModels({ + accessToken: conn.accessToken, + apiKey: conn.apiKey, + }); + return result?.models?.length ? { models: result.models } : null; + }, "grok-cli": async (conn) => { const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}); const result = await resolveGrokCliModels({ diff --git a/src/lib/auth/dashboardSession.js b/src/lib/auth/dashboardSession.js index b4c65aae..8f81cec5 100644 --- a/src/lib/auth/dashboardSession.js +++ b/src/lib/auth/dashboardSession.js @@ -7,6 +7,7 @@ import { DATA_DIR } from "@/lib/dataDir"; import { getSettings } from "@/lib/localDb"; const DEFAULT_PASSWORD = "123456"; +const SESSION_MAX_AGE_SEC = 24 * 60 * 60; function loadJwtSecret() { if (process.env.JWT_SECRET) return process.env.JWT_SECRET; @@ -64,6 +65,7 @@ export async function setDashboardAuthCookie(cookieStore, request, claims = {}) secure: shouldUseSecureCookie(request), sameSite: "lax", path: "/", + maxAge: SESSION_MAX_AGE_SEC, }); } diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index 050514d9..17b6dd49 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -19,6 +19,10 @@ async function tryBunSqlite() { async function tryBetterSqlite() { // Skip on Bun β€” better-sqlite3 native bindings unsupported if (process.versions.bun) return null; + // Skip on Node >= 24: the native addon SIGSEGVs on load there, which is a + // process-level crash the try/catch below cannot recover from. node:sqlite covers it. + const [nodeMajor] = process.versions.node.split(".").map(Number); + if (nodeMajor >= 24) return null; try { const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js"); return createBetterSqliteAdapter(DATA_FILE); diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index 4181843f..78abfc90 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -10,6 +10,28 @@ const OPTIONAL_FIELDS = [ "consecutiveUseCount", "idToken", "lastRefreshAt", ]; +const MODEL_LOCK_PREFIX = "modelLock_"; + +function resetHealthStateOnActivation(existing, patch) { + if (patch?.testStatus !== "active") return patch; + + const normalized = { + ...patch, + testStatus: "active", + lastError: Object.hasOwn(patch, "lastError") ? patch.lastError : null, + lastErrorAt: Object.hasOwn(patch, "lastErrorAt") ? patch.lastErrorAt : null, + errorCode: null, + rateLimitedUntil: null, + backoffLevel: 0, + }; + + for (const key of Object.keys(existing || {})) { + if (key.startsWith(MODEL_LOCK_PREFIX)) normalized[key] = null; + } + + return normalized; +} + function rowToConn(row) { if (!row) return null; const extra = parseJson(row.data, {}); @@ -147,7 +169,8 @@ export async function createProviderConnection(data) { // access_token: never dedup β€” user manages duplicates manually if (existing) { - const merged = { ...existing, ...data, updatedAt: now }; + const normalized = resetHealthStateOnActivation(existing, data); + const merged = { ...existing, ...normalized, updatedAt: now }; upsert(db, merged); result = merged; return; @@ -196,7 +219,8 @@ export async function updateProviderConnection(id, data) { const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]); if (!row) { result = null; return; } const existing = rowToConn(row); - const merged = { ...existing, ...data, updatedAt: new Date().toISOString() }; + const normalized = resetHealthStateOnActivation(existing, data); + const merged = { ...existing, ...normalized, updatedAt: new Date().toISOString() }; upsert(db, merged); if (data.priority !== undefined) reorderInTx(db, existing.provider); result = merged; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index c9e3cae6..77cd3c13 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -130,6 +130,21 @@ export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; // 3) Redirect β†’ ${cb}?refreshToken=...&loginHost=...&isRedirect=true // 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} β†’ {Result.AccessToken, ExpiresAt} // 5) POST GetUserInfo (x-cloudide-token) β†’ email/name +// Xiaomi MiMo Desktop OAuth β€” custom ECDH encrypted-callback flow (NOT standard OAuth2). +// 1) Client generates X25519 keypair +// 2) Browser opens ${platformUrl}/authorize?pk=&redirect_uri=http://localhost:/&kn=mimocode&key_name=... +// 3) Redirect β†’ http://localhost:/?u= +// 4) Decrypt: ECDH(shared) β†’ SHA256 β†’ AES-256-GCM +// Layout: [12-byte nonce][32-byte ephemeral pubkey][ciphertext][16-byte GCM tag] +// 5) Result JSON: { uid, sk, url } +export const XIAOMI_MIMO_CONFIG = { + platformUrl: process.env.MIMO_PLATFORM_URL || "https://platform.xiaomimimo.com", + defaultBaseUrl: "https://api.xiaomimimo.com/v1", + kn: "mimocode", + callbackPath: "/", + timeoutMs: 300000, // 5 minutes +}; + export const TRAE_CONFIG = { clientId: "ono9krqynydwx5", clientSecret: "-", diff --git a/src/lib/oauth/providers/xiaomi-mimo.js b/src/lib/oauth/providers/xiaomi-mimo.js new file mode 100644 index 00000000..ff85cf7b --- /dev/null +++ b/src/lib/oauth/providers/xiaomi-mimo.js @@ -0,0 +1,123 @@ +import crypto from "crypto"; +import { XIAOMI_MIMO_CONFIG } from "../constants/oauth.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Xiaomi MiMo OAuth helpers +// Custom ECDH + AES-256-GCM encrypted-callback flow (NOT standard OAuth2). +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Generate an X25519 keypair for the OAuth handshake. + * @returns {{ publicKey: string, privateKeyDer: Buffer }} + * publicKey β€” base64 SPKI (for the `pk` URL param) + * privateKeyDer β€” PKCS8 DER Buffer (for ECDH later) + */ +export function generateKeyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519"); + + const publicKeyDer = publicKey.export({ format: "der", type: "spki" }); + // SPKI for X25519 is 44 bytes; the raw 32-byte key is the last 32 bytes. + // But the platform expects the full base64 SPKI β€” pass as-is. + const publicKeyB64 = publicKeyDer.toString("base64"); + + const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" }); + + return { publicKey: publicKeyB64, privateKeyDer }; +} + +/** + * Decrypt the `u` query parameter from the Xiaomi OAuth callback. + * + * Wire format (base64-decoded): + * bytes 0..11 β€” 12-byte AES-GCM nonce + * bytes 12..43 β€” 32-byte ephemeral public key (raw X25519) + * bytes 44..n-16 β€” ciphertext + * last 16 bytes β€” GCM auth tag + * + * Key derivation: SHA256(ECDH(clientPrivateKey, ephemeralPublicKey)) + * + * @param {Buffer} privateKeyDer β€” PKCS8 DER private key from generateKeyPair() + * @param {string} encryptedB64 β€” the `u` query param value (base64) + * @returns {{ uid: string, sk: string, url?: string }} + */ +export function decryptCallback(privateKeyDer, encryptedB64) { + const raw = Buffer.from(encryptedB64, "base64"); + + if (raw.length < 12 + 32 + 16 + 1) { + throw new Error(`Encrypted payload too short: ${raw.length} bytes`); + } + + const nonce = raw.subarray(0, 12); + const ephemeralPubRaw = raw.subarray(12, 44); + const ciphertextAndTag = raw.subarray(44); + const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16); + const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16); + + // Reconstruct the ephemeral public key as SPKI DER for Node crypto. + // X25519 SPKI prefix: 302a300506032b656e032100 + const ephemeralPub = crypto.createPublicKey({ + key: Buffer.concat([ + Buffer.from("302a300506032b656e032100", "hex"), + ephemeralPubRaw, + ]), + format: "der", + type: "spki", + }); + + const privateKey = crypto.createPrivateKey({ + key: privateKeyDer, + format: "der", + type: "pkcs8", + }); + + const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub }); + const derivedKey = crypto.createHash("sha256").update(sharedSecret).digest(); + + const decipher = crypto.createDecipheriv("aes-256-gcm", derivedKey, nonce); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + const parsed = JSON.parse(decrypted.toString("utf-8")); + + if (!parsed || typeof parsed !== "object") { + throw new Error("Decrypted payload is not a valid object"); + } + + return { + uid: parsed.uid || null, + sk: parsed.sk || null, + url: parsed.url || XIAOMI_MIMO_CONFIG.defaultBaseUrl, + }; +} + +/** + * Build the browser authorization URL. + * @param {string} publicKey β€” base64 SPKI from generateKeyPair() + * @param {string} redirectUri β€” e.g. http://localhost:12345/ + * @param {string} [keyName] β€” optional stable key name + * @returns {string} + */ +export function buildAuthorizeUrl(publicKey, redirectUri, keyName) { + const params = new URLSearchParams({ + pk: publicKey, + redirect_uri: redirectUri, + kn: XIAOMI_MIMO_CONFIG.kn, + }); + if (keyName) params.set("key_name", keyName); + return `${XIAOMI_MIMO_CONFIG.platformUrl}/authorize?${params.toString()}`; +} + +/** + * Get or create a stable key name for this installation. + * Stored in the 9Router data dir so re-auth reuses the same name. + */ +export function getKeyName() { + // Use a deterministic name based on machine β€” avoids needing filesystem writes + // in the OAuth provider layer. The platform treats key_name as a label only. + const machineId = crypto + .createHash("sha256") + .update(`${process.platform}-${process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown"}`) + .digest("hex") + .slice(0, 8); + return `9router-xmd-${machineId}`; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 56eb67b1..80377752 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -755,3 +755,185 @@ export function stopZedProxy() { zedProxyPort = null; } +// ─────────────────────────────────────────────────────────────────────────── +// Xiaomi MiMo Desktop OAuth callback proxy +// Receives the ECDH-encrypted `u` param, decrypts it, stores the session. +// ─────────────────────────────────────────────────────────────────────────── + +let xiaomiMimoProxyServer = null; +let xiaomiMimoProxyPort = null; +let xiaomiMimoProxyTimeout = null; + +const xiaomiMimoSessions = new Map(); + +export function registerXiaomiMimoSession({ state, privateKeyDer }) { + if (!state || !privateKeyDer) return false; + xiaomiMimoSessions.set(state, { + privateKeyDer, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +export function getXiaomiMimoSessionStatus(state) { + const s = xiaomiMimoSessions.get(state); + if (!s) return null; + // Don't leak the private key to the client + return { status: s.status, result: s.result || null, error: s.error || null }; +} + +export function clearXiaomiMimoSession(state) { + xiaomiMimoSessions.delete(state); +} + +function renderXiaomiMimoResultPage(success, message) { + const color = success ? "#22c55e" : "#ef4444"; + const icon = success ? "✓" : "✗"; + const title = success ? "Authentication Successful" : "Authentication Failed"; + return ` + +${title} + + + +
+
${icon}
+

${title}

+

${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}

+ ${success ? "" : ""} +
+ +`; +} + +/** + * Start the Xiaomi Desktop OAuth callback proxy. + * @returns {Promise<{success: boolean, port?: number, callbackUrl?: string, reason?: string}>} + */ +export function startXiaomiMimoProxy() { + return new Promise((resolve) => { + if (xiaomiMimoProxyServer) { + resolve({ + success: true, + port: xiaomiMimoProxyPort, + callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`, + }); + return; + } + + const server = http.createServer(async (req, res) => { + // Origin guard + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const url = new URL(req.url, "http://127.0.0.1"); + const u = url.searchParams.get("u"); + + if (!u) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, "Missing encrypted payload (u parameter).")); + return; + } + + // Try each pending session's private key β€” the callback URL carries no + // state param, so we attempt decryption with every pending key. + const pendingSessions = [...xiaomiMimoSessions.entries()] + .filter(([, s]) => s.status === "pending"); + + if (pendingSessions.length === 0) { + res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, "No active OAuth session. Please restart the login flow.")); + return; + } + + try { + const { decryptCallback } = await import("../providers/xiaomi-mimo.js"); + let result = null; + let matchedState = null; + + for (const [state, session] of pendingSessions) { + try { + result = decryptCallback(session.privateKeyDer, u); + matchedState = state; + break; + } catch { + // Wrong key for this session β€” try next + } + } + + if (!result || !matchedState) { + throw new Error("Could not decrypt with any pending session key"); + } + + if (!result.sk) { + throw new Error("Decrypted payload missing sk (API key)"); + } + + // Store result only in the matched session + const session = xiaomiMimoSessions.get(matchedState); + if (session) { + session.status = "done"; + session.result = { + uid: result.uid, + accessToken: result.sk, + baseUrl: result.url || "https://api.xiaomimimo.com/v1", + }; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(true, "Xiaomi account linked. You can close this tab.")); + console.log("[xiaomi-mimo oauth] callback decrypted, uid:", result.uid); + } catch (err) { + console.error("[xiaomi-mimo oauth] decrypt failed:", err.message); + for (const [, session] of pendingSessions) { + session.status = "error"; + session.error = err.message; + } + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXiaomiMimoResultPage(false, `Decryption failed: ${err.message}`)); + } + }); + + server.on("error", (err) => { + console.log("[xiaomi-mimo oauth] listen error:", err.message); + resolve({ success: false, reason: err.message }); + }); + + server.listen(0, "127.0.0.1", () => { + xiaomiMimoProxyServer = server; + xiaomiMimoProxyPort = server.address().port; + xiaomiMimoProxyTimeout = setTimeout(() => { + console.log("[xiaomi-mimo oauth] timeout, stopping"); + stopXiaomiMimoProxy(); + }, 300000); + console.log(`[xiaomi-mimo oauth] listening on port ${xiaomiMimoProxyPort}`); + resolve({ + success: true, + port: xiaomiMimoProxyPort, + callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`, + }); + }); + }); +} + +export function stopXiaomiMimoProxy() { + console.log(`[xiaomi-mimo oauth] stopping (port ${xiaomiMimoProxyPort || "-"})`); + if (xiaomiMimoProxyTimeout) { clearTimeout(xiaomiMimoProxyTimeout); xiaomiMimoProxyTimeout = null; } + if (xiaomiMimoProxyServer) { xiaomiMimoProxyServer.close(); xiaomiMimoProxyServer = null; } + xiaomiMimoProxyPort = null; + // No callback can arrive once the listener is down, so drop every pending + // session β€” each holds an X25519 private key and they would otherwise + // accumulate for the process lifetime (one per /authorize call). + xiaomiMimoSessions.clear(); +} + diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 6e88622f..1645f190 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -20,6 +20,54 @@ const PROVIDER_ORDER = [ // Providers that need no auth β€” always show in model selector const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter(id => FREE_PROVIDERS[id].noAuth); +// Providers with per-account live catalogs via /api/providers/[id]/models. +// Static registry stays as fallback when live fetch fails or is empty. +const LIVE_CATALOG_PROVIDERS = ["cursor", "cline", "clinepass"]; + +// Fetch a provider's account-scoped catalog for every active connection and merge +// the results. Entries collapse by model id on purpose: two connections of the +// same provider produce the same picker value (`alias/id`), so keeping the first +// avoids duplicate rows. There is no per-connection metadata to preserve beyond +// {id,name}. Empty array means "nothing live" so callers keep the static fallback. +function useLiveProviderModels(isOpen, connectionIds, label) { + const [models, setModels] = useState([]); + const idsKey = (connectionIds ?? []).join("|"); + + useEffect(() => { + const ids = idsKey ? idsKey.split("|") : []; + if (!isOpen || ids.length === 0) { + setModels([]); + return undefined; + } + + let cancelled = false; + Promise.all(ids.map(async (connectionId) => { + const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + })) + .then((modelLists) => { + if (cancelled) return; + const seen = new Set(); + setModels(modelLists.flat().filter((model) => { + if (!model?.id || seen.has(model.id)) return false; + seen.add(model.id); + return true; + })); + }) + .catch((error) => { + // Do not hide the static fallback when the account catalog is unavailable. + console.warn(`Unable to load ${label} models for selector:`, error); + if (!cancelled) setModels([]); + }); + + return () => { cancelled = true; }; + }, [isOpen, idsKey, label]); + + return models; +} + export default function ModelSelectModal({ isOpen, onClose, @@ -50,48 +98,25 @@ export default function ModelSelectModal({ const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); const [disabledModels, setDisabledModels] = useState({}); - const [cursorModels, setCursorModels] = useState([]); - - // Cursor exposes the usable catalog per account. Keep the static catalog only - // as a fallback, since it quickly becomes stale and different accounts can - // have different model entitlements. - const cursorConnectionIds = useMemo( - () => activeProviders - .filter((provider) => provider.provider === "cursor" && provider.id) - .map((provider) => provider.id), - [activeProviders], - ); - - useEffect(() => { - if (!isOpen || cursorConnectionIds.length === 0) { - setCursorModels([]); - return undefined; + // Cursor and Cline expose the usable catalog per account, so the static catalog is + // kept only as a fallback: it goes stale quickly and entitlements differ per account. + // Single map driven by LIVE_CATALOG_PROVIDERS so the constant cannot drift + // from the memos below; per-provider arrays stay referentially stable unless + // activeProviders itself changes. + const liveConnectionIdsByProvider = useMemo(() => { + const map = Object.fromEntries(LIVE_CATALOG_PROVIDERS.map((id) => [id, []])); + for (const p of activeProviders) { + if (p?.id && Object.prototype.hasOwnProperty.call(map, p.provider)) map[p.provider].push(p.id); } + return map; + }, [activeProviders]); + const cursorConnectionIds = liveConnectionIdsByProvider.cursor; + const clineConnectionIds = liveConnectionIdsByProvider.cline; + const clinepassConnectionIds = liveConnectionIdsByProvider.clinepass; - let cancelled = false; - Promise.all(cursorConnectionIds.map(async (connectionId) => { - const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); - if (!response.ok) return []; - const data = await response.json(); - return Array.isArray(data.models) ? data.models : []; - })) - .then((modelLists) => { - if (cancelled) return; - const seen = new Set(); - setCursorModels(modelLists.flat().filter((model) => { - if (!model?.id || seen.has(model.id)) return false; - seen.add(model.id); - return true; - })); - }) - .catch((error) => { - // Do not hide the static fallback when the account catalog is unavailable. - console.warn("Unable to load Cursor models for selector:", error); - if (!cancelled) setCursorModels([]); - }); - - return () => { cancelled = true; }; - }, [isOpen, cursorConnectionIds]); + const cursorModels = useLiveProviderModels(isOpen, cursorConnectionIds, "Cursor"); + const clineModels = useLiveProviderModels(isOpen, clineConnectionIds, "Cline"); + const clinepassModels = useLiveProviderModels(isOpen, clinepassConnectionIds, "ClinePass"); const fetchCombos = async () => { try { @@ -324,8 +349,9 @@ export default function ModelSelectModal({ hasModels: mergedModels.length > 0, }; } else { - const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 - ? cursorModels + const liveModels = providerId === "cursor" ? cursorModels : providerId === "cline" ? clineModels : providerId === "clinepass" ? clinepassModels : []; + const hardcodedModels = liveModels.length > 0 + ? liveModels : getModelsByProviderId(providerId); const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); @@ -395,7 +421,7 @@ export default function ModelSelectModal({ }); return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels, clineModels, clinepassModels]); // Filter combos by search query (and hide combos when kindFilter is set β€” combos are LLM-only by design) const filteredCombos = useMemo(() => { diff --git a/src/shared/components/XiaomiMimoAuthModal.js b/src/shared/components/XiaomiMimoAuthModal.js new file mode 100644 index 00000000..e501698f --- /dev/null +++ b/src/shared/components/XiaomiMimoAuthModal.js @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button } from "@/shared/components"; + +/** + * Xiaomi MiMo Auth Modal + * + * Auto-imports credentials from the local Xiaomi MiMo Desktop auth.json (~/.local/share/mimocode/auth.json). + * If auto-import fails, offers a one-click browser OAuth fallback. + * Reached only via the "Connect with OAuth" button β€” the API-key path uses the + * standard Add API Key modal, since Xiaomi MiMo supports both auth modes. + */ +export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { + const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing | error + const [detectResult, setDetectResult] = useState(null); + const [error, setError] = useState(null); + const [oauthUrl, setOauthUrl] = useState(null); + const [oauthState, setOauthState] = useState(null); + + // Auto-detect local credentials when modal opens + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + + (async () => { + setPhase("detecting"); + setError(null); + setDetectResult(null); + setOauthUrl(null); + + try { + const res = await fetch("/api/oauth/xiaomi-mimo/auto-import"); + const data = await res.json(); + if (cancelled) return; + + if (data.found && data.apiKey) { + setDetectResult(data); + setPhase("found"); + } else { + setPhase("not-found"); + setError(data.error || "Xiaomi MiMo Desktop credentials not found on this machine."); + } + } catch { + if (!cancelled) { + setPhase("not-found"); + setError("Failed to read local Xiaomi MiMo Desktop credentials."); + } + } + })(); + + return () => { cancelled = true; }; + }, [isOpen]); + + // Import the auto-detected key + const handleImport = async () => { + if (!detectResult?.apiKey) return; + setPhase("importing"); + setError(null); + + try { + const res = await fetch("/api/oauth/xiaomi-mimo/api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: detectResult.apiKey, + uid: detectResult.uid, + baseUrl: detectResult.baseUrl, + mimoPassToken: detectResult.mimoPassToken || null, + mimoUserId: detectResult.mimoUserId || null, + mimoCUserId: detectResult.mimoCUserId || null, + }), + }); + const data = await res.json(); + + if (!res.ok || !data.success) { + throw new Error(data.error || "Import failed"); + } + + onSuccess?.(data.connection); + onClose(); + } catch (err) { + setPhase("found"); + setError(err.message); + } + }; + + // Start browser OAuth fallback + const handleStartOAuth = async () => { + setError(null); + try { + const state = crypto.randomUUID(); + const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`); + const data = await res.json(); + if (data.authorizeUrl) { + setOauthUrl(data.authorizeUrl); + setOauthState(data.state); + window.open(data.authorizeUrl, "_blank", "width=600,height=700"); + } else { + throw new Error(data.error || "Failed to start OAuth"); + } + } catch (err) { + setError(err.message); + } + }; + + // Poll OAuth result + const handlePollOAuth = async () => { + if (!oauthState) return; + setError(null); + try { + const res = await fetch(`/api/oauth/xiaomi-mimo/poll-status?state=${oauthState}`); + const data = await res.json(); + + if (data.status === "done" && data.result) { + // Exchange to create the connection + const exRes = await fetch("/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: oauthState }), + }); + const exData = await exRes.json(); + if (exData.success) { + onSuccess?.(exData.connection); + onClose(); + } else { + throw new Error(exData.error || "Exchange failed"); + } + } else if (data.status === "error") { + throw new Error(data.error || "OAuth failed"); + } else { + setError("Authorization not completed yet. Finish in the browser, then click Check Again."); + } + } catch (err) { + setError(err.message); + } + }; + + return ( + +
+ {/* Detecting */} + {phase === "detecting" && ( +
+
+ + progress_activity + +
+

Reading local credentials...

+

+ Checking ~/.local/share/mimocode/auth.json +

+
+ )} + + {/* Found β€” one-click import */} + {phase === "found" && detectResult && ( + <> +
+
+ check_circle +
+

Xiaomi MiMo Desktop credentials found!

+

+ UID: {detectResult.uid || "β€”"} Β· Source: {detectResult.source?.split(/[\\/]/).pop()} +

+
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+ + )} + + {/* Importing */} + {phase === "importing" && ( +
+
+ + progress_activity + +
+

Connecting...

+
+ )} + + {/* Not found β€” offer OAuth fallback */} + {phase === "not-found" && ( + <> +
+
+ info +
+

Local credentials not found

+

{error}

+

+ Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry. + Or sign in via browser below. +

+
+
+
+ + {!oauthUrl ? ( +
+ + +
+ ) : ( +
+
+

+ Browser opened. Complete the Xiaomi sign-in, then click{" "} + Check Again. +

+
+
+ + +
+
+ )} + + )} +
+
+ ); +} + +XiaomiMimoAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 6ab27790..8e180c06 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -28,6 +28,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal"; export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; export { default as CursorAuthModal } from "./CursorAuthModal"; +export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal"; export { default as IFlowCookieModal } from "./IFlowCookieModal"; export { default as GitLabAuthModal } from "./GitLabAuthModal"; export { default as EditConnectionModal } from "./EditConnectionModal"; diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 0f7c0236..a6bb4685 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -199,7 +199,7 @@ export const CLI_TOOLS = { id: "cline", name: "Cline", image: "/providers/cline.png", - color: "#00D1B2", + color: "#5B9BD5", description: "Cline AI Coding Assistant", configType: "custom", }, diff --git a/src/sse/handlers/videoGeneration.js b/src/sse/handlers/videoGeneration.js index 67142899..af19da19 100644 --- a/src/sse/handlers/videoGeneration.js +++ b/src/sse/handlers/videoGeneration.js @@ -5,7 +5,7 @@ import { extractApiKey, isValidApiKey, } from "../services/auth.js"; -import { getSettings } from "@/lib/localDb"; +import { getSettings, getProviderConnectionById } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; @@ -17,6 +17,21 @@ import * as log from "../utils/logger.js"; // (bare model id, or multipart bodies we deliberately don't parse) land here. const DEFAULT_VIDEO_PROVIDER = "xai"; +/** + * Poll requests carry no model, so the provider comes from the pinned + * connection (`x-connection-id`, returned on create) or an explicit + * `?provider=` β€” falling back to the historical xAI default. + */ +async function resolveGetProvider(request, connectionId) { + if (connectionId) { + const conn = await getProviderConnectionById(connectionId).catch(() => null); + if (conn?.provider && getVideoConfig(conn.provider)) return conn.provider; + } + const queried = new URL(request.url).searchParams.get("provider"); + if (queried && getVideoConfig(queried)) return queried; + return DEFAULT_VIDEO_PROVIDER; +} + // Creation POSTs are billable jobs β€” only rotate to another account for // errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may // have created the job, so it is returned to the caller instead of re-sent. @@ -185,8 +200,8 @@ export async function handleVideoGet(request, requestId) { if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id"); - const provider = DEFAULT_VIDEO_PROVIDER; const preferredConnectionId = request.headers.get("x-connection-id") || null; + const provider = await resolveGetProvider(request, preferredConnectionId); const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId }); if (!credentials || credentials.allRateLimited) { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index db0b8bad..2dd09f55 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -192,9 +192,10 @@ "baseUrl": "https://chatgpt.com/backend-api/codex/responses", "format": "openai-responses", "forceStream": true, + "cliVersion": "0.154.0", "headers": { "originator": "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.136.0" + "User-Agent": "codex_cli_rs/0.154.0" }, "usage": { "url": "https://chatgpt.com/backend-api/wham/usage", diff --git a/tests/translator/bugs-3905-deepseek-tool-type.test.js b/tests/translator/bugs-3905-deepseek-tool-type.test.js new file mode 100644 index 00000000..fd561d0b --- /dev/null +++ b/tests/translator/bugs-3905-deepseek-tool-type.test.js @@ -0,0 +1,33 @@ +// Regression for #3905: defaultClaudeToolType() (type:"custom") must only run for +// gateways that declare the requireClaudeToolType quirk (MiniMax). Claude-format +// endpoints that only accept the legacy typeless tool shape β€” e.g. DeepSeek's +// Anthropic-compatible endpoint, which answers HTTP 400 "unknown variant `custom`" β€” +// must never receive tools[].type = "custom". +import { describe, it, expect } from "vitest"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { shouldDefaultClaudeToolType } from "../../open-sse/translator/concerns/toolCall.js"; + +const tools = [{ name: "get_weather", description: "weather", input_schema: { type: "object" } }]; + +describe("Claude tool `type` defaulting is provider-scoped (#3905)", () => { + it("runs only for providers declaring requireClaudeToolType", () => { + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true); + expect(shouldDefaultClaudeToolType("minimax-cn", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true); + // Endpoints accepting only the legacy typeless shape must NOT get type:"custom". + expect(shouldDefaultClaudeToolType("deepseek", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("claude", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false); + }); + + it("never applies outside Claude-format requests or without tools", () => { + expect(shouldDefaultClaudeToolType("minimax", FORMATS.OPENAI, tools, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, undefined, PROVIDERS)).toBe(false); + expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, null, PROVIDERS)).toBe(false); + }); + + it("declares the quirk only on the MiniMax providers (registry tripwire)", () => { + expect(PROVIDERS.minimax?.quirks?.requireClaudeToolType).toBe(true); + expect(PROVIDERS["minimax-cn"]?.quirks?.requireClaudeToolType).toBe(true); + expect(PROVIDERS.deepseek?.quirks?.requireClaudeToolType).toBeUndefined(); + }); +}); diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 3fca7be1..ab122b0c 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -26,9 +26,8 @@ describe("Claude β†’ Kiro (direct route)", () => { expect(first.conversationState.conversationId).toBe("hermes-session-123-claude-replay"); expect(second.conversationState.conversationId).toBe("hermes-session-123-claude-replay"); - expect(first.conversationState.agentContinuationId).toBeTruthy(); - expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId); - expect(first.conversationState.agentTaskType).toBe("vibe"); + expect(first.conversationState).not.toHaveProperty("agentContinuationId"); + expect(second.conversationState).not.toHaveProperty("agentTaskType"); expect(second.conversationState.history[0].userInputMessage.content).toBe( first.conversationState.currentMessage.userInputMessage.content ); @@ -84,7 +83,7 @@ describe("Claude β†’ Kiro (direct route)", () => { expect(out.systemPrompt).toContain( "enabled" ); - expect(out.agentMode).toBe("vibe"); + expect(out).not.toHaveProperty("agentMode"); }); it("does not send additionalModelRequestFields for Kiro models without effort support", () => { diff --git a/tests/unit/antigravity-quota-gemini-3.6.test.js b/tests/unit/antigravity-quota-gemini-3.6.test.js index c67924d6..042983d1 100644 --- a/tests/unit/antigravity-quota-gemini-3.6.test.js +++ b/tests/unit/antigravity-quota-gemini-3.6.test.js @@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({ ok: true, status: 200, json: async () => url.includes(":loadCodeAssist") - ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } } + ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } } : { models: { "gemini-3.6-flash-high": { diff --git a/tests/unit/antigravity-quota-gemini-3.7.test.js b/tests/unit/antigravity-quota-gemini-3.7.test.js index e172be31..e1cad3a9 100644 --- a/tests/unit/antigravity-quota-gemini-3.7.test.js +++ b/tests/unit/antigravity-quota-gemini-3.7.test.js @@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({ ok: true, status: 200, json: async () => url.includes(":loadCodeAssist") - ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } } + ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } } : { models: { "gemini-3.7-flash-high": { diff --git a/tests/unit/antigravity-quota-gemini-3.8.test.js b/tests/unit/antigravity-quota-gemini-3.8.test.js index cb8637e5..8dd0056c 100644 --- a/tests/unit/antigravity-quota-gemini-3.8.test.js +++ b/tests/unit/antigravity-quota-gemini-3.8.test.js @@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({ ok: true, status: 200, json: async () => url.includes(":loadCodeAssist") - ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } } + ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } } : { models: { "gemini-3.8-flash-high": { diff --git a/tests/unit/antigravity-usage-headers.test.js b/tests/unit/antigravity-usage-headers.test.js index 74cf5287..363f5b6c 100644 --- a/tests/unit/antigravity-usage-headers.test.js +++ b/tests/unit/antigravity-usage-headers.test.js @@ -4,8 +4,10 @@ const proxyAwareFetch = vi.fn(async (url) => ({ ok: true, status: 200, json: async () => url.includes(":loadCodeAssist") - ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } } - : { models: {} }, + ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } } + : url.includes(":retrieveUserQuotaSummary") + ? { groups: [] } + : { models: {} }, text: async () => "{}", })); @@ -21,7 +23,8 @@ describe("Antigravity usage headers", () => { await getAntigravityUsage("access-token", {}); - expect(proxyAwareFetch).toHaveBeenCalledTimes(2); + // loadCodeAssist + fetchAvailableModels + retrieveUserQuotaSummary + expect(proxyAwareFetch).toHaveBeenCalledTimes(3); for (const [, options] of proxyAwareFetch.mock.calls) { expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64"); expect(options.headers).not.toHaveProperty("x-request-source"); diff --git a/tests/unit/antigravity-weekly-dashboard.test.js b/tests/unit/antigravity-weekly-dashboard.test.js new file mode 100644 index 00000000..1c4a2784 --- /dev/null +++ b/tests/unit/antigravity-weekly-dashboard.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +describe("Antigravity dashboard normalization with weekly quotas", () => { + const data = { + quotas: { + "gemini-pro-agent": { + displayName: "Gemini 3.1 Pro (High)", + used: 200, + total: 1000, + resetAt: "2026-09-08T00:00:00Z", + remainingPercentage: 80, + }, + "claude-opus-4-6-thinking": { + displayName: "Claude Opus 4.6 (Thinking)", + used: 100, + total: 1000, + resetAt: "2026-09-08T00:00:00Z", + remainingPercentage: 90, + }, + gemini_weekly: { + displayName: "Gemini (Weekly)", + used: 250, + total: 1000, + resetAt: "2026-09-15T00:00:00Z", + remainingPercentage: 75, + }, + claude_gpt_weekly: { + displayName: "Claude & GPT (Weekly)", + used: 500, + total: 1000, + resetAt: "2026-09-14T00:00:00Z", + remainingPercentage: 50, + }, + }, + }; + + it("includes weekly rows with correct display names", () => { + const quotas = parseQuotaData("antigravity", data); + const names = quotas.map((q) => q.name); + + expect(names).toContain("Gemini (Flash / Pro)"); + expect(names).toContain("Claude (Sonnet / Opus)"); + expect(names).toContain("Gemini (Weekly)"); + expect(names).toContain("Claude & GPT (Weekly)"); + }); + + it("uses stable modelKey for weekly rows", () => { + const quotas = parseQuotaData("antigravity", data); + const keys = quotas.map((q) => q.modelKey); + + expect(keys).toContain("gemini_weekly"); + expect(keys).toContain("claude_gpt_weekly"); + }); + + it("weekly rows carry correct quota values", () => { + const quotas = parseQuotaData("antigravity", data); + const geminiWeekly = quotas.find((q) => q.modelKey === "gemini_weekly"); + const claudeWeekly = quotas.find((q) => q.modelKey === "claude_gpt_weekly"); + + expect(geminiWeekly).toMatchObject({ + used: 250, + total: 1000, + remainingPercentage: 75, + resetAt: "2026-09-15T00:00:00Z", + }); + expect(claudeWeekly).toMatchObject({ + used: 500, + total: 1000, + remainingPercentage: 50, + resetAt: "2026-09-14T00:00:00Z", + }); + }); + + it("weekly rows do NOT appear as otherModels", () => { + const quotas = parseQuotaData("antigravity", data); + const weeklyRows = quotas.filter((q) => + q.modelKey === "gemini_weekly" || q.modelKey === "claude_gpt_weekly" + ); + expect(weeklyRows).toHaveLength(2); + expect(weeklyRows[0].name).toMatch(/Weekly/); + expect(weeklyRows[1].name).toMatch(/Weekly/); + }); + + it("order: gemini family, claude family, weekly, then other", () => { + const quotas = parseQuotaData("antigravity", data); + const keys = quotas.map((q) => q.modelKey); + + const geminiIdx = keys.indexOf("gemini"); + const claudeIdx = keys.indexOf("claude"); + const geminiWeeklyIdx = keys.indexOf("gemini_weekly"); + const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly"); + + expect(geminiIdx).toBeLessThan(geminiWeeklyIdx); + expect(claudeIdx).toBeLessThan(claudeWeeklyIdx); + }); + + it("works with no weekly keys present (backward compat)", () => { + const noWeekly = { + quotas: { + "gemini-pro-agent": { + displayName: "Gemini 3.1 Pro (High)", + used: 200, + total: 1000, + remainingPercentage: 80, + }, + }, + }; + const quotas = parseQuotaData("antigravity", noWeekly); + expect(quotas).toHaveLength(1); + expect(quotas[0].name).toBe("Gemini (Flash / Pro)"); + }); +}); diff --git a/tests/unit/antigravity-weekly-quota.test.js b/tests/unit/antigravity-weekly-quota.test.js new file mode 100644 index 00000000..55cb3a81 --- /dev/null +++ b/tests/unit/antigravity-weekly-quota.test.js @@ -0,0 +1,464 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock proxyAwareFetch before any imports that use it +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { + parseWeeklyQuotaSummary, + fetchAntigravityWeeklyQuota, + _clearWeeklyCache, +} from "../../open-sse/services/usage/antigravity-weekly.js"; + +// β€” Fixtures β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +const GEMINI_GROUP = { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-weekly-bucket", + displayName: "Weekly Limit", + remainingFraction: 0.75, + resetTime: "2026-09-15T00:00:00Z", + }, + { + bucketId: "gemini-daily-bucket", + displayName: "Daily Limit", + remainingFraction: 0.9, + resetTime: "2026-09-09T00:00:00Z", + }, + ], +}; + +const CLAUDE_GPT_GROUP = { + displayName: "Claude and GPT models", + buckets: [ + { + bucketId: "claude-gpt-weekly", + displayName: "Weekly Quota", + remainingFraction: 0.5, + resetTime: "2026-09-14T00:00:00Z", + }, + ], +}; + +const FULL_RESPONSE = { groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP] }; + +const NESTED_RESPONSE = { + quotaSummary: { + groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP], + }, +}; + +// β€” parseWeeklyQuotaSummary β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +describe("parseWeeklyQuotaSummary", () => { + it("extracts Gemini weekly quota from top-level groups", () => { + const result = parseWeeklyQuotaSummary(FULL_RESPONSE); + expect(result.gemini_weekly).toMatchObject({ + used: 250, + total: 1000, + remainingPercentage: 75, + displayName: "Gemini (Weekly)", + unlimited: false, + }); + expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z"); + }); + + it("extracts Claude & GPT weekly quota", () => { + const result = parseWeeklyQuotaSummary(FULL_RESPONSE); + expect(result.claude_gpt_weekly).toMatchObject({ + used: 500, + total: 1000, + remainingPercentage: 50, + displayName: "Claude & GPT (Weekly)", + unlimited: false, + }); + expect(result.claude_gpt_weekly.resetAt).toBe("2026-09-14T00:00:00.000Z"); + }); + + it("handles alternate nested quotaSummary.groups shape", () => { + const result = parseWeeklyQuotaSummary(NESTED_RESPONSE); + expect(result.gemini_weekly).toBeDefined(); + expect(result.claude_gpt_weekly).toBeDefined(); + expect(result.gemini_weekly.remainingPercentage).toBe(75); + expect(result.claude_gpt_weekly.remainingPercentage).toBe(50); + }); + + it("skips non-weekly buckets", () => { + const data = { + groups: [{ + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-daily-bucket", + displayName: "Daily Limit", + remainingFraction: 0.9, + resetTime: "2026-09-09T00:00:00Z", + }, + ], + }], + }; + const result = parseWeeklyQuotaSummary(data); + expect(result).toEqual({}); + }); + + it("skips disabled weekly buckets", () => { + const data = { + groups: [{ + displayName: "Gemini Models", + buckets: [{ + bucketId: "gemini-weekly-bucket", + displayName: "Weekly Limit", + remainingFraction: 0.75, + resetTime: "2026-09-15T00:00:00Z", + disabled: true, + }], + }], + }; + const result = parseWeeklyQuotaSummary(data); + expect(result).toEqual({}); + }); + + it("returns empty object for null/undefined input", () => { + expect(parseWeeklyQuotaSummary(null)).toEqual({}); + expect(parseWeeklyQuotaSummary(undefined)).toEqual({}); + expect(parseWeeklyQuotaSummary("string")).toEqual({}); + }); + + it("returns empty object for response with no groups", () => { + expect(parseWeeklyQuotaSummary({})).toEqual({}); + expect(parseWeeklyQuotaSummary({ groups: "not-array" })).toEqual({}); + expect(parseWeeklyQuotaSummary({ quotaSummary: {} })).toEqual({}); + }); + + it("handles groups with no buckets gracefully", () => { + const data = { + groups: [{ displayName: "Gemini Models" }], + }; + expect(parseWeeklyQuotaSummary(data)).toEqual({}); + }); + + it("handles bucket with non-finite remainingFraction", () => { + const data = { + groups: [{ + displayName: "Gemini Models", + buckets: [{ + bucketId: "weekly-bucket", + displayName: "Weekly", + remainingFraction: "not-a-number", + }], + }], + }; + expect(parseWeeklyQuotaSummary(data)).toEqual({}); + }); + + it("ignores groups that don't match known families", () => { + const data = { + groups: [{ + displayName: "Unknown AI Provider", + buckets: [{ + bucketId: "weekly-bucket", + displayName: "Weekly", + remainingFraction: 0.5, + }], + }], + }; + expect(parseWeeklyQuotaSummary(data)).toEqual({}); + }); +}); + +// β€” fetchAntigravityWeeklyQuota β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” +describe("fetchAntigravityWeeklyQuota", () => { + beforeEach(() => { + proxyAwareFetch.mockReset(); + _clearWeeklyCache(); + }); + + it("fetches and returns parsed weekly quota on success", async () => { + proxyAwareFetch.mockResolvedValue({ + ok: true, + json: async () => FULL_RESPONSE, + }); + + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result.gemini_weekly).toBeDefined(); + expect(result.claude_gpt_weekly).toBeDefined(); + }); + + it("returns {} on HTTP 401", async () => { + proxyAwareFetch.mockResolvedValue({ ok: false, status: 401 }); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("returns {} on HTTP 403", async () => { + proxyAwareFetch.mockResolvedValue({ ok: false, status: 403 }); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("returns {} on HTTP 404", async () => { + proxyAwareFetch.mockResolvedValue({ ok: false, status: 404 }); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("returns {} on HTTP 429", async () => { + proxyAwareFetch.mockResolvedValue({ ok: false, status: 429 }); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("returns {} on network error", async () => { + proxyAwareFetch.mockRejectedValue(new Error("network timeout")); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("returns {} on malformed JSON response", async () => { + proxyAwareFetch.mockResolvedValue({ + ok: true, + json: async () => { throw new SyntaxError("Unexpected token"); }, + }); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + expect(result).toEqual({}); + }); + + it("deduplicates concurrent requests for the same account", async () => { + let resolveResponse; + proxyAwareFetch.mockReturnValue(new Promise(resolve => { + resolveResponse = resolve; + })); + + const p1 = fetchAntigravityWeeklyQuota("token", "project-1"); + const p2 = fetchAntigravityWeeklyQuota("token", "project-1"); + + resolveResponse({ ok: true, json: async () => FULL_RESPONSE }); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + + it("serves cached result within TTL", async () => { + proxyAwareFetch.mockResolvedValue({ + ok: true, + json: async () => FULL_RESPONSE, + }); + + await fetchAntigravityWeeklyQuota("token", "project-1"); + const result = await fetchAntigravityWeeklyQuota("token", "project-1"); + + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + expect(result.gemini_weekly).toBeDefined(); + }); + + it("sends correct headers and body", async () => { + proxyAwareFetch.mockResolvedValue({ + ok: true, + json: async () => ({ groups: [] }), + }); + + await fetchAntigravityWeeklyQuota("token", "project-1"); + + expect(proxyAwareFetch).toHaveBeenCalledWith( + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Authorization": "Bearer token", + "User-Agent": "antigravity/ide/2.11.0 darwin/arm64", + "Content-Type": "application/json", + "X-Client-Name": "antigravity", + }), + body: JSON.stringify({ project: "project-1" }), + }), + expect.any(Object), + ); + }); +}); + +// β€” Integration: weekly failure does not affect existing quotas β€”β€”β€”β€”β€” +describe("weekly quota isolation from existing quota", () => { + beforeEach(() => { + proxyAwareFetch.mockReset(); + _clearWeeklyCache(); + }); + + it("existing getAntigravityUsage succeeds even when weekly RPC fails", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (url.includes(":loadCodeAssist")) { + return { + ok: true, + status: 200, + json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }), + }; + } + if (url.includes(":fetchAvailableModels")) { + return { + ok: true, + status: 200, + json: async () => ({ + models: { + "gemini-3.8-flash-high": { + displayName: "Gemini 3.8 Flash (High)", + quotaInfo: { remainingFraction: 0.85, resetTime: "2026-09-15T00:00:00Z" }, + }, + }, + }), + }; + } + if (url.includes(":retrieveUserQuotaSummary")) { + throw new Error("weekly endpoint unavailable"); + } + return { ok: false, status: 404 }; + }); + + const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js"); + const result = await getAntigravityUsage("token", {}); + + expect(result.quotas["gemini-3.8-flash-high"]).toMatchObject({ + used: 150, + total: 1000, + remainingPercentage: 85, + }); + expect(result.quotas.gemini_weekly).toBeUndefined(); + expect(result.quotas.claude_gpt_weekly).toBeUndefined(); + expect(result.message).toBeUndefined(); + }); + + it("free-tier accounts only show weekly quotas, not per-model short-window quotas", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (url.includes(":loadCodeAssist")) { + return { + ok: true, + status: 200, + json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Starter" }, paidTier: { id: "free-tier", name: "Antigravity Starter Quota" } }), + }; + } + if (url.includes(":fetchAvailableModels")) { + return { + ok: true, + status: 200, + json: async () => ({ + models: { + "gemini-3.8-flash-high": { + displayName: "Gemini 3.8 Flash (High)", + quotaInfo: { remainingFraction: 1, resetTime: "2026-09-15T00:00:00Z" }, + }, + "claude-sonnet-4-6": { + displayName: "Claude Sonnet 4.6", + // Missing remainingFraction β€” free tier exhausted + quotaInfo: { resetTime: "2026-09-13T12:00:00Z" }, + }, + }, + }), + }; + } + if (url.includes(":retrieveUserQuotaSummary")) { + return { + ok: true, + status: 200, + json: async () => ({ + groups: [{ + displayName: "Gemini Models", + buckets: [{ + bucketId: "gemini-weekly", + displayName: "Weekly Limit Remaining", + remainingFraction: 1, + resetTime: "2026-09-15T00:00:00Z", + }], + }, { + displayName: "Claude and GPT models", + buckets: [{ + bucketId: "3p-weekly", + displayName: "Weekly Limit Remaining", + remainingFraction: 0, + resetTime: "2026-09-13T12:00:00Z", + }], + }], + }), + }; + } + return { ok: false, status: 404 }; + }); + + const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js"); + const result = await getAntigravityUsage("token", {}); + + // Per-model quotas should be absent (free-tier accounts skip model parsing) + expect(result.quotas["gemini-3.8-flash-high"]).toBeUndefined(); + expect(result.quotas["claude-sonnet-4-6"]).toBeUndefined(); + + // Only weekly quotas should appear + expect(result.quotas.gemini_weekly).toMatchObject({ + used: 0, + total: 1000, + remainingPercentage: 100, + }); + expect(result.quotas.claude_gpt_weekly).toMatchObject({ + used: 1000, + total: 1000, + remainingPercentage: 0, + }); + }); + + it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (url.includes(":loadCodeAssist")) { + return { + ok: true, + status: 200, + json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }), + }; + } + if (url.includes(":fetchAvailableModels")) { + return { + ok: true, + status: 200, + json: async () => ({ + models: { + "gemini-3.8-flash-high": { + displayName: "Gemini 3.8 Flash (High)", + // Exhausted model: no remainingFraction, future resetTime + quotaInfo: { resetTime: "2026-09-13T12:00:00Z" }, + }, + }, + }), + }; + } + if (url.includes(":retrieveUserQuotaSummary")) { + return { + ok: true, + status: 200, + json: async () => ({ + groups: [{ + displayName: "Gemini Models", + buckets: [{ + bucketId: "gemini-weekly", + displayName: "Weekly Limit Remaining", + remainingFraction: 1, + resetTime: "2026-09-15T00:00:00Z", + }], + }], + }), + }; + } + return { ok: false, status: 404 }; + }); + + const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js"); + const result = await getAntigravityUsage("token", {}); + + // Per-model quota should show exhausted + expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0); + // Weekly quota should be reconciled to 0% with the family reset time + expect(result.quotas.gemini_weekly).toMatchObject({ + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: "2026-09-13T12:00:00.000Z", + }); + }); +}); diff --git a/tests/unit/api-airforce-free-models.test.js b/tests/unit/api-airforce-free-models.test.js new file mode 100644 index 00000000..0b435981 --- /dev/null +++ b/tests/unit/api-airforce-free-models.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import airforce from "../../open-sse/providers/registry/api-airforce.js"; +import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; + +describe("api-airforce free models", () => { + const ids = airforce.models.map((m) => m.id); + + it("registers the three live free models", () => { + expect(ids).toContain("gpt-oss-120b"); + expect(ids).toContain("gpt-oss-20b"); + expect(ids).toContain("kimi-k2.7-code"); + }); + + it("drops the dead catalog ids", () => { + expect(ids).not.toContain("anthropic/claude-3.7-sonnet"); + expect(ids).not.toContain("moonshot/kimi-k2.6"); + expect(ids).not.toContain("google/gemini-2.5-flash"); + }); + + it("is passthrough so any live id resolves", () => { + expect(airforce.passthroughModels).toBe(true); + expect(PROVIDERS["api-airforce"].forceStream).toBe(true); + }); + + it("PROVIDER_MODELS['af'] exposes the new ids", () => { + expect(PROVIDER_MODELS.af.map((m) => m.id)).toEqual(expect.arrayContaining([ + "gpt-oss-120b", "gpt-oss-20b", "kimi-k2.7-code", + ])); + }); + + it("caps resolve for the free ids", () => { + expect(getCapabilitiesForModel("api-airforce", "kimi-k2.7-code").reasoning).toBe(true); + expect(getCapabilitiesForModel("api-airforce", "gpt-oss-120b").reasoning).toBe(true); + }); +}); diff --git a/tests/unit/claude-cache-budget-single-object.test.js b/tests/unit/claude-cache-budget-single-object.test.js new file mode 100644 index 00000000..e2680723 --- /dev/null +++ b/tests/unit/claude-cache-budget-single-object.test.js @@ -0,0 +1,222 @@ +// #3795 β€” a proxy must not dispatch more cache_control blocks than Anthropic +// accepts, and must not lose turns that use single-object content (#3567 interplay). +import { describe, it, expect } from "vitest"; +import { + anchorClaudeCache, + normalizeClaudePassthrough, + prepareClaudeRequest, +} from "../../open-sse/translator/formats/claude.js"; +import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js"; + +const CC = { type: "ephemeral" }; +const text = (t, extra = {}) => ({ type: "text", text: t, ...extra }); +const tool = (name, extra = {}) => ({ name, description: "d", input_schema: {}, ...extra }); + +// counts markers incl. single-object content β€” mirrors the upstream contract +function countMarkers(body) { + let n = 0; + if (Array.isArray(body.system)) for (const b of body.system) if (b?.cache_control) n++; + if (Array.isArray(body.tools)) for (const t of body.tools) if (t?.cache_control) n++; + if (Array.isArray(body.messages)) for (const m of body.messages) { + if (Array.isArray(m?.content)) { + for (const b of m.content) if (b?.cache_control) n++; + } else if (m?.content && typeof m.content === "object" && m.content.cache_control) n++; + } + return n; +} + +describe("cache marker budget and single-block content", () => { + it("never emits more than four markers when the client already spent its budget", () => { + const out = anchorClaudeCache({ + system: [text("s1"), text("s2", { cache_control: CC })], + tools: [tool("t1"), tool("t2", { cache_control: CC })], + messages: [ + { role: "user", content: text("u1", { cache_control: CC }) }, + { role: "assistant", content: text("a1", { cache_control: CC }) }, + { role: "user", content: [text("q")] }, + ], + }); + expect(countMarkers(out)).toBeLessThanOrEqual(4); // base: 5 + }); + + it("normalizes a single-object turn in passthrough and anchors it", () => { + const body = { + messages: [ + { role: "user", content: [text("u1")] }, + { role: "assistant", content: text("a1") }, // single object, no marker + { role: "user", content: [text("q")] }, + ], + }; + normalizeClaudePassthrough(body); + const assistant = body.messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Array.isArray(assistant.content)).toBe(true); // base: still bare object + expect(assistant.content).toHaveLength(1); + const out = anchorClaudeCache(body); + expect(countMarkers(out)).toBe(1); + }); + + it("keeps a turn whose content is a single object and strips its marker", () => { + const out = prepareClaudeRequest({ + model: "claude-sonnet-5", max_tokens: 100, + system: [text("s1")], + messages: [ + { role: "user", content: text("u1", { cache_control: CC }) }, + { role: "assistant", content: [text("a1")] }, + { role: "user", content: [text("q")] }, + ], + }, "claude"); + const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1")); + expect(kept.length).toBe(1); // base: 0 (dropped) + expect(kept[0].content).toHaveLength(1); // normalized to array + expect(kept[0].content[0].cache_control).toBeUndefined(); + }); + + it("drops no conversation turn when content is a single text object", () => { + const out = prepareClaudeRequest({ + model: "claude-sonnet-5", max_tokens: 100, + messages: [ + { role: "user", content: text("u1") }, + { role: "assistant", content: text("a1") }, + { role: "user", content: [text("q")] }, + ], + }, "claude"); + expect(out.messages.length).toBe(3); // base: 1 + expect(Array.isArray(out.messages[0].content)).toBe(true); + }); + + it("re-anchors the last assistant turn even when it uses single-object content", () => { + const out = prepareClaudeRequest({ + model: "claude-sonnet-5", max_tokens: 100, + messages: [ + { role: "user", content: [text("u1")] }, + { role: "assistant", content: text("a1") }, + { role: "user", content: [text("q")] }, + ], + }, "claude"); + expect(countMarkers(out)).toBe(1); // base: 0 + }); + + it("keeps a marked single-object turn when the marker budget is spent", () => { + const body = { + system: [text("s1", { cache_control: CC })], + tools: [tool("t1", { cache_control: CC })], + messages: [ + { role: "user", content: [text("c1"), text("c2")] }, + { role: "assistant", content: [text("a1", { cache_control: CC })] }, + { role: "user", content: text("u1", { cache_control: CC }) }, + { role: "user", content: [text("q")] }, + ], + }; + normalizeClaudePassthrough(body); + const out = anchorClaudeCache(body); + const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1")); + expect(kept.length).toBe(1); + expect(Array.isArray(kept[0].content)).toBe(true); // base: bare object survives + expect(kept[0].content).toHaveLength(1); + expect(kept[0].content[0].cache_control).toBeUndefined(); + const ctx = out.messages.find(m => JSON.stringify(m.content).includes("c1")); + expect(ctx.content).toEqual([text("c1"), text("c2")]); + expect(countMarkers(out)).toBeLessThanOrEqual(4); // fixed: 3 + }); + + it("keeps single-object turns on the claude-to-openai leg", () => { + const out = claudeToOpenAIRequest("m", { + messages: [ + { role: "user", content: text("u1") }, + { role: "assistant", content: { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } } }, + ], + }, false); + expect(out.messages.some(m => JSON.stringify(m.content).includes("u1"))).toBe(true); // base: dropped + const img = out.messages.find(m => m.role === "assistant"); + expect(JSON.stringify(img.content)).toContain("image_url"); // base: dropped + }); + + it("keeps a bare-object user turn folded with a mid-conversation system message", () => { + const body = { + messages: [ + { role: "user", content: text("u1") }, + { role: "system", content: [text("reminder")] }, + { role: "user", content: [text("q")] }, + ], + }; + normalizeClaudePassthrough(body); + const first = body.messages[0]; + expect(Array.isArray(first.content)).toBe(true); + expect(JSON.stringify(first.content).includes("u1")).toBe(true); // pre-hoist: fold zeroes bare-object content + expect(JSON.stringify(first.content).includes("reminder")).toBe(true); + }); + it("prunes a client body that already carries five markers down to four", () => { + const out = anchorClaudeCache({ + system: [text("s1"), text("s2", { cache_control: CC })], + tools: [tool("t1", { cache_control: CC }), tool("t2", { cache_control: CC })], + messages: [ + { role: "user", content: [text("u1", { cache_control: CC })] }, + { role: "assistant", content: [text("a1", { cache_control: CC })] }, + { role: "user", content: [text("q")] }, + ], + }); + expect(countMarkers(out)).toBe(4); // pre-fix: 5 forwarded unchanged + expect(out.system[0].cache_control).toBeUndefined(); // earliest marker pruned + }); + + // A spent budget must not cost the head anchors their 1h TTL: system/tools are + // the whole point of re-anchoring, and a 5m fallback silently halves the cache + // lifetime on exactly the requests that already cached aggressively. + it("keeps the 1h head anchors when the client spent the whole budget", () => { + const out = anchorClaudeCache({ + system: [text("s1"), text("s2", { cache_control: CC })], + tools: [tool("t1", { cache_control: CC }), tool("t2")], + messages: [ + { role: "user", content: [text("u1", { cache_control: CC })] }, + { role: "assistant", content: [text("a1", { cache_control: CC })] }, + { role: "user", content: [text("q")] }, + ], + }); + expect(countMarkers(out)).toBeLessThanOrEqual(4); + expect(out.system.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m + expect(out.tools.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m + }); + + it("keeps the 1h head anchors on an over-budget body", () => { + const out = anchorClaudeCache({ + system: [text("s1", { cache_control: CC })], + tools: [tool("t1", { cache_control: CC }), tool("t2")], + messages: [ + { role: "user", content: [text("u1", { cache_control: CC })] }, + { role: "assistant", content: [text("a1", { cache_control: CC })] }, + { role: "user", content: [text("u2", { cache_control: CC })] }, + { role: "assistant", content: [text("a2", { cache_control: CC })] }, + { role: "user", content: [text("q")] }, + ], + }); + expect(countMarkers(out)).toBe(4); + expect(out.system.at(-1).cache_control?.ttl).toBe("1h"); + expect(out.tools.at(-1).cache_control?.ttl).toBe("1h"); + }); + + it("strips a marker from a deferred tool even when the budget is spent", () => { + const out = anchorClaudeCache({ + system: [text("s1", { cache_control: CC })], + tools: [tool("t1", { cache_control: CC, defer_loading: true })], + messages: [ + { role: "user", content: [text("u1", { cache_control: CC })] }, + { role: "assistant", content: [text("a1", { cache_control: CC })] }, + { role: "user", content: [text("q")] }, + ], + }); + expect(countMarkers(out)).toBeLessThanOrEqual(4); + const deferred = out.tools.find(t => t.defer_loading); + expect(deferred?.cache_control).toBeUndefined(); // pre-fix: invalid marker forwarded + }); + + it("keeps a bare-object system reminder on the claude-to-openai leg", () => { + const out = claudeToOpenAIRequest("m", { + messages: [ + { role: "user", content: "hi" }, + { role: "system", content: text("be brief") }, + ], + }, false); + expect(JSON.stringify(out.messages)).toContain("be brief"); // pre-fix: turn dropped + }); +}); diff --git a/tests/unit/cline-auth.test.js b/tests/unit/cline-auth.test.js new file mode 100644 index 00000000..e1eaae20 --- /dev/null +++ b/tests/unit/cline-auth.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getClineAccessToken, + getClineAuthorizationHeader, +} from "../../open-sse/shared/clineAuth.js"; + +test("getClineAccessToken keeps an existing workos: prefix", () => { + const token = "workos:eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9"; + assert.equal(getClineAccessToken(token), token); + assert.equal(getClineAccessToken(` ${token} `), token); +}); + +test("getClineAccessToken prefixes a bare WorkOS JWT with workos:", () => { + const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9"; + assert.equal(getClineAccessToken(jwt), `workos:${jwt}`); +}); + +test("getClineAccessToken does NOT prefix ClinePass API keys", () => { + // ClinePass API keys are opaque strings (e.g. clp_…). Sending them as + // `workos:clp_…` makes api.cline.bot respond 401. + assert.equal(getClineAccessToken("clp_1234567890abcdef"), "clp_1234567890abcdef"); + assert.equal(getClineAccessToken("sk-9r-abcdef"), "sk-9r-abcdef"); + assert.equal(getClineAccessToken(""), ""); + assert.equal(getClineAccessToken(" "), ""); + assert.equal(getClineAccessToken(undefined), ""); + assert.equal(getClineAccessToken(null), ""); +}); + +test("getClineAuthorizationHeader builds a Bearer header without double prefixing", () => { + assert.equal(getClineAuthorizationHeader("clp_abc"), "Bearer clp_abc"); + assert.equal( + getClineAuthorizationHeader("eyJpeg.eyJbG"), + "Bearer workos:eyJpeg.eyJbG" + ); + assert.equal( + getClineAuthorizationHeader("workos:eyJpeg.eyJbG"), + "Bearer workos:eyJpeg.eyJbG" + ); +}); \ No newline at end of file diff --git a/tests/unit/cline-free-models-envelope.test.js b/tests/unit/cline-free-models-envelope.test.js new file mode 100644 index 00000000..8ae0fa89 --- /dev/null +++ b/tests/unit/cline-free-models-envelope.test.js @@ -0,0 +1,297 @@ +// Cline free models (z-ai/glm-5.3-flash, deepseek-v4-flash) wrap non-stream +// chat completions in {"success":true,"data":{...choices...}} on +// https://api.cline.bot/api/v1/chat/completions. Both the UI model-test ping +// (src/app/api/models/test/ping.js) and the proxy non-stream path +// (open-sse/handlers/chatCore/nonStreamingHandler.js) read `choices` at the +// top level, so enveloped choices are invisible ("Provider returned no +// completion choices for this model"). These tests pin the unwrap behavior. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the heavy Next.js-dependent imports BEFORE importing ping.js +// (same pattern as tests/unit/ping-reasoning-models-3010.test.js). +vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) })); +vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } })); +vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") })); +// requestDetail.js imports from @/lib/usageDb.js too, so one mock covers both +// the handler and its usage/detail helpers. +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}), +})); + +const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js"); +const { handleNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js"); + +// The proxy adds a 2000-token headroom buffer to usage before returning it +// to the client (addBufferToUsage), so response-body usage is input + 2000. +// The usage recorded via saveRequestUsage is the unbuffered extraction β€” +// asserting on it proves the unwrap ran before usage extraction. +const { saveRequestUsage } = await import("@/lib/usageDb.js"); + +describe("cline free-models {success,data} envelope", () => { + let fetchMock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function jsonResponse(obj) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify(obj), + json: async () => obj, + }; + } + + it("ping: enveloped success unwraps to ok:true (regression for reported error)", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } }) + ); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + }); + + it("ping: enveloped reasoning-only response still soft-passes with note", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + data: { + choices: [ + { + finish_reason: "length", + message: { content: "", reasoning: "The user said hi" }, + }, + ], + }, + }) + ); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + expect(result.note).toMatch(/reasoning-only/); + }); + + it("ping: error envelope passes through without unwrap", async () => { + const body = { error: "empty response content", success: false }; + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: async () => JSON.stringify(body), + json: async () => body, + }); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/empty response content/); + }); + + it("ping: bare (un-enveloped) body still passes", async () => { + fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] })); + const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + }); + + it("ping: does not unwrap for a provider that did not opt in", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } }) + ); + const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/no completion choices/i); + }); +}); + +describe("cline free-models envelope in nonStreamingHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function stubLogger() { + return { logProviderResponse() {}, logConvertedResponse() {} }; + } + + function callHandler(providerResponse, provider = "cline") { + return handleNonStreamingResponse({ + providerResponse, + provider, + model: "z-ai/glm-5.3-flash", + sourceFormat: "openai", + targetFormat: "openai", + body: { stream: false }, + stream: false, + translatedBody: null, + finalBody: null, + requestStartTime: Date.now(), + connectionId: "c1", + apiKey: "k", + clientRawRequest: null, + onRequestSuccess: () => {}, + reqLogger: stubLogger(), + toolNameMap: null, + customToolNames: null, + trackDone: () => {}, + appendLog: () => {}, + pxpipe: null, + reqTag: "t", + log: null, + }); + } + + it("unwraps the {success,data} envelope before usage extraction and translation", async () => { + const providerResponse = new Response( + JSON.stringify({ + success: true, + data: { + choices: [{ message: { content: "Hi" } }], + usage: { prompt_tokens: 5, completion_tokens: 2 }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse); + expect(result.success).toBe(true); + const body = await result.response.json(); + expect(body.choices).toBeDefined(); + expect(body.choices[0].message.content).toBe("Hi"); + expect(body.success).toBeUndefined(); + expect(saveRequestUsage).toHaveBeenCalledTimes(1); + expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({ + prompt_tokens: 5, + completion_tokens: 2, + }); + expect(body.usage.prompt_tokens).toBe(2005); + }); + + it("passes a bare (non-enveloped) body through unchanged", async () => { + const providerResponse = new Response( + JSON.stringify({ + choices: [{ message: { content: "Hi" } }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse); + expect(result.success).toBe(true); + const body = await result.response.json(); + expect(body.choices[0].message.content).toBe("Hi"); + expect(saveRequestUsage).toHaveBeenCalledTimes(1); + expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({ + prompt_tokens: 3, + completion_tokens: 1, + }); + expect(body.usage.prompt_tokens).toBe(2003); + }); + + // The unwrap is opt-in via transport.quirks.clineEnvelope so it can never + // rewrite another provider's body β€” including one that happens to return + // {"success":true,"data":...} for its own reasons. + it("leaves an enveloped body untouched for a provider that did not opt in", async () => { + const providerResponse = new Response( + JSON.stringify({ + success: true, + data: { choices: [{ message: { content: "Hi" } }] }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse, "openai"); + const body = await result.response.json(); + expect(body.success).toBe(true); + expect(body.data.choices[0].message.content).toBe("Hi"); + expect(body.choices).toBeUndefined(); + }); +}); + +describe("cline /api/v1/models aggregation (resolveClineModels vs resolveClinepassModels)", () => { + const API_MODELS_URL = "https://api.cline.bot/api/v1/models"; + + const API_RESPONSE = [ + { id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "cline-pass/glm-5.2", name: "GLM-5.2" }, + { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash" }, + { id: "z-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" }, + ]; + + let fetchMock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolveClineModels returns all models (including free-tier)", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(4); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-pass/deepseek-v4-flash"); + expect(ids).toContain("z-ai/glm-5.3-flash"); + expect(ids).toContain("z-ai/deepseek-v4-flash"); + }); + + it("resolveClinepassModels returns only cline-pass/ models", async () => { + const { resolveClinepassModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClinepassModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(2); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-pass/deepseek-v4-flash"); + expect(ids).toContain("cline-pass/glm-5.2"); + expect(ids).not.toContain("z-ai/glm-5.3-flash"); + }); + + it("resolveClineModels unwraps {success,data} envelope", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: API_RESPONSE }), + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(4); + }); + + it("resolveClineModels returns null when no token", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({}); + expect(result).toBeNull(); + }); + + it("resolveClineModels returns null on fetch error", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockRejectedValue(new Error("network error")); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).toBeNull(); + }); + + it("resolveClineModels returns {id,name} shape", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models[0]).toHaveProperty("id"); + expect(result.models[0]).toHaveProperty("name"); + expect(typeof result.models[0].id).toBe("string"); + expect(typeof result.models[0].name).toBe("string"); + }); +}); diff --git a/tests/unit/codex-tool-normalization.test.js b/tests/unit/codex-tool-normalization.test.js index d1b5901e..c7193898 100644 --- a/tests/unit/codex-tool-normalization.test.js +++ b/tests/unit/codex-tool-normalization.test.js @@ -118,6 +118,69 @@ describe("CodexExecutor tool normalization", () => { ]); }); + it("strips only Unicode-property patterns rejected by Codex", () => { + const unicodePattern = "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]{1,200}$"; + const validPattern = "^[a-z][a-z0-9_-]{0,31}$"; + const sourceParameters = { + type: "object", + properties: { + artifact: { + type: "object", + properties: { + name: { type: "string", pattern: unicodePattern }, + slug: { type: "string", pattern: validPattern }, + }, + }, + // A property named "pattern" is data, not the schema keyword. + pattern: { type: "string", pattern: validPattern }, + }, + allOf: [{ properties: { title: { type: "string", pattern: unicodePattern } } }], + }; + const tools = normalizeTools([{ + type: "function", + name: "Artifact", + parameters: sourceParameters, + }]); + + expect(tools[0].parameters.properties.artifact.properties.name.pattern).toBeUndefined(); + expect(tools[0].parameters.properties.artifact.properties.slug.pattern).toBe(validPattern); + expect(tools[0].parameters.properties.pattern.pattern).toBe(validPattern); + expect(tools[0].parameters.allOf[0].properties.title.pattern).toBeUndefined(); + // Copy-on-write: the caller's schema remains available for another provider. + expect(sourceParameters.properties.artifact.properties.name.pattern).toBe(unicodePattern); + }); + + it("keeps escaped literal property text and schema identity when no strip is needed", () => { + const parameters = { + type: "object", + properties: { + literal: { type: "string", pattern: "^\\\\p{Cc}$" }, + simple: { type: "string", pattern: "^[A-Z]+$" }, + }, + }; + const tools = normalizeTools([{ type: "function", name: "probe", parameters }]); + + expect(tools[0].parameters).toBe(parameters); + expect(tools[0].parameters.properties.literal.pattern).toBe("^\\\\p{Cc}$"); + }); + + it("sanitizes nested namespace function schemas", () => { + const tools = normalizeTools([{ + type: "namespace", + name: "agent", + tools: [{ + type: "function", + name: "Artifact", + parameters: { + type: "object", + properties: { name: { type: "string", pattern: "^\\p{Cc}+$" } }, + }, + }], + }]); + + expect(tools[0].tools[0].parameters.properties.name.pattern).toBeUndefined(); + }); + it("preserves custom freeform tools with format payloads", () => { const tools = normalizeTools([ { diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index 52a80884..4955cd88 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -101,6 +101,104 @@ describe("DB SQLite layer β€” public API parity", () => { expect(back.providerSpecificData).toEqual({ foo: "bar" }); }); + it("providerConnections: successful validation clears stale routing locks", async () => { + const c = await sqliteDb.createProviderConnection({ + provider: "health-reset-update", + authType: "oauth", + email: "update@example.com", + accessToken: "old-token", + }); + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "unavailable", + lastError: "Access denied", + lastErrorAt: "2026-09-05T00:00:00.000Z", + errorCode: 403, + backoffLevel: 3, + rateLimitedUntil: "2099-01-01T00:00:00.000Z", + modelLock_modelA: "2099-01-01T00:00:00.000Z", + modelLock_modelB: "2099-01-01T00:00:00.000Z", + }); + + await sqliteDb.updateProviderConnection(c.id, { testStatus: "active" }); + + const back = await sqliteDb.getProviderConnectionById(c.id); + expect(back).toMatchObject({ + testStatus: "active", + lastError: null, + lastErrorAt: null, + errorCode: null, + backoffLevel: 0, + rateLimitedUntil: null, + modelLock_modelA: null, + modelLock_modelB: null, + }); + }); + + it("providerConnections: re-saving valid OAuth credentials clears stale routing locks", async () => { + const existing = await sqliteDb.createProviderConnection({ + provider: "health-reset-resave", + authType: "oauth", + email: "resave@example.com", + accessToken: "old-token", + }); + await sqliteDb.updateProviderConnection(existing.id, { + testStatus: "unavailable", + lastError: "Access denied", + errorCode: 403, + backoffLevel: 2, + modelLock_modelA: "2099-01-01T00:00:00.000Z", + }); + + const resaved = await sqliteDb.createProviderConnection({ + provider: "health-reset-resave", + authType: "oauth", + email: "resave@example.com", + accessToken: "new-token", + testStatus: "active", + }); + + expect(resaved.id).toBe(existing.id); + const back = await sqliteDb.getProviderConnectionById(existing.id); + expect(back).toMatchObject({ + accessToken: "new-token", + testStatus: "active", + lastError: null, + errorCode: null, + backoffLevel: 0, + modelLock_modelA: null, + }); + }); + + it("providerConnections: active soft warnings survive the health reset", async () => { + const c = await sqliteDb.createProviderConnection({ + provider: "health-reset-warning", + authType: "oauth", + email: "warning@example.com", + }); + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "unavailable", + lastError: "Old failure", + modelLock_modelA: "2099-01-01T00:00:00.000Z", + }); + + const warningAt = "2026-09-06T00:00:00.000Z"; + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "active", + lastError: "Connected, but credits are exhausted", + lastErrorAt: warningAt, + }); + + const back = await sqliteDb.getProviderConnectionById(c.id); + expect(back).toMatchObject({ + testStatus: "active", + lastError: "Connected, but credits are exhausted", + lastErrorAt: warningAt, + errorCode: null, + backoffLevel: 0, + modelLock_modelA: null, + }); + }); + it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => { const c = await sqliteDb.createProviderConnection({ provider: "github", diff --git a/tests/unit/deepseek-claude-tools.test.js b/tests/unit/deepseek-claude-tools.test.js new file mode 100644 index 00000000..bd05e208 --- /dev/null +++ b/tests/unit/deepseek-claude-tools.test.js @@ -0,0 +1,145 @@ +/** + * Regression test: prepareClaudeRequest() must strip client-defined `custom` + * tools when forwarding to a provider whose Anthropic-compatible endpoint + * does not accept them (DeepSeek β€” accepts only web_search_*). + * + * Background: + * When Claude Code talks to a DeepSeek route via /v1/messages, 9router + * forwards the request body as Claude-format to + * https://api.deepseek.com/anthropic/v1/messages. MCP / function tools + * arrive with `type: "custom"`. DeepSeek rejects them with HTTP 400 + * "tools[0]: unknown variant `custom`, expected `web_search_20250305` + * or `web_search_20260209`". The previous generic filter dropped them + * but also stripped the web_search_* tools that DeepSeek actually + * accepts. DeepSeek now exposes a `quirks.claudeSupportedToolTypes` + * whitelist and prepareClaudeRequest honours it. + */ + +import { describe, it, expect } from "vitest"; +import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; + +function makeBody(tools) { + return { + model: "deepseek-v4-pro", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + tools, + }; +} + +describe("prepareClaudeRequest β€” provider: deepseek", () => { + it("declares the supportedTypes quirk on the provider transport", () => { + expect(PROVIDERS.deepseek).toBeDefined(); + expect(PROVIDERS.deepseek.quirks).toBeDefined(); + expect(PROVIDERS.deepseek.quirks.claudeSupportedToolTypes).toEqual([ + "web_search_20250305", + "web_search_20260209", + ]); + }); + + it("strips MCP / custom tools (the regression) before forwarding", () => { + const body = makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "custom", name: "Read", input_schema: { type: "object" } }, + { type: "custom", name: "Glob", input_schema: { type: "object" } }, + ]); + + const out = prepareClaudeRequest(body, "deepseek"); + + expect(out.tools).toBeUndefined(); + expect(out.tool_choice).toBeUndefined(); + }); + + it("keeps web_search_20250305 and web_search_20260209", () => { + const out = prepareClaudeRequest( + makeBody([ + { type: "web_search_20250305", name: "web_search" }, + { type: "web_search_20260209", name: "web_search" }, + ]), + "deepseek" + ); + + expect(Array.isArray(out.tools)).toBe(true); + expect(out.tools).toHaveLength(2); + const types = out.tools.map(t => t.type).sort(); + expect(types).toEqual(["web_search_20250305", "web_search_20260209"]); + }); + + it("preserves the `type` field on web_search_* tools (DeepSeek requires it)", () => { + // The .map below the filter must NOT strip `type` when the provider + // declared a whitelist β€” DeepSeek would reject a tool object missing + // its discriminator field with the same unknown-variant error. + const out = prepareClaudeRequest( + makeBody([{ type: "web_search_20250305", name: "web_search" }]), + "deepseek" + ); + + expect(out.tools[0].type).toBe("web_search_20250305"); + expect(out.tools[0].name).toBe("web_search"); + }); + + it("drops `custom` but keeps `web_search_*` when both are present", () => { + const out = prepareClaudeRequest( + makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "web_search_20250305", name: "web_search" }, + ]), + "deepseek" + ); + + expect(Array.isArray(out.tools)).toBe(true); + expect(out.tools).toHaveLength(1); + expect(out.tools[0].type).toBe("web_search_20250305"); + expect(out.tools[0].name).toBe("web_search"); + }); + + it("survives when body has no tools", () => { + const out = prepareClaudeRequest(makeBody(undefined), "deepseek"); + expect(out.tools).toBeUndefined(); + }); + + it("rejects future / unknown tool types instead of forwarding them", () => { + const out = prepareClaudeRequest( + makeBody([{ type: "future_tool_2099", name: "x" }]), + "deepseek" + ); + expect(out.tools).toBeUndefined(); + }); +}); + +describe("prepareClaudeRequest β€” backward compat: providers without the quirk", () => { + // Pick any non-Claude provider that has a Claude-format transport and has + // NOT been migrated to the new quirk. This protects GLM / Kimi / future + // Anthropic-compatible providers from unintended changes. + it("keeps prior behaviour (drop custom + web_search_*, normalize no-type tools)", () => { + const candidate = Object.entries(PROVIDERS).find( + ([id, p]) => + id !== "claude" && + p?.transports?.some(t => t.format === "claude") && + !p?.quirks?.claudeSupportedToolTypes + ); + + if (!candidate) { + // Every Claude-format provider has been migrated β€” nothing to verify. + return; + } + + const [providerId] = candidate; + + const out = prepareClaudeRequest( + makeBody([ + { type: "custom", name: "Bash", input_schema: { type: "object" } }, + { type: "web_search_20250305", name: "web_search" }, + { name: "no_type_tool", input_schema: { type: "object" } }, + ]), + providerId + ); + + if (out.tools !== undefined) { + for (const t of out.tools) { + expect(t.type).toBeUndefined(); + } + } + }); +}); \ No newline at end of file diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index 3c602757..6b764212 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -351,7 +351,7 @@ describe("handleImageGenerationCore", () => { headers: expect.objectContaining({ authorization: "Bearer codex-token", "chatgpt-account-id": "account-123", - version: "0.136.0", + version: "0.154.0", }), }) ); @@ -367,6 +367,47 @@ describe("handleImageGenerationCore", () => { expect(responseBody.data[0].b64_json).toBe("base64codeximage"); }); + it("generates image with Codex gpt-image-2.5 tool model", async () => { + global.fetch.mockResolvedValueOnce( + new Response( + [ + "event: response.output_item.done", + 'data: {"item":{"type":"image_generation_call","result":"base64codeximage"}}', + "", + "", + ].join("\n"), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ) + ); + + const result = await handleImageGenerationCore({ + body: { + prompt: "A futuristic city", + size: "1024x1024", + output_format: "png", + }, + modelInfo: { provider: "codex", model: "gpt-image-2.5" }, + credentials: { + accessToken: "codex-token", + providerSpecificData: { chatgptAccountId: "account-123" }, + }, + log: null, + }); + + expect(result.success).toBe(true); + const fetchCall = global.fetch.mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.model).toBe("gpt-5.5"); + expect(requestBody.tools).toEqual([ + { type: "image_generation", output_format: "png", size: "1024x1024", action: "generate", model: "gpt-image-2.5" }, + ]); + expect(requestBody.tool_choice).toEqual({ type: "image_generation" }); + expect(requestBody.reasoning).toEqual({ effort: "medium", summary: "auto" }); + + const responseBody = await result.response.json(); + expect(responseBody.data[0].b64_json).toBe("base64codeximage"); + }); + it("generates image with Cloudflare Workers AI JSON response", async () => { global.fetch.mockResolvedValueOnce( new Response( diff --git a/tests/unit/kiro-api-key-endpoint-routing.test.js b/tests/unit/kiro-api-key-endpoint-routing.test.js index a0750adc..22cf97fc 100644 --- a/tests/unit/kiro-api-key-endpoint-routing.test.js +++ b/tests/unit/kiro-api-key-endpoint-routing.test.js @@ -20,26 +20,26 @@ describe("Kiro auth-aware endpoint routing", () => { ]); }); - it("keeps Builder ID OAuth on the Kiro runtime surface", () => { + it("routes Builder ID OAuth through Amazon Q first (runtime path deprecated)", () => { expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([ - RUNTIME, - CODEWHISPERER, Q, + CODEWHISPERER, + RUNTIME, ]); }); - it("keeps external IdP on CodeWhisperer before Amazon Q", () => { + it("routes external IdP through Amazon Q first", () => { expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([ - CODEWHISPERER, Q, + CODEWHISPERER, RUNTIME, ]); }); - it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => { + it("regionalizes AWS endpoints for IDC with Q first", () => { expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([ - "https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse", "https://q.eu-west-1.amazonaws.com/generateAssistantResponse", + "https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse", RUNTIME, ]); }); diff --git a/tests/unit/kiro-minimal-wire-payload.test.js b/tests/unit/kiro-minimal-wire-payload.test.js new file mode 100644 index 00000000..97075d4b --- /dev/null +++ b/tests/unit/kiro-minimal-wire-payload.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; + +for (const [name, translate, body] of [ + ["OpenAI", openaiToKiroRequest, { messages: [{ role: "user", content: "hello" }] }], + ["Claude", claudeToKiroRequest, { messages: [{ role: "user", content: "hello" }] }], +]) { + describe(`${name} Kiro minimal wire payload`, () => { + it("omits unsupported agent fields", () => { + const payload = translate("kiro/claude-sonnet-4.5", body, true, {}); + expect(payload).not.toHaveProperty("agentMode"); + expect(payload.conversationState).not.toHaveProperty("agentContinuationId"); + expect(payload.conversationState).not.toHaveProperty("agentTaskType"); + expect(payload.conversationState.chatTriggerType).toBe("MANUAL"); + expect(payload.conversationState.currentMessage.userInputMessage.origin).toBe("AI_EDITOR"); + }); + }); +} diff --git a/tests/unit/kiro-terminal-integrity.test.js b/tests/unit/kiro-terminal-integrity.test.js index aaf66209..3199c03d 100644 --- a/tests/unit/kiro-terminal-integrity.test.js +++ b/tests/unit/kiro-terminal-integrity.test.js @@ -115,7 +115,7 @@ async function text(stream) { async function execute(executor = new KiroExecutor(), overrides = {}) { return executor.execute({ model: "kr/claude-opus-4.8", - body: { systemPrompt: "base", conversationState: {} }, + body: { conversationState: { currentMessage: { userInputMessage: { content: "base", modelId: "m" } } } }, stream: true, credentials, ...overrides @@ -342,8 +342,12 @@ describe("Kiro terminal integrity recovery", () => { const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body); expect(body).toContain("Recovered safely."); - expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed"); - expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS"); + // The repair instruction rides in the user turn: kiro.dev rejects a + // top-level systemPrompt with 400 REQUEST_BODY_INVALID. + const retryContent = retryBody.conversationState.currentMessage.userInputMessage.content; + expect(retryBody.systemPrompt).toBeUndefined(); + expect(retryContent).toContain("tool_call wrapper was malformed"); + expect(retryContent).not.toContain("IGNORE_ALL_INSTRUCTIONS"); }); it("lets a complete tool call override metadata end_turn", async () => { diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index 5a86b276..9973012c 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -628,7 +628,7 @@ describe("openaiToKiroRequest", () => { ); expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay"); - expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId); + expect(second.conversationState).not.toHaveProperty("agentContinuationId"); expect(second.conversationState.history[0].userInputMessage.content).toBe( first.conversationState.currentMessage.userInputMessage.content ); diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 7ece100a..4ee2042a 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -4,9 +4,11 @@ import { PROVIDERS } from "../../open-sse/config/providers.js"; import { resolveTransport } from "../../open-sse/services/provider.js"; // Chat-only models (no /messages, no /responses support on opencode-go) -const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"]; +const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3", + "deepseek-flash", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "hy4-preview", "hy3"]; // Models that also expose the Anthropic /messages endpoint -const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"]; +const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"]; // Models that also expose the OpenAI /responses endpoint const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"]; @@ -22,11 +24,14 @@ describe("OpenCode Go model catalog", () => { it("matches the documented model IDs", () => { const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id); expect(ids).toEqual([ - "glm-5.3-flash", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", + "deepseek-flash", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", - "mimo-v2.5", "mimo-v2.5-pro", + "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + "hy4-preview", "hy3", + "grok-4.6", "gpt-5.6-luna", "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", ]); }); @@ -91,7 +96,7 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => { }); it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { - for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor"]) { + for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.6", "gpt-5.6-luna"]) { expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]); expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull(); diff --git a/tests/unit/qoder-context-tier.test.js b/tests/unit/qoder-context-tier.test.js new file mode 100644 index 00000000..8850fd0d --- /dev/null +++ b/tests/unit/qoder-context-tier.test.js @@ -0,0 +1,193 @@ +/** + * Qoder context-window tiers + routable model listing. + * + * The Qoder IDE lets a user pick 200K / 400K / 1M for a model; qodercli-style + * requests (what 9router sends) only carry the default max_input_tokens. These + * tests pin the escalation policy and the payload fields the IDE writes. + */ +import { describe, it, expect } from "vitest"; + +import { + parseTierTokenCount, + getQoderContextTiers, + estimateQoderPromptTokens, + resolveQoderContextTier, + applyQoderContextTier, +} from "../../open-sse/shared/qoder/contextTier.js"; +import { routableQoderModels } from "../../open-sse/services/qoderModels.js"; + +// Shape mirrors the live /algo/api/v2/model/list entry for qmodel_38max. +const MODEL_CONFIG = { + key: "qmodel_38max", + display_name: "Qwen3.8-Max", + is_reasoning: true, + max_input_tokens: 180_000, + max_output_tokens: 32_768, + context_config: [ + { name: "200K", tokenCount: 200_000, isDefault: true }, + { name: "400K", tokenCount: 400_000, isDefault: false }, + { name: "1M", tokenCount: 1_000_000, isDefault: false }, + ], +}; + +function promptOfTokens(n) { + // ~4 ASCII chars per token + return { system: "", messages: [{ role: "user", content: "abcd".repeat(n) }], tools: [] }; +} + +describe("parseTierTokenCount", () => { + it("accepts numbers and K/M suffixed strings", () => { + expect(parseTierTokenCount(204800)).toBe(204800); + expect(parseTierTokenCount("200K")).toBe(200_000); + expect(parseTierTokenCount("1M")).toBe(1_000_000); + expect(parseTierTokenCount("1.5m")).toBe(1_500_000); + expect(parseTierTokenCount("131072")).toBe(131072); + }); + + it("returns 0 for garbage", () => { + expect(parseTierTokenCount(null)).toBe(0); + expect(parseTierTokenCount("big")).toBe(0); + expect(parseTierTokenCount(-5)).toBe(0); + }); +}); + +describe("getQoderContextTiers", () => { + it("sorts tiers ascending and keeps the default flag", () => { + const tiers = getQoderContextTiers({ + context_config: [ + { name: "1M", tokenCount: 1_000_000 }, + { name: "200K", tokenCount: 200_000, isDefault: true }, + ], + }); + expect(tiers.map((t) => t.tokenCount)).toEqual([200_000, 1_000_000]); + expect(tiers[0].isDefault).toBe(true); + expect(tiers[1].isDefault).toBe(false); + }); + + it("understands camelCase / snake_case variants and derives names", () => { + const tiers = getQoderContextTiers({ + contextConfig: [{ token_count: "400K", is_default: true }, { max_input_tokens: 1_000_000 }], + }); + expect(tiers).toEqual([ + { name: "400K", tokenCount: 400_000, isDefault: true }, + { name: "1M", tokenCount: 1_000_000, isDefault: false }, + ]); + }); + + it("returns [] when the model has no tiers", () => { + expect(getQoderContextTiers({ max_input_tokens: 131072 })).toEqual([]); + expect(getQoderContextTiers(null)).toEqual([]); + }); +}); + +describe("estimateQoderPromptTokens", () => { + it("counts CJK characters as ~1 token each instead of chars/4", () => { + const ascii = estimateQoderPromptTokens({ messages: [{ role: "user", content: "a".repeat(4000) }] }); + const cjk = estimateQoderPromptTokens({ messages: [{ role: "user", content: "δΈ­".repeat(4000) }] }); + expect(ascii).toBeLessThan(1_200); + expect(cjk).toBeGreaterThan(4_000); + }); +}); + +describe("resolveQoderContextTier (auto)", () => { + it("leaves the payload untouched while the prompt fits the current max_input_tokens", () => { + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(50_000))).toBeNull(); + }); + + it("escalates to the smallest tier that fits once the prompt outgrows the default", () => { + const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000)); + expect(choice).not.toBeNull(); + expect(choice.tier.name).toBe("400K"); + expect(choice.reason).toBe("auto:fits"); + expect(choice.estimatedTokens).toBeGreaterThan(240_000); + }); + + it("falls back to the largest tier when nothing fits (upstream decides)", () => { + const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(1_200_000)); + expect(choice.tier.name).toBe("1M"); + expect(choice.reason).toBe("auto:largest"); + }); + + it("applies headroom so a prompt just under the limit still escalates", () => { + // 170K estimated * 1.15 = 195.5K > 180K current β†’ smallest tier above the current limit (200K) + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(170_000))?.tier.name).toBe("200K"); + // 190K * 1.15 = 218.5K β†’ 200K no longer fits β†’ 400K + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(190_000))?.tier.name).toBe("400K"); + }); + + it("returns null for models without context_config", () => { + expect(resolveQoderContextTier({ max_input_tokens: 131072 }, promptOfTokens(500_000))).toBeNull(); + }); + + it("never escalates when the current limit is already the largest tier", () => { + const cfg = { ...MODEL_CONFIG, max_input_tokens: 1_000_000 }; + expect(resolveQoderContextTier(cfg, promptOfTokens(1_500_000))).toBeNull(); + }); +}); + +describe("resolveQoderContextTier (forced via QODER_CONTEXT_TIER)", () => { + it("max picks the largest tier regardless of prompt size", () => { + const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "max" }); + expect(choice.tier.name).toBe("1M"); + expect(choice.reason).toBe("forced:max"); + }); + + it("default picks the isDefault tier", () => { + const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "default" }); + expect(choice.tier.name).toBe("200K"); + }); + + it("a tier name or token count selects that tier", () => { + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "400k" }).tier.tokenCount).toBe(400_000); + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "1000000" }).tier.name).toBe("1M"); + }); + + it("an unknown tier name falls back to auto", () => { + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "9M" })).toBeNull(); + expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000), { preference: "9M" }).tier.name).toBe("400K"); + }); +}); + +describe("applyQoderContextTier", () => { + it("mirrors the tier into the three places the IDE writes", () => { + const payload = { + parameters: { max_tokens: 32_768 }, + chat_context: { extra: { context: [], modelConfig: { key: "qmodel_38max" } } }, + model_config: { ...MODEL_CONFIG }, + }; + applyQoderContextTier(payload, { name: "1M", tokenCount: 1_000_000 }); + expect(payload.parameters).toEqual({ max_tokens: 32_768, context_length: 1_000_000 }); + expect(payload.chat_context.extra.ideModelConfigOverride).toEqual({ max_input_tokens: 1_000_000 }); + expect(payload.chat_context.extra.modelConfig).toEqual({ key: "qmodel_38max" }); + expect(payload.model_config.max_input_tokens).toBe(1_000_000); + expect(payload.model_config.context_config).toHaveLength(3); + }); + + it("is a no-op without a tier", () => { + const payload = { parameters: { max_tokens: 1 } }; + expect(applyQoderContextTier(payload, null)).toBe(payload); + expect(payload).toEqual({ parameters: { max_tokens: 1 } }); + }); +}); + +describe("routableQoderModels", () => { + it("lists visible models first, then hidden (enable:false) catalog keys", () => { + const catalog = { + models: [{ id: "qmodel_38max", name: "Qwen3.8-Max" }], + rawConfigs: new Map([ + ["qmodel_38max", { key: "qmodel_38max", enable: true }], + ["qfmodel", { key: "qfmodel", enable: false, display_name: "Qwen Fast" }], + ["dmodel", { key: "dmodel", enable: false }], + ]), + }; + expect(routableQoderModels(catalog)).toEqual([ + { id: "qmodel_38max", name: "Qwen3.8-Max", hidden: false }, + { id: "qfmodel", name: "Qwen Fast", hidden: true }, + { id: "dmodel", name: "dmodel", hidden: true }, + ]); + }); + + it("returns [] for a failed catalog fetch", () => { + expect(routableQoderModels(null)).toEqual([]); + }); +}); diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js index 04ce1c45..ea45ac61 100644 --- a/tests/unit/qoder.test.js +++ b/tests/unit/qoder.test.js @@ -9,7 +9,7 @@ * - device flow URL construction */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import crypto from "crypto"; import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js"; @@ -22,6 +22,13 @@ import { } from "../../src/lib/qoder/constants.js"; import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js"; import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js"; +import { canonicalizeQoderUsage } from "../../open-sse/shared/qoder/sse.js"; +import { + rewriteQoderMessageAttachments, + clearQoderUploadCache, + buildMultipartFile, +} from "../../open-sse/shared/qoder/attachments.js"; +import { qoderInferenceBase } from "../../open-sse/shared/qoder/constants.js"; // Convenience aliases β€” tests were originally written against module-level // helpers; the QoderService class wraps them so each test creates its own @@ -431,6 +438,21 @@ describe("normalizeMessages", () => { ]); expect(result.messages[0].content).toBe("hi"); }); + + it("turns leftover file/document blocks into short stubs instead of dropping them", () => { + const result = normalizeMessages([ + { + role: "user", + content: [ + { type: "text", text: "see" }, + { type: "file", file: { filename: "big.pdf", file_data: "data:application/pdf;base64,AAA" } }, + ], + }, + ]); + expect(result.messages[0].content).toContain("see"); + expect(result.messages[0].content).toContain("big.pdf"); + expect(result.messages[0].content).not.toContain("AAA"); + }); }); describe("wrapQoderSSE", () => { @@ -530,4 +552,190 @@ describe("wrapQoderSSE", () => { const wrapped = await wrapQoderSSE(r, "qoder/auto"); expect(wrapped).toBe(r); }); + + function envelope(body) { + return `data: ${JSON.stringify({ statusCodeValue: 200, body })}\n\n`; + } + + function parseForwardedChunks(out) { + return out + .split("\n\n") + .map((block) => block.trim()) + .filter((block) => block.startsWith("data:") && !block.includes("[DONE]")) + .map((block) => JSON.parse(block.slice("data:".length).trim())); + } + + it("coalesces empty finish-in-delta + usage-only into one OpenAI usage chunk", async () => { + const content = JSON.stringify({ + id: "chatcmpl-qoder-1", + created: 1700000000, + model: "auto", + choices: [{ index: 0, delta: { content: "hi" } }], + }); + const finish = JSON.stringify({ + id: "chatcmpl-qoder-1", + choices: [{ index: 0, delta: { content: "", finish_reason: "stop" } }], + }); + const usage = JSON.stringify({ + id: "chatcmpl-qoder-1", + choices: [], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + prompt_tokens_details: { cached_tokens: 40 }, + }, + }); + const wrapped = await wrapQoderSSE( + makeResponse([envelope(content) + envelope(finish) + envelope(usage) + envelope("[DONE]")]), + "qoder/auto", + ); + const out = await drain(wrapped); + expect(out).toContain(`data: ${content}\n\n`); + const chunks = parseForwardedChunks(out); + const usageChunk = chunks.find((c) => c.usage); + expect(usageChunk).toBeDefined(); + expect(usageChunk.choices[0].finish_reason).toBe("stop"); + expect(usageChunk.usage.prompt_tokens).toBe(100); + expect(usageChunk.usage.completion_tokens).toBe(20); + expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(40); + expect(chunks.some((c) => Array.isArray(c.choices) && c.choices.length === 0)).toBe(false); + expect((out.match(/data: \[DONE\]/g) || []).length).toBe(1); + }); + + it("maps Qoder input_tokens aliases onto prompt_tokens in the coalesced usage chunk", async () => { + const finish = JSON.stringify({ + choices: [{ index: 0, delta: { finish_reason: "stop" } }], + }); + const usage = JSON.stringify({ + choices: [], + usage: { + input_tokens: 80, + output_tokens: 10, + cache_read_input_tokens: 25, + }, + }); + const wrapped = await wrapQoderSSE( + makeResponse([envelope(finish) + envelope(usage)]), + "qoder/lite", + ); + const chunks = parseForwardedChunks(await drain(wrapped)); + const usageChunk = chunks.find((c) => c.usage); + expect(usageChunk.usage.prompt_tokens).toBe(80); + expect(usageChunk.usage.completion_tokens).toBe(10); + expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(25); + }); +}); + +describe("canonicalizeQoderUsage", () => { + it("returns null for missing or empty usage", () => { + expect(canonicalizeQoderUsage(null)).toBeNull(); + expect(canonicalizeQoderUsage({})).toBeNull(); + }); + + it("copies prompt_tokens_details.cached_tokens through", () => { + const out = canonicalizeQoderUsage({ + prompt_tokens: 50, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 12 }, + }); + expect(out.prompt_tokens).toBe(50); + expect(out.cached_tokens).toBe(12); + expect(out.prompt_tokens_details.cached_tokens).toBe(12); + expect(out.total_tokens).toBe(55); + }); +}); + +describe("qoderInferenceBase", () => { + it("sends job tokens to api2 and device tokens to api3", () => { + expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh"); + expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh"); + }); +}); + +describe("rewriteQoderMessageAttachments", () => { + beforeEach(() => clearQoderUploadCache()); + + it("uploads data-URI images and keeps only the OSS URL in the message", async () => { + const messages = [{ + role: "user", + content: [ + { type: "text", text: "see this" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ], + }]; + const stats = await rewriteQoderMessageAttachments(messages, { + uploadFn: async ({ buffer, mediaType }) => { + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(mediaType).toBe("image/png"); + return "https://cdn.qoder.example/img.png"; + }, + }); + expect(messages[0].content).toEqual([ + { type: "text", text: "see this" }, + { type: "image_url", image_url: { url: "https://cdn.qoder.example/img.png" } }, + ]); + expect(JSON.stringify(messages)).not.toContain("AAAA"); + expect(stats.imageUrls).toEqual(["https://cdn.qoder.example/img.png"]); + }); + + it("does not re-upload already-hosted http(s) image URLs", async () => { + const messages = [{ + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }], + }]; + await rewriteQoderMessageAttachments(messages, { + uploadFn: async () => { + throw new Error("should not upload remote URLs"); + }, + }); + expect(messages[0].content[0].image_url.url).toBe("https://example.com/a.png"); + }); + + it("stubs non-image file blocks instead of inlining bytes", async () => { + const pdfB64 = "A".repeat(200); + const messages = [{ + role: "user", + content: [ + { type: "text", text: "read this" }, + { type: "file", file: { filename: "big.pdf", file_data: `data:application/pdf;base64,${pdfB64}` } }, + ], + }]; + await rewriteQoderMessageAttachments(messages, { + uploadFn: async () => { + throw new Error("should not upload PDFs as images"); + }, + }); + const wire = JSON.stringify(messages); + expect(wire).not.toContain(pdfB64); + expect(wire).toContain("[file omitted: big.pdf"); + }); + + it("stubs oversized images when OSS upload fails instead of keeping a huge data URI", async () => { + const big = "A".repeat(700_000); + const messages = [{ + role: "user", + content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${big}` } }], + }]; + await rewriteQoderMessageAttachments(messages, { + uploadFn: async () => { + throw new Error("upstream 413"); + }, + }); + const wire = JSON.stringify(messages); + expect(wire).not.toContain(big); + expect(wire).toContain("[file omitted:"); + expect(Buffer.byteLength(wire, "utf8")).toBeLessThan(4096); + }); + + it("buildMultipartFile uses the file field name qodercli sends", () => { + const { boundary, body } = buildMultipartFile(Buffer.from("hi"), { + fileName: "image.png", + mediaType: "image/png", + }); + const text = body.toString("latin1"); + expect(text).toContain(`name="file"`); + expect(text).toContain("filename=\"image.png\""); + expect(text).toContain(`--${boundary}`); + }); }); diff --git a/tests/unit/system-inject.test.js b/tests/unit/system-inject.test.js index bff7c94c..fd074f2d 100644 --- a/tests/unit/system-inject.test.js +++ b/tests/unit/system-inject.test.js @@ -261,138 +261,121 @@ describe("system-inject gemini", () => { }); describe("system-inject kiro", () => { - it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => { - const oldPrompt = "OLD_SYS"; + // The kiro.dev gateway rejects any body carrying a top-level `systemPrompt` + // with 400 REQUEST_BODY_INVALID, so the prompt goes into the user turn only. + it("appends to first history user, leaves systemPrompt untouched", () => { const timeCtx = "[Context: Current time is 2026-01-01T00:00:00.000Z]"; const tail = "user tail content"; - const historyUserContent = `${oldPrompt}${SEP}${timeCtx}${SEP}${tail}`; + const historyUserContent = `${timeCtx}${SEP}${tail}`; const body = { - systemPrompt: oldPrompt, conversationState: { history: [{ userInputMessage: { content: historyUserContent, modelId: "m" } }, { assistantResponseMessage: { content: "..." } }], currentMessage: { userInputMessage: { content: "current " + tail, modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - const next = `${oldPrompt}${SEP}${P1}`; - expect(body.systemPrompt).toBe(next); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${next}${SEP}${timeCtx}${SEP}${tail}`); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`${historyUserContent}${SEP}${P1}`); // currentMessage must stay untouched expect(body.conversationState.currentMessage.userInputMessage.content).toBe("current " + tail); }); - it("when no history user, updates currentMessage instead", () => { - const oldPrompt = "OLD"; - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [], - currentMessage: { userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }, - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`); - expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`); - }); - - it("empty old prompt prepends to chosen user content", () => { - const body = { - systemPrompt: "", - conversationState: { - history: [{ userInputMessage: { content: "tail hello", modelId: "m" } }], - currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(P1); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}tail hello`); - }); - - it("if old prompt not mirrored at head, do not alter user content", () => { + it("never writes a top-level systemPrompt, even if one is already present", () => { const body = { systemPrompt: "OLD", conversationState: { - history: [{ userInputMessage: { content: "different head content", modelId: "m" } }], + history: [{ userInputMessage: { content: "tail", modelId: "m" } }], + }, + }; + injectSystemPrompt(body, FORMATS.KIRO, P1); + expect(body.systemPrompt).toBe("OLD"); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}`); + }); + + it("when no history user, updates currentMessage instead", () => { + const body = { + conversationState: { + history: [], + currentMessage: { userInputMessage: { content: "tail", modelId: "m" } }, + }, + }; + injectSystemPrompt(body, FORMATS.KIRO, P1); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`tail${SEP}${P1}`); + }); + + it("empty user content becomes the prompt itself", () => { + const body = { + conversationState: { + history: [{ userInputMessage: { content: "", modelId: "m" } }], currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`OLD${SEP}${P1}`); - expect(body.conversationState.history[0].userInputMessage.content).toBe("different head content"); + expect(body.conversationState.history[0].userInputMessage.content).toBe(P1); + expect(body.conversationState.currentMessage.userInputMessage.content).toBe("cur"); }); it("exact retry idempotency for kiro", () => { - const oldPrompt = "OLD"; const body = { - systemPrompt: oldPrompt, conversationState: { - history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }], + history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - const after1 = JSON.parse(JSON.stringify(body)); + const after1 = body.conversationState.history[0].userInputMessage.content; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(after1.systemPrompt); - expect(body.conversationState.history[0].userInputMessage.content).toBe(after1.conversationState.history[0].userInputMessage.content); - // different prompt both apply + expect(body.conversationState.history[0].userInputMessage.content).toBe(after1); + // different prompt both apply, in injection order injectSystemPrompt(body, FORMATS.KIRO, P2); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}${SEP}${P2}`); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}${SEP}${P2}`); }); it("preserves non-enumerable _kiroUpstreamModel", () => { const body = { - systemPrompt: "OLD", - conversationState: { history: [{ userInputMessage: { content: "OLD" + SEP + "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "OLD" + SEP + "tail2", modelId: "m" } } }, + conversationState: { history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "tail2", modelId: "m" } } }, }; Object.defineProperty(body, "_kiroUpstreamModel", { value: "m", enumerable: false }); injectSystemPrompt(body, FORMATS.KIRO, P1); expect(body._kiroUpstreamModel).toBe("m"); expect(Object.getOwnPropertyDescriptor(body, "_kiroUpstreamModel").enumerable).toBe(false); }); + + it("frozen user message fails open without throwing or half-writing", () => { + const body = { + conversationState: { + history: [{ userInputMessage: Object.freeze({ content: "tail", modelId: "m" }) }], + }, + }; + expect(() => injectSystemPrompt(body, FORMATS.KIRO, P1)).not.toThrow(); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.history[0].userInputMessage.content).toBe("tail"); + }); }); describe("system-inject regression fixes", () => { it("kiro partial mutation converges on retry after transient content write failure", () => { - const oldPrompt = "OLD"; let failNextWrite = true; - const um = { content: `${oldPrompt}${SEP}tail`, modelId: "m" }; + const um = { content: "tail", modelId: "m" }; const proxiedUm = new Proxy(um, { set(t, p, v) { if (p === "content" && failNextWrite) { failNextWrite = false; throw new Error("transient"); } t[p] = v; return true; }, }); - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [{ userInputMessage: proxiedUm }], - }, - }; + const body = { conversationState: { history: [{ userInputMessage: proxiedUm }] } }; injectSystemPrompt(body, FORMATS.KIRO, P1); - // first pass rolled back atomically β€” nothing half-applied - expect(body.systemPrompt).toBe(oldPrompt); - expect(um.content).toBe(`${oldPrompt}${SEP}tail`); + // nothing half-applied + expect(um.content).toBe("tail"); + expect(body.systemPrompt).toBeUndefined(); // retry converges injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`); - expect(um.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`); - }); - - it("kiro rolls back systemPrompt when user content write fails (atomicity)", () => { - const oldPrompt = "OLD"; - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [{ userInputMessage: Object.freeze({ content: `${oldPrompt}${SEP}tail`, modelId: "m" }) }], - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(oldPrompt); + expect(um.content).toBe(`tail${SEP}${P1}`); }); it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => { - const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} }; + const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], conversationState: {} }; injectSystemPrompt(body, FORMATS.OPENAI, P1); expect(body.messages[0].content).toBe(`hello${SEP}${P1}`); }); @@ -409,15 +392,14 @@ describe("system-inject regression fixes", () => { expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`); }); - it("kiro empty-old prepend fires when prompt appears mid-tail only", () => { + it("substring occurrence does not suppress kiro injection", () => { const body = { - systemPrompt: "", conversationState: { history: [{ userInputMessage: { content: `some ${P1} here`, modelId: "m" } }], }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}some ${P1} here`); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`some ${P1} here${SEP}${P1}`); }); }); diff --git a/tests/unit/video-providers.test.js b/tests/unit/video-providers.test.js new file mode 100644 index 00000000..bfa26589 --- /dev/null +++ b/tests/unit/video-providers.test.js @@ -0,0 +1,296 @@ +/** + * Unit tests for the OpenRouter + Vertex (Veo) video adapters. + * + * Covers: + * - registry wiring (videoConfig, video serviceKind, video-kind models) + * - OpenRouter: POST to the collection root, GET poll, verbatim passthrough + * - Vertex: predictLongRunning body translation, fetchPredictOperation polling, + * operation-name round-trip through the job id, response mapping + * - xAI default path is unchanged by the adapter hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("open-sse/services/tokenRefresh.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, refreshTokenByProvider: vi.fn(), refreshVertexToken: vi.fn() }; +}); + +import { handleVideoProxyCore, getVideoConfig } from "open-sse/handlers/videoCore.js"; +import { refreshVertexToken } from "open-sse/services/tokenRefresh.js"; +import { PROVIDER_MEDIA, PROVIDER_MODELS } from "open-sse/providers/index.js"; + +const originalFetch = global.fetch; +const jsonResponse = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +// Vertex operation names are resource paths; the adapter base64url-encodes them. +const OPERATION_NAME = + "projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/op-abc"; +const JOB_ID = Buffer.from(OPERATION_NAME, "utf8").toString("base64url"); + +describe("registry wiring", () => { + it("exposes videoConfig + video serviceKind for openrouter and vertex", () => { + expect(getVideoConfig("openrouter").baseUrl).toBe("https://openrouter.ai/api/v1/videos"); + expect(getVideoConfig("vertex").baseUrl).toBe("https://aiplatform.googleapis.com"); + expect(PROVIDER_MEDIA.openrouter.serviceKinds).toContain("video"); + expect(PROVIDER_MEDIA.vertex.serviceKinds).toContain("video"); + }); + + it("registers video-kind models on both providers", () => { + const or = PROVIDER_MODELS.openrouter.find((m) => m.id === "google/veo-3.1"); + const vx = PROVIDER_MODELS.vertex.find((m) => m.id === "veo-3.1-generate-preview"); + expect(or?.kind).toBe("video"); + expect(vx?.kind).toBe("video"); + }); +}); + +describe("openrouter video adapter", () => { + beforeEach(() => { global.fetch = vi.fn(); }); + afterEach(() => { global.fetch = originalFetch; }); + + it("POSTs creation to the collection root (no /generations suffix)", async () => { + global.fetch.mockResolvedValueOnce(jsonResponse({ id: "job-1", status: "pending" })); + + const raw = '{"model":"google/veo-3.1","prompt":"a paper boat"}'; + const result = await handleVideoProxyCore({ + provider: "openrouter", + action: "generations", + rawBody: raw, + contentType: "application/json", + credentials: { apiKey: "sk-or-key" }, + }); + + expect(result.success).toBe(true); + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://openrouter.ai/api/v1/videos"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(raw); // verbatim + expect(init.headers.Authorization).toBe("Bearer sk-or-key"); + expect(init.headers["HTTP-Referer"]).toBe("https://endpoint-proxy.local"); + expect(await result.response.json()).toEqual({ id: "job-1", status: "pending" }); + }); + + it("polls GET /videos/{id} and passes the payload through verbatim", async () => { + const payload = { id: "job-1", status: "completed", unsigned_urls: ["https://cdn/v.mp4"] }; + global.fetch.mockResolvedValueOnce(jsonResponse(payload)); + + const result = await handleVideoProxyCore({ + provider: "openrouter", + requestId: "job-1", + credentials: { apiKey: "sk-or-key" }, + }); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://openrouter.ai/api/v1/videos/job-1"); + expect(init.method).toBe("GET"); + expect(await result.response.json()).toEqual(payload); + }); + + it("rejects unsupported actions before any upstream call (no billable job)", async () => { + const result = await handleVideoProxyCore({ + provider: "openrouter", + action: "extensions", + rawBody: "{}", + contentType: "application/json", + credentials: { apiKey: "sk-or-key" }, + }); + + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("vertex (veo) video adapter", () => { + beforeEach(() => { + global.fetch = vi.fn(); + refreshVertexToken.mockReset(); + }); + afterEach(() => { global.fetch = originalFetch; }); + + const saJson = JSON.stringify({ + type: "service_account", + client_email: "sa@proj-1.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n", + project_id: "proj-1", + }); + + it("translates the create body to predictLongRunning and returns a poll-able job id", async () => { + refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" }); + global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME })); + + const result = await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ + model: "veo-3.1-generate-preview", + prompt: "a neon city", + duration: 8, + aspect_ratio: "16:9", + resolution: "720p", + n: 1, + }), + contentType: "application/json", + credentials: { apiKey: saJson }, + }); + + expect(result.success).toBe(true); + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe( + "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:predictLongRunning" + ); + expect(init.headers.Authorization).toBe("Bearer vertex-tok"); + expect(JSON.parse(init.body)).toEqual({ + instances: [{ prompt: "a neon city" }], + parameters: { sampleCount: 1, durationSeconds: 8, aspectRatio: "16:9", resolution: "720p" }, + }); + + // Response is mapped onto the async-job shape clients already poll. + expect(await result.response.json()).toEqual({ + id: JOB_ID, + request_id: JOB_ID, + status: "pending", + }); + }); + + it("maps a data-URL image onto the Vertex image instance", async () => { + refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" }); + global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME })); + + await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ + model: "veo-3.1-generate-preview", + prompt: "animate this", + image: "data:image/png;base64,AAAB", + }), + contentType: "application/json", + credentials: { apiKey: saJson }, + }); + + expect(JSON.parse(global.fetch.mock.calls[0][1].body).instances[0].image).toEqual({ + bytesBase64Encoded: "AAAB", + mimeType: "image/png", + }); + }); + + it("polls via fetchPredictOperation and maps a completed operation", async () => { + refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" }); + global.fetch.mockResolvedValueOnce( + jsonResponse({ + name: OPERATION_NAME, + done: true, + response: { videos: [{ gcsUri: "gs://bucket/v.mp4", mimeType: "video/mp4" }] }, + }) + ); + + const result = await handleVideoProxyCore({ + provider: "vertex", + requestId: JOB_ID, + credentials: { apiKey: saJson }, + }); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe( + "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:fetchPredictOperation" + ); + expect(init.method).toBe("POST"); // Vertex polls with POST, not GET + expect(JSON.parse(init.body)).toEqual({ operationName: OPERATION_NAME }); + + expect(await result.response.json()).toEqual({ + id: JOB_ID, + request_id: JOB_ID, + status: "completed", + video: { url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" }, + videos: [{ url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" }], + }); + }); + + it("maps a failed operation to status failed", async () => { + refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" }); + global.fetch.mockResolvedValueOnce( + jsonResponse({ name: OPERATION_NAME, done: true, error: { code: 3, message: "bad prompt" } }) + ); + + const result = await handleVideoProxyCore({ + provider: "vertex", + requestId: JOB_ID, + credentials: { apiKey: saJson }, + }); + + const body = await result.response.json(); + expect(body.status).toBe("failed"); + expect(body.error.message).toBe("bad prompt"); + }); + + it("rejects missing project id and raw API keys before any upstream call", async () => { + const noProject = await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }), + contentType: "application/json", + credentials: { apiKey: "AIzaRawKey" }, + }); + expect(noProject.success).toBe(false); + expect(noProject.status).toBe(400); + + const noToken = await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }), + contentType: "application/json", + credentials: { apiKey: "AIzaRawKey", providerSpecificData: { projectId: "proj-1" } }, + }); + expect(noToken.success).toBe(false); + expect(noToken.status).toBe(400); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects an invalid job id without calling upstream", async () => { + refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" }); + const result = await handleVideoProxyCore({ + provider: "vertex", + requestId: Buffer.from("not-an-operation", "utf8").toString("base64url"), + credentials: { apiKey: saJson }, + }); + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + // A base64url id decodes to arbitrary bytes, so a crafted one used to splice a + // path traversal into the fetch URL while the Authorization header stayed on. + it("rejects job ids that decode outside the projects/…/operations/ shape", async () => { + refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" }); + const jid = (s) => Buffer.from(s, "utf8").toString("base64url"); + + for (const id of [ + jid("../../evil"), + jid("projects/p/locations/l/publishers/google/models/m/operations/../../x"), + jid("../../evil/operations/op"), + "!!!not-base64!!!", + `${JOB_ID}=`, + `${JOB_ID}\n`, + ]) { + const result = await handleVideoProxyCore({ provider: "vertex", requestId: id, credentials: { apiKey: saJson } }); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + } + }); + + it("rejects a model id carrying path separators", async () => { + refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" }); + const result = await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ model: "../../evil", prompt: "x" }), + contentType: "application/json", + credentials: { apiKey: saJson }, + }); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/xai-video-handler.test.js b/tests/unit/xai-video-handler.test.js index 6cadab95..649f9a88 100644 --- a/tests/unit/xai-video-handler.test.js +++ b/tests/unit/xai-video-handler.test.js @@ -29,6 +29,7 @@ vi.mock("@/sse/services/auth.js", () => authMocks); vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks); vi.mock("@/lib/localDb", () => ({ getSettings: vi.fn(async () => ({ requireApiKey: false })), + getProviderConnectionById: vi.fn(async () => ({ id: "conn-5", provider: "xai" })), getComboByName: vi.fn(async () => null), getModelAliases: vi.fn(async () => ({})), getProviderNodes: vi.fn(async () => []), diff --git a/tests/unit/xiaomi-mimo-executor.test.js b/tests/unit/xiaomi-mimo-executor.test.js new file mode 100644 index 00000000..10c6a520 --- /dev/null +++ b/tests/unit/xiaomi-mimo-executor.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js"; +import { getExecutor } from "../../open-sse/executors/index.js"; + +const { bareModel, COOKIE_KEY } = __test__; + +const OPENAI_T = { runtimeTransport: { format: "openai", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions" } }; +const CLAUDE_T = { runtimeTransport: { format: "claude", baseUrl: "https://api.xiaomimimo.com/anthropic/v1/messages" } }; + +describe("xiaomi-mimo executor", () => { + let ex; + beforeEach(() => { + ex = new XiaomiMimoExecutor(); + }); + + it("is registered for xiaomi-mimo", () => { + expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor); + }); + + it("routes Preview models to the account-service route regardless of transport", () => { + const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; + expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected); + expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected); + // body.model arrives as `xiaomi/` via upstreamModelId + expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected); + }); + + it("keeps the sourceFormat-matched endpoint for cloud models", () => { + // Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions. + expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); + expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); + }); + + it("authenticates Preview calls with the account cookie", () => { + const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview"); + expect(headers.Cookie).toBe("serviceToken=abc"); + expect(headers.Authorization).toBeUndefined(); + }); + + it("authenticates cloud calls with the bearer key", () => { + const headers = ex.buildHeaders({ accessToken: "sk-x" }, true, "u", "mimo-v2.5-pro"); + expect(headers.Authorization).toBe("Bearer sk-x"); + expect(headers.Cookie).toBeUndefined(); + }); + + it("fails fast when a Preview call has no account session", async () => { + await expect( + ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }), + ).rejects.toThrow(/account session unavailable/); + }); + + it("flattens content-part arrays to plain strings", () => { + const out = ex.transformRequest( + "mimo-x-pro-preview", + { messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] }, + true, + {}, + ); + expect(out.messages[0].content).toBe("ab"); + }); + + it("applies Preview defaults without overriding explicit values", () => { + const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 }; + const out = ex.transformRequest("mimo-x-pro-preview", body, true, {}); + expect(out.temperature).toBe(0.2); // caller's value kept + expect(out.top_p).toBe(0.95); // default filled in + expect(out.max_tokens).toBe(4096); + }); + + it("leaves cloud bodies free of Preview defaults", () => { + const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {}); + expect(out.thinking).toBeUndefined(); + expect(out.max_tokens).toBeUndefined(); + }); + + it("strips a provider/model prefix when testing preview ids", () => { + expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + }); +}); diff --git a/tests/unit/xiaomi-mimo-oauth-proxy.test.js b/tests/unit/xiaomi-mimo-oauth-proxy.test.js new file mode 100644 index 00000000..c74573ff --- /dev/null +++ b/tests/unit/xiaomi-mimo-oauth-proxy.test.js @@ -0,0 +1,54 @@ +/** + * Regression: the xiaomi-mimo OAuth session store must not retain sessions + * once the callback listener is down. + * + * Each /authorize registers a session holding an X25519 private key, keyed by a + * fresh state. Unlike trae/windsurf/zed (singleton session) this is a Map, so + * without an explicit clear every login attempt would leak a private key for + * the whole process lifetime. + */ +import { describe, it, expect } from "vitest"; +import { + registerXiaomiMimoSession, + getXiaomiMimoSessionStatus, + clearXiaomiMimoSession, + stopXiaomiMimoProxy, +} from "../../src/lib/oauth/utils/server.js"; + +const KEY = Buffer.from("x25519-private-key-material"); + +describe("xiaomi-mimo OAuth session store", () => { + it("drops pending sessions when the proxy stops", () => { + registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY }); + expect(getXiaomiMimoSessionStatus("s1")).not.toBeNull(); + + stopXiaomiMimoProxy(); + + expect(getXiaomiMimoSessionStatus("s1")).toBeNull(); + }); + + it("drops every session, not just the last one", () => { + registerXiaomiMimoSession({ state: "a", privateKeyDer: KEY }); + registerXiaomiMimoSession({ state: "b", privateKeyDer: KEY }); + registerXiaomiMimoSession({ state: "c", privateKeyDer: KEY }); + + stopXiaomiMimoProxy(); + + for (const s of ["a", "b", "c"]) { + expect(getXiaomiMimoSessionStatus(s)).toBeNull(); + } + }); + + it("ignores registrations with a missing state or key", () => { + expect(registerXiaomiMimoSession({ state: "", privateKeyDer: KEY })).toBe(false); + expect(registerXiaomiMimoSession({ state: "s", privateKeyDer: null })).toBe(false); + }); + + it("never exposes the private key to callers", () => { + registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY }); + const view = getXiaomiMimoSessionStatus("s1"); + expect(view).toEqual({ status: "pending", result: null, error: null }); + expect(JSON.stringify(view)).not.toContain("privateKeyDer"); + clearXiaomiMimoSession("s1"); + }); +}); diff --git a/tests/unit/xiaomi-mimo-oauth-session.test.js b/tests/unit/xiaomi-mimo-oauth-session.test.js new file mode 100644 index 00000000..3aee1cc2 --- /dev/null +++ b/tests/unit/xiaomi-mimo-oauth-session.test.js @@ -0,0 +1,152 @@ +/** + * Regression: the poll-status/exchange session lifecycle for xiaomi-mimo. + * + * The original PR cleared the session inside poll-status, so the client's + * following POST /exchange always saw a missing session and returned 400 β€” + * the whole browser-OAuth fallback was dead. These tests pin the contract: + * - a finished session survives /poll-status until /exchange consumes it + * - a failed session is cleaned up by /poll-status itself + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ + NextResponse: { + json: (body, init) => ({ + status: init?.status || 200, + body, + json: async () => body, + }), + }, +})); + +vi.mock("@/lib/oauth/providers", () => ({ + getProvider: vi.fn(), + generateAuthData: vi.fn(), + exchangeTokens: vi.fn(), + requestDeviceCode: vi.fn(), + pollForToken: vi.fn(), +})); + +vi.mock("@/models", () => ({ + createProviderConnection: vi.fn(async (d) => ({ id: "conn-1", ...d })), +})); + +vi.mock("open-sse/shared/mimoAccount.js", () => ({ + readDesktopPassToken: vi.fn(async () => ({ passToken: "pt-abc", userId: "u1", cUserId: "c1" })), +})); + +vi.mock("@/lib/oauth/utils/ideDetect", () => ({ detectIdeInstalled: vi.fn() })); + +// Session store backing the mocked OAuth server helpers, so the test can assert +// on real lifecycle transitions rather than on call counts alone. +const sessions = new Map(); +const stopped = { count: 0 }; + +vi.mock("@/lib/oauth/utils/server", () => { + const notUsed = () => { throw new Error("unexpected helper"); }; + const noop = () => {}; + return { + startCodexProxy: notUsed, stopCodexProxy: noop, registerCodexSession: noop, + getCodexSessionStatus: () => null, clearCodexSession: noop, + startXaiProxy: notUsed, stopXaiProxy: noop, registerXaiSession: noop, + getXaiSessionStatus: () => null, clearXaiSession: noop, + startTraeProxy: notUsed, stopTraeProxy: noop, registerTraeSession: noop, + getTraeSessionStatus: () => null, clearTraeSession: noop, + startWindsurfProxy: notUsed, stopWindsurfProxy: noop, registerWindsurfSession: noop, + getWindsurfSessionStatus: () => null, clearWindsurfSession: noop, + startZedProxy: notUsed, stopZedProxy: noop, registerZedSession: noop, + getZedSessionStatus: () => null, clearZedSession: noop, + startXiaomiMimoProxy: notUsed, + stopXiaomiMimoProxy: () => { stopped.count += 1; }, + registerXiaomiMimoSession: () => {}, + getXiaomiMimoSessionStatus: (state) => { + const s = sessions.get(state); + return s ? { status: s.status, result: s.result || null, error: s.error || null } : null; + }, + clearXiaomiMimoSession: (state) => { sessions.delete(state); }, + }; +}); + +const { GET, POST } = await import("../../src/app/api/oauth/[provider]/[action]/route.js"); + +const get = (action, state) => + GET(new Request(`http://localhost/api/oauth/xiaomi-mimo/${action}?state=${state}`), { + params: Promise.resolve({ provider: "xiaomi-mimo", action }), + }); + +const exchange = (state) => + POST( + new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state }), + }), + { params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) }, + ); + +describe("xiaomi-mimo OAuth session lifecycle", () => { + beforeEach(() => { + sessions.clear(); + stopped.count = 0; + }); + + it("keeps a finished session alive so /exchange can consume it", async () => { + sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x", baseUrl: "https://api.xiaomimimo.com/v1" } }); + + const poll = await get("poll-status", "st1"); + expect(poll.status).toBe(200); + expect(await poll.json()).toMatchObject({ status: "done" }); + + // The bug: this used to be gone, making /exchange always 400. + expect(sessions.has("st1")).toBe(true); + + const res = await exchange("st1"); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + }); + + it("clears the session once /exchange consumed it", async () => { + sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x" } }); + await exchange("st1"); + expect(sessions.has("st1")).toBe(false); + }); + + it("cleans up a failed session in poll-status and stops the proxy", async () => { + sessions.set("st2", { status: "error", error: "Could not decrypt with any pending session key" }); + + const poll = await get("poll-status", "st2"); + expect(await poll.json()).toMatchObject({ status: "error" }); + + expect(sessions.has("st2")).toBe(false); + expect(stopped.count).toBe(1); + }); + + it("persists the Desktop passToken onto the connection (Preview models need it)", async () => { + const { createProviderConnection } = await import("@/models"); + sessions.set("st3", { status: "done", result: { uid: "u1", accessToken: "sk-x" } }); + + await exchange("st3"); + + const arg = createProviderConnection.mock.calls.at(-1)[0]; + expect(arg.provider).toBe("xiaomi-mimo"); + expect(arg.providerSpecificData.mimoPassToken).toBe("pt-abc"); + expect(arg.providerSpecificData.mimoUserId).toBe("u1"); + }); + + it("still reports unknown for an unregistered state", async () => { + const poll = await get("poll-status", "nope"); + expect(await poll.json()).toEqual({ status: "unknown" }); + }); + + it("rejects /exchange without a state", async () => { + const res = await POST( + new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + { params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) }, + ); + expect(res.status).toBe(400); + }); +});