mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
fix(glm): add dedicated coding transport (#2087)
Integrated into release/v3.8.0
This commit is contained in:
@@ -187,14 +187,17 @@ export default function ApiManagerPageClient() {
|
||||
for (const key of apiKeys) {
|
||||
// Match analytics entry by unique API Key ID (isolates usage to this specific key instance)
|
||||
const matches = byApiKey.filter((entry: any) => entry.apiKeyId === key.id);
|
||||
const totalRequests = matches.reduce((sum: number, entry: any) => sum + (Number(entry.requests) || 0), 0);
|
||||
const totalRequests = matches.reduce(
|
||||
(sum: number, entry: any) => sum + (Number(entry.requests) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Match call logs by unique ID as well for the lastUsed timestamp
|
||||
const lastUsed =
|
||||
(logs || []).find((log: any) => log.apiKeyId === key.id)?.timestamp || null;
|
||||
(logs || []).find(
|
||||
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
|
||||
)?.timestamp || null;
|
||||
(logs || []).find(
|
||||
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
|
||||
)?.timestamp || null;
|
||||
|
||||
stats[key.id] = {
|
||||
totalRequests,
|
||||
@@ -358,7 +361,7 @@ export default function ApiManagerPageClient() {
|
||||
expiresAt: string | null,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null,
|
||||
rateLimits: Array<{ limit: number; window: number }> | null
|
||||
rateLimits: Array<{ limit: number; window: number }> | null,
|
||||
scopes: string[]
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
@@ -971,7 +974,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
expiresAt: string | null,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null,
|
||||
rateLimits: Array<{ limit: number; window: number }> | null
|
||||
rateLimits: Array<{ limit: number; window: number }> | null,
|
||||
scopes: string[]
|
||||
) => void;
|
||||
}) {
|
||||
@@ -1141,7 +1144,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
expiresAt || null,
|
||||
maxSessions,
|
||||
schedule,
|
||||
rateLimits.length > 0 ? rateLimits : null
|
||||
rateLimits.length > 0 ? rateLimits : null,
|
||||
manageEnabled ? ["manage"] : []
|
||||
);
|
||||
}, [
|
||||
@@ -1329,7 +1332,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
value={String(rl.limit)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
setRateLimits(prev => {
|
||||
setRateLimits((prev) => {
|
||||
const next = [...prev];
|
||||
next[index].limit = val;
|
||||
return next;
|
||||
@@ -1344,7 +1347,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
value={String(rl.window)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
setRateLimits(prev => {
|
||||
setRateLimits((prev) => {
|
||||
const next = [...prev];
|
||||
next[index].window = val;
|
||||
return next;
|
||||
@@ -1355,7 +1358,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<span className="text-sm text-text-muted shrink-0">sec</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRateLimits(prev => prev.filter((_, i) => i !== index))}
|
||||
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
|
||||
title="Remove limit"
|
||||
>
|
||||
@@ -1522,6 +1525,24 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
Immediately revoke all access. Used for suspected abuse or compromised keys.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={keyIsBanned}
|
||||
onClick={() => setKeyIsBanned((prev) => !prev)}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
|
||||
keyIsBanned
|
||||
? "bg-red-500 text-white shadow-sm"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:bg-black/10 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsBanned ? "block" : "check_circle"}
|
||||
</span>
|
||||
{keyIsBanned ? "Banned" : "Active"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Management API Access Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -1551,7 +1572,9 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Expiration Date</p>
|
||||
<p className="text-xs text-text-muted">Key will automatically stop working after this date.</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Key will automatically stop working after this date.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -1563,7 +1586,16 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Management Access */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management Access</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this API key to manage OmniRoute configuration.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={manageEnabled}
|
||||
onClick={() => setManageEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
|
||||
@@ -3080,7 +3080,9 @@ export default function ProviderDetailPage() {
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
@@ -3164,122 +3166,124 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0">
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={
|
||||
gi === groupKeys.length - 1 && index === groupConns.length - 1
|
||||
}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) =>
|
||||
handleToggleRateLimit(conn.id, enabled)
|
||||
}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => setShowOAuthModal(true, conn)
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue(
|
||||
[conn.name, conn.email],
|
||||
emailsVisible,
|
||||
conn.id
|
||||
),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={
|
||||
gi === groupKeys.length - 1 && index === groupConns.length - 1
|
||||
}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) =>
|
||||
handleToggleRateLimit(conn.id, enabled)
|
||||
}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => setShowOAuthModal(true, conn)
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue(
|
||||
[conn.name, conn.email],
|
||||
emailsVisible,
|
||||
conn.id
|
||||
),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -5668,6 +5672,10 @@ function getProviderBaseUrlPlaceholder(providerId?: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function isGlmProvider(providerId?: string | null) {
|
||||
return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt";
|
||||
}
|
||||
|
||||
function parseRoutingTagsInput(value: string): string[] | undefined {
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
@@ -5723,7 +5731,7 @@ function AddApiKeyModal({
|
||||
const defaultBaseUrl = getProviderBaseUrlDefault(provider);
|
||||
const isVertex = provider === "vertex" || provider === "vertex-partner";
|
||||
const defaultRegion = "us-central1";
|
||||
const isGlm = provider === "glm" || provider === "glmt";
|
||||
const isGlm = isGlmProvider(provider);
|
||||
const isQoder = provider === "qoder";
|
||||
const isCloudflare = provider === "cloudflare-ai";
|
||||
const localProviderMetadata = getLocalProviderMetadata(provider);
|
||||
@@ -6225,7 +6233,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const usesBaseUrl = isBaseUrlConfigurableProvider(connection?.provider);
|
||||
const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider);
|
||||
const isVertex = connection?.provider === "vertex" || connection?.provider === "vertex-partner";
|
||||
const isGlm = connection?.provider === "glm" || connection?.provider === "glmt";
|
||||
const isGlm = isGlmProvider(connection?.provider);
|
||||
const isCloudflare = connection?.provider === "cloudflare-ai";
|
||||
const isCodex = connection?.provider === "codex";
|
||||
const isClaude = connection?.provider === "claude";
|
||||
|
||||
@@ -11,6 +11,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
const planVariants = {
|
||||
free: "default",
|
||||
lite: "primary",
|
||||
pro: "primary",
|
||||
ultra: "success",
|
||||
enterprise: "info",
|
||||
|
||||
@@ -64,6 +64,7 @@ const TIER_FILTERS = [
|
||||
{ key: "ultra", labelKey: "tierUltra" },
|
||||
{ key: "pro", labelKey: "tierPro" },
|
||||
{ key: "plus", labelKey: "tierPlus" },
|
||||
{ key: "lite", label: "Lite" },
|
||||
{ key: "free", labelKey: "tierFree" },
|
||||
{ key: "unknown", labelKey: "tierUnknown" },
|
||||
];
|
||||
@@ -349,6 +350,7 @@ export default function ProviderLimits() {
|
||||
ultra: 0,
|
||||
pro: 0,
|
||||
plus: 0,
|
||||
lite: 0,
|
||||
free: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
@@ -520,7 +522,7 @@ export default function ProviderLimits() {
|
||||
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>{t(tier.labelKey)}</span>
|
||||
<span>{tier.label || t(tier.labelKey)}</span>
|
||||
<span className="opacity-85">{tierCounts[tier.key] || 0}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -632,8 +634,11 @@ export default function ProviderLimits() {
|
||||
const remainingPercentage = Math.round(remainingPercentageRaw);
|
||||
const colors = getBarColor(remainingPercentage);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = formatQuotaLabel(q.name);
|
||||
const shortName = q.displayName || formatQuotaLabel(q.name);
|
||||
const staleAfterReset = q.staleAfterReset === true;
|
||||
const details = Array.isArray(q.details)
|
||||
? q.details.filter((detail) => detail && detail.used > 0)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -674,6 +679,16 @@ export default function ProviderLimits() {
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{details.length > 0 ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
{details
|
||||
.map(
|
||||
(detail) => `${formatQuotaLabel(detail.name)} ${detail.used}`
|
||||
)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Countdown */}
|
||||
{staleAfterReset ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
@@ -796,7 +811,10 @@ export default function ProviderLimits() {
|
||||
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
|
||||
{t("noAccountsForTierFilter")}{" "}
|
||||
<strong>
|
||||
{t(TIER_FILTERS.find((tier) => tier.key === tierFilter)?.labelKey || "tierUnknown")}
|
||||
{(() => {
|
||||
const tier = TIER_FILTERS.find((tier) => tier.key === tierFilter);
|
||||
return tier?.label || t(tier?.labelKey || "tierUnknown");
|
||||
})()}
|
||||
</strong>
|
||||
.
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,16 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
agentic_request_freetrial: "Agentic (Trial)",
|
||||
credits: "AI Credits",
|
||||
models: "Models",
|
||||
"5 Hours Quota": "5 Hours",
|
||||
"Weekly Quota": "Weekly",
|
||||
"Monthly Tools": "Monthly Tools",
|
||||
tokens: "Tokens",
|
||||
time_limit: "Time Limit",
|
||||
mcp_monthly: "Monthly",
|
||||
"search-prime": "Web Search",
|
||||
"web-reader": "Web Reader",
|
||||
zread: "Zread",
|
||||
};
|
||||
|
||||
const GLM_QUOTA_ORDER: Record<string, number> = {
|
||||
session: 0,
|
||||
weekly: 1,
|
||||
mcp_monthly: 2,
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -202,9 +207,10 @@ export function parseQuotaData(provider, data) {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
|
||||
const normalizedQuotas = [];
|
||||
const providerId = String(provider || "").toLowerCase();
|
||||
|
||||
try {
|
||||
switch (provider.toLowerCase()) {
|
||||
switch (providerId) {
|
||||
case "github":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
@@ -216,6 +222,21 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "glm-cn":
|
||||
case "glmt":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
displayName: quota?.displayName,
|
||||
details: Array.isArray(quota?.details) ? quota.details : undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "antigravity":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
@@ -361,6 +382,14 @@ export function parseQuotaData(provider, data) {
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "glm" || providerId === "glm-cn" || providerId === "glmt") {
|
||||
normalizedQuotas.sort((a, b) => {
|
||||
const orderA = GLM_QUOTA_ORDER[a.name] ?? 99;
|
||||
const orderB = GLM_QUOTA_ORDER[b.name] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedQuotas;
|
||||
}
|
||||
|
||||
@@ -392,7 +421,7 @@ export function resolvePlanValue(plan, providerSpecificData) {
|
||||
|
||||
/**
|
||||
* Normalize provider-specific plan labels into a shared tier taxonomy.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, plus, free, unknown.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown.
|
||||
*/
|
||||
export function normalizePlanTier(plan) {
|
||||
const raw = typeof plan === "string" ? plan.trim() : "";
|
||||
@@ -455,10 +484,18 @@ export function normalizePlanTier(plan) {
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("MAX")) {
|
||||
return { key: "ultra", label: "Max", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("PRO") || upper.includes("PREMIUM")) {
|
||||
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("LITE") || upper.includes("LIGHT")) {
|
||||
return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("PLUS") || upper.includes("PAID")) {
|
||||
return { key: "plus", label: "Plus", variant: "success", rank: 2, raw };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
|
||||
import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts";
|
||||
import { getGlmModelsUrl } from "@omniroute/open-sse/config/glmProvider.ts";
|
||||
import {
|
||||
buildGlmCodingHeaders,
|
||||
buildGlmModelsUrl,
|
||||
} from "@omniroute/open-sse/config/glmProvider.ts";
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts";
|
||||
@@ -1551,40 +1554,73 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "glm" || provider === "glmt") {
|
||||
if (provider === "glm" || provider === "glm-cn" || provider === "glmt") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const url = getGlmModelsUrl(connection.providerSpecificData);
|
||||
const token = apiKey || accessToken;
|
||||
const glmProviderSpecificData = {
|
||||
...asRecord(connection.providerSpecificData),
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
};
|
||||
const discoveredTargets = [
|
||||
{
|
||||
transport: "openai" as const,
|
||||
url: buildGlmModelsUrl(glmProviderSpecificData, "openai"),
|
||||
},
|
||||
{
|
||||
transport: "anthropic" as const,
|
||||
url: buildGlmModelsUrl(glmProviderSpecificData, "anthropic"),
|
||||
},
|
||||
];
|
||||
const discoveryTargets = discoveredTargets.filter(
|
||||
(target, index, all) => all.findIndex((other) => other.url === target.url) === index
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await safeOutboundFetch(url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
for (const target of discoveryTargets) {
|
||||
response = await safeOutboundFetch(target.url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers:
|
||||
target.transport === "openai"
|
||||
? token
|
||||
? buildGlmCodingHeaders(token, false)
|
||||
: { "Content-Type": "application/json", Accept: "application/json" }
|
||||
: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(token ? { "x-api-key": token } : {}),
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
});
|
||||
if (response.ok) break;
|
||||
if (response.status === 401 || response.status === 403) break;
|
||||
}
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error);
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
const fallback = buildDiscoveryFallbackResponse();
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
{ error: `Failed to fetch models: ${response?.status || 502}` },
|
||||
{ status: response?.status || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -269,8 +269,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash FROM api_keys WHERE key = ? OR key_hash = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||
}
|
||||
@@ -459,7 +458,6 @@ async function hashKey(key: string): Promise<string> {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
export async function createApiKey(name: string, machineId: string) {
|
||||
export async function createApiKey(name: string, machineId: string, scopes: string[] = []) {
|
||||
if (!machineId) {
|
||||
throw new Error("machineId is required");
|
||||
@@ -493,7 +491,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
|
||||
0,
|
||||
apiKey.createdAt,
|
||||
apiKey.key.slice(0, 12),
|
||||
await hashKey(apiKey.key)
|
||||
await hashKey(apiKey.key),
|
||||
JSON.stringify(scopes)
|
||||
);
|
||||
setNoLog(apiKey.id, false);
|
||||
@@ -584,8 +582,6 @@ export async function updateApiKeyPermissions(
|
||||
rateLimits: update.rateLimits,
|
||||
isBanned: update.isBanned,
|
||||
expiresAt: update.expiresAt,
|
||||
maxSessions: (update as { maxSessions?: number | null; expiresAt?: string | null })
|
||||
.maxSessions,
|
||||
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
|
||||
scopes: (update as { scopes?: string[] | null }).scopes,
|
||||
};
|
||||
@@ -603,7 +599,6 @@ export async function updateApiKeyPermissions(
|
||||
normalized.rateLimits === undefined &&
|
||||
normalized.isBanned === undefined &&
|
||||
normalized.expiresAt === undefined &&
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined &&
|
||||
(normalized as Record<string, unknown>).scopes === undefined
|
||||
) {
|
||||
@@ -734,7 +729,9 @@ export async function updateApiKeyPermissions(
|
||||
|
||||
// Also invalidate Redis if key_hash is available
|
||||
try {
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as { key_hash: string | null } | undefined;
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as
|
||||
| { key_hash: string | null }
|
||||
| undefined;
|
||||
if (row?.key_hash) {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
@@ -938,7 +935,6 @@ export async function getApiKeyMetadata(
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
ipAllowlist: [],
|
||||
scopes: [],
|
||||
isBanned: false,
|
||||
keyHash: null,
|
||||
scopes: ["manage"],
|
||||
|
||||
@@ -239,7 +239,11 @@ export async function resolveComboForModel(
|
||||
const regex = globToRegex(row.pattern);
|
||||
if (regex.test(modelStr)) {
|
||||
try {
|
||||
return JSON.parse(row.combo_data);
|
||||
const combo = JSON.parse(row.combo_data) as Record<string, unknown>;
|
||||
if (combo.isActive === false) {
|
||||
continue;
|
||||
}
|
||||
return combo;
|
||||
} catch {
|
||||
// Corrupted combo data — skip
|
||||
continue;
|
||||
|
||||
@@ -259,7 +259,10 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
|
||||
contextWindow,
|
||||
maxInputTokens: synced?.limit_input ?? contextWindow,
|
||||
maxOutputTokens:
|
||||
synced?.limit_output ?? spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens,
|
||||
synced?.limit_output ??
|
||||
(typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ??
|
||||
spec?.maxOutputTokens ??
|
||||
MODEL_SPECS.__default__.maxOutputTokens,
|
||||
defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0,
|
||||
thinkingBudgetCap: spec?.thinkingBudgetCap ?? null,
|
||||
thinkingOverhead: spec?.thinkingOverhead ?? null,
|
||||
|
||||
@@ -46,7 +46,7 @@ interface ProviderConnectionLike {
|
||||
|
||||
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"glm",
|
||||
"zai",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -1948,7 +1948,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"claude",
|
||||
"kimi-coding",
|
||||
"glm",
|
||||
"zai",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -1469,7 +1469,16 @@ export const updateKeyPermissionsSchema = z
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
maxSessions: z.number().int().min(0).max(10000).optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
rateLimits: z.union([z.array(z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })).max(50), z.null()]).optional(),
|
||||
rateLimits: z
|
||||
.union([
|
||||
z
|
||||
.array(
|
||||
z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })
|
||||
)
|
||||
.max(50),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -1484,7 +1493,7 @@ export const updateKeyPermissionsSchema = z
|
||||
value.expiresAt === undefined &&
|
||||
value.maxSessions === undefined &&
|
||||
value.accessSchedule === undefined &&
|
||||
value.rateLimits === undefined
|
||||
value.rateLimits === undefined &&
|
||||
value.scopes === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -855,7 +855,7 @@ async function handleSingleModelChat(
|
||||
return result.response;
|
||||
}
|
||||
|
||||
if (result.errorType === "stream_readiness_timeout") {
|
||||
if (result.errorType === "stream_timeout" || result.errorType === "stream_early_eof") {
|
||||
// Stream readiness timeout is an upstream stall, not an account/quota failure.
|
||||
// Do NOT mark the account as unavailable or trip the circuit breaker.
|
||||
return result.response;
|
||||
|
||||
@@ -1489,7 +1489,7 @@ export async function markAccountUnavailable(
|
||||
model,
|
||||
"forbidden",
|
||||
status,
|
||||
effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.unavailable,
|
||||
effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.serviceUnavailable,
|
||||
effectiveProviderProfile
|
||||
);
|
||||
updateProviderConnection(connectionId, {
|
||||
@@ -1505,7 +1505,11 @@ export async function markAccountUnavailable(
|
||||
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
|
||||
}
|
||||
|
||||
const terminalStatus = resolveTerminalConnectionStatus(status, result, providerErrorType);
|
||||
const terminalStatus = resolveTerminalConnectionStatus(
|
||||
status,
|
||||
result as { permanent?: boolean; creditsExhausted?: boolean },
|
||||
providerErrorType
|
||||
);
|
||||
const cooldownMs = terminalStatus ? 0 : rawCooldownMs;
|
||||
|
||||
// ── 404 model-only lockout: connection stays active ──
|
||||
@@ -1605,7 +1609,7 @@ export async function markAccountUnavailable(
|
||||
// NOTE: For permanent bans we disable immediately — no threshold needed,
|
||||
// because a permanent ban (403 "Verify your account" / ToS violation) will
|
||||
// NEVER recover, so retrying is pointless regardless of attempt count.
|
||||
if (result.permanent) {
|
||||
if ((result as { permanent?: boolean }).permanent) {
|
||||
try {
|
||||
const settings = await getCachedSettings();
|
||||
const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false;
|
||||
|
||||
Reference in New Issue
Block a user