diff --git a/backend/internal/handler/admin/account_ollama_cloud_usage_test.go b/backend/internal/handler/admin/account_ollama_cloud_usage_test.go index 89f2aa44ab..38db30a2c1 100644 --- a/backend/internal/handler/admin/account_ollama_cloud_usage_test.go +++ b/backend/internal/handler/admin/account_ollama_cloud_usage_test.go @@ -273,4 +273,5 @@ func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) require.Contains(t, recorder.Body.String(), `"enabled":false`) require.Contains(t, recorder.Body.String(), `"interval_minutes":60`) + require.Contains(t, recorder.Body.String(), `"debounce_minutes":1`) } diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage.go b/backend/internal/repository/account_repo_ollama_cloud_usage.go index 78d14d2097..9d06c44ef1 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage.go @@ -354,9 +354,31 @@ func canonicalJSON(raw string) string { return string(encoded) } -// ListDueOllamaCloudUsageAccounts returns at most one due representative per -// exact API key before hydration, preventing one shared group from consuming a -// whole runner cycle. +// ollamaCloudUsageParseRFC3339SQL reuses the verified RFC3339(/Nano) parse path +// for a snapshot timestamp expression. Invalid or missing values fail open to NULL. +func ollamaCloudUsageParseRFC3339SQL(expression string) string { + return `CASE + WHEN ` + expression + ` IS NULL THEN NULL + WHEN ` + expression + ` ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' + THEN jsonb_path_query_first_tz( + to_jsonb(regexp_replace( + ` + expression + `, + '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', + '\1\2' + )), + '$.datetime()', '{}'::jsonb, true + ) #>> '{}' + ELSE NULL + END` +} + +// ListDueOllamaCloudUsageAccounts returns at most one activity-driven candidate +// per exact API key. It stamps Account.LastUsedAt with the group MAX(last_used_at) +// so the service due pure function can decide without hydrating the whole table. +// +// SQL only prefilters potential candidates (missing/invalid snapshot, or group +// activity after the last successful fetch / failed attempt). Final due timing +// (debounce, max-wait, failure backoff) is evaluated in service code. func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { if limit <= 0 { return []service.Account{}, nil @@ -364,64 +386,90 @@ func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, if r == nil || r.sql == nil { return nil, errors.New("account repository SQL executor not configured") } + // now is retained in the signature for callers; activity due is evaluated in service. + _ = now rows, err := r.sql.QueryContext(ctx, ` - WITH candidates AS ( - SELECT id, credentials ->> 'api_key' AS api_key, - extra #>> '{ollama_cloud_usage_snapshot,next_refresh_at}' AS next_refresh_at + WITH eligible AS ( + SELECT id, + credentials ->> 'api_key' AS api_key, + last_used_at, + extra -> 'ollama_cloud_usage_snapshot' AS snapshot FROM accounts WHERE deleted_at IS NULL AND status = 'active' AND `+ollamaCloudUsageEligibleSQL+` AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string' AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb + ), group_activity AS ( + SELECT credentials ->> 'api_key' AS api_key, + MAX(last_used_at) AS group_last_used_at + FROM accounts + WHERE deleted_at IS NULL + AND `+ollamaCloudUsageEligibleSQL+` + AND jsonb_typeof(credentials -> 'api_key') = 'string' + GROUP BY credentials ->> 'api_key' + ), joined AS ( + SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at, + e.snapshot #>> '{status}' AS status, + e.snapshot #>> '{fetched_at}' AS fetched_at, + e.snapshot #>> '{last_attempt_at}' AS last_attempt_at + FROM eligible e + JOIN group_activity g ON g.api_key = e.api_key ), parsed AS MATERIALIZED ( - SELECT id, api_key, next_refresh_at, - next_refresh_at ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AS rfc3339_shape, - jsonb_path_query_first_tz( - to_jsonb(regexp_replace( - next_refresh_at, - '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', - '\1\2' - )), - '$.datetime()', '{}'::jsonb, true - ) #>> '{}' AS parsed_next_refresh_at - FROM candidates - ), due AS ( + SELECT id, api_key, snapshot, group_last_used_at, status, + `+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at, + `+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at + FROM joined + ), candidates AS ( SELECT *, - CASE WHEN next_refresh_at IS NULL OR NOT rfc3339_shape OR parsed_next_refresh_at IS NULL THEN 0 ELSE 1 END AS due_class + CASE + WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL + OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0 + WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0 + WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0 + WHEN status = 'ok' + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_fetched_at::timestamptz THEN 1 + WHEN status IN ('failed', 'unauthorized') + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_last_attempt_at::timestamptz THEN 1 + ELSE NULL + END AS due_class FROM parsed - WHERE next_refresh_at IS NULL - OR NOT rfc3339_shape - OR parsed_next_refresh_at IS NULL - OR parsed_next_refresh_at::timestamptz <= $1 ), ranked AS ( - SELECT *, row_number() OVER ( - PARTITION BY api_key - ORDER BY due_class, - CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST, - id - ) AS group_rank - FROM due + SELECT id, api_key, group_last_used_at, due_class, + row_number() OVER ( + PARTITION BY api_key + ORDER BY due_class, + group_last_used_at NULLS FIRST, + id + ) AS group_rank + FROM candidates + WHERE due_class IS NOT NULL ) - SELECT id + SELECT id, group_last_used_at FROM ranked WHERE group_rank = 1 - ORDER BY due_class, - CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST, - id - LIMIT $2 - `, now.UTC(), limit) + ORDER BY due_class, group_last_used_at NULLS FIRST, id + LIMIT $1 + `, limit) if err != nil { return nil, err } defer func() { _ = rows.Close() }() + type dueRow struct { + id int64 + groupLastUsed *time.Time + } + rowsOut := make([]dueRow, 0, limit) ids := make([]int64, 0, limit) for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { + var row dueRow + if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil { return nil, err } - ids = append(ids, id) + rowsOut = append(rowsOut, row) + ids = append(ids, row.id) } if err := rows.Err(); err != nil { return nil, err @@ -430,11 +478,26 @@ func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, if err != nil { return nil, err } - result := make([]service.Account, 0, len(accounts)) + byID := make(map[int64]*service.Account, len(accounts)) for _, account := range accounts { if account != nil { - result = append(result, *account) + byID[account.ID] = account } } + result := make([]service.Account, 0, len(rowsOut)) + for _, row := range rowsOut { + account := byID[row.id] + if account == nil { + continue + } + // Stamp group MAX(last_used_at) for service due evaluation. + if row.groupLastUsed != nil { + ts := row.groupLastUsed.UTC() + account.LastUsedAt = &ts + } else { + account.LastUsedAt = nil + } + result = append(result, *account) + } return result, nil } diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go index a7dc861249..49bb79127f 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go @@ -23,31 +23,40 @@ func TestListDueOllamaCloudUsageAccountsOrderingLimitAndProxyHydration(t *testin Username: "user", Password: "pass", Status: service.StatusActive, }) - createAccount := func(name, baseURL string, proxyID *int64, nextRefreshAt *time.Time) *service.Account { + createAccount := func(name, baseURL string, proxyID *int64, snapshot map[string]any, lastUsed *time.Time) *service.Account { t.Helper() extra := map[string]any{ service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", service.OllamaCloudUsageAutoRefreshExtraKey: true, } - if nextRefreshAt != nil { - extra[service.OllamaCloudUsageSnapshotExtraKey] = map[string]any{ - "status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt.UTC().Format(time.RFC3339Nano), - } + if snapshot != nil { + extra[service.OllamaCloudUsageSnapshotExtraKey] = snapshot } return mustCreateAccount(t, tx.Client(), &service.Account{ Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": name, "base_url": baseURL}, - Extra: extra, ProxyID: proxyID, + Extra: extra, ProxyID: proxyID, LastUsedAt: lastUsed, }) } - uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil) - missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil) - oldest := now.Add(-2 * time.Hour) - due := createAccount("ollama-due-oldest", "https://ollama.com", nil, &oldest) - future := now.Add(time.Minute) - _ = createAccount("ollama-not-due", "https://ollama.com", nil, &future) - _ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil) + uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil, nil) + missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil, nil) + fetched := now.Add(-2 * time.Hour) + activity := now.Add(-5 * time.Minute) + due := createAccount("ollama-due-activity", "https://ollama.com", nil, map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, &activity) + // Success snapshot without newer activity must not be listed. + _ = createAccount("ollama-not-due-idle", "https://ollama.com", nil, map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano), + "last_attempt_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano), + "next_refresh_at": now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), + }, nil) + _ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil, nil) accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 2) @@ -55,49 +64,83 @@ func TestListDueOllamaCloudUsageAccountsOrderingLimitAndProxyHydration(t *testin require.Len(t, accounts, 2) require.Equal(t, missingSnapshot.ID, accounts[0].ID) require.Equal(t, due.ID, accounts[1].ID) + require.NotNil(t, accounts[1].LastUsedAt) + require.WithinDuration(t, activity.UTC(), accounts[1].LastUsedAt.UTC(), time.Second) require.NotContains(t, accountIDs(accounts), uppercasePath.ID) require.NotNil(t, accounts[0].Proxy) require.Equal(t, proxy.ID, accounts[0].Proxy.ID) require.Equal(t, proxy.URL(), accounts[0].Proxy.URL()) } -func TestListDueOllamaCloudUsageAccountsParsesRFC3339NanoAndFailsOpen(t *testing.T) { +func TestListDueOllamaCloudUsageAccountsUsesGroupMaxLastUsedAndFailsOpen(t *testing.T) { ctx := context.Background() tx := testEntTx(t) repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + older := now.Add(-10 * time.Minute) + newer := now.Add(-2 * time.Minute) - create := func(name, nextRefreshAt string) *service.Account { - t.Helper() - return mustCreateAccount(t, tx.Client(), &service.Account{ - Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"}, - Extra: map[string]any{ - service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", - service.OllamaCloudUsageAutoRefreshExtraKey: true, - service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ - "status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt, - }, + leader := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-group-leader", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), }, - }) - } - - sevenDigitsOffset := create("ollama-nano-seven", "2026-07-22T11:00:00.1234567-02:00") - eightDigitsOffset := create("ollama-nano-eight", "2026-07-22T11:00:00.12345678+01:00") - nineDigitsZ := create("ollama-nano-nine", "2026-07-22T09:00:00.123456789Z") - invalidCalendar := create("ollama-nano-invalid", "2026-02-30T09:00:00.123456789Z") - future := create("ollama-nano-future", "2026-07-22T15:00:00.123456789Z") + }, + LastUsedAt: &older, + }) + _ = mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-group-sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://www.ollama.com/v1"}, + LastUsedAt: &newer, + }) + invalid := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-invalid-snapshot", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "invalid-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, "fetched_at": "2026-02-30T09:00:00.123456789Z", + }, + }, + }) + idle := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-idle-ok", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "idle-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, + }, + }) accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 10) require.NoError(t, err, "invalid stored values must not abort the query") - require.Equal(t, []int64{ - invalidCalendar.ID, - nineDigitsZ.ID, - eightDigitsOffset.ID, - sevenDigitsOffset.ID, - }, accountIDs(accounts)) - require.NotContains(t, accountIDs(accounts), future.ID) + ids := accountIDs(accounts) + require.Contains(t, ids, invalid.ID) + require.Contains(t, ids, leader.ID) + require.NotContains(t, ids, idle.ID) + for _, account := range accounts { + if account.ID == leader.ID { + require.NotNil(t, account.LastUsedAt) + require.WithinDuration(t, newer.UTC(), account.LastUsedAt.UTC(), time.Second, + "group MAX(last_used_at) must come from the sibling") + } + } } func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *testing.T) { diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go index 07b67287ba..6adb819144 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go @@ -191,9 +191,9 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) { t.Cleanup(func() { _ = db.Close() }) now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC) var capturedSQL string - mock.ExpectQuery("WITH candidates AS"). - WithArgs(now, 20). - WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery("WITH eligible AS"). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "group_last_used_at"})) repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil) accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, 20) @@ -209,10 +209,12 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) { ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"), "jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'", `extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb`, - "parsed_next_refresh_at::timestamptz <= $1", + "MAX(last_used_at) AS group_last_used_at", "PARTITION BY api_key", "WHERE group_rank = 1", - "LIMIT $2", + "LIMIT $1", + "group_last_used_at > parsed_fetched_at::timestamptz", + "group_last_used_at > parsed_last_attempt_at::timestamptz", } { require.Contains(t, normalized, clause) } diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 2eb7a97e3b..8d7610b936 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -114,6 +114,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( if resp != nil && resp.Body != nil { _ = resp.Body.Close() } + if !errors.Is(err, context.Canceled) { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 2e6093d18e..3005d93fa1 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -369,6 +369,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if resp != nil && resp.Body != nil { _ = resp.Body.Close() } + // Transport attempt left local validation; count Ollama Cloud activity. + if !errors.Is(err, context.Canceled) { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } // Ensure the client receives an error response (handlers assume Forward writes on non-failover errors). safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 03f757dabc..a32c007e03 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -356,6 +356,8 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { + // Upstream returned a non-success HTTP status; count Ollama Cloud activity. + scheduleOllamaCloudUsageActivity(s.deferredService, account) body, readErr := s.readUpstreamErrorBody(resp) if readErr != nil { // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, diff --git a/backend/internal/service/ollama_cloud_usage.go b/backend/internal/service/ollama_cloud_usage.go index 2b50bab62c..1de9574a74 100644 --- a/backend/internal/service/ollama_cloud_usage.go +++ b/backend/internal/service/ollama_cloud_usage.go @@ -36,6 +36,9 @@ const ( ollamaCloudUsageDefaultIntervalMinutes = 60 ollamaCloudUsageMinIntervalMinutes = 15 ollamaCloudUsageMaxIntervalMinutes = 24 * 60 + ollamaCloudUsageDefaultDebounceMinutes = 1 + ollamaCloudUsageMinDebounceMinutes = 1 + ollamaCloudUsageMaxDebounceMinutes = 60 ollamaCloudUsageCycleInterval = time.Minute ollamaCloudUsageManualRefreshInterval = 30 * time.Second ollamaCloudUsageRequestTimeout = 15 * time.Second @@ -76,10 +79,15 @@ const ( OllamaCloudUsageStatusFailed = "failed" ) -// OllamaCloudUsageSettings controls the opt-in periodic refresh runner. +// OllamaCloudUsageSettings controls the opt-in request-driven refresh runner. +// +// IntervalMinutes is the max-wait bound: when model requests keep arriving and +// the trailing debounce keeps sliding, a refresh is forced after this long. +// DebounceMinutes is the quiet period after the latest request in a group. type OllamaCloudUsageSettings struct { Enabled bool `json:"enabled"` - IntervalMinutes int `json:"interval_minutes"` + IntervalMinutes int `json:"interval_minutes"` // max wait while requests continue + DebounceMinutes int `json:"debounce_minutes"` // trailing quiet period after last request } // OllamaCloudUsageWindow is a narrow, sanitized view of one official usage window. @@ -114,6 +122,12 @@ type OllamaCloudUsageData struct { } // OllamaCloudUsageSnapshot is the only usage observation persisted in account extra. +// +// NextRefreshAt remains a persisted compatibility field. For status=ok it is a +// max-wait horizon marker only; automatic success refreshes are driven by model +// request activity (group last_used_at + debounce/max-wait), not by this field +// alone. For failed/unauthorized snapshots it is the failure not-before time +// (Retry-After / exponential backoff) and is enforced as max(activityDue, NextRefreshAt). type OllamaCloudUsageSnapshot struct { Status string `json:"status"` Data *OllamaCloudUsageData `json:"data,omitempty"` @@ -168,6 +182,9 @@ func (s *SettingService) GetOllamaCloudUsageSettings(ctx context.Context) (*Olla if settings.IntervalMinutes == 0 { settings.IntervalMinutes = defaults.IntervalMinutes } + if settings.DebounceMinutes == 0 { + settings.DebounceMinutes = defaults.DebounceMinutes + } normalizeOllamaCloudUsageSettings(&settings) return &settings, nil } @@ -185,6 +202,12 @@ func (s *SettingService) SetOllamaCloudUsageSettings(ctx context.Context, settin fmt.Sprintf("interval_minutes must be between %d and %d", ollamaCloudUsageMinIntervalMinutes, ollamaCloudUsageMaxIntervalMinutes), ) } + if settings.DebounceMinutes < ollamaCloudUsageMinDebounceMinutes || settings.DebounceMinutes > ollamaCloudUsageMaxDebounceMinutes { + return infraerrors.BadRequest( + "INVALID_OLLAMA_CLOUD_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes must be between %d and %d", ollamaCloudUsageMinDebounceMinutes, ollamaCloudUsageMaxDebounceMinutes), + ) + } normalizeOllamaCloudUsageSettings(settings) data, err := json.Marshal(settings) if err != nil { @@ -194,7 +217,11 @@ func (s *SettingService) SetOllamaCloudUsageSettings(ctx context.Context, settin } func defaultOllamaCloudUsageSettings() *OllamaCloudUsageSettings { - return &OllamaCloudUsageSettings{Enabled: false, IntervalMinutes: ollamaCloudUsageDefaultIntervalMinutes} + return &OllamaCloudUsageSettings{ + Enabled: false, + IntervalMinutes: ollamaCloudUsageDefaultIntervalMinutes, + DebounceMinutes: ollamaCloudUsageDefaultDebounceMinutes, + } } func normalizeOllamaCloudUsageSettings(settings *OllamaCloudUsageSettings) { @@ -204,6 +231,116 @@ func normalizeOllamaCloudUsageSettings(settings *OllamaCloudUsageSettings) { if settings.IntervalMinutes > ollamaCloudUsageMaxIntervalMinutes { settings.IntervalMinutes = ollamaCloudUsageMaxIntervalMinutes } + if settings.DebounceMinutes <= 0 { + settings.DebounceMinutes = ollamaCloudUsageDefaultDebounceMinutes + } + if settings.DebounceMinutes < ollamaCloudUsageMinDebounceMinutes { + settings.DebounceMinutes = ollamaCloudUsageMinDebounceMinutes + } + if settings.DebounceMinutes > ollamaCloudUsageMaxDebounceMinutes { + settings.DebounceMinutes = ollamaCloudUsageMaxDebounceMinutes + } +} + +func ollamaCloudUsageDurations(settings *OllamaCloudUsageSettings) (debounce, maxWait time.Duration) { + normalized := defaultOllamaCloudUsageSettings() + if settings != nil { + *normalized = *settings + } + normalizeOllamaCloudUsageSettings(normalized) + return time.Duration(normalized.DebounceMinutes) * time.Minute, + time.Duration(normalized.IntervalMinutes) * time.Minute +} + +// ollamaCloudUsageIsAutoRefreshDue decides whether a configured auto-refresh +// group should fetch now. groupLastUsedAt must be MAX(last_used_at) across the +// exact api_key group so shared multi-platform accounts do not miss activity. +// +// Success: a request must be newer than fetched_at; dueAt = min(lastUsed+debounce, fetchedAt+maxWait). +// Failure: a request must be newer than last_attempt_at; activity due uses the same min formula, +// then dueAt = max(activityDue, next_refresh_at) so Retry-After / exponential backoff win. +// Missing or invalid snapshots fail open to a first fetch. +func ollamaCloudUsageIsAutoRefreshDue( + snapshot *OllamaCloudUsageSnapshot, + groupLastUsedAt *time.Time, + now time.Time, + debounce, maxWait time.Duration, +) bool { + dueAt, ok := ollamaCloudUsageAutoRefreshDueAt(snapshot, groupLastUsedAt, debounce, maxWait) + if !ok { + return false + } + return !now.Before(dueAt) +} + +func ollamaCloudUsageAutoRefreshDueAt( + snapshot *OllamaCloudUsageSnapshot, + groupLastUsedAt *time.Time, + debounce, maxWait time.Duration, +) (time.Time, bool) { + if debounce <= 0 { + debounce = time.Duration(ollamaCloudUsageDefaultDebounceMinutes) * time.Minute + } + if maxWait <= 0 { + maxWait = time.Duration(ollamaCloudUsageDefaultIntervalMinutes) * time.Minute + } + if snapshot == nil { + return time.Time{}, true + } + switch snapshot.Status { + case OllamaCloudUsageStatusOK: + if snapshot.FetchedAt == nil || snapshot.FetchedAt.IsZero() { + return time.Time{}, true + } + fetchedAt := snapshot.FetchedAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(fetchedAt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + return minTime(lastUsed.Add(debounce), fetchedAt.Add(maxWait)), true + case OllamaCloudUsageStatusFailed, OllamaCloudUsageStatusUnauthorized: + if snapshot.LastAttemptAt.IsZero() { + return time.Time{}, true + } + lastAttempt := snapshot.LastAttemptAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(lastAttempt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + activityDue := minTime(lastUsed.Add(debounce), lastAttempt.Add(maxWait)) + if !snapshot.NextRefreshAt.IsZero() && snapshot.NextRefreshAt.UTC().After(activityDue) { + return snapshot.NextRefreshAt.UTC(), true + } + return activityDue, true + default: + return time.Time{}, true + } +} + +// maxOllamaCloudUsageGroupLastUsed returns the newest last_used_at among group members. +func maxOllamaCloudUsageGroupLastUsed(accounts []Account) *time.Time { + var latest *time.Time + for i := range accounts { + candidate := accounts[i].LastUsedAt + if candidate == nil || candidate.IsZero() { + continue + } + if latest == nil || candidate.After(*latest) { + ts := candidate.UTC() + latest = &ts + } + } + return latest +} + +// scheduleOllamaCloudUsageActivity records that an Ollama Cloud API-key account +// actually attempted an upstream model request (including 429/5xx/transport errors). +// Local auth/validation failures must not call this. DeferredService dedupes writes. +func scheduleOllamaCloudUsageActivity(deferred *DeferredService, account *Account) { + if deferred == nil || account == nil || !IsOllamaCloudUsageAccount(account) { + return + } + deferred.ScheduleLastUsedUpdate(account.ID) } // OllamaCloudUsageService refreshes the official settings HTML without affecting routing state. @@ -531,7 +668,7 @@ func (s *OllamaCloudUsageService) Refresh(ctx context.Context, accountID int64) if err != nil { return nil, err } - if _, err := s.refreshAccount(ctx, accountID, settings.IntervalMinutes, false); err != nil { + if _, err := s.refreshAccount(ctx, accountID, settings, false); err != nil { return nil, err } return s.GetState(ctx, accountID) @@ -561,6 +698,7 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { return ErrOllamaCloudUsageUnavailable } now := s.currentTime() + debounce, maxWait := ollamaCloudUsageDurations(settings) accounts, err := writer.ListDueOllamaCloudUsageAccounts(ctx, now, ollamaCloudUsageMaxPerCycle) if err != nil { return fmt.Errorf("list due Ollama Cloud usage accounts: %w", err) @@ -577,13 +715,15 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { continue } seenGroups[fingerprint] = struct{}{} - if snapshot := decodeOllamaCloudUsageSnapshot(account.Extra); snapshot != nil && now.Before(snapshot.NextRefreshAt) { + snapshot := decodeOllamaCloudUsageSnapshot(account.Extra) + // ListDue stamps Account.LastUsedAt with the api_key group MAX(last_used_at). + if !ollamaCloudUsageIsAutoRefreshDue(snapshot, account.LastUsedAt, now, debounce, maxWait) { continue } accountID := account.ID expected := account group.Go(func() error { - if _, refreshErr := s.refreshAccount(ctx, accountID, settings.IntervalMinutes, true); refreshErr != nil { + if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { if errors.Is(refreshErr, ErrOllamaCloudUsageIdentityChanged) { if disableErr := writer.DisableOllamaCloudUsageAutoRefresh(ctx, &expected); disableErr != nil { logger.LegacyPrintf("service.ollama_cloud_usage", "disable_auto_refresh_failed: account_id=%d err=%v", accountID, disableErr) @@ -598,10 +738,15 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { return group.Wait() } -func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID int64, intervalMinutes int, requireEnabled bool) (*OllamaCloudUsageSnapshot, error) { +func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID int64, settings *OllamaCloudUsageSettings, requireEnabled bool) (*OllamaCloudUsageSnapshot, error) { if s == nil || s.accountRepo == nil { return nil, ErrOllamaCloudUsageUnavailable } + if settings == nil { + settings = defaultOllamaCloudUsageSettings() + } + intervalMinutes := settings.IntervalMinutes + debounce, maxWait := ollamaCloudUsageDurations(settings) anchor, err := s.accountRepo.GetByID(ctx, accountID) if err != nil { return nil, err @@ -650,7 +795,13 @@ func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID if !account.IsActive() || !ollamaCloudUsageAutoRefreshEnabled(account) { return nil, nil } - if snapshot := decodeOllamaCloudUsageSnapshot(account.Extra); snapshot != nil && s.currentTime().Before(snapshot.NextRefreshAt) { + groupLastUsed := account.LastUsedAt + if writer, ok := s.accountRepo.(ollamaCloudUsageRepository); ok { + if siblings, listErr := writer.ListOllamaCloudUsageGroupAccounts(ctx, []*Account{account}); listErr == nil { + groupLastUsed = maxOllamaCloudUsageGroupLastUsed(siblings) + } + } + if !ollamaCloudUsageIsAutoRefreshDue(decodeOllamaCloudUsageSnapshot(account.Extra), groupLastUsed, s.currentTime(), debounce, maxWait) { return nil, nil } } diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index d949ce41fd..da73639a74 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -313,15 +313,87 @@ func TestOllamaCloudUsageSettingsDefaultOffAndValidation(t *testing.T) { require.NoError(t, err) require.False(t, settings.Enabled) require.Equal(t, 60, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) - err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 14}) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 14, DebounceMinutes: 1}) require.Error(t, err) - err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90}) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 0}) + require.Error(t, err) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 61}) + require.Error(t, err) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 2}) require.NoError(t, err) settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background()) require.NoError(t, err) require.True(t, settings.Enabled) require.Equal(t, 90, settings.IntervalMinutes) + require.Equal(t, 2, settings.DebounceMinutes) + + // Legacy JSON without debounce_minutes defaults to 1. + repo.values[SettingKeyOllamaCloudUsageSettings] = `{"enabled":true,"interval_minutes":45}` + settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 45, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) +} + +func TestOllamaCloudUsageIsAutoRefreshDue(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + ptr := func(ts time.Time) *time.Time { return &ts } + + require.True(t, ollamaCloudUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due") + require.True(t, ollamaCloudUsageIsAutoRefreshDue(&OllamaCloudUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due") + + okSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(fetched), + LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait), + } + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed") + require.True(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce") + + // Continuous requests: last used is now, but max-wait from old fetch forces due. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(oldFetched), + LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait), + } + require.True(t, ollamaCloudUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue") + // First request after a very old snapshot is immediately due because fetched+maxWait is past. + require.True(t, ollamaCloudUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate") + + failSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusFailed, FetchedAt: ptr(fetched), + LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute), + } + require.False(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff") + failSnap.NextRefreshAt = now.Add(-time.Second) + require.True(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request") + + require.True(t, ollamaCloudUsageIsAutoRefreshDue(&OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, LastAttemptAt: now, + }, nil, now, debounce, maxWait), "ok without fetched_at fails open") +} + +func TestScheduleOllamaCloudUsageActivityOnlyForOllama(t *testing.T) { + deferred := NewDeferredService(nil, nil, time.Second) + ollama := ollamaUsageAccount(1) + other := ollamaUsageAccount(2) + other.Credentials["base_url"] = "https://api.openai.com" + + scheduleOllamaCloudUsageActivity(deferred, ollama) + scheduleOllamaCloudUsageActivity(deferred, other) + scheduleOllamaCloudUsageActivity(nil, ollama) + + _, ok := deferred.lastUsedUpdates.Load(int64(1)) + require.True(t, ok) + _, ok = deferred.lastUsedUpdates.Load(int64(2)) + require.False(t, ok) } func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index c61f317859..ace8366a0e 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -51,6 +51,10 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(statusCode, responseBody) { return false } + // Any non-2xx upstream HTTP response means the model request was actually sent. + if s != nil { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 2e2fa90f57..4093ca0b64 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -124,6 +124,11 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co return err } + // Transport attempt reached the network path; count as Ollama Cloud activity. + if s != nil { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } + if classifyOpenAITransportError(err).Persistent { s.tempUnscheduleOpenAITransportError(ctx, account, safeErr) } diff --git a/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts b/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts index 1777819cb1..e60643a695 100644 --- a/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts +++ b/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts @@ -38,7 +38,7 @@ describe('admin Ollama Cloud usage API', () => { }) it('uses dedicated global settings endpoints', async () => { - const settings = { enabled: false, interval_minutes: 60 } + const settings = { enabled: false, interval_minutes: 60, debounce_minutes: 1 } get.mockResolvedValueOnce({ data: settings }) put.mockResolvedValueOnce({ data: settings }) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 45b9caca1e..8bc1a39cfc 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -335,11 +335,13 @@ export default { }, ollamaCloudUsage: { title: 'Ollama Cloud Usage Refresh', - description: 'Periodically refresh official Ollama settings-page usage for individually opted-in accounts. Disabled by default.', + description: 'Refresh official Ollama settings-page usage driven by model requests for individually opted-in accounts. Disabled by default. Idle accounts are not polled.', enabled: 'Enable global automatic refresh', - enabledHint: 'Only accounts with a stored browser session and their own automatic refresh switch enabled are refreshed. Manual refresh remains available.', - intervalMinutes: 'Refresh interval (minutes)', - intervalHint: 'Range: 15–1440 minutes. Failures use bounded exponential backoff.', + enabledHint: 'Only accounts with a stored browser session and their own automatic refresh switch enabled are refreshed, and only after subsequent model requests. Manual refresh remains available.', + intervalMinutes: 'Max wait while requests continue (minutes)', + intervalHint: 'Range: 15–1440 minutes. When continuous requests keep sliding the debounce, force a refresh after this wait.', + debounceMinutes: 'Quiet period after last request (minutes)', + debounceHint: 'Range: 1–60 minutes. Refresh after the latest model request has been quiet for this long.', saved: 'Ollama Cloud usage refresh settings saved', saveFailed: 'Failed to save Ollama Cloud usage refresh settings' }, diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 4d7a2b23a1..dccf073c14 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -328,11 +328,13 @@ export default { }, ollamaCloudUsage: { title: 'Ollama Cloud 用量刷新', - description: '定期刷新账号在 Ollama 官方设置页展示的用量;默认关闭。', + description: '在模型请求驱动下刷新账号在 Ollama 官方设置页展示的用量;默认关闭。无新请求时不会自动抓取。', enabled: '启用全局自动刷新', - enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;手动刷新不受影响。', - intervalMinutes: '刷新周期(分钟)', - intervalHint: '范围 15–1440 分钟。失败后按有上限的指数退避重试。', + enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;需有后续模型请求才会触发。手动刷新不受影响。', + intervalMinutes: '持续请求最长等待(分钟)', + intervalHint: '范围 15–1440 分钟。请求持续不断导致 debounce 一直后移时,最晚在此时间强制刷新。', + debounceMinutes: '请求安静等待(分钟)', + debounceHint: '范围 1–60 分钟。最后一次模型请求安静满此时长后再抓取用量。', saved: 'Ollama Cloud 用量刷新设置已保存', saveFailed: '保存 Ollama Cloud 用量刷新设置失败' }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6198d8f832..98baf3c4ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1023,7 +1023,10 @@ export interface OllamaCloudUsageState { export interface OllamaCloudUsageSettings { enabled: boolean + /** Max wait while model requests keep arriving (minutes). */ interval_minutes: number + /** Trailing quiet period after the latest model request (minutes). */ + debounce_minutes: number } export interface Account { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 3f50450b47..120a631740 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4220,23 +4220,43 @@ data-testid="ollama-cloud-usage-global-enabled" /> -
- - -

- {{ t("admin.settings.ollamaCloudUsage.intervalHint") }} -

+
+
+ + +

+ {{ t("admin.settings.ollamaCloudUsage.debounceHint") }} +

+
+
+ + +

+ {{ t("admin.settings.ollamaCloudUsage.intervalHint") }} +

+