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:
kang-heewon
2026-04-23 14:02:45 +09:00
parent 08d0e9f8b4
commit bdfcec7cf7
13 changed files with 869 additions and 65 deletions

View File

@@ -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 = {

View File

@@ -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"

View File

@@ -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>

View File

@@ -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);

View File

@@ -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.",

View File

@@ -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": "아직 활성 속도 제한기가 없습니다.",

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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())