From e7b5f09d506072baec14c45119bc9acd9bec623e Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 9 Sep 2026 09:45:18 +0700 Subject: [PATCH 01/28] fix(gemini): normalize contents and handle intermediate tool responses in Antigravity Co-Authored-By: Claude Code --- open-sse/executors/antigravity.js | 18 +++++++-------- open-sse/translator/formats/gemini.js | 18 +++++++++++++++ .../translator/request/openai-to-gemini.js | 23 ++++++------------- 3 files changed, 33 insertions(+), 26 deletions(-) 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/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/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 }; From 628ff1eab5cb5996ac52cfb34389eec6aa2fc482 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 9 Sep 2026 09:45:23 +0700 Subject: [PATCH 02/28] fix(auth): set 24h maxAge for dashboard session cookie Co-Authored-By: Claude Code --- src/lib/auth/dashboardSession.js | 2 ++ 1 file changed, 2 insertions(+) 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, }); } From e3bf94ee25fc4f472e323bbb961ca5cc3ae56367 Mon Sep 17 00:00:00 2001 From: Christian Gennari Date: Wed, 9 Sep 2026 09:54:40 +0700 Subject: [PATCH 03/28] feat(antigravity): add weekly quota tracking and free-tier handling (#3892) --- open-sse/providers/registry/antigravity.js | 1 + open-sse/services/usage/antigravity-weekly.js | 150 ++++++ open-sse/services/usage/google.js | 62 ++- .../usage/components/ProviderLimits/utils.js | 15 +- .../unit/antigravity-quota-gemini-3.6.test.js | 2 +- .../unit/antigravity-quota-gemini-3.7.test.js | 2 +- .../unit/antigravity-quota-gemini-3.8.test.js | 2 +- tests/unit/antigravity-usage-headers.test.js | 9 +- .../unit/antigravity-weekly-dashboard.test.js | 113 +++++ tests/unit/antigravity-weekly-quota.test.js | 464 ++++++++++++++++++ 10 files changed, 811 insertions(+), 9 deletions(-) create mode 100644 open-sse/services/usage/antigravity-weekly.js create mode 100644 tests/unit/antigravity-weekly-dashboard.test.js create mode 100644 tests/unit/antigravity-weekly-quota.test.js 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/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/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/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 86fd30d8..ad321433 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -376,10 +376,12 @@ export function parseQuotaData(provider, data) { case "antigravity": if (data.quotas) { const entries = Object.entries(data.quotas); + const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]); const geminiModels = entries.filter(([k]) => 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/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", + }); + }); +}); From 4ad1e7a4ba264ebef95a539f0d33867c983a88e4 Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:18:35 +0700 Subject: [PATCH 04/28] fix(usage): parse Fable weekly limit from limits[] instead of fabricating a row (#3847) --- open-sse/services/usage/claude.js | 36 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 20 deletions(-) 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 { From 7fee56bacd72c27eb0d7c73568ab9400dc3f253d Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Wed, 9 Sep 2026 10:24:30 +0700 Subject: [PATCH 05/28] fix(providers): clear stale locks after validation (#3830) Clear stale connection health state (modelLock_*, backoffLevel, rateLimitedUntil, errorCode) whenever a connection is explicitly marked active after successful validation or OAuth re-login. Closes #3810 --- src/lib/db/repos/connectionsRepo.js | 28 +++++++- tests/unit/db-sqlite-vs-lowdb.test.js | 98 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) 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/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", From 35b950be81314158999b63566cf3bbad502dcfd7 Mon Sep 17 00:00:00 2001 From: mrnim94 <50592567+mrnim94@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:45:44 +0700 Subject: [PATCH 06/28] fix(kiro): route requests through current runtime surfaces and fix 400 REQUEST_BODY_INVALID (#3776) --- open-sse/executors/kiro.js | 40 +++++++++++++------ open-sse/translator/request/claude-to-kiro.js | 3 -- open-sse/translator/request/openai-to-kiro.js | 3 -- tests/translator/claude-kiro-direct.test.js | 7 ++-- .../kiro-api-key-endpoint-routing.test.js | 14 +++---- tests/unit/kiro-minimal-wire-payload.test.js | 19 +++++++++ tests/unit/openai-to-kiro.test.js | 2 +- 7 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 tests/unit/kiro-minimal-wire-payload.test.js diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 77616618..e12aced6 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -259,6 +259,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 +298,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 +314,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/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 3cfad109..9a7cf377 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -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/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 1d9bedec..990b607f 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -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/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/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/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index 17773bdb..965bd2d1 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -606,7 +606,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 ); From 40dffbce538e6df8914362c5eb91d256397562bb Mon Sep 17 00:00:00 2001 From: 617929zcxc <101911800+617929zcxc@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:53:23 +0700 Subject: [PATCH 07/28] feat(providers): add standalone Qwen provider --- open-sse/providers/registry/index.js | 3 ++- open-sse/providers/registry/qwen.js | 36 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 open-sse/providers/registry/qwen.js diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index ab71e2e7..48a49ebe 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -123,7 +123,7 @@ import p119 from "./selfhosted-embedding.js"; import p120 from "./fish-audio.js"; import p121 from "./alitp-intl.js"; import p122 from "./xquik.js"; - +import p124 from "./qwen.js"; export default [ p0, p1, @@ -247,4 +247,5 @@ export default [ p120, p121, p122, + p124, ]; diff --git a/open-sse/providers/registry/qwen.js b/open-sse/providers/registry/qwen.js new file mode 100644 index 00000000..d51e88a8 --- /dev/null +++ b/open-sse/providers/registry/qwen.js @@ -0,0 +1,36 @@ +export default { + id: "qwen", + priority: 12, + alias: "qwen", + aliases: ["qw"], + display: { + name: "Qwen", + icon: "sparkles", + color: "#6366F1", + textIcon: "Qw", + website: "https://www.alibabacloud.com/en/product/model-studio", + notice: { + apiKeyUrl: + "https://modelstudio.console.alibabacloud.com/?apiKey=1", + }, + }, + category: "apikey", + transport: { + baseUrl: + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + headers: {}, + quirks: { preserveCacheControl: true }, + }, + models: [ + // Flagship & API models + { id: "qwen3.8-max", name: "Qwen3.8 Max" }, + { id: "qwen3.8-flash", name: "Qwen3.8 Flash" }, + { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, + { id: "qwen3.7-flash", name: "Qwen3.7 Flash" }, + // Open-source models + { id: "qwen3.8-2.4t-a95b", name: "Qwen3.8 2.4T MoE (Open)" }, + { id: "qwen3.8-27b", name: "Qwen3.8 27B (Open)" }, + { id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B MoE (Open)" }, + { id: "qwen3.6-27b", name: "Qwen3.6 27B (Open)" }, + ], +}; From eee3515e5419cd0824bedb4beb0e0d91dd662b50 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 21:08:32 +0700 Subject: [PATCH 08/28] feat(opencode-go): add newly published Go models Add the models the provider docs now list but the registry lacked: chat/completions glm-5.3, kimi-k3, deepseek-flash, longcat-2.0, hy4-preview, hy3 + /messages qwen3.8-max, qwen3.8-flash responses only grok-4.6, gpt-5.6-luna Endpoints follow the table at https://opencode.ai/docs/go/. chat-only models stay on the sourceFormat-matched transport guard so a Claude client is never routed to /messages for a model that lacks it. Co-Authored-By: Claude Code --- open-sse/providers/registry/opencode-go.js | 15 +++++++++++++-- tests/unit/opencode-go-models.test.js | 18 +++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c6673223..a715a98e 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: "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: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, + { 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/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 7ece100a..0ed0804b 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,13 @@ 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-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", - "mimo-v2.5", "mimo-v2.5-pro", + "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", "deepseek-flash", + "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 +95,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(); From a7047a07d475207a6685b1d288f1aa57c027f405 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 21:23:24 +0700 Subject: [PATCH 09/28] fix(codex): restore Version header and single-source the CLI version The image handler's `version` header was commented out, so Codex image requests reached chatgpt.com without the Version identity the backend expects. Restore it and route every Codex identity header through one constant. The CLI version now lives on registry codex.transport as `cliVersion` (the same pattern gemini-cli uses) and is re-exported as CODEX_CLI_VERSION, so the registry User-Agent, the image handler and the connection test can no longer drift apart. Bumped 0.136.0 -> 0.154.0 (current stable). Co-Authored-By: Claude Code --- open-sse/config/appConstants.js | 3 +++ open-sse/handlers/imageProviders/codex.js | 6 +++--- open-sse/providers/registry/codex.js | 7 ++++++- src/app/api/providers/[id]/test/testUtils.js | 3 ++- tests/__baseline__/providers-baseline.json | 3 ++- tests/unit/image-generation.test.js | 2 +- 6 files changed, 17 insertions(+), 7 deletions(-) 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/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 218302ab..385ec5b6 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -2,10 +2,10 @@ 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"; @@ -157,7 +157,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(), }; }, diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 1909fe97..bf436a10 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", 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/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/unit/image-generation.test.js b/tests/unit/image-generation.test.js index 12dce95d..4df94afc 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", }), }) ); From 4a390685b3d38054305bb014a63910a827d6c652 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 21:56:30 +0700 Subject: [PATCH 10/28] feat(cli): group model selector by provider with search Replace the flat numbered model list with provider-grouped browsing (combos first, then providers by alias order), full-text search across all models, and manual custom model ID entry. A single available category opens directly into its model list. Also bump root and cli packages to 0.5.75 and ignore packed `9router-*` tarballs. Co-Authored-By: Claude Code --- .gitignore | 3 +- cli/package.json | 2 +- cli/src/cli/utils/modelSelector.js | 227 +++++++++++++++++++++++------ package.json | 2 +- 4 files changed, 186 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 0f0fb03c..6773d550 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,5 @@ graphify-out/* .next-analyze/* # Kiro local workspace state -.kiro/ \ No newline at end of file +.kiro/ +9router-* diff --git a/cli/package.json b/cli/package.json index db516ac4..58b9dfa8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.69", + "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/package.json b/package.json index 80f6d46b..15b7eca7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.69", + "version": "0.5.75", "description": "9Router web dashboard", "private": true, "scripts": { From 807553e24662a219fecabca38b1fc9626e2b7fbc Mon Sep 17 00:00:00 2001 From: zmf Date: Thu, 10 Sep 2026 21:57:22 +0700 Subject: [PATCH 11/28] feat(codebuddy-cn): replace deepseek-v4-flash with deepseek-v4.1-flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's product-config payload (which the IDE plugin fetches from copilot.tencent.com) publishes deepseek-v4.1-flash and no longer lists deepseek-v4-flash, so the old id is dropped — same pattern as the previous catalog refreshes (#3648, #3802). The v4-flash endpoint still answers 200, but the published list is the contract. Per the server table, maxOutput rises 50000 -> 128000 while contextWindow stays 1000000. - registry/codebuddy-cn.js: models[] entry swapped to the new id - capabilities.js: per-model entry swapped, maxOutput -> 128000 No changes needed in thinkingLevels.js (the deepseek-v4* pattern already matches the new id and publishes low/high/xhigh, matching the server's supportedEfforts or pricing.js (the deepseek-v* glob yields the same rates). EOF ) --- open-sse/providers/capabilities.js | 5 ++++- open-sse/providers/registry/codebuddy-cn.js | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index a9f8745a..dd9fa381 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 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", From 3288bbc47e75c90a9ebf02ac90a5fe934b41b90c Mon Sep 17 00:00:00 2001 From: coozgan Date: Thu, 10 Sep 2026 22:05:10 +0700 Subject: [PATCH 12/28] feat(video): add OpenRouter and Vertex AI (Veo) video generation Video generation was xAI-only. Adds an adapter layer under open-sse/handlers/videoProviders/ so /v1/videos/* can target OpenRouter or Google Cloud credentials. A provider with no adapter keeps the exact previous behaviour (raw body to {baseUrl}/{action}, poll {baseUrl}/{id}, verbatim passthrough), so the xAI path is unchanged. - openrouter: async job shape identical to xAI; creation POSTs to the /videos collection root (no /generations suffix) and the registry HTTP-Referer / X-Title headers are applied. Bodies pass through verbatim. - vertex: two-way translation, since Veo does not speak the OpenAI-ish videos shape. create -> :predictLongRunning { instances[], parameters{} }, poll -> :fetchPredictOperation (Veo has no REST GET poll). The operation resource name is base64url-encoded into the job id so GET /v1/videos/{id} stays a flat path. Access tokens are minted from Service Account JSON via the existing refreshVertexToken; raw API keys are rejected up front. The operation response maps back onto the { id, status, video, videos } shape clients already poll. - videoCore: the request plan is rebuilt per attempt, so the 401 -> refresh once -> retry once path picks up the refreshed token. Adapter validation errors return 400 before any upstream call, so a malformed request can never create a billable job. - videoGeneration: GET /v1/videos/{id} resolves the provider from the pinned x-connection-id connection, then ?provider=, then falls back to the xAI default. - registry: openrouter and vertex gain videoConfig, the video serviceKind and video-kind models (Veo 3.1 / 3 / 2, Sora 2 Pro, Seedance 2.0). --- open-sse/handlers/videoCore.js | 67 ++++- open-sse/handlers/videoProviders/index.js | 13 + .../handlers/videoProviders/openrouter.js | 39 +++ open-sse/handlers/videoProviders/vertex.js | 145 ++++++++++ open-sse/providers/registry/openrouter.js | 11 +- open-sse/providers/registry/vertex.js | 9 +- src/sse/handlers/videoGeneration.js | 19 +- tests/unit/video-providers.test.js | 263 ++++++++++++++++++ tests/unit/xai-video-handler.test.js | 1 + 9 files changed, 551 insertions(+), 16 deletions(-) create mode 100644 open-sse/handlers/videoProviders/index.js create mode 100644 open-sse/handlers/videoProviders/openrouter.js create mode 100644 open-sse/handlers/videoProviders/vertex.js create mode 100644 tests/unit/video-providers.test.js 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..25394b94 --- /dev/null +++ b/open-sse/handlers/videoProviders/vertex.js @@ -0,0 +1,145 @@ +// 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"); +const decodeJobId = (id) => Buffer.from(id, "base64url").toString("utf8"); + +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) { + let operationName; + try { + operationName = decodeJobId(requestId); + } catch { + return { error: "Invalid Vertex video job id" }; + } + const modelPath = operationName.split("/operations/")[0]; + if (!modelPath || modelPath === operationName) return { error: "Invalid Vertex video job id" }; + return { + method: "POST", + url: `${base}/v1/${modelPath}: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)" }; + 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/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/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/unit/video-providers.test.js b/tests/unit/video-providers.test.js new file mode 100644 index 00000000..680db5a5 --- /dev/null +++ b/tests/unit/video-providers.test.js @@ -0,0 +1,263 @@ +/** + * 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(); + }); +}); diff --git a/tests/unit/xai-video-handler.test.js b/tests/unit/xai-video-handler.test.js index 563ee08c..a18411b4 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 () => []), From 832a34659e3ea4a97373bdbfef41462f855f5f68 Mon Sep 17 00:00:00 2001 From: Hai Trinh Date: Thu, 10 Sep 2026 22:06:17 +0700 Subject: [PATCH 13/28] feat(codex): add GPT Image 2.5, Flare and Sunburst image models - Add gpt-image-1.5, gpt-image-2, gpt-image-2.5, gpt-image-2.5-flare and gpt-image-2.5-sunburst as Codex image models with multi-image support - Add gpt-image-2.5, gpt-image-2.5-flare and gpt-image-2.5-sunburst to the OpenAI provider catalog - Route tool-backed image models through the Codex responses model while passing the selected model to the image_generation tool, pinning tool_choice and deriving generate/edit from the presence of references - Cover the Codex gpt-image-2.5 request shape with a unit test --- open-sse/handlers/imageProviders/codex.js | 26 ++++++++++++-- open-sse/providers/registry/codex.js | 5 +++ open-sse/providers/registry/openai.js | 3 ++ tests/unit/image-generation.test.js | 41 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 385ec5b6..afaf8e97 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -9,6 +9,14 @@ 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; @@ -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/providers/registry/codex.js b/open-sse/providers/registry/codex.js index bf436a10..9eaafe74 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -65,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/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/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index 4df94afc..6a22cd40 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -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( From 1f10f9e5c4603673aae6720bf91f8c62aedf6730 Mon Sep 17 00:00:00 2001 From: LLL <2798142644@qq.com> Date: Thu, 10 Sep 2026 22:06:49 +0700 Subject: [PATCH 14/28] fix(qoder): report usage to all clients and stop inlining large attachments - Coalesce Qoder's empty finish-in-delta frame with the later choices:[] usage frame so OpenAI and Claude clients receive prompt_tokens, completion_tokens and cache-hit tokens (the dashboard already saw them) - Upload inlined images through /api/v2/image/upload like qodercli, and stub oversized non-image files instead of stuffing 30MB+ data URIs into agent_chat_generation - Emit response.completed -> response.usage for chat-native upstreams so /v1/responses clients (Codex CLI, sub2api) no longer log 0/0/0 - Keep Claude message_delta.usage working when usage arrives without choices[0] - Escalate to the smallest advertised Qoder context tier (200K/400K/1M) when the estimated prompt no longer fits max_input_tokens - Pass apiKey for PAT connections and list hidden enable:false catalog keys from /v1/models --- open-sse/executors/qoder.js | 133 +++++-- .../handlers/chatCore/nonStreamingHandler.js | 8 +- .../handlers/chatCore/sseToJsonHandler.js | 8 +- open-sse/providers/capabilities.js | 6 +- open-sse/services/qoderModels.js | 24 ++ open-sse/shared/qoder/attachments.js | 341 ++++++++++++++++++ open-sse/shared/qoder/constants.js | 33 ++ open-sse/shared/qoder/contextTier.js | 160 ++++++++ open-sse/shared/qoder/sse.js | 208 +++++++++++ open-sse/transformer/responsesTransformer.js | 27 +- open-sse/translator/concerns/usage.js | 31 ++ .../translator/response/openai-responses.js | 34 +- .../translator/response/openai-to-claude.js | 81 +++-- open-sse/utils/stream.js | 17 +- src/app/api/v1/models/route.js | 13 +- tests/unit/openai-responses-usage.test.js | 182 ++++++++++ tests/unit/openai-to-claude.test.js | 27 ++ tests/unit/qoder-context-tier.test.js | 193 ++++++++++ tests/unit/qoder.test.js | 210 ++++++++++- 19 files changed, 1615 insertions(+), 121 deletions(-) create mode 100644 open-sse/shared/qoder/attachments.js create mode 100644 open-sse/shared/qoder/contextTier.js create mode 100644 open-sse/shared/qoder/sse.js create mode 100644 tests/unit/openai-responses-usage.test.js create mode 100644 tests/unit/qoder-context-tier.test.js diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 86055aba..8ee21ac5 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -31,14 +31,16 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.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 @@ -70,15 +72,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; @@ -88,10 +91,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. @@ -103,13 +120,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); } } @@ -189,7 +207,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. @@ -208,7 +226,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; @@ -227,7 +268,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(), @@ -275,6 +330,8 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio }, modelConfig, }; + if (tierChoice) applyQoderContextTier(built.payload, tierChoice.tier); + return built; } /** @@ -338,6 +395,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. @@ -364,6 +426,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) => { @@ -374,15 +441,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({ @@ -398,14 +467,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({ @@ -464,7 +527,7 @@ async function wrapQoderSSE(response, model) { } finally { if (!doneEmitted) { try { - controller.enqueue(encoder.encode(SSE_DONE)); + coalescer.flush(controller); doneEmitted = true; } catch { /* already closed */ } } @@ -493,13 +556,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/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index becc0842..3b6cfc6d 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -10,6 +10,7 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; +import { toResponsesUsage } from "../../translator/concerns/usage.js"; function parseToolArguments(value) { if (!value) return {}; @@ -130,11 +131,8 @@ function openAICompletionToResponses(responseBody, customToolNames = null) { background: false, error: null, output, - usage: { - input_tokens: usage.prompt_tokens || usage.input_tokens || 0, - output_tokens: usage.completion_tokens || usage.output_tokens || 0, - total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), - }, + // Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them + usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, }; } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 6801be89..0da5c69f 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -5,6 +5,7 @@ import { FORMATS } from "../../translator/formats.js"; import { PROVIDERS } from "../../config/providers.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; +import { toResponsesUsage } from "../../translator/concerns/usage.js"; // Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; @@ -97,11 +98,8 @@ function chatCompletionToResponses(responseBody, customToolNames = null) { background: false, error: null, output, - usage: { - input_tokens: usage.prompt_tokens || usage.input_tokens || 0, - output_tokens: usage.completion_tokens || usage.output_tokens || 0, - total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), - }, + // Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them + usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, }; } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index dd9fa381..e011b788 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -222,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/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/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/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js index ac84db20..6558a3f1 100644 --- a/open-sse/transformer/responsesTransformer.js +++ b/open-sse/transformer/responsesTransformer.js @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { toResponsesUsage } from "../translator/concerns/usage.js"; // Create log directory for responses (Node.js only) export function createResponsesLogger(model, logsDir = null) { @@ -73,6 +74,7 @@ export function createResponsesApiTransformStream(logger = null) { funcArgsDone: {}, funcItemDone: {}, buffer: "", + usage: null, completedSent: false }; @@ -225,17 +227,17 @@ export function createResponsesApiTransformStream(logger = null) { const sendCompleted = (controller) => { if (!state.completedSent) { state.completedSent = true; - emit(controller, "response.completed", { - type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null - } - }); + const response = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + }; + const usage = toResponsesUsage(state.usage); + if (usage) response.usage = usage; + emit(controller, "response.completed", { type: "response.completed", response }); } }; @@ -264,6 +266,9 @@ export function createResponsesApiTransformStream(logger = null) { continue; } + // Remember usage (finish chunk or trailing include_usage frame) for response.completed + if (parsed.usage && typeof parsed.usage === "object") state.usage = parsed.usage; + if (!parsed.choices?.length) continue; const choice = parsed.choices[0]; diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js index 3ace3062..1ee28489 100644 --- a/open-sse/translator/concerns/usage.js +++ b/open-sse/translator/concerns/usage.js @@ -67,3 +67,34 @@ export function toOpenAIUsage(raw, kind) { if (!extract || !raw || typeof raw !== "object") return null; return buildUsage(extract(raw)); } + +// Convert an OpenAI-shaped (or already-canonical / Claude-shaped) usage object into the +// Responses API shape emitted by `response.completed`. Details objects are always present +// (like the real API) so proxies that read `input_tokens_details.cached_tokens` never see undefined. +// Returns null when there is nothing countable. +export function toResponsesUsage(usage) { + if (!usage || typeof usage !== "object") return null; + const input = n(usage.prompt_tokens ?? usage.input_tokens); + const output = n(usage.completion_tokens ?? usage.output_tokens); + if (input === 0 && output === 0) return null; + const cached = n( + usage.input_tokens_details?.cached_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + usage.cached_tokens ?? + usage.cache_read_input_tokens + ); + const reasoning = n( + usage.output_tokens_details?.reasoning_tokens ?? + usage.completion_tokens_details?.reasoning_tokens ?? + usage.reasoning_tokens + ); + const out = { + input_tokens: input, + output_tokens: output, + total_tokens: typeof usage.total_tokens === "number" ? usage.total_tokens : input + output, + input_tokens_details: { cached_tokens: cached }, + output_tokens_details: { reasoning_tokens: reasoning }, + }; + if (usage.estimated) out.estimated = true; + return out; +} diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index bd435f9c..1d9a7dec 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -5,7 +5,7 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { buildChunk } from "../concerns/chunk.js"; -import { buildUsage } from "../concerns/usage.js"; +import { buildUsage, toResponsesUsage } from "../concerns/usage.js"; import { fallbackToolCallId } from "../concerns/toolCall.js"; import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js"; import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js"; @@ -18,7 +18,13 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { if (!chunk) { return flushEvents(state); } - + + // Usage riding on the finish chunk (include_usage style, e.g. coalesced Qoder frames): + // remember it so response.completed can report tokens even outside stream.js. + if (chunk.usage && typeof chunk.usage === "object" && !state.usage) { + state.usage = chunk.usage; + } + if (!chunk.choices?.length) return []; const events = []; @@ -368,17 +374,19 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; - emit("response.completed", { - type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null - } - }); + const response = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + }; + // Carry provider usage (recorded by stream.js or from the finish chunk itself) in the + // Responses shape; proxies such as sub2api/Codex read tokens only from here. + const usage = toResponsesUsage(state.usage); + if (usage) response.usage = usage; + emit("response.completed", { type: "response.completed", response }); } } diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index 3998cc84..f291e83a 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -67,48 +67,50 @@ function stopTextBlock(state, results) { state.textBlockStarted = false; } +function recordOpenAIUsage(chunk, state) { + if (!chunk?.usage || typeof chunk.usage !== "object") return; + + const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; + const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; + + // Extract cache tokens from prompt_tokens_details + const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; + const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; + const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; + const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; + + // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens + // Because OpenAI's prompt_tokens includes all prompt-side tokens + const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; + + state.usage = { + input_tokens: inputTokens, + output_tokens: outputTokens + }; + + if (cacheReadTokens > 0) { + state.usage.cache_read_input_tokens = cacheReadTokens; + } + if (cacheCreateTokens > 0) { + state.usage.cache_creation_input_tokens = cacheCreateTokens; + } +} + // Convert OpenAI stream chunk to Claude format export function openaiToClaudeResponse(chunk, state) { - if (!chunk || !chunk.choices?.[0]) return null; + if (!chunk) return null; + + // Track usage from OpenAI chunk if available + if (chunk.usage && typeof chunk.usage === "object") { + recordOpenAIUsage(chunk, state); + } + + if (!chunk.choices?.[0]) return null; const results = []; const choice = chunk.choices[0]; const delta = choice.delta; - // Track usage from OpenAI chunk if available - if (chunk.usage && typeof chunk.usage === "object") { - const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; - const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; - - // Extract cache tokens from prompt_tokens_details - const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; - const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; - const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; - const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; - - // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens - // Because OpenAI's prompt_tokens includes all prompt-side tokens - const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; - - state.usage = { - input_tokens: inputTokens, - output_tokens: outputTokens - }; - - // Add cache_read_input_tokens if present - if (cacheReadTokens > 0) { - state.usage.cache_read_input_tokens = cacheReadTokens; - } - - // Add cache_creation_input_tokens if present - if (cacheCreateTokens > 0) { - state.usage.cache_creation_input_tokens = cacheCreateTokens; - } - - // Note: completion_tokens_details.reasoning_tokens is already included in output_tokens - // No need to add separately as Claude expects total output_tokens - } - // First chunk - ALWAYS send message_start first if (!state.messageStartSent) { state.messageStartSent = true; @@ -221,8 +223,9 @@ export function openaiToClaudeResponse(chunk, state) { } } - // Finish - if (choice.finish_reason) { + // Finish (OpenAI puts this on the choice; Qoder often puts it on delta) + const finishReason = choice.finish_reason || delta?.finish_reason; + if (finishReason) { stopThinkingBlock(state, results); stopTextBlock(state, results); @@ -244,13 +247,13 @@ export function openaiToClaudeResponse(chunk, state) { } // Mark finish for later usage injection in stream.js - state.finishReason = choice.finish_reason; + state.finishReason = finishReason; // Use tracked usage (will be estimated in stream.js if not valid) const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 }; results.push({ type: "message_delta", - delta: { stop_reason: convertFinishReason(choice.finish_reason) }, + delta: { stop_reason: convertFinishReason(finishReason) }, usage: finalUsage }); results.push({ type: "message_stop" }); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 15ee37d8..f61fd193 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -3,6 +3,7 @@ import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; +import { toResponsesUsage } from "../translator/concerns/usage.js"; import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; @@ -201,7 +202,8 @@ export function createSSEStream(options = {}) { responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed); - const isFinishChunk = parsed.choices?.[0]?.finish_reason; + const isFinishChunk = parsed.choices?.[0]?.finish_reason + || parsed.choices?.[0]?.delta?.finish_reason; if (isFinishChunk && !hasValidUsage(parsed.usage)) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); @@ -365,6 +367,19 @@ export function createSSEStream(options = {}) { item.usage = filterUsageForFormat(buffered, sourceFormat); } + // Responses API clients (Codex, sub2api /v1/responses): usage lives on + // response.completed → response.usage. Same buffer/estimate policy as above. + const completedResponse = item.event === "response.completed" ? item.data?.response : null; + if (completedResponse && typeof completedResponse === "object") { + if (state.usage) { + completedResponse.usage = toResponsesUsage(addBufferToUsage(state.usage)) ?? completedResponse.usage; + } else if (!completedResponse.usage && totalContentLength > 0) { + const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); + completedResponse.usage = toResponsesUsage(estimated); + state.usage = estimated; + } + } + const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 40adc696..173d91c2 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -9,7 +9,7 @@ import { getProviderConnections, getCombos, getCustomModels, getModelAliases } f 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 { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; @@ -34,15 +34,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({ diff --git a/tests/unit/openai-responses-usage.test.js b/tests/unit/openai-responses-usage.test.js new file mode 100644 index 00000000..38ec581d --- /dev/null +++ b/tests/unit/openai-responses-usage.test.js @@ -0,0 +1,182 @@ +/** + * Responses API clients (Codex, sub2api /v1/responses) read token usage only from + * `response.completed → response.usage`. For chat-native upstreams (Qoder, most + * OpenAI-compatible providers) the translator used to emit that event without usage, + * so proxies logged 0 input / 0 output / 0 cached tokens. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}), + trackPendingRequest: vi.fn(() => {}), +})); + +const { FORMATS } = await import("../../open-sse/translator/formats.js"); +const { initState } = await import("../../open-sse/translator/index.js"); +const { toResponsesUsage } = await import("../../open-sse/translator/concerns/usage.js"); +const { openaiToOpenAIResponsesResponse } = await import("../../open-sse/translator/response/openai-responses.js"); +const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.js"); +const { createResponsesApiTransformStream } = await import("../../open-sse/transformer/responsesTransformer.js"); +const { addBufferToUsage } = await import("../../open-sse/utils/usageTracking.js"); +// stream.js adds the same context-safety buffer it applies to chat/claude clients +const BUFFER_TOKENS = addBufferToUsage({ prompt_tokens: 0 }).prompt_tokens; + +const QODER_FINISH_CHUNK = { + id: "chatcmpl-qoder-1", + object: "chat.completion.chunk", + created: 1_700_000_000, + model: "qmodel_38max", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 27_339, + completion_tokens: 437, + total_tokens: 27_776, + prompt_tokens_details: { cached_tokens: 27_200 }, + }, +}; + +function sse(chunks) { + return chunks.map((c) => `data: ${typeof c === "string" ? c : JSON.stringify(c)}\n\n`).join(""); +} + +async function pipe(input, transform) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(input)); + controller.close(); + }, + }); + const reader = stream.pipeThrough(transform).getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +function completedEvent(text) { + const m = text.match(/event: response\.completed\ndata: (.+)\n/); + return m ? JSON.parse(m[1]) : null; +} + +describe("toResponsesUsage", () => { + it("maps OpenAI usage (nested cached_tokens) to the Responses shape", () => { + expect(toResponsesUsage(QODER_FINISH_CHUNK.usage)).toEqual({ + input_tokens: 27_339, + output_tokens: 437, + total_tokens: 27_776, + input_tokens_details: { cached_tokens: 27_200 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + }); + + it("accepts canonical flat fields and Claude-style cache fields", () => { + expect(toResponsesUsage({ prompt_tokens: 10, completion_tokens: 2, cached_tokens: 4, reasoning_tokens: 1 })).toMatchObject({ + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + input_tokens_details: { cached_tokens: 4 }, + output_tokens_details: { reasoning_tokens: 1 }, + }); + expect(toResponsesUsage({ input_tokens: 5, output_tokens: 1, cache_read_input_tokens: 3 }).input_tokens_details.cached_tokens).toBe(3); + }); + + it("keeps the estimated marker and returns null for empty usage", () => { + expect(toResponsesUsage({ prompt_tokens: 1, completion_tokens: 1, estimated: true }).estimated).toBe(true); + expect(toResponsesUsage({})).toBeNull(); + expect(toResponsesUsage(null)).toBeNull(); + }); +}); + +describe("openai → openai-responses translator", () => { + it("puts usage from the finish chunk on response.completed", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const events = openaiToOpenAIResponsesResponse(QODER_FINISH_CHUNK, state); + const completed = events.find((e) => e.event === "response.completed"); + expect(completed).toBeTruthy(); + expect(completed.data.response.usage).toEqual({ + input_tokens: 27_339, + output_tokens: 437, + total_tokens: 27_776, + input_tokens_details: { cached_tokens: 27_200 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + }); + + it("omits usage when the upstream never reported any", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const events = openaiToOpenAIResponsesResponse({ ...QODER_FINISH_CHUNK, usage: undefined }, state); + const completed = events.find((e) => e.event === "response.completed"); + expect(completed.data.response.usage).toBeUndefined(); + }); +}); + +describe("stream.js translate mode: chat upstream → Responses client", () => { + const transform = () => createSSETransformStreamWithLogger( + FORMATS.OPENAI, // provider (Qoder executor emits OpenAI chunks) + FORMATS.OPENAI_RESPONSES, // client + "qoder", + null, + null, + "qmodel_38max", + null, + { model: "qd/qmodel_38max", messages: [{ role: "user", content: "hi" }] }, + ); + + it("emits provider usage (+buffer) with cached tokens on response.completed", async () => { + const out = await pipe(sse([ + { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined }, + QODER_FINISH_CHUNK, + "[DONE]", + ]), transform()); + + const completed = completedEvent(out); + expect(completed).toBeTruthy(); + expect(completed.response.usage).toEqual({ + input_tokens: 27_339 + BUFFER_TOKENS, + output_tokens: 437, + total_tokens: 27_776 + BUFFER_TOKENS, + input_tokens_details: { cached_tokens: 27_200 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + // Responses clients terminate on response.completed (no [DONE] sentinel in translate mode) + expect(out.indexOf("event: response.completed")).toBeGreaterThan(out.indexOf("event: response.output_item.done")); + }); + + it("injects estimated usage when the upstream reports none", async () => { + const out = await pipe(sse([ + { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello world" }, finish_reason: null }], usage: undefined }, + { ...QODER_FINISH_CHUNK, usage: undefined }, + "[DONE]", + ]), transform()); + + const completed = completedEvent(out); + expect(completed.response.usage).toBeTruthy(); + expect(completed.response.usage.estimated).toBe(true); + expect(completed.response.usage.input_tokens).toBeGreaterThan(0); + expect(completed.response.usage.output_tokens).toBeGreaterThan(0); + }); +}); + +describe("responsesTransformer (Chat SSE → Codex Responses SSE)", () => { + it("forwards finish-chunk usage on response.completed", async () => { + const out = await pipe(sse([ + { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined }, + QODER_FINISH_CHUNK, + "[DONE]", + ]), createResponsesApiTransformStream()); + + const completed = completedEvent(out); + expect(completed.response.usage).toMatchObject({ + input_tokens: 27_339, + output_tokens: 437, + input_tokens_details: { cached_tokens: 27_200 }, + }); + }); +}); diff --git a/tests/unit/openai-to-claude.test.js b/tests/unit/openai-to-claude.test.js index 45b67fb2..0c5768d7 100644 --- a/tests/unit/openai-to-claude.test.js +++ b/tests/unit/openai-to-claude.test.js @@ -203,4 +203,31 @@ describe("openaiToClaudeResponse", () => { limit: 120 }); }); + + it("records usage from a choices:[] frame so the finish chunk can emit it", () => { + const state = { toolCalls: new Map() }; + expect(openaiToClaudeResponse({ + usage: { + prompt_tokens: 90, + completion_tokens: 7, + prompt_tokens_details: { cached_tokens: 30 }, + }, + choices: [], + }, state)).toBeNull(); + expect(state.usage).toEqual({ + input_tokens: 60, + output_tokens: 7, + cache_read_input_tokens: 30, + }); + + const events = openaiToClaudeResponse({ + id: "chatcmpl-qoder-finish", + model: "qoder/auto", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, state); + const delta = events.find((e) => e.type === "message_delta"); + expect(delta.usage.input_tokens).toBe(60); + expect(delta.usage.output_tokens).toBe(7); + expect(delta.usage.cache_read_input_tokens).toBe(30); + }); }); 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}`); + }); }); From 1892ed77c842d0be7db85e3958215ab699640dfc Mon Sep 17 00:00:00 2001 From: Federico Liva Date: Thu, 10 Sep 2026 22:23:01 +0700 Subject: [PATCH 15/28] fix(kiro): never send a top-level systemPrompt (400 REQUEST_BODY_INVALID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kiro.dev rejects any body carrying a top-level systemPrompt with 400 REQUEST_BODY_INVALID. The translators stopped emitting the field in v0.5.59 (the prompt travels in the first user turn via contentPrefix), but two paths kept writing it back downstream of the translator: - rtk/systemInject.js::injectKiroSystem() appended the RTK prompt to body.systemPrompt, so every kr/ model failed whenever an RTK injector (caveman, ponytail) was active. It now appends to the first history user turn's content (else currentMessage), reusing dedupStringAppend/hasPrompt so retries stay idempotent. - executors/kiro.js::appendRepairInstruction() wrote the tool-call repair instruction to systemPrompt on the retry, turning every repair into a hard failure. It now appends to currentMessage.userInputMessage.content. isKiroBody() no longer requires a string body.systemPrompt — that marker is gone from the wire shape — and sniffs the conversation turn shape instead, keeping the stray-conversationState guard intact. Stale comments in both kiro translators corrected: the systemPrompt local is only a session-replay cache key, not a wire field. Also drops the mirror/rollback repair heuristic the injector no longer needs: net -52 lines. Fixes #3641, #3845, #2890, #2901, #2939, #3109, #3459, #3749 --- open-sse/executors/kiro.js | 12 +- open-sse/rtk/systemInject.js | 100 ++++--------- open-sse/translator/request/claude-to-kiro.js | 6 +- open-sse/translator/request/openai-to-kiro.js | 6 +- tests/unit/kiro-terminal-integrity.test.js | 10 +- tests/unit/system-inject.test.js | 136 ++++++++---------- 6 files changed, 109 insertions(+), 161 deletions(-) diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index e12aced6..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; } 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/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 9a7cf377..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) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 990b607f..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)); 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/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}`); }); }); From 781c18d83746a9b3a9dd7d401ca0ed2c6db1ce69 Mon Sep 17 00:00:00 2001 From: anhtran-ai Date: Thu, 10 Sep 2026 22:25:23 +0700 Subject: [PATCH 16/28] fix(codex): strip Unicode-property tool schema patterns Codex rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's /responses validator has no Unicode property escapes, so a tool `pattern` containing `\p{...}` 400s the whole request with `Invalid schema for function ... is not a 'regex'` — identically on every account, costing a full combo failover per turn (#3922). - Add open-sse/utils/codexToolSchema.js: copy-on-write walk that drops only `pattern` values carrying a property escape, returning the original reference when nothing changed so the caller's schema stays intact for a retry against another provider - Treat `properties` keys as property names, so a field literally called `pattern` is never read as the schema keyword; skip escaped literals via backslash-parity counting - Apply it in normalizeCodexTools for both function and namespace sub-tool parameters, and log the strip count via dbg - Add three cases to tests/unit/codex-tool-normalization.test.js --- open-sse/executors/codex.js | 12 +++- open-sse/utils/codexToolSchema.js | 80 +++++++++++++++++++++ tests/unit/codex-tool-normalization.test.js | 63 ++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 open-sse/utils/codexToolSchema.js 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/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/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([ { From 45ec1d30bb694123c93ca1b55fd9d9c3bdd26e43 Mon Sep 17 00:00:00 2001 From: kimono381 <62897149+kimono381@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:26:15 +0700 Subject: [PATCH 17/28] fix(deepseek): keep Anthropic-only tool types when forwarding to /anthropic/v1/messages DeepSeek's Anthropic-compatible endpoint accepts only the built-in web_search_20250305 / web_search_20260209 tools and rejects client-defined `custom` tools (MCP / Read / Bash) with HTTP 400 "unknown variant `custom`". The generic non-Claude filter in prepareClaudeRequest dropped the offending tools but also dropped the web_search_* ones DeepSeek does accept. - Add an opt-in per-provider transport quirk `claudeSupportedToolTypes`; when declared it becomes a strict allow-list for Anthropic tool `type` values - Stop stripping the `type` discriminator from surviving tools under that quirk, since DeepSeek needs it to route built-ins - Declare the quirk on the deepseek transport with the two web_search_* types - Providers without the quirk keep the previous filter and normalisation behaviour byte-for-byte; openai-format targets never reach this path --- open-sse/providers/registry/deepseek.js | 15 +++ open-sse/translator/formats/claude.js | 22 +++- tests/unit/deepseek-claude-tools.test.js | 145 +++++++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tests/unit/deepseek-claude-tools.test.js 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/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 62a0531a..930c761a 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -461,8 +461,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 +484,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/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 From f6e7cabe60461b9b55f7b7a0ceedeb9f56c72207 Mon Sep 17 00:00:00 2001 From: izzzzzi Date: Thu, 10 Sep 2026 22:48:06 +0700 Subject: [PATCH 18/28] fix(cline): stop workos:-prefixing ClinePass API keys and add clinepass token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cline/ClinePass requests failed with HTTP 401 ("Please make sure you are using the latest version of Cline and re-authenticate your Cline account", #3230 / #2333 / #3644). `getClineAccessToken()` unconditionally prefixed every token with `workos:`, which is correct for Cline OAuth access tokens (WorkOS JWTs) but wrong for ClinePass API keys — those are opaque strings (e.g. `clp_…`) that the API accepts only verbatim, so the `workos:`-prefixed value was rejected. Only prefix tokens that look like a WorkOS JWT (`eyJ…`); API keys and other opaque tokens pass through untouched, and an existing `workos:` prefix is never doubled. Also register `clinepass` in the token-refresh handlers. ClinePass shares Cline's WorkOS auth endpoints, but without the entry expired ClinePass OAuth tokens were never rotated, so every request kept 401ing. Finally, list `apikey` first in the ClinePass `authModes` (ClinePass is meant to be used with an API key from app.cline.bot/settings/api-keys), and add an "Import from /models" button that pulls the live Cline catalog into custom models. --- open-sse/providers/registry/clinepass.js | 5 +- open-sse/services/tokenRefresh.js | 2 + open-sse/shared/clineAuth.js | 9 ++- .../dashboard/providers/[id]/page.js | 62 +++++++++++++++++++ tests/unit/cline-auth.test.js | 40 ++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cline-auth.test.js diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index 702054ac..1e7d0520 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", 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/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/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index df1c9db6..9657b7ed 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -81,6 +81,7 @@ export default function ProviderDetailPage() { const [oneByOneSummary, setOneByOneSummary] = useState(null); const stopOneByOneRef = useRef(false); const [importingQoderModels, setImportingQoderModels] = useState(false); + const [importingClineModels, setImportingClineModels] = useState(false); const { copied, copy } = useCopyToClipboard(); const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; @@ -612,6 +613,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); + } + }; const handleRunOneByOneTest = async () => { if (oneByOneRunning || connections.length === 0) return; @@ -1187,6 +1235,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([ 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 From 122f23eebc45f43214f0397f28130f8978715386 Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Thu, 10 Sep 2026 22:46:07 +0700 Subject: [PATCH 19/28] fix(cline,airforce): unwrap {success,data} envelope, add live catalog, and refresh airforce free models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cline (api.cline.bot) wraps non-stream chat completions in {"success":true,"data":{...choices...}}, which both the dashboard model-test ping and the proxy non-stream path read at top level, producing "Provider returned no completion choices for this model" (#3644). Unwrap the envelope before usage extraction and response translation; the error envelope ({"success":false,...}) never matches and passes through untouched. Scoped through `transport.quirks.clineEnvelope` so only cline/clinepass opt in — no other provider's response body is ever rewritten. Also adds a live Cline catalog: `fetchClineRawModels()` is shared between `resolveClineModels()` (full catalog, including free-tier ids such as z-ai/glm-5.3-flash) and `resolveClinepassModels()` (cline-pass/* only), wired into /v1/models, the per-provider models route, and the combo selector's model picker with the static catalog kept as fallback. Refreshes the dead api-airforce free models (anthropic/claude-3.7-sonnet, moonshot/kimi-k2.6, google/gemini-2.5-flash) with the live gpt-oss-120b, gpt-oss-20b and kimi-k2.7-code, plus passthroughModels, forceStream and a suggested-models filter. --- .../handlers/chatCore/nonStreamingHandler.js | 6 + open-sse/providers/registry/api-airforce.js | 9 +- open-sse/providers/registry/cline.js | 4 + open-sse/providers/registry/clinepass.js | 2 + open-sse/services/clinepassModels.js | 62 +++- open-sse/shared/clineEnvelope.js | 19 ++ src/app/api/models/test/ping.js | 7 + src/app/api/providers/[id]/models/route.js | 32 ++ .../api/providers/suggested-models/filters.js | 6 + src/app/api/v1/models/route.js | 9 +- src/shared/components/ModelSelectModal.js | 112 ++++--- src/shared/constants/cliTools.js | 2 +- tests/unit/api-airforce-free-models.test.js | 36 +++ tests/unit/cline-free-models-envelope.test.js | 297 ++++++++++++++++++ 14 files changed, 540 insertions(+), 63 deletions(-) create mode 100644 open-sse/shared/clineEnvelope.js create mode 100644 tests/unit/api-airforce-free-models.test.js create mode 100644 tests/unit/cline-free-models-envelope.test.js diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 3b6cfc6d..fe94b282 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 } from "./requestDetail.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; @@ -302,6 +303,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/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 1e7d0520..fc21590f 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -25,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/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/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/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 24119eb2..273aff47 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"; @@ -151,6 +153,11 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ 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/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/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 173d91c2..9ce571b4 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -11,7 +11,7 @@ import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.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"; @@ -79,6 +79,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/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 2cbc11a2..20e17d95 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, @@ -49,48 +97,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 { @@ -323,8 +348,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)); @@ -394,7 +420,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/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/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/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"); + }); +}); From 8a81085a72dd4624cc958de165da3e8bb2ba358e Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Thu, 10 Sep 2026 22:53:57 +0700 Subject: [PATCH 20/28] fix(claude): cap re-anchored cache_control at the 4-marker budget and keep single-object content turns Anthropic accepts at most 4 blocks carrying cache_control per request. When the client had already spent that budget, the re-anchor added a 5th marker and the request was rejected with a non-retryable 400 that the failure path treated as an account problem, retrying the same malformed body across the whole pool until every account locked. anchorClaudeCache now normalizes bare-object content, strips the invalid cache_control carried by defer_loading tools, pins the 1h head anchors on the last system block and last cacheable tool, then trims an over-budget body to 4 markers. The trim holds those head anchors and fills the remaining slots with the tail-most message markers: a plain "keep the last four in document order" rule drops the anchors first even though they lead document order, and skipping the re-anchor at a spent budget left system/tools on the 5m default instead of 1h. Some clients send content as a single block object rather than a one-element array. Such a turn was dropped or zeroed on every leg that reads messages, silently losing conversation history. normalizeMessageContent wraps it as a one-block array on all four paths, and hasValidContent keeps it. --- open-sse/translator/formats/claude.js | 106 ++++++++- .../translator/request/claude-to-openai.js | 7 + .../claude-cache-budget-single-object.test.js | 222 ++++++++++++++++++ 3 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 tests/unit/claude-cache-budget-single-object.test.js diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 930c761a..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)) { 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/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 + }); +}); From 998bb3d97593ac668c74f506e54c48a74a4e9cd1 Mon Sep 17 00:00:00 2001 From: galiehneh Date: Thu, 10 Sep 2026 23:03:42 +0700 Subject: [PATCH 21/28] fix(tools): scope Claude tool type defaulting to gateways that need it (#3905) defaultClaudeToolType() stamped tools[].type = "custom" onto every Claude-format request carrying tools since e08ac6da. That satisfied MiniMax (error 2013) but broke Anthropic-compatible endpoints that only accept the legacy typeless tool shape. DeepSeek's endpoint (api.deepseek.com/anthropic/v1/messages) whitelists its tool `type` enum to the web_search_* variants and answers HTTP 400 "unknown variant `custom`", so every Claude Code request routed to a DeepSeek connection failed and surfaced as a persistent 503. Run the defaulting only when the target provider declares the new requireClaudeToolType quirk (MiniMax, MiniMax-CN). Add shouldDefaultClaudeToolType(provider, finalFormat, tools, PROVIDERS) in translator/concerns/toolCall.js so the gate is unit-testable, and cover MiniMax keeping the explicit type, DeepSeek/Anthropic staying typeless, non-Claude formats and tool-less requests never defaulting. Effectively a no-op for MiniMax and a restore of the pre-e08ac6da behaviour everywhere else. Another strict gateway now only needs the same one-line quirk instead of a global behavioural change. --- open-sse/handlers/chatCore.js | 8 +++-- open-sse/providers/registry/minimax-cn.js | 1 + open-sse/providers/registry/minimax.js | 1 + open-sse/translator/concerns/toolCall.js | 15 +++++++++ .../bugs-3905-deepseek-tool-type.test.js | 33 +++++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/translator/bugs-3905-deepseek-tool-type.test.js diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index d5dcf2d8..85c6fc4a 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"; /** @@ -243,7 +243,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/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/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/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(); + }); +}); From 248d7da01c44180dcac466631e24a17b585b95b0 Mon Sep 17 00:00:00 2001 From: LLL <2798142644@qq.com> Date: Thu, 10 Sep 2026 22:52:29 +0700 Subject: [PATCH 22/28] revert(qoder): drop the Responses usage plumbing from shared code The merged Qoder work also rewrote shared translator/handler code so that /v1/responses clients got token usage on response.completed. That changed behaviour for every provider, not just Qoder: proxies saw input tokens rise by the 2000-token context buffer, and the plain token mapping was replaced by one that always adds input_tokens_details. A probe confirms the Qoder benefit does not depend on those edits: the executor's coalescer already emits one include_usage-style finish chunk, so a Claude client receives input_tokens and cache_read_input_tokens with every shared file at its original state. Only the Responses path relies on the shared translator, and that path has no Qoder-owned seam to put it in. Reverts the shared files to their pre-PR state and drops the Responses usage test. The Cline envelope unwrap in nonStreamingHandler.js, which landed after the PR in the same file, is kept. --- .../handlers/chatCore/nonStreamingHandler.js | 8 +- .../handlers/chatCore/sseToJsonHandler.js | 8 +- open-sse/transformer/responsesTransformer.js | 27 ++- open-sse/translator/concerns/usage.js | 31 --- .../translator/response/openai-responses.js | 34 ++-- .../translator/response/openai-to-claude.js | 81 ++++---- open-sse/utils/stream.js | 17 +- tests/unit/openai-responses-usage.test.js | 182 ------------------ tests/unit/openai-to-claude.test.js | 27 --- 9 files changed, 74 insertions(+), 341 deletions(-) delete mode 100644 tests/unit/openai-responses-usage.test.js diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index fe94b282..d7f4f3fe 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -11,7 +11,6 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; -import { toResponsesUsage } from "../../translator/concerns/usage.js"; function parseToolArguments(value) { if (!value) return {}; @@ -132,8 +131,11 @@ function openAICompletionToResponses(responseBody, customToolNames = null) { background: false, error: null, output, - // Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them - usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + usage: { + input_tokens: usage.prompt_tokens || usage.input_tokens || 0, + output_tokens: usage.completion_tokens || usage.output_tokens || 0, + total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), + }, }; } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 0da5c69f..6801be89 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -5,7 +5,6 @@ import { FORMATS } from "../../translator/formats.js"; import { PROVIDERS } from "../../config/providers.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; -import { toResponsesUsage } from "../../translator/concerns/usage.js"; // Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; @@ -98,8 +97,11 @@ function chatCompletionToResponses(responseBody, customToolNames = null) { background: false, error: null, output, - // Keep cached/reasoning details (input_tokens_details) — proxies bill cache hits from them - usage: toResponsesUsage(usage) || { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + usage: { + input_tokens: usage.prompt_tokens || usage.input_tokens || 0, + output_tokens: usage.completion_tokens || usage.output_tokens || 0, + total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), + }, }; } diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js index 6558a3f1..ac84db20 100644 --- a/open-sse/transformer/responsesTransformer.js +++ b/open-sse/transformer/responsesTransformer.js @@ -6,7 +6,6 @@ import fs from "fs"; import path from "path"; -import { toResponsesUsage } from "../translator/concerns/usage.js"; // Create log directory for responses (Node.js only) export function createResponsesLogger(model, logsDir = null) { @@ -74,7 +73,6 @@ export function createResponsesApiTransformStream(logger = null) { funcArgsDone: {}, funcItemDone: {}, buffer: "", - usage: null, completedSent: false }; @@ -227,17 +225,17 @@ export function createResponsesApiTransformStream(logger = null) { const sendCompleted = (controller) => { if (!state.completedSent) { state.completedSent = true; - const response = { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null - }; - const usage = toResponsesUsage(state.usage); - if (usage) response.usage = usage; - emit(controller, "response.completed", { type: "response.completed", response }); + emit(controller, "response.completed", { + type: "response.completed", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + } + }); } }; @@ -266,9 +264,6 @@ export function createResponsesApiTransformStream(logger = null) { continue; } - // Remember usage (finish chunk or trailing include_usage frame) for response.completed - if (parsed.usage && typeof parsed.usage === "object") state.usage = parsed.usage; - if (!parsed.choices?.length) continue; const choice = parsed.choices[0]; diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js index 1ee28489..3ace3062 100644 --- a/open-sse/translator/concerns/usage.js +++ b/open-sse/translator/concerns/usage.js @@ -67,34 +67,3 @@ export function toOpenAIUsage(raw, kind) { if (!extract || !raw || typeof raw !== "object") return null; return buildUsage(extract(raw)); } - -// Convert an OpenAI-shaped (or already-canonical / Claude-shaped) usage object into the -// Responses API shape emitted by `response.completed`. Details objects are always present -// (like the real API) so proxies that read `input_tokens_details.cached_tokens` never see undefined. -// Returns null when there is nothing countable. -export function toResponsesUsage(usage) { - if (!usage || typeof usage !== "object") return null; - const input = n(usage.prompt_tokens ?? usage.input_tokens); - const output = n(usage.completion_tokens ?? usage.output_tokens); - if (input === 0 && output === 0) return null; - const cached = n( - usage.input_tokens_details?.cached_tokens ?? - usage.prompt_tokens_details?.cached_tokens ?? - usage.cached_tokens ?? - usage.cache_read_input_tokens - ); - const reasoning = n( - usage.output_tokens_details?.reasoning_tokens ?? - usage.completion_tokens_details?.reasoning_tokens ?? - usage.reasoning_tokens - ); - const out = { - input_tokens: input, - output_tokens: output, - total_tokens: typeof usage.total_tokens === "number" ? usage.total_tokens : input + output, - input_tokens_details: { cached_tokens: cached }, - output_tokens_details: { reasoning_tokens: reasoning }, - }; - if (usage.estimated) out.estimated = true; - return out; -} diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 1d9a7dec..bd435f9c 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -5,7 +5,7 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { buildChunk } from "../concerns/chunk.js"; -import { buildUsage, toResponsesUsage } from "../concerns/usage.js"; +import { buildUsage } from "../concerns/usage.js"; import { fallbackToolCallId } from "../concerns/toolCall.js"; import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js"; import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js"; @@ -18,13 +18,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { if (!chunk) { return flushEvents(state); } - - // Usage riding on the finish chunk (include_usage style, e.g. coalesced Qoder frames): - // remember it so response.completed can report tokens even outside stream.js. - if (chunk.usage && typeof chunk.usage === "object" && !state.usage) { - state.usage = chunk.usage; - } - + if (!chunk.choices?.length) return []; const events = []; @@ -374,19 +368,17 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; - const response = { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null - }; - // Carry provider usage (recorded by stream.js or from the finish chunk itself) in the - // Responses shape; proxies such as sub2api/Codex read tokens only from here. - const usage = toResponsesUsage(state.usage); - if (usage) response.usage = usage; - emit("response.completed", { type: "response.completed", response }); + emit("response.completed", { + type: "response.completed", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + } + }); } } diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index f291e83a..3998cc84 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -67,50 +67,48 @@ function stopTextBlock(state, results) { state.textBlockStarted = false; } -function recordOpenAIUsage(chunk, state) { - if (!chunk?.usage || typeof chunk.usage !== "object") return; - - const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; - const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; - - // Extract cache tokens from prompt_tokens_details - const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; - const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; - const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; - const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; - - // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens - // Because OpenAI's prompt_tokens includes all prompt-side tokens - const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; - - state.usage = { - input_tokens: inputTokens, - output_tokens: outputTokens - }; - - if (cacheReadTokens > 0) { - state.usage.cache_read_input_tokens = cacheReadTokens; - } - if (cacheCreateTokens > 0) { - state.usage.cache_creation_input_tokens = cacheCreateTokens; - } -} - // Convert OpenAI stream chunk to Claude format export function openaiToClaudeResponse(chunk, state) { - if (!chunk) return null; - - // Track usage from OpenAI chunk if available - if (chunk.usage && typeof chunk.usage === "object") { - recordOpenAIUsage(chunk, state); - } - - if (!chunk.choices?.[0]) return null; + if (!chunk || !chunk.choices?.[0]) return null; const results = []; const choice = chunk.choices[0]; const delta = choice.delta; + // Track usage from OpenAI chunk if available + if (chunk.usage && typeof chunk.usage === "object") { + const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; + const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; + + // Extract cache tokens from prompt_tokens_details + const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; + const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; + const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; + const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; + + // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens + // Because OpenAI's prompt_tokens includes all prompt-side tokens + const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; + + state.usage = { + input_tokens: inputTokens, + output_tokens: outputTokens + }; + + // Add cache_read_input_tokens if present + if (cacheReadTokens > 0) { + state.usage.cache_read_input_tokens = cacheReadTokens; + } + + // Add cache_creation_input_tokens if present + if (cacheCreateTokens > 0) { + state.usage.cache_creation_input_tokens = cacheCreateTokens; + } + + // Note: completion_tokens_details.reasoning_tokens is already included in output_tokens + // No need to add separately as Claude expects total output_tokens + } + // First chunk - ALWAYS send message_start first if (!state.messageStartSent) { state.messageStartSent = true; @@ -223,9 +221,8 @@ export function openaiToClaudeResponse(chunk, state) { } } - // Finish (OpenAI puts this on the choice; Qoder often puts it on delta) - const finishReason = choice.finish_reason || delta?.finish_reason; - if (finishReason) { + // Finish + if (choice.finish_reason) { stopThinkingBlock(state, results); stopTextBlock(state, results); @@ -247,13 +244,13 @@ export function openaiToClaudeResponse(chunk, state) { } // Mark finish for later usage injection in stream.js - state.finishReason = finishReason; + state.finishReason = choice.finish_reason; // Use tracked usage (will be estimated in stream.js if not valid) const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 }; results.push({ type: "message_delta", - delta: { stop_reason: convertFinishReason(finishReason) }, + delta: { stop_reason: convertFinishReason(choice.finish_reason) }, usage: finalUsage }); results.push({ type: "message_stop" }); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index f61fd193..15ee37d8 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -3,7 +3,6 @@ import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; -import { toResponsesUsage } from "../translator/concerns/usage.js"; import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; @@ -202,8 +201,7 @@ export function createSSEStream(options = {}) { responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed); - const isFinishChunk = parsed.choices?.[0]?.finish_reason - || parsed.choices?.[0]?.delta?.finish_reason; + const isFinishChunk = parsed.choices?.[0]?.finish_reason; if (isFinishChunk && !hasValidUsage(parsed.usage)) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); @@ -367,19 +365,6 @@ export function createSSEStream(options = {}) { item.usage = filterUsageForFormat(buffered, sourceFormat); } - // Responses API clients (Codex, sub2api /v1/responses): usage lives on - // response.completed → response.usage. Same buffer/estimate policy as above. - const completedResponse = item.event === "response.completed" ? item.data?.response : null; - if (completedResponse && typeof completedResponse === "object") { - if (state.usage) { - completedResponse.usage = toResponsesUsage(addBufferToUsage(state.usage)) ?? completedResponse.usage; - } else if (!completedResponse.usage && totalContentLength > 0) { - const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); - completedResponse.usage = toResponsesUsage(estimated); - state.usage = estimated; - } - } - const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); diff --git a/tests/unit/openai-responses-usage.test.js b/tests/unit/openai-responses-usage.test.js deleted file mode 100644 index 38ec581d..00000000 --- a/tests/unit/openai-responses-usage.test.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Responses API clients (Codex, sub2api /v1/responses) read token usage only from - * `response.completed → response.usage`. For chat-native upstreams (Qoder, most - * OpenAI-compatible providers) the translator used to emit that event without usage, - * so proxies logged 0 input / 0 output / 0 cached tokens. - */ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/usageDb.js", () => ({ - appendRequestLog: vi.fn(async () => {}), - saveRequestDetail: vi.fn(async () => {}), - saveRequestUsage: vi.fn(async () => {}), - trackPendingRequest: vi.fn(() => {}), -})); - -const { FORMATS } = await import("../../open-sse/translator/formats.js"); -const { initState } = await import("../../open-sse/translator/index.js"); -const { toResponsesUsage } = await import("../../open-sse/translator/concerns/usage.js"); -const { openaiToOpenAIResponsesResponse } = await import("../../open-sse/translator/response/openai-responses.js"); -const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.js"); -const { createResponsesApiTransformStream } = await import("../../open-sse/transformer/responsesTransformer.js"); -const { addBufferToUsage } = await import("../../open-sse/utils/usageTracking.js"); -// stream.js adds the same context-safety buffer it applies to chat/claude clients -const BUFFER_TOKENS = addBufferToUsage({ prompt_tokens: 0 }).prompt_tokens; - -const QODER_FINISH_CHUNK = { - id: "chatcmpl-qoder-1", - object: "chat.completion.chunk", - created: 1_700_000_000, - model: "qmodel_38max", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { - prompt_tokens: 27_339, - completion_tokens: 437, - total_tokens: 27_776, - prompt_tokens_details: { cached_tokens: 27_200 }, - }, -}; - -function sse(chunks) { - return chunks.map((c) => `data: ${typeof c === "string" ? c : JSON.stringify(c)}\n\n`).join(""); -} - -async function pipe(input, transform) { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(input)); - controller.close(); - }, - }); - const reader = stream.pipeThrough(transform).getReader(); - const decoder = new TextDecoder(); - let text = ""; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - text += decoder.decode(value, { stream: true }); - } - return text + decoder.decode(); -} - -function completedEvent(text) { - const m = text.match(/event: response\.completed\ndata: (.+)\n/); - return m ? JSON.parse(m[1]) : null; -} - -describe("toResponsesUsage", () => { - it("maps OpenAI usage (nested cached_tokens) to the Responses shape", () => { - expect(toResponsesUsage(QODER_FINISH_CHUNK.usage)).toEqual({ - input_tokens: 27_339, - output_tokens: 437, - total_tokens: 27_776, - input_tokens_details: { cached_tokens: 27_200 }, - output_tokens_details: { reasoning_tokens: 0 }, - }); - }); - - it("accepts canonical flat fields and Claude-style cache fields", () => { - expect(toResponsesUsage({ prompt_tokens: 10, completion_tokens: 2, cached_tokens: 4, reasoning_tokens: 1 })).toMatchObject({ - input_tokens: 10, - output_tokens: 2, - total_tokens: 12, - input_tokens_details: { cached_tokens: 4 }, - output_tokens_details: { reasoning_tokens: 1 }, - }); - expect(toResponsesUsage({ input_tokens: 5, output_tokens: 1, cache_read_input_tokens: 3 }).input_tokens_details.cached_tokens).toBe(3); - }); - - it("keeps the estimated marker and returns null for empty usage", () => { - expect(toResponsesUsage({ prompt_tokens: 1, completion_tokens: 1, estimated: true }).estimated).toBe(true); - expect(toResponsesUsage({})).toBeNull(); - expect(toResponsesUsage(null)).toBeNull(); - }); -}); - -describe("openai → openai-responses translator", () => { - it("puts usage from the finish chunk on response.completed", () => { - const state = initState(FORMATS.OPENAI_RESPONSES); - const events = openaiToOpenAIResponsesResponse(QODER_FINISH_CHUNK, state); - const completed = events.find((e) => e.event === "response.completed"); - expect(completed).toBeTruthy(); - expect(completed.data.response.usage).toEqual({ - input_tokens: 27_339, - output_tokens: 437, - total_tokens: 27_776, - input_tokens_details: { cached_tokens: 27_200 }, - output_tokens_details: { reasoning_tokens: 0 }, - }); - }); - - it("omits usage when the upstream never reported any", () => { - const state = initState(FORMATS.OPENAI_RESPONSES); - const events = openaiToOpenAIResponsesResponse({ ...QODER_FINISH_CHUNK, usage: undefined }, state); - const completed = events.find((e) => e.event === "response.completed"); - expect(completed.data.response.usage).toBeUndefined(); - }); -}); - -describe("stream.js translate mode: chat upstream → Responses client", () => { - const transform = () => createSSETransformStreamWithLogger( - FORMATS.OPENAI, // provider (Qoder executor emits OpenAI chunks) - FORMATS.OPENAI_RESPONSES, // client - "qoder", - null, - null, - "qmodel_38max", - null, - { model: "qd/qmodel_38max", messages: [{ role: "user", content: "hi" }] }, - ); - - it("emits provider usage (+buffer) with cached tokens on response.completed", async () => { - const out = await pipe(sse([ - { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined }, - QODER_FINISH_CHUNK, - "[DONE]", - ]), transform()); - - const completed = completedEvent(out); - expect(completed).toBeTruthy(); - expect(completed.response.usage).toEqual({ - input_tokens: 27_339 + BUFFER_TOKENS, - output_tokens: 437, - total_tokens: 27_776 + BUFFER_TOKENS, - input_tokens_details: { cached_tokens: 27_200 }, - output_tokens_details: { reasoning_tokens: 0 }, - }); - // Responses clients terminate on response.completed (no [DONE] sentinel in translate mode) - expect(out.indexOf("event: response.completed")).toBeGreaterThan(out.indexOf("event: response.output_item.done")); - }); - - it("injects estimated usage when the upstream reports none", async () => { - const out = await pipe(sse([ - { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello world" }, finish_reason: null }], usage: undefined }, - { ...QODER_FINISH_CHUNK, usage: undefined }, - "[DONE]", - ]), transform()); - - const completed = completedEvent(out); - expect(completed.response.usage).toBeTruthy(); - expect(completed.response.usage.estimated).toBe(true); - expect(completed.response.usage.input_tokens).toBeGreaterThan(0); - expect(completed.response.usage.output_tokens).toBeGreaterThan(0); - }); -}); - -describe("responsesTransformer (Chat SSE → Codex Responses SSE)", () => { - it("forwards finish-chunk usage on response.completed", async () => { - const out = await pipe(sse([ - { ...QODER_FINISH_CHUNK, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], usage: undefined }, - QODER_FINISH_CHUNK, - "[DONE]", - ]), createResponsesApiTransformStream()); - - const completed = completedEvent(out); - expect(completed.response.usage).toMatchObject({ - input_tokens: 27_339, - output_tokens: 437, - input_tokens_details: { cached_tokens: 27_200 }, - }); - }); -}); diff --git a/tests/unit/openai-to-claude.test.js b/tests/unit/openai-to-claude.test.js index 0c5768d7..45b67fb2 100644 --- a/tests/unit/openai-to-claude.test.js +++ b/tests/unit/openai-to-claude.test.js @@ -203,31 +203,4 @@ describe("openaiToClaudeResponse", () => { limit: 120 }); }); - - it("records usage from a choices:[] frame so the finish chunk can emit it", () => { - const state = { toolCalls: new Map() }; - expect(openaiToClaudeResponse({ - usage: { - prompt_tokens: 90, - completion_tokens: 7, - prompt_tokens_details: { cached_tokens: 30 }, - }, - choices: [], - }, state)).toBeNull(); - expect(state.usage).toEqual({ - input_tokens: 60, - output_tokens: 7, - cache_read_input_tokens: 30, - }); - - const events = openaiToClaudeResponse({ - id: "chatcmpl-qoder-finish", - model: "qoder/auto", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }, state); - const delta = events.find((e) => e.type === "message_delta"); - expect(delta.usage.input_tokens).toBe(60); - expect(delta.usage.output_tokens).toBe(7); - expect(delta.usage.cache_read_input_tokens).toBe(30); - }); }); From da6aa90128c0861beccdeea1a6f23b0d1816673a Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 23:18:45 +0700 Subject: [PATCH 23/28] fix(video/vertex): reject job ids and model ids that escape the URL path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Vertex job id is a base64url-encoded operation name, and base64url decoding accepts arbitrary bytes without throwing, so the previous decode-and-split check let a crafted id splice a path traversal into the fetch URL while the Bearer token stayed attached — e.g. "..%2F..%2Fevil" resolved to /v1/evil:fetchPredictOperation on the Vertex host. body.model had the same shape on the create path, where it is interpolated into the URL unescaped. decodeJobId now requires a charset-only id, a byte-for-byte round-trip, and a decoded name matching ^projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}$ — no field may contain "/", so ".." can never reach the URL. model ids are restricted to [A-Za-z0-9._-]. Co-Authored-By: Claude Code --- open-sse/handlers/videoProviders/vertex.js | 34 +++++++++++++++------- tests/unit/video-providers.test.js | 33 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/open-sse/handlers/videoProviders/vertex.js b/open-sse/handlers/videoProviders/vertex.js index 25394b94..a4ff66c6 100644 --- a/open-sse/handlers/videoProviders/vertex.js +++ b/open-sse/handlers/videoProviders/vertex.js @@ -13,7 +13,25 @@ import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefre const DEFAULT_LOCATION = "us-central1"; const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url"); -const decodeJobId = (id) => Buffer.from(id, "base64url").toString("utf8"); + +// 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); @@ -103,17 +121,11 @@ export default { const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` }; if (requestId) { - let operationName; - try { - operationName = decodeJobId(requestId); - } catch { - return { error: "Invalid Vertex video job id" }; - } - const modelPath = operationName.split("/operations/")[0]; - if (!modelPath || modelPath === operationName) return { error: "Invalid Vertex video job id" }; + const operationName = decodeJobId(requestId); + if (!operationName) return { error: "Invalid Vertex video job id" }; return { method: "POST", - url: `${base}/v1/${modelPath}:fetchPredictOperation`, + url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`, headers, body: JSON.stringify({ operationName }), }; @@ -131,6 +143,8 @@ export default { 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 { diff --git a/tests/unit/video-providers.test.js b/tests/unit/video-providers.test.js index 680db5a5..bfa26589 100644 --- a/tests/unit/video-providers.test.js +++ b/tests/unit/video-providers.test.js @@ -260,4 +260,37 @@ describe("vertex (veo) video adapter", () => { 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(); + }); }); From c712641123d3d155ecce9d375f15415026d89547 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 23:18:58 +0700 Subject: [PATCH 24/28] feat(opencode-go): list deepseek-v4.1-flash first in the model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry order is the display order for the provider page, /v1/models, and the CLI selector, so moving the entry to the head of `models` is the whole change. deepseek-flash keeps its id and supportedFormats — only its position moves. Co-Authored-By: Claude Code --- open-sse/providers/registry/opencode-go.js | 2 +- tests/unit/opencode-go-models.test.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index a715a98e..1d1d12c0 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -35,6 +35,7 @@ export default { ], // 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"] }, @@ -45,7 +46,6 @@ export default { { 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: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, { 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"] }, diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 0ed0804b..4ee2042a 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -24,8 +24,9 @@ describe("OpenCode Go model catalog", () => { it("matches the documented model IDs", () => { const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id); expect(ids).toEqual([ + "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", "deepseek-flash", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", From accf2c52969b0876625ffa8d66a5add803dc056f Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 23:32:55 +0700 Subject: [PATCH 25/28] fix(providers): remove the duplicate qwen provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qwen.js points at the same transport as alims-intl — identical baseUrl (dashscope-intl compatible-mode), headers, and quirks — but carries only 8 Qwen models against alims-intl's broader catalog, so it adds nothing a user could not already reach. Node count is back to 81. The id was dropped on 2026-08-05 (dcdd4628b) when the Qwen OAuth flow died; this removes the standalone API-key entry that shadowed it. The `qw` alias returns to unassigned, its state before today. Co-Authored-By: Claude Code --- open-sse/providers/registry/index.js | 2 -- open-sse/providers/registry/qwen.js | 36 ---------------------------- 2 files changed, 38 deletions(-) delete mode 100644 open-sse/providers/registry/qwen.js diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 48a49ebe..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"; -import p124 from "./qwen.js"; export default [ p0, p1, @@ -247,5 +246,4 @@ export default [ p120, p121, p122, - p124, ]; diff --git a/open-sse/providers/registry/qwen.js b/open-sse/providers/registry/qwen.js deleted file mode 100644 index d51e88a8..00000000 --- a/open-sse/providers/registry/qwen.js +++ /dev/null @@ -1,36 +0,0 @@ -export default { - id: "qwen", - priority: 12, - alias: "qwen", - aliases: ["qw"], - display: { - name: "Qwen", - icon: "sparkles", - color: "#6366F1", - textIcon: "Qw", - website: "https://www.alibabacloud.com/en/product/model-studio", - notice: { - apiKeyUrl: - "https://modelstudio.console.alibabacloud.com/?apiKey=1", - }, - }, - category: "apikey", - transport: { - baseUrl: - "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", - headers: {}, - quirks: { preserveCacheControl: true }, - }, - models: [ - // Flagship & API models - { id: "qwen3.8-max", name: "Qwen3.8 Max" }, - { id: "qwen3.8-flash", name: "Qwen3.8 Flash" }, - { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, - { id: "qwen3.7-flash", name: "Qwen3.7 Flash" }, - // Open-source models - { id: "qwen3.8-2.4t-a95b", name: "Qwen3.8 2.4T MoE (Open)" }, - { id: "qwen3.8-27b", name: "Qwen3.8 27B (Open)" }, - { id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B MoE (Open)" }, - { id: "qwen3.6-27b", name: "Qwen3.6 27B (Open)" }, - ], -}; From 83af3f1853b295eda428f3d3d8f5ff96ca25bfab Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 23:35:10 +0700 Subject: [PATCH 26/28] # v0.5.75 (2026-09-10) Covers the 24 commits since the v0.5.69 tag. The package version already moved to 0.5.75 in 4a390685b (CLI model selector), so the release commit is the changelog alone, matching the convention in eb712ca82/4eda76e2a. Co-Authored-By: Claude Code --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b3010c..a2b6139d 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.69 (2026-09-05) ## Features From 73cb89143c29575e098e460b514b44c234c67739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E7=82=9C=E6=9C=8B?= Date: Thu, 10 Sep 2026 23:41:40 +0700 Subject: [PATCH 27/28] feat(xiaomi-mimo): merge MiMo Desktop support into xiaomi-mimo as dual auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Desktop-exclusive Preview models and the Xiaomi account-session route to the existing xiaomi-mimo provider instead of a separate xiaomi-desktop provider, so the dashboard shows one MiMo entry rather than three overlapping ones. Dual auth, same pattern as kimi — API key (sk-) covers the cloud API, Desktop/OAuth adds the account session used by the Preview models: - registry: category oauth, authModes [oauth, apikey], oauth block, the two mimo-x-*-preview models, and the invite signupUrl - executor: routes Preview models to the account-service route with a Cookie session, everything else keeps the sourceFormat-matched transport - oauth: custom ECDH encrypted-callback flow (X25519 -> SHA256 -> AES-256-GCM) with a loopback callback proxy, plus one-click import of the local Desktop auth.json - usage: weekly quota from the account session Fixes found while merging: - the OAuth browser flow was dead: poll-status cleared the session before the client could POST /exchange, so every exchange returned 400 - a Claude-format client was sent to /v1/chat/completions instead of the declared /anthropic/v1/messages transport, because buildUrl ignored runtimeTransport - stopXiaomiMimoProxy leaked every pending session (each holding an X25519 private key) for the process lifetime - the OAuth exchange did not persist the Desktop passToken, so the Preview models could never work after a browser sign-in Removes dead code: the local engine token minting (mimoEngine, never called on the request path), the model-catalog and usage routes, engineToken/ engineUrl plumbing, and an unread top-level usage block. Adds tests/unit/xiaomi-mimo-{executor,oauth-session,oauth-proxy}.test.js — the provider previously had none. --- .gitignore | 11 + open-sse/executors/index.js | 3 + open-sse/executors/xiaomi-mimo.js | 99 +++++++ open-sse/providers/registry/xiaomi-mimo.js | 32 +- open-sse/services/usage.js | 2 + open-sse/services/usage/xiaomi-mimo.js | 125 ++++++++ open-sse/shared/mimoAccount.js | 264 +++++++++++++++++ open-sse/translator/concerns/paramSupport.js | 3 + .../dashboard/providers/[id]/page.js | 15 +- .../api/oauth/[provider]/[action]/route.js | 115 +++++++- .../api/oauth/xiaomi-mimo/api-key/route.js | 136 +++++++++ .../oauth/xiaomi-mimo/auto-import/route.js | 140 +++++++++ src/lib/db/driver.js | 4 + src/lib/oauth/constants/oauth.js | 15 + src/lib/oauth/providers/xiaomi-mimo.js | 123 ++++++++ src/lib/oauth/utils/server.js | 182 ++++++++++++ src/shared/components/XiaomiMimoAuthModal.js | 276 ++++++++++++++++++ src/shared/components/index.js | 1 + tests/unit/xiaomi-mimo-executor.test.js | 80 +++++ tests/unit/xiaomi-mimo-oauth-proxy.test.js | 54 ++++ tests/unit/xiaomi-mimo-oauth-session.test.js | 152 ++++++++++ 21 files changed, 1828 insertions(+), 4 deletions(-) create mode 100644 open-sse/executors/xiaomi-mimo.js create mode 100644 open-sse/services/usage/xiaomi-mimo.js create mode 100644 open-sse/shared/mimoAccount.js create mode 100644 src/app/api/oauth/xiaomi-mimo/api-key/route.js create mode 100644 src/app/api/oauth/xiaomi-mimo/auto-import/route.js create mode 100644 src/lib/oauth/providers/xiaomi-mimo.js create mode 100644 src/shared/components/XiaomiMimoAuthModal.js create mode 100644 tests/unit/xiaomi-mimo-executor.test.js create mode 100644 tests/unit/xiaomi-mimo-oauth-proxy.test.js create mode 100644 tests/unit/xiaomi-mimo-oauth-session.test.js diff --git a/.gitignore b/.gitignore index 6773d550..e17d69b7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,14 @@ 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 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/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/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/services/usage.js b/open-sse/services/usage.js index eeb46c3c..d7868760 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -17,6 +17,7 @@ import { getDeepseekUsage } from "./usage/deepseek.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 { @@ -60,6 +61,7 @@ const USAGE_HANDLERS = { deepseek: (c) => getDeepseekUsage(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/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/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/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/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 9657b7ed..5ae217fb 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 } from "open-sse/providers/thinkingLevels.js"; @@ -45,6 +45,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(""); @@ -98,6 +99,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; @@ -1796,6 +1802,13 @@ export default function ProviderDetailPage() { onClose={() => setShowOAuthModal(false)} /> )} + + {/* Xiaomi Desktop: auto-import local credentials modal */} + setShowXiaomiMimoModal(false)} + /> {providerId === "iflow" && ( 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/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/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/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 c04453a9..3d508e91 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/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); + }); +}); From 17c4cc76877bd1755030a8414f8d0083f48dcccf Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 11 Sep 2026 00:09:15 +0700 Subject: [PATCH 28/28] feat(claude-code): drive auto-compact window, add a 1M-context toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Context window" dropdown wrote CLAUDE_CODE_MAX_CONTEXT_TOKENS, which Claude Code ignores for any model it recognizes: its window resolver returns the env value only when the id is unknown to the model table, so every claude-* mapping kept the built-in 200K and the dropdown did nothing. It was never the compaction threshold either. - Replace it with CLAUDE_CODE_AUTO_COMPACT_WINDOW — the documented trigger (100K–1M, clamped to the model window, env beats the autoCompactWindow setting) — and relabel the field Auto-compact. The 1M preset becomes 700K, which no longer collides with the marker it depends on. - Add a "1M context" checkbox that appends the `[1m]` marker to the ANTHROPIC_DEFAULT_*_MODEL envs. Claude Code assumes 200K unless the name carries the marker — the resolver is a plain /\[1m\]/i test on the string, so it applies to any id and no model lookup is involved; the user decides which models are worth declaring as 1M. - Toggling rewrites the model inputs immediately, and Apply writes them verbatim, so a marker typed by hand is not stripped. Rename maxContextTokens -> autoCompactWindow through the POST body and RESET_ENV_KEYS so a reset clears the key actually written. Co-Authored-By: Claude Code --- .../cli-tools/components/ClaudeToolCard.js | 81 +++++++++++++++---- .../api/cli-tools/claude-settings/route.js | 15 ++-- 2 files changed, 73 insertions(+), 23 deletions(-) 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/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)