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:
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
195
src/sse/services/backgroundTokenRefresh.js
Normal file
195
src/sse/services/backgroundTokenRefresh.js
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user