feat(ollama): 支持 Cloud 官方用量自动刷新

This commit is contained in:
alfadb
2026-07-23 15:50:44 +08:00
parent 2c76506e07
commit 5ac4a9fac2
47 changed files with 5571 additions and 85 deletions
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, put, del } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { get, post, put, delete: del }
}))
import {
deleteOllamaCloudUsageSession,
getOllamaCloudUsage,
getOllamaCloudUsageSettings,
refreshOllamaCloudUsage,
saveOllamaCloudUsageSession,
setOllamaCloudUsageAutoRefresh,
updateOllamaCloudUsageSettings
} from '@/api/admin/accounts'
const state = {
account_id: 7,
eligible: true,
configured: true,
auto_refresh_enabled: false,
encryption_key_configured: true
}
describe('admin Ollama Cloud usage API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
put.mockReset()
del.mockReset()
})
it('uses dedicated global settings endpoints', async () => {
const settings = { enabled: false, interval_minutes: 60 }
get.mockResolvedValueOnce({ data: settings })
put.mockResolvedValueOnce({ data: settings })
await expect(getOllamaCloudUsageSettings()).resolves.toEqual(settings)
await expect(updateOllamaCloudUsageSettings(settings)).resolves.toEqual(settings)
expect(get).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings')
expect(put).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings', settings)
})
it('keeps session configuration write-only and separate from account updates', async () => {
get.mockResolvedValueOnce({ data: state })
put.mockResolvedValueOnce({ data: state }).mockResolvedValueOnce({ data: state })
del.mockResolvedValueOnce({ data: { ...state, configured: false } })
post.mockResolvedValueOnce({ data: state })
await expect(getOllamaCloudUsage(7)).resolves.toEqual(state)
await expect(saveOllamaCloudUsageSession(7, 'wos-session=secret')).resolves.toEqual(state)
await expect(setOllamaCloudUsageAutoRefresh(7, true)).resolves.toEqual(state)
await expect(refreshOllamaCloudUsage(7)).resolves.toEqual(state)
await expect(deleteOllamaCloudUsageSession(7)).resolves.toMatchObject({ configured: false })
expect(put).toHaveBeenNthCalledWith(1, '/admin/accounts/7/ollama-cloud-usage/session', { session: 'wos-session=secret' })
expect(put).toHaveBeenNthCalledWith(2, '/admin/accounts/7/ollama-cloud-usage/auto-refresh', { enabled: true })
expect(post).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/refresh')
expect(del).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/session')
})
})
+55 -2
View File
@@ -22,7 +22,9 @@ import type {
CheckMixedChannelRequest,
CheckMixedChannelResponse,
UpstreamBillingProbeResult,
UpstreamBillingProbeSettings
UpstreamBillingProbeSettings,
OllamaCloudUsageSettings,
OllamaCloudUsageState
} from '@/types'
/**
@@ -882,6 +884,50 @@ export async function probeUpstreamBillingBatch(accountIds: number[]): Promise<U
return data.results
}
export async function getOllamaCloudUsageSettings(): Promise<OllamaCloudUsageSettings> {
const { data } = await apiClient.get<OllamaCloudUsageSettings>('/admin/accounts/ollama-cloud-usage/settings')
return data
}
export async function updateOllamaCloudUsageSettings(
settings: OllamaCloudUsageSettings
): Promise<OllamaCloudUsageSettings> {
const { data } = await apiClient.put<OllamaCloudUsageSettings>(
'/admin/accounts/ollama-cloud-usage/settings',
settings
)
return data
}
export async function getOllamaCloudUsage(id: number): Promise<OllamaCloudUsageState> {
const { data } = await apiClient.get<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage`)
return data
}
export async function saveOllamaCloudUsageSession(id: number, session: string): Promise<OllamaCloudUsageState> {
const { data } = await apiClient.put<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/session`, {
session
})
return data
}
export async function deleteOllamaCloudUsageSession(id: number): Promise<OllamaCloudUsageState> {
const { data } = await apiClient.delete<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/session`)
return data
}
export async function setOllamaCloudUsageAutoRefresh(id: number, enabled: boolean): Promise<OllamaCloudUsageState> {
const { data } = await apiClient.put<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/auto-refresh`, {
enabled
})
return data
}
export async function refreshOllamaCloudUsage(id: number): Promise<OllamaCloudUsageState> {
const { data } = await apiClient.post<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/refresh`)
return data
}
export const accountsAPI = {
list,
listWithEtag,
@@ -933,7 +979,14 @@ export const accountsAPI = {
updateUpstreamBillingProbeSettings,
setUpstreamBillingProbeEnabled,
probeUpstreamBilling,
probeUpstreamBillingBatch
probeUpstreamBillingBatch,
getOllamaCloudUsageSettings,
updateOllamaCloudUsageSettings,
getOllamaCloudUsage,
saveOllamaCloudUsageSession,
deleteOllamaCloudUsageSession,
setOllamaCloudUsageAutoRefresh,
refreshOllamaCloudUsage
}
export default accountsAPI
@@ -552,6 +552,10 @@
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
<div v-else class="space-y-1">
<OllamaCloudUsageCell
v-if="account.ollama_cloud_usage?.eligible"
:account="account"
/>
<!-- Today stats row (requests, tokens, cost, user_cost) -->
<div
v-if="todayStats"
@@ -609,7 +613,10 @@
/>
<!-- No data at all -->
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
<div
v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota && !account.ollama_cloud_usage?.eligible"
class="text-xs text-gray-400"
>-</div>
</div>
</div>
</template>
@@ -627,6 +634,7 @@ import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
@@ -1635,6 +1635,12 @@
/>
</div>
<OllamaCloudUsageSettings
v-if="account?.ollama_cloud_usage?.eligible"
:account="account"
@updated="handleOllamaCloudUsageUpdated"
/>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
@@ -2598,7 +2604,8 @@ import type {
CheckMixedChannelResponse,
OpenAICompactMode,
OpenAIResponsesMode,
OpenAIEndpointCapability
OpenAIEndpointCapability,
OllamaCloudUsageState
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
@@ -2612,6 +2619,7 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
@@ -2669,6 +2677,10 @@ const authStore = useAuthStore()
// 故隐藏代理选择器。
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => {
if (props.account) emit('updated', { ...props.account, ollama_cloud_usage: state })
}
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
@@ -0,0 +1,35 @@
<template>
<div
v-if="state?.eligible"
class="min-w-0 max-w-full space-y-1"
data-testid="ollama-cloud-usage-cell"
>
<UsageProgressBar
v-if="snapshot?.data?.five_hour"
label="5h"
:utilization="snapshot.data.five_hour.used_percent"
:resets-at="snapshot.data.five_hour.reset_at"
color="indigo"
data-testid="ollama-cloud-five-hour"
/>
<UsageProgressBar
v-if="snapshot?.data?.seven_day"
label="7d"
:utilization="snapshot.data.seven_day.used_percent"
:resets-at="snapshot.data.seven_day.reset_at"
color="emerald"
data-testid="ollama-cloud-seven-day"
/>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Account } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
const props = defineProps<{ account: Account }>()
const state = computed(() => props.account.ollama_cloud_usage)
const snapshot = computed(() => state.value?.snapshot)
</script>
@@ -0,0 +1,270 @@
<template>
<section v-if="state?.eligible" class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600" data-testid="ollama-cloud-usage-settings">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.ollamaCloud.title') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.ollamaCloud.sessionSecurityHint') }}
</p>
</div>
<span
class="whitespace-nowrap rounded px-2 py-1 text-xs font-medium"
:class="state.configured
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'"
>
{{ state.configured ? t('admin.accounts.ollamaCloud.configured') : t('admin.accounts.ollamaCloud.notConfigured') }}
</span>
</div>
<div v-if="loading" class="flex h-20 items-center justify-center text-gray-400">
<Icon name="refresh" size="sm" class="animate-spin" />
</div>
<template v-else>
<div v-if="!state.encryption_key_configured" class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-200">
{{ t('admin.accounts.ollamaCloud.encryptionKeyRequired') }}
</div>
<div
v-if="snapshot"
class="border-y border-gray-100 py-3 dark:border-dark-700"
data-testid="ollama-cloud-usage-details"
>
<div class="grid grid-cols-[minmax(4rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.plan') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.plan || '-' }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.fiveHour') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.five_hour) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.sevenDay') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.seven_day) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.balance') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.balance || '-' }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.models') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ modelSummary }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.status') }}</span>
<span class="break-words font-medium text-gray-900 dark:text-white">{{ statusLabel }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.updatedAt') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ formatDate(snapshot.fetched_at || snapshot.last_attempt_at) }}</span>
</div>
<p v-if="snapshot.last_error" class="mt-2 break-words border-t border-gray-100 pt-2 text-xs text-amber-700 dark:border-dark-700 dark:text-amber-300">
{{ t(`admin.accounts.ollamaCloud.errors.${snapshot.last_error}`, snapshot.last_error) }}
</p>
</div>
<div>
<label class="input-label" for="ollama-cloud-session">{{ t('admin.accounts.ollamaCloud.sessionLabel') }}</label>
<textarea
id="ollama-cloud-session"
v-model="session"
rows="3"
class="input font-mono text-xs"
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
:placeholder="t('admin.accounts.ollamaCloud.sessionPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.ollamaCloud.writeOnlyHint') }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving || !session.trim() || !state.encryption_key_configured"
data-testid="ollama-cloud-session-save"
@click="saveSession"
>
<Icon name="check" size="xs" class="mr-1.5" />
{{ t('common.save') }}
</button>
<button
v-if="state.configured"
type="button"
class="btn btn-secondary btn-sm text-red-600 dark:text-red-400"
:disabled="saving"
data-testid="ollama-cloud-session-delete"
@click="showDeleteConfirm = true"
>
<Icon name="trash" size="xs" class="mr-1.5" />
{{ t('admin.accounts.ollamaCloud.deleteSession') }}
</button>
<button
v-if="state.configured"
type="button"
class="btn btn-secondary btn-sm"
:disabled="refreshing"
data-testid="ollama-cloud-refresh"
@click="refreshUsage"
>
<Icon name="refresh" size="xs" class="mr-1.5" :class="{ 'animate-spin': refreshing }" />
{{ t('admin.accounts.ollamaCloud.refreshNow') }}
</button>
</div>
<div v-if="state.configured" class="flex items-center justify-between gap-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.ollamaCloud.autoRefresh') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.ollamaCloud.autoRefreshHint') }}
</p>
</div>
<Toggle
:model-value="state.auto_refresh_enabled"
:disabled="saving"
data-testid="ollama-cloud-auto-refresh"
@update:model-value="setAutoRefresh"
/>
</div>
</template>
<ConfirmDialog
:show="showDeleteConfirm"
:title="t('admin.accounts.ollamaCloud.deleteSession')"
:message="t('admin.accounts.ollamaCloud.deleteConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="deleteSession"
@cancel="showDeleteConfirm = false"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
import type { Account, OllamaCloudUsageState, OllamaCloudUsageWindow } from '@/types'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ account: Account }>()
const emit = defineEmits<{ updated: [state: OllamaCloudUsageState] }>()
const { t } = useI18n()
const appStore = useAppStore()
const state = ref<OllamaCloudUsageState | null>(props.account.ollama_cloud_usage ?? null)
const session = ref('')
const loading = ref(false)
const saving = ref(false)
const refreshing = ref(false)
const showDeleteConfirm = ref(false)
const snapshot = computed(() => state.value?.snapshot)
const statusLabel = computed(() => {
if (!snapshot.value) return t('admin.accounts.ollamaCloud.notRefreshed')
if (snapshot.value.status === 'unauthorized') return t('admin.accounts.ollamaCloud.unauthorized')
if (snapshot.value.status === 'failed') return t('admin.accounts.ollamaCloud.failed')
return t('admin.accounts.ollamaCloud.ok')
})
const modelSummary = computed(() => snapshot.value?.data?.models?.map(model => {
const window = model.window === 'five_hour'
? t('admin.accounts.ollamaCloud.fiveHourShort')
: t('admin.accounts.ollamaCloud.sevenDayShort')
return `${window} ${model.model}: ${model.requests}`
}).join(', ') || '-')
const formatPercent = (value?: number) => typeof value === 'number' && Number.isFinite(value)
? `${value.toFixed(value % 1 ? 1 : 0)}%`
: '-'
const formatDate = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
const windowSummary = (window?: OllamaCloudUsageWindow) => {
if (!window) return '-'
const reset = window.reset_at ? formatDate(window.reset_at) : window.reset_text
return reset
? t('admin.accounts.ollamaCloud.windowWithReset', { percent: formatPercent(window.used_percent), reset })
: formatPercent(window.used_percent)
}
const applyState = (next: OllamaCloudUsageState) => {
state.value = next
emit('updated', next)
}
const load = async () => {
loading.value = true
try {
applyState(await adminAPI.accounts.getOllamaCloudUsage(props.account.id))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.loadFailed')))
} finally {
loading.value = false
}
}
const saveSession = async () => {
if (!session.value.trim()) return
saving.value = true
try {
applyState(await adminAPI.accounts.saveOllamaCloudUsageSession(props.account.id, session.value))
session.value = ''
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionSaved'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionSaveFailed')))
} finally {
saving.value = false
}
}
const deleteSession = async () => {
saving.value = true
showDeleteConfirm.value = false
try {
applyState(await adminAPI.accounts.deleteOllamaCloudUsageSession(props.account.id))
session.value = ''
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionDeleted'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionDeleteFailed')))
} finally {
saving.value = false
}
}
const setAutoRefresh = async (enabled: boolean) => {
saving.value = true
try {
applyState(await adminAPI.accounts.setOllamaCloudUsageAutoRefresh(props.account.id, enabled))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.autoRefreshFailed')))
} finally {
saving.value = false
}
}
const refreshUsage = async () => {
refreshing.value = true
try {
applyState(await adminAPI.accounts.refreshOllamaCloudUsage(props.account.id))
appStore.showSuccess(t('admin.accounts.ollamaCloud.refreshSuccess'))
} catch (error) {
appStore.showError(extractI18nErrorMessage(
error,
t,
'admin.accounts.ollamaCloud.errors',
t('admin.accounts.ollamaCloud.refreshFailed')
))
} finally {
refreshing.value = false
}
}
watch(() => props.account.id, () => {
state.value = props.account.ollama_cloud_usage ?? null
session.value = ''
if (!state.value) void load()
})
onMounted(() => {
if (!state.value) void load()
})
</script>
@@ -72,6 +72,47 @@ describe('AccountUsageCell', () => {
})
})
it('renders eligible Ollama Cloud state inside the unified usage cell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 9001,
platform: 'openai',
type: 'apikey',
ollama_cloud_usage: {
account_id: 9001,
eligible: true,
configured: true,
auto_refresh_enabled: true,
encryption_key_configured: true,
snapshot: {
status: 'ok',
last_attempt_at: '2026-07-23T00:00:00Z',
next_refresh_at: '2026-07-23T01:00:00Z',
data: {
five_hour: { used_percent: 12 },
seven_day: { used_percent: 34 }
}
}
}
})
},
global: {
stubs: {
OllamaCloudUsageCell: {
props: ['account'],
template: '<div data-test="embedded-ollama">{{ account.ollama_cloud_usage.snapshot.data.five_hour.used_percent }}</div>'
},
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
expect(wrapper.get('[data-test="embedded-ollama"]').text()).toBe('12')
expect(getUsage).not.toHaveBeenCalled()
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
@@ -0,0 +1,104 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OllamaCloudUsageCell from '../OllamaCloudUsageCell.vue'
import UsageProgressBar from '../UsageProgressBar.vue'
import type { Account, OllamaCloudUsageState } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const usageState = (): OllamaCloudUsageState => ({
account_id: 7,
eligible: true,
configured: true,
auto_refresh_enabled: false,
encryption_key_configured: true,
snapshot: {
status: 'ok',
fetched_at: '2026-07-22T12:00:00Z',
last_attempt_at: '2026-07-22T12:00:00Z',
next_refresh_at: '2026-07-22T13:00:00Z',
data: {
plan: 'max',
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
balance: '$0',
models: [
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
]
}
}
})
const account = (state = usageState()): Account => ({
id: 7,
name: 'ollama',
platform: 'openai',
type: 'apikey',
ollama_cloud_usage: state,
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-07-22T00:00:00Z',
updated_at: '2026-07-22T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null
})
describe('OllamaCloudUsageCell', () => {
it('renders only native 5h and 7d windows in a shrinkable mobile-safe cell', () => {
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
const cell = wrapper.get('[data-testid="ollama-cloud-usage-cell"]')
expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
expect(cell.classes()).not.toContain('min-w-[12rem]')
const bars = wrapper.findAllComponents(UsageProgressBar)
expect(bars).toHaveLength(2)
expect(bars[0].props()).toMatchObject({
label: '5h',
utilization: 5.6,
resetsAt: '2026-07-23T03:00:00Z'
})
expect(bars[1].props()).toMatchObject({
label: '7d',
utilization: 14.2,
resetsAt: '2026-07-29T00:00:00Z'
})
expect(wrapper.find('[data-testid="ollama-cloud-usage-details"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="ollama-cloud-usage-refresh"]').exists()).toBe(false)
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).not.toContain('max')
expect(wrapper.text()).not.toContain('$0')
expect(wrapper.text()).not.toContain('gpt-oss:120b-cloud')
})
it('reacts to an account snapshot update without a list-cell refresh action', async () => {
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
const next = usageState()
next.snapshot!.data!.five_hour!.used_percent = 43
await wrapper.setProps({ account: account(next) })
expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43)
expect(wrapper.findAll('button')).toHaveLength(0)
})
})
@@ -0,0 +1,175 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OllamaCloudUsageSettings from '../OllamaCloudUsageSettings.vue'
import type { Account, OllamaCloudUsageState } from '@/types'
const api = vi.hoisted(() => ({
getOllamaCloudUsage: vi.fn(),
saveOllamaCloudUsageSession: vi.fn(),
deleteOllamaCloudUsageSession: vi.fn(),
setOllamaCloudUsageAutoRefresh: vi.fn(),
refreshOllamaCloudUsage: vi.fn()
}))
const notifications = vi.hoisted(() => ({ showSuccess: vi.fn(), showError: vi.fn() }))
vi.mock('@/api/admin', () => ({ adminAPI: { accounts: api } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => notifications }))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'admin.accounts.ollamaCloud.errors.OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED') {
return `retry in ${params?.retry_after_seconds} seconds`
}
if (key === 'admin.accounts.ollamaCloud.fiveHourShort') return '5h'
if (key === 'admin.accounts.ollamaCloud.sevenDayShort') return '7d'
return key
}
})
}
})
const state = (overrides: Partial<OllamaCloudUsageState> = {}): OllamaCloudUsageState => ({
account_id: 7,
eligible: true,
configured: false,
auto_refresh_enabled: false,
encryption_key_configured: true,
...overrides
})
const detailedState = (plan = 'max'): OllamaCloudUsageState => state({
configured: true,
snapshot: {
status: 'ok',
fetched_at: '2026-07-22T12:00:00Z',
last_attempt_at: '2026-07-22T12:00:00Z',
next_refresh_at: '2026-07-22T13:00:00Z',
data: {
plan,
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
balance: '$0',
models: [
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
]
}
}
})
const account = (usage: OllamaCloudUsageState = state()): Account => ({
id: 7,
name: 'ollama',
platform: 'anthropic',
type: 'apikey',
ollama_cloud_usage: usage,
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-07-22T00:00:00Z',
updated_at: '2026-07-22T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null
})
describe('OllamaCloudUsageSettings', () => {
beforeEach(() => {
Object.values(api).forEach(mock => mock.mockReset())
notifications.showSuccess.mockReset()
notifications.showError.mockReset()
})
it('uses the existing account state without an immediate duplicate GET', async () => {
api.saveOllamaCloudUsageSession.mockResolvedValueOnce(state({ configured: true }))
const wrapper = mount(OllamaCloudUsageSettings, { props: { account: account() } })
await flushPromises()
const input = wrapper.get('#ollama-cloud-session')
await input.setValue('wos-session=browser-secret')
await wrapper.get('[data-testid="ollama-cloud-session-save"]').trigger('click')
await flushPromises()
expect(api.getOllamaCloudUsage).not.toHaveBeenCalled()
expect(api.saveOllamaCloudUsageSession).toHaveBeenCalledWith(7, 'wos-session=browser-secret')
expect((input.element as HTMLTextAreaElement).value).toBe('')
expect(wrapper.text()).not.toContain('browser-secret')
expect(wrapper.emitted('updated')?.at(-1)?.[0]).toMatchObject({ configured: true })
})
it('fails closed when the persistent encryption key is unavailable', async () => {
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(state({ encryption_key_configured: false })) }
})
await flushPromises()
await wrapper.get('#ollama-cloud-session').setValue('wos-session=secret')
expect(wrapper.get('[data-testid="ollama-cloud-session-save"]').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('admin.accounts.ollamaCloud.encryptionKeyRequired')
expect(api.saveOllamaCloudUsageSession).not.toHaveBeenCalled()
})
it('updates the account-level automatic refresh switch through its dedicated endpoint', async () => {
api.setOllamaCloudUsageAutoRefresh.mockResolvedValueOnce(state({ configured: true, auto_refresh_enabled: true }))
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(state({ configured: true })) }
})
await flushPromises()
await wrapper.get('[data-testid="ollama-cloud-auto-refresh"]').trigger('click')
await flushPromises()
expect(api.setOllamaCloudUsageAutoRefresh).toHaveBeenCalledWith(7, true)
})
it('keeps plan, balance, model, status, and manual refresh details in the edit settings', async () => {
api.refreshOllamaCloudUsage.mockResolvedValueOnce(detailedState('pro'))
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(detailedState()) }
})
await flushPromises()
const details = wrapper.get('[data-testid="ollama-cloud-usage-details"]')
expect(details.text()).toContain('max')
expect(details.text()).toContain('$0')
expect(details.text()).toContain('5h gpt-oss:120b-cloud: 2')
expect(details.text()).toContain('7d gpt-oss:120b-cloud: 12')
expect(details.text()).toContain('admin.accounts.ollamaCloud.ok')
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
await flushPromises()
expect(api.refreshOllamaCloudUsage).toHaveBeenCalledWith(7)
expect(wrapper.get('[data-testid="ollama-cloud-usage-details"]').text()).toContain('pro')
expect(notifications.showSuccess).toHaveBeenCalled()
})
it('shows the structured manual refresh limit from the edit settings', async () => {
api.refreshOllamaCloudUsage.mockRejectedValueOnce({
status: 429,
reason: 'OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED',
metadata: { retry_after_seconds: '18' }
})
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(detailedState()) }
})
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
await flushPromises()
expect(notifications.showError).toHaveBeenCalledWith('retry in 18 seconds')
})
})
+3 -2
View File
@@ -70,12 +70,13 @@
<div
v-for="column in dataColumns"
:key="column.key"
class="flex items-start justify-between gap-4"
:data-field="column.key"
class="flex min-w-0 items-start justify-between gap-4"
>
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
{{ column.label }}
</span>
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
<div class="min-w-0 max-w-full text-right text-sm text-gray-900 dark:text-gray-100">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
@@ -309,6 +309,35 @@ describe('DataTable', () => {
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
})
it('keeps the single usage field shrinkable in a 320px mobile card', () => {
stubMobileMatchMedia()
const viewport = document.createElement('div')
viewport.style.width = '320px'
document.body.appendChild(viewport)
const wrapper = mount(DataTable, {
attachTo: viewport,
props: {
columns: [{ key: 'usage', label: 'Usage' }],
data: [{ id: 1, usage: 'snapshot' }],
rowKey: 'id'
},
slots: {
'cell-usage': '<div data-test="usage-cell">snapshot</div>'
}
})
expect(viewport.style.width).toBe('320px')
expect(wrapper.findAll('[data-field="usage"]')).toHaveLength(1)
expect(wrapper.find('[data-field="ollama_cloud_usage"]').exists()).toBe(false)
const field = wrapper.get('[data-field="usage"]')
expect(field.classes()).toContain('min-w-0')
expect(field.get('div').classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
expect(wrapper.findAll('[data-test="usage-cell"]')).toHaveLength(1)
wrapper.unmount()
viewport.remove()
})
it('offers current-page select all in the mobile card layout', async () => {
stubMobileMatchMedia()
const wrapper = mount(DataTable, {
@@ -173,6 +173,55 @@ export default {
hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, billing rate, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.'
},
usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.',
ollamaCloud: {
title: 'Ollama Cloud usage',
sessionSecurityHint: 'The browser session is encrypted at rest and sent only to the fixed official settings URL.',
configured: 'Configured',
notConfigured: 'Not configured',
notRefreshed: 'Not refreshed',
encryptionKeyRequired: 'Set a persistent TOTP_ENCRYPTION_KEY before storing a browser session.',
sessionLabel: 'Ollama browser Cookie',
sessionPlaceholder: 'wos-session=...; __Secure-authjs.session-token.0=...',
writeOnlyHint: 'Write-only. The saved value cannot be viewed and an empty value never replaces it.',
deleteSession: 'Delete session',
deleteConfirm: 'Delete the stored Ollama browser session and its usage snapshot?',
refreshNow: 'Refresh usage',
autoRefresh: 'Automatic usage refresh',
autoRefreshHint: 'Runs only when the account switch and the global switch are both enabled.',
plan: 'Plan',
fiveHour: '5 hour',
fiveHourShort: '5h',
sevenDay: '7 day',
sevenDayShort: '7d',
balance: 'Balance',
models: 'Models',
status: 'Status',
updatedAt: 'Updated',
ok: 'Current',
unauthorized: 'Session expired',
failed: 'Refresh failed',
windowWithReset: '{percent} used, resets {reset}',
loadFailed: 'Failed to load Ollama Cloud usage settings',
sessionSaved: 'Ollama browser session saved',
sessionSaveFailed: 'Failed to save Ollama browser session',
sessionDeleted: 'Ollama browser session deleted',
sessionDeleteFailed: 'Failed to delete Ollama browser session',
autoRefreshFailed: 'Failed to update automatic usage refresh',
refreshSuccess: 'Ollama Cloud usage refreshed',
refreshFailed: 'Failed to refresh Ollama Cloud usage',
errors: {
request_failed: 'Request failed',
empty_response: 'Empty response',
response_host_mismatch: 'Unexpected response host',
redirect_blocked: 'Official settings redirected the request',
unauthorized: 'Browser session expired',
http_error: 'Official settings returned an error',
response_read_failed: 'Failed to read the response',
response_too_large: 'Settings page exceeded the response limit',
invalid_html: 'Settings page format was not recognized',
OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.'
}
},
upstreamBilling: {
trustWarning: 'This rate is declared by the upstream site for the current API key. Sub2API cannot verify that it matches actual charges. The upstream site or an intermediary may return forged, stale, or modified data. Verify it against bills, balance changes, and actual usage.',
autoProbe: 'Automatically probe upstream declared rate',
@@ -333,6 +333,16 @@ export default {
saved: 'Upstream rate auto detection settings saved',
saveFailed: 'Failed to save upstream rate auto detection settings'
},
ollamaCloudUsage: {
title: 'Ollama Cloud Usage Refresh',
description: 'Periodically refresh official Ollama settings-page usage for individually opted-in accounts. Disabled by default.',
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.',
saved: 'Ollama Cloud usage refresh settings saved',
saveFailed: 'Failed to save Ollama Cloud usage refresh settings'
},
gatewayForwarding: {
title: 'Request Forwarding',
description: 'Control how requests are forwarded to upstream OAuth accounts',
@@ -126,6 +126,55 @@ export default {
hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量、计费倍率等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。'
},
usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。',
ollamaCloud: {
title: 'Ollama Cloud 用量',
sessionSecurityHint: '浏览器会话会加密落库,且只发送到固定的 Ollama 官方设置页。',
configured: '已配置',
notConfigured: '未配置',
notRefreshed: '尚未刷新',
encryptionKeyRequired: '请先配置持久 TOTP_ENCRYPTION_KEY,再保存浏览器会话。',
sessionLabel: 'Ollama 浏览器 Cookie',
sessionPlaceholder: 'wos-session=...; __Secure-authjs.session-token.0=...',
writeOnlyHint: '仅写入。已保存内容不可查看,留空不会覆盖。',
deleteSession: '删除会话',
deleteConfirm: '确定删除已保存的 Ollama 浏览器会话及其用量快照?',
refreshNow: '刷新用量',
autoRefresh: '自动刷新用量',
autoRefreshHint: '只有账号开关和全局开关同时启用时才会定时刷新。',
plan: '套餐',
fiveHour: '5 小时',
fiveHourShort: '5h',
sevenDay: '7 天',
sevenDayShort: '7d',
balance: '余额',
models: '模型',
status: '状态',
updatedAt: '更新时间',
ok: '正常',
unauthorized: '会话已过期',
failed: '刷新失败',
windowWithReset: '已用 {percent},{reset} 重置',
loadFailed: '加载 Ollama Cloud 用量设置失败',
sessionSaved: 'Ollama 浏览器会话已保存',
sessionSaveFailed: '保存 Ollama 浏览器会话失败',
sessionDeleted: 'Ollama 浏览器会话已删除',
sessionDeleteFailed: '删除 Ollama 浏览器会话失败',
autoRefreshFailed: '更新自动刷新设置失败',
refreshSuccess: 'Ollama Cloud 用量已刷新',
refreshFailed: '刷新 Ollama Cloud 用量失败',
errors: {
request_failed: '请求失败',
empty_response: '响应为空',
response_host_mismatch: '响应主机不符合安全边界',
redirect_blocked: '官方设置页发生重定向',
unauthorized: '浏览器会话已过期',
http_error: '官方设置页返回错误',
response_read_failed: '读取响应失败',
response_too_large: '设置页超过响应大小限制',
invalid_html: '无法识别设置页格式',
OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。'
}
},
upstreamBilling: {
trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。',
autoProbe: '自动探测上游声明倍率',
@@ -326,6 +326,16 @@ export default {
saved: '上游倍率自动探测设置已保存',
saveFailed: '保存上游倍率自动探测设置失败'
},
ollamaCloudUsage: {
title: 'Ollama Cloud 用量刷新',
description: '定期刷新账号在 Ollama 官方设置页展示的用量;默认关闭。',
enabled: '启用全局自动刷新',
enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;手动刷新不受影响。',
intervalMinutes: '刷新周期(分钟)',
intervalHint: '范围 15–1440 分钟。失败后按有上限的指数退避重试。',
saved: 'Ollama Cloud 用量刷新设置已保存',
saveFailed: '保存 Ollama Cloud 用量刷新设置失败'
},
gatewayForwarding: {
title: '请求转发行为',
description: '控制请求转发到上游 OAuth 账号时的行为',
+48
View File
@@ -975,6 +975,53 @@ export interface UpstreamBillingProbeResult {
error?: string
}
export type OllamaCloudUsageStatus = 'ok' | 'unauthorized' | 'failed'
export interface OllamaCloudUsageWindow {
used_percent: number
reset_at?: string
reset_text?: string
}
export interface OllamaCloudUsageModel {
model: string
window: 'five_hour' | 'seven_day'
requests: number
}
export interface OllamaCloudUsageData {
plan?: string
five_hour?: OllamaCloudUsageWindow
seven_day?: OllamaCloudUsageWindow
balance?: string
models?: OllamaCloudUsageModel[]
}
export interface OllamaCloudUsageSnapshot {
status: OllamaCloudUsageStatus
data?: OllamaCloudUsageData
fetched_at?: string
last_attempt_at: string
next_refresh_at: string
failure_count?: number
http_status?: number
last_error?: string
}
export interface OllamaCloudUsageState {
account_id: number
eligible: boolean
configured: boolean
auto_refresh_enabled: boolean
encryption_key_configured: boolean
snapshot?: OllamaCloudUsageSnapshot
}
export interface OllamaCloudUsageSettings {
enabled: boolean
interval_minutes: number
}
export interface Account {
id: number
name: string
@@ -987,6 +1034,7 @@ export interface Account {
// 改为通过 credentials_status.has_<key> 暴露存在性。
credentials?: Record<string, unknown>
credentials_status?: Record<string, boolean>
ollama_cloud_usage?: OllamaCloudUsageState
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
extra?: (CodexUsageSnapshot & OpenAICompactState & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
+1 -1
View File
@@ -1407,8 +1407,8 @@ const allColumns = computed(() => {
if (!authStore.isSimpleMode) {
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
}
c.push({ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false })
c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
+103
View File
@@ -4189,6 +4189,70 @@
</div>
</div>
<!-- Ollama Cloud Usage Settings -->
<div class="card" data-testid="ollama-cloud-usage-global-settings">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.ollamaCloudUsage.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.ollamaCloudUsage.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div v-if="ollamaCloudUsageLoading" class="flex items-center gap-2 text-gray-500">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
{{ t("common.loading") }}
</div>
<template v-else>
<div class="flex items-center justify-between gap-4">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.ollamaCloudUsage.enabled") }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.ollamaCloudUsage.enabledHint") }}
</p>
</div>
<Toggle
v-model="ollamaCloudUsageForm.enabled"
:aria-label="t('admin.settings.ollamaCloudUsage.enabled')"
data-testid="ollama-cloud-usage-global-enabled"
/>
</div>
<div v-if="ollamaCloudUsageForm.enabled" class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="ollama-cloud-usage-interval">
{{ t("admin.settings.ollamaCloudUsage.intervalMinutes") }}
</label>
<input
id="ollama-cloud-usage-interval"
v-model.number="ollamaCloudUsageForm.interval_minutes"
type="number"
min="15"
max="1440"
class="input w-32"
data-testid="ollama-cloud-usage-global-interval"
@keydown.enter.prevent="saveOllamaCloudUsageSettings"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.ollamaCloudUsage.intervalHint") }}
</p>
</div>
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="ollamaCloudUsageSaving"
data-testid="ollama-cloud-usage-global-save"
@click="saveOllamaCloudUsageSettings"
>
{{ ollamaCloudUsageSaving ? t("common.saving") : t("common.save") }}
</button>
</div>
</template>
</div>
</div>
<!-- Gateway Scheduling Settings -->
<div class="card">
<div
@@ -7865,6 +7929,13 @@ const upstreamBillingProbeForm = reactive({
interval_minutes: 30,
});
const ollamaCloudUsageLoading = ref(true);
const ollamaCloudUsageSaving = ref(false);
const ollamaCloudUsageForm = reactive({
enabled: false,
interval_minutes: 60,
});
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true);
const overloadCooldownSaving = ref(false);
@@ -10481,6 +10552,37 @@ async function saveUpstreamBillingProbeSettings() {
}
}
async function loadOllamaCloudUsageSettings() {
ollamaCloudUsageLoading.value = true;
try {
Object.assign(
ollamaCloudUsageForm,
await adminAPI.accounts.getOllamaCloudUsageSettings(),
);
} catch (_error: unknown) {
// Keep the fail-safe disabled defaults when this optional setting cannot be loaded.
} finally {
ollamaCloudUsageLoading.value = false;
}
}
async function saveOllamaCloudUsageSettings() {
ollamaCloudUsageSaving.value = true;
try {
const updated = await adminAPI.accounts.updateOllamaCloudUsageSettings({
...ollamaCloudUsageForm,
});
Object.assign(ollamaCloudUsageForm, updated);
appStore.showSuccess(t("admin.settings.ollamaCloudUsage.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.ollamaCloudUsage.saveFailed")),
);
} finally {
ollamaCloudUsageSaving.value = false;
}
}
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true;
@@ -11178,6 +11280,7 @@ onMounted(() => {
loadSubscriptionGroups();
loadAdminApiKey();
loadUpstreamBillingProbeSettings();
loadOllamaCloudUsageSettings();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadStreamTimeoutSettings();
@@ -166,6 +166,16 @@ describe('admin AccountsView usage windows hint', () => {
expect(hint.text()).toBe('admin.accounts.usageWindowsHint')
})
it('keeps Ollama Cloud in the single usage column and ignores legacy column preferences', async () => {
localStorage.setItem('account-hidden-columns', JSON.stringify(['ollama_cloud_usage']))
const wrapper = mountView()
await flushPromises()
const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string }>
expect(columns.filter(column => column.key === 'usage')).toHaveLength(1)
expect(columns.some(column => column.key === 'ollama_cloud_usage')).toBe(false)
})
it('renders the upstream billing trust warning next to the declared-rate column', async () => {
const wrapper = mountView()
await flushPromises()
@@ -18,6 +18,8 @@ const {
getBetaPolicySettings,
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
getOllamaCloudUsageSettings,
updateOllamaCloudUsageSettings,
getGroups,
listProxies,
getProviders,
@@ -45,6 +47,11 @@ const {
interval_minutes: 30,
}),
updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
getOllamaCloudUsageSettings: vi.fn().mockResolvedValue({
enabled: false,
interval_minutes: 60,
}),
updateOllamaCloudUsageSettings: vi.fn().mockImplementation(async (payload) => payload),
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
@@ -77,6 +84,8 @@ vi.mock("@/api", () => ({
accounts: {
getUpstreamBillingProbeSettings,
updateUpstreamBillingProbeSettings,
getOllamaCloudUsageSettings,
updateOllamaCloudUsageSettings,
},
groups: {
getAll: getGroups,
@@ -573,6 +582,8 @@ describe("admin SettingsView payment visible method controls", () => {
getBetaPolicySettings.mockReset();
getUpstreamBillingProbeSettings.mockReset();
updateUpstreamBillingProbeSettings.mockReset();
getOllamaCloudUsageSettings.mockReset();
updateOllamaCloudUsageSettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
@@ -633,6 +644,11 @@ describe("admin SettingsView payment visible method controls", () => {
interval_minutes: 30,
});
updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
getOllamaCloudUsageSettings.mockResolvedValue({
enabled: false,
interval_minutes: 60,
});
updateOllamaCloudUsageSettings.mockImplementation(async (payload) => payload);
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
@@ -976,6 +992,31 @@ describe("admin SettingsView payment visible method controls", () => {
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
});
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
const wrapper = mountView();
await flushPromises();
await openGatewayTab(wrapper);
const card = wrapper.get('[data-testid="ollama-cloud-usage-global-settings"]');
expect(card.isVisible()).toBe(true);
expect(
(card.get('[data-testid="ollama-cloud-usage-global-enabled"]').element as HTMLInputElement)
.checked,
).toBe(false);
expect(card.find('[data-testid="ollama-cloud-usage-global-interval"]').exists()).toBe(false);
await card.get('[data-testid="ollama-cloud-usage-global-enabled"]').setValue(true);
await card.get('[data-testid="ollama-cloud-usage-global-interval"]').setValue(90);
await card.get('[data-testid="ollama-cloud-usage-global-save"]').trigger("click");
await flushPromises();
expect(updateOllamaCloudUsageSettings).toHaveBeenCalledWith({
enabled: true,
interval_minutes: 90,
});
});
it("places and explains rate controls for both scheduling modes", async () => {
const wrapper = mountView();