merge: integrate origin/master (v0.5.50) into gitea/new_feature

- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns
  from the details-filters feature, adopt origin's stripContinuityFields,
  customToolNames, cache-inclusive usage accounting, and Responses-API
  SSE→JSON conversion
- Adopt origin's provider usage handlers (codebuddy-intl, qoder creds)
  and modality detection (audio/video inputs)
- Keep requestDetails apiKey column (schema v2) + masked key persistence

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-08-06 09:54:31 +07:00
180 changed files with 8622 additions and 6233 deletions

View File

@@ -81,6 +81,33 @@ export default function ToolDetailClient({ toolId, machineId }) {
models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id });
}
});
// openai/anthropic-compatible providers are registered with a random UUID (e.g.
// "openai-compatible-chat-<uuid>") that has no entry in the static PROVIDER_MODELS
// catalog, so `getModelsByProviderId` returns []. Routing still works because the
// request path uses the connection's own model config, but `hasActiveProviders`
// below would flip to false and disable the Apply button. Fall back to the
// connection's own models so these providers are usable from CLI tool pages.
if (providerModels.length === 0) {
const prefix = conn.providerSpecificData?.prefix || alias;
const fallbackModels = [];
if (conn.defaultModel) fallbackModels.push({ id: conn.defaultModel, name: conn.defaultModel });
(conn.providerSpecificData?.customModels || []).forEach(m => {
if (m?.id && !fallbackModels.some(f => f.id === m.id)) fallbackModels.push({ id: m.id, name: m.name || m.id });
});
if (fallbackModels.length === 0 && conn.testStatus === "active") {
// Provider is confirmed reachable but exposes no model info anywhere;
// still let the user apply so they aren't stuck on a permanently disabled button.
fallbackModels.push({ id: "model-id", name: `${prefix}/model-id` });
}
fallbackModels.forEach(m => {
const modelValue = `${prefix}/${m.id}`;
if (!seenModels.has(modelValue)) {
seenModels.add(modelValue);
models.push({ value: modelValue, label: `${prefix}/${m.id}`, provider: conn.provider, alias: prefix, connectionName: conn.name, modelId: m.id });
}
});
}
});
return models;
};

File diff suppressed because it is too large Load Diff

View File

@@ -261,11 +261,26 @@ export default function APIPageClient({ machineId }) {
const fetchData = async () => {
try {
const keysRes = await fetch("/api/keys");
const keysData = await keysRes.json();
if (keysRes.ok) {
setKeys(keysData.keys || []);
const fetchKeys = async () => {
const res = await fetch("/api/keys");
if (!res.ok) return [];
const data = await res.json();
return data.keys || [];
};
let existing = await fetchKeys();
// Auto-provision a default key for first-time users so the endpoint works out of the box.
if (existing.length === 0) {
try {
const createRes = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Default Key" }),
});
if (createRes.ok) existing = await fetchKeys();
} catch { /* fall through to empty render */ }
}
setKeys(existing);
} catch (error) {
console.log("Error fetching data:", error);
} finally {
@@ -1026,7 +1041,7 @@ export default function APIPageClient({ machineId }) {
</code>
<button
onClick={() => toggleKeyVisibility(key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
title={visibleKeys.has(key.id) ? "Hide key" : "Show key"}
>
<span className="material-symbols-outlined text-[14px]">
@@ -1035,7 +1050,7 @@ export default function APIPageClient({ machineId }) {
</button>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}

View File

@@ -6,6 +6,7 @@ import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
import { translate } from "@/i18n/runtime";
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
import { Row } from "./exampleShared";
@@ -40,6 +41,7 @@ export function TtsExampleCard({ providerId }) {
// Form state
const [input, setInput] = useState("Hello, this is a text to speech test.");
const [style, setStyle] = useState(""); // style/voice instructions (e.g. MiMo voicedesign)
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
@@ -59,8 +61,10 @@ export function TtsExampleCard({ providerId }) {
const [modalSearch, setModalSearch] = useState("");
const [modalError, setModalError] = useState("");
const [byLang, setByLang] = useState({});
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
// Language hint (e.g. Gemini/MiMo): guides the spoken language without affecting voice selection
const [languageHint, setLanguageHint] = useState("");
// Number of stored provider connections (shown when no dashboard API key)
const [connectionCount, setConnectionCount] = useState(0);
useEffect(() => {
setLocalEndpoint(window.location.origin);
@@ -68,6 +72,10 @@ export function TtsExampleCard({ providerId }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/providers", { cache: "no-store" })
.then((r) => r.json())
.then((d) => { setConnectionCount((d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false).length); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
@@ -111,6 +119,10 @@ export function TtsExampleCard({ providerId }) {
if (voices.length) {
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name || voices[0].id);
} else {
// Model has no preset voices (voicedesign/voiceclone) — drop stale voice
setSelectedVoice("");
setSelectedVoiceName("");
}
}, [selectedModel]);
@@ -184,6 +196,7 @@ export function TtsExampleCard({ providerId }) {
const ttsBody = (() => {
const b = { model: modelFull, input };
if (config.hasLanguageHint && languageHint) b.language = languageHint;
if (config.hasStyleInput && style.trim()) b.style = style.trim();
return b;
})();
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
@@ -218,7 +231,8 @@ export function TtsExampleCard({ providerId }) {
if (responseFormat === "json") {
const data = await res.json();
setJsonResponse(data); // Store full JSON response
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
const format = data.format || "mp3";
const audioBlob = await fetch(`data:audio/${format};base64,${data.audio}`).then(r => r.blob());
setAudioUrl(URL.createObjectURL(audioBlob));
} else {
const blob = await res.blob();
@@ -259,7 +273,11 @@ export function TtsExampleCard({ providerId }) {
</Row>
<Row label="API Key">
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
{apiKey
? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}`
: connectionCount > 0
? <span className="text-text-muted italic">Using stored key(s) · {connectionCount} connection{connectionCount > 1 ? "s" : ""}</span>
: <span className="text-text-muted italic">No key configured</span>}
</span>
</Row>
@@ -281,7 +299,7 @@ export function TtsExampleCard({ providerId }) {
</Row>
)}
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
{/* Language hint dropdown (Gemini, Xiaomi MiMo) — sends body.language to guide pronunciation */}
{config.hasLanguageHint && (
<Row label="Language">
<select
@@ -290,9 +308,11 @@ export function TtsExampleCard({ providerId }) {
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="">Auto-detect</option>
{GOOGLE_TTS_LANGUAGES.map((l) => (
<option key={l.id} value={l.name}>{l.name}</option>
))}
{(config.languageOptions || GOOGLE_TTS_LANGUAGES).map((l) =>
typeof l === "string"
? <option key={l} value={l}>{l}</option>
: <option key={l.id} value={l.name}>{l.name}</option>
)}
</select>
</Row>
)}
@@ -320,7 +340,7 @@ export function TtsExampleCard({ providerId }) {
</Row>
)}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs/MiMo) */}
{countryVoices.length > 0 && (
<Row label="Voice">
<div className="flex flex-wrap gap-1.5">
@@ -338,7 +358,9 @@ export function TtsExampleCard({ providerId }) {
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
}`}
>
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.name}
{v.language ? ` · ${v.language}` : ""}
{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.free_users_allowed === true && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
)}
@@ -418,6 +440,30 @@ export function TtsExampleCard({ providerId }) {
</div>
</Row>
{/* Style / voice instructions (Xiaomi MiMo) */}
{config.hasStyleInput && (
<Row label={translate("Style")}>
<div className="relative">
<textarea
value={style}
onChange={(e) => setStyle(e.target.value)}
placeholder={translate("e.g. a warm, gentle voice, speaking slowly with a British accent")}
rows={2}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
{style && (
<button
type="button"
onClick={() => setStyle("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
)}
{/* Output Format */}
<Row label="Output Format">
<select

View File

@@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
const isXaiApiKey = provider === "xai" && !isCookie;
const credentialLabel = isCookie ? "Cookie Value" : "API Key";
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key";
const credentialPlaceholder = isCookie
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...")
: (isXaiApiKey ? "xai-..." : "");
: (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : "");
const isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai";
@@ -44,7 +44,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
: BULK_PLACEHOLDER;
: provider === "qoder"
? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
: BULK_PLACEHOLDER;
const [mode, setMode] = useState("single"); // "single" | "bulk"
const [bulkText, setBulkText] = useState("");
@@ -145,6 +147,21 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
let failed = 0;
for (const entry of plan) {
try {
// Validate each key before saving so bulk-added connections get a
// real status (active/unknown) like single adds, instead of a
// hardcoded "unknown" that never flips until a manual test.
let isValid = false;
try {
const vres = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: entry.apiKey }),
});
const vdata = await vres.json().catch(() => ({}));
isValid = !!vdata.valid;
} catch {
isValid = false;
}
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -153,7 +170,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
apiKey: entry.apiKey,
name: entry.name,
priority: 1,
testStatus: "unknown",
testStatus: isValid ? "active" : "unknown",
...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}),
}),
});
@@ -184,7 +201,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
<p className="text-xs text-text-muted">
{isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
: provider === "qoder"
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
}
</p>
<textarea

File diff suppressed because it is too large Load Diff

View File

@@ -283,7 +283,15 @@ export default function ProvidersPage() {
const dualAuthTypes = (info, key) => {
if (key === "kiro") return ["oauth", "apikey", "api_key"];
const modes = info?.authModes;
if (!Array.isArray(modes) || !modes.includes("apikey")) return "oauth";
// Free-tier and API-key providers default to supporting apikey even when the
// registry entry omits authModes (e.g. cloudflare-ai, byteplus, ollama,
// vertex) — otherwise their apikey connections are invisible on the grid card.
if (!Array.isArray(modes)) {
return key in FREE_TIER_PROVIDERS || key in APIKEY_PROVIDERS
? ["oauth", "apikey", "api_key"]
: "oauth";
}
if (!modes.includes("apikey")) return "oauth";
return ["oauth", "apikey", "api_key"];
};

View File

@@ -475,48 +475,6 @@ export function parseQuotaData(provider, data) {
}
break;
case "xai":
// xAI mixes:
// - weekly: percentage window (used/total 0-100) from GetGrokCreditsConfig
// - api_usage: absolute monthly credits from /v1/billing
// For absolute rows, do not forward remainingCredits as `remaining`
// (QuotaTable treats remaining as a 0-100 percentage; same pitfall as Qoder).
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]) => {
const name =
quotaType === "weekly"
? "Weekly limit"
: quotaType === "api_usage"
? "Api usage"
: quotaType === "monthly"
? "Api usage"
: quotaType === "on_demand"
? "On-demand"
: quotaType;
if (quotaType === "weekly") {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 100,
remaining: quota.remaining,
remainingPercentage: quota.remainingPercentage ?? quota.remaining,
resetAt: quota.resetAt || null,
});
return;
}
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
unit: quota.unit,
resetAt: quota.resetAt || null,
});
});
}
break;
case "grok-cli":
// Grok Build credits (on-demand window + prepaid balance).
// Do NOT forward absolute `remaining` — getRemainingPercentage treats
@@ -564,17 +522,15 @@ export function parseQuotaData(provider, data) {
}
break;
case "commandcode":
// CommandCode reports currency credits (5-hour/weekly windows + monthly
// credits) with used/total in dollars. Forward remainingPercentage (the
// UI would otherwise render "$0.05" balances as "0%") and unit "$".
case "ollama":
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
unit: quota.unit || "$",
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});

View File

@@ -24,6 +24,7 @@ export async function GET() {
hasPassword: !!settings.password,
displayName,
loginMethod,
authenticated: !!session,
oidcName: oidcName || null,
oidcEmail: oidcEmail || null,
oidcLogin: !!session?.oidc,
@@ -37,6 +38,7 @@ export async function GET() {
hasPassword: false,
displayName: "Password user",
loginMethod: "Password",
authenticated: false,
oidcName: null,
oidcEmail: null,
oidcLogin: false,

View File

@@ -254,6 +254,7 @@ export async function POST(request, { params }) {
if (action === "register-session") {
// Register proxy session out of URL query (state) + body (codeVerifier).
// Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs.
const searchParams = new URL(request.url).searchParams;
const state = searchParams.get("state") || body?.state;
if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 });
let ok = false;

View File

@@ -79,18 +79,6 @@ const createOpenAIModelsConfig = (url) => ({
parseResponse: parseOpenAIStyleModels
});
const resolveQwenModelsUrl = (connection) => {
const fallback = "https://portal.qwen.ai/v1/models";
const raw = connection?.providerSpecificData?.resourceUrl;
if (!raw || typeof raw !== "string") return fallback;
const value = raw.trim();
if (!value) return fallback;
if (value.startsWith("http://") || value.startsWith("https://")) {
return `${value.replace(/\/$/, "")}/models`;
}
return `https://${value.replace(/\/$/, "")}/v1/models`;
};
const getStaticProviderModels = (providerId) =>
getModelsByProviderId(providerId).map((model) => ({
...model,
@@ -156,14 +144,6 @@ const PROVIDER_MODELS_CONFIG = {
authQuery: "key", // Use query param for API key
parseResponse: (data) => data.models || []
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
codex: {
customResolver: buildOAuthResolver({
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
@@ -356,6 +336,7 @@ const PROVIDER_MODELS_CONFIG = {
customResolver: async (connection) => {
const credentials = {
accessToken: connection.accessToken,
apiKey: connection.apiKey,
refreshToken: connection.refreshToken,
email: connection.email,
displayName: connection.displayName,
@@ -571,9 +552,6 @@ export async function GET(request, { params }) {
// Build request URL
let url = config.url;
if (connection.provider === "qwen") {
url = resolveQwenModelsUrl(connection);
}
if (config.authQuery) {
url += `?${config.authQuery}=${token}`;
}

View File

@@ -12,7 +12,6 @@ import {
GEMINI_CONFIG,
ANTIGRAVITY_CONFIG,
KIRO_CONFIG,
QWEN_CONFIG,
CLAUDE_CONFIG,
CLINE_CONFIG,
KILOCODE_CONFIG,
@@ -62,7 +61,6 @@ const OAUTH_TEST_CONFIG = {
method: "GET",
noAuth: true,
},
qwen: { checkExpiry: true, refreshable: true },
kiro: { checkExpiry: true, refreshable: true },
qoder: {
// Test by hitting Qoder's userinfo endpoint with the device token.
@@ -285,21 +283,6 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
}
if (provider === "qwen") {
const response = await fetch(QWEN_CONFIG.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: QWEN_CONFIG.clientId,
}),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "cline") {
const response = await fetch(CLINE_CONFIG.refreshUrl, {
method: "POST",

View File

@@ -4,6 +4,7 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbe
import { getDefaultModel } from "open-sse/config/providerModels.js";
import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js";
import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js";
import { resolveQoderCredentials, resolveQoderModels } from "open-sse/services/qoderModels.js";
import { normalizeProviderId } from "@/lib/providerNormalization";
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
@@ -581,6 +582,20 @@ export async function POST(request) {
break;
}
case "qoder": {
// PAT (pt-...) needs the job-token exchange before it can sign
// anything — the generic OpenAI-compat probe below can't validate it.
try {
const resolved = await resolveQoderCredentials({ apiKey, providerSpecificData }, null, AbortSignal.timeout(8000));
const result = await resolveQoderModels(resolved, { forceRefresh: true });
isValid = !!result?.models?.length;
} catch (err) {
isValid = false;
error = err.message;
}
break;
}
default: {
// Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS)
const cfg = PROVIDERS[provider];

View File

@@ -37,7 +37,7 @@ export default function LoginPage() {
if (res.ok) {
const data = await res.json();
if (data.requireLogin === false) {
if (data.authenticated === true || data.requireLogin === false) {
window.location.assign("/dashboard");
return;
}

View File

@@ -15,10 +15,25 @@ async function getObservabilityConfig() {
try {
const { getSettings } = await import("./settingsRepo.js");
const settings = await getSettings();
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
const enabled = typeof settings.enableObservability2 === "boolean"
? settings.enableObservability2
: envEnabled;
const envRequestLogs = process.env.ENABLE_REQUEST_LOGS;
if (envRequestLogs !== undefined) {
const enabled = envRequestLogs.toLowerCase() === "true";
cachedConfig = {
enabled,
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10),
flushIntervalMs: settings.observabilityFlushIntervalMs || parseInt(process.env.OBSERVABILITY_FLUSH_INTERVAL_MS || String(DEFAULT_FLUSH_INTERVAL_MS), 10),
maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || "5", 10)) * 1024,
};
cachedConfigTs = Date.now();
return cachedConfig;
}
const envFallback = process.env.OBSERVABILITY_ENABLED !== "false";
const uiFlag = typeof settings.enableObservability === "boolean";
const enabled = uiFlag
? settings.enableObservability
: envFallback;
cachedConfig = {
enabled,
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
@@ -126,7 +141,7 @@ async function flushToDatabase() {
export async function saveRequestDetail(detail) {
const config = await getObservabilityConfig();
if (!config.enabled) return;
if (!config.enabled) {return;}
writeBuffer.push(detail);

View File

@@ -2,118 +2,121 @@ import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
const DEFAULT_HEADROOM_URL =
process.env.HEADROOM_URL || "http://localhost:8787";
const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
const DEFAULT_SETTINGS = {
cloudEnabled: false,
tunnelEnabled: false,
tunnelUrl: "",
tunnelProvider: "cloudflare",
tailscaleEnabled: false,
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
providerTimeouts: {},
streamErrorPatterns: {},
defaultTimeoutMs: null,
quotaVisibility: {},
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},
requireLogin: true,
tunnelDashboardAccess: true,
authMode: "password",
oidcIssuerUrl: "",
oidcClientId: "",
oidcClientSecret: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
enableObservability: true,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
observabilityMaxJsonSize: 5,
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
dnsToolEnabled: {},
rtkEnabled: true,
headroomEnabled: false,
headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false,
cavemanEnabled: false,
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
pxpipeEnabled: false,
pxpipeAutoInstall: true,
pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000,
cloudEnabled: false,
tunnelEnabled: false,
tunnelUrl: "",
tunnelProvider: "cloudflare",
tailscaleEnabled: false,
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
quotaVisibility: {},
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},
capacityAdapter: {
vision: { enabled: true, roundRobin: false, models: [] },
pdf: { enabled: false, roundRobin: false, models: [] },
audioInput: { enabled: true, roundRobin: false, models: [] },
videoInput: { enabled: false, roundRobin: false, models: [] },
},
requireLogin: true,
requireApiKey: true,
tunnelDashboardAccess: true,
authMode: "password",
oidcIssuerUrl: "",
oidcClientId: "",
oidcClientSecret: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
enableObservability: false,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
observabilityMaxJsonSize: 5,
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
dnsToolEnabled: {},
rtkEnabled: true,
headroomEnabled: false,
headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false,
cavemanEnabled: false,
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
pxpipeEnabled: false,
pxpipeAutoInstall: true,
pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000,
};
async function readRaw() {
const db = await getAdapter();
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
return row ? parseJson(row.data, {}) : {};
const db = await getAdapter();
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
return row ? parseJson(row.data, {}) : {};
}
// Merge raw settings with defaults; backward-compat for missing keys
function mergeWithDefaults(raw) {
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) {
if (
key === "outboundProxyEnabled" &&
typeof merged.outboundProxyUrl === "string" &&
merged.outboundProxyUrl.trim()
) {
merged[key] = true;
} else {
merged[key] = defVal;
}
}
}
return merged;
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) {
if (
key === "outboundProxyEnabled" &&
typeof merged.outboundProxyUrl === "string" &&
merged.outboundProxyUrl.trim()
) {
merged[key] = true;
} else {
merged[key] = defVal;
}
}
}
return merged;
}
export async function getSettings() {
const raw = await readRaw();
return mergeWithDefaults(raw);
const raw = await readRaw();
return mergeWithDefaults(raw);
}
// Atomic read-merge-write inside transaction (prevents losing concurrent updates)
export async function updateSettings(updates) {
const db = await getAdapter();
let next;
db.transaction(() => {
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
const current = row ? parseJson(row.data, {}) : {};
next = { ...current, ...updates };
db.run(
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
[stringifyJson(next)],
);
});
return mergeWithDefaults(next);
const db = await getAdapter();
let next;
db.transaction(function () {
const row = db.get(`SELECT data FROM settings WHERE id = 1`);
const current = row ? parseJson(row.data, {}) : {};
next = { ...current, ...updates };
db.run(
`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
[stringifyJson(next)],
);
});
return mergeWithDefaults(next);
}
export async function isCloudEnabled() {
const settings = await getSettings();
return settings.cloudEnabled === true;
const settings = await getSettings();
return settings.cloudEnabled === true;
}
export async function getCloudUrl() {
const settings = await getSettings();
return (
settings.cloudUrl ||
process.env.CLOUD_URL ||
process.env.NEXT_PUBLIC_CLOUD_URL ||
""
);
const settings = await getSettings();
return (
settings.cloudUrl ||
process.env.CLOUD_URL ||
process.env.NEXT_PUBLIC_CLOUD_URL ||
""
);
}
export async function exportSettings() {
return await readRaw();
return await readRaw();
}

View File

@@ -28,9 +28,6 @@ export const CODEX_CONFIG = { ...PROVIDER_OAUTH["codex"] };
// clientId/clientSecret from GOOGLE_OAUTH_CLIENT (shared.js) — not stored in registry
export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini-cli"] };
// Qwen OAuth Configuration (Device Code Flow with PKCE)
export const QWEN_CONFIG = { ...PROVIDER_OAUTH["qwen"] };
// Qoder OAuth Configuration (Device Token Flow with PKCE).
// Device tokens are long-lived (~30 days for access, ~360 for refresh).
// The upstream refresh endpoint at center.qoder.sh returns 403 for our
@@ -206,7 +203,6 @@ export const PROVIDERS = {
CLAUDE: "claude",
CODEX: "codex",
GEMINI: "gemini-cli",
QWEN: "qwen",
QODER: "qoder",
IFLOW: "iflow",
ANTIGRAVITY: "antigravity",

View File

@@ -39,13 +39,10 @@ const antigravity = {
return await response.json();
},
postExchange: async (tokens) => {
// Numeric enums matching Antigravity binary ClientMetadata
const loadHeaders = {
"Authorization": `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
"x-request-source": "local",
};
const metadata = getOAuthClientMetadata();

View File

@@ -12,7 +12,6 @@ import geminiCli from "./gemini-cli.js";
import antigravity from "./antigravity.js";
import iflow from "./iflow.js";
import qoder from "./qoder.js";
import qwen from "./qwen.js";
import github from "./github.js";
import kiro from "./kiro.js";
import cursor from "./cursor.js";
@@ -38,7 +37,6 @@ const PROVIDERS = {
antigravity,
iflow,
qoder,
qwen,
github,
kiro,
cursor,

View File

@@ -1,56 +0,0 @@
import { QWEN_CONFIG } from "../constants/oauth.js";
const qwen = {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
};
export default qwen;

View File

@@ -85,8 +85,6 @@ export class AntigravityService {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": this.config.loadCodeAssistUserAgent,
"X-Goog-Api-Client": this.config.loadCodeAssistApiClient,
"Client-Metadata": this.config.loadCodeAssistClientMetadata,
};
}

View File

@@ -6,7 +6,6 @@ export { OAuthService } from "./oauth.js";
export { ClaudeService } from "./claude.js";
export { CodexService } from "./codex.js";
export { GeminiCLIService } from "./gemini.js";
export { QwenService } from "./qwen.js";
export { IFlowService } from "./iflow.js";
export { QoderService } from "./qoder.js";
export { AntigravityService } from "./antigravity.js";

View File

@@ -1,170 +0,0 @@
import open from "open";
import { QWEN_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* Qwen OAuth Service
* Uses Device Code Flow with PKCE
*/
export class QwenService {
constructor() {
this.config = QWEN_CONFIG;
}
/**
* Request device code
*/
async requestDeviceCode(codeChallenge) {
const response = await fetch(this.config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: this.config.clientId,
scope: this.config.scope,
code_challenge: codeChallenge,
code_challenge_method: this.config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
}
/**
* Poll for token
*/
async pollForToken(deviceCode, codeVerifier, interval = 5) {
const maxAttempts = 60; // 5 minutes
const pollInterval = interval * 1000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise((r) => setTimeout(r, pollInterval));
const response = await fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: this.config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
if (response.ok) {
return await response.json();
}
const error = await response.json();
if (error.error === "authorization_pending") {
continue;
} else if (error.error === "slow_down") {
await new Promise((r) => setTimeout(r, 5000));
continue;
} else if (error.error === "expired_token") {
throw new Error("Device code expired");
} else if (error.error === "access_denied") {
throw new Error("Access denied");
} else {
throw new Error(error.error_description || error.error);
}
}
throw new Error("Authorization timeout");
}
/**
* Save Qwen tokens to server
*/
async saveTokens(tokens) {
const { server, token, userId } = getServerCredentials();
const response = await fetch(`${server}/api/cli/providers/qwen`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-User-Id": userId,
},
body: JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
resourceUrl: tokens.resource_url,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
return await response.json();
}
/**
* Complete Qwen OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting Qwen OAuth...").start();
try {
spinner.text = "Generating PKCE...";
// Generate PKCE
const { codeVerifier, codeChallenge } = generatePKCE();
spinner.text = "Requesting device code...";
// Request device code
const deviceData = await this.requestDeviceCode(codeChallenge);
spinner.stop();
console.log("\n📋 Please visit the following URL and enter the code:\n");
console.log(` ${deviceData.verification_uri}\n`);
console.log(` Code: ${deviceData.user_code}\n`);
// Open browser
if (deviceData.verification_uri_complete) {
await open(deviceData.verification_uri_complete);
} else {
await open(deviceData.verification_uri);
}
spinner.start("Waiting for authorization...");
// Poll for token
const tokens = await this.pollForToken(
deviceData.device_code,
codeVerifier,
deviceData.interval || 5
);
spinner.text = "Saving tokens to server...";
// Save tokens to server
await this.saveTokens(tokens);
spinner.succeed("Qwen connected successfully!");
return true;
} catch (error) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -226,7 +226,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
"qwen",
"kiro",
"kimi",
"kimi-coding",

View File

@@ -21,7 +21,7 @@ const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/combos", label: "Combo & Vision Adapter", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },

View File

@@ -416,6 +416,45 @@ devin auth login
devin --version`,
},
},
opendesign: {
id: "opendesign",
name: "OpenDesign",
image: "/providers/opendesign.png",
color: "#7C3AED",
description: "OpenDesign — claude.ai/design open-sourced! Agent-native design skills pack",
docsUrl: "https://github.com/manalkaff/opendesign",
configType: "guide",
notes: [
{ type: "info", text: "OpenDesign ships as a plugin/skills pack installed into Claude Code, Cursor, OpenAI Codex, Gemini CLI, or OpenCode. It inherits the host agent's model config, so once your host points at 9Router, /opendesign design sessions route through 9Router automatically — no extra env vars needed." },
{ type: "info", text: "Invoke with /opendesign <brief>. Covers decks, wireframes, interactive prototypes, design-system extraction, and brand systems, with a verifier subagent that checks output against the brief." },
],
guideSteps: [
{ step: 1, title: "Install the plugin", desc: "Pick your host below and run the matching install command from the matrix." },
{ step: 2, title: "No config needed", desc: "OpenDesign runs inside your host agent and uses its model config. If the host already routes through 9Router, /opendesign traffic does too." },
{ step: 3, title: "Start designing", desc: "Invoke OpenDesign from your agent:", value: "/opendesign make a pitch deck for a seed-stage AI company, 10 slides", copyable: true },
],
codeBlock: {
language: "bash",
code: `# Claude Code
/plugin marketplace add manalkaff/opendesign
/plugin install opendesign@opendesign
# Cursor
/add-plugin opendesign
# OpenAI Codex CLI
/plugins # search "opendesign" -> Install Plugin
# OpenAI Codex App
# Plugins sidebar -> OpenDesign (Design section) -> +
# Gemini CLI
gemini extensions install https://github.com/manalkaff/opendesign
# OpenCode
# Fetch and follow .opencode/INSTALL.md from the repo`,
},
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",

View File

@@ -135,4 +135,16 @@ export const TTS_PROVIDER_CONFIG = {
voiceKey: "gemini-tts-voices",
voicesPerModel: true,
},
"xiaomi-mimo": {
hasLanguageDropdown: false,
hasModelSelector: true,
hasBrowseButton: false,
hasVoiceIdInput: false,
hasStyleInput: true, // style/voice instructions (role: user)
hasLanguageHint: true, // language dropdown (Auto-detect default); voices are language-independent
languageOptions: ["Chinese", "English"],
voiceSource: "hardcoded",
modelKey: "xiaomi-mimo-tts-models",
voicesPerModel: true,
},
};

View File

@@ -112,6 +112,12 @@ async function runHeavyStartup() {
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
}
// Proactive OAuth token refresh (e.g. grok-cli ~6h TTL). Module is idempotent
// and also started from custom-server.js when that entry is used.
import("@/sse/services/backgroundTokenRefresh.js")
.then(({ startBackgroundTokenRefresh }) => startBackgroundTokenRefresh())
.catch((e) => console.log("[BackgroundTokenRefresh] scheduler start failed:", e.message));
}
function hasQuotaAutoPingEnabled(settings) {

View File

@@ -1,13 +1,12 @@
import "open-sse/index.js";
import {
getProviderCredentials,
markAccountUnavailable,
clearAccountError,
extractApiKey,
isValidApiKey,
getProviderCredentials,
markAccountUnavailable,
clearAccountError,
extractApiKey,
isValidApiKey,
} from "../services/auth.js";
import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
@@ -15,15 +14,13 @@ import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js";
import { appendPxpipeEvent } from "@/lib/pxpipe/events.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js";
import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js";
import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js";
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
import * as log from "../utils/logger.js";
import {
updateProviderCredentials,
checkAndRefreshToken,
} from "../services/tokenRefresh.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { getProjectIdForConnection } from "open-sse/services/projectId.js";
/**
@@ -32,355 +29,288 @@ import { getProjectIdForConnection } from "open-sse/services/projectId.js";
* Format detection and translation handled by translator
*/
export async function handleChat(request, clientRawRequest = null) {
let body;
try {
body = await request.json();
} catch {
log.warn("CHAT", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
let body;
try {
body = await request.json();
} catch {
log.warn("CHAT", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
// Build clientRawRequest for logging (if not provided)
if (!clientRawRequest) {
const url = new URL(request.url);
clientRawRequest = {
endpoint: url.pathname,
body,
headers: Object.fromEntries(request.headers.entries()),
};
}
cacheClaudeHeaders(clientRawRequest.headers);
// Build clientRawRequest for logging (if not provided)
if (!clientRawRequest) {
const url = new URL(request.url);
clientRawRequest = {
endpoint: url.pathname,
body,
headers: Object.fromEntries(request.headers.entries())
};
}
const modelStr = body.model;
const modelStr = body.model;
// Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account)
// Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account)
// Log API key (masked)
const authHeader = request.headers.get("Authorization");
const apiKey = extractApiKey(request);
if (authHeader && apiKey) {
const masked = log.maskKey(apiKey);
log.debug("AUTH", `API Key: ${masked}`);
} else {
log.debug("AUTH", "No API key provided (local mode)");
}
// Log API key (masked)
const authHeader = request.headers.get("Authorization");
const apiKey = extractApiKey(request);
if (authHeader && apiKey) {
const masked = log.maskKey(apiKey);
log.debug("AUTH", `API Key: ${masked}`);
} else {
log.debug("AUTH", "No API key provided (local mode)");
}
// Enforce API key if enabled in settings
const settings = await getSettings();
if (settings.requireApiKey) {
if (!apiKey) {
log.warn("AUTH", "Missing API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
// Enforce API key if enabled in settings
const settings = await getSettings();
if (settings.requireApiKey) {
if (!apiKey) {
log.warn("AUTH", "Missing API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
if (!modelStr) {
log.warn("CHAT", "Missing model");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (!modelStr) {
log.warn("CHAT", "Missing model");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// Bypass naming/warmup requests before combo rotation to avoid wasting rotation slots
const userAgent = request?.headers?.get("user-agent") || "";
const bypassResponse = handleBypassRequest(body, modelStr, userAgent, !!settings.ccFilterNaming);
if (bypassResponse) return bypassResponse.response || bypassResponse;
// Bypass naming/warmup requests before combo rotation to avoid wasting rotation slots
const userAgent = request?.headers?.get("user-agent") || "";
const bypassResponse = handleBypassRequest(
body,
modelStr,
userAgent,
!!settings.ccFilterNaming,
);
if (bypassResponse) return bypassResponse.response || bypassResponse;
const requiredCapabilities = detectRequiredCapabilities(body);
// Check if model is a combo (has multiple models with fallback)
const comboModels = await getComboModels(modelStr);
if (comboModels) {
// Check for combo-specific strategy first, fallback to global
const comboStrategies = settings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy =
comboSpecificStrategy || settings.comboStrategy || "fallback";
// Check if model is a combo (has multiple models with fallback)
const comboModels = await getComboModels(modelStr);
if (comboModels) {
// Check for combo-specific strategy first, fallback to global
const comboStrategies = settings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy = comboSpecificStrategy || settings.comboStrategy || "fallback";
const augmentedModels = augmentModelsWithCapacityAdapter(comboModels, requiredCapabilities, settings);
const adapterAdded = augmentedModels.filter((m) => !comboModels.includes(m));
if (comboStrategy === "fusion") {
log.info(
"CHAT",
`Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`,
);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m, isPanel) => {
let cleanRawReq = clientRawRequest;
if (isPanel && clientRawRequest) {
const { tools, tool_choice, ...cleanBody } =
clientRawRequest.body || {};
cleanRawReq = { ...clientRawRequest, body: cleanBody };
}
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
},
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
if (comboStrategy === "fusion") {
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m, isPanel) => {
let cleanRawReq = clientRawRequest;
if (isPanel && clientRawRequest) {
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
cleanRawReq = { ...clientRawRequest, body: cleanBody };
}
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
},
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info(
"CHAT",
`Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`,
);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) =>
handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit,
});
}
const comboStickyLimit = settings.comboStickyRoundRobinLimit;
log.info("CHAT", `Combo "${modelStr}" with ${augmentedModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
body,
models: augmentedModels,
handleSingleModel: withCapacityAdapterStripping(
(b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
adapterAdded
),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit
});
}
// Single model request
return handleSingleModelChat(
body,
modelStr,
clientRawRequest,
request,
apiKey,
);
// Single model request — may still switch to a capacity-adapter model if the
// target lacks a capability the request needs (e.g. no vision, request has an image).
const soloAugmented = augmentModelsWithCapacityAdapter([modelStr], requiredCapabilities, settings);
if (soloAugmented.length > 1) {
const adapterAdded = soloAugmented.filter((m) => m !== modelStr);
log.info("CHAT", `Capacity adapter for [${[...requiredCapabilities].join(",")}] on "${modelStr}" → trying ${soloAugmented.join(", ")}`);
return handleComboChat({
body,
models: soloAugmented,
handleSingleModel: withCapacityAdapterStripping(
(b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
adapterAdded
),
log,
comboName: modelStr,
comboStrategy: getActiveAdapterStrategy(requiredCapabilities, settings)
});
}
return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey);
}
/**
* Handle single model chat request
*/
async function handleSingleModelChat(
body,
modelStr,
clientRawRequest = null,
request = null,
apiKey = null,
) {
const modelInfo = await getModelInfo(modelStr);
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) {
const modelInfo = await getModelInfo(modelStr);
// If provider is null, this might be a combo name - check and handle
if (!modelInfo.provider) {
const comboModels = await getComboModels(modelStr);
if (comboModels) {
const chatSettings = await getSettings();
// Check for combo-specific strategy first, fallback to global
const comboStrategies = chatSettings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy =
comboSpecificStrategy || chatSettings.comboStrategy || "fallback";
// If provider is null, this might be a combo name - check and handle
if (!modelInfo.provider) {
const comboModels = await getComboModels(modelStr);
if (comboModels) {
const chatSettings = await getSettings();
// Check for combo-specific strategy first, fallback to global
const comboStrategies = chatSettings.comboStrategies || {};
const comboSpecificStrategy = comboStrategies[modelStr]?.fallbackStrategy;
const comboStrategy = comboSpecificStrategy || chatSettings.comboStrategy || "fallback";
const requiredCapabilities = detectRequiredCapabilities(body);
const augmentedModels = augmentModelsWithCapacityAdapter(comboModels, requiredCapabilities, chatSettings);
const adapterAdded = augmentedModels.filter((m) => !comboModels.includes(m));
if (comboStrategy === "fusion") {
log.info(
"CHAT",
`Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`,
);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m, isPanel) => {
let cleanRawReq = clientRawRequest;
if (isPanel && clientRawRequest) {
const { tools, tool_choice, ...cleanBody } =
clientRawRequest.body || {};
cleanRawReq = { ...clientRawRequest, body: cleanBody };
}
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
},
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
if (comboStrategy === "fusion") {
log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models (strategy: fusion)`);
return handleFusionChat({
body,
models: comboModels,
handleSingleModel: (b, m, isPanel) => {
let cleanRawReq = clientRawRequest;
if (isPanel && clientRawRequest) {
const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {};
cleanRawReq = { ...clientRawRequest, body: cleanBody };
}
return handleSingleModelChat(b, m, cleanRawReq, request, apiKey);
},
log,
comboName: modelStr,
judgeModel: comboStrategies[modelStr]?.judgeModel,
tuning: comboStrategies[modelStr]?.fusionTuning,
});
}
const comboStickyLimit = chatSettings.comboStickyRoundRobinLimit;
log.info(
"CHAT",
`Combo "${modelStr}" with ${comboModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`,
);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) =>
handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit,
});
}
log.warn("CHAT", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
const comboStickyLimit = chatSettings.comboStickyRoundRobinLimit;
log.info("CHAT", `Combo "${modelStr}" with ${augmentedModels.length} models (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
return handleComboChat({
body,
models: augmentedModels,
handleSingleModel: withCapacityAdapterStripping(
(b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
adapterAdded
),
log,
comboName: modelStr,
comboStrategy,
comboStickyLimit
});
}
log.warn("CHAT", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
const { provider, model } = modelInfo;
const { provider, model } = modelInfo;
// Routing shown in the unified "▶" line (client model → provider/model)
// Routing shown in the unified "▶" line (client model → provider/model)
// Extract userAgent from request
const userAgent = request?.headers?.get("user-agent") || "";
// Optional pin to a specific connection (dashboard test / client override)
const preferredConnectionId =
request?.headers?.get("x-connection-id") || null;
// Extract userAgent from request
const userAgent = request?.headers?.get("user-agent") || "";
// Try with available accounts (fallback on errors unless pinned)
const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
// Try with available accounts (fallback on errors)
const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(
provider,
excludeConnectionIds,
model,
{ preferredConnectionId },
);
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status =
lastStatus ||
Number(credentials.lastErrorCode) ||
HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn(
"CHAT",
`[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`,
);
return unavailableResponse(
status,
`[${provider}/${model}] ${errorMsg}`,
credentials.retryAfter,
credentials.retryAfterHuman,
);
}
if (excludeConnectionIds.size === 0) {
log.warn("AUTH", `No active credentials for provider: ${provider}`);
return errorResponse(
HTTP_STATUS.NOT_FOUND,
`No active credentials for provider: ${provider}`,
);
}
log.warn("CHAT", "No more accounts available", { provider });
return errorResponse(
lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE,
lastError || "All accounts unavailable",
);
}
// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (excludeConnectionIds.size === 0) {
log.warn("AUTH", `No active credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`);
}
log.warn("CHAT", "No more accounts available", { provider });
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
// Account selection shown in the unified "▶" line (acc:...)
const refreshedCredentials = await checkAndRefreshToken(
provider,
credentials,
);
// Account selection shown in the unified "▶" line (acc:...)
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
// Ensure real project ID is available for providers that need it (P0 fix: cold miss)
if (
(provider === "antigravity" || provider === "gemini-cli") &&
!refreshedCredentials.projectId
) {
const pid = await getProjectIdForConnection(
credentials.connectionId,
refreshedCredentials.accessToken,
provider,
);
if (pid) {
refreshedCredentials.projectId = pid;
// Persist to DB in background so subsequent requests have it immediately
updateProviderCredentials(credentials.connectionId, {
projectId: pid,
}).catch(() => {});
}
}
// Ensure real project ID is available for providers that need it (P0 fix: cold miss)
if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) {
const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider);
if (pid) {
refreshedCredentials.projectId = pid;
// Persist to DB in background so subsequent requests have it immediately
updateProviderCredentials(credentials.connectionId, { projectId: pid }).catch(() => { });
}
}
// Use shared chatCore
const chatSettings = await getSettings();
const providerThinking =
(chatSettings.providerThinking || {})[provider] || null;
const result = await handleChatCore({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
clientRawRequest,
connectionId: credentials.connectionId,
userAgent,
apiKey,
ccFilterNaming: !!chatSettings.ccFilterNaming,
rtkEnabled: !!chatSettings.rtkEnabled,
headroomEnabled: !!chatSettings.headroomEnabled,
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
cavemanEnabled: !!chatSettings.cavemanEnabled,
cavemanLevel: chatSettings.cavemanLevel || "full",
ponytailEnabled: !!chatSettings.ponytailEnabled,
ponytailLevel: chatSettings.ponytailLevel || "full",
pxpipeEnabled: !!chatSettings.pxpipeEnabled,
pxpipeMinChars: chatSettings.pxpipeMinChars,
pxpipeTimeoutMs: chatSettings.pxpipeTimeoutMs,
// Lazily warms the in-process module on first use; null when not installed (fail-open)
pxpipeTransform: chatSettings.pxpipeEnabled
? await getPxpipeTransform()
: null,
onPxpipeEvent: appendPxpipeEvent,
providerThinking,
streamErrorPatterns: chatSettings.streamErrorPatterns || {},
// Detect source format by endpoint + body
sourceFormatOverride: request?.url
? detectFormatByEndpoint(new URL(request.url).pathname, body)
: null,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
...newCreds,
existingProviderSpecificData: credentials.providerSpecificData,
testStatus: "active",
});
},
onRequestSuccess: async () => {
await clearAccountError(credentials.connectionId, credentials, model);
},
});
// Use shared chatCore
const chatSettings = await getSettings();
const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
const result = await handleChatCore({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
clientRawRequest,
connectionId: credentials.connectionId,
userAgent,
apiKey,
ccFilterNaming: !!chatSettings.ccFilterNaming,
rtkEnabled: !!chatSettings.rtkEnabled,
headroomEnabled: !!chatSettings.headroomEnabled,
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
cavemanEnabled: !!chatSettings.cavemanEnabled,
cavemanLevel: chatSettings.cavemanLevel || "full",
ponytailEnabled: !!chatSettings.ponytailEnabled,
ponytailLevel: chatSettings.ponytailLevel || "full",
pxpipeEnabled: !!chatSettings.pxpipeEnabled,
pxpipeMinChars: chatSettings.pxpipeMinChars,
pxpipeTimeoutMs: chatSettings.pxpipeTimeoutMs,
// Lazily warms the in-process module on first use; null when not installed (fail-open)
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
onPxpipeEvent: appendPxpipeEvent,
providerThinking,
// Detect source format by endpoint + body
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
...newCreds,
existingProviderSpecificData: credentials.providerSpecificData,
testStatus: "active"
});
},
onRequestSuccess: async () => {
await clearAccountError(credentials.connectionId, credentials, model);
}
});
if (result.success) return result.response;
if (result.success) return result.response;
// Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs)
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId,
result.status,
result.error,
provider,
model,
result.resetsAtMs,
);
// Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs)
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs);
if (shouldFallback) {
// When a connection is explicitly pinned, never rotate to another account.
if (preferredConnectionId) {
log.warn(
"AUTH",
`Pinned account ${credentials.connectionName} unavailable (${result.status}), no fallback`,
);
return result.response;
}
log.warn(
"FALLBACK",
`⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`,
);
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
}
if (shouldFallback) {
log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
}
return result.response;
}
return result.response;
}
}

View File

@@ -30,6 +30,7 @@ export async function handleTts(request) {
const modelStr = body.model;
const responseFormat = url.searchParams.get("response_format") || "mp3"; // mp3 (default) | json
const language = body.language || ""; // Optional language hint (currently used by Gemini)
const style = body.style || ""; // Optional style/voice instructions (e.g. Xiaomi MiMo)
log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`);
const settings = await getSettings();
@@ -53,7 +54,7 @@ export async function handleTts(request) {
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language),
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language, style),
log,
comboName: modelStr,
comboStrategy,
@@ -61,10 +62,10 @@ export async function handleTts(request) {
});
}
return handleSingleModelTts(body, modelStr, responseFormat, language);
return handleSingleModelTts(body, modelStr, responseFormat, language, style);
}
async function handleSingleModelTts(body, modelStr, responseFormat, language) {
async function handleSingleModelTts(body, modelStr, responseFormat, language, style) {
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
@@ -73,7 +74,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
// noAuth providers — no credential needed
if (!CREDENTIALED_PROVIDERS.has(provider)) {
const result = await handleTtsCore({ provider, model, input: body.input, responseFormat, language });
const result = await handleTtsCore({ provider, model, input: body.input, responseFormat, language, style });
if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "TTS failed");
}
@@ -98,7 +99,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language });
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
if (result.success) return result.response;

View File

@@ -8,6 +8,15 @@ import * as log from "../utils/logger.js";
// Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve();
const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan";
function githubMonthlyResetMs(status, errorText, provider) {
if (resolveProviderId(provider) !== "github" || Number(status) !== 402) return null;
if (!String(errorText || "").toLowerCase().includes(GITHUB_MONTHLY_USAGE_LIMIT)) return null;
const now = new Date();
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
}
/**
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
@@ -219,9 +228,16 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
const conn = connections.find(c => c.id === connectionId);
const backoffLevel = conn?.backoffLevel || 0;
// GitHub premium-request exhaustion is account-wide until the next UTC month.
const githubResetAtMs = githubMonthlyResetMs(status, errorText, provider);
// Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff
let shouldFallback, cooldownMs, newBackoffLevel;
if (resetsAtMs && resetsAtMs > Date.now()) {
if (githubResetAtMs) {
shouldFallback = true;
cooldownMs = githubResetAtMs - Date.now();
newBackoffLevel = 0;
} else if (resetsAtMs && resetsAtMs > Date.now()) {
shouldFallback = true;
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
newBackoffLevel = 0;
@@ -231,7 +247,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
const lockUpdate = buildModelLockUpdate(model, cooldownMs);
const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
await updateProviderConnection(connectionId, {
...lockUpdate,

View File

@@ -0,0 +1,195 @@
// Background proactive OAuth token refresh — independent of inbound requests.
// Fail-open everywhere: tick errors and per-connection failures never kill the interval.
import * as log from "../utils/logger.js";
import { getRefreshLeadMs } from "open-sse/services/tokenRefresh.js";
import { getCredentialExpiryMs } from "open-sse/services/oauthCredentialManager.js";
/** Refresh when expiry is within 30 minutes (or the provider on-request lead, whichever larger). */
export const BACKGROUND_REFRESH_LEAD_MS = 30 * 60 * 1000;
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
const INITIAL_DELAY_MS = 10 * 1000;
let started = false;
let intervalHandle = null;
let initialTimeoutHandle = null;
let tickRunning = false;
function isTruthyEnv(value) {
if (value == null || value === "") return false;
const v = String(value).trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
function isNonServerRuntime() {
if (typeof window !== "undefined") return true;
const phase = process.env.NEXT_PHASE || "";
if (
phase === "phase-production-build" ||
phase === "phase-export" ||
phase === "phase-static"
) {
return true;
}
// Next.js build / static generation markers
if (process.env.NEXT_RUNTIME === "edge") return true;
return false;
}
/**
* Pure selection: OAuth connections with a refreshToken whose access token
* expires within max(provider on-request lead, BACKGROUND_REFRESH_LEAD_MS).
*
* @param {Array<object>} connections
* @param {number} [nowMs]
* @returns {Array<object>}
*/
export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now()) {
if (!Array.isArray(connections) || connections.length === 0) return [];
const out = [];
for (const conn of connections) {
if (!conn) continue;
const authType = String(conn.authType || "").toLowerCase().replace(/_/g, "");
if (authType !== "oauth") continue;
if (!conn.refreshToken) continue;
const expiresAtMs = getCredentialExpiryMs(conn);
if (expiresAtMs === null) continue;
const providerLead = getRefreshLeadMs(conn.provider);
const leadMs = Math.max(
Number.isFinite(providerLead) ? providerLead : 0,
BACKGROUND_REFRESH_LEAD_MS
);
if (expiresAtMs - nowMs < leadMs) {
out.push(conn);
}
}
return out;
}
async function loadActiveConnections() {
// Dynamic import avoids circular load with db / app graph at module eval time.
const { getProviderConnections } = await import("../../lib/db/repos/connectionsRepo.js");
return getProviderConnections({ isActive: true });
}
async function refreshOne(connection) {
const { checkAndRefreshToken } = await import("./tokenRefresh.js");
return checkAndRefreshToken(connection.provider, connection, { force: true });
}
/**
* One scheduler tick. Fail-open at top level and per connection.
* @param {{ loadConnections?: Function, refreshConnection?: Function }} [deps]
*/
export async function runBackgroundTokenRefreshTick(deps = {}) {
if (tickRunning) {
log.debug("BG_TOKEN_REFRESH", "Tick already running, skip");
return;
}
tickRunning = true;
try {
const load = deps.loadConnections || loadActiveConnections;
const refresh = deps.refreshConnection || refreshOne;
const connections = await load();
const due = selectConnectionsNeedingRefresh(connections, Date.now());
if (due.length === 0) {
log.debug("BG_TOKEN_REFRESH", "No connections due for refresh", {
active: Array.isArray(connections) ? connections.length : 0,
});
return;
}
log.info("BG_TOKEN_REFRESH", "Refreshing due OAuth connections", {
due: due.length,
ids: due.map((c) => c.id).filter(Boolean),
});
await Promise.allSettled(
due.map(async (conn) => {
try {
await refresh(conn);
log.info("BG_TOKEN_REFRESH", "Connection refresh finished", {
id: conn.id,
provider: conn.provider,
});
} catch (err) {
log.warn("BG_TOKEN_REFRESH", "Connection refresh failed (swallowed)", {
id: conn?.id,
provider: conn?.provider,
error: err?.message ?? String(err),
});
}
})
);
} catch (err) {
log.warn("BG_TOKEN_REFRESH", "Tick failed (swallowed)", {
error: err?.message ?? String(err),
});
} finally {
tickRunning = false;
}
}
/**
* Start the background interval. Safe to call multiple times (no-op if already started).
* @param {{ intervalMs?: number }} [opts]
* @returns {boolean} true if started this call
*/
export function startBackgroundTokenRefresh({ intervalMs } = {}) {
if (started) return false;
if (isTruthyEnv(process.env.DISABLE_BACKGROUND_TOKEN_REFRESH)) {
log.info("BG_TOKEN_REFRESH", "Disabled via DISABLE_BACKGROUND_TOKEN_REFRESH");
return false;
}
if (isNonServerRuntime()) {
log.debug("BG_TOKEN_REFRESH", "Skip start outside long-running server runtime");
return false;
}
started = true;
const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS;
const safeTick = () => {
runBackgroundTokenRefreshTick().catch((err) => {
log.warn("BG_TOKEN_REFRESH", "Unhandled tick rejection (swallowed)", {
error: err?.message ?? String(err),
});
});
};
// First pass soon after boot so idle connections don't wait a full interval.
initialTimeoutHandle = setTimeout(safeTick, INITIAL_DELAY_MS);
if (initialTimeoutHandle.unref) initialTimeoutHandle.unref();
intervalHandle = setInterval(safeTick, period);
if (intervalHandle.unref) intervalHandle.unref();
log.info("BG_TOKEN_REFRESH", "Scheduler started", {
intervalMs: period,
initialDelayMs: INITIAL_DELAY_MS,
leadMs: BACKGROUND_REFRESH_LEAD_MS,
});
return true;
}
export function stopBackgroundTokenRefresh() {
if (initialTimeoutHandle) {
clearTimeout(initialTimeoutHandle);
initialTimeoutHandle = null;
}
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
if (started) {
started = false;
log.info("BG_TOKEN_REFRESH", "Scheduler stopped");
}
}

View File

@@ -11,7 +11,6 @@ import {
refreshAccessToken as _refreshAccessToken,
refreshClaudeOAuthToken as _refreshClaudeOAuthToken,
refreshGoogleToken as _refreshGoogleToken,
refreshQwenToken as _refreshQwenToken,
refreshCodexToken as _refreshCodexToken,
refreshIflowToken as _refreshIflowToken,
refreshGitHubToken as _refreshGitHubToken,
@@ -41,9 +40,6 @@ export const refreshClaudeOAuthToken = (refreshToken) =>
export const refreshGoogleToken = (refreshToken, clientId, clientSecret) =>
_refreshGoogleToken(refreshToken, clientId, clientSecret, log);
export const refreshQwenToken = (refreshToken) =>
_refreshQwenToken(refreshToken, log);
export const refreshCodexToken = (refreshToken) =>
_refreshCodexToken(refreshToken, log);
@@ -216,16 +212,20 @@ export async function updateProviderCredentials(connectionId, newCredentials) {
*
* @param {string} provider
* @param {object} credentials
* @param {{ force?: boolean }} [options] force=true skips the on-request lead check
* (used by background scheduler which applies a larger lead). Request path omits this.
* @returns {Promise<object>} updated credentials object
*/
export async function checkAndRefreshToken(provider, credentials) {
export async function checkAndRefreshToken(provider, credentials, options = {}) {
let creds = { ...credentials };
if (!creds.connectionId && creds.id) {
creds.connectionId = creds.id;
}
const force = options?.force === true;
// ── 1. Regular access-token expiry ────────────────────────────────────────
if (_shouldRefreshCredentials(provider, creds)) {
if (force || _shouldRefreshCredentials(provider, creds)) {
const expiresAt = creds.expiresAt ? new Date(creds.expiresAt).getTime() : null;
const remaining = expiresAt ? expiresAt - Date.now() : null;
const refreshLead = _getRefreshLeadMs(provider);