mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
feat: provider/account 단위 동시성 cap 추가
- DB 마이그레이션 028: provider_connections.max_concurrent 컬럼 추가 - AccountSemaphore: 계정별 FIFO 세마포어 (acquire/release/timeout/block) - chatCore.ts: 요청 파이프라인에 선제적 cap enforcement 통합 - providers.ts: maxConround-trip round-trip 저장/조회, cleanNulls() 보정 - API: provider limits route에서 maxConcurrent GET/PUT 지원 - UI: provider 연결 상세 페이지 account native cap 입력 필드 + hint - UI: ResilienceTab combo concurrency 라벨 구분 (combo vs account) - i18n: en/ko 번역 키 추가 - schemas.ts: maxConcurrent 음수 검증 + null 허용 - 테스트: semaphore 6개, DB round-trip + validation 6개
This commit is contained in:
@@ -142,8 +142,10 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
retryDelay: "Initial delay between retries. Higher values reduce burst pressure.",
|
||||
timeout: "Maximum request time before aborting. Set higher for long generations.",
|
||||
healthcheck: "Skips unhealthy models/providers from routing decisions when enabled.",
|
||||
concurrencyPerModel: "Max simultaneous requests sent to each model in round-robin.",
|
||||
queueTimeout: "How long a request can wait in queue before timeout in round-robin.",
|
||||
concurrencyPerModel:
|
||||
"Round-robin combo/model limit: max simultaneous requests sent to each model target. This is separate from any provider account-only cap.",
|
||||
queueTimeout:
|
||||
"How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.",
|
||||
};
|
||||
|
||||
const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
|
||||
@@ -542,6 +542,7 @@ interface EditConnectionModalConnection {
|
||||
name?: string;
|
||||
email?: string;
|
||||
priority?: number;
|
||||
maxConcurrent?: number | null;
|
||||
authType?: string;
|
||||
provider?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
@@ -5807,6 +5808,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
priority: 1,
|
||||
maxConcurrent: "",
|
||||
apiKey: "",
|
||||
healthCheckInterval: 60,
|
||||
baseUrl: "",
|
||||
@@ -5871,6 +5873,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
maxConcurrent:
|
||||
connection.maxConcurrent === null || connection.maxConcurrent === undefined
|
||||
? ""
|
||||
: String(connection.maxConcurrent),
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
baseUrl: existingBaseUrl || defaultBaseUrl,
|
||||
@@ -5963,9 +5969,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const trimmedMaxConcurrent = formData.maxConcurrent.trim();
|
||||
let parsedMaxConcurrent: number | null = null;
|
||||
if (trimmedMaxConcurrent) {
|
||||
const numericMaxConcurrent = Number(trimmedMaxConcurrent);
|
||||
if (!Number.isInteger(numericMaxConcurrent) || numericMaxConcurrent < 0) {
|
||||
setSaveError("Max concurrent must be a whole number greater than or equal to 0.");
|
||||
return;
|
||||
}
|
||||
parsedMaxConcurrent = numericMaxConcurrent;
|
||||
}
|
||||
|
||||
const updates: any = {
|
||||
name: formData.name,
|
||||
priority: formData.priority,
|
||||
maxConcurrent: parsedMaxConcurrent,
|
||||
healthCheckInterval: formData.healthCheckInterval,
|
||||
};
|
||||
|
||||
@@ -6211,6 +6229,30 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t("accountConcurrencyCapLabel")}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={formData.maxConcurrent}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setFormData({ ...formData, maxConcurrent: nextValue });
|
||||
if (saveError && nextValue.trim()) {
|
||||
const numericValue = Number(nextValue);
|
||||
if (Number.isInteger(numericValue) && numericValue >= 0) {
|
||||
setSaveError(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
hint={t("accountConcurrencyCapHint")}
|
||||
/>
|
||||
{saveError && (
|
||||
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
@@ -6256,11 +6298,6 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
</Badge>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
|
||||
@@ -224,7 +224,11 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
|
||||
{[
|
||||
{ key: "requestsPerMinute", label: t("rpm") },
|
||||
{ key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs },
|
||||
{ key: "concurrentRequests", label: t("maxConcurrent") },
|
||||
{
|
||||
key: "concurrentRequests",
|
||||
label: t("comboConcurrencyLabel"),
|
||||
hint: t("comboConcurrencyHint"),
|
||||
},
|
||||
].map(({ key, label, format }) => (
|
||||
<div key={key}>
|
||||
{editMode ? (
|
||||
@@ -243,6 +247,11 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-text-muted">{label}</div>
|
||||
{key === "concurrentRequests" && (
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-text-muted">
|
||||
{t("comboConcurrencyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
rateLimitedUntil,
|
||||
lastTested,
|
||||
healthCheckInterval,
|
||||
maxConcurrent,
|
||||
providerSpecificData: incomingPsd,
|
||||
} = body;
|
||||
|
||||
@@ -139,6 +140,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
if (rateLimitedUntil !== undefined) updateData.rateLimitedUntil = rateLimitedUntil;
|
||||
if (lastTested !== undefined) updateData.lastTested = lastTested;
|
||||
if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval;
|
||||
if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent;
|
||||
|
||||
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
|
||||
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {
|
||||
@@ -160,7 +162,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
updateData.providerSpecificData =
|
||||
normalizeProviderSpecificData(existing.provider, mergedPsd) || {};
|
||||
normalizeProviderSpecificData(existing.provider as string | null | undefined, mergedPsd) ||
|
||||
{};
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
@@ -1896,6 +1896,8 @@
|
||||
"save": "Save",
|
||||
"editConnection": "Edit Connection",
|
||||
"accountName": "Account name",
|
||||
"accountConcurrencyCapLabel": "Provider account max concurrent requests",
|
||||
"accountConcurrencyCapHint": "Maximum simultaneous requests for this provider account. Leave empty or set 0 for no limit. Helps prevent limits enforced by the provider itself before requests fail.",
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
|
||||
@@ -2303,6 +2305,8 @@
|
||||
"defaultSafetyNet": "Default Safety Net",
|
||||
"rpm": "RPM",
|
||||
"minGap": "Min Gap",
|
||||
"comboConcurrencyLabel": "Combo round-robin max concurrent requests per model",
|
||||
"comboConcurrencyHint": "Maximum simultaneous requests per model during combo round-robin. This setting only applies to combo strategies.",
|
||||
"maxConcurrent": "Max Concurrent",
|
||||
"activeLimiters": "Active Limiters",
|
||||
"noActiveLimiters": "No active rate limiters yet.",
|
||||
|
||||
@@ -1845,6 +1845,8 @@
|
||||
"save": "저장",
|
||||
"editConnection": "연결 편집",
|
||||
"accountName": "계정 이름",
|
||||
"accountConcurrencyCapLabel": "Provider 계정 최대 동시 요청 수",
|
||||
"accountConcurrencyCapHint": "이 provider 계정의 최대 동시 요청 수입니다. 비워 두거나 0으로 설정하면 제한이 없습니다. provider 자체가 부과하는 제한에 먼저 걸리지 않도록 돕습니다.",
|
||||
"email": "이메일",
|
||||
"healthCheckMinutes": "상태 점검(분)",
|
||||
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
|
||||
@@ -2215,6 +2217,8 @@
|
||||
"defaultSafetyNet": "기본 안전망",
|
||||
"rpm": "RPM",
|
||||
"minGap": "민갭",
|
||||
"comboConcurrencyLabel": "콤보 라운드로빈 모델별 최대 동시 요청 수",
|
||||
"comboConcurrencyHint": "Combo 라운드로빈에서 모델별 최대 동시 요청 수입니다. 이 설정은 combo 전략에만 적용됩니다.",
|
||||
"maxConcurrent": "최대 동시",
|
||||
"activeLimiters": "활성 리미터",
|
||||
"noActiveLimiters": "아직 활성 속도 제한기가 없습니다.",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 028_provider_connection_max_concurrent.sql
|
||||
-- Adds account-native concurrency cap to provider_connections.
|
||||
-- This defines the maximum concurrent requests allowed for a specific account (connection).
|
||||
-- Coexists with existing combo-level concurrencyPerModel, which is separate.
|
||||
|
||||
-- Add max_concurrent column (NULL = unlimited, uses provider defaults or combo rules)
|
||||
ALTER TABLE provider_connections ADD COLUMN max_concurrent INTEGER;
|
||||
|
||||
-- Index for provider-level filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_pc_max_concurrent ON provider_connections(provider, max_concurrent);
|
||||
@@ -21,6 +21,26 @@ interface DbLike {
|
||||
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
|
||||
}
|
||||
|
||||
function withNullableMaxConcurrent(
|
||||
record: JsonRecord,
|
||||
source: JsonRecord | null | undefined
|
||||
): JsonRecord {
|
||||
if (!source || !Object.hasOwn(source, "maxConcurrent")) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const sourceMaxConcurrent = source.maxConcurrent;
|
||||
const normalizedMaxConcurrent =
|
||||
typeof sourceMaxConcurrent === "number" || sourceMaxConcurrent === null
|
||||
? sourceMaxConcurrent
|
||||
: record.maxConcurrent;
|
||||
|
||||
return {
|
||||
...record,
|
||||
maxConcurrent: normalizedMaxConcurrent,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" ? (value as JsonRecord) : {};
|
||||
}
|
||||
@@ -56,13 +76,19 @@ export async function getProviderConnections(filter: JsonRecord = {}) {
|
||||
sql += " ORDER BY priority ASC, updated_at DESC";
|
||||
|
||||
const rows = db.prepare(sql).all(params);
|
||||
return rows.map((r) => decryptConnectionFields(cleanNulls(rowToCamel(r))));
|
||||
return rows.map((r) => {
|
||||
const camelRow = rowToCamel(r);
|
||||
return decryptConnectionFields(withNullableMaxConcurrent(cleanNulls(camelRow), camelRow));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProviderConnectionById(id: string) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
|
||||
return row ? decryptConnectionFields(cleanNulls(rowToCamel(row))) : null;
|
||||
if (!row) return null;
|
||||
|
||||
const camelRow = rowToCamel(row);
|
||||
return decryptConnectionFields(withNullableMaxConcurrent(cleanNulls(camelRow), camelRow));
|
||||
}
|
||||
|
||||
export async function createProviderConnection(data: JsonRecord) {
|
||||
@@ -133,7 +159,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
);
|
||||
_updateConnectionRow(db, existingId, merged);
|
||||
backupDbFile("pre-write");
|
||||
return cleanNulls(merged);
|
||||
return withNullableMaxConcurrent(cleanNulls(merged), merged);
|
||||
}
|
||||
|
||||
// Generate name: prefer explicit name, then email, then a stable short-ID label.
|
||||
@@ -195,6 +221,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
"consecutiveUseCount",
|
||||
"rateLimitProtection",
|
||||
"group",
|
||||
"maxConcurrent",
|
||||
];
|
||||
for (const field of optionalFields) {
|
||||
if (data[field] !== undefined && data[field] !== null) {
|
||||
@@ -213,7 +240,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
|
||||
return cleanNulls(connection);
|
||||
return withNullableMaxConcurrent(cleanNulls(connection), connection);
|
||||
}
|
||||
|
||||
function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
@@ -227,7 +254,8 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
rate_limited_until, health_check_interval, last_health_check_at,
|
||||
last_tested, api_key, id_token, provider_specific_data,
|
||||
expires_in, display_name, global_priority, default_model,
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", created_at, updated_at
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", max_concurrent,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @provider, @authType, @name, @email, @priority, @isActive,
|
||||
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
|
||||
@@ -236,7 +264,8 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
|
||||
@lastTested, @apiKey, @idToken, @providerSpecificData,
|
||||
@expiresIn, @displayName, @globalPriority, @defaultModel,
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @createdAt, @updatedAt
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @maxConcurrent,
|
||||
@createdAt, @updatedAt
|
||||
)
|
||||
`
|
||||
).run({
|
||||
@@ -279,6 +308,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: conn.lastUsedAt || null,
|
||||
group: conn.group || null,
|
||||
maxConcurrent: conn.maxConcurrent ?? null,
|
||||
createdAt: conn.createdAt,
|
||||
updatedAt: conn.updatedAt,
|
||||
});
|
||||
@@ -304,6 +334,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
rate_limit_protection = @rateLimitProtection,
|
||||
last_used_at = @lastUsedAt,
|
||||
"group" = @group,
|
||||
max_concurrent = @maxConcurrent,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
`
|
||||
@@ -347,6 +378,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: data.lastUsedAt || null,
|
||||
group: data.group || null,
|
||||
maxConcurrent: data.maxConcurrent ?? null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -378,7 +410,7 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
|
||||
_reorderConnections(db, providerId);
|
||||
}
|
||||
|
||||
return cleanNulls(merged);
|
||||
return withNullableMaxConcurrent(cleanNulls(merged), merged);
|
||||
}
|
||||
|
||||
export async function deleteProviderConnection(id: string) {
|
||||
|
||||
@@ -1406,6 +1406,7 @@ export const updateProviderConnectionSchema = z
|
||||
lastTested: z.union([z.string(), z.null()]).optional(),
|
||||
healthCheckInterval: z.coerce.number().int().min(0).optional(),
|
||||
group: z.union([z.string().max(100), z.null()]).optional(),
|
||||
maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(),
|
||||
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
||||
providerSpecificData: z
|
||||
.record(z.string(), z.unknown())
|
||||
|
||||
Reference in New Issue
Block a user