Skip to content

fix provider limits sync cadence - #888

Closed
rdself wants to merge 1 commit into
cheaper-inference:mainfrom
rdself:coder/provider-limits-sync-interval
Closed

rdself wants to merge 1 commit into
cheaper-inference:mainfrom
rdself:coder/provider-limits-sync-interval

Conversation

@rdself

@rdself rdself commented Apr 1, 2026 •

Copy link
Copy Markdown
Contributor

What changed

This PR stops Provider Limits from doing a full live refresh every time the page opens.

Instead, Provider Limits now:

  • loads cached quota data on page open
  • refreshes automatically on a server-side schedule controlled by PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES
  • defaults that schedule to 70 minutes when the env var is unset or invalid
  • still allows manual refresh from the UI for both single providers and Refresh All

Why this changed

The previous implementation triggered /api/usage/[connectionId] for every supported account on component mount. That meant simply opening the Provider Limits page caused a live quota sync burst against upstream providers.

Some providers/channels enforce very strict RPM limits on quota or limits endpoints. When users open this page repeatedly, those quota fetches can hit upstream 429s very easily even though regular model traffic is otherwise fine.

The root cause was that the dashboard only had an in-memory front-end state for Provider Limits, so it had to refresh live on every mount to show anything.

Implementation details

  • added a dedicated SQLite-backed Provider Limits cache in key_value
  • extracted live Provider Limits fetching into a shared usage service
  • added a server-side Provider Limits sync scheduler started from Node instrumentation
  • added cached read and manual bulk refresh API endpoints at /api/usage/provider-limits
  • changed the dashboard component to hydrate from cached data instead of refreshing on mount
  • documented the new environment variable in .env.example, README.md, and docs/USER_GUIDE.md

Impact

  • opening Provider Limits no longer triggers live upstream refreshes by default
  • this avoids repeatedly burning strict quota-endpoint RPM budgets and reduces avoidable 429s
  • operators can tune automatic refresh cadence with one env var
  • manual UI refresh behavior is preserved
  • cached quota data survives page reloads and server-side sync cycles

Validation

  • npx eslint 'src/lib/db/providerLimits.ts' 'src/lib/usage/providerLimits.ts' 'src/shared/services/providerLimitsSyncScheduler.ts' 'src/app/api/usage/[connectionId]/route.ts' 'src/app/api/usage/provider-limits/route.ts' 'src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx' 'src/instrumentation-node.ts'
  • node --import tsx/esm --test tests/unit/copilot-usage.test.mjs (fails on a pre-existing GitHub Copilot quota assertion unrelated to this diff: expected remainingPercentage 60, got 100)
  • npm run typecheck:core (blocked by a pre-existing error in open-sse/handlers/responseTranslator.ts:410 where openaiResponse.choices is typed as unknown)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a server-side provider limits synchronization system, replacing the previous client-side auto-refresh logic. It includes a new background scheduler, database caching for provider limits, and updated API endpoints to handle both manual and scheduled syncs. Documentation and environment variables have been updated to reflect these changes. Feedback suggests simplifying redundant object assignments in the credential refresh logic and improving error handling when processing settled promises during bulk synchronization.

Comment on lines +141 to +147
connection: {
...connection,
...updateData,
providerSpecificData:
(updateData.providerSpecificData as JsonRecord | undefined) ||
connection.providerSpecificData,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The explicit assignment of providerSpecificData is redundant here. Since updateData is spread after connection, if updateData.providerSpecificData is defined (which happens in the logic above), it will naturally overwrite the value from connection. If it is undefined, the value from connection is preserved. Removing this line simplifies the object construction while maintaining the same logic.

    connection: {
      ...connection,
      ...updateData,
    },

Comment on lines +354 to +355
const reason = result.reason as { message?: string } | undefined;
errors[connectionId] = reason?.message || "Failed to refresh provider limits";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When handling rejected promises from Promise.allSettled, the reason can be any type (e.g., a string or a custom object), not necessarily an object with a message property. Using instanceof Error or a robust string fallback ensures that the errors map is always populated with a meaningful string, preventing potential undefined values in the UI.

Suggested change
const reason = result.reason as { message?: string } | undefined;
errors[connectionId] = reason?.message || "Failed to refresh provider limits";
const reason = result.reason;
errors[connectionId] = reason instanceof Error ? reason.message : String(reason || "Failed to refresh provider limits");

@rdself rdself changed the title [codex] fix provider limits sync cadence fix provider limits sync cadence Apr 1, 2026
@rdself
rdself marked this pull request as ready for review April 1, 2026 06:40
Copilot AI review requested due to automatic review settings April 1, 2026 06:40

@kilo-code-bot kilo-code-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review - Additional Issues Found

I've identified a few additional issues beyond the existing comments:

  1. Scheduler runs immediately on server startup - The scheduler waits 5 seconds (STARTUP_DELAY_MS) but runs on the first request that triggers the import, not on a fixed schedule. This could cause delays.

  2. No upper bound on sync interval - The env var validation allows any positive number, meaning extremely large values (e.g., 525600 minutes = 1 year) are technically accepted.

@kilo-code-bot

kilo-code-bot Bot commented Apr 1, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 2 Existing Issues | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0

Existing Issues (from other reviewers)

The following issues have already been identified by other reviewers:

File Line Issue
src/lib/usage/providerLimits.ts 147 Redundant assignment of providerSpecificData - the spread operator already handles this
src/lib/usage/providerLimits.ts 355 Error message fallback could be more robust - reason?.message may be undefined for non-Error objects

Other Observations (not in diff)

Issues found in unchanged code or patterns that should be considered:

File Line Issue
src/lib/usage/providerLimits.ts 186-188 No upper bound validation on PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES - extremely large values (e.g., 525600 minutes = 1 year) are technically accepted
src/shared/services/providerLimitsSyncScheduler.ts 46 Scheduler initializes lazily on first module import rather than on a fixed server startup schedule - this could cause the initial sync to be delayed depending on when the module is first imported

Files Reviewed (6 files)

  • .env.example - Documentation update
  • README.md - Documentation update
  • docs/USER_GUIDE.md - Documentation update
  • src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx - Frontend caching logic
  • src/app/api/usage/[connectionId]/route.ts - Simplified to use shared service
  • src/app/api/usage/provider-limits/route.ts - New API endpoints (GET/POST)
  • src/instrumentation-node.ts - Added scheduler initialization
  • src/lib/db/providerLimits.ts - New DB layer for cache
  • src/lib/localDb.ts - Re-exports for new DB functions
  • src/lib/usage/providerLimits.ts - Core service (2 existing issues)
  • src/shared/services/providerLimitsSyncScheduler.ts - New scheduler

Overall Assessment: The PR implements the caching layer correctly and addresses the original issue of avoiding live upstream refreshes on page load. The existing comments from other reviewers identify actionable improvements. The additional observations are minor and relate to edge cases that don't block merge.

Fix these issues in Kilo Cloud

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a server-scheduled, SQLite-backed cache for Provider Limits so that opening the dashboard page no longer triggers a full live quota refresh burst against upstream provider limits endpoints.

Changes:

  • Added a key_value-backed Provider Limits cache plus a shared live-fetch + persist usage service.
  • Added a Node-instrumentation-started scheduler to auto-sync Provider Limits on a configurable cadence (PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES, default 70m).
  • Updated dashboard + APIs to hydrate from cached data on page load and keep manual refresh behaviors.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/shared/services/providerLimitsSyncScheduler.ts New periodic sync scheduler with startup delay and last-run alignment.
src/lib/usage/providerLimits.ts New shared service for fetching live provider limits, persisting cache, and bulk sync.
src/lib/db/providerLimits.ts New DB helpers for reading/writing Provider Limits cache entries in key_value.
src/lib/localDb.ts Re-export Provider Limits cache DB functions/types via localDb facade.
src/instrumentation-node.ts Starts the Provider Limits sync scheduler at Node startup.
src/app/api/usage/provider-limits/route.ts New endpoints to read cached limits and manually sync all.
src/app/api/usage/[connectionId]/route.ts Refactored to use the shared Provider Limits live-fetch + persist logic.
src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx Page now hydrates from cached limits on mount; manual refresh all uses the new bulk endpoint.
README.md Updated feature bullet to reflect cached/scheduled Provider Limits tracking.
docs/USER_GUIDE.md Documented PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES in env var table.
.env.example Added PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +54 to +55
if (elapsedMs < intervalMs) {
initialDelayMs = Math.max(intervalMs - elapsedMs, STARTUP_DELAY_MS);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elapsedMs can go negative if lastAutoSyncAt is in the future (clock skew / bad setting value), which makes initialDelayMs larger than intervalMs (because intervalMs - elapsedMs becomes intervalMs + |elapsedMs|). Consider guarding against future timestamps (e.g., treat negative elapsed as 0 or fall back to STARTUP_DELAY_MS) so a bad timestamp can’t delay sync for hours.

Suggested change
if (elapsedMs < intervalMs) {
initialDelayMs = Math.max(intervalMs - elapsedMs, STARTUP_DELAY_MS);
const safeElapsedMs = Math.max(0, elapsedMs);
if (safeElapsedMs < intervalMs) {
initialDelayMs = Math.max(intervalMs - safeElapsedMs, STARTUP_DELAY_MS);

Copilot uses AI. Check for mistakes.
`[ProviderLimitsSync] Cycle complete: ${result.succeeded}/${result.total} synced in ${Date.now() - start}ms`
);
} catch (error) {
console.warn("[ProviderLimitsSync] Cycle failed:", (error as Error).message);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block logs (error as Error).message, which can be undefined for non-Error throws (and it drops stack/context). Prefer logging the error object (or error instanceof Error ? error.message : String(error)) to preserve useful diagnostics.

Suggested change
console.warn("[ProviderLimitsSync] Cycle failed:", (error as Error).message);
console.warn(
"[ProviderLimitsSync] Cycle failed:",
error instanceof Error ? error : String(error)
);

Copilot uses AI. Check for mistakes.
const chunk = connections.slice(i, i + concurrency);
const results = await Promise.allSettled(
chunk.map(async (connection) => {
const { usage } = await fetchLiveProviderLimits(connection.id);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncAllProviderLimits() iterates over connections from getProviderConnections(), but each task calls fetchLiveProviderLimits(connection.id) which re-queries the DB via getProviderConnectionById for every connection. This is an avoidable N+1 DB pattern; consider adding an overload that accepts the already-loaded connection object (or splitting out an internal fetchLiveProviderLimitsForConnection(connection)), and only hit the DB when you truly need a fresh row.

Suggested change
const { usage } = await fetchLiveProviderLimits(connection.id);
const { usage } = await fetchLiveProviderLimits(connection);

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +193
export function getProviderLimitsSyncIntervalMinutes(): number {
const raw = Number.parseInt(process.env.PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES;
}

export function getProviderLimitsSyncIntervalMs(): number {
return getProviderLimitsSyncIntervalMinutes() * 60 * 1000;
}

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getProviderLimitsSyncIntervalMinutes() and the sync/cache persistence logic are new behavior but there are no unit tests covering interval parsing (unset/invalid/zero/negative), cached read/write normalization, and syncAllProviderLimits() error aggregation. Given the repo has unit tests for other src/lib/usage/* modules, adding a focused tests/unit/provider-limits.test.mjs would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +133
plan: cached.plan || null,
message: cached.message || null,

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plan: cached.plan || null will coerce falsy-but-valid plan values (e.g., 0, false, or empty string) to null. If you only want to default when the field is missing, use nullish coalescing (cached.plan ?? null). Same applies to cached.message if you want to preserve empty strings.

Suggested change
plan: cached.plan || null,
message: cached.message || null,
plan: cached.plan ?? null,
message: cached.message ?? null,

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +67
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(PROVIDER_LIMITS_CACHE_NAMESPACE, connectionId) as KeyValueRow | undefined;
if (!row?.value) return null;
return normalizeCacheEntry(parseJson(row.value));

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SELECT only fetches value, but the result is cast to KeyValueRow (which includes key). This type mismatch makes the code harder to reason about and can mask mistakes. Consider introducing a ValueRow type ({ value: string }) for this query (or select both key, value).

Copilot uses AI. Check for mistakes.
diegosouzapw added a commit that referenced this pull request Apr 1, 2026
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Merged manually into release/v3.4.2. Thanks for the contribution!

@rdself
rdself deleted the coder/provider-limits-sync-interval branch April 16, 2026 01:14
@diegosouzapw diegosouzapw mentioned this pull request Apr 22, 2026
This was referenced Apr 30, 2026
diegosouzapw added a commit that referenced this pull request Jun 18, 2026
…(design phase 3, C3)

The shared DataTable hardcoded its colors with inline rgba and two dead var()
fallbacks — var(--bg-table-header, rgba(15,15,25,0.95)) and var(--text-secondary,
#888) — that never resolved, so the table was effectively always-dark and broken
in light theme.

Introduce a --table-* token set whose DARK values exactly equal the old hardcoded
rgba (header bg, zebra, row hover, cell border) so dark stays byte-identical, and
whose light values fix the light theme. Header border → --color-border (which is
rgba(255,255,255,0.08) in dark = the exact old value); secondary text →
--color-text-muted; the selected-row accent tint is kept (theme-neutral).

⚠️ Wants a visual pass before merge — light-theme tables + the #888→muted text shift.
logTableStyles.ts + the legacy Ant table rules are left untouched (separate, lower
priority). See design.md.
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
diegosouzapw added a commit that referenced this pull request Aug 28, 2026
…ote-only replace

CodeQL js/incomplete-sanitization (#888): the hand-rolled replace only escaped
double quotes, so a backslash in the fixture would have produced a malformed YAML
scalar. JSON.stringify covers every escape the double-quoted YAML scalar needs.
diegosouzapw added a commit that referenced this pull request Aug 28, 2026
…ote-only replace (#11942)

CodeQL js/incomplete-sanitization (alert #888 on #11929): the hand-rolled replace
only escaped double quotes, so a backslash in the fixture would have produced a
malformed YAML scalar. JSON.stringify covers every escape the double-quoted YAML
scalar needs. Test-only change (7/7 pass).
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…ote-only replace

CodeQL js/incomplete-sanitization (cheaper-inference#888): the hand-rolled replace only escaped
double quotes, so a backslash in the fixture would have produced a malformed YAML
scalar. JSON.stringify covers every escape the double-quoted YAML scalar needs.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…ote-only replace (cheaper-inference#11942)

CodeQL js/incomplete-sanitization (alert cheaper-inference#888 on cheaper-inference#11929): the hand-rolled replace
only escaped double quotes, so a backslash in the fixture would have produced a
malformed YAML scalar. JSON.stringify covers every escape the double-quoted YAML
scalar needs. Test-only change (7/7 pass).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants