Skip to content

Add Codex OAuth limit estimates and LiteLLM pricing sync#226

Open
WenjieZhao1 wants to merge 1 commit into
Willxup:mainfrom
WenjieZhao1:feat/oauth-limit-estimates-pricing-sync
Open

Add Codex OAuth limit estimates and LiteLLM pricing sync#226
WenjieZhao1 wants to merge 1 commit into
Willxup:mainfrom
WenjieZhao1:feat/oauth-limit-estimates-pricing-sync

Conversation

@WenjieZhao1

Copy link
Copy Markdown

Summary

  • Add Keeper-side Codex OAuth limit estimates for rolling 5h and 7d windows, including inferred token limits and USD estimates when local model prices are available.
  • Add LiteLLM pricing sync with startup, daily, and manual refresh support, sync status metadata, and preservation of manual prices by default.
  • Surface OAuth limit estimates in the Credentials/Auth Files tab and expose pricing sync controls/status in model price settings.

Details

  • Adds POST /api/v1/usage/oauth-limit-estimates.
  • Adds GET /api/v1/pricing/sync and POST /api/v1/pricing/sync.
  • Extends model_price_settings with source, source_url, and synced_at metadata.
  • Uses local model prices for USD estimates and reports missing model prices instead of silently defaulting.
  • Preserves existing Models.dev pricing preview/import behavior and manual pricing edits.

Testing

  • go test ./cmd/... ./internal/...
  • npm --prefix ./web run test
  • npm --prefix ./web run lint
  • npm --prefix ./web run typecheck
  • npm --prefix ./web run build

@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 pricing synchronization from LiteLLM and adds a new endpoint to estimate OAuth limits for Codex identities based on rolling windows (5h, 7d) and model pricing. The frontend has been updated to display sync status, trigger manual LiteLLM syncs, and show OAuth limit estimates on credentials. Key feedback from the review includes: (1) fixing a database-agnostic migration issue where raw SQL with DATETIME will fail on PostgreSQL by using GORM's Migrator().AddColumn, (2) centralizing AbortController management in the useOAuthLimitEstimates React hook to prevent race conditions, (3) adding a defensive nil check on task in oauth_limit_estimates.go to avoid nil pointer dereferences, and (4) removing unused write-only fields (mu and running) from PricingSyncRunner.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +16 to +32
columns := []struct {
name string
sql string
}{
{name: "source", sql: "ALTER TABLE model_price_settings ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'"},
{name: "source_url", sql: "ALTER TABLE model_price_settings ADD COLUMN source_url TEXT"},
{name: "synced_at", sql: "ALTER TABLE model_price_settings ADD COLUMN synced_at DATETIME"},
}

for _, column := range columns {
if tx.Migrator().HasColumn(&entities.ModelPriceSetting{}, column.name) {
continue
}
if err := tx.Exec(column.sql).Error; err != nil {
return fmt.Errorf("add model_price_settings.%s column: %w", column.name, err)
}
}

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.

high

Using raw SQL ALTER TABLE with DATETIME type is not database-agnostic and will fail on PostgreSQL, which does not support the DATETIME type (it uses TIMESTAMP instead). Use GORM's built-in Migrator().AddColumn to perform database-agnostic schema migrations safely.

Suggested change
columns := []struct {
name string
sql string
}{
{name: "source", sql: "ALTER TABLE model_price_settings ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'"},
{name: "source_url", sql: "ALTER TABLE model_price_settings ADD COLUMN source_url TEXT"},
{name: "synced_at", sql: "ALTER TABLE model_price_settings ADD COLUMN synced_at DATETIME"},
}
for _, column := range columns {
if tx.Migrator().HasColumn(&entities.ModelPriceSetting{}, column.name) {
continue
}
if err := tx.Exec(column.sql).Error; err != nil {
return fmt.Errorf("add model_price_settings.%s column: %w", column.name, err)
}
}
columns := []string{"Source", "SourceURL", "SyncedAt"}
for _, column := range columns {
if tx.Migrator().HasColumn(&entities.ModelPriceSetting{}, column) {
continue
}
if err := tx.Migrator().AddColumn(&entities.ModelPriceSetting{}, column); err != nil {
return fmt.Errorf("add model_price_settings.%s column: %w", column, err)
}
}

Comment on lines +24 to +90
const load = useCallback(async (signal?: AbortSignal) => {
if (!enabled) {
return
}
if (authIndexes.length === 0) {
setEstimatesByAuthIndex({})
setLoading(false)
setError('')
return
}

setLoading(true)
setError('')
try {
const response = await fetchUsageOAuthLimitEstimates(authIndexes, signal)
if (signal?.aborted) {
return
}
setEstimatesByAuthIndex(() => {
const requested = new Set(authIndexes)
const next: Record<string, UsageOAuthLimitEstimateItem> = {}
for (const item of response.items ?? []) {
if (requested.has(item.auth_index)) {
next[item.auth_index] = item
}
}
return next
})
} catch (nextError) {
if (signal?.aborted) {
return
}
if (nextError instanceof ApiError && nextError.status === 401) {
onAuthRequired?.()
return
}
setError(oauthLimitEstimateErrorMessage(nextError))
} finally {
if (!signal?.aborted) {
setLoading(false)
}
}
}, [authIndexes, enabled, onAuthRequired])

useEffect(() => {
if (!enabled) {
requestControllerRef.current?.abort()
requestControllerRef.current = null
setLoading(false)
return
}
requestControllerRef.current?.abort()
const controller = new AbortController()
requestControllerRef.current = controller
void load(controller.signal).finally(() => {
if (requestControllerRef.current === controller) {
requestControllerRef.current = null
}
})
return () => {
controller.abort()
}
}, [enabled, load])

const refresh = useCallback(async () => {
await load()
}, [load])

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.

high

Centralize the AbortController management inside the load function. This prevents potential race conditions where a manual refresh call (which currently doesn't use or manage an abort signal) could run concurrently with automatic loads and overwrite the state with stale data. It also simplifies the useEffect and allows callers to safely await the refresh promise.

  const load = useCallback(async () => {
    if (!enabled) {
      return
    }
    if (authIndexes.length === 0) {
      setEstimatesByAuthIndex({})
      setLoading(false)
      setError('')
      return
    }

    requestControllerRef.current?.abort()
    const controller = new AbortController()
    requestControllerRef.current = controller

    setLoading(true)
    setError('')
    try {
      const response = await fetchUsageOAuthLimitEstimates(authIndexes, controller.signal)
      if (controller.signal.aborted) {
        return
      }
      setEstimatesByAuthIndex(() => {
        const requested = new Set(authIndexes)
        const next: Record<string, UsageOAuthLimitEstimateItem> = {}
        for (const item of response.items ?? []) {
          if (requested.has(item.auth_index)) {
            next[item.auth_index] = item
          }
        }
        return next
      })
    } catch (nextError) {
      if (controller.signal.aborted) {
        return
      }
      if (nextError instanceof ApiError && nextError.status === 401) {
        onAuthRequired?.()
        return
      }
      setError(oauthLimitEstimateErrorMessage(nextError))
    } finally {
      if (!controller.signal.aborted) {
        setLoading(false)
        if (requestControllerRef.current === controller) {
          requestControllerRef.current = null
        }
      }
    }
  }, [authIndexes, enabled, onAuthRequired])

  useEffect(() => {
    void load()
    return () => {
      requestControllerRef.current?.abort()
    }
  }, [load])

  const refresh = useCallback(async () => {
    await load()
  }, [load])

Comment thread internal/quota/oauth_limit_estimates.go Outdated

for _, authIndex := range authIndexes {
task, ok := s.refreshTasks[authIndex]
if !ok || task.Status != RefreshTaskStatusCompleted || task.Quota == nil {

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

Defensive programming: check if task is nil before accessing its fields to prevent potential nil pointer dereference panics.

Suggested change
if !ok || task.Status != RefreshTaskStatusCompleted || task.Quota == nil {
if !ok || task == nil || task.Status != RefreshTaskStatusCompleted || task.Quota == nil {
References
  1. Ensure pointer receivers in Go are checked for nil before accessing their fields or locking their mutexes to prevent nil pointer dereference panics.

Comment on lines +22 to +23
mu sync.Mutex
running bool

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 mu and running fields in PricingSyncRunner are write-only and never read anywhere in the codebase. Removing them simplifies the runner and improves maintainability.

	syncer PricingSyncer
	now    func() time.Time
	sleep  func(context.Context, time.Duration) bool

@Willxup

Willxup commented Jun 17, 2026

Copy link
Copy Markdown
Owner

This duplicates existing main-branch functionality. Auth Files already has quota Current/Estimated estimation, and Settings already has manual model price sync. Please reuse/extend those existing flows instead of adding parallel Codex-only estimate APIs/UI and a second pricing sync path.

@WenjieZhao1

Copy link
Copy Markdown
Author

Thanks for the review. I may not have described the intent clearly enough.

The existing Auth Files quota Current/Estimated flow shows the provider quota percentage and can estimate usage from quota window data. What I was trying to add is a more specific measurement: estimating the approximate USD value of each Codex OAuth account’s 5h and weekly quota, based on Keeper’s local SQLite usage records plus local model prices.

There has been a lot of discussion around Codex quotas for ChatGPT Plus and Team accounts. In practice, the quota is hard to quantify precisely, and it appears that even different Team accounts may have different effective limits. This feature is meant to make that measurable from real local usage data instead of guessing from plan names.

For each Codex OAuth account, it uses:

  • cached quota percent for the 5h / weekly window
  • local rolling token usage in that same window
  • local model prices for input/cache/output tokens

Then it infers:

  • approximate total token quota for the account/window
  • approximate used USD
  • approximate total USD value of that quota window

So the goal is not to duplicate the existing quota bar, but to answer: “roughly how many tokens / dollars of model usage does this specific account’s 5h and weekly quota represent?”

@Willxup

Willxup commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR and for clarifying the intent. The idea itself is useful: estimating the approximate token/USD value of a quota window can help users better understand what each account’s 5h / weekly quota means in practice.

However, I do not think this should be added as a parallel flow in its current form.

First, the quota estimate feature overlaps with the existing Auth Files quota Current/Estimated flow. With this PR, the page would show two separate quota estimation concepts for the same account/window, which is confusing from a product perspective. This should be merged into the existing quota estimate model/UI instead of adding a separate Codex-only estimate API, hook, and panel.

The current UI placement also makes each Auth Files row much taller and less predictable, since the estimate cards are always rendered under the quota bars. This becomes especially visible for Codex Pro accounts, where the quota area can already render four quota bars. Adding two more estimate cards below those bars would make a single row very tall and harder to scan. A better interaction would be to keep the row compact and show these richer token/USD details in a popover or modal when the user clicks the quota bar.

Also, the new estimate logic is currently Codex-specific. Existing quota estimation has already been optimized around 5h / weekly quota windows more generally, so if we extend this feature, it should fit the existing quota system and be designed in a way that can support other credential/provider types where applicable.

Second, the LiteLLM pricing sync should be integrated with the existing manual pricing sync flow instead of introducing a second sync path and a second source-specific workflow. Price sync should have one product model and one UI flow, with different sources handled as options within that flow if needed.

So I think the next step should be to re-scope this PR around extending the existing Auth Files quota estimation and existing pricing sync mechanisms, rather than adding parallel systems.

@WenjieZhao1
WenjieZhao1 force-pushed the feat/oauth-limit-estimates-pricing-sync branch from 4c8b876 to 4b05e27 Compare June 17, 2026 04:46
@WenjieZhao1

Copy link
Copy Markdown
Author

Updated this PR based on the review feedback and force-pushed the re-scoped implementation.

What changed:

  • Removed the parallel Codex-only OAuth limit estimate endpoint, hook, types, and always-rendered estimate panel.
  • Integrated local token/USD window usage into the existing Auth Files quota Current/Estimated flow.
  • Kept Auth Files rows compact; richer token/USD details are now shown from the quota bar detail modal.
  • Added quota row metadata for source, cost availability, missing prices, and calculated time.
  • Local usage backfills provider window usage only when provider window usage is missing/incomplete; provider values are preserved.
  • Changed LiteLLM pricing sync into a fallback path: it creates missing prices or updates existing LiteLLM-sourced prices, and does not overwrite manual or other user-applied sources.
  • Added handling for stale in-memory quota refresh task polling after service restarts so old task 404s do not surface as credential errors.

Verification:

  • go test ./cmd/... ./internal/...
  • npm --prefix ./web run test
  • npm --prefix ./web run lint
  • npm --prefix ./web run typecheck
  • npm --prefix ./web run build

@Willxup

Willxup commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Thanks for the contribution and for the follow-up updates.

I pulled your branch locally, ran it, and reviewed the feature from an end-user perspective. I appreciate the effort, but I do not think this PR is ready to be accepted.

A feature PR should be reviewed and polished by the contributor before submission, including actually running and using the feature in the application. During this review, I had to repeatedly pull the branch to inspect both the code and the implementation. Much of the feedback was about product flow, integration with existing UI/business logic, and feature polish rather than small review fixes.

Also, a PR should ideally focus on one feature or one coherent change.

Unpolished PRs that require maintainers to repeatedly guide the feature design and user experience will not be accepted. Contributions are welcome, but please make sure the feature is complete, integrated with the existing product model, personally tested, and scoped to a clear single purpose before opening or updating the PR.

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.

2 participants