From 99282e1054f6fb3fd1117a6438cba6fa2ea53869 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:05:16 -0300 Subject: [PATCH 01/15] fix(sse): pin the ok variant of the non-streaming leg result in chatCore (#12963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base-red: `API Route Typecheck` falhava no tip com 13 TS2339 novos em `chatCore.ts`, vindos do #12867 — que eu mergeei validando só com `typecheck:core`, que não cobre esse arquivo. Causa: `legResult` é a união `NonStreamingProviderLegResult`; o guard de erro estreita para a variante `ok`, mas a reatribuição condicional do tool loop devolve o tipo declarado e as 13 leituras seguintes perdem a narrowing. Corrigido fixando a variante num binding próprio — `loopApply.leg` já é `& { kind: "ok" }`, então sem cast. Gate: 302 erros com 13 novos → **289, todos dentro da baseline congelada**. `typecheck:core` limpo, ESLint 0 no arquivo. Dois commits: o primeiro é Prettier puro sobre o arquivo do tip (que chegou fora do padrão pelo #12867), verificado byte a byte contra `prettier(tip)`; o segundo é a mudança semântica, 39 linhas. Os demais vermelhos deste PR são herdados e cobertos por #12990, #12964 e #12970. --- .../fixes/chatcore-legresult-narrowing.md | 1 + config/quality/eslint-suppressions.json | 5 - open-sse/handlers/chatCore.ts | 2974 +++++++++-------- 3 files changed, 1498 insertions(+), 1482 deletions(-) create mode 100644 changelog.d/fixes/chatcore-legresult-narrowing.md diff --git a/changelog.d/fixes/chatcore-legresult-narrowing.md b/changelog.d/fixes/chatcore-legresult-narrowing.md new file mode 100644 index 0000000000..ae8c7f084d --- /dev/null +++ b/changelog.d/fixes/chatcore-legresult-narrowing.md @@ -0,0 +1 @@ +- Restore the API-route typecheck gate: the non-streaming leg result lost its discriminated-union narrowing after the server-owned tool loop reassignment, producing 13 new TS2339 diagnostics in `chatCore.ts`. diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index e10d1f6551..2074040010 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -215,11 +215,6 @@ "count": 1 } }, - "open-sse/handlers/chatCore.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 26 - } - }, "open-sse/handlers/chatCore/executorHelpers.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 63395b47ad..96ff6a260d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -111,7 +111,7 @@ import { import { recoverAnthropicThinkingSignature } from "./chatCore/thinkingSignatureRecovery.ts"; import { runProviderExecutionPipeline } from "./chatCore/providerExecutionPipeline.ts"; import { runNonStreamingProviderLeg } from "./chatCore/nonStreamingProviderLeg.ts"; -import type { ChatCoreErrorResult } from "@/lib/skills/toolLoopTypes.ts"; +import type { NonStreamingProviderLegResult } from "@/lib/skills/toolLoopTypes.ts"; import { applyServerOwnedToolLoopIfNeeded, derivePostInjectionRequestIdentity, @@ -258,10 +258,7 @@ import { resolveResilienceSettings, isStreamRecoveryExplicitlyConfigured, } from "@/lib/resilience/settings"; -import { - classifyProviderError, - PROVIDER_ERROR_TYPES, -} from "../services/errorClassifier.ts"; +import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts"; import { updateProviderConnection, getProviderConnectionById } from "@/lib/db/providers"; import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { connectionHasExtraKeys } from "../services/apiKeyRotator.ts"; @@ -3626,778 +3623,666 @@ export async function handleChatCore({ let pipelineRecovered = false; if (stream) { - try { - const pipelineOutcome = await runProviderExecutionPipeline({ - policy: { - allowAccountRotation: !managedLease && comboStrategy !== "context-relay", - allowModelFallback: true, - expectedConnectionId: managedLease - ? String(getCurrentConnectionId() || connectionId || "") || undefined - : undefined, - }, - target: { - provider, - requestedModel: effectiveModel, - sourceFormat, - targetFormat, - stream, - }, - connection: { - initialConnectionId: String(getCurrentConnectionId() || connectionId || ""), - getCurrentConnectionId: () => getCurrentConnectionId() || undefined, - getCredentials: () => (credentials || {}) as Record, - replaceCredentials: (next) => { - Object.assign(credentials, next); - }, - onCredentialsRefreshed: async () => {}, - assertManagedLeaseFence: (id) => { - assertManagedLeaseFence(id); - }, - getProviderCredentials, - }, - wire: { - body: translatedBody as Record, - currentModel, - triedModels, - setBodyAndModel: (body, model) => { - translatedBody = body as typeof translatedBody; - currentModel = model; - triedModels.add(model); - }, - }, - state: { - updatePendingStage: (stage, data) => { - updatePendingScope(pendingScope, { stage, ...(data || {}) }); - }, - recordRateLimitHeaders: updateFromHeaders, - recordRateLimitBody: updateFromResponseBody, - writeTerminalStatus, - persistConnectionPatch: updateProviderConnection, - setConnectionRateLimitedUntil: async (id, untilMs) => { - const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - setConnectionRateLimitUntil(id, untilMs); - }, - lockModel, - recordAntigravityQuotaState: recordCoreOwnedAntigravityQuotaState, - markAccountSemaphoreBlocked: (key) => { - markAccountSemaphoreBlocked(key, Date.now() + 60_000); - }, - isolateProbeFailures: () => shouldIsolateProbeFailures(), - onCodexScopeRateLimited: async (params) => { - await markCodexScopeRateLimited({ - failedConnectionId: params.failedConnectionId, - model: params.model, - rateLimitedUntil: params.rateLimitedUntil, - credentials: (params.credentials || credentials) as { - connectionId?: string | null; - providerSpecificData?: unknown; - }, - }); - }, - onClearSessionAffinity: () => { - const key = - sessionAffinityKey || - extractSessionAffinityKey(body, clientRawRequest?.headers) || - null; - if (!key) return; - try { - deleteSessionAccountAffinity(key, "codex"); - } catch { - // best-effort - } - }, - onAuditAccountRotation: (params) => { - logAuditEvent({ - action: params.action, - actor: apiKeyInfo?.name || "system", - target: params.newConnectionId, - details: { - failed_connection_id: params.failedConnectionId, - new_connection_id: params.newConnectionId, - attempt: params.attempt, - retry_after_ms: params.retryAfterMs, - }, - }); - }, - }, - sendProviderAttempt: (modelToCall, allowDedup) => executeProviderRequest(modelToCall, allowDedup), - }); - - pipelineRecovered = true; - currentModel = pipelineOutcome.model; - if (pipelineOutcome.kind === "error") { - providerResponse = pipelineOutcome.result.response; - providerUrl = ""; - providerHeaders = normalizeHeaders(pipelineOutcome.result.response.headers); - finalBody = translatedBody; - } else { - const result = { - response: pipelineOutcome.response, - url: pipelineOutcome.url, - headers: pipelineOutcome.headers, - transformedBody: pipelineOutcome.transformedBody, - }; - providerResponse = result.response; - providerUrl = result.url; - providerHeaders = result.headers; - finalBody = providerRequestCapture.body(result.transformedBody); - } - const responseConnectionId = getCurrentConnectionId(); - effectiveServiceTier = resolveEffectiveServiceTier(finalBody); - claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta( - targetFormat, - finalBody, - providerHeaders, - clientRawRequest?.headers - ); - - // Log target request (final request to provider) - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - updatePendingScope(pendingScope, { - providerRequest: finalBody, - providerUrl, - stage: "provider_response_started", - }); - // Update rate limiter from response headers (learn limits dynamically) - updateFromHeaders( - provider, - responseConnectionId, - providerResponse.headers, - providerResponse.status, - model - ); - - // Store rate-limit headers for quota saturation signals try { - const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals"); - storeRateLimitHeaders( - responseConnectionId, - provider, - providerResponse.headers as Record + const pipelineOutcome = await runProviderExecutionPipeline({ + policy: { + allowAccountRotation: !managedLease && comboStrategy !== "context-relay", + allowModelFallback: true, + expectedConnectionId: managedLease + ? String(getCurrentConnectionId() || connectionId || "") || undefined + : undefined, + }, + target: { + provider, + requestedModel: effectiveModel, + sourceFormat, + targetFormat, + stream, + }, + connection: { + initialConnectionId: String(getCurrentConnectionId() || connectionId || ""), + getCurrentConnectionId: () => getCurrentConnectionId() || undefined, + getCredentials: () => (credentials || {}) as Record, + replaceCredentials: (next) => { + Object.assign(credentials, next); + }, + onCredentialsRefreshed: async () => {}, + assertManagedLeaseFence: (id) => { + assertManagedLeaseFence(id); + }, + getProviderCredentials, + }, + wire: { + body: translatedBody as Record, + currentModel, + triedModels, + setBodyAndModel: (body, model) => { + translatedBody = body as typeof translatedBody; + currentModel = model; + triedModels.add(model); + }, + }, + state: { + updatePendingStage: (stage, data) => { + updatePendingScope(pendingScope, { stage, ...(data || {}) }); + }, + recordRateLimitHeaders: updateFromHeaders, + recordRateLimitBody: updateFromResponseBody, + writeTerminalStatus, + persistConnectionPatch: updateProviderConnection, + setConnectionRateLimitedUntil: async (id, untilMs) => { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(id, untilMs); + }, + lockModel, + recordAntigravityQuotaState: recordCoreOwnedAntigravityQuotaState, + markAccountSemaphoreBlocked: (key) => { + markAccountSemaphoreBlocked(key, Date.now() + 60_000); + }, + isolateProbeFailures: () => shouldIsolateProbeFailures(), + onCodexScopeRateLimited: async (params) => { + await markCodexScopeRateLimited({ + failedConnectionId: params.failedConnectionId, + model: params.model, + rateLimitedUntil: params.rateLimitedUntil, + credentials: (params.credentials || credentials) as { + connectionId?: string | null; + providerSpecificData?: unknown; + }, + }); + }, + onClearSessionAffinity: () => { + const key = + sessionAffinityKey || + extractSessionAffinityKey(body, clientRawRequest?.headers) || + null; + if (!key) return; + try { + deleteSessionAccountAffinity(key, "codex"); + } catch { + // best-effort + } + }, + onAuditAccountRotation: (params) => { + logAuditEvent({ + action: params.action, + actor: apiKeyInfo?.name || "system", + target: params.newConnectionId, + details: { + failed_connection_id: params.failedConnectionId, + new_connection_id: params.newConnectionId, + attempt: params.attempt, + retry_after_ms: params.retryAfterMs, + }, + }); + }, + }, + sendProviderAttempt: (modelToCall, allowDedup) => + executeProviderRequest(modelToCall, allowDedup), + }); + + pipelineRecovered = true; + currentModel = pipelineOutcome.model; + if (pipelineOutcome.kind === "error") { + providerResponse = pipelineOutcome.result.response; + providerUrl = ""; + providerHeaders = normalizeHeaders(pipelineOutcome.result.response.headers); + finalBody = translatedBody; + } else { + const result = { + response: pipelineOutcome.response, + url: pipelineOutcome.url, + headers: pipelineOutcome.headers, + transformedBody: pipelineOutcome.transformedBody, + }; + providerResponse = result.response; + providerUrl = result.url; + providerHeaders = result.headers; + finalBody = providerRequestCapture.body(result.transformedBody); + } + const responseConnectionId = getCurrentConnectionId(); + effectiveServiceTier = resolveEffectiveServiceTier(finalBody); + claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta( + targetFormat, + finalBody, + providerHeaders, + clientRawRequest?.headers ); - } catch { - // fail-open: saturation signal is best-effort - } - } catch (error) { - trackPendingRequest(model, provider, connectionId, false); - if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error); - if (isSemaphoreCapacityError(error)) { + + // Log target request (final request to provider) + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingScope(pendingScope, { + providerRequest: finalBody, + providerUrl, + stage: "provider_response_started", + }); + // Update rate limiter from response headers (learn limits dynamically) + updateFromHeaders( + provider, + responseConnectionId, + providerResponse.headers, + providerResponse.status, + model + ); + + // Store rate-limit headers for quota saturation signals + try { + const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals"); + storeRateLimitHeaders( + responseConnectionId, + provider, + providerResponse.headers as Record + ); + } catch { + // fail-open: saturation signal is best-effort + } + } catch (error) { + trackPendingRequest(model, provider, connectionId, false); + if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error); + if (isSemaphoreCapacityError(error)) { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${error.code}`, + }).catch(() => {}); + const failureMessage = error.message || "Semaphore timeout"; + persistAttemptLogs({ + status: HTTP_STATUS.RATE_LIMITED, + error: failureMessage, + providerRequest: finalBody || translatedBody, + clientResponse: buildErrorBody(HTTP_STATUS.RATE_LIMITED, failureMessage), + claudeCacheMeta: claudePromptCacheLogMeta, + cacheSource: "upstream", + }); + persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code); + const result = stream + ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code) + : createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage); + return { + ...result, + errorType: "account_semaphore_capacity", + errorCode: error.code, + }; + } + // abort(reason) can reject with a raw string lacking `name`/`status`; classify + // it through isLocalStreamLifecycleError so it maps to 499 rather than the + // 502 provider-failure default. + const isRequestAborted = isLocalStreamLifecycleError(error); + // #8376: proxyFetch tags unreachable transport failures so they remain + // distinguishable from ordinary provider 5xx responses. + const isProxyUnreachableFailure = + !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; + const errorCode = getUpstreamErrorIdentifier(error); + const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error); + const failureStatus = isRequestAborted + ? 499 + : isProxyUnreachableFailure + ? HTTP_STATUS.BAD_GATEWAY + : localRateLimitFailure + ? localRateLimitFailure.status + : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; + const failureMessage = isRequestAborted + ? "Request aborted" + : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus); + const upstreamErrorCode = + localRateLimitFailure?.code ?? + (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode); + // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, + // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a + // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already + // tags its pre-response timeout via the code below.) + const isOwnDeadlineTimeout = + failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT && + (error.name === "TimeoutError" || error.name === "BodyTimeoutError"); + const upstreamErrorType = + upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout + ? "upstream_timeout" + : failureStatus === 401 + ? "authentication_error" + : undefined; appendRequestLog({ model, provider, connectionId, - status: `FAILED ${error.code}`, + status: `FAILED ${failureStatus}`, }).catch(() => {}); - const failureMessage = error.message || "Semaphore timeout"; persistAttemptLogs({ - status: HTTP_STATUS.RATE_LIMITED, + status: failureStatus, error: failureMessage, providerRequest: finalBody || translatedBody, - clientResponse: buildErrorBody(HTTP_STATUS.RATE_LIMITED, failureMessage), + // On a client-abort (AbortError), the client already disconnected before + // we ever got here — this body is what we WOULD have sent, not what was + // actually delivered. Logging it as `clientResponse` is misleading (the + // dashboard reads that field as "what the client received"), so omit it + // for this case; `error` above already records the failure reason. + clientResponse: + error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage), claudeCacheMeta: claudePromptCacheLogMeta, cacheSource: "upstream", }); - persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code); - const result = stream - ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code) - : createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage); - return { - ...result, - errorType: "account_semaphore_capacity", - errorCode: error.code, - }; - } - // abort(reason) can reject with a raw string lacking `name`/`status`; classify - // it through isLocalStreamLifecycleError so it maps to 499 rather than the - // 502 provider-failure default. - const isRequestAborted = isLocalStreamLifecycleError(error); - // #8376: proxyFetch tags unreachable transport failures so they remain - // distinguishable from ordinary provider 5xx responses. - const isProxyUnreachableFailure = - !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; - const errorCode = getUpstreamErrorIdentifier(error); - const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error); - const failureStatus = isRequestAborted - ? 499 - : isProxyUnreachableFailure - ? HTTP_STATUS.BAD_GATEWAY - : localRateLimitFailure - ? localRateLimitFailure.status - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" - ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status - : HTTP_STATUS.BAD_GATEWAY; - const failureMessage = isRequestAborted - ? "Request aborted" - : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus); - const upstreamErrorCode = - localRateLimitFailure?.code ?? (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode); - // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, - // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a - // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already - // tags its pre-response timeout via the code below.) - const isOwnDeadlineTimeout = - failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT && - (error.name === "TimeoutError" || error.name === "BodyTimeoutError"); - const upstreamErrorType = - upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout - ? "upstream_timeout" - : failureStatus === 401 - ? "authentication_error" - : undefined; - appendRequestLog({ - model, - provider, - connectionId, - status: `FAILED ${failureStatus}`, - }).catch(() => {}); - persistAttemptLogs({ - status: failureStatus, - error: failureMessage, - providerRequest: finalBody || translatedBody, - // On a client-abort (AbortError), the client already disconnected before - // we ever got here — this body is what we WOULD have sent, not what was - // actually delivered. Logging it as `clientResponse` is misleading (the - // dashboard reads that field as "what the client received"), so omit it - // for this case; `error` above already records the failure reason. - clientResponse: - error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage), - claudeCacheMeta: claudePromptCacheLogMeta, - cacheSource: "upstream", - }); - if (isRequestAborted) { - streamController.handleError(error); - return createErrorResult(499, "Request aborted"); - } - const persistentErrorCode = projectFailureUsageErrorCode({ - statusCode: failureStatus, - message: failureMessage, - errorCode: - upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error"), - errorType: upstreamErrorType, - }); - persistFailureUsage(failureStatus, persistentErrorCode); - console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`); - if (stream && upstreamErrorCode) { - const result = createStreamingErrorResult( + if (isRequestAborted) { + streamController.handleError(error); + return createErrorResult(499, "Request aborted"); + } + const persistentErrorCode = projectFailureUsageErrorCode({ + statusCode: failureStatus, + message: failureMessage, + errorCode: + upstreamErrorCode || + (error instanceof Error && error.name ? error.name : "upstream_error"), + errorType: upstreamErrorType, + }); + persistFailureUsage(failureStatus, persistentErrorCode); + console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`); + if (stream && upstreamErrorCode) { + const result = createStreamingErrorResult( + failureStatus, + failureMessage, + upstreamErrorCode, + upstreamErrorType + ); + localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error); + return { + ...result, + errorType: upstreamErrorType, + errorCode: upstreamErrorCode, + }; + } + const result = createErrorResult( failureStatus, failureMessage, + null, upstreamErrorCode, upstreamErrorType ); localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error); - return { - ...result, - errorType: upstreamErrorType, - errorCode: upstreamErrorCode, - }; + return result; } - const result = createErrorResult( - failureStatus, - failureMessage, - null, - upstreamErrorCode, - upstreamErrorType - ); - localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error); - return result; - } - let upstreamErrorParsed = false; - let parsedStatusCode = providerResponse.status; - let parsedMessage = ""; - let parsedRetryAfterMs: number | null = null; - let upstreamErrorBody: unknown = null; + let upstreamErrorParsed = false; + let parsedStatusCode = providerResponse.status; + let parsedMessage = ""; + let parsedRetryAfterMs: number | null = null; + let upstreamErrorBody: unknown = null; - // Track whether stream_options was present and stripped — if so, 401/403 after - // that may be from the modification rather than a genuine auth failure, so we - // skip the credential refresh attempt in that case. - const hadStreamOptions = - targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody; - if (hadStreamOptions) { - delete translatedBody.stream_options; - } + // Track whether stream_options was present and stripped — if so, 401/403 after + // that may be from the modification rather than a genuine auth failure, so we + // skip the credential refresh attempt in that case. + const hadStreamOptions = + targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody; + if (hadStreamOptions) { + delete translatedBody.stream_options; + } - // Handle 401/403 - try token refresh using executor - // T-PROBE: probe-origin failures never attempt the refresh — a probe must - // not consume a rotating refresh token nor persist an "expired" - // deactivation on refresh failure (#9817). The 401/403 then flows into - // the normal providerFailure classification (record-only in probe mode). - if ( - (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || - providerResponse.status === HTTP_STATUS.FORBIDDEN) && - !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth - !(await shouldIsolateProbeFailures()) - ) { - // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback - // executes INSIDE the per-connection mutex held by getAccessToken. This makes - // [network refresh + DB write + outer-state mutation] one atomic step and - // prevents concurrent requests from reading a stale refreshToken before the - // DB has been updated (refresh_token_reused on Codex/OpenAI). - // - // Not every executor routes refresh through getAccessToken (e.g. github.ts - // calls refreshCopilotToken directly). When the persistFn doesn't fire from - // inside getAccessToken, we still need to do the credentials mutation + user - // callback after refreshCredentials returns. The `persistFnRan` flag tracks - // which path executed so we don't double-fire (race-prone) or skip (regression). - // Front 3: remember the refresh_token we are about to present so that, if the - // refresh fails as unrecoverable, we can tell a genuine death apart from a - // stale-token reuse that a concurrent/sibling refresh already rotated past. - const attemptedRefreshToken = - typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null; - let persistFnRan = false; - const persistFn = onCredentialsRefreshed - ? async (refreshResult: Record) => { - persistFnRan = true; - // Mutate the shared credentials object so subsequent executor calls - // in this request see the new tokens. Runs INSIDE the mutex. - Object.assign(credentials, refreshResult); - await onCredentialsRefreshed(refreshResult); + // Handle 401/403 - try token refresh using executor + // T-PROBE: probe-origin failures never attempt the refresh — a probe must + // not consume a rotating refresh token nor persist an "expired" + // deactivation on refresh failure (#9817). The 401/403 then flows into + // the normal providerFailure classification (record-only in probe mode). + if ( + (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || + providerResponse.status === HTTP_STATUS.FORBIDDEN) && + !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth + !(await shouldIsolateProbeFailures()) + ) { + // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback + // executes INSIDE the per-connection mutex held by getAccessToken. This makes + // [network refresh + DB write + outer-state mutation] one atomic step and + // prevents concurrent requests from reading a stale refreshToken before the + // DB has been updated (refresh_token_reused on Codex/OpenAI). + // + // Not every executor routes refresh through getAccessToken (e.g. github.ts + // calls refreshCopilotToken directly). When the persistFn doesn't fire from + // inside getAccessToken, we still need to do the credentials mutation + user + // callback after refreshCredentials returns. The `persistFnRan` flag tracks + // which path executed so we don't double-fire (race-prone) or skip (regression). + // Front 3: remember the refresh_token we are about to present so that, if the + // refresh fails as unrecoverable, we can tell a genuine death apart from a + // stale-token reuse that a concurrent/sibling refresh already rotated past. + const attemptedRefreshToken = + typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null; + let persistFnRan = false; + const persistFn = onCredentialsRefreshed + ? async (refreshResult: Record) => { + persistFnRan = true; + // Mutate the shared credentials object so subsequent executor calls + // in this request see the new tokens. Runs INSIDE the mutex. + Object.assign(credentials, refreshResult); + await onCredentialsRefreshed(refreshResult); + } + : undefined; + + // #4038: build a compare-and-swap reread so getAccessToken can skip the persist if a + // concurrent writer (sibling request / HealthCheck / replica) already rotated this + // connection's refresh_token past the one we presented — overwriting would revert it + // and revoke the token family. No connectionId ⇒ no guard (behavior unchanged). + const casConnectionId = + typeof credentials?.connectionId === "string" ? credentials.connectionId.trim() : ""; + const casReread = casConnectionId + ? async () => { + const latest = await getProviderConnectionById(casConnectionId); + return typeof latest?.refreshToken === "string" ? latest.refreshToken : null; + } + : null; + + const newCredentials = (await refreshWithRetry( + () => + runWithCasGuard( + casReread ? { expectedRefreshToken: attemptedRefreshToken, reread: casReread } : null, + () => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log)) + ), + 3, + log, + provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker + )) as null | { + accessToken?: string; + copilotToken?: string; + }; + + if (newCredentials?.accessToken || newCredentials?.copilotToken) { + log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`); + + // Fall back to post-mutex mutation only for executors that don't route + // through getAccessToken (and therefore never fire onPersist). For + // executors that DO route through it (Codex, Claude, Gemini, etc.) the + // mutation already happened atomically inside the mutex. + if (!persistFnRan) { + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) { + await onCredentialsRefreshed(newCredentials); + } } - : undefined; - // #4038: build a compare-and-swap reread so getAccessToken can skip the persist if a - // concurrent writer (sibling request / HealthCheck / replica) already rotated this - // connection's refresh_token past the one we presented — overwriting would revert it - // and revoke the token family. No connectionId ⇒ no guard (behavior unchanged). - const casConnectionId = - typeof credentials?.connectionId === "string" ? credentials.connectionId.trim() : ""; - const casReread = casConnectionId - ? async () => { - const latest = await getProviderConnectionById(casConnectionId); - return typeof latest?.refreshToken === "string" ? latest.refreshToken : null; + // Retry with new credentials — model + extra headers follow translatedBody.model so they + // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback). + try { + const retryModelId = String(translatedBody.model || effectiveModel); + assertManagedLeaseFence(getExecutionConnectionId(getExecutionCredentials())); + const retryResult = normalizeExecutorResult( + await runWithCapture(providerRequestCapture, () => + executor.execute({ + model: retryModelId, + body: translatedBody, + stream: upstreamStream, + credentials: getExecutionCredentials(), + signal: streamController.signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), + clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), + clientResponseFormat, + onCredentialsRefreshed, + skipUpstreamRetry: isCombo, + contextEditing: { enabled: contextEditingEnabled }, + }) + ) + ); + + if (retryResult.response.ok) { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerHeaders = new Headers(retryResult.headers || {}); + finalBody = providerRequestCapture.body(retryResult.transformedBody); + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingScope(pendingScope, { + providerRequest: finalBody, + providerUrl, + stage: "provider_response_started", + }); + upstreamErrorParsed = false; // Reset since new response is OK + } else { + providerResponse = retryResult.response; + upstreamErrorParsed = false; // Let it be parsed downstream + } + } catch (retryErr) { + if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr); + // Refresh succeeded but the retry leg failed (network blip, AbortError, + // executor throw). Don't swallow — the operator-visible signal "the user + // saw 401 even though auth was actually fixed" is much more confusing + // than the original 401 alone. Surface at error level with sanitization. + log?.error?.( + "TOKEN", + `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` + ); } - : null; - - const newCredentials = (await refreshWithRetry( - () => - runWithCasGuard( - casReread ? { expectedRefreshToken: attemptedRefreshToken, reread: casReread } : null, - () => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log)) - ), - 3, - log, - provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker - )) as null | { - accessToken?: string; - copilotToken?: string; - }; - - if (newCredentials?.accessToken || newCredentials?.copilotToken) { - log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`); - - // Fall back to post-mutex mutation only for executors that don't route - // through getAccessToken (and therefore never fire onPersist). For - // executors that DO route through it (Codex, Claude, Gemini, etc.) the - // mutation already happened atomically inside the mutex. - if (!persistFnRan) { - Object.assign(credentials, newCredentials); - if (onCredentialsRefreshed) { - await onCredentialsRefreshed(newCredentials); + } else { + log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`); + if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) { + // Front 3 (reuse-race tolerance): before deactivating, re-read the DB. + // If a sibling/concurrent refresh already rotated this connection's + // refresh_token (common for Codex/OpenAI under one shared Auth0 client), + // the failure we saw was a stale-token reuse — the account is healthy + // with the newer token, so keep it active instead of killing it. + let alreadyRotated = false; + if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) { + try { + const latest = await getProviderConnectionById(connectionId); + if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) { + alreadyRotated = true; + log?.warn?.( + "TOKEN", + `${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active` + ); + } + } catch { + // DB read failed — fall through to the safe default (deactivate). + } + } + if (!alreadyRotated) { + await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + } } } + } - // Retry with new credentials — model + extra headers follow translatedBody.model so they - // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback). - try { - const retryModelId = String(translatedBody.model || effectiveModel); - assertManagedLeaseFence(getExecutionConnectionId(getExecutionCredentials())); - const retryResult = normalizeExecutorResult( - await runWithCapture(providerRequestCapture, () => - executor.execute({ - model: retryModelId, - body: translatedBody, - stream: upstreamStream, - credentials: getExecutionCredentials(), - signal: streamController.signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), - clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), - clientResponseFormat, - onCredentialsRefreshed, - skipUpstreamRetry: isCombo, - contextEditing: { enabled: contextEditingEnabled }, - }) - ) + // Check provider response - return error info for fallback handling + providerFailure: if (!providerResponse.ok) { + trackPendingRequest(model, provider, connectionId, false); + + let statusCode = providerResponse.status; + let message = ""; + let retryAfterMs: number | null = null; + let upstreamErrorCode: string | undefined; + let upstreamErrorType: string | undefined; + + if (upstreamErrorParsed) { + statusCode = parsedStatusCode; + message = parsedMessage; + retryAfterMs = parsedRetryAfterMs; + } else { + const details = await parseUpstreamError(providerResponse, provider); + statusCode = details.statusCode; + message = details.message; + retryAfterMs = details.retryAfterMs; + upstreamErrorBody = details.responseBody; + upstreamErrorCode = details.errorCode as string | undefined; + upstreamErrorType = details.errorType as string | undefined; + } + + // Gateways like agentrouter misstate temporary quota exhaustion as 403/400, + // which downstream classification treats as AUTH_ERROR and clients like + // Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After) + // BEFORE any classification so both the fallback engine and the surfaced + // client status see a retryable error. Registry-scoped per provider. + const restatement = applyStatusRestatement({ + provider, + status: statusCode, + message, + body: upstreamErrorBody, + retryAfterMs, + }); + if (restatement.ruleId) { + statusCode = restatement.status; + retryAfterMs = restatement.retryAfterMs; + log?.info?.( + "STATUS_RESTATE", + `${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})` ); + } - if (retryResult.response.ok) { - providerResponse = retryResult.response; - providerUrl = retryResult.url; - providerHeaders = new Headers(retryResult.headers || {}); - finalBody = providerRequestCapture.body(retryResult.transformedBody); + const signatureRecovery = pipelineRecovered + ? { attempted: false, succeeded: false, execution: null, error: null, recoveryBody: null } + : await recoverAnthropicThinkingSignature({ + provider, + statusCode, + message, + body: translatedBody, + execute: async (recoveryBody) => { + translatedBody = recoveryBody as typeof translatedBody; + return executeProviderRequest(currentModel, false); + }, + parseError: (response) => parseUpstreamError(response, provider), + }); + if (!pipelineRecovered && signatureRecovery.attempted && signatureRecovery.execution) { + providerResponse = signatureRecovery.execution.response; + if (signatureRecovery.succeeded) { + providerUrl = signatureRecovery.execution.url; + providerHeaders = signatureRecovery.execution.headers; + finalBody = providerRequestCapture.body(signatureRecovery.execution.transformedBody); reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); updatePendingScope(pendingScope, { providerRequest: finalBody, providerUrl, stage: "provider_response_started", }); - upstreamErrorParsed = false; // Reset since new response is OK - } else { - providerResponse = retryResult.response; - upstreamErrorParsed = false; // Let it be parsed downstream - } - } catch (retryErr) { - if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr); - // Refresh succeeded but the retry leg failed (network blip, AbortError, - // executor throw). Don't swallow — the operator-visible signal "the user - // saw 401 even though auth was actually fixed" is much more confusing - // than the original 401 alone. Surface at error level with sanitization. - log?.error?.( - "TOKEN", - `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` - ); - } - } else { - log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`); - if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) { - // Front 3 (reuse-race tolerance): before deactivating, re-read the DB. - // If a sibling/concurrent refresh already rotated this connection's - // refresh_token (common for Codex/OpenAI under one shared Auth0 client), - // the failure we saw was a stale-token reuse — the account is healthy - // with the newer token, so keep it active instead of killing it. - let alreadyRotated = false; - if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) { - try { - const latest = await getProviderConnectionById(connectionId); - if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) { - alreadyRotated = true; - log?.warn?.( - "TOKEN", - `${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active` - ); - } - } catch { - // DB read failed — fall through to the safe default (deactivate). - } - } - if (!alreadyRotated) { - await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + log?.info?.( + "THINKING_SIGNATURE", + `Recovered ${provider}/${currentModel} after one historical-thinking retry` + ); + } else if (signatureRecovery.error) { + statusCode = signatureRecovery.error.statusCode; + message = signatureRecovery.error.message; + retryAfterMs = signatureRecovery.error.retryAfterMs; + upstreamErrorBody = signatureRecovery.error.responseBody; + upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined; + upstreamErrorType = signatureRecovery.error.errorType as string | undefined; } } - } - } - // Check provider response - return error info for fallback handling - providerFailure: if (!providerResponse.ok) { - trackPendingRequest(model, provider, connectionId, false); + if (signatureRecovery.succeeded) break providerFailure; - let statusCode = providerResponse.status; - let message = ""; - let retryAfterMs: number | null = null; - let upstreamErrorCode: string | undefined; - let upstreamErrorType: string | undefined; - - if (upstreamErrorParsed) { - statusCode = parsedStatusCode; - message = parsedMessage; - retryAfterMs = parsedRetryAfterMs; - } else { - const details = await parseUpstreamError(providerResponse, provider); - statusCode = details.statusCode; - message = details.message; - retryAfterMs = details.retryAfterMs; - upstreamErrorBody = details.responseBody; - upstreamErrorCode = details.errorCode as string | undefined; - upstreamErrorType = details.errorType as string | undefined; - } - - // Gateways like agentrouter misstate temporary quota exhaustion as 403/400, - // which downstream classification treats as AUTH_ERROR and clients like - // Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After) - // BEFORE any classification so both the fallback engine and the surfaced - // client status see a retryable error. Registry-scoped per provider. - const restatement = applyStatusRestatement({ - provider, - status: statusCode, - message, - body: upstreamErrorBody, - retryAfterMs, - }); - if (restatement.ruleId) { - statusCode = restatement.status; - retryAfterMs = restatement.retryAfterMs; - log?.info?.( - "STATUS_RESTATE", - `${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})` - ); - } - - const signatureRecovery = pipelineRecovered - ? { attempted: false, succeeded: false, execution: null, error: null, recoveryBody: null } - : await recoverAnthropicThinkingSignature({ - provider, - statusCode, - message, - body: translatedBody, - execute: async (recoveryBody) => { - translatedBody = recoveryBody as typeof translatedBody; - return executeProviderRequest(currentModel, false); - }, - parseError: (response) => parseUpstreamError(response, provider), - }); - if (!pipelineRecovered && signatureRecovery.attempted && signatureRecovery.execution) { - providerResponse = signatureRecovery.execution.response; - if (signatureRecovery.succeeded) { - providerUrl = signatureRecovery.execution.url; - providerHeaders = signatureRecovery.execution.headers; - finalBody = providerRequestCapture.body(signatureRecovery.execution.transformedBody); - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - updatePendingScope(pendingScope, { - providerRequest: finalBody, - providerUrl, - stage: "provider_response_started", + // #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check + // sends `max_tokens: 1`): the model burns the whole budget on thinking, and + // some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty + // outcome with a 5xx ("empty response content") instead of a truncated 200. + // Answer such probes with a valid truncated response rather than relaying the + // upstream failure — which would also mark the connection unavailable and + // poison fallback/cooldown bookkeeping for a request that is only a probe. + if ( + !stream && + isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) && + isEmptyContentUpstreamFailure(statusCode, message) + ) { + providerResponse = buildReasoningProbeTruncatedResponse({ + model: currentModel, + maxTokens: toPositiveInteger( + (finalBody || translatedBody)?.max_tokens ?? + (finalBody || translatedBody)?.max_completion_tokens + ), + requestId: skillRequestId, }); - log?.info?.( - "THINKING_SIGNATURE", - `Recovered ${provider}/${currentModel} after one historical-thinking retry` + log?.warn?.( + "PROBE", + `Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"` ); - } else if (signatureRecovery.error) { - statusCode = signatureRecovery.error.statusCode; - message = signatureRecovery.error.message; - retryAfterMs = signatureRecovery.error.retryAfterMs; - upstreamErrorBody = signatureRecovery.error.responseBody; - upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined; - upstreamErrorType = signatureRecovery.error.errorType as string | undefined; + break providerFailure; } - } - if (signatureRecovery.succeeded) break providerFailure; - - // #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check - // sends `max_tokens: 1`): the model burns the whole budget on thinking, and - // some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty - // outcome with a 5xx ("empty response content") instead of a truncated 200. - // Answer such probes with a valid truncated response rather than relaying the - // upstream failure — which would also mark the connection unavailable and - // poison fallback/cooldown bookkeeping for a request that is only a probe. - if ( - !stream && - isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) && - isEmptyContentUpstreamFailure(statusCode, message) - ) { - providerResponse = buildReasoningProbeTruncatedResponse({ - model: currentModel, - maxTokens: toPositiveInteger( - (finalBody || translatedBody)?.max_tokens ?? - (finalBody || translatedBody)?.max_completion_tokens - ), - requestId: skillRequestId, - }); - log?.warn?.( - "PROBE", - `Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"` - ); - break providerFailure; - } - - // T06/T10/T36: classify provider errors and persist terminal account states. - let errorType = classifyProviderError(statusCode, message, provider); - if (statusCode === 429 && isModelScope()) { - const decision = classifyModelScope429(message, normalizeHeaders(providerResponse.headers)); - errorType = - decision.kind === "quota_exhausted" - ? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED - : PROVIDER_ERROR_TYPES.RATE_LIMITED; - log?.warn?.( - "MODELSCOPE_429", - `${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})` - ); - } - // Classifiers and recovery paths above consume the raw provider wording. - // Project a separate value only at persistent connection-state boundaries. - const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed"; - const errorConnectionId = getCurrentConnectionId(); - if (errorConnectionId && errorType) { - try { - if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { - { - const probeIsolated = await shouldIsolateProbeFailures(); - await writeTerminalStatus( - errorConnectionId, - { - testStatus: "banned", - isActive: false, - lastError: persistentMessage, - lastErrorType: errorType, - errorCode: String(statusCode), - }, - probeIsolated ? "probe" : "production" - ); - if (probeIsolated) { - console.warn( - `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` - ); - } else { - console.warn( - `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently` - ); - } - } - } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { - // T-PROBE: probe-origin failures (test-all) never deactivate — - // record but stay active; Plan A (extra keys) stays first so the - // real path keeps its existing priority (#9817). - // Plan A: if connection has extra API keys, don't disable — only the failing key is affected. - // Single-key connections still get disabled as before. - if ( - connectionHasExtraKeys( - errorConnectionId, - (credentials?.providerSpecificData as Record | undefined) - ?.extraApiKeys as string[] | undefined - ) - ) { - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active` - ); - } else { - const probeIsolated2 = await shouldIsolateProbeFailures(); - await writeTerminalStatus( - errorConnectionId, - { - testStatus: "deactivated", - isActive: false, - lastError: persistentMessage, - lastErrorType: errorType, - errorCode: String(statusCode), - }, - probeIsolated2 ? "probe" : "production" - ); - if (probeIsolated2) { - console.warn( - `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` - ); - } else { - console.warn( - `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently` - ); - } - } - } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { - { - const probeIsolated3 = await shouldIsolateProbeFailures(); - if (probeIsolated3) { + // T06/T10/T36: classify provider errors and persist terminal account states. + let errorType = classifyProviderError(statusCode, message, provider); + if (statusCode === 429 && isModelScope()) { + const decision = classifyModelScope429(message, normalizeHeaders(providerResponse.headers)); + errorType = + decision.kind === "quota_exhausted" + ? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED + : PROVIDER_ERROR_TYPES.RATE_LIMITED; + log?.warn?.( + "MODELSCOPE_429", + `${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})` + ); + } + // Classifiers and recovery paths above consume the raw provider wording. + // Project a separate value only at persistent connection-state boundaries. + const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed"; + const errorConnectionId = getCurrentConnectionId(); + if (errorConnectionId && errorType) { + try { + if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { + { + const probeIsolated = await shouldIsolateProbeFailures(); await writeTerminalStatus( errorConnectionId, { - testStatus: "credits_exhausted", + testStatus: "banned", + isActive: false, lastError: persistentMessage, lastErrorType: errorType, errorCode: String(statusCode), }, - "probe" + probeIsolated ? "probe" : "production" ); - console.warn( - `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` - ); - } else { - // Kimi's 403 says "billing cycle" for both an exhausted subscription and a - // temporary request window. Read its official usage endpoint before making - // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit - // window must recover automatically at the reported reset time. - let kimiRateLimitResetAt: string | null = null; - if (provider === "kimi-coding") { - try { - const { fetchAndPersistProviderLimits } = - await import("@/lib/usage/providerLimits"); - const { usage } = await fetchAndPersistProviderLimits( - errorConnectionId, - "manual" - ); - kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); - } catch { - // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. - } - } - - // Providers with per-model quotas — lock the model only, not the connection - let quotaCooldownMs = kimiRateLimitResetAt - ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) - : retryAfterMs || COOLDOWN_MS.rateLimit; - const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller( - provider, - typeof onStreamFailure === "function" - ); - const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller( - provider, - true - ); - let coreOwnedAntigravityLockout: { - cooldownMs: number; - failureCount: number; - } | null = null; - if (isAntigravityQuotaFamily && !deferAntigravityQuotaStateToCaller) { - const quotaErrorText = - typeof upstreamErrorBody === "string" - ? upstreamErrorBody - : upstreamErrorBody == null - ? message - : JSON.stringify(upstreamErrorBody); - coreOwnedAntigravityLockout = await recordCoreOwnedAntigravityQuotaState({ - provider, - connectionId: errorConnectionId, - model, - status: statusCode, - errorText: quotaErrorText, - headers: providerResponse.headers, - }); - quotaCooldownMs = coreOwnedAntigravityLockout.cooldownMs; - } - const accountSemaphoreKey = resolveAccountSemaphoreKey({ - provider, - model: currentModel, - connectionId: errorConnectionId, - credentials, - }); - if (accountSemaphoreKey && !deferAntigravityQuotaStateToCaller) { - markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); - } - if (deferAntigravityQuotaStateToCaller) { - // Defer both model and account-semaphore cooldowns to - // markAccountUnavailable, where header/body provenance and the - // configured maxCooldownMs are available. Direct consumers such - // as Responses pass no owner callback and retain core ownership. - } else if (coreOwnedAntigravityLockout) { + if (probeIsolated) { console.warn( - `[provider] Node ${errorConnectionId} Antigravity model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(coreOwnedAntigravityLockout.cooldownMs / 1000)}s (failureCount=${coreOwnedAntigravityLockout.failureCount}, owner=core)` - ); - } else if (kimiRateLimitResetAt) { - await updateProviderConnection(errorConnectionId, { - testStatus: "unavailable", - rateLimitedUntil: kimiRateLimitResetAt, - backoffLevel: 0, - lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, - lastError: persistentMessage, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` - ); - } else if (isModelScope() && errorConnectionId) { - lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); - console.warn( - `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` - ); - } else if ( - lockModelIfPerModelQuota( - provider, - errorConnectionId, - model, - "quota_exhausted", - quotaCooldownMs - ) - ) { - const quotaScope = getQuotaScopeLabelForProvider(provider, model); - console.warn( - `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` ); } else { + console.warn( + `[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently` + ); + } + } + } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { + // T-PROBE: probe-origin failures (test-all) never deactivate — + // record but stay active; Plan A (extra keys) stays first so the + // real path keeps its existing priority (#9817). + // Plan A: if connection has extra API keys, don't disable — only the failing key is affected. + // Single-key connections still get disabled as before. + if ( + connectionHasExtraKeys( + errorConnectionId, + (credentials?.providerSpecificData as Record | undefined) + ?.extraApiKeys as string[] | undefined + ) + ) { + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active` + ); + } else { + const probeIsolated2 = await shouldIsolateProbeFailures(); + await writeTerminalStatus( + errorConnectionId, + { + testStatus: "deactivated", + isActive: false, + lastError: persistentMessage, + lastErrorType: errorType, + errorCode: String(statusCode), + }, + probeIsolated2 ? "probe" : "production" + ); + if (probeIsolated2) { + console.warn( + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` + ); + } else { + console.warn( + `[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently` + ); + } + } + } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { + { + const probeIsolated3 = await shouldIsolateProbeFailures(); + if (probeIsolated3) { await writeTerminalStatus( errorConnectionId, { @@ -4406,176 +4291,314 @@ export async function handleChatCore({ lastErrorType: errorType, errorCode: String(statusCode), }, - "production" + "probe" ); console.warn( - `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})` + `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` ); + } else { + // Kimi's 403 says "billing cycle" for both an exhausted subscription and a + // temporary request window. Read its official usage endpoint before making + // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit + // window must recover automatically at the reported reset time. + let kimiRateLimitResetAt: string | null = null; + if (provider === "kimi-coding") { + try { + const { fetchAndPersistProviderLimits } = + await import("@/lib/usage/providerLimits"); + const { usage } = await fetchAndPersistProviderLimits( + errorConnectionId, + "manual" + ); + kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); + } catch { + // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. + } + } + + // Providers with per-model quotas — lock the model only, not the connection + let quotaCooldownMs = kimiRateLimitResetAt + ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) + : retryAfterMs || COOLDOWN_MS.rateLimit; + const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller( + provider, + typeof onStreamFailure === "function" + ); + const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller( + provider, + true + ); + let coreOwnedAntigravityLockout: { + cooldownMs: number; + failureCount: number; + } | null = null; + if (isAntigravityQuotaFamily && !deferAntigravityQuotaStateToCaller) { + const quotaErrorText = + typeof upstreamErrorBody === "string" + ? upstreamErrorBody + : upstreamErrorBody == null + ? message + : JSON.stringify(upstreamErrorBody); + coreOwnedAntigravityLockout = await recordCoreOwnedAntigravityQuotaState({ + provider, + connectionId: errorConnectionId, + model, + status: statusCode, + errorText: quotaErrorText, + headers: providerResponse.headers, + }); + quotaCooldownMs = coreOwnedAntigravityLockout.cooldownMs; + } + const accountSemaphoreKey = resolveAccountSemaphoreKey({ + provider, + model: currentModel, + connectionId: errorConnectionId, + credentials, + }); + if (accountSemaphoreKey && !deferAntigravityQuotaStateToCaller) { + markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); + } + if (deferAntigravityQuotaStateToCaller) { + // Defer both model and account-semaphore cooldowns to + // markAccountUnavailable, where header/body provenance and the + // configured maxCooldownMs are available. Direct consumers such + // as Responses pass no owner callback and retain core ownership. + } else if (coreOwnedAntigravityLockout) { + console.warn( + `[provider] Node ${errorConnectionId} Antigravity model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(coreOwnedAntigravityLockout.cooldownMs / 1000)}s (failureCount=${coreOwnedAntigravityLockout.failureCount}, owner=core)` + ); + } else if (kimiRateLimitResetAt) { + await updateProviderConnection(errorConnectionId, { + testStatus: "unavailable", + rateLimitedUntil: kimiRateLimitResetAt, + backoffLevel: 0, + lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, + lastError: persistentMessage, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` + ); + } else if (isModelScope() && errorConnectionId) { + lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); + console.warn( + `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` + ); + } else if ( + lockModelIfPerModelQuota( + provider, + errorConnectionId, + model, + "quota_exhausted", + quotaCooldownMs + ) + ) { + const quotaScope = getQuotaScopeLabelForProvider(provider, model); + console.warn( + `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` + ); + } else { + await writeTerminalStatus( + errorConnectionId, + { + testStatus: "credits_exhausted", + lastError: persistentMessage, + lastErrorType: errorType, + errorCode: String(statusCode), + }, + "production" + ); + console.warn( + `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})` + ); + } + } // close probeIsolated3 else + } + } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { + // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); + } else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) { + // OAuth 401 with invalid credentials - token refresh can recover + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) { + // Cloud Code 403 with stale project: not a ban, keep account active. + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) { + // Google regional-availability refusal (e.g. "User location is not + // supported for the API use."). Account-independent and non-terminal: + // exclude the connection for the cooldown window so routing moves to + // other accounts instead of re-selecting this one on every request, + // and never mark it banned/expired. It becomes usable again once + // egress is routed through a supported-region proxy. + const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000; + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); + // T-PROBE: the 24h exclusion is a routing mutation — a probe must + // not push a connection into a day-long cooldown (#9817). + if (!(await shouldIsolateProbeFailures())) { + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); + } catch { + // DB write failure must never break the fallback loop } - } // close probeIsolated3 else - } - } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { - // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - } else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) { - // OAuth 401 with invalid credentials - token refresh can recover - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available` - ); - } else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) { - // Cloud Code 403 with stale project: not a ban, keep account active. - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` - ); - } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) { - // Google regional-availability refusal (e.g. "User location is not - // supported for the API use."). Account-independent and non-terminal: - // exclude the connection for the cooldown window so routing moves to - // other accounts instead of re-selecting this one on every request, - // and never mark it banned/expired. It becomes usable again once - // egress is routed through a supported-region proxy. - const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000; - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - // T-PROBE: the 24h exclusion is a routing mutation — a probe must - // not push a connection into a day-long cooldown (#9817). - if (!(await shouldIsolateProbeFailures())) { + } + console.warn( + `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) { + // Antigravity BYOP: the account must Bring Its Own GCP Project. + // Account-specific and fixable by entering a Project ID — never a + // model lockout, never a ban. Exclude the connection for the + // cooldown window so selection prefers sibling accounts; the 422 + // body carries the actionable message when no sibling is available. + const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000; + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: persistentMessage, + errorCode: statusCode, + }); try { const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs); } catch { - // DB write failure must never break the fallback loop + // best-effort — never break the error path + } + console.warn( + `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { + // 404 — model/endpoint does not exist upstream. Lock the model so the + // retry/backoff loop stops hammering the dead endpoint (which would + // otherwise degenerate into a 429 rate-limit storm). Connection stays + // active since only the specific model is unavailable. (#6827) + const notFoundCooldownMs = COOLDOWN_MS.notFound; + // T-PROBE: the model lockout is a routing mutation — a probe must + // not lock a model for the cooldown window (#9817). + if (!(await shouldIsolateProbeFailures())) { + lockModel( + provider, + errorConnectionId, + currentModel, + "model_not_found", + notFoundCooldownMs + ); + console.warn( + `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` + ); } } - console.warn( - `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` - ); - } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) { - // Antigravity BYOP: the account must Bring Its Own GCP Project. - // Account-specific and fixable by entering a Project ID — never a - // model lockout, never a ban. Exclude the connection for the - // cooldown window so selection prefers sibling accounts; the 422 - // body carries the actionable message when no sibling is available. - const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000; - await updateProviderConnection(errorConnectionId, { - lastErrorType: errorType, - lastError: persistentMessage, - errorCode: statusCode, - }); - try { - const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs); - } catch { - // best-effort — never break the error path - } - console.warn( - `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)` - ); - } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { - // 404 — model/endpoint does not exist upstream. Lock the model so the - // retry/backoff loop stops hammering the dead endpoint (which would - // otherwise degenerate into a 429 rate-limit storm). Connection stays - // active since only the specific model is unavailable. (#6827) - const notFoundCooldownMs = COOLDOWN_MS.notFound; - // T-PROBE: the model lockout is a routing mutation — a probe must - // not lock a model for the cooldown window (#9817). - if (!(await shouldIsolateProbeFailures())) { - lockModel( - provider, - errorConnectionId, - currentModel, - "model_not_found", - notFoundCooldownMs - ); - console.warn( - `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` - ); - } + } catch { + // Best-effort state update; request flow should continue with fallback handling. } - } catch { - // Best-effort state update; request flow should continue with fallback handling. } - } - appendRequestLog({ - model, - provider, - connectionId: errorConnectionId, - status: `FAILED ${statusCode}`, - }).catch(() => {}); + appendRequestLog({ + model, + provider, + connectionId: errorConnectionId, + status: `FAILED ${statusCode}`, + }).catch(() => {}); - const errMsg = formatProviderError(new Error(message), provider, model, statusCode); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); - // Log Antigravity retry time if available - if (retryAfterMs && provider === "antigravity") { - const retrySeconds = Math.ceil(retryAfterMs / 1000); - log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`); - } + // Log Antigravity retry time if available + if (retryAfterMs && provider === "antigravity") { + const retrySeconds = Math.ceil(retryAfterMs / 1000); + log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`); + } - // Log error with full request body for debugging - reqLogger.logError(new Error(message), finalBody || translatedBody); - reqLogger.logProviderResponse( - providerResponse.status, - providerResponse.statusText, - providerResponse.headers, - upstreamErrorBody - ); + // Log error with full request body for debugging + reqLogger.logError(new Error(message), finalBody || translatedBody); + reqLogger.logProviderResponse( + providerResponse.status, + providerResponse.statusText, + providerResponse.headers, + upstreamErrorBody + ); - // Update rate limiter from error response headers - updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model); - if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) { - updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model); - } + // Update rate limiter from error response headers + updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model); + if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) { + updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model); + } - // ── T5: Intra-family model fallback ────────────────────────────────────── - // Before returning a model-unavailable error upstream, try sibling models - // from the same family. This keeps the request alive on the same account - // instead of failing the entire combo. - if (!pipelineRecovered && isModelUnavailableError(statusCode, message, provider)) { - const nextModel = getNextFamilyFallback(currentModel, triedModels, provider); - if (nextModel) { - triedModels.add(nextModel); - currentModel = nextModel; - translatedBody.model = nextModel; - log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`); - // Re-execute with the fallback model - try { - const fallbackResult = await executeProviderRequest(nextModel, false); - if (fallbackResult.response.ok) { - providerResponse = fallbackResult.response; - providerUrl = fallbackResult.url; - providerHeaders = fallbackResult.headers; - finalBody = providerRequestCapture.body(fallbackResult.transformedBody); - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - updatePendingScope(pendingScope, { - providerRequest: finalBody, - providerUrl, - stage: "provider_response_started", - }); - // Continue processing with the fallback response — skip error return - log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`); - // Jump to streaming/non-streaming handling below - // We fall through by NOT returning here - } else { - // Fallback also failed — return original error + // ── T5: Intra-family model fallback ────────────────────────────────────── + // Before returning a model-unavailable error upstream, try sibling models + // from the same family. This keeps the request alive on the same account + // instead of failing the entire combo. + if (!pipelineRecovered && isModelUnavailableError(statusCode, message, provider)) { + const nextModel = getNextFamilyFallback(currentModel, triedModels, provider); + if (nextModel) { + triedModels.add(nextModel); + currentModel = nextModel; + translatedBody.model = nextModel; + log?.info?.( + "MODEL_FALLBACK", + `${model} unavailable (${statusCode}) → trying ${nextModel}` + ); + // Re-execute with the fallback model + try { + const fallbackResult = await executeProviderRequest(nextModel, false); + if (fallbackResult.response.ok) { + providerResponse = fallbackResult.response; + providerUrl = fallbackResult.url; + providerHeaders = fallbackResult.headers; + finalBody = providerRequestCapture.body(fallbackResult.transformedBody); + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingScope(pendingScope, { + providerRequest: finalBody, + providerUrl, + stage: "provider_response_started", + }); + // Continue processing with the fallback response — skip error return + log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`); + // Jump to streaming/non-streaming handling below + // We fall through by NOT returning here + } else { + // Fallback also failed — return original error + persistAttemptLogs({ + status: statusCode, + error: errMsg, + providerRequest: finalBody || translatedBody, + providerResponse: upstreamErrorBody, + clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", + }); + persistFailureUsage(statusCode, "model_unavailable"); + return createErrorResult( + statusCode, + errMsg, + retryAfterMs, + upstreamErrorCode, + upstreamErrorType, + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } + ); + } + } catch { persistAttemptLogs({ status: statusCode, error: errMsg, @@ -4595,7 +4618,7 @@ export async function handleChatCore({ { passthrough: sourceFormat === FORMATS.CLAUDE } ); } - } catch { + } else { persistAttemptLogs({ status: statusCode, error: errMsg, @@ -4615,56 +4638,59 @@ export async function handleChatCore({ { passthrough: sourceFormat === FORMATS.CLAUDE } ); } - } else { - persistAttemptLogs({ - status: statusCode, - error: errMsg, - providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, - clientResponse: buildErrorBody(statusCode, errMsg), - cacheSource: "upstream", - }); - persistFailureUsage(statusCode, "model_unavailable"); - return createErrorResult( - statusCode, - errMsg, - retryAfterMs, - upstreamErrorCode, - upstreamErrorType, - upstreamErrorBody, - { passthrough: sourceFormat === FORMATS.CLAUDE } + } else if (isContextOverflowError(statusCode, message)) { + const familyCandidates = getModelFamily(currentModel, provider).filter( + (m) => m !== currentModel && !triedModels.has(m) ); - } - } else if (isContextOverflowError(statusCode, message)) { - const familyCandidates = getModelFamily(currentModel, provider).filter( - (m) => m !== currentModel && !triedModels.has(m) - ); - const nextModel = - findLargerContextModel(currentModel, familyCandidates, provider) ?? - getNextFamilyFallback(currentModel, triedModels, provider); - if (nextModel) { - triedModels.add(nextModel); - currentModel = nextModel; - translatedBody.model = nextModel; - log?.info?.("CONTEXT_OVERFLOW_FALLBACK", `${model} context overflow → trying ${nextModel}`); - try { - const fallbackResult = await executeProviderRequest(nextModel, false); - if (fallbackResult.response.ok) { - providerResponse = fallbackResult.response; - providerUrl = fallbackResult.url; - providerHeaders = fallbackResult.headers; - finalBody = providerRequestCapture.body(fallbackResult.transformedBody); - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - updatePendingScope(pendingScope, { - providerRequest: finalBody, - providerUrl, - stage: "provider_response_started", - }); - log?.info?.( - "CONTEXT_OVERFLOW_FALLBACK", - `Serving ${nextModel} as fallback for ${model}` - ); - } else { + const nextModel = + findLargerContextModel(currentModel, familyCandidates, provider) ?? + getNextFamilyFallback(currentModel, triedModels, provider); + if (nextModel) { + triedModels.add(nextModel); + currentModel = nextModel; + translatedBody.model = nextModel; + log?.info?.( + "CONTEXT_OVERFLOW_FALLBACK", + `${model} context overflow → trying ${nextModel}` + ); + try { + const fallbackResult = await executeProviderRequest(nextModel, false); + if (fallbackResult.response.ok) { + providerResponse = fallbackResult.response; + providerUrl = fallbackResult.url; + providerHeaders = fallbackResult.headers; + finalBody = providerRequestCapture.body(fallbackResult.transformedBody); + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + updatePendingScope(pendingScope, { + providerRequest: finalBody, + providerUrl, + stage: "provider_response_started", + }); + log?.info?.( + "CONTEXT_OVERFLOW_FALLBACK", + `Serving ${nextModel} as fallback for ${model}` + ); + } else { + persistAttemptLogs({ + status: statusCode, + error: errMsg, + providerRequest: finalBody || translatedBody, + providerResponse: upstreamErrorBody, + clientResponse: buildErrorBody(statusCode, errMsg), + cacheSource: "upstream", + }); + persistFailureUsage(statusCode, "context_overflow"); + return createErrorResult( + statusCode, + errMsg, + retryAfterMs, + upstreamErrorCode, + upstreamErrorType, + upstreamErrorBody, + { passthrough: sourceFormat === FORMATS.CLAUDE } + ); + } + } catch { persistAttemptLogs({ status: statusCode, error: errMsg, @@ -4684,7 +4710,7 @@ export async function handleChatCore({ { passthrough: sourceFormat === FORMATS.CLAUDE } ); } - } catch { + } else { persistAttemptLogs({ status: statusCode, error: errMsg, @@ -4713,7 +4739,14 @@ export async function handleChatCore({ clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); - persistFailureUsage(statusCode, "context_overflow"); + persistFailureUsage(statusCode, `upstream_${statusCode}`); + + // Emergency budget fallback is orchestrated exclusively by the routing layer + // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency + // provider through account selection. The executor-level hop that used to + // live here re-sent the FAILING provider's credentials to the emergency + // provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com) + // — a cross-provider credential leak that also never succeeded upstream. return createErrorResult( statusCode, errMsg, @@ -4724,41 +4757,18 @@ export async function handleChatCore({ { passthrough: sourceFormat === FORMATS.CLAUDE } ); } - } else { - persistAttemptLogs({ - status: statusCode, - error: errMsg, - providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, - clientResponse: buildErrorBody(statusCode, errMsg), - cacheSource: "upstream", - }); - persistFailureUsage(statusCode, `upstream_${statusCode}`); - - // Emergency budget fallback is orchestrated exclusively by the routing layer - // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency - // provider through account selection. The executor-level hop that used to - // live here re-sent the FAILING provider's credentials to the emergency - // provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com) - // — a cross-provider credential leak that also never succeeded upstream. - return createErrorResult( - statusCode, - errMsg, - retryAfterMs, - upstreamErrorCode, - upstreamErrorType, - upstreamErrorBody, - { passthrough: sourceFormat === FORMATS.CLAUDE } - ); + // ── End T5 ─────────────────────────────────────────────────────────────── } - // ── End T5 ─────────────────────────────────────────────────────────────── - } } // Non-streaming response if (!stream) { try { - const runNonStreamingPipeline = async ({ policy, model: pipelineModel, translatedBody: wireBody }) => { + const runNonStreamingPipeline = async ({ + policy, + model: pipelineModel, + translatedBody: wireBody, + }) => { translatedBody = wireBody as typeof translatedBody; currentModel = pipelineModel; triedModels.add(pipelineModel); @@ -4852,341 +4862,486 @@ export async function handleChatCore({ sendProviderAttempt: (modelToCall, allowDedup) => executeProviderRequest(modelToCall, allowDedup), }); - }; + }; - let toolLoopRan = false; - let toolLoopUsage = null; - let legResult = await runNonStreamingProviderLeg({ - phase: "initial", - sourceBody: (body || {}) as Record, - expectedConnectionId: managedLease + let toolLoopRan = false; + let toolLoopUsage = null; + let legResult = await runNonStreamingProviderLeg({ + phase: "initial", + sourceBody: (body || {}) as Record, + expectedConnectionId: managedLease + ? String(getCurrentConnectionId() || connectionId || "") || undefined + : undefined, + allowAccountRotation: !managedLease && comboStrategy !== "context-relay", + allowModelFallback: true, + executeProviderRequest: (modelToCall, allowDedup) => + executeProviderRequest(modelToCall, allowDedup), + runProviderExecution: runNonStreamingPipeline, + setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => { + translatedBody = nextBody as typeof translatedBody; + currentModel = nextModel; + triedModels.add(nextModel); + }, + sourceFormat, + targetFormat, + clientResponseFormat, + provider, + model: effectiveModel, + connectionId: String(getCurrentConnectionId() || connectionId || ""), + getCurrentConnectionId: () => getCurrentConnectionId() || undefined, + effectiveModel: currentModel, + translatedBody: translatedBody as Record, + toolNameMap, + requestToolIdentityMap, + reasoningCacheScope, + clientHeaders: clientRawRequest?.headers ?? null, + isClaudeCodeCompatible, + log, + }); + + if (legResult.kind === "error") { + const err = legResult.result; + const captured = providerRequestCapture.latest?.() ?? null; + finalBody = captured?.body ?? finalBody ?? translatedBody; + if (captured) { + reqLogger.logTargetRequest(captured.url, captured.headers, captured.body); + } + reqLogger.logError(new Error(err.error || "Provider request failed"), finalBody); + const isNetworkThrow = Boolean(err.originalError); + if (err.response && !isNetworkThrow) { + reqLogger.logProviderResponse( + err.status, + err.response.statusText || "Error", + err.response.headers, + err.response + ); + } + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${err.status}`, + }).catch(() => {}); + persistAttemptLogs({ + status: err.status, + error: err.error || "Provider request failed", + providerRequest: finalBody || translatedBody, + providerResponse: isNetworkThrow ? undefined : err.response, + clientResponse: buildErrorBody(err.status, err.error || "Provider request failed"), + cacheSource: "upstream", + }); + persistFailureUsage(err.status, err.errorCode || `upstream_${err.status}`); + trackPendingRequest(model, provider, connectionId, false); + return err; + } + + pipelineRecovered = true; + const expectedConn = managedLease ? String(getCurrentConnectionId() || connectionId || "") || undefined - : undefined, - allowAccountRotation: !managedLease && comboStrategy !== "context-relay", - allowModelFallback: true, - executeProviderRequest: (modelToCall, allowDedup) => - executeProviderRequest(modelToCall, allowDedup), - runProviderExecution: runNonStreamingPipeline, - setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => { - translatedBody = nextBody as typeof translatedBody; - currentModel = nextModel; - triedModels.add(nextModel); - }, - sourceFormat, - targetFormat, - clientResponseFormat, - provider, - model: effectiveModel, - connectionId: String(getCurrentConnectionId() || connectionId || ""), - getCurrentConnectionId: () => getCurrentConnectionId() || undefined, - effectiveModel: currentModel, - translatedBody: translatedBody as Record, - toolNameMap, - requestToolIdentityMap, - reasoningCacheScope, - clientHeaders: clientRawRequest?.headers ?? null, - isClaudeCodeCompatible, - log, - }); + : undefined; + const loopApply = await applyServerOwnedToolLoopIfNeeded({ + enabled: isServerOwnedToolLoopEnabled(), + stream, + isResponsesEndpoint, + sourceFormat, + initialLeg: legResult, + sourceBody: (body || {}) as Record, + skillsModelId: getSkillsModelIdForFormat(sourceFormat), + executionContext: { + apiKeyId: memoryOwnerId || "local", + sessionId: pipelineSessionId, + requestId: skillRequestId, + requestIdentity: derivePostInjectionRequestIdentity({ + apiKeyId: memoryOwnerId || "local", + headers: clientRawRequest?.headers ?? null, + skillRequestId, + postInjectionBody: (body || {}) as Record, + }), + builtinToolNames: injectionResult.builtinToolNames, + injectedCustomSkillNames: injectionResult.injectedCustomSkillNames, + customSkillExecutionEnabled: + Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true, + executionFenceEnabled: true, + provider, + model: effectiveModel, + }, + abortSignal: clientRawRequest?.signal, + expectedConnectionId: expectedConn, + followUpLeg: async (nextSourceBody) => { + translatedBody = translateRequest( + sourceFormat, + targetFormat, + model, + { ...nextSourceBody }, + false, + credentials, + provider, + reqLogger, + { + normalizeToolCallId: getModelNormalizeToolCallId( + provider || "", + model || "", + sourceFormat + ), + preserveDeveloperRole: getModelPreserveOpenAIDeveloperRole( + provider || "", + model || "", + sourceFormat + ), + preserveCacheControl, + signatureNamespace: connectionId, + copilotClient: copilotCompatibleReasoning, + reasoningCacheScope, + } + ); + return runNonStreamingProviderLeg( + followUpLegInput( + { + executeProviderRequest: (modelToCall, allowDedup) => + executeProviderRequest(modelToCall, allowDedup), + runProviderExecution: runNonStreamingPipeline, + setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => { + translatedBody = nextBody as typeof translatedBody; + currentModel = nextModel; + triedModels.add(nextModel); + }, + sourceFormat, + targetFormat, + clientResponseFormat, + provider, + model: effectiveModel, + connectionId: String(getCurrentConnectionId() || connectionId || ""), + getCurrentConnectionId: () => getCurrentConnectionId() || undefined, + effectiveModel: currentModel, + translatedBody: translatedBody as Record, + toolNameMap, + requestToolIdentityMap, + reasoningCacheScope, + clientHeaders: clientRawRequest?.headers ?? null, + isClaudeCodeCompatible, + log, + }, + nextSourceBody, + expectedConn + ) + ); + }, + logReceipt: (receipt) => reqLogger.logToolLoopReceipt(receipt), + }); + if (loopApply.kind === "error") { + return await finalizeToolLoopError({ + loop: loopApply.loop, + model, + provider, + connectionId, + providerRequest: loopApply.loop.finalProviderRequest || finalBody || translatedBody, + persistFailureUsage, + persistAttemptLogs, + trackPendingRequest, + }); + } + // `legResult` is declared as the full NonStreamingProviderLegResult union. The + // `kind === "error"` guard above narrows it to the ok variant, but the conditional + // reassignment below widens it back to the declared type, so every field read past + // this point lost the narrowing — 13 TS2339 diagnostics under + // tsconfig.typecheck-api.json, which pulls chatCore.ts in through the route while + // tsconfig.typecheck-core.json does not. Pin the ok variant in its own binding: + // `loopApply.leg` is already `NonStreamingProviderLegResult & { kind: "ok" }`, + // so no cast is involved. + let okLeg: NonStreamingProviderLegResult & { kind: "ok" } = legResult; + if (loopApply.kind === "ok") { + toolLoopRan = true; + toolLoopUsage = loopApply.usage; + okLeg = loopApply.leg; + } - if (legResult.kind === "error") { - const err = legResult.result; - const captured = providerRequestCapture.latest?.() ?? null; - finalBody = captured?.body ?? finalBody ?? translatedBody; - if (captured) { - reqLogger.logTargetRequest(captured.url, captured.headers, captured.body); + if (okLeg.upstreamResponse) { + providerResponse = okLeg.upstreamResponse; + providerHeaders = normalizeHeaders(okLeg.upstreamResponse.headers); + } else { + providerResponse = new Response(null, { + status: 200, + headers: okLeg.headers, + }); + providerHeaders = normalizeHeaders(okLeg.headers); } - reqLogger.logError(new Error(err.error || "Provider request failed"), finalBody); - const isNetworkThrow = Boolean(err.originalError); - if (err.response && !isNetworkThrow) { - reqLogger.logProviderResponse( - err.status, - err.response.statusText || "Error", - err.response.headers, - err.response - ); + finalBody = providerRequestCapture.body(okLeg.providerRequest || translatedBody); + const capturedOk = providerRequestCapture.latest?.(); + reqLogger.logTargetRequest( + okLeg.requestUrl || capturedOk?.url || "", + okLeg.requestHeaders || capturedOk?.headers || {}, + capturedOk?.body ?? finalBody + ); + const responseBody = okLeg.providerBody; + const responsePayloadFormat = okLeg.responsePayloadFormat; + const looksLikeSSE = okLeg.looksLikeSSE; + let translatedResponse = okLeg.response; + const memoryExtractionResponse = okLeg.responseForMemoryExtraction; + reqLogger.logProviderResponse( + 200, + "OK", + providerResponse.headers, + looksLikeSSE + ? { _streamed: true, _format: "sse-json", summary: responseBody } + : responseBody + ); + effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier; + if (onRequestSuccess) { + await onRequestSuccess(); } + const successConnectionId = getCurrentConnectionId(); + await maybeSyncClaudeExtraUsageState({ + provider, + connectionId: successConnectionId, + providerSpecificData: credentials?.providerSpecificData, + log, + }); + const usage = toolLoopUsage ?? extractUsageFromResponse(responseBody, provider); + const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); + if (usage && typeof usage === "object") { + attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider"); + if (provider === "gemini") { + const promptTokens = + typeof (usage as Record).prompt_tokens === "number" + ? ((usage as Record).prompt_tokens as number) + : 0; + if (promptTokens > 0) incrementTokenUsage(model, promptTokens); + } + } + recordContextEditingTelemetryHook({ + contextEditingEnabled, + provider, + responseBody, + skillRequestId, + log, + }); appendRequestLog({ model, provider, - connectionId, - status: `FAILED ${err.status}`, + connectionId: successConnectionId, + tokens: usage, + status: "200 OK", }).catch(() => {}); - persistAttemptLogs({ - status: err.status, - error: err.error || "Provider request failed", - providerRequest: finalBody || translatedBody, - providerResponse: isNetworkThrow ? undefined : err.response, - clientResponse: buildErrorBody(err.status, err.error || "Provider request failed"), - cacheSource: "upstream", - }); - persistFailureUsage( - err.status, - err.errorCode || `upstream_${err.status}` - ); - trackPendingRequest(model, provider, connectionId, false); - return err; - } - - pipelineRecovered = true; - const expectedConn = managedLease - ? String(getCurrentConnectionId() || connectionId || "") || undefined - : undefined; - const loopApply = await applyServerOwnedToolLoopIfNeeded({ - enabled: isServerOwnedToolLoopEnabled(), - stream, - isResponsesEndpoint, - sourceFormat, - initialLeg: legResult, - sourceBody: (body || {}) as Record, - skillsModelId: getSkillsModelIdForFormat(sourceFormat), - executionContext: { - apiKeyId: memoryOwnerId || "local", - sessionId: pipelineSessionId, - requestId: skillRequestId, - requestIdentity: derivePostInjectionRequestIdentity({ - apiKeyId: memoryOwnerId || "local", - headers: clientRawRequest?.headers ?? null, - skillRequestId, - postInjectionBody: (body || {}) as Record, - }), - builtinToolNames: injectionResult.builtinToolNames, - injectedCustomSkillNames: injectionResult.injectedCustomSkillNames, - customSkillExecutionEnabled: - Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true, - executionFenceEnabled: true, + recordNonStreamingUsageStats(usage, { + traceEnabled, provider, - model: effectiveModel, - }, - abortSignal: clientRawRequest?.signal, - expectedConnectionId: expectedConn, - followUpLeg: async (nextSourceBody) => { - translatedBody = translateRequest( - sourceFormat, - targetFormat, - model, - { ...nextSourceBody }, - false, - credentials, - provider, - reqLogger, + connectionId: successConnectionId, + model, + startTime, + apiKeyInfo, + effectiveServiceTier, + isCombo, + comboStrategy, + endpoint: endpointPath, + }); + + // #12150 P1b surface 3 (fix round 1): a video-bridge-observed request's + // request- AND response-derived text both carry the full transcript (the + // flattened description on the request side, the model's own reply on + // the response side) — neither may populate durable Memory. See + // runMemoryExtractionGate for the shared gate + extraction wiring, unit + // tested directly in tests/unit/video-bridge-memory-suppression.test.ts. + runMemoryExtractionGate({ + memoryOwnerId, + memorySettings, + videoBridgeObserved, + pipelineSessionId, + requestBody: body as Record, + responseBody: memoryExtractionResponse as Record | null, + extractFacts, + log, + }); + + const customSkillExecutionEnabled = + Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true; + const builtinToolNames = [ + webSearchFallbackPlan.toolName, + webFetchFallbackPlan.toolName, + ...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []), + ].filter((name): name is string => Boolean(name)); + if (!toolLoopRan && (customSkillExecutionEnabled || builtinToolNames.length > 0)) { + const skillSessionId = pipelineSessionId; + + translatedResponse = await handleToolCallExecution( + translatedResponse, + getSkillsModelIdForFormat(sourceFormat), { - normalizeToolCallId: getModelNormalizeToolCallId(provider || "", model || "", sourceFormat), - preserveDeveloperRole: getModelPreserveOpenAIDeveloperRole( - provider || "", - model || "", - sourceFormat - ), - preserveCacheControl, - signatureNamespace: connectionId, - copilotClient: copilotCompatibleReasoning, - reasoningCacheScope, + apiKeyId: memoryOwnerId || "local", + sessionId: skillSessionId, + requestId: skillRequestId, + builtinToolNames, + customSkillExecutionEnabled, + provider, + model: effectiveModel, } ); - return runNonStreamingProviderLeg( - followUpLegInput( - { - executeProviderRequest: (modelToCall, allowDedup) => - executeProviderRequest(modelToCall, allowDedup), - runProviderExecution: runNonStreamingPipeline, - setRequestWireState: ({ translatedBody: nextBody, effectiveModel: nextModel }) => { - translatedBody = nextBody as typeof translatedBody; - currentModel = nextModel; - triedModels.add(nextModel); - }, - sourceFormat, - targetFormat, - clientResponseFormat, - provider, - model: effectiveModel, - connectionId: String(getCurrentConnectionId() || connectionId || ""), - getCurrentConnectionId: () => getCurrentConnectionId() || undefined, - effectiveModel: currentModel, - translatedBody: translatedBody as Record, - toolNameMap, - requestToolIdentityMap, - reasoningCacheScope, - clientHeaders: clientRawRequest?.headers ?? null, - isClaudeCodeCompatible, - log, - }, - nextSourceBody, - expectedConn - ) - ); - }, - logReceipt: (receipt) => reqLogger.logToolLoopReceipt(receipt), - }); - if (loopApply.kind === "error") { - return await finalizeToolLoopError({ - loop: loopApply.loop, + } + + const guardrailContext = buildPostCallGuardrailContext({ + apiKeyInfo, + body, + clientRawRequest, + log, model, provider, - connectionId, - providerRequest: loopApply.loop.finalProviderRequest || finalBody || translatedBody, - persistFailureUsage, - persistAttemptLogs, - trackPendingRequest, + responsePayloadFormat, + clientResponseFormat, }); - } - if (loopApply.kind === "ok") { - toolLoopRan = true; - toolLoopUsage = loopApply.usage; - legResult = loopApply.leg; - } - - if (legResult.upstreamResponse) { - providerResponse = legResult.upstreamResponse; - providerHeaders = normalizeHeaders(legResult.upstreamResponse.headers); - } else { - providerResponse = new Response(null, { - status: 200, - headers: legResult.headers, - }); - providerHeaders = normalizeHeaders(legResult.headers); - } - finalBody = providerRequestCapture.body(legResult.providerRequest || translatedBody); - const capturedOk = providerRequestCapture.latest?.(); - reqLogger.logTargetRequest( - legResult.requestUrl || capturedOk?.url || "", - legResult.requestHeaders || capturedOk?.headers || {}, - capturedOk?.body ?? finalBody - ); - const responseBody = legResult.providerBody; - const responsePayloadFormat = legResult.responsePayloadFormat; - const looksLikeSSE = legResult.looksLikeSSE; - let translatedResponse = legResult.response; - const memoryExtractionResponse = legResult.responseForMemoryExtraction; - reqLogger.logProviderResponse( - 200, - "OK", - providerResponse.headers, - looksLikeSSE - ? { _streamed: true, _format: "sse-json", summary: responseBody } - : responseBody - ); - effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier; - if (onRequestSuccess) { - await onRequestSuccess(); - } - const successConnectionId = getCurrentConnectionId(); - await maybeSyncClaudeExtraUsageState({ - provider, - connectionId: successConnectionId, - providerSpecificData: credentials?.providerSpecificData, - log, - }); - const usage = toolLoopUsage ?? extractUsageFromResponse(responseBody, provider); - const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); - if (usage && typeof usage === "object") { - attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider"); - if (provider === "gemini") { - const promptTokens = - typeof (usage as Record).prompt_tokens === "number" - ? ((usage as Record).prompt_tokens as number) - : 0; - if (promptTokens > 0) incrementTokenUsage(model, promptTokens); - } - } - recordContextEditingTelemetryHook({ - contextEditingEnabled, - provider, - responseBody, - skillRequestId, - log, - }); - appendRequestLog({ - model, - provider, - connectionId: successConnectionId, - tokens: usage, - status: "200 OK", - }).catch(() => {}); - recordNonStreamingUsageStats(usage, { - traceEnabled, - provider, - connectionId: successConnectionId, - model, - startTime, - apiKeyInfo, - effectiveServiceTier, - isCombo, - comboStrategy, - endpoint: endpointPath, - }); - - // #12150 P1b surface 3 (fix round 1): a video-bridge-observed request's - // request- AND response-derived text both carry the full transcript (the - // flattened description on the request side, the model's own reply on - // the response side) — neither may populate durable Memory. See - // runMemoryExtractionGate for the shared gate + extraction wiring, unit - // tested directly in tests/unit/video-bridge-memory-suppression.test.ts. - runMemoryExtractionGate({ - memoryOwnerId, - memorySettings, - videoBridgeObserved, - pipelineSessionId, - requestBody: body as Record, - responseBody: memoryExtractionResponse as Record | null, - extractFacts, - log, - }); - - const customSkillExecutionEnabled = - Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true; - const builtinToolNames = [ - webSearchFallbackPlan.toolName, - webFetchFallbackPlan.toolName, - ...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []), - ].filter((name): name is string => Boolean(name)); - if (!toolLoopRan && (customSkillExecutionEnabled || builtinToolNames.length > 0)) { - const skillSessionId = pipelineSessionId; - - translatedResponse = await handleToolCallExecution( + const postCallGuardrails = await guardrailRegistry.runPostCallHooks( translatedResponse, - getSkillsModelIdForFormat(sourceFormat), - { - apiKeyId: memoryOwnerId || "local", - sessionId: skillSessionId, - requestId: skillRequestId, - builtinToolNames, - customSkillExecutionEnabled, - provider, - model: effectiveModel, - } + guardrailContext ); - } + translatedResponse = postCallGuardrails.response; - const guardrailContext = buildPostCallGuardrailContext({ - apiKeyInfo, - body, - clientRawRequest, - log, - model, - provider, - responsePayloadFormat, - clientResponseFormat, - }); - const postCallGuardrails = await guardrailRegistry.runPostCallHooks( - translatedResponse, - guardrailContext - ); - translatedResponse = postCallGuardrails.response; + const responseUsage = isJsonRecord(usage) + ? usage + : isJsonRecord(translatedResponse.usage) + ? translatedResponse.usage + : null; + const costUsage = normalizeUsage(responseUsage); + const estimatedCost = costUsage + ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier }) + : 0; - const responseUsage = isJsonRecord(usage) - ? usage - : isJsonRecord(translatedResponse.usage) - ? translatedResponse.usage - : null; - const costUsage = normalizeUsage(responseUsage); - const estimatedCost = costUsage - ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier }) - : 0; + if (postCallGuardrails.blocked) { + const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail"; + persistAttemptLogs({ + status: HTTP_STATUS.BAD_REQUEST, + tokens: usage, + responseBody, + providerRequest: finalBody || translatedBody, + providerResponse: looksLikeSSE + ? { + _streamed: true, + _format: "sse-json", + summary: responseBody, + } + : responseBody, + clientResponse: buildErrorBody(HTTP_STATUS.BAD_REQUEST, guardrailMessage), + claudeCacheMeta: claudePromptCacheLogMeta, + claudeCacheUsageMeta: cacheUsageLogMeta, + cacheSource: "upstream", + }); + if (apiKeyInfo?.id && estimatedCost > 0) { + recordCost(apiKeyInfo.id, estimatedCost); + } + log?.warn?.( + "GUARDRAIL", + `Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}` + ); + finalizePendingScope(pendingScope, { + providerResponse: responseBody, + clientResponse: translatedResponse, + }); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage); + } - if (postCallGuardrails.blocked) { - const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail"; + // Validate the *translated* response actually carries client-usable output. + // isEmptyContentResponse (above) runs on the raw responseBody before translation; + // this check runs after translation + sanitization + tool-call execution to catch + // cases where a provider returns a structurally valid raw body that translates into + // choices:[] or output:[] with no usable content (Responses API shape included). + const malformedTranslatedReason = detectMalformedNonStream(translatedResponse); + if (malformedTranslatedReason) { + const totalLatency = Date.now() - startTime; + const rawBytes = (() => { + try { + return JSON.stringify(responseBody || {}).length; + } catch { + return -1; + } + })(); + reportMalformed200({ + mode: "nonstream", + provider, + model, + connectionId, + reason: malformedTranslatedReason, + recvBytes: rawBytes, + recvLines: -1, + emitted: -1, + events: {}, + ttftMs: totalLatency, + elapsedMs: totalLatency, + }); + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason); + const malformedMessage = `[${provider}/${model}] ${malformed.message}`; + const malformedClientBody = buildErrorBody( + HTTP_STATUS.BAD_GATEWAY, + malformedMessage, + undefined, + { code: malformed.code, type: malformed.type } + ); + persistAttemptLogs({ + status: HTTP_STATUS.BAD_GATEWAY, + tokens: usage, + responseBody, + providerRequest: finalBody || translatedBody, + providerResponse: looksLikeSSE + ? { _streamed: true, _format: "sse-json", summary: responseBody } + : responseBody, + clientResponse: malformedClientBody, + claudeCacheMeta: claudePromptCacheLogMeta, + claudeCacheUsageMeta: cacheUsageLogMeta, + cacheSource: "upstream", + }); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response"); + trackPendingRequest(model, provider, pendingConnId, false); + // Routing event (feedback foundation) — record the malformed outcome so + // the quality tracker de-prioritizes this model over time. + void emitRoutingEvent( + createRoutingEvent({ + requestId: traceId || pendingRequestId || "unknown", + provider: provider || "unknown", + model: model || "unknown", + strategy: isCombo ? (comboStrategy ?? "combo") : "direct", + latencyMs: Date.now() - startTime, + ttftMs: null, + inputTokens: null, + outputTokens: null, + cost: null, + retries: 0, + fallbackUsed: false, // combo-level fallback tracked by decisionTrace + outcome: "malformed", + status: HTTP_STATUS.BAD_GATEWAY, + finishReason: routingFinishReason(translatedResponse), + connectionId: credentials?.connectionId ?? null, + }) + ); + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + malformedMessage, + null, + malformed.code, + malformed.type + ); + } + + // ── Phase 9.1: Cache store (non-streaming, temp=0) ── + storeSemanticCacheResponse({ + enabled: semanticCacheEnabled, + body: bodyForCacheWrite, + headers: clientRawRequest?.headers, + translatedResponse, + model, + apiKeyId: apiKeyInfo?.id ?? undefined, + usage, + log, + }); + + // ── Phase 9.2: Save for idempotency ── + // Reuse the key resolved by checkIdempotencyCache() above (single derivation per + // request). (#3821-review LEDGER-6) + saveIdempotency(idempotencyKey, translatedResponse, 200); + reqLogger.logConvertedResponse(translatedResponse); persistAttemptLogs({ - status: HTTP_STATUS.BAD_REQUEST, + status: 200, tokens: usage, responseBody, providerRequest: finalBody || translatedBody, @@ -5197,7 +5352,7 @@ export async function handleChatCore({ summary: responseBody, } : responseBody, - clientResponse: buildErrorBody(HTTP_STATUS.BAD_REQUEST, guardrailMessage), + clientResponse: translatedResponse, claudeCacheMeta: claudePromptCacheLogMeta, claudeCacheUsageMeta: cacheUsageLogMeta, cacheSource: "upstream", @@ -5205,76 +5360,61 @@ export async function handleChatCore({ if (apiKeyInfo?.id && estimatedCost > 0) { recordCost(apiKeyInfo.id, estimatedCost); } - log?.warn?.( - "GUARDRAIL", - `Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}` - ); + + // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open === + await scheduleQuotaShareConsumption({ + apiKeyId: apiKeyInfo?.id, + connectionId: credentials?.connectionId, + provider, + model, + usage, + estimatedCost, + log, + }); + // === /Quota Share POST-hook === + + // ── Gamification event (fire-and-forget) ── + await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); + finalizePendingScope(pendingScope, { providerResponse: responseBody, clientResponse: translatedResponse, }); - return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage); - } + const responseHeaders = buildNonStreamingResponseHeaders({ + provider, + model, + startTime, + responseUsage, + estimatedCost, + requestId: skillRequestId, + compressionResponseMeta, + comboStrategy, + }); + // #6426: align response body `model` with the `X-OmniRoute-Model` header + // (both must be the resolved backend model). Some upstreams (notably legacy + // /v1/completions text-completion path) return a body `model` field that + // differs from the resolved backend id we advertised in the header, leaving + // strict clients unable to reconcile the two. Rewrite body.model to `model` + // FIRST, then let #1311 echo override it when the opt-in setting is on. + if (typeof model === "string" && model) echoModelInObject(translatedResponse, model); + // #1311: echo the requested alias/combo name in the non-streaming response model. + if (echoModel) echoModelInObject(translatedResponse, echoModel); - // Validate the *translated* response actually carries client-usable output. - // isEmptyContentResponse (above) runs on the raw responseBody before translation; - // this check runs after translation + sanitization + tool-call execution to catch - // cases where a provider returns a structurally valid raw body that translates into - // choices:[] or output:[] with no usable content (Responses API shape included). - const malformedTranslatedReason = detectMalformedNonStream(translatedResponse); - if (malformedTranslatedReason) { - const totalLatency = Date.now() - startTime; - const rawBytes = (() => { - try { - return JSON.stringify(responseBody || {}).length; - } catch { - return -1; - } - })(); - reportMalformed200({ - mode: "nonstream", - provider, - model, - connectionId, - reason: malformedTranslatedReason, - recvBytes: rawBytes, - recvLines: -1, - emitted: -1, - events: {}, - ttftMs: totalLatency, - elapsedMs: totalLatency, - }); - appendRequestLog({ + // ── Plugin onResponse hook (fire-and-forget) ── + // #8395: the streaming branch below already calls this; the non-streaming + // (stream:false) branch returned without it, so onResponse never fired for + // non-streaming requests at all. + await runPluginOnResponseHook({ + requestId: traceId, + body, model, provider, - connectionId, - status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, - }).catch(() => {}); - const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason); - const malformedMessage = `[${provider}/${model}] ${malformed.message}`; - const malformedClientBody = buildErrorBody( - HTTP_STATUS.BAD_GATEWAY, - malformedMessage, - undefined, - { code: malformed.code, type: malformed.type } - ); - persistAttemptLogs({ - status: HTTP_STATUS.BAD_GATEWAY, - tokens: usage, - responseBody, - providerRequest: finalBody || translatedBody, - providerResponse: looksLikeSSE - ? { _streamed: true, _format: "sse-json", summary: responseBody } - : responseBody, - clientResponse: malformedClientBody, - claudeCacheMeta: claudePromptCacheLogMeta, - claudeCacheUsageMeta: cacheUsageLogMeta, - cacheSource: "upstream", + apiKeyInfo, + headers: clientRawRequest?.headers, + response: { status: 200, data: translatedResponse }, }); - persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response"); - trackPendingRequest(model, provider, pendingConnId, false); - // Routing event (feedback foundation) — record the malformed outcome so - // the quality tracker de-prioritizes this model over time. + + // Routing event (feedback foundation) — fire-and-forget, cheap. void emitRoutingEvent( createRoutingEvent({ requestId: traceId || pendingRequestId || "unknown", @@ -5283,158 +5423,38 @@ export async function handleChatCore({ strategy: isCombo ? (comboStrategy ?? "combo") : "direct", latencyMs: Date.now() - startTime, ttftMs: null, - inputTokens: null, - outputTokens: null, - cost: null, + inputTokens: + usage && typeof usage === "object" + ? (() => { + const promptTokens = (usage as Record).prompt_tokens; + return typeof promptTokens === "number" && Number.isFinite(promptTokens) + ? promptTokens + : null; + })() + : null, + outputTokens: + usage && typeof usage === "object" + ? (() => { + const completionTokens = (usage as Record).completion_tokens; + return typeof completionTokens === "number" && Number.isFinite(completionTokens) + ? completionTokens + : null; + })() + : null, + cost: Number.isFinite(estimatedCost) ? estimatedCost : null, retries: 0, fallbackUsed: false, // combo-level fallback tracked by decisionTrace - outcome: "malformed", - status: HTTP_STATUS.BAD_GATEWAY, + outcome: "success", + status: 200, finishReason: routingFinishReason(translatedResponse), connectionId: credentials?.connectionId ?? null, }) ); - return createErrorResult( - HTTP_STATUS.BAD_GATEWAY, - malformedMessage, - null, - malformed.code, - malformed.type - ); - } - // ── Phase 9.1: Cache store (non-streaming, temp=0) ── - storeSemanticCacheResponse({ - enabled: semanticCacheEnabled, - body: bodyForCacheWrite, - headers: clientRawRequest?.headers, - translatedResponse, - model, - apiKeyId: apiKeyInfo?.id ?? undefined, - usage, - log, - }); - - // ── Phase 9.2: Save for idempotency ── - // Reuse the key resolved by checkIdempotencyCache() above (single derivation per - // request). (#3821-review LEDGER-6) - saveIdempotency(idempotencyKey, translatedResponse, 200); - reqLogger.logConvertedResponse(translatedResponse); - persistAttemptLogs({ - status: 200, - tokens: usage, - responseBody, - providerRequest: finalBody || translatedBody, - providerResponse: looksLikeSSE - ? { - _streamed: true, - _format: "sse-json", - summary: responseBody, - } - : responseBody, - clientResponse: translatedResponse, - claudeCacheMeta: claudePromptCacheLogMeta, - claudeCacheUsageMeta: cacheUsageLogMeta, - cacheSource: "upstream", - }); - if (apiKeyInfo?.id && estimatedCost > 0) { - recordCost(apiKeyInfo.id, estimatedCost); - } - - // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open === - await scheduleQuotaShareConsumption({ - apiKeyId: apiKeyInfo?.id, - connectionId: credentials?.connectionId, - provider, - model, - usage, - estimatedCost, - log, - }); - // === /Quota Share POST-hook === - - // ── Gamification event (fire-and-forget) ── - await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); - - finalizePendingScope(pendingScope, { - providerResponse: responseBody, - clientResponse: translatedResponse, - }); - const responseHeaders = buildNonStreamingResponseHeaders({ - provider, - model, - startTime, - responseUsage, - estimatedCost, - requestId: skillRequestId, - compressionResponseMeta, - comboStrategy, - }); - // #6426: align response body `model` with the `X-OmniRoute-Model` header - // (both must be the resolved backend model). Some upstreams (notably legacy - // /v1/completions text-completion path) return a body `model` field that - // differs from the resolved backend id we advertised in the header, leaving - // strict clients unable to reconcile the two. Rewrite body.model to `model` - // FIRST, then let #1311 echo override it when the opt-in setting is on. - if (typeof model === "string" && model) echoModelInObject(translatedResponse, model); - // #1311: echo the requested alias/combo name in the non-streaming response model. - if (echoModel) echoModelInObject(translatedResponse, echoModel); - - // ── Plugin onResponse hook (fire-and-forget) ── - // #8395: the streaming branch below already calls this; the non-streaming - // (stream:false) branch returned without it, so onResponse never fired for - // non-streaming requests at all. - await runPluginOnResponseHook({ - requestId: traceId, - body, - model, - provider, - apiKeyInfo, - headers: clientRawRequest?.headers, - response: { status: 200, data: translatedResponse }, - }); - - // Routing event (feedback foundation) — fire-and-forget, cheap. - void emitRoutingEvent( - createRoutingEvent({ - requestId: traceId || pendingRequestId || "unknown", - provider: provider || "unknown", - model: model || "unknown", - strategy: isCombo ? (comboStrategy ?? "combo") : "direct", - latencyMs: Date.now() - startTime, - ttftMs: null, - inputTokens: - usage && typeof usage === "object" - ? (() => { - const promptTokens = (usage as Record).prompt_tokens; - return typeof promptTokens === "number" && Number.isFinite(promptTokens) - ? promptTokens - : null; - })() - : null, - outputTokens: - usage && typeof usage === "object" - ? (() => { - const completionTokens = (usage as Record).completion_tokens; - return typeof completionTokens === "number" && Number.isFinite(completionTokens) - ? completionTokens - : null; - })() - : null, - cost: Number.isFinite(estimatedCost) ? estimatedCost : null, - retries: 0, - fallbackUsed: false, // combo-level fallback tracked by decisionTrace - outcome: "success", - status: 200, - finishReason: routingFinishReason(translatedResponse), - connectionId: credentials?.connectionId ?? null, - }) - ); - - return { - success: true, - response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders), - }; + return { + success: true, + response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders), + }; } catch (error) { trackPendingRequest(model, provider, connectionId, false); if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error); From 3ab53d188c31efbc2954c90709770a29df7bcd11 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:10:03 -0300 Subject: [PATCH 02/15] fix(sse): restore abort mapping, cache telemetry and fence safety on the non-streaming leg (#12990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consertadas 5 das 7 regressões que o #12867 introduziu em `tests/unit/chatcore-translation-paths.test.ts` — arquivo que ele não toca, e por isso fora da minha validação focada quando o mergeei. Medido: **74/74** em `ce49d96` (antes), **67/74** em `d6f3150` (depois), **72/74** agora. **Abort de cliente perdeu o mapeamento (3 testes).** O leg classificava por `error.name === "AbortError"`, mas `abort(reason)` pode rejeitar com string crua sem `name` — essa forma caía em 502 em vez de 499, o que o #7907 fixou. E a mensagem passava por `formatProviderError`, entregando `[499]: request aborted by client` ao cliente. O `chatCore` sempre usou `isLocalStreamLifecycleError` e o literal `"Request aborted"`; espelhado. **`clientResponse` sintético em abort (1 teste).** O caminho antigo omitia o campo porque o cliente já tinha desconectado — esse corpo é o que teríamos enviado, e o dashboard lê o campo como "o que o cliente recebeu". O caminho novo gravava sempre. **Telemetria de prompt cache sumiu do call log (1 teste).** `claudePromptCacheLogMeta` só era construído dentro do `executeProviderRequest`; o leg virou dono do primeiro send e a variável ficou `null`, então `_omniroute.claudePromptCache` desapareceu **em silêncio** de todo call log desse caminho. Não é teste chato: é observabilidade perdida em produção. **Corpo não canonicalizável derrubava a request (1 teste).** `derivePostInjectionRequestIdentity` era chamado antes de qualquer checagem de flag; ele canonicaliza o corpo e o `canonicalStringify` rejeita `Date`, `Map` e instâncias de classe por desenho. Um corpo com essas formas lançava `TypeError` em **toda** request não-streaming, inclusive com `SERVER_OWNED_TOOL_LOOP_ENABLED` desligada, que é o default. Agora deriva só quando o loop pode rodar e falha fechada. Evidência: 74 testes do arquivo 72/74; 138 nas 5 suítes vizinhas com 136 passando; `typecheck:core` limpo; `check-api-typecheck` OK 289; ESLint 0. **As 2 restantes ficam abertas de propósito** — `refreshes GitHub credentials after 401` e `locks per-model quota failures`. Mesma causa: o leg encerra num não-2xx sem passar pela classificação de falha do `chatCore`. `nonStreamingProviderLeg.ts` não tem uma ocorrência de `lockModel`, `refreshCredentials` ou `markAccountUnavailable`; o `chatCore` tem ~170 linhas disso mais o bloco de refresh 401. Em produção: token Copilot não renova no 401, e 402/429 por quota não trava o modelo naquela conexão. Não consertei porque devolver a `Response` ao `chatCore` é impossível (já consumida por `.text()`) e reimplementar a classificação no leg é decisão de desenho do refactor — @HouMinXi tem o contexto. --- .../chatcore-nonstreaming-regressions.md | 1 + open-sse/handlers/chatCore.ts | 54 ++++++++++++++++--- .../chatCore/nonStreamingProviderLeg.ts | 26 +++++---- 3 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fixes/chatcore-nonstreaming-regressions.md diff --git a/changelog.d/fixes/chatcore-nonstreaming-regressions.md b/changelog.d/fixes/chatcore-nonstreaming-regressions.md new file mode 100644 index 0000000000..b11fdc89e8 --- /dev/null +++ b/changelog.d/fixes/chatcore-nonstreaming-regressions.md @@ -0,0 +1 @@ +- Restore four non-streaming behaviours the server-owned tool loop refactor dropped: client aborts map to 499 with the fixed `Request aborted` message, an aborted request no longer logs a synthetic `clientResponse`, Claude prompt-cache telemetry is recorded again, and a body that cannot be canonicalized no longer throws when the tool loop is off. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 96ff6a260d..446cb84a9f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4927,7 +4927,14 @@ export async function handleChatCore({ error: err.error || "Provider request failed", providerRequest: finalBody || translatedBody, providerResponse: isNetworkThrow ? undefined : err.response, - clientResponse: buildErrorBody(err.status, err.error || "Provider request failed"), + // On a client abort the client already disconnected before we got here, so this + // body is what we WOULD have sent, not what was delivered. The dashboard reads + // `clientResponse` as "what the client received", so logging it misleads — + // `error` above already records the reason. The pre-#12867 path omitted it here; + // the leg-based path must keep doing so. + clientResponse: isLocalStreamLifecycleError(err.originalError) + ? undefined + : buildErrorBody(err.status, err.error || "Provider request failed"), cacheSource: "upstream", }); persistFailureUsage(err.status, err.errorCode || `upstream_${err.status}`); @@ -4939,8 +4946,34 @@ export async function handleChatCore({ const expectedConn = managedLease ? String(getCurrentConnectionId() || connectionId || "") || undefined : undefined; + // The identity is the tool loop's execution fence key, and deriveToolRequestIdentity + // canonicalizes the body — which by design rejects Dates, Maps and class instances. + // It was computed eagerly, so a body carrying any of those threw on EVERY + // non-streaming request even with SERVER_OWNED_TOOL_LOOP_ENABLED off (the default). + // Derive it only when the loop can run, and fail closed rather than crash: no + // identity means no fence, and without a fence the loop must not run. + let toolLoopEnabled = isServerOwnedToolLoopEnabled(); + let postInjectionRequestIdentity = ""; + if (toolLoopEnabled) { + try { + postInjectionRequestIdentity = derivePostInjectionRequestIdentity({ + apiKeyId: memoryOwnerId || "local", + headers: clientRawRequest?.headers ?? null, + skillRequestId, + postInjectionBody: (body || {}) as Record, + }); + } catch (identityError) { + log?.warn?.( + "SERVER_OWNED_TOOL_LOOP", + `request body is not canonicalizable, skipping the loop: ${ + identityError instanceof Error ? identityError.message : "unknown" + }` + ); + toolLoopEnabled = false; + } + } const loopApply = await applyServerOwnedToolLoopIfNeeded({ - enabled: isServerOwnedToolLoopEnabled(), + enabled: toolLoopEnabled, stream, isResponsesEndpoint, sourceFormat, @@ -4951,12 +4984,7 @@ export async function handleChatCore({ apiKeyId: memoryOwnerId || "local", sessionId: pipelineSessionId, requestId: skillRequestId, - requestIdentity: derivePostInjectionRequestIdentity({ - apiKeyId: memoryOwnerId || "local", - headers: clientRawRequest?.headers ?? null, - skillRequestId, - postInjectionBody: (body || {}) as Record, - }), + requestIdentity: postInjectionRequestIdentity, builtinToolNames: injectionResult.builtinToolNames, injectedCustomSkillNames: injectionResult.injectedCustomSkillNames, customSkillExecutionEnabled: @@ -5066,6 +5094,16 @@ export async function handleChatCore({ providerHeaders = normalizeHeaders(okLeg.headers); } finalBody = providerRequestCapture.body(okLeg.providerRequest || translatedBody); + // Built inside executeProviderRequest on the pre-#12867 path. The leg now owns the + // first non-streaming send, so that assignment never runs here and the meta stayed + // null — `_omniroute.claudePromptCache` silently vanished from every call log on + // this path. Same inputs, same helper, at the point where they are available. + claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta( + targetFormat, + finalBody, + providerHeaders, + clientRawRequest?.headers + ); const capturedOk = providerRequestCapture.latest?.(); reqLogger.logTargetRequest( okLeg.requestUrl || capturedOk?.url || "", diff --git a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts index 0e8f2e5969..7656939533 100644 --- a/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts +++ b/open-sse/handlers/chatCore/nonStreamingProviderLeg.ts @@ -23,6 +23,7 @@ import { restoreNonStreamingToolNames } from "./passthroughToolNames.ts"; import { extractUsageFromResponse } from "../usageExtractor.ts"; import { sanitizeUsagePayloadForRequest } from "../../utils/usageTracking.ts"; import { createErrorResult, formatProviderError } from "../../utils/error.ts"; +import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker"; import { unwrapClinepassEnvelope } from "../../utils/clinepassEnvelope.ts"; import { unwrapClineNonStreamingEnvelope } from "./clineResponseEnvelope.ts"; import { @@ -232,7 +233,6 @@ function parseRetryAfterMs(response: Response): number | null { return null; } - function finishOk( input: ProviderLegInput, params: { @@ -462,14 +462,22 @@ export async function runNonStreamingProviderLeg( ) { throw error; } - const failureStatus = - error instanceof Error && error.name === "AbortError" - ? 499 - : error instanceof Error && error.name === "TimeoutError" - ? 504 - : 502; - const failureMessage = - error instanceof Error + // `abort(reason)` can reject with a raw string that has no `name`/`status`, so + // `error.name === "AbortError"` is too narrow — that shape fell through to the 502 + // provider-failure default (#7907). chatCore classified this through + // isLocalStreamLifecycleError before this leg took over the first send; mirror it. + const isRequestAborted = isLocalStreamLifecycleError(error); + const failureStatus = isRequestAborted + ? 499 + : error instanceof Error && error.name === "TimeoutError" + ? 504 + : 502; + // A client abort is not a provider failure: formatProviderError would stamp the raw + // upstream text as `[499]: `, leaking it to the client. chatCore has always + // normalized this to the fixed "Request aborted". + const failureMessage = isRequestAborted + ? "Request aborted" + : error instanceof Error ? formatProviderError(error, provider, currentModel, failureStatus) : "Provider request failed"; const receipt = buildReceipt(input, { From 89c42d36dfae70b6e15d4873b8f59d4523489ae2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:10:36 -0300 Subject: [PATCH 03/15] fix(security): redact AIza credentials of any length in error bodies (#12964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vazamento de credencial em corpo de erro. `tests/unit/error-sanitizer-sk-key-qv45.test.ts` falhava no tip em 8ms: ``` AssertionError: Google key survived: Bad credentials for AIzaSyA1B2C3D4E5F6G7H8I9J0KaLbMcNdOeP ``` O padrão era `/AIza[0-9A-Za-z_-]{35}/` — comprimento **exato**. Uma chave Google padrão tem 39 caracteres e casa; qualquer credencial `AIza…` mais curta ou mais longa passava direto para o corpo do erro. Os dois lados divergiram na reconciliação de dois PRs do mesmo GHSA: o padrão com `{35}` veio do #12506, o teste anti-drift que cobra `/\\bAIza[A-Za-z0-9_-]{20,}/` veio do #12620. Está vermelho desde que os dois entraram em sequência. `{20,}` no lugar de `{35}`. Numa mensagem de erro, redigir demais uma string que apenas começa com `AIza` não custa nada; redigir de menos vaza credencial — o lado errado para errar é claro. Evidência: o arquivo vai de 7/9 para **9/9**. Bateria de sanitização com 538 testes: 533 passam, e as 5 restantes são pré-existentes no tip, não desta mudança (4 levam 21–25s por spawn de processo isolado sob carga; `tunnel-routes-error-sanitization` falha igual no tip puro, verificado). Nenhum teste foi enfraquecido — o padrão foi ampliado para satisfazer uma asserção que já existia. --- changelog.d/fixes/google-key-redaction-length.md | 1 + open-sse/utils/credentialPatterns.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/google-key-redaction-length.md diff --git a/changelog.d/fixes/google-key-redaction-length.md b/changelog.d/fixes/google-key-redaction-length.md new file mode 100644 index 0000000000..e212604119 --- /dev/null +++ b/changelog.d/fixes/google-key-redaction-length.md @@ -0,0 +1 @@ +- Redact Google API keys of any length in error bodies: the pattern required exactly 39 characters, so shorter or longer `AIza…` credentials passed through unredacted. diff --git a/open-sse/utils/credentialPatterns.ts b/open-sse/utils/credentialPatterns.ts index 02784a4ae5..b9a2366d70 100644 --- a/open-sse/utils/credentialPatterns.ts +++ b/open-sse/utils/credentialPatterns.ts @@ -18,7 +18,13 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [ regex: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:anthropic]", }, - { name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" }, + // {20,} rather than the exact {35} of a standard 39-char Google API key. #12506 added + // this pattern with the exact length; #12620 landed the anti-drift test that asserts + // /\bAIza[A-Za-z0-9_-]{20,}/ must not survive. Anything shorter or longer than 39 was + // therefore passing straight through into error bodies. In an error message + // over-redacting a string that merely starts with AIza costs nothing; under-redacting + // one leaks a credential, so the loose bound is the correct side to err on. + { name: "google", regex: /AIza[0-9A-Za-z_-]{20,}/g, replacement: "[REDACTED:google]" }, { name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" }, { name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" }, { name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" }, From 10fef01d20ab24a23527cbedc58e08d350687e75 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:10:40 -0300 Subject: [PATCH 04/15] docs: sync migration and strategy counts with the code (#12970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contadores de docs fora de sincronia com o código, aprovado pelo dono em chat por tocar `AGENTS.md` e `skills/cli-tunnel/SKILL.md` (Hard Rule — superfície de instrução de agente). Nenhuma instrução mudou. `Docs Gates` acusava 6 drifts STRICT. Dois vieram da minha leva de 16 PRs: migrations 169 → **171** (#12707 trouxe a 173, #12867 a 174) e estratégias de roteamento 19 → **20** (#12789 registrou a `quota-weighted`). Contei os arquivos em vez de confiar na memória: `ls src/lib/db/migrations/*.sql | wc -l` → 171. Os 41 mirrors de `docs/i18n/*/llm.txt` foram regenerados com `scripts/i18n/sync-llm-mirrors.mjs` — o gate exige cópia exata da raiz. O outro braço, `check:agent-skills-sync` acusando `GENERATED: + cli-tunnel`, era herdado (o corpo do #12866 já o registrava). O `SKILL.md` commitado documentava `tunnel create [type]`, um argumento que a CLI **não aceita** — conferido em `bin/cli/commands/tunnel.mjs:21`, que declara `.command("create")` puro. Saída do gerador, não escrita à mão. | gate | antes | depois | |---|---|---| | `check:docs-counts` | 6 drifts STRICT | **0** | | `check:docs-sync` | FAIL — 41 mirrors divergentes | **PASS** | | `check:agent-skills-sync` | `+ cli-tunnel` | **UNCHANGED: 46 skills** | --- AGENTS.md | 2 +- README.md | 2 +- .../doc-counts-171-migrations-20-strategies.md | 1 + docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/readme-hero.svg | 2 +- docs/diagrams/tier-cascade.svg | 2 +- docs/i18n/ar/llm.txt | 8 ++++---- docs/i18n/az/llm.txt | 8 ++++---- docs/i18n/bg/llm.txt | 8 ++++---- docs/i18n/bn/llm.txt | 8 ++++---- docs/i18n/cs/llm.txt | 8 ++++---- docs/i18n/da/llm.txt | 8 ++++---- docs/i18n/de/llm.txt | 8 ++++---- docs/i18n/es/llm.txt | 8 ++++---- docs/i18n/fa/llm.txt | 8 ++++---- docs/i18n/fi/llm.txt | 8 ++++---- docs/i18n/fr/llm.txt | 8 ++++---- docs/i18n/gu/llm.txt | 8 ++++---- docs/i18n/he/llm.txt | 8 ++++---- docs/i18n/hi/llm.txt | 8 ++++---- docs/i18n/hu/llm.txt | 8 ++++---- docs/i18n/id/llm.txt | 8 ++++---- docs/i18n/it/llm.txt | 8 ++++---- docs/i18n/ja/llm.txt | 8 ++++---- docs/i18n/ko/llm.txt | 8 ++++---- docs/i18n/mr/llm.txt | 8 ++++---- docs/i18n/ms/llm.txt | 8 ++++---- docs/i18n/nl/llm.txt | 8 ++++---- docs/i18n/no/llm.txt | 8 ++++---- docs/i18n/phi/llm.txt | 8 ++++---- docs/i18n/pl/llm.txt | 8 ++++---- docs/i18n/pt-BR/llm.txt | 8 ++++---- docs/i18n/pt/llm.txt | 8 ++++---- docs/i18n/ro/llm.txt | 8 ++++---- docs/i18n/ru/llm.txt | 8 ++++---- docs/i18n/sk/llm.txt | 8 ++++---- docs/i18n/sv/llm.txt | 8 ++++---- docs/i18n/sw/llm.txt | 8 ++++---- docs/i18n/ta/llm.txt | 8 ++++---- docs/i18n/te/llm.txt | 8 ++++---- docs/i18n/th/llm.txt | 8 ++++---- docs/i18n/tr/llm.txt | 8 ++++---- docs/i18n/uk-UA/llm.txt | 8 ++++---- docs/i18n/ur/llm.txt | 8 ++++---- docs/i18n/vi/llm.txt | 8 ++++---- docs/i18n/zh-CN/llm.txt | 8 ++++---- docs/i18n/zh-TW/llm.txt | 8 ++++---- llm.txt | 8 ++++---- skills/cli-tunnel/SKILL.md | 4 ++-- 49 files changed, 176 insertions(+), 175 deletions(-) create mode 100644 changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md diff --git a/AGENTS.md b/AGENTS.md index 6e7ad18f2f..1c0a979789 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (169 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (171 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/README.md b/README.md index b64f3dc543..8753569c47 100644 --- a/README.md +++ b/README.md @@ -1244,7 +1244,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 171 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md b/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md new file mode 100644 index 0000000000..d5a857d295 --- /dev/null +++ b/changelog.d/maintenance/doc-counts-171-migrations-20-strategies.md @@ -0,0 +1 @@ +- Bring the documented counts back in line with the code: 171 migrations (was 169) and 20 routing strategies (was 19), plus the regenerated `cli-tunnel` skill reference. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 92fe11718c..ede29cd43a 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 6f56f7edfc..ba64577494 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. diff --git a/docs/diagrams/tier-cascade.svg b/docs/diagrams/tier-cascade.svg index 5dfe2314fd..d2d7f93f62 100644 --- a/docs/diagrams/tier-cascade.svg +++ b/docs/diagrams/tier-cascade.svg @@ -90,7 +90,7 @@ OmniRoute — Smart Router - RTK + Caveman compression · 19 routing strategies + RTK + Caveman compression · 20 routing strategies Circuit breakers · TLS stealth · MCP · A2A · Guardrails diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 9f405eda91..f6add5b77d 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 74a600ba34..9dab70a26b 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 0db6d8a4ce..ee99596179 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index a7417f642e..4db6e639b5 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index c8b5c31922..e073c3be2c 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 3d9663c28f..a234875f15 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 0c8572e4f4..ddf7e03f64 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index 75a6488e37..1039568356 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index cdcccf2fc8..d9808e8436 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 66d62309f4..e4f7a201f3 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index dabd29e87f..93d66e6451 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 21dad6d8f9..5dc2b7929e 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 4174e2aa0f..56bc6752f3 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index b08baf5189..89c09b2fbb 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 13109da6fc..d0ec559160 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 77df7b4c16..0b42b77310 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index b479c853cd..4185cf36f1 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index a1528c646b..d2e76a9303 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 4d8508fd12..b1a4061328 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 8ba71d98f5..3a2aae0929 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 3903f94cf1..6c6dd28642 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 8db8eb985b..03fd86a100 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index e635301c86..65760eca18 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index b760a56857..49f5903147 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index e683cdb76c..502816502c 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 49a62961e7..0b35be9e16 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 9362022f86..a9199b8f7a 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index fd21c2042b..00511f6036 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index d68314887e..fd44211ffc 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 666d1a570e..236aa18200 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 74238247ae..fd167e45ef 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 5f129a8263..e6cc4f9e5b 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index d42fa346a0..8b3c4567b2 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 6ca040018f..bbe2b4fd6d 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 9884ab3b21..90c2ab1626 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 77b9eadb1b..c432dc480f 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 3a7bc01f36..9d6b0422ca 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index 1cdd0e6bb5..6d6c81eb2d 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 3e28435b0a..1e3e334685 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index d11163a83a..decf982bcf 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 56256a53da..58ce9f5917 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/llm.txt b/llm.txt index 3748b08432..2ebe387b79 100644 --- a/llm.txt +++ b/llm.txt @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 169 versioned SQL migration files +│ │ │ └── migrations/ # 171 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/skills/cli-tunnel/SKILL.md b/skills/cli-tunnel/SKILL.md index 4d7388bb7b..d62aab6abb 100644 --- a/skills/cli-tunnel/SKILL.md +++ b/skills/cli-tunnel/SKILL.md @@ -37,12 +37,12 @@ omniroute tunnel omniroute tunnel list ``` -### `tunnel create [type]` +### `tunnel create` **Example:** ```bash -omniroute tunnel create [type] +omniroute tunnel create ``` ### `tunnel stop ` From 86b1cb84feb495e4c040dcb9526304a00904fd3e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:11:13 -0300 Subject: [PATCH 05/15] feat(release): reconcile-changelog tool + version-anchored fragment aggregation (#12987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): reconcile-changelog tool + version-anchored fragment aggregation `npm run release:reconcile` (scripts/release/reconcile-changelog.mjs) turns the v3.8.51 reconciliation pass (#12971) into a repeatable Phase 0a step: - folds `changelog.d/` fragments under `## []` — never under the first matching heading — and credits each one with the PR of the commit that ADDED it (`git log --diff-filter=A`), because the filename prefix is not reliable (issue numbers, closed/recreated PRs, literal `#PR_NUMBER`); `--carrier N` marks a PR that only back-filled fragments for other people's PRs - drops fragments whose text already ships in another version section (phantom fragments re-added by branches cut before the previous aggregation) - covers a commit only when its OWN PR is a primary ref of a bullet (a `/pull/N` link, the trailing `(#N)`, or an explicit `(#N …)` group) so an incidental mention cannot hide a PR's own bullet; generates `**type(scope):** subject (#PR) — thanks @author` for the rest, rolls Dependabot bumps into one line, documents direct pushes by hash - `--credit N=handle` carries the closed-PR / co-author / deleted-PR audit - keeps pre-existing section bullets verbatim (changelog-integrity compares bullet lines), never touches `[Unreleased]` or older sections - opens the section with "📊 Release by the numbers" + "🏆 Top 25" (mailmap + merged-PR login), the v3.8.50 format `aggregate-changelog.mjs` gains the same anchoring: `insertBullets(text, bullets, version)` searches the heading inside `## [version]` only (with `[Unreleased]` still carrying `### ✨ New Features`, every feature fragment was landing there); `aggregate()` reads the version from package.json. Tests: tests/unit/reconcile-changelog.test.ts (helpers + an end-to-end reconcile fixture) and two new cases in tests/unit/changelog-fragments.test.ts. * fix(release): escape every regex metacharacter before building the mention regex CodeQL js/incomplete-sanitization on reconcile-changelog.mjs: the handle was only escaping '-' before being interpolated into a RegExp. Use a full escapeRegExp helper instead; handles are [A-Za-z0-9_-] in practice, so behaviour is unchanged for real input and the addCredit tests still pass. --- package.json | 1 + scripts/release/aggregate-changelog.mjs | 40 +- scripts/release/reconcile-changelog.mjs | 831 ++++++++++++++++++++++++ tests/unit/changelog-fragments.test.ts | 60 ++ tests/unit/reconcile-changelog.test.ts | 431 ++++++++++++ 5 files changed, 1355 insertions(+), 8 deletions(-) create mode 100644 scripts/release/reconcile-changelog.mjs create mode 100644 tests/unit/reconcile-changelog.test.ts diff --git a/package.json b/package.json index f984ef4314..5cbcf71b42 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "postbuild": "node scripts/build/colocate-standalone.mjs", "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", + "release:reconcile": "node scripts/release/reconcile-changelog.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs index 7d490045f6..efc65d0ba3 100644 --- a/scripts/release/aggregate-changelog.mjs +++ b/scripts/release/aggregate-changelog.mjs @@ -44,6 +44,15 @@ export const SECTIONS = Object.freeze({ const SKIP_FILES = new Set(["README.md", ".gitkeep"]); +/** The living cycle version = package.json `version` (null when unreadable → legacy first-heading mode). */ +export function readVersion(root = ROOT) { + try { + return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || null; + } catch { + return null; + } +} + /** * Validate one fragment's text. Returns null when OK, or a human-readable error. * Pure — unit-tested. @@ -89,17 +98,32 @@ export function collectFragments(root) { /** * Append bullets at the END of a living-section heading's bullet block (before the - * next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this - * repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested. - * Throws when a needed heading is missing (the release captain adds the heading; the - * script never invents structure). + * next "##"/"###" heading). When `version` is given the heading is searched INSIDE the + * `## [version]` block only — `[Unreleased]` still carries a `### ✨ New Features` + * heading, so the first occurrence in the file is the wrong one (v3.8.51: every feature + * fragment was landing under `[Unreleased]`, #12971). Without `version` the FIRST + * occurrence is used (legacy behaviour). Pure — unit-tested. Throws when a needed heading + * is missing (the release captain adds the heading; the script never invents structure). */ -export function insertBullets(changelogText, bulletsBySection) { +export function insertBullets(changelogText, bulletsBySection, version = null) { let lines = changelogText.split("\n"); for (const [section, heading] of Object.entries(SECTIONS)) { const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b); if (bullets.length === 0) continue; - const headIdx = lines.findIndex((l) => l.trim() === heading); + let from = 0; + let to = lines.length; + if (version) { + from = lines.findIndex((l) => l.startsWith(`## [${version}]`)); + if (from === -1) { + throw new Error( + `section "## [${version}]" not found in CHANGELOG.md — fragments must land in the living version section` + ); + } + to = lines.findIndex((l, i) => i > from && l.startsWith("## [")); + if (to === -1) to = lines.length; + } + const rel = lines.slice(from, to).findIndex((l) => l.trim() === heading); + const headIdx = rel === -1 ? -1 : from + rel; if (headIdx === -1) { throw new Error( `heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments` @@ -125,7 +149,7 @@ export function insertBullets(changelogText, bulletsBySection) { * Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is * true nothing is written or deleted. */ -export function aggregate({ root = ROOT, dryRun = false } = {}) { +export function aggregate({ root = ROOT, dryRun = false, version = readVersion(root) } = {}) { const collected = collectFragments(root); if (collected.invalid.length > 0) { const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n"); @@ -134,7 +158,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) { const total = collected.features.length + collected.fixes.length + collected.maintenance.length; const changelogPath = join(root, "CHANGELOG.md"); const before = readFileSync(changelogPath, "utf8"); - const after = total === 0 ? before : insertBullets(before, collected); + const after = total === 0 ? before : insertBullets(before, collected, version); if (!dryRun && total > 0) { writeFileSync(changelogPath, after); for (const section of Object.keys(SECTIONS)) { diff --git a/scripts/release/reconcile-changelog.mjs b/scripts/release/reconcile-changelog.mjs new file mode 100644 index 0000000000..a6e2d28739 --- /dev/null +++ b/scripts/release/reconcile-changelog.mjs @@ -0,0 +1,831 @@ +#!/usr/bin/env node +// scripts/release/reconcile-changelog.mjs +// +// Reconcile the living `## []` CHANGELOG section against the FULL development cycle +// (Phase 0a.1–0a.3a of /generate-release), so that at the moment a release is cut: +// • every cycle commit is represented by a bullet whose PRIMARY reference is that commit's PR, +// • every bullet carries the merged PR link and `— thanks @author` (Hard Rule #16), +// • fragments are folded in under the RIGHT version section (not the first heading that +// matches — `[Unreleased]` still carries a `### ✨ New Features` heading), +// • fragments that duplicate bullets already shipped in a previous version are dropped, +// • the section opens with "📊 Release by the numbers" + "🏆 Top 25 contributors" (v3.8.50 format). +// +// It never touches bullets that already exist in the section (the changelog-integrity gate +// compares bullet lines against the base), never touches `[Unreleased]`, and never edits any +// other version section. Run `npm run release:contributors -- --inject` afterwards to +// (re)build the `### 🙌 Contributors` table, then `release:sync-changelog-i18n`. +// +// Lessons baked in (v3.8.51 reconciliation, 2026-09-07 — PR #12971): +// • prefix of a fragment filename is NOT a reliable PR number (issue numbers, closed/recreated +// PRs, literal `#PR_NUMBER`); the commit that ADDED the fragment (`git log --diff-filter=A`) +// is the definitive origin — unless that commit is a "carrier" PR that only back-filled +// fragments for other people's PRs (`--carrier N`), then the prefix wins; +// • a commit is covered only when its OWN PR is a primary ref (a `/pull/N` link or the last +// `#N` on the bullet line) — an incidental mention ("Opper #11629 + 1min.ai #11631") must not +// hide a PR's own bullet; +// • gen-contributors only reads lines that start with "- ", so fragment bullets are collapsed +// to a single line (pre-existing section bullets are left verbatim). +// +// Usage: +// node scripts/release/reconcile-changelog.mjs [--version 3.8.51] [--base ] [--head ] +// [--release-branch release/v3.8.51] [--credit 12255=backryun] [--carrier 11938] +// [--drop-fragment changelog.d/fixes/x.md] [--fragment-pr changelog.d/fixes/y.md=11845] +// [--prs ] [--dry-run] [--report ] +// +// Defaults: version = package.json; release branch = release/v; head = HEAD; +// base = parent of the commit that opened the cycle (first commit carrying the version string, +// see resolveCycleBase in list-uncovered-commits.mjs) — pass `--base origin/release/v` to +// use the previous release tip explicitly. Exit 0 always (advisory: the captain reviews the diff). + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveCycleBase } from "./list-uncovered-commits.mjs"; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +export const MAINTAINER = "diegosouzapw"; +export const SECTION_HEADINGS = Object.freeze({ + features: "### ✨ New Features", + fixes: "### 🐛 Bug Fixes", + maintenance: "### 📝 Maintenance", +}); +const TYPE_LABEL = { + fix: "🐛 Fixes", + feat: "✨ Features", + docs: "📚 Docs", + chore: "🧹 Chore", + test: "🧪 Tests", + refactor: "♻️ Refactor", + perf: "⚡ Performance", + security: "🔒 Security", + ci: "⚙️ CI", + deps: "📦 Dependencies", + build: "🏗️ Build", + revert: "⏪ Reverts", + other: "🔀 Other", +}; +const BOT_RE = /dependabot|\[bot\]|^app\//i; + +// ───────────────────────────── pure helpers (unit-tested) ───────────────────────────── + +/** `[#N](url)` → `#N`, `[@h](url)` → `@h` so refs/handles can be scanned uniformly. */ +export const normalizeLinks = (s) => + s.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1").replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1"); +export const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); +export const prLink = (repo, n) => `[#${n}](https://github.com/${repo}/pull/${n})`; + +/** + * Primary refs of a bullet: every `/pull/N` link, the LAST `#N` on its first line (the + * conventional trailing `(#N)` back-reference) and every explicit `(#N …)` group — a group + * that OPENS with the ref, e.g. `(#11436)` or `(#11436 — thanks @x)`. An incidental mention + * inside prose (`(Opper #11629 + 1min.ai #11631)`) does not count. + */ +export function primaryRefs(bullet) { + const flat = normalizeLinks(bullet.replace(/\n\s+/g, " ")); + const out = new Set([...bullet.matchAll(/\/pull\/(\d+)\)/g)].map((m) => Number(m[1]))); + const refs = refsIn(flat); + if (refs.length) out.add(refs[refs.length - 1]); + for (const m of flat.matchAll(/\(#(\d+)(?=[\s,)—-])/g)) out.add(Number(m[1])); + return out; +} + +/** Commit hashes a bullet documents explicitly as `(direct commit \`\`)`. */ +export const directHashes = (bullet) => + [...bullet.matchAll(/direct commit `([0-9a-f]{7,40})`/g)].map((m) => m[1]); + +/** + * Split a markdown text into bullet blocks per section heading. A block is a `- ` line plus its + * indented continuation lines. `fixedSection` forces every bullet into one section (fragments). + * `collapse` joins continuation lines into the first line (fragments only — never pre-existing + * section bullets, which the changelog-integrity gate compares line by line). + */ +export function parseBlocks(text, { fixedSection = null, collapse = false } = {}) { + const out = { features: [], fixes: [], maintenance: [] }; + let cur = fixedSection; + let block = null; + const flush = () => { + if (block && cur) { + out[cur].push( + collapse + ? block.map((l, i) => (i ? l.trim() : l.replace(/\s+$/, ""))).join(" ") + : block.join("\n") + ); + } + block = null; + }; + for (const line of text.split("\n")) { + if (!fixedSection && line.startsWith("### ")) { + flush(); + cur = Object.keys(SECTION_HEADINGS).find((k) => SECTION_HEADINGS[k] === line.trim()) || null; + continue; + } + if (line.startsWith("- ")) { + flush(); + if (cur) block = [line]; + continue; + } + if (block && /^\s+\S/.test(line)) { + block.push(line); + continue; + } + flush(); + } + flush(); + return out; +} + +/** Escape every regex metacharacter (CodeQL js/incomplete-sanitization: never escape just one). */ +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const mentions = (b, h) => new RegExp(`@${escapeRegExp(h)}(?![A-Za-z0-9_-])`, "i").test(b); + +/** Append `— thanks @a / @b` (or extend an existing trailing thanks group) for handles not yet mentioned. */ +export function addCredit(bullet, handles, maintainer = MAINTAINER) { + const hs = [...new Set(handles.filter((h) => h && h !== maintainer && !mentions(bullet, h)))]; + if (!hs.length) return bullet; + const lines = bullet.split("\n"); + let last = lines[lines.length - 1]; + const add = hs.map((h) => `@${h}`).join(" / "); + if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\s*$/.test(last)) { + last = `${last.replace(/\s*$/, "")} / ${add}`; + } else if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\)\s*$/.test(last)) { + last = `${last.replace(/\)\s*$/, "")} / ${add})`; + } else { + last = `${last.replace(/\s*$/, "")} — thanks ${add}`; + } + lines[lines.length - 1] = last; + return lines.join("\n"); +} + +/** Append a `([#N](…))` link to the last line of a bullet, before any trailing thanks group. */ +export function appendLink(bullet, repo, n) { + if (refsIn(normalizeLinks(bullet)).includes(n)) return bullet; + const lines = bullet.split("\n"); + let last = lines[lines.length - 1]; + const th = last.match(/\s*—\s*thanks\s+@[^\n]*$/); + last = th + ? `${last.slice(0, th.index).replace(/\s*$/, "")} (${prLink(repo, n)})${th[0]}` + : `${last.replace(/\s*$/, "")} (${prLink(repo, n)})`; + lines[lines.length - 1] = last; + return lines.join("\n"); +} + +/** Conventional-commit subject → { type, text } with the type prefix bolded (repo format). */ +export function bulletFromSubject(subject, special = {}) { + let s = subject + .replace(/\s*\(#\d+\)\s*$/, "") + .replace(/^\[?URGENT\]?\s*/i, "") + .trim(); + if (special[s]) s = special[s]; + const m = s.match(/^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i); + if (!m) return { type: "other", text: s }; + return { type: m[1].toLowerCase(), text: `**${m[1].toLowerCase()}${m[2] || ""}:** ${m[4]}` }; +} + +export const sectionForType = (type) => + type === "feat" + ? "features" + : ["fix", "perf", "security", "revert"].includes(type) + ? "fixes" + : "maintenance"; + +/** Type of an existing bullet (`- **fix(x):** …` or `- fix(x): …`), else "other". */ +export function typeOfBullet(bullet) { + const m = normalizeLinks(bullet).match(/^- \*{0,2}([a-z]+)(?:\([^)]*\))?!?:\*{0,2}/i); + return m ? m[1].toLowerCase() : "other"; +} + +/** Text key used to spot twins (two fragments for one PR, a direct-commit twin of a synced PR). */ +export const dedupKey = (b) => + normalizeLinks(b) + .split("\n")[0] + .replace(/\s*\((?:#\d+[^)]*|direct commit[^)]*)\)\s*/g, " ") + .replace(/\s*—\s*thanks.*$/, "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim() + .slice(0, 100); + +/** + * Keep one bullet per dedupKey (the best-linked one) and MERGE every `([#N](…))` / + * `(direct commit …)` group the dropped twins carried into the survivor. + */ +export function dedupeBullets(list) { + const best = new Map(); + const score = (b) => (b.match(/\/pull\//g) || []).length * 10 + b.length / 1000; + for (const b of list) { + const k = dedupKey(b); + if (!best.has(k) || score(b) > best.get(k).score) best.set(k, { b, score: score(b) }); + } + const extras = new Map(); + const dropped = []; + for (const b of list) { + const k = dedupKey(b); + if (best.get(k).b === b) continue; + dropped.push(b); + if (!extras.has(k)) extras.set(k, []); + for (const m of b.matchAll(/\(\[#\d+\]\([^)]*\)\)|\(direct commit `[0-9a-f]+`\)/g)) { + extras.get(k).push(m[0]); + } + } + const seen = new Set(); + const kept = []; + for (const b of list) { + const k = dedupKey(b); + if (best.get(k).b !== b || seen.has(k)) continue; + seen.add(k); + let out = b; + for (const g of extras.get(k) || []) { + if (out.includes(g)) continue; + const th = out.match(/\s*—\s*thanks[^\n]*$/); + out = th ? `${out.slice(0, th.index)} ${g}${th[0]}` : `${out} ${g}`; + } + kept.push(out); + } + return { kept, dropped }; +} + +/** Extract `## [version]` … up to the next `## [` (exclusive). */ +export function versionSectionRange(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const m = changelog.match(new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m")); + if (!m) return null; + const bodyStart = m.index + m[0].length; + const rest = changelog.slice(bodyStart); + const next = rest.search(/\n## \[/); + return { start: m.index, bodyStart, end: next === -1 ? changelog.length : bodyStart + next }; +} + +/** Everything under every `## [` heading OTHER than `version` (used to spot already-shipped text). */ +export function otherSectionsText(changelog, version) { + const r = versionSectionRange(changelog, version); + if (!r) return changelog; + return changelog.slice(0, r.start) + changelog.slice(r.end); +} + +/** Commits covered by the bullets: own PR is a primary ref (or closes a cited issue); no-PR commits by any ref. */ +export function computeCoverage( + rows, + bullets, + { closingPrs = new Set(), originHashes = new Set(), skipHashes = new Set() } = {} +) { + const cited = new Set(); + const hashes = []; + for (const b of bullets) { + for (const n of primaryRefs(b)) cited.add(n); + hashes.push(...directHashes(b)); + } + const documentedHash = (full) => + hashes.some((h) => full.startsWith(h) || h.startsWith(full.slice(0, 9))); + const uncovered = rows.filter((r) => { + const h = r.hash.slice(0, 9); + if (skipHashes.has(h) || originHashes.has(h)) return false; + if (r.pr) return !(cited.has(r.pr) || closingPrs.has(r.pr)); + return !(documentedHash(r.hash) || r.refs.some((x) => cited.has(x))); + }); + return { cited, uncovered }; +} + +/** Rank authors by commits; key = GitHub login of the merged PR when known, else the mailmap name. */ +export function rankAuthors(rows, limit = 25) { + const counts = {}; + const names = {}; + for (const r of rows) { + if (BOT_RE.test(r.authorName)) continue; + const k = r.prAuthor || r.authorName; + counts[k] = (counts[k] || 0) + 1; + names[k] ??= {}; + names[k][r.authorName] = (names[k][r.authorName] || 0) + 1; + } + const display = (k) => Object.entries(names[k]).sort((a, b) => b[1] - a[1])[0][0]; + return { + counts, + top: Object.entries(counts) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([k, c]) => [display(k) === k ? k : `${display(k)} (@${k})`, c]), + }; +} + +const fmt = (n) => n.toLocaleString("en-US"); +const medal = (i) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : String(i + 1)); + +/** Render the whole `## [version]` section (header → stats → ranking → three sections). */ +export function renderSection({ + version, + today, + baseTip, + headTip, + rows, + all, + ranking, + prNumbers, + dateSuffix = "TBD", +}) { + const bullets = Object.values(all) + .flat() + .filter((b) => b.startsWith("- ")); + const byType = {}; + const SEC_DEFAULT = { features: "feat", fixes: "fix", maintenance: "chore" }; + for (const k of Object.keys(all)) { + for (const b of all[k].filter((x) => x.startsWith("- "))) { + let t = typeOfBullet(b); + if (!TYPE_LABEL[t] || t === "other") t = SEC_DEFAULT[k]; + byType[TYPE_LABEL[t]] = (byType[TYPE_LABEL[t]] || 0) + 1; + } + } + const prRefs = new Set(); + const handles = new Set(); + for (const b of bullets) { + for (const n of refsIn(normalizeLinks(b))) if (prNumbers.has(n)) prRefs.add(n); + for (const m of normalizeLinks(b).matchAll(/@([A-Za-z0-9_-]+)/g)) handles.add(m[1]); + } + const humans = rows.filter((r) => !BOT_RE.test(r.authorName)); + const people = new Set([...Object.keys(ranking.counts), ...handles].map((x) => x.toLowerCase())); + const lines = [ + `## [${version}] — ${dateSuffix}`, + ``, + `_Living section — reconciled ${today} from all cycle commits (\`${baseTip}\` → \`${headTip}\`, ${fmt(rows.length)} non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each \`/generate-release\` phase._`, + ``, + `### 📊 Release by the numbers`, + ``, + `| | |`, + `| --- | ---: |`, + `| 👥 People who contributed | **${fmt(people.size)}** |`, + `| 📝 Commits in the cycle | **${fmt(rows.length)}** |`, + `| 🔀 Pull requests referenced | **${fmt(prRefs.size)}** |`, + `| 📋 Changelog entries | **${fmt(bullets.length)}** |`, + `| 🙌 Contributors credited in entries | **${fmt(handles.size)}** |`, + `| 🤖 Automated dependency commits | ${rows.length - humans.length} |`, + ``, + `**Entries by type**`, + ``, + `| Type | Count |`, + `| --- | ---: |`, + ...Object.entries(byType) + .sort((a, b) => b[1] - a[1]) + .map(([t, c]) => `| ${t} | ${c} |`), + ``, + `### 🏆 Top 25 contributors this cycle`, + ``, + `_By commits in \`${baseTip}..${headTip}\`, author identities consolidated via \`.mailmap\` and the merged PR's GitHub login. Bots excluded._`, + ``, + `| # | Contributor | Commits |`, + `| ---: | --- | ---: |`, + ...ranking.top.map(([n, c], i) => `| ${medal(i)} | ${n} | ${c} |`), + ``, + ]; + for (const k of ["features", "fixes", "maintenance"]) { + lines.push(SECTION_HEADINGS[k], "", ...all[k], ""); + } + return `${lines.join("\n")}\n---\n`; +} + +// ───────────────────────────── data acquisition (git + gh) ───────────────────────────── + +const git = (args, cwd = ROOT) => + execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }).trim(); +const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 28 }); + +export function repoSlug(cwd = ROOT) { + const url = git(["remote", "get-url", "origin"], cwd); + const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/); + return m ? m[1] : "diegosouzapw/OmniRoute"; +} + +/** Non-merge commits in `base..head` with mailmap identities, PR number and co-author trailers. */ +export function readCommits(base, head, cwd = ROOT) { + const raw = git( + [ + "log", + "--no-merges", + "--use-mailmap", + "--date=short", + "--format=%H%x1f%ad%x1f%aN%x1f%aE%x1f%s%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1e)%x1e%x1e", + `${base}..${head}`, + ], + cwd + ); + return raw + .split("\x1e\x1e") + .map((s) => s.replace(/^\n/, "")) + .filter((s) => s.trim()) + .map((r) => { + const [hash, date, authorName, authorEmail, subject, coa] = r.split("\x1f"); + const refs = refsIn(subject); + const prMatch = subject.match(/\(#(\d+)\)\s*$/); + return { + hash, + date, + authorName, + authorEmail, + subject, + refs, + pr: prMatch ? Number(prMatch[1]) : null, + prAuthor: null, + coauthors: (coa || "") + .split("\x1e") + .map((s) => s.trim()) + .filter(Boolean), + }; + }); +} + +export function fetchMergedPrs(repo, releaseBranch) { + return JSON.parse( + gh([ + "pr", + "list", + "--repo", + repo, + "--state", + "merged", + "--base", + releaseBranch, + "--limit", + "1000", + "--json", + "number,title,author,body,closingIssuesReferences,mergedAt", + ]) + ); +} + +export function fetchPr(repo, n) { + try { + return JSON.parse( + execFileSync( + "gh", + [ + "pr", + "view", + String(n), + "--repo", + repo, + "--json", + "number,title,author,body,closingIssuesReferences", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + } + ) + ); + } catch { + return null; + } +} + +/** Fragment files at `ref` + the commit that ADDED each one (definitive origin of the credit). */ +export function readFragments(ref = "HEAD", cwd = ROOT) { + const out = []; + for (const dir of Object.keys(SECTION_HEADINGS)) { + let files = ""; + try { + files = git(["ls-tree", "--name-only", ref, `changelog.d/${dir}/`], cwd); + } catch { + continue; + } + for (const f of files + .split("\n") + .filter((x) => x && !/README\.md$|\.gitkeep$/.test(x)) + .sort()) { + const text = git(["show", `${ref}:${f}`], cwd); + const origin = + git(["log", "--diff-filter=A", "--format=%h%x09%s", "--", f], cwd) + .split("\n") + .filter(Boolean) + .pop() || ""; + const [ohash, osubject = ""] = origin.split("\t"); + const om = osubject.match(/\(#(\d+)\)\s*$/); + const pm = f.match(/\/(\d{4,6})-/); + out.push({ + path: f, + section: dir, + text, + originHash: ohash ? ohash.slice(0, 9) : null, + originPr: om ? Number(om[1]) : null, + prefixPr: pm ? Number(pm[1]) : null, + }); + } + } + return out; +} + +// ───────────────────────────── the reconciliation itself ───────────────────────────── + +export function reconcile({ + changelog, + version, + repo, + rows, + prs, + fragments, + extraPrs = new Map(), + credits = {}, + carriers = new Set(), + dropFragments = new Set(), + fragmentPr = {}, + fragmentCredit = {}, + today, + baseTip, + headTip, + skipHashes = new Set(), + special = {}, +}) { + const prBy = new Map(prs.map((p) => [p.number, p])); + for (const [n, p] of extraPrs) if (!prBy.has(n)) prBy.set(n, p); + const prAuthor = (n) => prBy.get(n)?.author?.login || null; + for (const r of rows) r.prAuthor = r.pr ? prAuthor(r.pr) : null; + const issueToPrs = new Map(); + for (const p of prs) + for (const ci of p.closingIssuesReferences || []) { + if (!issueToPrs.has(ci.number)) issueToPrs.set(ci.number, []); + issueToPrs.get(ci.number).push(p.number); + } + + const range = versionSectionRange(changelog, version); + if (!range) throw new Error(`CHANGELOG.md has no "## [${version}]" section`); + const existing = parseBlocks(changelog.slice(range.bodyStart, range.end)); + const shippedElsewhere = normalizeLinks(otherSectionsText(changelog, version)); + + const blocks = { + features: [...existing.features], + fixes: [...existing.fixes], + maintenance: [...existing.maintenance], + }; + const srcOf = new Map(); + for (const f of fragments) { + const parsed = parseBlocks(f.text, { fixedSection: f.section, collapse: true }); + for (const b of parsed[f.section]) { + blocks[f.section].push(b); + srcOf.set(b, f); + } + } + + const dropped = []; + const patched = []; + const mismatches = []; + const originHashes = new Set(); + for (const k of Object.keys(blocks)) { + const out = []; + for (let b of blocks[k]) { + const frag = srcOf.get(b); + const first = b.split("\n")[0].trim(); + if (frag && dropFragments.has(frag.path)) { + dropped.push({ why: "dropped by --drop-fragment", first, src: frag.path }); + continue; + } + const probe = normalizeLinks(first) + .replace(/\s*\(#\d+.*$/, "") + .slice(0, 90); + if (frag && probe.length > 40 && shippedElsewhere.includes(probe)) { + dropped.push({ + why: "text already shipped in another version section", + first, + src: frag.path, + }); + continue; + } + if (frag) { + const refs0 = refsIn(normalizeLinks(b)); + const override = fragmentPr[frag.path]; + const oPr = frag.originPr; + const fPr = frag.prefixPr; + let defPrs = override + ? [override] + : oPr && prBy.has(oPr) && !carriers.has(oPr) + ? [oPr] + : fPr && prBy.has(fPr) + ? [fPr] + : oPr && prBy.has(oPr) + ? [oPr] + : []; + if (!defPrs.length) + for (const n of refs0) for (const pr of issueToPrs.get(n) || []) defPrs.push(pr); + defPrs = [...new Set(defPrs)]; + if (oPr && fPr && prBy.has(fPr) && oPr !== fPr) { + mismatches.push( + `${frag.path}: prefix #${fPr} (${prAuthor(fPr)}) vs origin #${oPr} (${prAuthor(oPr)}) → used #${defPrs.join("/")}` + ); + } + if (b.includes("#PR_NUMBER") && defPrs.length) { + b = b + .replace(/\[#PR_NUMBER\]\([^)]*\)/g, prLink(repo, defPrs[0])) + .replace(/#PR_NUMBER/g, `#${defPrs[0]}`); + } + for (const h of fragmentCredit[frag.path] || []) b = addCredit(b, [h]); + for (const n of defPrs) { + const before = b; + b = appendLink(b, repo, n); + b = addCredit(b, [prAuthor(n), ...(credits[n] || [])]); + if (b !== before) patched.push(`${frag.path} → #${n} @${prAuthor(n)}`); + } + if (!override && !(oPr && carriers.has(oPr)) && frag.originHash) + originHashes.add(frag.originHash); + } + out.push(b); + } + blocks[k] = out; + } + + const allExisting = Object.values(blocks) + .flat() + .filter((b) => b.startsWith("- ")); + const closingPrs = new Set(); + const citedIssues = new Set(allExisting.flatMap((b) => refsIn(normalizeLinks(b)))); + for (const p of prs) + for (const ci of p.closingIssuesReferences || []) + if (citedIssues.has(ci.number)) closingPrs.add(p.number); + const { uncovered } = computeCoverage(rows, allExisting, { + closingPrs, + originHashes, + skipHashes, + }); + + const gen = { features: [], fixes: [], maintenance: [] }; + const deps = []; + for (const r of [...uncovered].sort( + (a, b) => (a.pr || 0) - (b.pr || 0) || a.date.localeCompare(b.date) + )) { + if (BOT_RE.test(r.authorName)) { + deps.push(r); + continue; + } + const c = bulletFromSubject(r.subject, special); + let bullet = `- ${c.text}`; + bullet += r.pr ? ` (${prLink(repo, r.pr)})` : ` (direct commit \`${r.hash.slice(0, 10)}\`)`; + bullet = addCredit(bullet, [ + r.pr ? prAuthor(r.pr) : null, + ...(r.pr ? credits[r.pr] || [] : []), + ]); + gen[sectionForType(c.type)].push(bullet); + } + if (deps.length) { + gen.maintenance.push( + `- **deps:** ${deps.length} Dependabot bumps — ${deps + .map( + (r) => + `${r.subject.replace(/^deps(\([^)]*\))?:\s*/, "").replace(/\s*\(#\d+\)\s*$/, "")} (${prLink(repo, r.pr)})` + ) + .join("; ")}` + ); + } + + const all = {}; + const dedupDropped = []; + for (const k of Object.keys(blocks)) { + const existingSet = new Set(existing[k]); + const merged = [...blocks[k], ...gen[k]]; + const kept = merged.filter((b) => existingSet.has(b)); + const { kept: rest, dropped: dd } = dedupeBullets(merged.filter((b) => !existingSet.has(b))); + dedupDropped.push(...dd); + all[k] = [...kept, ...rest]; + } + + const ranking = rankAuthors(rows); + const section = renderSection({ + version, + today, + baseTip, + headTip, + rows, + all, + ranking, + prNumbers: new Set(prBy.keys()), + }); + const next = `${changelog.slice(0, range.start)}${section}${changelog.slice(range.end + 1)}`; + const report = { + version, + baseTip, + headTip, + commits: rows.length, + existing: Object.fromEntries(Object.entries(existing).map(([k, v]) => [k, v.length])), + fragments: fragments.length, + generated: Object.fromEntries(Object.entries(gen).map(([k, v]) => [k, v.length])), + bullets: Object.values(all) + .flat() + .filter((b) => b.startsWith("- ")).length, + dropped, + patched: patched.length, + mismatches, + dedupDropped, + uncovered: uncovered.length, + depsRolled: deps.length, + ranking: ranking.top, + }; + return { changelog: next, report }; +} + +// ───────────────────────────── CLI ───────────────────────────── + +function parseArgs(argv) { + const o = { + credit: {}, + carriers: new Set(), + dropFragments: new Set(), + fragmentPr: {}, + dryRun: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const v = () => argv[++i]; + if (a === "--version") o.version = v(); + else if (a === "--base") o.base = v(); + else if (a === "--head") o.head = v(); + else if (a === "--release-branch") o.releaseBranch = v(); + else if (a === "--prs") o.prs = v(); + else if (a === "--report") o.report = v(); + else if (a === "--dry-run") o.dryRun = true; + else if (a === "--carrier") o.carriers.add(Number(v())); + else if (a === "--drop-fragment") o.dropFragments.add(v()); + else if (a === "--credit") { + const [n, hs] = v().split("="); + o.credit[Number(n)] = hs.split(",").map((h) => h.replace(/^@/, "")); + } else if (a === "--fragment-pr") { + const [p, n] = v().split("="); + o.fragmentPr[p] = Number(n); + } + } + return o; +} + +export function main(argv = process.argv.slice(2)) { + const o = parseArgs(argv); + const version = + o.version || JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const releaseBranch = o.releaseBranch || `release/v${version}`; + const head = o.head || "HEAD"; + let base = o.base; + if (!base) { + const { base: open, source } = resolveCycleBase(version); + base = `${open}^`; + console.log(`[reconcile-changelog] base = parent of the ${source} commit ${open.slice(0, 10)}`); + } + const repo = repoSlug(); + const rows = readCommits(base, head); + const prs = o.prs + ? JSON.parse(fs.readFileSync(o.prs, "utf8")) + : fetchMergedPrs(repo, releaseBranch); + const known = new Set(prs.map((p) => p.number)); + const extraPrs = new Map(); + for (const n of new Set(rows.filter((r) => r.pr && !known.has(r.pr)).map((r) => r.pr))) { + const p = fetchPr(repo, n); + if (p) extraPrs.set(n, p); + } + const fragments = readFragments(head); + const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8"); + const baseTip = git(["rev-parse", "--short=10", base]); + const headTip = git(["rev-parse", "--short=10", head]); + // the cycle-open bump and the living-section restore are the only legitimate non-bullet commits + const skipHashes = new Set( + rows + .filter( + (r) => + /^chore\(release\): (open v[\d.]+ development cycle|restore the living)/.test( + r.subject + ) || /^Release v[\d.]+$/.test(r.subject) + ) + .map((r) => r.hash.slice(0, 9)) + ); + const { changelog: next, report } = reconcile({ + changelog, + version, + repo, + rows, + prs, + fragments, + extraPrs, + credits: o.credit, + carriers: o.carriers, + dropFragments: o.dropFragments, + fragmentPr: o.fragmentPr, + today: new Date().toISOString().slice(0, 10), + baseTip, + headTip, + skipHashes, + }); + if (!o.dryRun) { + fs.writeFileSync(path.join(ROOT, "CHANGELOG.md"), next); + for (const f of fragments) { + try { + fs.unlinkSync(path.join(ROOT, f.path)); + } catch { + /* already gone */ + } + } + } + if (o.report) fs.writeFileSync(o.report, JSON.stringify(report, null, 1)); + const { dropped, mismatches, dedupDropped, ranking, ...summary } = report; + console.log(`[reconcile-changelog] ${o.dryRun ? "(dry-run) " : ""}${JSON.stringify(summary)}`); + for (const d of dropped) console.log(` dropped: ${d.why} — ${d.first.slice(0, 90)}`); + for (const m of mismatches) console.log(` review: ${m}`); + for (const d of dedupDropped) console.log(` deduped: ${d.slice(0, 90)}`); + console.log( + `[reconcile-changelog] next: npm run release:contributors -- ${version} --inject && npx prettier --write CHANGELOG.md && npm run release:sync-changelog-i18n -- ${version} && npm run check:changelog-integrity` + ); + return 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts index 2dddf03613..7c503fa16d 100644 --- a/tests/unit/changelog-fragments.test.ts +++ b/tests/unit/changelog-fragments.test.ts @@ -189,3 +189,63 @@ test("SECTIONS maps every dir to a real living-section heading in the fixture", assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`); } }); + +test("insertBullets with a version anchors on THAT section even when [Unreleased] has the same heading", () => { + const withUnreleasedHeading = `# Changelog + +## [Unreleased] + +### ✨ New Features + +- **unreleased**: leftover from an older cycle (#9) + +## [3.8.47] — TBD + +### ✨ New Features + +- **existing feature**: already here (#1 — thanks @a) + +### 🐛 Bug Fixes + +- **fix(x):** existing fix (#2 — thanks @b) + +## [3.8.46] - 2026-07-04 + +### ✨ New Features + +- **old feature**: shipped (#0) +`; + const out = insertBullets( + withUnreleasedHeading, + { features: [{ text: "- **new**: landed (#4)" }] }, + "3.8.47" + ); + const unreleased = out.slice(out.indexOf("## [Unreleased]"), out.indexOf("## [3.8.47]")); + const living = out.slice(out.indexOf("## [3.8.47]"), out.indexOf("## [3.8.46]")); + assert.ok(!unreleased.includes("landed (#4)"), "must not land under [Unreleased]"); + assert.ok( + living.includes("- **existing feature**: already here (#1 — thanks @a)\n- **new**: landed (#4)") + ); + assert.throws( + () => insertBullets(withUnreleasedHeading, { features: [{ text: "- x" }] }, "9.9.9"), + /section "## \[9\.9\.9\]" not found/ + ); +}); + +test("aggregate reads the living version from package.json and lands fragments under it", () => { + const root = makeRoot({ + fragments: { "features/9-new.md": "- **new**: from a fragment (#9)\n" }, + }); + writeFileSync(join(root, "package.json"), JSON.stringify({ version: "3.8.47" })); + const before = readFileSync(join(root, "CHANGELOG.md"), "utf8"); + const withHeading = before.replace( + "## [Unreleased]\n", + "## [Unreleased]\n\n### ✨ New Features\n\n- stale (#8)\n" + ); + writeFileSync(join(root, "CHANGELOG.md"), withHeading); + aggregate({ root }); + const after = readFileSync(join(root, "CHANGELOG.md"), "utf8"); + const unreleased = after.slice(after.indexOf("## [Unreleased]"), after.indexOf("## [3.8.47]")); + assert.ok(!unreleased.includes("from a fragment (#9)")); + assert.ok(after.slice(after.indexOf("## [3.8.47]")).includes("from a fragment (#9)")); +}); diff --git a/tests/unit/reconcile-changelog.test.ts b/tests/unit/reconcile-changelog.test.ts new file mode 100644 index 0000000000..83db5c7c24 --- /dev/null +++ b/tests/unit/reconcile-changelog.test.ts @@ -0,0 +1,431 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../scripts/release/reconcile-changelog.mjs"); +const { + primaryRefs, + parseBlocks, + addCredit, + appendLink, + bulletFromSubject, + sectionForType, + dedupeBullets, + computeCoverage, + versionSectionRange, + otherSectionsText, + rankAuthors, + reconcile, +} = mod; + +const REPO = "diegosouzapw/OmniRoute"; +const link = (n: number) => `[#${n}](https://github.com/${REPO}/pull/${n})`; + +test("primaryRefs: /pull links and the LAST #N count, incidental mentions do not", () => { + const b = `- **test:** counts after additions — Opper #11629 + 1min.ai #11631 (${link(11674)}) — thanks @x`; + assert.deepEqual([...primaryRefs(b)].sort(), [11674]); + assert.deepEqual( + [...primaryRefs("- fix(x): thing (#11766) (#11794)")].sort(), + [11766, 11794], + "explicit (#N) groups count" + ); + assert.deepEqual( + [...primaryRefs("- **security:** verbatim,\n across two lines (#12457)")], + [12457], + "last ref of a multi-line block" + ); + assert.deepEqual([...primaryRefs("- plain sentence, no refs")], []); +}); + +test("parseBlocks: sections by heading, continuation lines kept; fragments collapse to one line", () => { + const text = `### ✨ New Features + +- **feat(a):** one + continued here + +### 🐛 Bug Fixes + +- **fix(b):** two +- **fix(c):** three +`; + const p = parseBlocks(text); + assert.equal(p.features.length, 1); + assert.equal(p.features[0], "- **feat(a):** one\n continued here"); + assert.deepEqual(p.fixes, ["- **fix(b):** two", "- **fix(c):** three"]); + const frag = parseBlocks("- **fix(d):** wrapped\n tail line\n", { + fixedSection: "fixes", + collapse: true, + }); + assert.deepEqual(frag.fixes, ["- **fix(d):** wrapped tail line"]); +}); + +test("addCredit: appends, extends a trailing thanks group, skips maintainer and already-mentioned handles", () => { + assert.equal(addCredit("- x (#1)", ["alice"]), "- x (#1) — thanks @alice"); + assert.equal(addCredit("- x (#1) — thanks @alice", ["bob"]), "- x (#1) — thanks @alice / @bob"); + assert.equal(addCredit("- x (#1 — thanks @alice)", ["bob"]), "- x (#1 — thanks @alice / @bob)"); + assert.equal( + addCredit("- x (#1) — thanks @alice", ["alice", "diegosouzapw"]), + "- x (#1) — thanks @alice" + ); + assert.equal( + addCredit("- x by @alice-dev", ["alice"]), + "- x by @alice-dev — thanks @alice", + "handle boundary is exact" + ); +}); + +test("appendLink: adds the PR link before a trailing thanks group and never twice", () => { + const once = appendLink("- x — thanks @alice", REPO, 12); + assert.equal(once, `- x (${link(12)}) — thanks @alice`); + assert.equal(appendLink(once, REPO, 12), once); + assert.equal(appendLink("- y", REPO, 3), `- y (${link(3)})`); +}); + +test("bulletFromSubject: bolds the conventional prefix, strips (#N) and URGENT, maps specials", () => { + assert.deepEqual(bulletFromSubject("fix(cli): thing (#11794)"), { + type: "fix", + text: "**fix(cli):** thing", + }); + assert.deepEqual(bulletFromSubject("[URGENT] fix(dev): hot (#1)"), { + type: "fix", + text: "**fix(dev):** hot", + }); + assert.deepEqual(bulletFromSubject("Update README.md (#5)"), { + type: "other", + text: "Update README.md", + }); + assert.deepEqual( + bulletFromSubject("Update README.md (#5)", { "Update README.md": "docs(readme): touch-ups" }), + { + type: "docs", + text: "**docs(readme):** touch-ups", + } + ); + assert.equal(sectionForType("feat"), "features"); + assert.equal(sectionForType("perf"), "fixes"); + assert.equal(sectionForType("docs"), "maintenance"); +}); + +test("dedupeBullets: keeps the best-linked twin and merges the other twin's refs", () => { + const a = `- **fix(glm):** drop the extra arg (${link(12770)})`; + const b = "- **fix(glm):** drop the extra arg"; + const c = `- **test(check):** escape the fixture (direct commit \`fb7445eaa1\`)`; + const d = `- **test(check):** escape the fixture (${link(11942)})`; + const { kept, dropped } = dedupeBullets([b, a, c, d]); + assert.deepEqual(dropped, [b, c]); + assert.equal(kept.length, 2); + assert.equal(kept[0], a); + assert.ok( + kept[1].includes(link(11942)) && kept[1].includes("direct commit `fb7445eaa1`"), + "merged the twin's hash" + ); +}); + +test("computeCoverage: own PR must be a primary ref; incidental mention leaves the commit uncovered", () => { + const rows = [ + { hash: "aaaaaaaaa1", pr: 11631, refs: [11631], subject: "feat: 1min (#11631)" }, + { hash: "bbbbbbbbb2", pr: 11674, refs: [11674], subject: "test: counts (#11674)" }, + { hash: "ccccccccc3", pr: null, refs: [11449], subject: "fix: direct (#11449)" }, + { hash: "ddddddddd4", pr: 12000, refs: [12000], subject: "fix: closes issue (#12000)" }, + { hash: "eeeeeeeee5", pr: null, refs: [], subject: "chore(release): open cycle" }, + { hash: "f0f0f0f0f0abcdef", pr: null, refs: [], subject: "fix(ui): direct push" }, + ]; + const bullets = [ + `- **test:** counts — 1min.ai #11631 (${link(11674)})`, + "- **fix:** direct push fixing #11449", + "- **fix:** the issue bullet cites #7000 only", + "- **fix(ui):** direct push (direct commit `f0f0f0f0f0`)", + ]; + const { uncovered } = computeCoverage(rows, bullets, { + closingPrs: new Set([12000]), // PR 12000 closes issue 7000, which is cited + skipHashes: new Set(["eeeeeeeee"]), + }); + assert.deepEqual( + uncovered.map((r) => r.hash), + ["aaaaaaaaa1"], + "only the incidentally-mentioned PR is uncovered" + ); +}); + +test("versionSectionRange / otherSectionsText isolate exactly one version block", () => { + const cl = `# Changelog + +## [Unreleased] + +### ✨ New Features + +- old feature (#1) + +## [3.8.51] — TBD + +### 🐛 Bug Fixes + +- **fix:** in section (#2) + +--- + +## [3.8.50] — 2026-08-25 + +- shipped (#3) +`; + const r = versionSectionRange(cl, "3.8.51"); + assert.ok(r && cl.slice(r.start, r.end).includes("in section (#2)")); + assert.ok(!cl.slice(r.start, r.end).includes("shipped (#3)")); + const other = otherSectionsText(cl, "3.8.51"); + assert.ok( + other.includes("old feature (#1)") && + other.includes("shipped (#3)") && + !other.includes("in section (#2)") + ); + assert.equal(versionSectionRange(cl, "9.9.9"), null); +}); + +test("rankAuthors: consolidates display-name drift by PR login, excludes bots", () => { + const rows = [ + { authorName: "Webman", prAuthor: "jonlwheat2-gif" }, + { authorName: "Webman", prAuthor: "jonlwheat2-gif" }, + { authorName: "WebPerson", prAuthor: "jonlwheat2-gif" }, + { authorName: "solo", prAuthor: null }, + { authorName: "dependabot[bot]", prAuthor: null }, + ]; + const { top, counts } = rankAuthors(rows); + assert.deepEqual(top[0], ["Webman (@jonlwheat2-gif)", 3]); + assert.deepEqual(top[1], ["solo", 1]); + assert.equal("dependabot[bot]" in counts, false); +}); + +test("reconcile: folds fragments under the version section, credits by fragment origin, drops shipped twins, generates the rest", () => { + const changelog = `# Changelog + +## [Unreleased] + +### ✨ New Features + +- **feat(old):** shipped last cycle but never moved (#9000) + +## [3.8.51] — TBD + +### ✨ New Features + +### 🐛 Bug Fixes + +- **security(streaming):** pre-existing bullet stays verbatim, + across two lines (#12457) + +### 📝 Maintenance + +--- + +## [3.8.50] — 2026-08-25 + +### 🐛 Bug Fixes + +- **fix(cli):** \`omniroute update\` now finds npm on Windows. It called execFile with no shell (#11335) + +--- +`; + const rows = [ + { + hash: "a0ceccc6f0aaaa", + date: "2026-08-25", + authorName: "Burak", + authorEmail: "b@x", + subject: "feat(search): add Xquik (#11370)", + refs: [11370], + pr: 11370, + coauthors: [], + }, + { + hash: "b1b1b1b1b1bbbb", + date: "2026-08-26", + authorName: "Zero", + authorEmail: "z@x", + subject: "feat(providers): add 1min.ai provider (#11631)", + refs: [11631], + pr: 11631, + coauthors: [], + }, + { + hash: "c2c2c2c2c2cccc", + date: "2026-08-26", + authorName: "diegosouzapw", + authorEmail: "d@x", + subject: "feat(models): recreated (#11887)", + refs: [11887], + pr: 11887, + coauthors: [], + }, + { + hash: "d3d3d3d3d3dddd", + date: "2026-08-27", + authorName: "Markus Hartung", + authorEmail: "m@x", + subject: "fix(x): phantom fragment carrier (#11434)", + refs: [11434], + pr: 11434, + coauthors: [], + }, + { + hash: "e4e4e4e4e4eeee", + date: "2026-08-27", + authorName: "dependabot[bot]", + authorEmail: "dep@x", + subject: "deps: bump a from 1 to 2 (#11500)", + refs: [11500], + pr: 11500, + coauthors: [], + }, + { + hash: "f5f5f5f5f5ffff", + date: "2026-08-28", + authorName: "diegosouzapw", + authorEmail: "d@x", + subject: "chore(release): open v3.8.51 development cycle", + refs: [], + pr: null, + coauthors: [], + }, + { + hash: "0606060606aaaa", + date: "2026-08-29", + authorName: "diegosouzapw", + authorEmail: "d@x", + subject: "fix(streaming): sanitize (#12457)", + refs: [12457], + pr: 12457, + coauthors: [], + }, + ]; + const prs = [ + { + number: 11631, + title: "1min", + author: { login: "zero-executioner" }, + body: "", + closingIssuesReferences: [], + }, + { + number: 11887, + title: "recreated", + author: { login: "diegosouzapw" }, + body: "", + closingIssuesReferences: [], + }, + { + number: 11434, + title: "carrier", + author: { login: "hartmark" }, + body: "", + closingIssuesReferences: [], + }, + { + number: 11500, + title: "bump", + author: { login: "app/dependabot" }, + body: "", + closingIssuesReferences: [], + }, + { + number: 12457, + title: "sanitize", + author: { login: "diegosouzapw" }, + body: "", + closingIssuesReferences: [], + }, + ]; + const fragments = [ + // no-ref fragment whose origin commit is the contributor's own PR → link + credit from origin + { + path: "changelog.d/features/xquik.md", + section: "features", + text: "- **feat(search):** Add Xquik X search\n with typed results\n", + originHash: "a0ceccc6f", + originPr: 11370, + prefixPr: null, + }, + // fragment named after the CLOSED PR (11685) but landed by the recreated PR 11887 → origin wins, extra credit applies + { + path: "changelog.d/features/11685-antigravity.md", + section: "features", + text: "- Default Antigravity connections to auto-sync\n", + originHash: "c2c2c2c2c", + originPr: 11887, + prefixPr: 11685, + }, + // phantom fragment: text already shipped in [3.8.50] → dropped + { + path: "changelog.d/fixes/cli-update-npm-win32.md", + section: "fixes", + text: "- **fix(cli):** `omniroute update` now finds npm on Windows. It called execFile with no shell\n", + originHash: "d3d3d3d3d", + originPr: 11434, + prefixPr: null, + }, + ]; + const { changelog: next, report } = reconcile({ + changelog, + version: "3.8.51", + repo: REPO, + rows, + prs, + fragments, + credits: { 11887: ["MumuTW"] }, + fragmentCredit: { "changelog.d/features/xquik.md": ["kriptoburak"] }, + today: "2026-09-07", + baseTip: "0915890", + headTip: "d6f3150", + skipHashes: new Set(["f5f5f5f5f"]), + }); + const section = next.slice(next.indexOf("## [3.8.51]"), next.indexOf("## [3.8.50]")); + // structure + assert.ok( + section.includes("### 📊 Release by the numbers") && + section.includes("### 🏆 Top 25 contributors this cycle") + ); + assert.ok(section.indexOf("### ✨ New Features") < section.indexOf("### 🐛 Bug Fixes")); + // [Unreleased] and [3.8.50] untouched + assert.ok(next.includes("- **feat(old):** shipped last cycle but never moved (#9000)")); + assert.equal( + (next.match(/now finds npm on Windows/g) || []).length, + 1, + "phantom fragment dropped, original kept" + ); + // pre-existing bullet verbatim (two lines) + assert.ok( + section.includes( + "- **security(streaming):** pre-existing bullet stays verbatim,\n across two lines (#12457)" + ) + ); + // fragment credited by origin + override, collapsed to one line + assert.ok( + section.includes( + `- **feat(search):** Add Xquik X search with typed results — thanks @kriptoburak` + ), + section + ); + // recreated PR: link to the landing PR, credit to the original author + assert.ok( + section.includes( + `- Default Antigravity connections to auto-sync (${link(11887)}) — thanks @MumuTW` + ), + section + ); + // uncovered commit generated with author credit; dependabot rolled up; cycle-open skipped + assert.ok( + section.includes( + `- **feat(providers):** add 1min.ai provider (${link(11631)}) — thanks @zero-executioner` + ) + ); + assert.ok(section.includes("- **deps:** 1 Dependabot bumps — bump a from 1 to 2")); + assert.ok(!section.includes("open v3.8.51 development cycle")); + // the carrier commit of the dropped phantom fragment is NOT silently covered → it gets its own bullet + assert.ok( + section.includes(`- **fix(x):** phantom fragment carrier (${link(11434)}) — thanks @hartmark`) + ); + assert.equal(report.dropped.length, 1); + assert.equal(report.generated.features, 1); + assert.equal( + report.generated.fixes, + 1, + "12457's own commit is covered by the pre-existing multi-line bullet; only 11434 is generated" + ); +}); From 678af2ea3883fb88d06541860d18e4dad44dcae2 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:29:33 +0200 Subject: [PATCH 06/15] fix(db): ignore non-finite rate_limited_until writes, preserve null clear (#12788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289, dentro da baseline), 70 testes focados no runner Node e 1 no vitest, todos verdes. Persistir `NaN`/`Infinity` numa coluna TEXT envenena toda leitura futura, e um timestamp já expirado sobrescrevendo uma linha viva é pior que não escrever nada. O guard fundido na cabeça da função cobre os dois sem tocar leitores nem o caminho de clear. Os 6 casos do teste incluem o que mais importa: `null` continua limpando, e uma escrita expirada não derruba um cooldown ativo. Nota: 3 deles falham antes do guard, como você registrou. **Integração:** este arquivo colidiu com o #12951, que também guarda a cabeça de `setConnectionRateLimitUntil` — lá o `null` vira caminho de clear que também remove os cooldowns filhos do Codex. Os dois compõem: trata-se o `null` primeiro (clear + return), e o seu guard de finitude/expiração passa a valer para os não-nulos. Ambos preservados. --- .../fixes/12788-ratelimit-write-guard.md | 1 + src/lib/db/providers/rateLimit.ts | 5 + tests/unit/db-rate-limit-guard.test.ts | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 changelog.d/fixes/12788-ratelimit-write-guard.md create mode 100644 tests/unit/db-rate-limit-guard.test.ts diff --git a/changelog.d/fixes/12788-ratelimit-write-guard.md b/changelog.d/fixes/12788-ratelimit-write-guard.md new file mode 100644 index 0000000000..1662233abf --- /dev/null +++ b/changelog.d/fixes/12788-ratelimit-write-guard.md @@ -0,0 +1 @@ +- **fix(db):** ignore expired or invalid rate-limit cooldown writes so a stale timestamp can't lock a connection that should be usable — clearing still works as before ([#12788](https://github.com/diegosouzapw/OmniRoute/pull/12788)) — thanks @maxmad64bis diff --git a/src/lib/db/providers/rateLimit.ts b/src/lib/db/providers/rateLimit.ts index 7812080a0f..e89199c3b6 100644 --- a/src/lib/db/providers/rateLimit.ts +++ b/src/lib/db/providers/rateLimit.ts @@ -28,6 +28,11 @@ interface DbLike { * @param until - Epoch ms when the rate limit expires (null to clear) */ export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void { + // Guard: never persist a non-finite or already-expired timestamp. The TEXT + // column would store "NaN"/"Infinity" and pollute every future read. null + // is the only clear path (via clearConnectionRateLimit); past/zero + // timestamps are noops so an expired write cannot overwrite a live row. + if (until !== null && (!Number.isFinite(until) || until <= Date.now())) return; const db = getDbInstance() as unknown as DbLike; db.prepare( "UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?" diff --git a/tests/unit/db-rate-limit-guard.test.ts b/tests/unit/db-rate-limit-guard.test.ts new file mode 100644 index 0000000000..69fb9e1e06 --- /dev/null +++ b/tests/unit/db-rate-limit-guard.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-ratelimit-guard-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts") as typeof import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts") as typeof import("../../src/lib/db/providers.ts"); +const { + setConnectionRateLimitUntil, + clearConnectionRateLimit, + isConnectionRateLimited, +} = providersDb; + +function readRateLimitedUntil(connectionId: string): unknown { + const db = ( + core as unknown as { + getDbInstance: () => { + prepare: (sql: string) => { + get: (id: string) => { rate_limited_until: unknown } | undefined; + }; + }; + } + ).getDbInstance(); + return db + .prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?") + .get(connectionId)?.rate_limited_until ?? null; +} + +async function resetStorage() { + core.resetDbInstance(); + // Retry loop copied from db-providers-crud.test.ts:17-30 (Windows EBUSY/EPERM). + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + // No retry here (unlike resetStorage): teardown-only, Linux CI; Windows + // EBUSY surfaces in beforeEach retries, not here. + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function seedConnection(): Promise { + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Guard probe", + apiKey: "guard-key", + }); + return (connection as { id: string }).id; +} + +test("ignores NaN (row unchanged)", async () => { + const id = await seedConnection(); + setConnectionRateLimitUntil(id, NaN); + assert.equal(readRateLimitedUntil(id), null); +}); + +test("ignores Infinity and -Infinity", async () => { + const id = await seedConnection(); + setConnectionRateLimitUntil(id, Infinity); + setConnectionRateLimitUntil(id, -Infinity); + assert.equal(readRateLimitedUntil(id), null); +}); + +test("ignores past timestamps and 0 (documented noop; clear path is null)", async () => { + const id = await seedConnection(); + setConnectionRateLimitUntil(id, Date.now() - 1000); + setConnectionRateLimitUntil(id, 0); + assert.equal(readRateLimitedUntil(id), null); +}); + +test("writes future timestamps", async () => { + const id = await seedConnection(); + const until = Date.now() + 60_000; + setConnectionRateLimitUntil(id, until); + assert.equal(Number(readRateLimitedUntil(id)), until); + assert.equal(isConnectionRateLimited(id), true); +}); + +test("preserves null clear (non-regression for clearConnectionRateLimit)", async () => { + const id = await seedConnection(); + setConnectionRateLimitUntil(id, Date.now() + 60_000); + clearConnectionRateLimit(id); + assert.equal(readRateLimitedUntil(id), null); + assert.equal(isConnectionRateLimited(id), false); +}); + +test("expired write does not overwrite a live row (future preserved)", async () => { + const id = await seedConnection(); + const future = Date.now() + 60_000; + setConnectionRateLimitUntil(id, future); + setConnectionRateLimitUntil(id, Date.now() - 1000); + assert.equal(Number(readRateLimitedUntil(id)), future); + assert.equal(isConnectionRateLimited(id), true); +}); + +// Without the guard the TEXT column stores the string "NaN" — rejected by +// current readers, but hygiene demands never writing it. From 3abd855095a18b7e5f79c133ae8ccb53fff16f16 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:29:38 +0200 Subject: [PATCH 07/15] fix(quota): dedup concurrent getSaturation misses with singleflight (#12787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289, dentro da baseline), 70 testes focados no runner Node e 1 no vitest, todos verdes. Singleflight no ponto certo: o cache de 30s já existia, mas não cobria a janela entre o miss e a resolução — que é exatamente quando a rajada acontece. Manter o `finally` para limpar o pending é o detalhe que impede o dedup de virar um cache permanente em caso de erro. O teste que vale é o quinto: chamadores concorrentes na mesma chave disparam **um** fetch upstream. Os outros quatro protegem o que não pode mudar — fail-open em 0, veneno de 30s, e nenhum vazamento entre chaves. --- .../fixes/12787-saturation-singleflight.md | 1 + src/lib/quota/saturationSignals.ts | 64 ++++++++----- .../saturation-signals-singleflight.test.ts | 94 +++++++++++++++++++ 3 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/12787-saturation-singleflight.md create mode 100644 tests/unit/saturation-signals-singleflight.test.ts diff --git a/changelog.d/fixes/12787-saturation-singleflight.md b/changelog.d/fixes/12787-saturation-singleflight.md new file mode 100644 index 0000000000..51ee5c7ac4 --- /dev/null +++ b/changelog.d/fixes/12787-saturation-singleflight.md @@ -0,0 +1 @@ +- **fix(quota):** share one upstream quota read when identical saturation checks arrive at the same time, so traffic bursts don't multiply provider API calls ([#12787](https://github.com/diegosouzapw/OmniRoute/pull/12787)) — thanks @maxmad64bis diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index 0eee4331dc..eb0365445a 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -51,6 +51,10 @@ const CACHE_TTL_MS = 30_000; // 30 seconds const _cache = new Map(); +// Pending miss fetches, keyed like _cache. Concurrent getSaturation calls for +// the same key share the promise instead of firing one upstream read each. +const _inflight = new Map>(); + // --------------------------------------------------------------------------- // Rate-limit header cache (populated by response handlers) // --------------------------------------------------------------------------- @@ -259,6 +263,7 @@ function cacheKey(connectionId: string, provider: string, dim: DimensionSpec): s // Exported for test reset export function _clearSaturationCache(): void { _cache.clear(); + _inflight.clear(); } // --------------------------------------------------------------------------- @@ -537,31 +542,40 @@ export async function getSaturation( return cached.value; } - let value = 0; - try { - switch (provider) { - case "codex": - value = await fetchCodexSaturation(connectionId, dim, connection); - break; - case "bailian": - value = await fetchBailianSaturation(connectionId, dim); - break; - case "anthropic": - case "claude": - value = await fetchAnthropicSaturation(connectionId, dim); - break; - default: - value = await fetchGenericSaturation(connectionId, provider); - break; + const pending = _inflight.get(key); + if (pending) return pending; + const task = (async (): Promise => { + let value = 0; + try { + switch (provider) { + case "codex": + value = await fetchCodexSaturation(connectionId, dim, connection); + break; + case "bailian": + value = await fetchBailianSaturation(connectionId, dim); + break; + case "anthropic": + case "claude": + value = await fetchAnthropicSaturation(connectionId, dim); + break; + default: + value = await fetchGenericSaturation(connectionId, provider); + break; + } + } catch (err) { + log.warn( + { err: (err as Error)?.message, connectionId, provider }, + "saturation fetch failed — failing open with 0" + ); + value = 0; } - } catch (err) { - log.warn( - { err: (err as Error)?.message, connectionId, provider }, - "saturation fetch failed — failing open with 0" - ); - value = 0; + _cache.set(key, { value, ts: Date.now() }); + return value; + })(); + _inflight.set(key, task); + try { + return await task; + } finally { + _inflight.delete(key); } - - _cache.set(key, { value, ts: Date.now() }); - return value; } diff --git a/tests/unit/saturation-signals-singleflight.test.ts b/tests/unit/saturation-signals-singleflight.test.ts new file mode 100644 index 0000000000..268e171906 --- /dev/null +++ b/tests/unit/saturation-signals-singleflight.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const satMod = await import("../../src/lib/quota/saturationSignals.ts"); +const { getSaturation, _clearSaturationCache, __setGenericUsageFetcherForTests } = satMod; + +test.beforeEach(() => { + _clearSaturationCache(); + // NOTE: _clearSaturationCache empties _cache AND _inflight. _inflight must + // always be empty between tests anyway because every getSaturation call is + // awaited (finally deletes on settle) — never fire-and-forget a call + // without awaiting it in tests. +}); + +test("concurrent same-key calls resolve to the same value (singleflight)", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + const [a, b] = await Promise.all([ + getSaturation("conn-dedup", "unknown_xyz_dedup", dim), + getSaturation("conn-dedup", "unknown_xyz_dedup", dim), + ]); + assert.equal(a, b); + assert.equal(a, 0); // fail-open; both callers shared the single miss +}); + +test("_inflight entry is cleaned up after resolve (no leak across keys)", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + await getSaturation("conn-a", "unknown_xyz_a", dim); + await getSaturation("conn-b", "unknown_xyz_b", dim); + const [a, b] = await Promise.all([ + getSaturation("conn-a", "unknown_xyz_a", dim), + getSaturation("conn-b", "unknown_xyz_b", dim), + ]); + assert.equal(a, 0); + assert.equal(b, 0); +}); + +test("error path serves fail-open 0 and poisons _cache (no refetch before TTL)", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + const first = await getSaturation("conn-rej", "unknown_xyz_rej", dim); + assert.equal(first, 0); + const second = await getSaturation("conn-rej", "unknown_xyz_rej", dim); + assert.equal(second, 0); // _cache hit, not a refetch +}); + +test("cache hit does not create _inflight state", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + await getSaturation("conn-hit", "unknown_xyz_hit", dim); + const again = await getSaturation("conn-hit", "unknown_xyz_hit", dim); + assert.equal(again, 0); +}); + +test("concurrent same-key calls start ONE upstream fetch (singleflight)", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + let calls = 0; + __setGenericUsageFetcherForTests(async () => { calls++; return { quotas: {} }; }); + try { + _clearSaturationCache(); + const [a, b] = await Promise.all([ + getSaturation("conn-dedup-count", "unknown_xyz_count", dim), + getSaturation("conn-dedup-count", "unknown_xyz_count", dim), + ]); + assert.equal(a, b); + assert.equal(calls, 1, "second concurrent caller must share the inflight fetch"); + } finally { + __setGenericUsageFetcherForTests(null); + } +}); + +test("reject path fails open to 0, serves _cache without refetch, cleans _inflight", async () => { + const dim = { unit: "tokens", window: "hourly" } as const; + let calls = 0; + __setGenericUsageFetcherForTests(async () => { calls++; throw new Error("boom"); }); + try { + _clearSaturationCache(); + const [a, b] = await Promise.all([ + getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim), + getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim), + ]); + assert.equal(a, 0); // fail-open + assert.equal(b, 0); // shared inflight reject + assert.equal(calls, 1, "concurrent reject must share the single inflight fetch"); + const second = await getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim); + assert.equal(second, 0); // _cache poisoned to 0 for CACHE_TTL_MS + assert.equal(calls, 1, "immediate call must hit _cache, not refetch"); + // _inflight was cleaned by the finally delete: after clearing the poisoned + // _cache entry a new fetch is possible again. + _clearSaturationCache(); + const third = await getSaturation("conn-rej-throw", "unknown_xyz_rej_throw", dim); + assert.equal(third, 0); + assert.equal(calls, 2, "after clear a refetch must be possible"); + } finally { + __setGenericUsageFetcherForTests(null); + } +}); From 29593377cc510ba52efc951f4672ba9353b7eb55 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:29:41 +0200 Subject: [PATCH 08/15] fix(db): extract WAL maintenance, surface TRUNCATE no-op (#12853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289, dentro da baseline), 70 testes focados no runner Node e 1 no vitest, todos verdes. "Um checkpoint que nunca olhou o próprio resultado" é o tipo de defeito que só aparece quando o WAL fica maior que o banco. Ler a linha de retorno do pragma e diferenciar busy de sucesso é a correção; o retry `PASSIVE` um minuto depois é o que evita esperar as 6 horas do próximo tick. Gostei da decisão de não persistir contador: streak em memória que zera no stop é o comportamento honesto para uma métrica de contenção. Os 11 casos cobrem as formas de retorno que o pragma pode assumir — sentinela `-1`, objeto pelado, `undefined`/`null`/`[]` — que é onde esse tipo de parsing costuma quebrar em silêncio. --- .../fixes/12853-wal-maintenance-extraction.md | 1 + docs/reference/ENVIRONMENT.md | 2 +- src/app/api/monitoring/health/route.ts | 6 + src/lib/db/core.ts | 74 ++---- src/lib/db/walMaintenance.ts | 236 ++++++++++++++++++ src/lib/monitoring/observability.ts | 33 +++ tests/unit/db-wal-truncate-scheduler.test.ts | 76 ++++-- tests/unit/observability-payloads.test.ts | 53 ++++ tests/unit/wal-maintenance.test.ts | 166 ++++++++++++ 9 files changed, 572 insertions(+), 75 deletions(-) create mode 100644 changelog.d/fixes/12853-wal-maintenance-extraction.md create mode 100644 src/lib/db/walMaintenance.ts create mode 100644 tests/unit/wal-maintenance.test.ts diff --git a/changelog.d/fixes/12853-wal-maintenance-extraction.md b/changelog.d/fixes/12853-wal-maintenance-extraction.md new file mode 100644 index 0000000000..eabeb66866 --- /dev/null +++ b/changelog.d/fixes/12853-wal-maintenance-extraction.md @@ -0,0 +1 @@ +- **fix(db):** WAL maintenance lives in its own module: a `TRUNCATE` checkpoint that hits a busy database now warns with its streak and retries once via `PASSIVE` instead of logging success, and closing-time checkpoints no longer report success on builds without a database file, with the busy totals visible in the authenticated monitoring health payload ([#12853](https://github.com/diegosouzapw/OmniRoute/pull/12853)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index da053f1db3..f357f73584 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -101,7 +101,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | | `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | | `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. | -| `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` | `21600000` (6h) | `src/lib/db/core.ts` | Override the periodic `wal_checkpoint(TRUNCATE)` interval (ms). Auto-checkpoint never shrinks the WAL file itself, and a long-running server never closes its DB. `0` disables. | +| `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` | `21600000` (6h) | `src/lib/db/walMaintenance.ts` | Override the periodic `wal_checkpoint(TRUNCATE)` interval (ms). Auto-checkpoint never shrinks the WAL file itself, and a long-running server never closes its DB. `0` disables. | | `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | Set to `1` to skip the DB healthcheck entirely on startup. Useful for short-lived tasks and integration tests. | | `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). | | `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | Set to `1` to skip the native-runtime warm-up during `npm install`. Useful in CI/headless installs where sqlite is already built. | diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index c859ad7bdd..08f3cf04bd 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getProviderConnections } from "@/lib/db/providers"; import { getCachedSettings } from "@/lib/db/readCache"; +import { getWalMaintenanceState } from "@/lib/db/walMaintenance"; import { buildHealthPayload } from "@/lib/monitoring/observability"; import { readRunningBuildSha } from "@/lib/monitoring/buildSha"; import { APP_CONFIG } from "@/shared/constants/config"; @@ -241,6 +242,10 @@ async function rebuildHealthPayload(): Promise { null ) : null; + // #12853: WAL maintenance state (ticks/busy streak + totals) next to the + // admission gates. getWalMaintenanceState never throws and never touches + // the DB — a monitoring read stays cheap. Additive key, nothing moves. + const walMaintenance = readHealthValue("wal maintenance", () => getWalMaintenanceState(), null); const payload = buildHealthPayload({ appVersion: APP_CONFIG.version, @@ -266,6 +271,7 @@ async function rebuildHealthPayload(): Promise { credentialHealth, adaptiveAdmission, chatAdmission, + walMaintenance, }); if (generation === healthPayloadCacheGeneration) { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index c8ba9b1f18..3c3769c066 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -41,6 +41,14 @@ import { rowToCamel } from "./caseMapping"; import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; import { parseModelAccessMode } from "./apiKeys/modelAccessMode"; import { getExistingDbInstance as getDb, setDbInstance as setDb } from "./singleton"; +import type { WalCheckpointMode } from "./walMaintenance"; +import { + startWalMaintenance, + stopWalMaintenance, + runCheckpointNow, + getWalMaintenanceState, + logCheckpointOutcome, +} from "./walMaintenance"; // Re-exported so existing call sites that pull these helpers off the core module keep working. export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping"; import { @@ -55,7 +63,6 @@ import { type SqliteDatabase = SqliteAdapter; type JsonRecord = Record; -type CheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"; type DatabaseOptimizationSettings = DatabaseSettings["optimization"]; type PreservedTableSnapshot = { table: string; @@ -529,12 +536,6 @@ declare global { var __omnirouteDbOomFailureCount: number | undefined; } -function checkpointDb(db: SqliteDatabase, mode: CheckpointMode = "TRUNCATE"): boolean { - if (isCloud || isBuildPhase || !SQLITE_FILE) return false; - db.pragma(`wal_checkpoint(${mode})`); - return true; -} - function summarizePreservedTables(tables: PreservedTableSnapshot[]): string { if (tables.length === 0) return "none"; return tables.map((table) => `${table.table}(${table.rowCount})`).join(", "); @@ -962,50 +963,9 @@ function startDbHealthCheckScheduler(db: SqliteDatabase) { dbHealthCheckTimer.unref?.(); } -let walTruncateTimer: NodeJS.Timeout | null = null; - -function getWalTruncateIntervalMs(): number { - const rawValue = process.env.OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS; - if (typeof rawValue === "string" && rawValue.trim().length > 0) { - const parsed = Number(rawValue); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - } - return 6 * 60 * 60 * 1000; -} - -function clearWalTruncateScheduler() { - if (walTruncateTimer) { - clearInterval(walTruncateTimer); - walTruncateTimer = null; - } -} - // Auto-checkpoint moves WAL pages back into the main DB file but never shrinks the WAL // file itself; only wal_checkpoint(TRUNCATE) does, and a long-running server never closes its DB. -function startWalTruncateScheduler(db: SqliteDatabase) { - clearWalTruncateScheduler(); - if (isCloud || isBuildPhase || isAutomatedTestProcess()) return; - - const intervalMs = getWalTruncateIntervalMs(); - if (intervalMs <= 0) return; - - walTruncateTimer = setInterval(() => { - try { - if (!db.open) return; - // TRUNCATE waits for readers; under concurrent write load it can no-op without - // shrinking the file. That is expected — it retries on the next tick. - if (checkpointDb(db, "TRUNCATE")) { - console.log("[DB] Periodic SQLite WAL checkpoint completed (TRUNCATE)."); - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.warn("[DB] Periodic WAL truncate failed:", message); - } - }, intervalMs); - walTruncateTimer.unref?.(); -} +// The scheduler lives in ./walMaintenance (periodic TRUNCATE + busy warn + PASSIVE retry). export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) { const db = getDbInstance(); @@ -1390,7 +1350,7 @@ export function getDbInstance(): SqliteDatabase { } startDbHealthCheckScheduler(db); - startWalTruncateScheduler(db); + startWalMaintenance(db, SQLITE_FILE); // Log the resolved absolute DATA_DIR + SQLITE_FILE once at init so a // multi-replica / Docker volume-topology mismatch (each replica opening a // different on-disk DB → "phantom"/missing combos & connections) is @@ -1416,9 +1376,10 @@ export function pingDb(): boolean { } } -export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean { +export function closeDbInstance(options?: { checkpointMode?: WalCheckpointMode | null }): boolean { clearDbHealthCheckScheduler(); - clearWalTruncateScheduler(); + const streakBefore = getWalMaintenanceState().busyStreak; + stopWalMaintenance(); const db = getDb(); if (!db) return false; @@ -1427,9 +1388,12 @@ export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | nu try { if (checkpointMode) { try { - if (checkpointDb(db, checkpointMode)) { - console.log(`[DB] SQLite WAL checkpoint completed (${checkpointMode}).`); - } + const outcome = runCheckpointNow(db, checkpointMode, { + sqliteFile: SQLITE_FILE, + isCloud, + isBuildPhase, + }); + logCheckpointOutcome(outcome, checkpointMode, streakBefore); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn(`[DB] WAL checkpoint failed during close (${checkpointMode}):`, message); diff --git a/src/lib/db/walMaintenance.ts b/src/lib/db/walMaintenance.ts new file mode 100644 index 0000000000..8dfad87bad --- /dev/null +++ b/src/lib/db/walMaintenance.ts @@ -0,0 +1,236 @@ +import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; +import { isNextBuildPhase } from "../buildPhase"; +import type { SqliteAdapter } from "./adapters/types"; +import { registerDbStateResetter } from "./stateReset"; + +/** + * WAL maintenance owns the periodic `wal_checkpoint(TRUNCATE)` lifecycle that + * used to live inside `core.ts`: interval parsing, the scheduler, and reading + * the pragma result so a busy checkpoint warns instead of logging success. + */ +export type WalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"; + +export interface WalCheckpointOutcome { + ok: boolean; + busy: boolean; + skipped: boolean; + logFrames: number | null; + checkpointedFrames: number | null; + error: string | null; +} + +export interface WalCheckpointContext { + sqliteFile?: string | null; + isCloud?: boolean; + isBuildPhase?: boolean; +} + +export interface WalMaintenanceState { + ticks: number; + busyStreak: number; + busyTotal: number; + lastBusyAt: string | null; + lastOkAt: string | null; +} + +const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; + +const DEFAULT_WAL_TRUNCATE_INTERVAL_MS = 6 * 60 * 60 * 1000; +const RETRY_DELAY_MS = 60_000; + +let walTimer: NodeJS.Timeout | null = null; +let retryTimer: NodeJS.Timeout | null = null; +let ticks = 0; +let busyStreak = 0; +let busyTotal = 0; +let lastBusyAt: string | null = null; +let lastOkAt: string | null = null; + +function recordBusy(): void { + busyStreak++; + busyTotal++; + lastBusyAt = new Date().toISOString(); +} + +function recordOk(): void { + busyStreak = 0; + lastOkAt = new Date().toISOString(); +} + +function toFiniteNumber(value: unknown): number | null { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function failOpen(): WalCheckpointOutcome { + return { + ok: true, + busy: false, + skipped: false, + logFrames: null, + checkpointedFrames: null, + error: null, + }; +} + +function parseCheckpointRow(result: unknown): WalCheckpointOutcome { + const row = Array.isArray(result) ? result[0] : result; + if (row === undefined || row === null) return failOpen(); + if (typeof row !== "object") return failOpen(); + const record = row as Record; + const busy = toFiniteNumber(record.busy); + const logFrames = toFiniteNumber(record.log); + const checkpointedFrames = toFiniteNumber(record.checkpointed); + if (busy === null || logFrames === null || checkpointedFrames === null) return failOpen(); + return { + ok: busy !== 1, + busy: busy === 1, + skipped: false, + logFrames, + checkpointedFrames, + error: null, + }; +} + +export function runCheckpointNow( + db: SqliteAdapter, + mode: WalCheckpointMode = "TRUNCATE", + ctx: WalCheckpointContext = {} +): WalCheckpointOutcome { + if (ctx.sqliteFile === null || ctx.isCloud === true || ctx.isBuildPhase === true) { + return { + ok: false, + busy: false, + skipped: true, + logFrames: null, + checkpointedFrames: null, + error: null, + }; + } + try { + return parseCheckpointRow(db.pragma(`wal_checkpoint(${mode})`)); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + busy: false, + skipped: false, + logFrames: null, + checkpointedFrames: null, + error: message, + }; + } +} + +export function getWalMaintenanceIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + const rawValue = env.OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS; + if (typeof rawValue === "string" && rawValue.trim().length > 0) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return DEFAULT_WAL_TRUNCATE_INTERVAL_MS; +} + +export function logCheckpointOutcome( + outcome: WalCheckpointOutcome, + mode: WalCheckpointMode, + streak: number +): void { + if (outcome.skipped) return; + if (outcome.busy) { + console.warn( + `[DB] SQLite WAL checkpoint busy — ${outcome.logFrames} frames pending (streak ${streak})` + ); + return; + } + if (!outcome.ok) { + console.warn( + `[DB] SQLite WAL checkpoint failed (${mode}): ${outcome.error ?? "unknown error"}` + ); + return; + } + console.log(`[DB] SQLite WAL checkpoint completed (${mode})`); +} + +function schedulePassiveRetry(db: SqliteAdapter): void { + if (retryTimer) return; + retryTimer = setTimeout(() => { + retryTimer = null; + try { + if (isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return; + if (!db.open) return; + const outcome = runCheckpointNow(db, "PASSIVE"); + if (outcome.skipped) return; + if (outcome.busy) { + recordBusy(); + logCheckpointOutcome(outcome, "PASSIVE", busyStreak); + } else if (outcome.ok) { + recordOk(); + } else { + logCheckpointOutcome(outcome, "PASSIVE", busyStreak); + } + } catch { + // A periodic retry must never throw into the event loop. + } + }, RETRY_DELAY_MS); + retryTimer.unref?.(); +} + +export function startWalMaintenance( + db: SqliteAdapter, + sqliteFile: string | null, + env: NodeJS.ProcessEnv = process.env +): void { + stopWalMaintenance(); + if (sqliteFile === null || isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return; + const intervalMs = getWalMaintenanceIntervalMs(env); + if (intervalMs <= 0) return; + walTimer = setInterval(() => { + try { + if (!db.open) return; + const outcome = runCheckpointNow(db, "TRUNCATE"); + if (outcome.skipped) return; + ticks++; + if (outcome.busy) { + recordBusy(); + logCheckpointOutcome(outcome, "TRUNCATE", busyStreak); + schedulePassiveRetry(db); + } else if (outcome.ok) { + recordOk(); + } else { + logCheckpointOutcome(outcome, "TRUNCATE", busyStreak); + } + } catch { + // A periodic scheduler must never throw into the event loop. + } + }, intervalMs); + walTimer.unref?.(); +} + +export function stopWalMaintenance(): void { + if (walTimer) { + clearInterval(walTimer); + walTimer = null; + } + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + ticks = 0; + busyStreak = 0; + busyTotal = 0; + lastBusyAt = null; + lastOkAt = null; +} + +export function getWalMaintenanceState(): WalMaintenanceState { + return { ticks, busyStreak, busyTotal, lastBusyAt, lastOkAt }; +} + +export function __resetForTests(): void { + stopWalMaintenance(); +} + +registerDbStateResetter(stopWalMaintenance); diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index 747afa0b91..91854bde4a 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -4,6 +4,7 @@ import { } from "@omniroute/open-sse/services/codexAccount/index.ts"; import type { AdaptiveAdmissionPublicSnapshot } from "@omniroute/open-sse/services/admission/runtime.ts"; import type { PerConnectionAdmissionController } from "@/shared/middleware/chatBodyAdmission"; +import type { WalMaintenanceState } from "@/lib/db/walMaintenance"; type JsonRecord = Record; @@ -37,6 +38,32 @@ export type ChatAdmissionHealthSummary = { countCapEnabled: boolean; }; +/** + * WAL maintenance health summary (#12853) — the periodic TRUNCATE lifecycle + * from walMaintenance.ts. Fixed low-cardinality shape, never raw-spread. + */ +export type WalMaintenanceSnapshot = Pick< + WalMaintenanceState, + "ticks" | "busyStreak" | "busyTotal" | "lastBusyAt" | "lastOkAt" +>; + +/** + * Explicit allowlisted projection of the WAL maintenance state. + * Copies only the documented scalar fields — no timers, no internals. + */ +export function projectWalMaintenanceSummary( + state: WalMaintenanceState | null | undefined +): WalMaintenanceSnapshot | null { + if (!state || typeof state !== "object") return null; + return { + ticks: state.ticks, + busyStreak: state.busyStreak, + busyTotal: state.busyTotal, + lastBusyAt: state.lastBusyAt, + lastOkAt: state.lastOkAt, + }; +} + /** * Explicit allowlisted projection of the structural admission snapshot. * Never spreads the snapshot — only the documented low-cardinality fields pass. @@ -217,6 +244,8 @@ interface BuildHealthPayloadOptions { adaptiveAdmission?: AdaptiveAdmissionPublicSnapshot | null; /** #11244: optional structural chat-admission snapshot; projected, never raw-spread. */ chatAdmission?: ChatAdmissionSnapshot | null; + /** #12853: optional WAL maintenance snapshot; projected, never raw-spread. */ + walMaintenance?: WalMaintenanceSnapshot | null; } function limitMonitors(monitors: QuotaMonitorSnapshot[], maxItems = 8): QuotaMonitorSnapshot[] { @@ -405,6 +434,7 @@ export function buildHealthPayload({ credentialHealth, adaptiveAdmission = null, chatAdmission = null, + walMaintenance = null, buildSha = null, }: BuildHealthPayloadOptions) { const timestamp = new Date().toISOString(); @@ -510,6 +540,9 @@ export function buildHealthPayload({ // #11244: the STRUCTURAL gate (chatBodyAdmission.ts) next to the adaptive one — // distinct key so clients reading `adaptiveAdmission` are untouched. chatAdmission: projectChatAdmissionSummary(chatAdmission), + // #12853: WAL maintenance next to the admission gates — additive key, + // nothing existing moves. + walMaintenance: projectWalMaintenanceSummary(walMaintenance), dedup: { inflightRequests, }, diff --git a/tests/unit/db-wal-truncate-scheduler.test.ts b/tests/unit/db-wal-truncate-scheduler.test.ts index cbd93cde7e..5d2d310b01 100644 --- a/tests/unit/db-wal-truncate-scheduler.test.ts +++ b/tests/unit/db-wal-truncate-scheduler.test.ts @@ -17,24 +17,22 @@ function readSource(relativePath: string): string { } const CORE_PATH = "src/lib/db/core.ts"; +const MAINTENANCE_PATH = "src/lib/db/walMaintenance.ts"; test("a periodic WAL truncate scheduler is started when the DB instance boots", () => { const source = readSource(CORE_PATH); assert.match( source, - /startWalTruncateScheduler\(db\)/, - "getDbInstance() must start the WAL truncate scheduler alongside the DB health-check scheduler" + /startWalMaintenance\(db/, + "getDbInstance() must start the WAL maintenance scheduler alongside the DB health-check scheduler" ); }); test("the WAL truncate scheduler runs wal_checkpoint(TRUNCATE), not a lighter mode", () => { - const source = readSource(CORE_PATH); - const fnStart = source.indexOf("function startWalTruncateScheduler"); - assert.notEqual(fnStart, -1, "startWalTruncateScheduler must exist"); - const fnBody = source.slice(fnStart, fnStart + 1200); + const source = readSource(MAINTENANCE_PATH); assert.match( - fnBody, - /checkpointDb\(db, "TRUNCATE"\)/, + source, + /wal_checkpoint\(TRUNCATE\)/, "the scheduled checkpoint must request TRUNCATE mode — a lighter mode would not shrink the WAL file" ); }); @@ -47,13 +45,13 @@ test("the WAL truncate scheduler is cleared on close, like the health-check sche assert.match(fnBody, /clearDbHealthCheckScheduler\(\)/); assert.match( fnBody, - /clearWalTruncateScheduler\(\)/, - "closeDbInstance() must clear the WAL truncate timer so it does not outlive the DB handle" + /stopWalMaintenance\(\)/, + "closeDbInstance() must stop the WAL maintenance timer so it does not outlive the DB handle" ); }); test("the truncate interval is overridable via OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS", () => { - const source = readSource(CORE_PATH); + const source = readSource(MAINTENANCE_PATH); assert.match( source, /OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS/, @@ -62,14 +60,10 @@ test("the truncate interval is overridable via OMNIROUTE_WAL_TRUNCATE_INTERVAL_M }); test("the scheduler self-gates the same way the DB health-check scheduler does", () => { - const source = readSource(CORE_PATH); - const fnStart = source.indexOf("function startWalTruncateScheduler"); - const fnBody = source.slice(fnStart, fnStart + 300); - assert.match( - fnBody, - /isCloud \|\| isBuildPhase \|\| isAutomatedTestProcess\(\)/, - "must not run during cloud/build/test contexts, same as startDbHealthCheckScheduler" - ); + const source = readSource(MAINTENANCE_PATH); + assert.match(source, /isCloud/); + assert.match(source, /isNextBuildPhase\(\)/); + assert.match(source, /isAutomatedTestProcess\(\)/); }); test("the new env var is documented", () => { @@ -80,3 +74,47 @@ test("the new env var is documented", () => { "docs/reference/ENVIRONMENT.md must document the new env var (check:env-doc-sync)" ); }); + +test("close carries the busy streak into the checkpoint log", () => { + const source = readSource(CORE_PATH); + const fnStart = source.indexOf("export function closeDbInstance"); + assert.notEqual(fnStart, -1, "closeDbInstance must exist"); + const fnBody = source.slice(fnStart, fnStart + 1200); + assert.match(fnBody, /getWalMaintenanceState\(\)\.busyStreak/); + assert.match(fnBody, /runCheckpointNow\(db, checkpointMode, \{/); + assert.match(fnBody, /logCheckpointOutcome\(outcome, checkpointMode, streakBefore\)/); +}); + +test("periodic schedulers log the error path (ok:false, busy:false)", () => { + const source = readSource(MAINTENANCE_PATH); + const periodic = source.slice(source.indexOf("function schedulePassiveRetry")); + const truncateLogs = ( + periodic.match(/logCheckpointOutcome\(outcome, "TRUNCATE", busyStreak\)/g) ?? [] + ).length; + const passiveLogs = ( + periodic.match(/logCheckpointOutcome\(outcome, "PASSIVE", busyStreak\)/g) ?? [] + ).length; + assert.ok( + truncateLogs >= 2, + `periodic TRUNCATE scheduler must log busy AND error outcomes (found ${truncateLogs} log calls)` + ); + assert.ok( + passiveLogs >= 2, + `PASSIVE retry scheduler must log busy AND error outcomes (found ${passiveLogs} log calls)` + ); +}); + +test("close reads the busy streak BEFORE stopping maintenance (streak otherwise always 0)", () => { + const source = readSource(CORE_PATH); + const fnStart = source.indexOf("export function closeDbInstance"); + assert.notEqual(fnStart, -1, "closeDbInstance must exist"); + const fnBody = source.slice(fnStart, fnStart + 1200); + const streakIdx = fnBody.indexOf("getWalMaintenanceState().busyStreak"); + const stopIdx = fnBody.indexOf("stopWalMaintenance()"); + assert.notEqual(streakIdx, -1, "closeDbInstance must read busyStreak"); + assert.notEqual(stopIdx, -1, "closeDbInstance must stop maintenance"); + assert.ok( + streakIdx < stopIdx, + "streakBefore must be captured before stopWalMaintenance() resets busyStreak to 0" + ); +}); diff --git a/tests/unit/observability-payloads.test.ts b/tests/unit/observability-payloads.test.ts index 05bc8f6b76..210308f12d 100644 --- a/tests/unit/observability-payloads.test.ts +++ b/tests/unit/observability-payloads.test.ts @@ -7,6 +7,7 @@ import { buildTelemetryPayload, projectAdaptiveAdmissionSummary, projectChatAdmissionSummary, + projectWalMaintenanceSummary, } from "../../src/lib/monitoring/observability.ts"; test("buildSessionsSummary returns sticky counts and ordered top sessions", () => { @@ -416,3 +417,55 @@ test("buildHealthPayload projects allowlisted structural chatAdmission fields on assert.equal(projectChatAdmissionSummary(null), null); assert.equal(projectChatAdmissionSummary(undefined), null); }); + +test("buildHealthPayload projects allowlisted walMaintenance fields only", () => { + const state = { + ticks: 4, + busyStreak: 1, + busyTotal: 2, + lastBusyAt: "2026-09-06T10:00:00.000Z", + lastOkAt: "2026-09-06T11:00:00.000Z", + // Internal keys that must never leak into the public payload. + walTimer: { _idleTimeout: 1 }, + retryTimer: null, + } as unknown as import("../../src/lib/monitoring/observability.ts").WalMaintenanceSnapshot; + + const payload = buildHealthPayload({ + appVersion: "9.9.9", + settings: { setupComplete: false }, + connections: [], + circuitBreakers: [], + rateLimitStatus: {}, + learnedLimits: {}, + lockouts: {}, + localProviders: {}, + inflightRequests: 0, + quotaMonitorSummary: { + active: 0, + alerting: 0, + exhausted: 0, + errors: 0, + statusCounts: { starting: 0, idle: 0, healthy: 0, warning: 0, exhausted: 0, error: 0 }, + byProvider: {}, + }, + quotaMonitorMonitors: [], + activeSessions: [], + walMaintenance: state, + }); + + assert.deepEqual(payload.walMaintenance, { + ticks: 4, + busyStreak: 1, + busyTotal: 2, + lastBusyAt: "2026-09-06T10:00:00.000Z", + lastOkAt: "2026-09-06T11:00:00.000Z", + }); + + const json = JSON.stringify(payload); + assert.equal(json.includes("walTimer"), false); + assert.equal(json.includes("retryTimer"), false); + + // Absent / null state projects to null (degraded path parity). + assert.equal(projectWalMaintenanceSummary(null), null); + assert.equal(projectWalMaintenanceSummary(undefined), null); +}); diff --git a/tests/unit/wal-maintenance.test.ts b/tests/unit/wal-maintenance.test.ts new file mode 100644 index 0000000000..8447de2829 --- /dev/null +++ b/tests/unit/wal-maintenance.test.ts @@ -0,0 +1,166 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { runCheckpointNow, logCheckpointOutcome } from "../../src/lib/db/walMaintenance.ts"; + +function fakeDb(result: unknown, throws?: string) { + return { + pragma: (_s: string) => { + if (throws) throw new Error(throws); + return result; + }, + }; +} + +test("busy row reports busy, not ok", () => { + const out = runCheckpointNow(fakeDb([{ busy: 1, log: 5, checkpointed: 5 }]) as never); + assert.equal(out.ok, false); + assert.equal(out.busy, true); +}); + +test("clean row reports ok", () => { + const out = runCheckpointNow(fakeDb([{ busy: 0, log: 0, checkpointed: 12 }]) as never); + assert.equal(out.ok, true); + assert.equal(out.busy, false); + assert.equal(out.logFrames, 0); + assert.equal(out.checkpointedFrames, 12); +}); + +test("sentinel -1 row is success, not busy", () => { + const out = runCheckpointNow(fakeDb([{ busy: 0, log: -1, checkpointed: -1 }]) as never); + assert.equal(out.ok, true); + assert.equal(out.busy, false); +}); + +test("bare object tolerated", () => { + const out = runCheckpointNow(fakeDb({ busy: 0, log: 0, checkpointed: 3 }) as never); + assert.equal(out.ok, true); +}); + +test("undefined, null, [] fail open", () => { + for (const shape of [undefined, null, []]) { + const out = runCheckpointNow(fakeDb(shape) as never); + assert.equal(out.ok, true); + assert.equal(out.busy, false); + } +}); + +test("bun:sqlite checkpoint shape parses (array of one row)", async (t) => { + if (!process.versions.bun) { + t.skip("bun:sqlite is only available under Bun"); + return; + } + const { Database } = await import("bun:sqlite"); + const { createBunSqliteAdapter } = await import("../../src/lib/db/adapters/bunSqliteAdapter.ts"); + const adapter = createBunSqliteAdapter(new Database(":memory:"), ":memory:"); + t.after(() => adapter.close()); + const out = runCheckpointNow(adapter, "TRUNCATE"); + assert.equal(out.ok, true); + assert.equal(out.busy, false); +}); + +test("pragma throw never propagates", () => { + const out = runCheckpointNow(fakeDb(undefined, "database is locked") as never); + assert.equal(out.ok, false); + assert.match(out.error ?? "", /database is locked/); +}); + +test("error outcome (ok:false, busy:false) is logged as a failure, not swallowed", () => { + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + try { + logCheckpointOutcome( + { + ok: false, + busy: false, + skipped: false, + logFrames: null, + checkpointedFrames: null, + error: "boom", + }, + "TRUNCATE", + 0 + ); + assert.ok( + warnings.some((line) => line.includes("WAL checkpoint failed")), + `expected a "WAL checkpoint failed" warn, got: ${JSON.stringify(warnings)}` + ); + } finally { + console.warn = origWarn; + } +}); + +test("guarded ctx skips without calling pragma", () => { + let called = 0; + const db = { + pragma: (_s: string) => { + called++; + return [{ busy: 0, log: 0, checkpointed: 0 }]; + }, + }; + const out = runCheckpointNow(db as never, "TRUNCATE", { sqliteFile: null }); + assert.equal(out.skipped, true); + assert.equal(called, 0); +}); + +test("interval defaults to 6h, rejects garbage, honors 0", async () => { + const { getWalMaintenanceIntervalMs } = await import("../../src/lib/db/walMaintenance.ts"); + assert.equal(getWalMaintenanceIntervalMs({} as NodeJS.ProcessEnv), 6 * 60 * 60 * 1000); + assert.equal( + getWalMaintenanceIntervalMs({ + OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "nope", + } as NodeJS.ProcessEnv), + 6 * 60 * 60 * 1000 + ); + assert.equal( + getWalMaintenanceIntervalMs({ + OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "60000", + } as NodeJS.ProcessEnv), + 60000 + ); + assert.equal( + getWalMaintenanceIntervalMs({ OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "0" } as NodeJS.ProcessEnv), + 0 + ); +}); + +test("__resetForTests zeroes state", async () => { + const mod = await import("../../src/lib/db/walMaintenance.ts"); + mod.__resetForTests(); + assert.deepEqual(mod.getWalMaintenanceState(), { + ticks: 0, + busyStreak: 0, + busyTotal: 0, + lastBusyAt: null, + lastOkAt: null, + }); +}); + +test("start is silent and stateless under the test-process gate", async () => { + const mod = await import("../../src/lib/db/walMaintenance.ts"); + mod.__resetForTests(); + const db = { open: true, pragma: (_s: string) => [{ busy: 0, log: 0, checkpointed: 0 }] }; + mod.startWalMaintenance(db as never, "/tmp/fake.sqlite"); + assert.deepEqual(mod.getWalMaintenanceState(), { + ticks: 0, + busyStreak: 0, + busyTotal: 0, + lastBusyAt: null, + lastOkAt: null, + }); + mod.__resetForTests(); + mod.__resetForTests(); + assert.deepEqual(mod.getWalMaintenanceState(), { + ticks: 0, + busyStreak: 0, + busyTotal: 0, + lastBusyAt: null, + lastOkAt: null, + }); +}); + +test.beforeEach(async () => { + (await import("../../src/lib/db/walMaintenance.ts")).__resetForTests(); +}); From 0f81e7557c442a17a1682897a84db9bd3c6ca834 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Tue, 8 Sep 2026 08:29:45 -0400 Subject: [PATCH 09/15] feat(volcengine): canonical quota window mapping and safe multi-connection plan binding (#12950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289, dentro da baseline), 70 testes focados no runner Node e 1 no vitest, todos verdes. Mapeamento canônico de janela de quota mais binding multi-conexão seguro, com 4 arquivos de teste para 4 de produção — proporção que dá para revisar. --- changelog.d/fixes/volcengine-quota-binding.md | 1 + open-sse/services/genericQuotaFetcher.ts | 26 ++- open-sse/services/usage/volcenginePlan.ts | 42 ++-- src/lib/providers/requestDefaults.ts | 2 + src/lib/providers/volcenginePlanBinding.ts | 128 +++++++++-- .../generic-quota-fetcher-volcengine.test.ts | 57 +++++ .../volcengine-plan-binding-upsert.test.ts | 203 ++++++++++++++++++ .../volcengine-plan-quota-windows.test.ts | 83 +++++++ ...lcengine-plan-request-sanitization.test.ts | 25 +++ 9 files changed, 536 insertions(+), 31 deletions(-) create mode 100644 changelog.d/fixes/volcengine-quota-binding.md create mode 100644 tests/unit/generic-quota-fetcher-volcengine.test.ts create mode 100644 tests/unit/volcengine-plan-binding-upsert.test.ts create mode 100644 tests/unit/volcengine-plan-quota-windows.test.ts create mode 100644 tests/unit/volcengine-plan-request-sanitization.test.ts diff --git a/changelog.d/fixes/volcengine-quota-binding.md b/changelog.d/fixes/volcengine-quota-binding.md new file mode 100644 index 0000000000..edd9e7484e --- /dev/null +++ b/changelog.d/fixes/volcengine-quota-binding.md @@ -0,0 +1 @@ +- fix(volcengine): resolve quota window mapping gaps, safe multi-connection console binding, and response cookie sanitization diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index ac89aae30a..ab5a87a693 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -330,6 +330,19 @@ function antigravityWeeklyWindowMatchesFamily( return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly"; } +const TIME_WINDOW_KEYS = new Set([ + "session", + "weekly", + "daily", + "monthly", + "session (5h)", + "weekly (7d)", + "AFPFiveHour", + "AFPWeekly", + "AFPDaily", + "AFPMonthly", +]); + function normalizeQuotaWindows( windows: Record, context: UsageToQuotaContext @@ -340,12 +353,14 @@ function normalizeQuotaWindows( ? getAntigravityQuotaFamily(context.requestedModel) : null; - // Claude-style explicit time windows. - if (windows["session (5h)"] && !normalized.window5h) { - normalized.window5h = windows["session (5h)"]; + // Explicit time windows (canonical and legacy aliases). + const fiveHourWindow = windows["session (5h)"] || windows["session"]; + if (fiveHourWindow && !normalized.window5h) { + normalized.window5h = fiveHourWindow; } - if (windows["weekly (7d)"] && !normalized.window7d) { - normalized.window7d = windows["weekly (7d)"]; + const sevenDayWindow = windows["weekly (7d)"] || windows["weekly"]; + if (sevenDayWindow && !normalized.window7d) { + normalized.window7d = sevenDayWindow; } // Antigravity-style per-model windows: pick worst only inside requested family. @@ -356,6 +371,7 @@ function normalizeQuotaWindows( !key.startsWith("window") && !key.includes("(5h)") && !key.includes("(7d)") && + !TIME_WINDOW_KEYS.has(key) && (requestedFamily === null || requestedFamily === "other" || getAntigravityQuotaFamily(key) === requestedFamily) diff --git a/open-sse/services/usage/volcenginePlan.ts b/open-sse/services/usage/volcenginePlan.ts index 64bc4a4b99..442176ca2b 100644 --- a/open-sse/services/usage/volcenginePlan.ts +++ b/open-sse/services/usage/volcenginePlan.ts @@ -101,9 +101,16 @@ function tsToIso(seconds: number): string | null { return Number.isNaN(d.getTime()) ? null : d.toISOString(); } +const CODING_LEVEL_TO_CANONICAL: Record = { + session: "session (5h)", + weekly: "weekly (7d)", + daily: "daily", + monthly: "monthly", +}; + const CODING_WINDOW_LABEL: Record = { - session: "Session (5h)", - weekly: "Weekly", + "session (5h)": "Session (5h)", + "weekly (7d)": "Weekly", monthly: "Monthly", daily: "Daily", }; @@ -119,27 +126,29 @@ function mapCodingPlanUsage(result: JsonRecord): Record { const w = toRecord(raw); const level = String(w.Level || "").toLowerCase(); if (!level) continue; - const cap = toNumber(w.Cap, 100) || 100; + const canonicalKey = CODING_LEVEL_TO_CANONICAL[level] || level; + const rawCap = toNumber(w.Cap, 100); + const cap = rawCap > 0 ? rawCap : 100; const usedPercent = toNumber(w.Percent, 0); const remainingPercentage = Math.max(0, Math.min(100, cap - usedPercent)); - quotas[level] = { + quotas[canonicalKey] = { used: usedPercent, total: cap, remaining: Math.max(0, cap - usedPercent), remainingPercentage, resetAt: tsToIso(toNumber(w.ResetTimestamp, 0)), unlimited: false, - displayName: CODING_WINDOW_LABEL[level] || level, + displayName: CODING_WINDOW_LABEL[canonicalKey] || level, }; } return quotas; } -const AGENT_WINDOW_LABEL: Array<[string, string]> = [ - ["AFPFiveHour", "Session (5h)"], - ["AFPDaily", "Daily"], - ["AFPWeekly", "Weekly"], - ["AFPMonthly", "Monthly"], +const AGENT_WINDOW_MAPPING: Array<[string, string, string]> = [ + ["AFPFiveHour", "session (5h)", "Session (5h)"], + ["AFPDaily", "daily", "Daily"], + ["AFPWeekly", "weekly (7d)", "Weekly"], + ["AFPMonthly", "monthly", "Monthly"], ]; /** @@ -148,16 +157,16 @@ const AGENT_WINDOW_LABEL: Array<[string, string]> = [ */ function mapAgentPlanUsage(result: JsonRecord): Record { const quotas: Record = {}; - for (const [key, label] of AGENT_WINDOW_LABEL) { - const w = toRecord(result[key]); + for (const [sourceKey, canonicalKey, label] of AGENT_WINDOW_MAPPING) { + const w = toRecord(result[sourceKey]); if (Object.keys(w).length === 0) continue; const total = toNumber(w.Quota, 0); const used = toNumber(w.Used, 0); const remaining = Math.max(0, total - used); const remainingPercentage = - total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 100; + total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0; const resetMs = toNumber(w.ResetTime, 0); - quotas[key] = { + quotas[canonicalKey] = { used, total, remaining, @@ -315,3 +324,8 @@ export async function getVolcenginePlanUsage( }; } } + +export const __testing = { + mapCodingPlanUsage, + mapAgentPlanUsage, +}; diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index aaa72497d2..301ad61f75 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -346,6 +346,8 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec delete sanitized.alibabaConsoleSecToken; delete sanitized.runtimeKey; delete sanitized.validationId; + delete sanitized.volcConsoleCookie; + delete sanitized.volcCsrfToken; // System-managed Codex fingerprint seed: never exposed through the API // (mirrors sub2api stripping `codex_fingerprint_seed`); the server-side // partial-update merge keeps it alive without the client round-tripping it. diff --git a/src/lib/providers/volcenginePlanBinding.ts b/src/lib/providers/volcenginePlanBinding.ts index ae989d5eae..96d2e34fba 100644 --- a/src/lib/providers/volcenginePlanBinding.ts +++ b/src/lib/providers/volcenginePlanBinding.ts @@ -118,7 +118,20 @@ export async function detectPlan( if (!result.ok) { return { available: false, usage: {}, error: result.error }; } - return { available: true, usage: record(result.json.Result), error: null }; + const usage = record(result.json.Result); + if (kind === "coding") { + const quotaUsage = usage.QuotaUsage; + if (!Array.isArray(quotaUsage) || quotaUsage.length === 0) { + return { available: false, usage: {}, error: "No active Coding Plan quota windows" }; + } + } else if (kind === "agent") { + const hasFiveHour = Boolean(usage.AFPFiveHour && Object.keys(record(usage.AFPFiveHour)).length > 0); + const hasWeekly = Boolean(usage.AFPWeekly && Object.keys(record(usage.AFPWeekly)).length > 0); + if (!hasFiveHour && !hasWeekly) { + return { available: false, usage: {}, error: "No active Agent Plan quota windows" }; + } + } + return { available: true, usage, error: null }; } function firstApiKeyItem(result: JsonRecord): JsonRecord | null { @@ -173,16 +186,102 @@ async function fetchRawApiKey( return { apiKey, id, maskedKey: stringField(item?.Key) || null, error: null }; } -async function upsertConnection( +export interface FindTargetConnectionCriteria { + targetConnectionId?: string; + provider: string; + apiKey?: string; + apiKeyId?: number | null; + defaultName: string; +} + +/** + * Pure matcher to find an existing connection to adopt or update during console binding. + * + * Matching precedence: + * 1. targetConnectionId priority match (strictly verified against criteria.provider). + * 2. Exact apiKey match, or volcApiKeyId match (numeric and > 0). + * 3. Canonical default name match (e.g. 'Volcano Ark Coding Plan'). + * 4. Intentional fallback: safe adoption for single existing connection under this provider. + * - Design rationale: Operators commonly created custom connections (e.g. named 'main') + * prior to console login. This fallback connects console cookies to that sole instance. + * - Safety boundary: strictly scoped to allConnections.length === 1 so that multiple + * distinct accounts are never silently clobbered. + * 5. When multiple connections exist and none match: returns undefined (triggers new connection creation). + */ +export function findTargetConnection( + allConnections: JsonRecord[], + criteria: FindTargetConnectionCriteria +): JsonRecord | undefined { + // 1. targetConnectionId priority match (with provider affinity check) + if (criteria.targetConnectionId) { + const matched = allConnections.find( + (conn) => + stringField(conn.id) === criteria.targetConnectionId && + stringField(conn.provider) === criteria.provider + ); + if (matched) return matched; + } + + // 2. Match by valid apiKey or valid volcApiKeyId + if (criteria.apiKey) { + const matched = allConnections.find( + (conn) => + stringField(conn.apiKey) === criteria.apiKey && + stringField(conn.provider) === criteria.provider + ); + if (matched) return matched; + } + if (typeof criteria.apiKeyId === "number" && criteria.apiKeyId > 0) { + const matched = allConnections.find((conn) => { + const psd = record(conn.providerSpecificData); + return ( + Number(psd.volcApiKeyId) === criteria.apiKeyId && + stringField(conn.provider) === criteria.provider + ); + }); + if (matched) return matched; + } + + // 3. Match by canonical default name + const nameMatched = allConnections.find( + (conn) => + stringField(conn.name) === criteria.defaultName && + stringField(conn.provider) === criteria.provider + ); + if (nameMatched) return nameMatched; + + // 4. Safe adoption for single connection scenario under matching provider (e.g. user-named 'main') + if (allConnections.length === 1 && stringField(allConnections[0].provider) === criteria.provider) { + return allConnections[0]; + } + + // 5. Multiple connections exist and none matched -> do not clobber; return undefined + return undefined; +} + +export async function upsertConnection( kind: PlanKind, apiKey: string, cookieHeader: string, csrfToken: string, apiKeyId: number | null, - usage: JsonRecord + usage: JsonRecord, + targetConnectionId?: string ) { const cfg = PLAN_CONFIG[kind]; + const allConnections = (await getProviderConnections({ provider: cfg.provider })) as JsonRecord[]; + + const matched = findTargetConnection(allConnections, { + targetConnectionId, + provider: cfg.provider, + apiKey, + apiKeyId, + defaultName: cfg.name, + }); + + const existingPsd = matched ? record(matched.providerSpecificData) : {}; const providerSpecificData = { + ...existingPsd, volcConsoleCookie: cookieHeader, volcCsrfToken: csrfToken, volcApiKeyId: apiKeyId, @@ -192,14 +291,10 @@ async function upsertConnection( autoSync: true, }; - const existing = (await getProviderConnections({ provider: cfg.provider })).find( - (conn: JsonRecord) => stringField(conn.name) === cfg.name - ); - - if (existing?.id) { - return await updateProviderConnection(stringField(existing.id), { + if (matched?.id) { + return await updateProviderConnection(stringField(matched.id), { apiKey, - name: cfg.name, + name: stringField(matched.name) || cfg.name, providerSpecificData, isActive: true, testStatus: "active", @@ -217,7 +312,10 @@ async function upsertConnection( }); } -export async function bindVolcenginePlansFromConsoleCredentials(credentials: JsonRecord) { +export async function bindVolcenginePlansFromConsoleCredentials( + credentials: JsonRecord, + options?: { targetConnectionId?: string } +) { const cookieHeader = buildCookieHeader(credentials); const csrfToken = extractCsrf(credentials, cookieHeader); if (!cookieHeader || !csrfToken) { @@ -260,7 +358,8 @@ export async function bindVolcenginePlansFromConsoleCredentials(credentials: Jso cookieHeader, csrfToken, key.id, - detected.usage + detected.usage, + options?.targetConnectionId ); results.push({ plan: kind, @@ -277,3 +376,8 @@ export async function bindVolcenginePlansFromConsoleCredentials(credentials: Jso results, }; } + +export const __testing = { + findTargetConnection, + upsertConnection, +}; diff --git a/tests/unit/generic-quota-fetcher-volcengine.test.ts b/tests/unit/generic-quota-fetcher-volcengine.test.ts new file mode 100644 index 0000000000..04dfbba24f --- /dev/null +++ b/tests/unit/generic-quota-fetcher-volcengine.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { convertUsageToQuotaInfo } from "../../open-sse/services/genericQuotaFetcher.ts"; + +test("convertUsageToQuotaInfo extracts window5h and window7d for volcengine canonical keys", () => { + const usage = { + plan: "Volcano Ark Coding Plan", + quotas: { + "session (5h)": { + used: 40, + total: 100, + remainingPercentage: 60, + resetAt: "2026-09-07T12:00:00.000Z", + }, + "weekly (7d)": { + used: 15, + total: 100, + remainingPercentage: 85, + resetAt: "2026-09-14T00:00:00.000Z", + }, + }, + }; + + const info = convertUsageToQuotaInfo(usage, { provider: "volcengine-coding-plan" }); + assert.ok(info); + assert.equal(info.window5h?.percentUsed, 0.4); + assert.equal(info.window5h?.resetAt, "2026-09-07T12:00:00.000Z"); + assert.equal(info.window7d?.percentUsed, 0.15); + assert.equal(info.window7d?.resetAt, "2026-09-14T00:00:00.000Z"); +}); + +test("convertUsageToQuotaInfo extracts window5h and window7d from legacy fallback keys without poisoning modelWindows", () => { + const usage = { + plan: "Volcano Ark Agent Plan", + quotas: { + weekly: { + used: 200, + total: 1000, + remainingPercentage: 80, + resetAt: "2026-09-14T00:00:00.000Z", + }, + monthly: { + used: 500, + total: 5000, + remainingPercentage: 90, + resetAt: "2026-10-01T00:00:00.000Z", + }, + }, + }; + + const info = convertUsageToQuotaInfo(usage, { provider: "volcengine-agent-plan" }); + assert.ok(info); + // Missing 5h window must remain undefined, NOT poisoned by weekly or monthly + assert.equal(info.window5h, undefined); + assert.equal(info.window7d?.percentUsed, 0.2); + assert.equal(info.window7d?.resetAt, "2026-09-14T00:00:00.000Z"); +}); diff --git a/tests/unit/volcengine-plan-binding-upsert.test.ts b/tests/unit/volcengine-plan-binding-upsert.test.ts new file mode 100644 index 0000000000..51a3ca840e --- /dev/null +++ b/tests/unit/volcengine-plan-binding-upsert.test.ts @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; +import { + createProviderConnection, + getProviderConnectionById, +} from "../../src/models/index.ts"; +import { detectPlan, __testing as bindingTesting } from "../../src/lib/providers/volcenginePlanBinding.ts"; + +test("detectPlan returns available: false when account has no active quota windows (unsubscribed)", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + // Mock console response with empty QuotaUsage + globalThis.fetch = async () => + new Response( + JSON.stringify({ + ResponseMetadata: {}, + Result: { + QuotaUsage: [], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const coding = await detectPlan("coding", "cookie=1", "csrf=1"); + assert.equal(coding.available, false); + + // Mock console response for Agent with empty quota object + globalThis.fetch = async () => + new Response( + JSON.stringify({ + ResponseMetadata: {}, + Result: {}, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const agent = await detectPlan("agent", "cookie=1", "csrf=1"); + assert.equal(agent.available, false); +}); + +test("volcenginePlanBinding upsert rules with SQLite temp isolation", async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "or-volc-test-")); + const prevDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tempDir; + + t.after(() => { + resetDbInstance(); + if (prevDataDir) process.env.DATA_DIR = prevDataDir; + else delete process.env.DATA_DIR; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + resetDbInstance(); + + // T-1: Single connection named 'main' is adopted and updated, preserving name and autoFetchModels + const conn1 = await createProviderConnection({ + provider: "volcengine-coding-plan", + name: "main", + apiKey: "ark-original-key-1", + providerSpecificData: { autoFetchModels: true, customTag: "keep-me" }, + }); + + const updated1 = await (bindingTesting as any).upsertConnection( + "coding", + "ark-new-key-1", + "new-cookie-1", + "new-csrf-1", + 123, + { dummy: 1 }, + undefined + ); + + assert.equal(updated1.id, conn1.id); + assert.equal(updated1.name, "main", "Preserves original custom name 'main'"); + assert.equal(updated1.apiKey, "ark-new-key-1"); + assert.equal(updated1.providerSpecificData.autoFetchModels, true, "Preserves existing PSD autoFetchModels"); + assert.equal(updated1.providerSpecificData.customTag, "keep-me", "Preserves existing PSD customTag"); + assert.equal(updated1.providerSpecificData.volcConsoleCookie, "new-cookie-1"); + assert.equal(updated1.providerSpecificData.volcApiKeyId, 123); + + // T-4: Multiple connections exist without match -> safely creates new connection without clobbering + await createProviderConnection({ + provider: "volcengine-coding-plan", + name: "secondary", + apiKey: "ark-other-key-2", + providerSpecificData: {}, + }); + + const createdNew = await (bindingTesting as any).upsertConnection( + "coding", + "ark-brand-new-key-3", + "new-cookie-3", + "new-csrf-3", + 999, + {}, + undefined + ); + + assert.notEqual(createdNew.id, conn1.id); + assert.equal(createdNew.name, "Volcano Ark Coding Plan"); + const preserved = await getProviderConnectionById(conn1.id as string); + assert.equal(preserved.apiKey, "ark-new-key-1", "Original connection was NOT clobbered"); + + // T-5: targetConnectionId with cross-provider guard + const agentConn = await createProviderConnection({ + provider: "volcengine-agent-plan", + name: "agent-main", + apiKey: "ark-agent-key", + providerSpecificData: {}, + }); + + // Passing conn1.id (which is coding-plan) into agent upsert must NOT match conn1 + const agentUpsertResult = await (bindingTesting as any).upsertConnection( + "agent", + "ark-agent-new-key", + "agent-cookie", + "agent-csrf", + 888, + {}, + conn1.id as string // Mismatched provider + ); + assert.notEqual(agentUpsertResult.id, conn1.id); + assert.equal(agentUpsertResult.id, agentConn.id, "Matched the single agent connection instead"); +}); + +test("findTargetConnection pure matching logic and single-connection adoption intent", () => { + const criteria = { + provider: "volcengine-coding-plan", + defaultName: "Volcano Ark Coding Plan", + apiKey: "ark-key-match", + }; + + // 1. targetConnectionId priority match (strictly same provider) + const list1 = [ + { id: "c1", provider: "volcengine-coding-plan", name: "any" }, + { id: "c2", provider: "volcengine-coding-plan", name: "other" }, + ]; + assert.equal( + bindingTesting.findTargetConnection(list1, { ...criteria, targetConnectionId: "c2" })?.id, + "c2" + ); + // targetConnectionId with mismatched provider does not match + assert.equal( + bindingTesting.findTargetConnection( + [{ id: "c-other", provider: "different-provider", name: "any" }], + { ...criteria, targetConnectionId: "c-other" } + ), + undefined + ); + + // 2. ApiKey match + const list2 = [ + { id: "c1", provider: "volcengine-coding-plan", apiKey: "ark-key-match" }, + { id: "c2", provider: "volcengine-coding-plan", apiKey: "ark-diff-key" }, + ]; + assert.equal(bindingTesting.findTargetConnection(list2, criteria)?.id, "c1"); + + // 3. VolcApiKeyId match + const list3 = [ + { id: "c1", provider: "volcengine-coding-plan", providerSpecificData: { volcApiKeyId: 777 } }, + { id: "c2", provider: "volcengine-coding-plan", providerSpecificData: { volcApiKeyId: 888 } }, + ]; + assert.equal( + bindingTesting.findTargetConnection(list3, { ...criteria, apiKey: undefined, apiKeyId: 888 })?.id, + "c2" + ); + + // 4. Default name match + const list4 = [ + { id: "c1", provider: "volcengine-coding-plan", name: "custom-name" }, + { id: "c2", provider: "volcengine-coding-plan", name: "Volcano Ark Coding Plan" }, + ]; + assert.equal( + bindingTesting.findTargetConnection(list4, { ...criteria, apiKey: undefined })?.id, + "c2" + ); + + // 5. Intentional fallback: single existing connection adoption + const list5 = [{ id: "c-single", provider: "volcengine-coding-plan", name: "main" }]; + assert.equal( + bindingTesting.findTargetConnection(list5, { ...criteria, apiKey: undefined })?.id, + "c-single", + "Adopts sole existing connection for provider" + ); + + // 6. Multiple connections without match -> undefined (new connection will be created) + const list6 = [ + { id: "c1", provider: "volcengine-coding-plan", name: "account-a" }, + { id: "c2", provider: "volcengine-coding-plan", name: "account-b" }, + ]; + assert.equal( + bindingTesting.findTargetConnection(list6, { ...criteria, apiKey: undefined }), + undefined, + "Does not clobber when multiple connections exist" + ); +}); diff --git a/tests/unit/volcengine-plan-quota-windows.test.ts b/tests/unit/volcengine-plan-quota-windows.test.ts new file mode 100644 index 0000000000..64ada8fd33 --- /dev/null +++ b/tests/unit/volcengine-plan-quota-windows.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { __testing } from "../../open-sse/services/usage/volcenginePlan.ts"; + +test("mapCodingPlanUsage maps windows to canonical names and handles cap edge cases", () => { + const sampleResult = { + QuotaUsage: [ + { + Level: "session", + Cap: 100, + Percent: 35, + ResetTimestamp: 1725686400, + }, + { + Level: "weekly", + Cap: 100, + Percent: 10, + ResetTimestamp: 1726204800, + }, + { + Level: "daily", + Cap: 0, + Percent: 0, + ResetTimestamp: 0, + }, + ], + }; + + const quotas = __testing.mapCodingPlanUsage(sampleResult); + assert.ok(quotas["session (5h)"]); + assert.equal(quotas["session (5h)"].used, 35); + assert.equal(quotas["session (5h)"].total, 100); + assert.equal(quotas["session (5h)"].remainingPercentage, 65); + assert.ok(quotas["session (5h)"].resetAt); + + assert.ok(quotas["weekly (7d)"]); + assert.equal(quotas["weekly (7d)"].used, 10); + assert.equal(quotas["weekly (7d)"].remainingPercentage, 90); + + assert.ok(quotas["daily"]); + assert.equal(quotas["daily"].resetAt, null); + assert.equal("session" in quotas, false); + assert.equal("weekly" in quotas, false); +}); + +test("mapAgentPlanUsage maps windows to canonical names and guards zero total", () => { + const sampleResult = { + AFPFiveHour: { + Quota: 1000, + Used: 250, + ResetTime: 1725686400000, + }, + AFPWeekly: { + Quota: 5000, + Used: 500, + ResetTime: 1726204800000, + }, + AFPDaily: { + Quota: 0, + Used: 0, + ResetTime: 0, + }, + }; + + const quotas = __testing.mapAgentPlanUsage(sampleResult); + assert.ok(quotas["session (5h)"]); + assert.equal(quotas["session (5h)"].used, 250); + assert.equal(quotas["session (5h)"].total, 1000); + assert.equal(quotas["session (5h)"].remainingPercentage, 75); + + assert.ok(quotas["weekly (7d)"]); + assert.equal(quotas["weekly (7d)"].used, 500); + assert.equal(quotas["weekly (7d)"].total, 5000); + assert.equal(quotas["weekly (7d)"].remainingPercentage, 90); + + assert.ok(quotas["daily"]); + assert.equal(quotas["daily"].total, 0); + // Total 0 must result in remainingPercentage: 0, NOT 100 (which poisons routing priority) + assert.equal(quotas["daily"].remainingPercentage, 0); + + assert.equal("AFPFiveHour" in quotas, false); + assert.equal("AFPWeekly" in quotas, false); +}); diff --git a/tests/unit/volcengine-plan-request-sanitization.test.ts b/tests/unit/volcengine-plan-request-sanitization.test.ts new file mode 100644 index 0000000000..b62f510eca --- /dev/null +++ b/tests/unit/volcengine-plan-request-sanitization.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeProviderSpecificDataForResponse } from "../../src/lib/providers/requestDefaults.ts"; + +test("sanitizeProviderSpecificDataForResponse strips volcConsoleCookie and volcCsrfToken", () => { + const input = { + autoSync: true, + autoFetchModels: true, + volcConsoleCookie: "session=secret123; AccountID=acc456", + volcCsrfToken: "csrf-token-xyz", + volcApiKeyId: 1001, + volcPlanKind: "coding", + }; + + const sanitized = sanitizeProviderSpecificDataForResponse(input); + assert.ok(sanitized); + assert.equal(sanitized.autoSync, true); + assert.equal(sanitized.autoFetchModels, true); + assert.equal(sanitized.volcApiKeyId, 1001); + assert.equal(sanitized.volcPlanKind, "coding"); + assert.equal(sanitized.volcConsoleCookie, undefined); + assert.equal(sanitized.volcCsrfToken, undefined); + assert.equal("volcConsoleCookie" in sanitized, false); + assert.equal("volcCsrfToken" in sanitized, false); +}); From f7349dd7768e061d840c4674f0d81a114b5df848 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 8 Sep 2026 09:30:02 -0300 Subject: [PATCH 10/15] chore(quality): rebaseline chatCore.ts after the non-streaming regression fixes (#13045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap de arquivo estourado por mim: mergeei o #12963 e o #12990 sem rebaselinar o `chatCore.ts`, que ambos fazem crescer. Cada um passou no próprio gate porque mediu contra o tip de onde saiu — o cap só estoura no conjunto, que é o padrão registrado sete vezes no handoff da campanha anterior. 5984 → 6021, +37 linhas, irredutíveis nos chokepoints existentes: cada edição fica onde o `chatCore` já toma a decisão, e os helpers estão sob o cap. Coberto por `chatcore-translation-paths` (72/74; as 2 abertas são a issue #13043). --- config/quality/file-size-baseline.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 769a6552f0..49c5f74724 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -424,14 +424,13 @@ "open-sse/executors/codex.ts": 1505, "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5984, + "open-sse/handlers/chatCore.ts": 6021, "open-sse/handlers/imageGeneration.ts": 3259, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, - "open-sse/services/combo.ts": 4084, "open-sse/services/combo.ts": 4080, "open-sse/services/combo/executeTargetAttempt.ts": 1205, "open-sse/translator/response/openai-responses.ts": 1466, @@ -652,5 +651,6 @@ "_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).", "_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).", "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).", - "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped." + "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped.", + "_rebaseline_2026_09_07_chatcore_nonstreaming_regression_fixes": "Own growth: open-sse/handlers/chatCore.ts 5984->6021 (+37). Two of my own PRs on top of #12867: #12963 pins the ok variant of the non-streaming leg result in its own binding (the discriminated-union narrowing was lost across the tool-loop reassignment, 13 TS2339 under tsconfig.typecheck-api.json), and #12990 restores four behaviours the same refactor dropped — abort classification through isLocalStreamLifecycleError, the omitted synthetic clientResponse, the claudePromptCacheLogMeta rebuild on the leg path, and the lazy fail-closed fence identity. Irreducible at the existing chokepoints: each edit sits where chatCore already owns the decision, and the helpers themselves (nonStreamingProviderLeg.ts, serverOwnedToolLoopWire.ts) are under cap. Covered by tests/unit/chatcore-translation-paths.test.ts (72/74; the 2 open are issue #13043)." } From ba597b631d22d85e56db6982f24b7d1ebe238df9 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:31:32 +0200 Subject: [PATCH 11/15] fix(db): call_logs provider stats read true on empty and legacy data (#12832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289), 70 testes focados no runner Node e 1 no vitest, todos verdes. "Zeros continuam zeros, latências ausentes continuam ausentes, falhas pré-coluna ganham o próprio balde" — a distinção entre ausência e zero é o miolo aqui. Um install novo mostrando 0ms como se tivesse medido é pior que mostrar nada, porque parece dado. **Uma mudança minha na sua branch: a migration foi renumerada de 174 para 175.** A `174_server_tool_executions.sql` entrou no #12867, mergeado horas antes desta leva, então `174_call_logs_provider_stats_indexes.sql` colidia. Renomeei o arquivo e ajustei o rótulo do teste ("migration 174 creates..." → 175). Confirmei que não sobrou prefixo duplicado em `src/lib/db/migrations/` e revalidei o `call-logs-provider-stats`: 4/4. Os dois índices compostos são a parte que paga a longo prazo — rollup por provider parando de varrer a tabela. --- .../fixes/12832-call-logs-provider-stats.md | 1 + src/app/api/provider-metrics/route.ts | 9 +- src/app/api/search/stats/route.ts | 2 +- src/lib/db/callLogStats.ts | 32 ++--- src/lib/db/core.ts | 2 + .../175_call_logs_provider_stats_indexes.sql | 6 + .../unit/db/call-logs-provider-stats.test.ts | 119 ++++++++++++++++++ 7 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fixes/12832-call-logs-provider-stats.md create mode 100644 src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql create mode 100644 tests/unit/db/call-logs-provider-stats.test.ts diff --git a/changelog.d/fixes/12832-call-logs-provider-stats.md b/changelog.d/fixes/12832-call-logs-provider-stats.md new file mode 100644 index 0000000000..9e4ef7c433 --- /dev/null +++ b/changelog.d/fixes/12832-call-logs-provider-stats.md @@ -0,0 +1 @@ +- **fix(db):** provider stats stay truthful on empty and legacy databases: fallback counts default to `0` instead of `null`, latency averages read `null` (not `0`) when no durations were recorded, failures logged before the error-type column existed group under `pre_migration` instead of `unclassified`, and per-provider queries use two new composite indexes ([#12832](https://github.com/diegosouzapw/OmniRoute/pull/12832)) — thanks @maxmad64bis diff --git a/src/app/api/provider-metrics/route.ts b/src/app/api/provider-metrics/route.ts index 3b6e16a0a7..ad2d0e06d7 100644 --- a/src/app/api/provider-metrics/route.ts +++ b/src/app/api/provider-metrics/route.ts @@ -4,7 +4,7 @@ import pino from "pino"; import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; import { getProviderMetrics } from "@/lib/db/callLogStats"; -import { toNumber } from "@/shared/utils/numeric"; +import { toNumber, toNumberOrNull } from "@/shared/utils/numeric"; const logger = pino({ name: "provider-metrics-api" }); @@ -22,7 +22,7 @@ export async function GET() { totalRequests: number; totalSuccesses: number; successRate: number; - avgLatencyMs: number; + avgLatencyMs: number | null; lastRequestAt: string | null; lastErrorAt: string | null; lastStatus: number | null; @@ -41,7 +41,7 @@ export async function GET() { : "unknown"; const totalRequests = toNumber(row.totalRequests); const totalSuccesses = toNumber(row.totalSuccesses); - const avgLatencyMs = toNumber(row.avgLatencyMs); + const avgLatencyMs = toNumberOrNull(row.avgLatencyMs); const lastRequestAt = typeof row.lastRequestAt === "string" ? row.lastRequestAt : null; const lastErrorAt = typeof row.lastErrorAt === "string" ? row.lastErrorAt : null; const lastStatus = row.lastStatus == null ? null : toNumber(row.lastStatus); @@ -66,8 +66,7 @@ export async function GET() { // Only flag as errorProvider if the provider's MOST RECENT request was itself // a failure. A provider with a historical lastErrorAt but a recent success // (lastStatus 2xx/3xx) must not be shown as currently errored (#3619). - const isCurrentlyInError = - lastStatus !== null && (lastStatus < 200 || lastStatus >= 400); + const isCurrentlyInError = lastStatus !== null && (lastStatus < 200 || lastStatus >= 400); const errorTs = isCurrentlyInError && lastErrorAt ? Date.parse(lastErrorAt) : 0; if (Number.isFinite(errorTs) && errorTs > errorProviderTs) { errorProvider = provider; diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts index bf0992f1a4..6559ff732a 100644 --- a/src/app/api/search/stats/route.ts +++ b/src/app/api/search/stats/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { const providers: Record< string, - { requests: number; avg_latency_ms: number; total_cost: number } + { requests: number; avg_latency_ms: number | null; total_cost: number } > = {}; for (const row of providerStats) { const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0; diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index f3845f31f1..8498ce3331 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -18,7 +18,7 @@ export interface ProviderMetricRow { provider: string; totalRequests: number; totalSuccesses: number; - avgLatencyMs: number; + avgLatencyMs: number | null; lastRequestAt: string | null; lastErrorAt: string | null; lastStatus: number | null; @@ -37,7 +37,7 @@ export interface ProviderUsageRow { export interface SearchProviderStatRow { provider: string; requests: number; - avg_latency_ms: number; + avg_latency_ms: number | null; } export interface SearchRecentRow { @@ -126,10 +126,9 @@ export function getProviderMetrics(): ProviderMetricRow[] { * * Deliberately NOT `getProviderMetrics()` with a `since` parameter: that query * carries two correlated subqueries (`lastStatus`, `lastErrorStatus`) which a - * ranking never displays, and they dominate its cost — `call_logs` is indexed - * on `timestamp` alone, so each correlated pass rescans the whole window per - * provider. Here a single bounded `GROUP BY` uses `idx_cl_timestamp` and stops - * there. The rules are shared with its neighbour, not the query: same success + * ranking never displays, and they dominate its cost. Here a single bounded + * `GROUP BY` leans on `idx_cl_timestamp` plus `idx_cl_provider_timestamp` / + * `idx_cl_request_provider` (migration 174) and stops there. The rules are shared with its neighbour, not the query: same success * definition, same `#10714` guard against providers whose connections are gone. */ export function getProviderUsageSince(since: string): ProviderUsageRow[] { @@ -259,17 +258,17 @@ export function getFallbackStats( .prepare( ` SELECT - SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as total, - SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END) as with_requested, - SUM(CASE + COALESCE(SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END), 0) as total, + COALESCE(SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END), 0) as with_requested, + COALESCE(SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') AND requested_model IS NOT NULL AND requested_model != '' AND model IS NOT NULL AND model != '' THEN 1 ELSE 0 END - ) as fallback_eligible, - SUM(CASE + ), 0) as fallback_eligible, + COALESCE(SUM(CASE WHEN (combo_name IS NULL OR combo_name = '') AND requested_model IS NOT NULL AND requested_model != '' @@ -277,7 +276,7 @@ export function getFallbackStats( AND model != '' AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model) THEN 1 ELSE 0 END - ) as fallbacks + ), 0) as fallbacks FROM call_logs ${whereClause} ` @@ -289,8 +288,9 @@ export function getFallbackStats( /** * Failure-family breakdown over `call_logs` for the usage analytics endpoint. * Failures are rows with status >= 400 or a non-empty error summary; successes - * are excluded in SQL. Pre-migration rows and failures the classifier does not - * recognize (null family) land in the explicit `unclassified` bucket. + * are excluded in SQL. Rows predating migration 158 (`error_type` NULL, + * `timestamp` before 2026-08-20) land in `pre_migration`; other NULL families + * land in `unclassified`. * * @param whereClause - SQL WHERE clause (may be empty string) using the same * named params as the usage_history queries. @@ -305,7 +305,9 @@ export function getErrorTypeBreakdown( .prepare( ` SELECT - COALESCE(error_type, 'unclassified') AS errorType, + -- '2026-08-20' = commit 4c15c05f9 that added error_type (migration 158). + -- Lower bound, not exact: late upgraders have post-cutoff rows with NULL values. + CASE WHEN error_type IS NULL AND timestamp < '2026-08-20' THEN 'pre_migration' WHEN error_type IS NULL THEN 'unclassified' ELSE error_type END AS errorType, COUNT(*) AS count FROM call_logs ${whereClause} ${whereClause ? "AND" : "WHERE"} (status >= 400 OR error_summary IS NOT NULL) diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 3c3769c066..a69db56e64 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -407,6 +407,8 @@ const SCHEMA_SQL = ` ); CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); + CREATE INDEX IF NOT EXISTS idx_cl_provider_timestamp ON call_logs(provider, timestamp); + CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider); CREATE TABLE IF NOT EXISTS proxy_logs ( id TEXT PRIMARY KEY, diff --git a/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql b/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql new file mode 100644 index 0000000000..b6a8a7b5d1 --- /dev/null +++ b/src/lib/db/migrations/175_call_logs_provider_stats_indexes.sql @@ -0,0 +1,6 @@ +-- GROUP BY provider support. (provider,timestamp) backs the bare +-- GROUP BY provider in getProviderMetrics; (request_type,provider) +-- backs WHERE request_type='search' GROUP BY provider. Non-covering for the +-- real queries (duration/status outside the index) by design — no third index. +CREATE INDEX IF NOT EXISTS idx_cl_provider_timestamp ON call_logs(provider, timestamp); +CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider); diff --git a/tests/unit/db/call-logs-provider-stats.test.ts b/tests/unit/db/call-logs-provider-stats.test.ts new file mode 100644 index 0000000000..3f3a503d58 --- /dev/null +++ b/tests/unit/db/call-logs-provider-stats.test.ts @@ -0,0 +1,119 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-call-logs-stats-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const stats = await import("../../../src/lib/db/callLogStats.ts"); + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetDb(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("getFallbackStats on empty DB returns zeros, not nulls", () => { + core.getDbInstance(); // plays SCHEMA + runMigrations on the file DB + const row = stats.getFallbackStats("", {}); + assert.deepEqual(row, { + total: 0, + with_requested: 0, + fallback_eligible: 0, + fallbacks: 0, + }); +}); + +test("avgLatencyMs is null when all durations are NULL, and the route propagates null", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) + VALUES ('conn-1', 'openai', ?, ?)` + ).run(now, now); + db.prepare( + `INSERT INTO call_logs (id, timestamp, provider, status, duration) + VALUES ('log-1', ?, 'openai', 200, NULL)` + ).run(now); + + const { toNumberOrNull } = await import("../../../src/shared/utils/numeric.ts"); + const rows = stats.getProviderMetrics(); + assert.equal(rows.length, 1); + // Lib-level: passes before AND after (the driver already returns null — only + // the TS type lied). Kept as documentation, not as red/green proof. + assert.equal(toNumberOrNull(rows[0].avgLatencyMs), null); + + const { GET } = await import("../../../src/app/api/provider-metrics/route.ts"); + const res = await GET(); + const body = (await res.json()) as { + metrics: Record; + }; + // Route-level: THIS is the red/green proof (toNumber(null) → 0 before fix). + assert.equal(body.metrics["openai"].avgLatencyMs, null); +}); + +test("error_type NULL splits into pre_migration vs unclassified by timestamp", () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) + VALUES ('conn-1', 'openai', ?, ?)` + ).run(now, now); + db.prepare( + `INSERT INTO call_logs (id, timestamp, provider, status, error_type) + VALUES ('old-1', '2026-08-01T00:00:00.000Z', 'openai', 500, NULL), + ('new-1', ?, 'openai', 500, NULL)` + ).run(now); + + const breakdown = stats.getErrorTypeBreakdown("", {}); + const byType = new Map(breakdown.map((b) => [b.errorType, b.count])); + assert.equal(byType.get("pre_migration"), 1); + assert.equal(byType.get("unclassified"), 1); +}); + +test("migration 175 creates provider GROUP BY indexes used by search stats", () => { + const db = core.getDbInstance(); + + const names = ( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{ + name: string; + }> + ).map((r) => r.name); + assert.ok( + names.includes("idx_cl_provider_timestamp"), + "idx_cl_provider_timestamp must exist after migrations" + ); + assert.ok( + names.includes("idx_cl_request_provider"), + "idx_cl_request_provider must exist after migrations" + ); + + const plan = ( + db + .prepare( + "EXPLAIN QUERY PLAN SELECT provider, COUNT(*), AVG(duration) FROM call_logs WHERE request_type = 'search' GROUP BY provider" + ) + .all() as Array<{ detail: string }> + ) + .map((r) => r.detail) + .join(" | "); + assert.ok( + plan.includes("USING INDEX idx_cl_request_provider"), + `planner must use idx_cl_request_provider, got: ${plan}` + ); + assert.ok(!plan.includes("SCAN TABLE"), `must not table-scan, got: ${plan}`); +}); From 949235736042b13cf64215632e6d44db7985af76 Mon Sep 17 00:00:00 2001 From: anhtahaylove <37265396+anhtahaylove@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:58:23 +0700 Subject: [PATCH 12/15] fix(deepseek): release the PoW worker slot when spawning fails (#13097) `solveInWorker` increments `activeWorkerCount` before constructing the Worker, but the `cleanup()` that decrements it lives inside the promise executor and only runs once the worker exists. Anything that throws first -- most obviously `resolveWorkerPath()` when the worker script is missing, since it resolves against `process.cwd()` -- leaves the counter permanently incremented. With `MAX_CONCURRENT_WORKERS = 2`, two such failures wedge the solver for the lifetime of the process: every later call rejects with "capacity reached (2)" while no worker is actually running, and the real cause is hidden. Construct the Worker inside a try/catch and release the slot before rejecting. Fixes #13094 --- .../fixes/13094-deepseek-pow-slot-leak.md | 1 + open-sse/lib/deepseek-pow.ts | 13 ++- .../unit/deepseek-pow-slot-leak-13094.test.ts | 85 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/13094-deepseek-pow-slot-leak.md create mode 100644 tests/unit/deepseek-pow-slot-leak-13094.test.ts diff --git a/changelog.d/fixes/13094-deepseek-pow-slot-leak.md b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md new file mode 100644 index 0000000000..6560854f8d --- /dev/null +++ b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md @@ -0,0 +1 @@ +Fix a concurrency-slot leak in the DeepSeek PoW solver: a worker that failed to spawn (for example a missing worker script) never released its slot, so `MAX_CONCURRENT_WORKERS` failures disabled the solver until restart. diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts index d70998276b..3d373dcb13 100644 --- a/open-sse/lib/deepseek-pow.ts +++ b/open-sse/lib/deepseek-pow.ts @@ -106,7 +106,18 @@ function solveInWorker( activeWorkerCount += 1; return new Promise((resolve, reject) => { - const worker = new Worker(resolveWorkerPath(), { workerData: validated }); + // The slot is taken before this executor runs, so anything that throws here + // -- a missing worker script, a spawn failure -- has to hand it back. Without + // this, MAX_CONCURRENT_WORKERS spawn failures wedge the solver permanently + // and every later call reports "capacity reached" instead of the real cause. + let worker: Worker; + try { + worker = new Worker(resolveWorkerPath(), { workerData: validated }); + } catch (error) { + activeWorkerCount = Math.max(0, activeWorkerCount - 1); + reject(error instanceof Error ? error : new Error(String(error))); + return; + } let settled = false; const cleanup = () => { diff --git a/tests/unit/deepseek-pow-slot-leak-13094.test.ts b/tests/unit/deepseek-pow-slot-leak-13094.test.ts new file mode 100644 index 0000000000..e3d9239791 --- /dev/null +++ b/tests/unit/deepseek-pow-slot-leak-13094.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +const { solveDeepSeekPowAsync } = await import("../../open-sse/lib/deepseek-pow.ts"); + +// A valid challenge: the failure under test happens at worker construction, +// long after validation, so these values just have to pass validateChallenge(). +const ALGORITHM = "DeepSeekHashV1"; +const CHALLENGE = "a".repeat(64); +const SALT = "test-salt"; +const DIFFICULTY = 1; + +function futureExpiry() { + return Date.now() + 60_000; +} + +test("a failed worker spawn does not consume a permanent concurrency slot (#13094)", async () => { + // resolveWorkerPath() resolves the worker script against process.cwd(), so + // running from a directory without it makes construction throw -- the same + // condition users hit when the process starts from an unexpected cwd. + const originalCwd = process.cwd(); + const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-")); + process.chdir(emptyDir); + + try { + // MAX_CONCURRENT_WORKERS is 2, so two leaked slots exhaust the budget. + for (let i = 0; i < 2; i++) { + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + /worker script not found/i, + `attempt ${i + 1} should fail on the missing worker script` + ); + } + + // The third attempt must still report the real cause. Before the fix the + // counter had been incremented twice without ever being released, so this + // rejected with "capacity reached" and the solver stayed dead until restart. + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + (error: Error) => { + assert.doesNotMatch( + error.message, + /capacity reached/i, + "a spawn failure must not be reported as exhausted capacity" + ); + assert.match(error.message, /worker script not found/i); + return true; + } + ); + } finally { + process.chdir(originalCwd); + rmSync(emptyDir, { recursive: true, force: true }); + } +}); + +test("the slot is released again once spawning works (#13094)", async () => { + const originalCwd = process.cwd(); + const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-")); + + try { + process.chdir(emptyDir); + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + /worker script not found/i + ); + + // Back in a working directory the solver has to be usable again: a leaked + // slot would eventually surface here as a spurious capacity rejection. + process.chdir(originalCwd); + const answer = await solveDeepSeekPowAsync( + ALGORITHM, + CHALLENGE, + SALT, + DIFFICULTY, + futureExpiry() + ); + assert.equal(typeof answer, "number", "a real solve should still succeed"); + } finally { + process.chdir(originalCwd); + rmSync(emptyDir, { recursive: true, force: true }); + } +}); From bfbd090a96cb6f4e2d56e3dc4321607e6ce2e9c3 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Thu, 10 Sep 2026 07:15:57 -0400 Subject: [PATCH 13/15] fix(codex): lift nested child cooldowns on parent clear and on snapshot headroom (#12951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com a onda de persistência desta leva sobre `release/v3.8.51`: check-file-size e check-changelog-integrity OK, typecheck:core limpo, check-api-typecheck OK (289). Revalidei o head atual mergeado com o tip: typecheck:core limpo e **36/36** entre `db-rate-limit-guard` e as suítes desta PR. Levantar o cooldown do escopo pai sem deixar os filhos aninhados presos é o miolo — um cooldown órfão em filho é invisível no dashboard e mantém a conexão fora de rota sem explicação. **Nota de coordenação:** o `setConnectionRateLimitUntil` colidiu com o #12788 (guard contra timestamp não-finito ou já expirado), que mergeei nesta mesma onda. Eu tinha resolvido a integração na minha worktree, mas ao empurrar o push foi rejeitado — você já tinha empurrado `441fd44`, `f853ba5` e `af2ed01` com a integração feita, e a sua ordenação é equivalente à minha. Descartei a minha e mantive a sua; o crédito é seu inteiro. Fica o registro de que push rejeitado não é erro leve: se eu tivesse mergeado sem reler, teria levado a branch errada. --- .../fixes/12951-codex-scope-cooldown-clear.md | 1 + open-sse/services/codexAccount/quota.ts | 4 +- src/lib/db/providers.ts | 77 +-- src/lib/db/providers/codexAccountState.ts | 247 +++++++++- src/lib/db/providers/rateLimit.ts | 73 +++ src/lib/db/quotaSnapshots.ts | 60 +++ tests/unit/codex-reset-credits.test.ts | 153 ++++++ .../codex-scope-cooldown-clear-12817.test.ts | 449 ++++++++++++++++++ 8 files changed, 979 insertions(+), 85 deletions(-) create mode 100644 changelog.d/fixes/12951-codex-scope-cooldown-clear.md create mode 100644 tests/unit/codex-scope-cooldown-clear-12817.test.ts diff --git a/changelog.d/fixes/12951-codex-scope-cooldown-clear.md b/changelog.d/fixes/12951-codex-scope-cooldown-clear.md new file mode 100644 index 0000000000..edc1d4a64c --- /dev/null +++ b/changelog.d/fixes/12951-codex-scope-cooldown-clear.md @@ -0,0 +1 @@ +- **fix(codex):** dashboard "clear cooldown" and the CAS recovery path now drop nested `codexScopeRateLimitedUntil` maps in the same write that nulls `rate_limited_until`, and fresh quota snapshots with headroom lift fallback-sourced scope cooldowns parked by quota preflight ([#12817](https://github.com/diegosouzapw/OmniRoute/issues/12817), [#12860](https://github.com/diegosouzapw/OmniRoute/issues/12860), [#12951](https://github.com/diegosouzapw/OmniRoute/pull/12951)) The dashboard reset-credit button (`consumeCodexResetCredit`) now exercises that same snapshot path after a successful redeem, so a filled quota bar is enough to unpark a leftover fallback Codex child without another manual clear. diff --git a/open-sse/services/codexAccount/quota.ts b/open-sse/services/codexAccount/quota.ts index 3317ebd5be..80b15c8f0f 100644 --- a/open-sse/services/codexAccount/quota.ts +++ b/open-sse/services/codexAccount/quota.ts @@ -56,7 +56,9 @@ export async function persistCodexChildQuotaResponse(params: { rateLimitedUntil, rateLimitSource: exhaustedWindow ? ("quota_reset" as const) : ("fallback" as const), } - : {}), + : params.status === 200 + ? { rateLimitedUntil: null } + : {}), }); if (!providerSpecificData) return null; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 04c4de9b6f..1cb3f5a73a 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -39,6 +39,7 @@ import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSel import { isMicrosoftDesignerWebRetiredProviderId } from "@/shared/constants/designerWebRetirement"; import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation"; import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement"; +import { applyCodexChildCooldownClearOnUpdate } from "./providers/codexAccountState"; /** * normalizeProviderSpecificData + the Codex fingerprint-seed invariant: Codex @@ -951,11 +952,14 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { ...data, updatedAt: new Date().toISOString(), }; - merged.providerSpecificData = normalizeConnectionProviderSpecificData( - toStringOrNull(merged.provider), - merged.providerSpecificData, - merged, - existingCamel.providerSpecificData + merged.providerSpecificData = applyCodexChildCooldownClearOnUpdate( + data, + normalizeConnectionProviderSpecificData( + toStringOrNull(merged.provider), + merged.providerSpecificData, + merged, + existingCamel.providerSpecificData + ) ); // Mirror the sanitization the create path applies — keep the returned // object in lockstep with what we persist. @@ -1024,65 +1028,13 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { export { updateCodexScopedQuotaState, updateCodexScopeCooldown, + applyCodexChildCooldownClearOnUpdate, + stripCodexChildCooldownFields, + stripCodexChildCooldownsFromConnection, + hasCodexScopeCooldown, + liftCodexScopeCooldownOnHeadroom, } from "./providers/codexAccountState"; -/** - * Atomic conditional clear of recoverable error state on a connection row. - * - * Returns true when the row was cleared, false when a concurrent writer - * (markAccountUnavailable, connectionRecovery tick, test, etc.) changed the - * row between the caller's snapshot read and this UPDATE — in which case the - * clear is skipped to preserve the freshest error state. Closes the TOCTOU - * window in the quota-recovery path. - * - * CAS token = (test_status, last_error_at, rate_limited_until). - * markAccountUnavailable always bumps last_error_at on every cooldown/error - * write, so an unchanged last_error_at reliably indicates no concurrent write. - */ -export async function clearConnectionErrorIfUnchanged( - id: string, - expected: { - testStatus: string | null | undefined; - lastErrorAt: string | null | undefined; - rateLimitedUntil: string | null | undefined; - } -): Promise { - const db = getDbInstance() as unknown as DbLike; - const result = db - .prepare( - ` - UPDATE provider_connections SET - test_status = 'active', - last_error = NULL, - last_error_at = NULL, - last_error_type = NULL, - last_error_source = NULL, - error_code = NULL, - rate_limited_until = NULL, - backoff_level = 0, - updated_at = ? - WHERE id = ? - AND IFNULL(test_status, '') = ? - AND IFNULL(last_error_at, '') = ? - AND IFNULL(rate_limited_until, '') = ? - ` - ) - .run( - new Date().toISOString(), - id, - expected.testStatus ?? "", - expected.lastErrorAt ?? "", - expected.rateLimitedUntil ?? "" - ); - const applied = (result.changes ?? 0) > 0; - if (applied) { - backupDbFile("pre-write"); - invalidateDbCache("connections"); - bumpProxyConfigGeneration(); - } - return applied; -} - /** * Lightweight stat bump — updates lastUsedAt and consecutiveUseCount without * SELECT, re-encrypt, cache invalidation, or file backup. @@ -1183,4 +1135,5 @@ export { formatResetCountdown, isConnectionRateLimited, getRateLimitedConnections, + clearConnectionErrorIfUnchanged, } from "./providers/rateLimit"; diff --git a/src/lib/db/providers/codexAccountState.ts b/src/lib/db/providers/codexAccountState.ts index 77600de3bd..ab3c0fc57b 100644 --- a/src/lib/db/providers/codexAccountState.ts +++ b/src/lib/db/providers/codexAccountState.ts @@ -18,10 +18,105 @@ interface DbLike { type CodexScopedQuotaPatch = { quotaState?: JsonRecord; exhaustedWindow?: "5h" | "7d" | null; - rateLimitedUntil?: string; + rateLimitedUntil?: string | null; rateLimitSource?: "fallback" | "quota_reset"; }; +const CODEX_CHILD_COOLDOWN_KEYS = [ + "codexScopeRateLimitedUntil", + "codexScopeRateLimitSource", +] as const; + +function omitEmptyRecord(record: JsonRecord): JsonRecord | undefined { + return Object.keys(record).length > 0 ? record : undefined; +} + +/** Drop nested Codex child cooldowns; keep quota snapshots and unrelated keys. */ +export function stripCodexChildCooldownFields(psd: JsonRecord): JsonRecord { + if (!connectionHasCodexChildCooldown(psd)) return psd; + const next = { ...psd }; + for (const key of CODEX_CHILD_COOLDOWN_KEYS) delete next[key]; + return next; +} + +/** PUT/CAS payload that clears the parent column must also drop nested maps. */ +export function applyCodexChildCooldownClearOnUpdate( + data: JsonRecord, + psd: T +): T { + if (psd == null) return psd; + if (!Object.hasOwn(data, "rateLimitedUntil")) return psd; + if (data.rateLimitedUntil != null && data.rateLimitedUntil !== "") return psd; + return stripCodexChildCooldownFields(psd) as T; +} + +function connectionHasCodexChildCooldown(psd: JsonRecord): boolean { + return "codexScopeRateLimitedUntil" in psd || "codexScopeRateLimitSource" in psd; +} + +/** + * Persist a full-parent cooldown lift into the nested Codex child maps. + * When `alsoClearTopLevel` is set, the parent `rate_limited_until` column is + * nulled in the same transaction so a crash between the two writes cannot + * leave a nested child map behind a cleared parent column. + */ +export function stripCodexChildCooldownsFromConnection( + id: string, + options?: { alsoClearTopLevel?: boolean } +): void { + if (typeof id !== "string" || id.length === 0) return; + const db = getDbInstance() as unknown as DbLike; + const alsoClearTopLevel = options?.alsoClearTopLevel === true; + const candidate = db + .prepare("SELECT provider FROM provider_connections WHERE id = ?") + .get(id); + const isCodex = toRecord(candidate).provider === "codex"; + if (!alsoClearTopLevel && !isCodex) return; + + backupDbFile("pre-write"); + const wrote = db.transaction(() => { + const existing = db + .prepare( + "SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?" + ) + .get(id); + if (!existing) return false; + const existingRecord = toRecord(rowToCamel(existing)); + const providerSpecificData = toRecord(existingRecord.providerSpecificData); + const stripNested = + existingRecord.provider === "codex" && + connectionHasCodexChildCooldown(providerSpecificData); + if (!alsoClearTopLevel && !stripNested) return false; + + const now = new Date().toISOString(); + if (alsoClearTopLevel && stripNested) { + db.prepare( + `UPDATE provider_connections + SET rate_limited_until = NULL, + provider_specific_data = ?, + updated_at = ? + WHERE id = ?` + ).run(JSON.stringify(stripCodexChildCooldownFields(providerSpecificData)), now, id); + return true; + } + if (alsoClearTopLevel) { + db.prepare( + `UPDATE provider_connections + SET rate_limited_until = NULL, updated_at = ? + WHERE id = ?` + ).run(now, id); + return true; + } + db.prepare( + `UPDATE provider_connections + SET provider_specific_data = ?, updated_at = ? + WHERE id = ?` + ).run(JSON.stringify(stripCodexChildCooldownFields(providerSpecificData)), now, id); + return true; + })(); + if (wrote) invalidateDbCache("connections"); +} + /** * Atomically merge one virtual Codex child's quota evidence into its persisted parent. * The transaction reads the latest row so sibling child state cannot be lost. @@ -70,28 +165,35 @@ export async function updateCodexScopedQuotaState( } } - if (patch.rateLimitedUntil) { - const scopeCooldowns = toRecord(providerSpecificData.codexScopeRateLimitedUntil); - const sourceByScope = toRecord(providerSpecificData.codexScopeRateLimitSource); - const existingCooldownMs = - typeof scopeCooldowns[scope] === "string" - ? new Date(scopeCooldowns[scope] as string).getTime() - : NaN; - const existingIsAuthoritative = - sourceByScope[scope] === "quota_reset" && - patch.rateLimitSource !== "quota_reset" && - Number.isFinite(existingCooldownMs) && - existingCooldownMs > Date.now(); - nextProviderSpecificData.codexScopeRateLimitedUntil = { - ...scopeCooldowns, - [scope]: existingIsAuthoritative ? scopeCooldowns[scope] : patch.rateLimitedUntil, - }; - nextProviderSpecificData.codexScopeRateLimitSource = { - ...sourceByScope, - [scope]: existingIsAuthoritative + if (patch.rateLimitedUntil !== undefined) { + const scopeCooldowns = { ...toRecord(providerSpecificData.codexScopeRateLimitedUntil) }; + const sourceByScope = { ...toRecord(providerSpecificData.codexScopeRateLimitSource) }; + if (patch.rateLimitedUntil) { + const existingCooldownMs = + typeof scopeCooldowns[scope] === "string" + ? new Date(scopeCooldowns[scope] as string).getTime() + : NaN; + const existingIsAuthoritative = + sourceByScope[scope] === "quota_reset" && + patch.rateLimitSource !== "quota_reset" && + Number.isFinite(existingCooldownMs) && + existingCooldownMs > Date.now(); + scopeCooldowns[scope] = existingIsAuthoritative + ? scopeCooldowns[scope] + : patch.rateLimitedUntil; + sourceByScope[scope] = existingIsAuthoritative ? sourceByScope[scope] - : (patch.rateLimitSource ?? "fallback"), - }; + : (patch.rateLimitSource ?? "fallback"); + } else { + delete scopeCooldowns[scope]; + delete sourceByScope[scope]; + } + const nextCooldowns = omitEmptyRecord(scopeCooldowns); + const nextSources = omitEmptyRecord(sourceByScope); + if (nextCooldowns) nextProviderSpecificData.codexScopeRateLimitedUntil = nextCooldowns; + else delete nextProviderSpecificData.codexScopeRateLimitedUntil; + if (nextSources) nextProviderSpecificData.codexScopeRateLimitSource = nextSources; + else delete nextProviderSpecificData.codexScopeRateLimitSource; } db.prepare( @@ -106,6 +208,107 @@ export async function updateCodexScopedQuotaState( return persisted; } +/** Grace window absorbing clock skew against the upstream quota server. */ +const QUOTA_RESET_CLOCK_SKEW_GRACE_MS = 30_000; + +/** Cheap probe: does this connection+scope currently carry a child cooldown? */ +export function hasCodexScopeCooldown(id: string, scope: "codex" | "spark"): boolean { + if (typeof id !== "string" || id.length === 0) return false; + const db = getDbInstance() as unknown as DbLike; + const row = db + .prepare("SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?") + .get(id); + if (!row) return false; + const record = toRecord(rowToCamel(row)); + if (record.provider !== "codex") return false; + const psd = toRecord(record.providerSpecificData); + return Boolean(toRecord(psd.codexScopeRateLimitedUntil)[scope]); +} + +/** + * #12860: When fresh quota snapshot data demonstrates headroom on a scope, + * lift any fallback-sourced cooldown (e.g. parked by quota preflight). + * Cooldowns sourced from upstream 429 quota_reset retain their authority + * until their reset timestamp has elapsed. + */ +export function liftCodexScopeCooldownOnHeadroom( + id: string, + scope: "codex" | "spark" +): boolean { + if (typeof id !== "string" || id.length === 0) return false; + const db = getDbInstance() as unknown as DbLike; + + // Cheap eligibility probe so the backup only runs when a write is plausible. + // The transaction below re-reads under lock and remains the authority. + if (!hasCodexScopeCooldown(id, scope)) return false; + + backupDbFile("pre-write"); + const wrote = db.transaction(() => { + const existing = db + .prepare( + "SELECT provider, provider_specific_data FROM provider_connections WHERE id = ?" + ) + .get(id); + if (!existing) return false; + const existingRecord = toRecord(rowToCamel(existing)); + if (existingRecord.provider !== "codex") return false; + const currentPsd = toRecord(existingRecord.providerSpecificData); + const currentCooldowns = { ...toRecord(currentPsd.codexScopeRateLimitedUntil) }; + if (!currentCooldowns[scope]) return false; + + const currentSources = { ...toRecord(currentPsd.codexScopeRateLimitSource) }; + const curSource = currentSources[scope]; + const curUntilMs = + typeof currentCooldowns[scope] === "string" + ? new Date(currentCooldowns[scope] as string).getTime() + : NaN; + // An upstream-authoritative `quota_reset` deadline outranks a local snapshot: + // the grace window absorbs clock skew against the quota server, so a fast + // local clock cannot lift a cooldown upstream still considers active. + if ( + curSource === "quota_reset" && + Number.isFinite(curUntilMs) && + curUntilMs > Date.now() - QUOTA_RESET_CLOCK_SKEW_GRACE_MS + ) { + return false; + } + + delete currentCooldowns[scope]; + delete currentSources[scope]; + + const nextPsd: JsonRecord = { ...currentPsd }; + const nextCooldowns = omitEmptyRecord(currentCooldowns); + const nextSources = omitEmptyRecord(currentSources); + if (nextCooldowns) nextPsd.codexScopeRateLimitedUntil = nextCooldowns; + else delete nextPsd.codexScopeRateLimitedUntil; + if (nextSources) nextPsd.codexScopeRateLimitSource = nextSources; + else delete nextPsd.codexScopeRateLimitSource; + + const exhaustedByScope = { ...toRecord(currentPsd.codexExhaustedWindowByScope) }; + if (exhaustedByScope[scope]) { + delete exhaustedByScope[scope]; + const nextExhausted = omitEmptyRecord(exhaustedByScope); + if (nextExhausted) { + nextPsd.codexExhaustedWindowByScope = nextExhausted; + } else { + delete nextPsd.codexExhaustedWindowByScope; + delete nextPsd.codexExhaustedWindow; + } + } + + const now = new Date().toISOString(); + db.prepare( + `UPDATE provider_connections + SET provider_specific_data = ?, updated_at = ? + WHERE id = ?` + ).run(JSON.stringify(nextPsd), now, id); + return true; + })(); + + if (wrote) invalidateDbCache("connections"); + return wrote; +} + /** Persist one child cooldown through the shared scoped quota-state transaction. */ export async function updateCodexScopeCooldown( id: string, diff --git a/src/lib/db/providers/rateLimit.ts b/src/lib/db/providers/rateLimit.ts index e89199c3b6..c15dd3b2ee 100644 --- a/src/lib/db/providers/rateLimit.ts +++ b/src/lib/db/providers/rateLimit.ts @@ -4,6 +4,9 @@ import { getDbInstance } from "../core"; import { invalidateDbCache } from "../readCache"; +import { backupDbFile } from "../backup"; +import { bumpProxyConfigGeneration } from "../settings"; +import { stripCodexChildCooldownsFromConnection } from "./codexAccountState"; interface StatementLike { all: (...params: unknown[]) => TRow[]; @@ -33,6 +36,10 @@ export function setConnectionRateLimitUntil(connectionId: string, until: number // is the only clear path (via clearConnectionRateLimit); past/zero // timestamps are noops so an expired write cannot overwrite a live row. if (until !== null && (!Number.isFinite(until) || until <= Date.now())) return; + if (until == null) { + stripCodexChildCooldownsFromConnection(connectionId, { alsoClearTopLevel: true }); + return; + } const db = getDbInstance() as unknown as DbLike; db.prepare( "UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?" @@ -234,6 +241,72 @@ export function clearStaleCrashCooldowns(): { cleared: number } { return { cleared: toReset.length }; } +/** + * Atomic conditional clear of recoverable error state on a connection row. + * + * Returns true when the row was cleared, false when a concurrent writer + * (markAccountUnavailable, connectionRecovery tick, test, etc.) changed the + * row between the caller's snapshot read and this UPDATE — in which case the + * clear is skipped to preserve the freshest error state. Closes the TOCTOU + * window in the quota-recovery path. + * + * CAS token = (test_status, last_error_at, rate_limited_until). + * Nested Codex child cooldown maps are stripped in the same UPDATE so a + * concurrent writer cannot re-persist them between two statements. + */ +export async function clearConnectionErrorIfUnchanged( + id: string, + expected: { + testStatus: string | null | undefined; + lastErrorAt: string | null | undefined; + rateLimitedUntil: string | null | undefined; + } +): Promise { + const db = getDbInstance() as unknown as DbLike; + backupDbFile("pre-write"); + const result = db + .prepare( + ` + UPDATE provider_connections SET + test_status = 'active', + last_error = NULL, + last_error_at = NULL, + last_error_type = NULL, + last_error_source = NULL, + error_code = NULL, + rate_limited_until = NULL, + backoff_level = 0, + provider_specific_data = CASE + WHEN provider = 'codex' AND json_valid(provider_specific_data) + THEN json_remove( + provider_specific_data, + '$.codexScopeRateLimitedUntil', + '$.codexScopeRateLimitSource' + ) + ELSE provider_specific_data + END, + updated_at = ? + WHERE id = ? + AND IFNULL(test_status, '') = ? + AND IFNULL(last_error_at, '') = ? + AND IFNULL(rate_limited_until, '') = ? + ` + ) + .run( + new Date().toISOString(), + id, + expected.testStatus ?? "", + expected.lastErrorAt ?? "", + expected.rateLimitedUntil ?? "" + ); + const applied = (result.changes ?? 0) > 0; + if (applied) { + invalidateDbCache("connections"); + bumpProxyConfigGeneration(); + } + return applied; +} + // T13: Format a reset countdown as a human-readable string ("2h 35m" / "4m 30s"). // The implementation lives in the client-safe formatting utils so client // components (e.g. CoolingConnectionsPanel) can import it without pulling this diff --git a/src/lib/db/quotaSnapshots.ts b/src/lib/db/quotaSnapshots.ts index 1e77cc47fb..5a2391130e 100644 --- a/src/lib/db/quotaSnapshots.ts +++ b/src/lib/db/quotaSnapshots.ts @@ -1,5 +1,10 @@ import { getDbInstance, rowToCamel } from "./core"; import type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization"; +import { + hasCodexScopeCooldown, + liftCodexScopeCooldownOnHeadroom, +} from "./providers/codexAccountState"; +import { isCodexSparkQuotaKey } from "@omniroute/open-sse/config/codexQuotaScopes"; type JsonRecord = Record; @@ -45,6 +50,61 @@ export function saveQuotaSnapshot(snapshot: Omit & { + windowKey?: string; + remainingPercentage?: number; + isExhausted?: number; +}; + +function snapshotHasHeadroom(s: SnapshotShape): boolean { + const pct = s.remainingPercentage ?? s.remaining_percentage ?? 0; + const exhausted = s.isExhausted ?? s.is_exhausted ?? 0; + return pct > 0 && exhausted !== 1; +} + +function maybeLiftCodexCooldownOnHeadroom( + snapshot: Omit +): void { + if ( + snapshot.provider?.toLowerCase() !== "codex" || + typeof snapshot.connection_id !== "string" || + snapshot.connection_id.length === 0 || + (snapshot.remaining_percentage ?? 0) <= 0 || + snapshot.is_exhausted === 1 + ) { + return; + } + + try { + const scope = isCodexSparkQuotaKey(snapshot.window_key) ? "spark" : "codex"; + // The scope-wide read is only worth paying for when a cooldown is + // actually parked on this connection+scope; the common case is clean. + if (!hasCodexScopeCooldown(snapshot.connection_id, scope)) return; + + const scopeWindows = getLatestQuotaSnapshotsForConnection(snapshot.connection_id).filter( + (s: SnapshotShape) => { + const key = s.windowKey ?? s.window_key; + return scope === "spark" ? isCodexSparkQuotaKey(key) : !isCodexSparkQuotaKey(key); + } + ); + // Every window in the scope must be healthy: one exhausted window still + // justifies the cooldown even when a sibling reports full headroom. + if (scopeWindows.length > 0 && scopeWindows.every(snapshotHasHeadroom)) { + liftCodexScopeCooldownOnHeadroom(snapshot.connection_id, scope); + } + } catch (err) { + console.debug("[QuotaSnapshots] Headroom evaluation skipped:", err); + } } export function getQuotaSnapshots(opts: { diff --git a/tests/unit/codex-reset-credits.test.ts b/tests/unit/codex-reset-credits.test.ts index 2a6ecccee7..b1278a5d11 100644 --- a/tests/unit/codex-reset-credits.test.ts +++ b/tests/unit/codex-reset-credits.test.ts @@ -11,6 +11,7 @@ process.env.API_KEY_SECRET = "test-codex-reset-credits-secret"; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const resetCredits = await import("../../src/lib/usage/codexResetCredits.ts"); +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); const originalFetch = globalThis.fetch; type QuotaUsageRecord = Record; @@ -45,6 +46,69 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); +async function persistBothChildCooldowns(id: string) { + const codexUntil = new Date(Date.now() + 60_000).toISOString(); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + await codexAccount.persistCodexChildCooldown({ + connectionId: id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }); + await codexAccount.persistCodexChildCooldown({ + connectionId: id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }); + return { codexUntil, sparkUntil }; +} + +async function readConnection(id: string) { + const connection = await providersDb.getProviderConnectionById(id); + assert.ok(connection); + return connection as Record; +} + +function mockResetThenUsage(opts: { + consumeBody: unknown; + consumeStatus?: number; + usageBody?: unknown; + usageStatus?: number; +}) { + globalThis.fetch = (async (url) => { + const href = String(url); + if (href.endsWith("/rate-limit-reset-credits")) { + return new Response( + JSON.stringify({ credits: [{ id: "credit-123", status: "available" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (href.includes("/rate-limit-reset-credits/consume")) { + return new Response(JSON.stringify(opts.consumeBody), { + status: opts.consumeStatus ?? 200, + headers: { "content-type": "application/json" }, + }); + } + if (href.includes("/backend-api/wham/usage")) { + if (opts.usageStatus && opts.usageStatus >= 500) { + return new Response("upstream down", { status: opts.usageStatus }); + } + return new Response( + JSON.stringify( + opts.usageBody ?? { + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 0 }, + secondary_window: { used_percent: 0 }, + }, + } + ), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; +} + test("consumeCodexResetCredit fetches a credit id, posts it, then refreshes usage", async () => { const connection = (await createCodexConnection()) as { id: string }; const calls: Array<{ url: string; init: RequestInit }> = []; @@ -355,3 +419,92 @@ test("consumeCodexResetCredit rejects non-Codex and missing connections", async error.code === "codex_provider_required" ); }); + +test("#12951 consumeCodexResetCredit lifts fallback Codex child cooldown after healthy usage refresh", async () => { + const connection = (await createCodexConnection()) as { id: string }; + const parked = await persistBothChildCooldowns(connection.id); + const before = await readConnection(connection.id); + assert.equal(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5"), parked.codexUntil); + assert.equal( + codexAccount.getCodexChildCooldown(before as never, "gpt-5.3-codex-spark"), + parked.sparkUntil + ); + + mockResetThenUsage({ consumeBody: { code: "reset" } }); + + const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-button-path"); + assert.equal(result.outcome, "reset"); + + const after = await readConnection(connection.id); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); + assert.equal( + codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), + parked.sparkUntil + ); +}); + +test("#12951 consumeCodexResetCredit alreadyRedeemed still needs healthy usage to lift fallback cooldown", async () => { + const connection = (await createCodexConnection()) as { id: string }; + await persistBothChildCooldowns(connection.id); + + mockResetThenUsage({ + consumeBody: { code: "alreadyRedeemed" }, + usageBody: { + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 100 }, + secondary_window: { used_percent: 100 }, + }, + }, + }); + + const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-already"); + assert.equal(result.outcome, "alreadyRedeemed"); + + const after = await readConnection(connection.id); + assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5")); + assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark")); +}); + +test("#12951 consumeCodexResetCredit failed redeem keeps nested child cooldowns", async () => { + const connection = (await createCodexConnection()) as { id: string }; + const parked = await persistBothChildCooldowns(connection.id); + + mockResetThenUsage({ + consumeBody: { code: "noCredit" }, + consumeStatus: 409, + }); + + await assert.rejects( + () => resetCredits.consumeCodexResetCredit(connection.id, "redeem-fail"), + (error: unknown) => + error instanceof resetCredits.CodexResetCreditError && + error.status === 409 && + error.code === "no_credit" + ); + + const after = await readConnection(connection.id); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), parked.codexUntil); + assert.equal( + codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), + parked.sparkUntil + ); +}); + +test("#12951 consumeCodexResetCredit incomplete usage refresh keeps nested child cooldowns", async () => { + const connection = (await createCodexConnection()) as { id: string }; + const parked = await persistBothChildCooldowns(connection.id); + + mockResetThenUsage({ consumeBody: { code: "reset" }, usageStatus: 500 }); + + const result = await resetCredits.consumeCodexResetCredit(connection.id, "redeem-incomplete"); + assert.equal(result.outcome, "reset"); + + const after = await readConnection(connection.id); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), parked.codexUntil); + assert.equal( + codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), + parked.sparkUntil + ); +}); diff --git a/tests/unit/codex-scope-cooldown-clear-12817.test.ts b/tests/unit/codex-scope-cooldown-clear-12817.test.ts new file mode 100644 index 0000000000..10a538cb98 --- /dev/null +++ b/tests/unit/codex-scope-cooldown-clear-12817.test.ts @@ -0,0 +1,449 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-clear-12817-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-clear-12817-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const rateLimit = await import("../../src/lib/db/providers/rateLimit.ts"); +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); +const quotaSnapshots = await import("../../src/lib/db/quotaSnapshots.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +interface SeededConnection { + id: string; + providerSpecificData: Record; +} + +async function seedCodexConnection(): Promise { + return providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-clear-12817", + email: "codex-clear-12817@example.com", + apiKey: "codex-clear-12817-key", + accessToken: "codex-clear-12817-access", + refreshToken: "codex-clear-12817-refresh", + providerSpecificData: { + unrelated: { retained: true }, + }, + }) as unknown as Promise; +} + +async function seedGlmConnection(): Promise { + return providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "glm-clear-12817", + apiKey: "glm-clear-12817-key", + providerSpecificData: { + leftover: "keep-me", + }, + }) as unknown as Promise; +} + +async function readConnection(id: string): Promise> { + const connection = await providersDb.getProviderConnectionById(id); + assert.ok(connection); + return connection as unknown as Record; +} + +function psd(connection: Record): Record { + return (connection.providerSpecificData ?? {}) as Record; +} + +function futureIso(ms: number): string { + return new Date(Date.now() + ms).toISOString(); +} + +function quotaHeaders(resetAt5h: string, resetAt7d: string, usage5h = "10") { + return { + "x-codex-5h-usage": usage5h, + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": resetAt5h, + "x-codex-7d-usage": "10", + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": resetAt7d, + }; +} + +async function persistBothChildCooldowns(id: string): Promise<{ + codexUntil: string; + sparkUntil: string; +}> { + const codexUntil = futureIso(60_000); + const sparkUntil = futureIso(120_000); + await codexAccount.persistCodexChildCooldown({ + connectionId: id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }); + await codexAccount.persistCodexChildCooldown({ + connectionId: id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }); + return { codexUntil, sparkUntil }; +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12817 PUT rateLimitedUntil:null also drops nested Codex child cooldowns", async () => { + const connection = await seedCodexConnection(); + const { sparkUntil } = await persistBothChildCooldowns(connection.id); + await providersDb.updateProviderConnection(connection.id, { + rateLimitedUntil: futureIso(90_000), + }); + + const before = await readConnection(connection.id); + assert.equal( + codexAccount.getCodexChildCooldown(before as never, "gpt-5.3-codex-spark"), + sparkUntil + ); + + await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: null }); + + const after = await readConnection(connection.id); + const data = psd(after); + assert.equal(after.rateLimitedUntil, undefined); + assert.equal(data.codexScopeRateLimitedUntil, undefined); + assert.equal(data.codexScopeRateLimitSource, undefined); + assert.deepEqual(data.unrelated, { retained: true }); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), null); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12817 PUT rateLimitedUntil:\"\" also drops nested Codex child cooldowns", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + await providersDb.updateProviderConnection(connection.id, { + rateLimitedUntil: futureIso(90_000), + }); + + await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: "" }); + + const after = await readConnection(connection.id); + const data = psd(after); + assert.equal(after.rateLimitedUntil, undefined); + assert.equal(data.codexScopeRateLimitedUntil, undefined); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12817 clearConnectionRateLimit strips nested Codex child cooldowns", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + rateLimit.setConnectionRateLimitUntil(connection.id, Date.now() + 90_000); + + rateLimit.clearConnectionRateLimit(connection.id); + + const after = await readConnection(connection.id); + const data = psd(after); + assert.equal(after.rateLimitedUntil, undefined); + assert.equal(data.codexScopeRateLimitedUntil, undefined); + assert.equal(data.codexScopeRateLimitSource, undefined); + assert.deepEqual(data.unrelated, { retained: true }); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12817 CAS error-clear also strips nested Codex child cooldowns", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + const until = futureIso(90_000); + await providersDb.updateProviderConnection(connection.id, { + testStatus: "unavailable", + lastError: "429", + lastErrorAt: new Date().toISOString(), + lastErrorType: "rate_limit_exceeded", + rateLimitedUntil: until, + }); + const before = await readConnection(connection.id); + + const applied = await providersDb.clearConnectionErrorIfUnchanged(connection.id, { + testStatus: (before.testStatus as string) ?? null, + lastErrorAt: (before.lastErrorAt as string) ?? null, + rateLimitedUntil: (before.rateLimitedUntil as string) ?? null, + }); + assert.equal(applied, true); + + const after = await readConnection(connection.id); + assert.equal(after.testStatus, "active"); + assert.equal(psd(after).codexScopeRateLimitedUntil, undefined); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12817 a successful quota observation clears that child's leftover cooldown", async () => { + const connection = await seedCodexConnection(); + const reset5h = futureIso(60_000); + const reset7d = futureIso(600_000); + + await persistBothChildCooldowns(connection.id); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(reset5h, reset7d, "95"), + status: 429, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(reset5h, reset7d, "10"), + status: 200, + }); + + const after = await readConnection(connection.id); + const data = psd(after); + const until = data.codexScopeRateLimitedUntil as Record | undefined; + const exhausted = data.codexExhaustedWindowByScope as Record | undefined; + assert.equal(until?.codex, undefined); + assert.equal(typeof until?.spark, "string"); + assert.equal(exhausted?.codex, undefined); + assert.deepEqual(data.unrelated, { retained: true }); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.3-codex-spark"), true); +}); + +test("#12817 clearing a non-Codex cooldown leaves providerSpecificData alone", async () => { + const connection = await seedGlmConnection(); + await providersDb.updateProviderConnection(connection.id, { + rateLimitedUntil: futureIso(90_000), + }); + await providersDb.updateProviderConnection(connection.id, { rateLimitedUntil: null }); + const after = await readConnection(connection.id); + assert.equal(after.rateLimitedUntil, undefined); + assert.equal(psd(after).leftover, "keep-me"); +}); + +test("#12860 saveQuotaSnapshot with headroom lifts fallback-sourced scope cooldown", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + + const before = await readConnection(connection.id); + assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5")); + + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "primary", + remaining_percentage: 100, + is_exhausted: 0, + next_reset_at: futureIso(3600_000), + window_duration_ms: 18_000_000, + raw_data: null, + }); + + const after = await readConnection(connection.id); + const data = psd(after); + const until = data.codexScopeRateLimitedUntil as Record | undefined; + assert.equal(until?.codex, undefined); + assert.equal(typeof until?.spark, "string"); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12860 saveQuotaSnapshot with headroom does NOT lift authoritative quota_reset cooldown", async () => { + const connection = await seedCodexConnection(); + const reset5h = futureIso(120_000); + const reset7d = futureIso(600_000); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(reset5h, reset7d, "95"), + status: 429, + }); + + const before = await readConnection(connection.id); + assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5")); + + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "session", + remaining_percentage: 100, + is_exhausted: 0, + next_reset_at: futureIso(3600_000), + window_duration_ms: 18_000_000, + raw_data: null, + }); + + const after = await readConnection(connection.id); + const data = psd(after); + const until = data.codexScopeRateLimitedUntil as Record | undefined; + assert.equal(typeof until?.codex, "string"); + assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5")); +}); + +test("#12860 saveQuotaSnapshot does NOT lift scope cooldown if another window for that scope is still exhausted", async () => { + const connection = await seedCodexConnection(); + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: futureIso(120_000), + }); + + // Weekly window is currently exhausted (0% remaining) + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "weekly", + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: futureIso(7200_000), + window_duration_ms: 604_800_000, + raw_data: null, + }); + + // Session window reports 100% headroom, but weekly is still exhausted + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "session", + remaining_percentage: 100, + is_exhausted: 0, + next_reset_at: futureIso(3600_000), + window_duration_ms: 18_000_000, + raw_data: null, + }); + + let mid = await readConnection(connection.id); + assert.ok(codexAccount.getCodexChildCooldown(mid as never, "gpt-5.5")); + + // Now weekly also recovers to 100% headroom + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "weekly", + remaining_percentage: 100, + is_exhausted: 0, + next_reset_at: futureIso(7200_000), + window_duration_ms: 604_800_000, + raw_data: null, + }); + + let after = await readConnection(connection.id); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12860 saveQuotaSnapshot for spark scope with headroom lifts only spark cooldown", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + + quotaSnapshots.saveQuotaSnapshot({ + provider: "codex", + connection_id: connection.id, + window_key: "gpt_5_3_codex_spark_session", + remaining_percentage: 100, + is_exhausted: 0, + next_reset_at: futureIso(3600_000), + window_duration_ms: 18_000_000, + raw_data: null, + }); + + const after = await readConnection(connection.id); + const data = psd(after); + const until = data.codexScopeRateLimitedUntil as Record | undefined; + assert.equal(typeof until?.codex, "string"); + assert.equal(until?.spark, undefined); + assert.ok(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5")); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.3-codex-spark"), null); +}); + +test("#12860 setQuotaCache with fresh usage headroom lifts fallback-sourced scope cooldown", async () => { + const connection = await seedCodexConnection(); + await persistBothChildCooldowns(connection.id); + + const before = await readConnection(connection.id); + assert.ok(codexAccount.getCodexChildCooldown(before as never, "gpt-5.5")); + + quotaCache.setQuotaCache(connection.id, "codex", { + session: { + used: 0, + total: 100, + remainingPercentage: 100, + resetAt: futureIso(3600_000), + }, + }); + + const after = await readConnection(connection.id); + const data = psd(after); + const until = data.codexScopeRateLimitedUntil as Record | undefined; + assert.equal(until?.codex, undefined); + assert.equal(typeof until?.spark, "string"); + assert.equal(codexAccount.getCodexChildCooldown(after as never, "gpt-5.5"), null); + assert.equal(codexAccount.isCodexChildUnavailable(after as never, "gpt-5.5"), false); +}); + +test("#12860 quota_reset cooldown that just elapsed is still held by the skew grace window", async () => { + const connection = await seedCodexConnection(); + // Reset deadline sits 5s in the past — inside the 30s clock-skew grace, so a + // fast local clock must not lift an upstream-authoritative cooldown early. + const justElapsed = new Date(Date.now() - 5_000).toISOString(); + + await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { + codexScopeRateLimitedUntil: { codex: justElapsed }, + codexScopeRateLimitSource: { codex: "quota_reset" }, + }, + }); + + const lifted = providersDb.liftCodexScopeCooldownOnHeadroom(connection.id, "codex"); + assert.equal(lifted, false); + + const after = await readConnection(connection.id); + const until = psd(after).codexScopeRateLimitedUntil as Record | undefined; + assert.equal(until?.codex, justElapsed); +}); + +test("#12860 quota_reset cooldown past the skew grace window is lifted", async () => { + const connection = await seedCodexConnection(); + const wellElapsed = new Date(Date.now() - 120_000).toISOString(); + + await providersDb.updateProviderConnection(connection.id, { + providerSpecificData: { + codexScopeRateLimitedUntil: { codex: wellElapsed }, + codexScopeRateLimitSource: { codex: "quota_reset" }, + }, + }); + + const lifted = providersDb.liftCodexScopeCooldownOnHeadroom(connection.id, "codex"); + assert.equal(lifted, true); + + const after = await readConnection(connection.id); + assert.equal(psd(after).codexScopeRateLimitedUntil, undefined); +}); + +test("#12860 hasCodexScopeCooldown short-circuits the snapshot scan when nothing is parked", async () => { + const connection = await seedCodexConnection(); + assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "codex"), false); + + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: futureIso(120_000), + }); + + assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "codex"), true); + assert.equal(providersDb.hasCodexScopeCooldown(connection.id, "spark"), false); + + const glm = await seedGlmConnection(); + assert.equal(providersDb.hasCodexScopeCooldown(glm.id, "codex"), false); + assert.equal(providersDb.hasCodexScopeCooldown("does-not-exist", "codex"), false); +}); From 85d29b253ffbc5e423a0acd55dbb0e25218d4c61 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:17:58 +0200 Subject: [PATCH 14/15] fix(health): read empty quota as unknown instead of 0% (#12857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um 0% vermelho num install novo não é só feio: é um número contraditório, porque afirma medição onde não houve nenhuma. Ler ausência como "n/a" é a correção certa, e mantê-la display-only no caminho de combo health mantém o escopo honesto. Registro que gostei: a PR **não** mexe no `/api/health`, e diz por quê — qualquer coisa que aquela rota devolva é pública numa instância exposta. Recusar o escopo adjacente com a razão escrita vale mais que a mudança em si. Revalidei após mergear a base na branch: `api-health-version-source` + `combo-health-empty-snapshot` 5/5 no runner Node, `combo-health-null-quota` 1/1 no vitest, typecheck:core limpo. **Integração:** `src/app/api/system/version/route.ts` conflitou com o `restartRunningServer` que entrou na release depois que você cortou a branch. Aditivo — ficaram os dois imports, a sua troca por `APP_CONFIG.version` e o passo de restart do outro PR. --- .../fixes/12857-empty-quota-unknown.md | 1 + .../dashboard/analytics/ComboHealthTab.tsx | 7 +- src/app/api/health/route.ts | 1 + src/app/api/system/version/route.ts | 7 +- src/lib/combos/controlCenter.ts | 4 +- src/lib/usage/comboHealth.ts | 32 ++++---- src/shared/types/utilization.ts | 4 +- tests/unit/api-health-version-source.test.ts | 24 ++++++ .../unit/combo-health-empty-snapshot.test.ts | 63 +++++++++++++++ .../unit/ui/combo-health-null-quota.test.tsx | 77 +++++++++++++++++++ 10 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fixes/12857-empty-quota-unknown.md create mode 100644 tests/unit/api-health-version-source.test.ts create mode 100644 tests/unit/combo-health-empty-snapshot.test.ts create mode 100644 tests/unit/ui/combo-health-null-quota.test.tsx diff --git a/changelog.d/fixes/12857-empty-quota-unknown.md b/changelog.d/fixes/12857-empty-quota-unknown.md new file mode 100644 index 0000000000..4cbace07ff --- /dev/null +++ b/changelog.d/fixes/12857-empty-quota-unknown.md @@ -0,0 +1 @@ +- **fix(health):** quota with no snapshots reads empty instead of a contradicting 0% ([#12857](https://github.com/diegosouzapw/OmniRoute/pull/12857)) — thanks @maxmad64bis diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index d36ea43170..c0e93258a3 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -526,7 +526,7 @@ function ComboHealthCard({ {combo.quotaHealth.providers.map((provider) => { const trendMeta = getTrendMeta(provider.trend); - const width = `${Math.max(provider.remainingPct, provider.remainingPct > 0 ? 6 : 0)}%`; + const pct = provider.remainingPct; + const width = `${pct === null ? 0 : Math.max(pct, pct > 0 ? 6 : 0)}%`; return (
{t("comboHealthRemainingQuota", { - value: formatPercent(provider.remainingPct, 1), + value: formatPercentOrDash(provider.remainingPct, 1), })}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 2ead593b78..860e9f03f9 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -14,6 +14,7 @@ import { NextResponse } from "next/server"; * public on an exposed instance, so version, uptime and memory stay behind the authenticated * `/api/monitoring/health`. For a probe that also confirms the database answers, use * `/api/health/ping`. + * Readiness (DB-backed) lives at /api/health/ping (pingDb, 503 when down); this route stays liveness-only so a slow DB never restarts the container. */ export const dynamic = "force-dynamic"; diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index f3ec9c26d4..5284d00077 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -25,6 +25,7 @@ import { } from "@/lib/system/versionCheck"; import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath"; import { restartRunningServer } from "@/lib/system/processManagerRestart"; +import { APP_CONFIG } from "@/shared/constants/appConfig"; // #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without // a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the // shell on win32 only; SERVICE_VERSION_PATTERN keeps the shell-joined version safe. @@ -35,11 +36,7 @@ const execFileAsync = promisify(execFile); export const dynamic = "force-dynamic"; function getCurrentVersion(): string { - try { - return require("../../../../../package.json").version as string; - } catch { - return "unknown"; - } + return APP_CONFIG.version; } /** diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 825f96942c..d606d2ec93 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -31,10 +31,10 @@ export interface ComboControlCenterHealth { totalRequests?: number; }; quotaHealth?: { - worstRemainingPct?: number; + worstRemainingPct?: number | null; providers?: Array<{ provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }>; diff --git a/src/lib/usage/comboHealth.ts b/src/lib/usage/comboHealth.ts index a9f12ac1f2..2bb72b5e95 100644 --- a/src/lib/usage/comboHealth.ts +++ b/src/lib/usage/comboHealth.ts @@ -32,7 +32,7 @@ type QuotaSnapshotView = { type ProviderHealth = { provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }; @@ -112,11 +112,12 @@ function calculateGini(values: number[]): number { return (2 * weightedSum) / (count * sum) - (count + 1) / count; } -function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth { +export function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): ProviderHealth { if (snapshots.length === 0) { return { provider, - remainingPct: 0, + remainingPct: null, + // stable: no data yet, not exhausted — null pct, not 0 isExhausted: false, trend: "stable", }; @@ -186,7 +187,7 @@ function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): P return { provider, - remainingPct: roundNumber(lastAverage), + remainingPct: lastValues.length === 0 ? null : roundNumber(lastAverage), isExhausted, trend, }; @@ -218,10 +219,10 @@ function buildConnectionHealth( }); const firstRemaining = - (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0; + (firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null; const lastRemaining = - (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0; - const delta = lastRemaining - firstRemaining; + (lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? null; + const delta = (lastRemaining ?? 0) - (firstRemaining ?? 0); let trend: ProviderHealth["trend"] = "stable"; if (delta >= 5) trend = "improving"; @@ -229,7 +230,7 @@ function buildConnectionHealth( return { provider: `${provider}:${connectionId}`, - remainingPct: roundNumber(lastRemaining), + remainingPct: lastRemaining === null ? null : roundNumber(lastRemaining), isExhausted: (ordered[ordered.length - 1] as unknown as QuotaSnapshotView | undefined)?.isExhausted === 1, trend, @@ -317,22 +318,19 @@ function buildPerformance(comboName: string, since: string): ComboHealthMetrics[ }; } -function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] { +export function buildQuotaHealth(providers: string[], since: string): ComboHealthMetrics["quotaHealth"] { const providerHealth = providers.map((provider) => buildProviderHealth(provider, getQuotaSnapshots({ provider, since })) ); - const worstRemainingPct = - providerHealth.length > 0 - ? providerHealth.reduce( - (lowest, entry) => Math.min(lowest, entry.remainingPct), - providerHealth[0].remainingPct - ) - : 0; + const nonNull = providerHealth + .map((entry) => entry.remainingPct) + .filter((v): v is number => typeof v === "number"); + const worst = nonNull.length > 0 ? Math.min(...nonNull) : null; return { providers: providerHealth, - worstRemainingPct: roundNumber(worstRemainingPct), + worstRemainingPct: worst === null ? null : roundNumber(worst), }; } diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts index 90fb14fce0..ba519ed9fe 100644 --- a/src/shared/types/utilization.ts +++ b/src/shared/types/utilization.ts @@ -58,11 +58,11 @@ export interface ComboHealthMetrics { quotaHealth: { providers: Array<{ provider: string; - remainingPct: number; + remainingPct: number | null; isExhausted: boolean; trend: "improving" | "stable" | "declining"; }>; - worstRemainingPct: number; + worstRemainingPct: number | null; }; usageSkew: { modelDistribution: Array<{ diff --git a/tests/unit/api-health-version-source.test.ts b/tests/unit/api-health-version-source.test.ts new file mode 100644 index 0000000000..24d32d2be6 --- /dev/null +++ b/tests/unit/api-health-version-source.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { GET } from "@/app/api/health/route"; +import { APP_CONFIG } from "@/shared/constants/appConfig"; + +test("GET /api/health stays minimal: 200, no version anywhere", async () => { + const res = await GET(); + assert.equal(res.status, 200); + assert.equal(res.headers.get("ETag"), null); + assert.equal(res.headers.get("X-OmniRoute-Version"), null); + const body = (await res.json()) as Record; + assert.equal(body.status, "ok"); + assert.ok(typeof body.timestamp === "string"); + assert.ok(!("version" in body)); +}); + +test("system/version reads no package.json directly", async () => { + const { readFileSync } = await import("node:fs"); + const src = readFileSync(path.join(process.cwd(), "src/app/api/system/version/route.ts"), "utf8"); + assert.ok(!src.includes("require("), "direct require(package.json) must be gone"); + assert.ok(src.includes("APP_CONFIG.version")); + assert.equal(typeof APP_CONFIG.version, "string"); +}); diff --git a/tests/unit/combo-health-empty-snapshot.test.ts b/tests/unit/combo-health-empty-snapshot.test.ts new file mode 100644 index 0000000000..25ff864935 --- /dev/null +++ b/tests/unit/combo-health-empty-snapshot.test.ts @@ -0,0 +1,63 @@ +// tests/unit/combo-health-empty-snapshot.test.ts — pattern db-quota-snapshots.test.ts:7-26 : +// isolation DB réelle, zéro mock (mock.module indisponible sous tsx/ESM ; sans polyfill+isolateDataDir, +// DATA_DIR tombe sur ~/.omniroute réel → flaky). +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-combo-health-null-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const { buildProviderHealth, buildQuotaHealth } = + await import("../../src/lib/usage/comboHealth.ts"); + +async function resetStorage() { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const SNAP = { + provider: "openrouter", + connection_id: "conn-1", + window_key: "hourly", + is_exhausted: 0, + next_reset_at: "2026-01-01T01:00:00.000Z", + window_duration_ms: 3600000, + raw_data: "{}", +} as const; + +test("empty snapshots read null, not 0%, and stay non-exhausted", () => { + const h = buildProviderHealth("openrouter", []); + assert.equal(h.remainingPct, null); + assert.equal(h.isExhausted, false); + assert.equal(h.trend, "stable"); +}); + +test("all-null percentages read null (B3)", () => { + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z"); + assert.equal(q.providers[0].remainingPct, null); + assert.equal(q.worstRemainingPct, null); +}); + +test("worstRemainingPct ignores nulls, null when all null", () => { + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: null }); + quotaSnapshotsDb.saveQuotaSnapshot({ ...SNAP, remaining_percentage: 42.346 }); + const q = buildQuotaHealth(["openrouter"], "1970-01-01T00:00:00.000Z"); + assert.equal(q.worstRemainingPct, 42.35); +}); diff --git a/tests/unit/ui/combo-health-null-quota.test.tsx b/tests/unit/ui/combo-health-null-quota.test.tsx new file mode 100644 index 0000000000..4b781083a4 --- /dev/null +++ b/tests/unit/ui/combo-health-null-quota.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +// ComboHealthTab fetch "/api/usage/combo-health-dashboard?range=${range}&horizon=${horizon}" (ComboHealthTab.tsx:817-820) au mount ; +// on intercepte global.fetch et on monte le composant réel. +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const translate = (key: string, params?: Record) => + params && "value" in params ? String(params.value) : key; +vi.mock("next-intl", () => ({ useTranslations: () => translate })); + +const ComboHealthTab = ( + await import("../../../src/app/(dashboard)/dashboard/analytics/ComboHealthTab") +).default; + +const NULL_COMBO = { + comboId: "c1", + comboName: "null-quota", + strategy: "auto", + models: ["m1"], + quotaHealth: { + providers: [ + { provider: "openrouter", remainingPct: null, isExhausted: false, trend: "stable" }, + ], + worstRemainingPct: null, + }, + usageSkew: { modelDistribution: [], giniCoefficient: 0 }, + performance: { avgLatencyMs: 0, successRate: 0, totalRequests: 0 }, +}; +// Enveloppe réelle lue par ComboHealthTab.tsx:828-836 : result.health + result.errors +// (pas {combos:[…]} — sinon setData(undefined), liste vide, jamais de n/a). +const NULL_PAYLOAD = { + health: { timeRange: "24h", combos: [NULL_COMBO] }, + forecast: null, + autopilot: null, + scoring: null, + errors: {}, +}; + +function mount() { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + return { el, root }; +} + +describe("combo health null quota", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => { + expect(String(url)).toContain("/api/usage/combo-health-dashboard"); + return new Response(JSON.stringify(NULL_PAYLOAD), { status: 200 }); + }) + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("renders n/a in the quota section, bar width 0%", async () => { + const { el, root } = mount(); + await act(async () => { + root.render(); + }); + // Assertion scopée à la section quota (C1) : le bloc perf rend "0.0%" légitime + // via formatPercent(successRate*100) même post-fix — un not.toContain("0%") global + // serait un faux-positif permanent. + const quotaSection = el.querySelector("section") as HTMLElement | null; + const quotaText = quotaSection?.textContent ?? ""; + expect(quotaText).toContain("n/a"); + const bar = quotaSection?.querySelector('[style*="width"]') as HTMLElement | null; + expect(bar?.style.width).toBe("0%"); + }); +}); From 5a777a243b389355891bc3aa917139d7cef5d866 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 10 Sep 2026 08:21:02 -0300 Subject: [PATCH 15/15] docs(changelog): add the missing bullet marker to the DeepSeek PoW fragment (#13200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um caractere. O fragmento do #13097 subiu sem o `- ` inicial e derrubou o `Merge integrity` para todo mundo que veio depois. Terceira ocorrência da mesma causa nesta release; a anterior foi o `reset-aware-model-family.md`, que o #12711 consertou de carona. --- changelog.d/fixes/13094-deepseek-pow-slot-leak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fixes/13094-deepseek-pow-slot-leak.md b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md index 6560854f8d..ab26d28c6f 100644 --- a/changelog.d/fixes/13094-deepseek-pow-slot-leak.md +++ b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md @@ -1 +1 @@ -Fix a concurrency-slot leak in the DeepSeek PoW solver: a worker that failed to spawn (for example a missing worker script) never released its slot, so `MAX_CONCURRENT_WORKERS` failures disabled the solver until restart. +- Fix a concurrency-slot leak in the DeepSeek PoW solver: a worker that failed to spawn (for example a missing worker script) never released its slot, so `MAX_CONCURRENT_WORKERS` failures disabled the solver until restart.