merge: resolve conflicts with upstream/main

Keep model-scoped reasoning effort mappings together with the
deny-or-downgrade ceiling control that landed on main.
This commit is contained in:
刘佳林
2026-09-02 10:01:42 +08:00
179 changed files with 6415 additions and 1152 deletions
+3
View File
@@ -16,6 +16,7 @@ export interface PricingInterval {
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_write_1h_price?: number | null
cache_read_price: number | null
input_multiplier: number | null
output_multiplier: number | null
@@ -45,6 +46,7 @@ export interface ChannelModelPricing {
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_write_1h_price?: number | null
cache_read_price: number | null
fast_multiplier?: number | null
flex_multiplier?: number | null
@@ -173,6 +175,7 @@ export interface ModelDefaultPricing {
input_price?: number // per-token price
output_price?: number
cache_write_price?: number
cache_write_1h_price?: number | null
cache_read_price?: number
image_input_price?: number
image_output_price?: number
+2
View File
@@ -29,6 +29,7 @@ export interface UserPricingInterval {
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_write_1h_price?: number | null
cache_read_price: number | null
per_request_price: number | null
}
@@ -38,6 +39,7 @@ export interface UserSupportedModelPricing {
input_price: number | null
output_price: number | null
cache_write_price: number | null
cache_write_1h_price?: number | null
cache_read_price: number | null
image_input_price: number | null
image_output_price: number | null
@@ -224,6 +224,7 @@ const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
const formatScopeName = (scope: string): string => {
const aliases: Record<string, string> = {
// Claude 系列
'claude-fable-5-1': 'CFable51',
'claude-fable-5': 'CFable5',
'claude-opus-4-6': 'COpus46',
'claude-opus-4-6-thinking': 'COpus46T',
@@ -859,6 +859,7 @@ const antigravity3ImageUsageFromAPI = computed(() =>
// Claude from API (all Claude model variants)
const antigravityClaudeUsageFromAPI = computed(() =>
getAntigravityUsageFromAPI([
'claude-fable-5-1',
'claude-fable-5',
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-6-thinking',
@@ -1319,7 +1319,7 @@
/>
</div>
</div>
<p v-if="form.platform !== 'deepseek'" class="input-hint">
<p v-if="!cnSupportsNativeResponses(form.platform)" class="input-hint">
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
</p>
</div>
@@ -3814,6 +3814,7 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
isHeaderOverrideCapable,
@@ -4006,7 +4007,7 @@ const upstreamBillingAutoProbeEnabled = ref(true)
// ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ──
const accountMode = ref<CnAccountMode>('payg')
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。
// responses=deepseek / kimi 原生 Responses 端点(Codex)。与账号类型正交。
const apiProtocol = ref<CnApiProtocol>('adaptive')
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
const zhipuOrganization = ref('')
@@ -4027,14 +4028,14 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
}
return 'kimi'
})
// 当前平台可选的协议档(responses 仅 deepseek)。
// 当前平台可选的协议档(responses 仅 deepseek / kimi)。
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
{ value: 'adaptive', labelKey: 'adaptive' },
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (form.platform === 'deepseek') {
if (cnSupportsNativeResponses(form.platform)) {
opts.push({ value: 'responses', labelKey: 'responses' })
}
return opts
@@ -4044,7 +4045,7 @@ const cnAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol; l
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (form.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' })
if (cnSupportsNativeResponses(form.platform)) opts.push({ value: 'responses', labelKey: 'responses' })
return opts
})
@@ -72,7 +72,7 @@
<input v-model="editAdaptiveBaseUrls[item.value]" type="text" class="input" />
</div>
</div>
<p v-if="account.platform !== 'deepseek'" class="input-hint">
<p v-if="!cnSupportsNativeResponses(account.platform)" class="input-hint">
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
</p>
</div>
@@ -2909,6 +2909,7 @@ import {
isHeaderOverrideCapable,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
@@ -3057,7 +3058,7 @@ const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: strin
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (props.account?.platform === 'deepseek') {
if (cnSupportsNativeResponses(props.account?.platform ?? '')) {
opts.push({ value: 'responses', labelKey: 'responses' })
}
return opts
@@ -3067,7 +3068,7 @@ const editAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol;
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (props.account?.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' })
if (cnSupportsNativeResponses(props.account?.platform ?? '')) opts.push({ value: 'responses', labelKey: 'responses' })
return opts
})
watch(editApiProtocol, (protocol, previousProtocol) => {
@@ -3947,7 +3948,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
storedProtocol === 'responses'
? storedProtocol
: 'chat_completions'
if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') {
if (!cnSupportsNativeResponses(newAccount.platform) && editApiProtocol.value === 'responses') {
editApiProtocol.value = 'chat_completions'
}
const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value)
@@ -375,7 +375,31 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
base_url: 'https://api.moonshot.cn/v1',
api_base_urls: {
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic'
anthropic: 'https://api.moonshot.cn/anthropic',
responses: 'https://api.moonshot.cn/v1'
}
})
})
it('submits adaptive Kimi Coding Plan Responses endpoint', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
await selectButtonByText(wrapper, 'admin.accounts.cnProviders.accountMode.coding')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi coding')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi-coding')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'coding',
api_protocol: 'adaptive',
base_url: 'https://api.kimi.com/coding/v1',
api_base_urls: {
chat_completions: 'https://api.kimi.com/coding/v1',
anthropic: 'https://api.kimi.com/coding',
responses: 'https://api.kimi.com/coding/v1'
}
})
})
@@ -355,6 +355,39 @@ describe('EditAccountModal', () => {
})
})
it('preserves adaptive Kimi Responses endpoint on submit', async () => {
const account = buildAccount()
account.platform = 'kimi'
account.credentials = {
api_key: 'sk-kimi',
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.moonshot.cn/v1',
api_base_urls: {
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic',
responses: 'https://api.moonshot.cn/v1'
}
}
updateAccountMock.mockReset().mockResolvedValue(account)
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.moonshot.cn/v1',
api_base_urls: {
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic',
responses: 'https://api.moonshot.cn/v1'
}
})
})
it('preserves adaptive GLM endpoints on submit', async () => {
const account = buildAccount()
account.platform = 'zhipu'
@@ -1,18 +1,27 @@
import { describe, expect, it } from 'vitest'
import { defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
import { cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
describe('cnSupportsNativeResponses', () => {
it('is true for DeepSeek and Kimi only', () => {
expect(cnSupportsNativeResponses('deepseek')).toBe(true)
expect(cnSupportsNativeResponses('kimi')).toBe(true)
expect(cnSupportsNativeResponses('zhipu')).toBe(false)
expect(cnSupportsNativeResponses('openai')).toBe(false)
})
})
describe('defaultCNAdaptiveBaseUrls', () => {
it('resolves Kimi endpoints by account mode', () => {
expect(defaultCNAdaptiveBaseUrls('kimi', 'payg')).toEqual({
chat_completions: 'https://api.moonshot.cn/v1',
anthropic: 'https://api.moonshot.cn/anthropic',
responses: ''
responses: 'https://api.moonshot.cn/v1'
})
expect(defaultCNAdaptiveBaseUrls('kimi', 'coding')).toEqual({
chat_completions: 'https://api.kimi.com/coding/v1',
anthropic: 'https://api.kimi.com/coding',
responses: ''
responses: 'https://api.kimi.com/coding/v1'
})
})
@@ -256,10 +256,15 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
export type CnAccountMode = 'payg' | 'coding'
/** 仅 deepseek 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
/** deepseek / kimi 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses'
export type CnNativeApiProtocol = Exclude<CnApiProtocol, 'adaptive'>
/** DeepSeek 与 Kimi(按量付费 / Coding Plan)提供原生 Responses 端点。 */
export function cnSupportsNativeResponses(platform: string): boolean {
return platform === 'deepseek' || platform === 'kimi'
}
export interface CnBaseUrlPreset {
mode: CnAccountMode
protocol: CnApiProtocol
@@ -273,8 +278,10 @@ export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUr
kimi: [
{ mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' },
{ mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' },
{ mode: 'payg', protocol: 'responses', label: 'Moonshot Responses', url: 'https://api.moonshot.cn/v1' },
{ mode: 'coding', protocol: 'chat_completions', label: 'Kimi For Coding', url: 'https://api.kimi.com/coding/v1' },
{ mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' }
{ mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' },
{ mode: 'coding', protocol: 'responses', label: 'Kimi Coding Responses', url: 'https://api.kimi.com/coding/v1' }
],
zhipu: [
{ mode: 'payg', protocol: 'chat_completions', label: 'GLM PaaS', url: 'https://open.bigmodel.cn/api/paas/v4' },
@@ -307,7 +314,7 @@ export function defaultCNBaseUrl(
return ''
}
}
// responses 仅 deepseek:base 与 chat_completions 相同(端点路径差异由后端处理)。
// responses:Kimi / DeepSeek 的 base 与 chat_completions 相同(端点路径差异由后端处理)。
switch (platform) {
case 'kimi':
return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1'
@@ -330,7 +337,7 @@ export function defaultCNAdaptiveBaseUrls(
return {
chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'),
responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : ''
responses: cnSupportsNativeResponses(platform) ? defaultCNBaseUrl(platform, mode, 'responses') : ''
}
}
@@ -3,7 +3,7 @@
:class="isEmpty ? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-950/20' : 'border-gray-200 bg-white dark:border-dark-500 dark:bg-dark-700'">
<!-- Token mode: context range + prices ($/MTok) -->
<template v-if="mode === 'token'">
<div class="grid min-w-0 flex-1 grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-6">
<div class="grid min-w-0 flex-1 grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-7">
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
@@ -25,10 +25,15 @@
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePriceShort') }} <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWrite5mPriceShort') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWrite1hPriceShort') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_write_1h_price" @input="emitField('cache_write_1h_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPriceShort') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
@@ -117,6 +122,7 @@ const isEmpty = computed(() => {
return (iv.input_price == null || iv.input_price === '') &&
(iv.output_price == null || iv.output_price === '') &&
(iv.cache_write_price == null || iv.cache_write_price === '') &&
(iv.cache_write_1h_price == null || iv.cache_write_1h_price === '') &&
(iv.cache_read_price == null || iv.cache_read_price === '') &&
(iv.input_multiplier == null || iv.input_multiplier === '') &&
(iv.output_multiplier == null || iv.output_multiplier === '') &&
@@ -106,7 +106,7 @@
{{ t('admin.channels.form.defaultPrices') }}
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
</label>
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-7">
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }}</label>
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
@@ -118,10 +118,15 @@
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWrite5mPrice') }}</label>
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWrite1hPrice') }}</label>
<input :value="entry.cache_write_1h_price" @input="emitField('cache_write_1h_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice') }}</label>
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
@@ -312,6 +317,7 @@ function addInterval() {
intervals.push({
min_tokens: 0, max_tokens: null, tier_label: '',
input_price: null, output_price: null, cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null, per_request_price: null,
input_multiplier: null, output_multiplier: null,
cache_write_multiplier: null, cache_read_multiplier: null,
@@ -328,6 +334,7 @@ function addMediaTier() {
intervals.push({
min_tokens: 0, max_tokens: null, tier_label: labels[intervals.length] || '',
input_price: null, output_price: null, cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null, per_request_price: null,
input_multiplier: null, output_multiplier: null,
cache_write_multiplier: null, cache_read_multiplier: null,
@@ -359,7 +366,7 @@ async function onModelsUpdate(newModels: string[]) {
// 检查是否所有价格字段都为空
const e = props.entry
const hasPrice = e.input_price != null || e.output_price != null ||
e.cache_write_price != null || e.cache_read_price != null
e.cache_write_price != null || e.cache_write_1h_price != null || e.cache_read_price != null
if (hasPrice) return
// 查询第一个新增模型的默认价格
@@ -372,6 +379,7 @@ async function onModelsUpdate(newModels: string[]) {
input_price: perTokenToMTok(result.input_price ?? null),
output_price: perTokenToMTok(result.output_price ?? null),
cache_write_price: perTokenToMTok(result.cache_write_price ?? null),
cache_write_1h_price: perTokenToMTok(result.cache_write_1h_price ?? null),
cache_read_price: perTokenToMTok(result.cache_read_price ?? null),
image_input_price: perTokenToMTok(result.image_input_price ?? null),
image_output_price: perTokenToMTok(result.image_output_price ?? null),
@@ -9,6 +9,7 @@ export interface IntervalFormEntry {
input_price: number | string | null
output_price: number | string | null
cache_write_price: number | string | null
cache_write_1h_price?: number | string | null
cache_read_price: number | string | null
input_multiplier: number | string | null
output_multiplier: number | string | null
@@ -24,6 +25,7 @@ export interface PricingFormEntry {
input_price: number | string | null
output_price: number | string | null
cache_write_price: number | string | null
cache_write_1h_price?: number | string | null
cache_read_price: number | string | null
fast_multiplier?: number | string | null
flex_multiplier?: number | string | null
@@ -200,6 +202,7 @@ export function apiIntervalsToForm(intervals: PricingInterval[]): IntervalFormEn
input_price: perTokenToMTok(iv.input_price),
output_price: perTokenToMTok(iv.output_price),
cache_write_price: perTokenToMTok(iv.cache_write_price),
cache_write_1h_price: perTokenToMTok(iv.cache_write_1h_price),
cache_read_price: perTokenToMTok(iv.cache_read_price),
input_multiplier: iv.input_multiplier,
output_multiplier: iv.output_multiplier,
@@ -218,6 +221,7 @@ export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInter
input_price: mTokToPerToken(iv.input_price),
output_price: mTokToPerToken(iv.output_price),
cache_write_price: mTokToPerToken(iv.cache_write_price),
cache_write_1h_price: mTokToPerToken(iv.cache_write_1h_price),
cache_read_price: mTokToPerToken(iv.cache_read_price),
input_multiplier: toNullableNumber(iv.input_multiplier),
output_multiplier: toNullableNumber(iv.output_multiplier),
@@ -342,6 +346,7 @@ function validateIntervalPrices(iv: IntervalFormEntry, idx: number, t: Translate
['inputPrice', iv.input_price],
['outputPrice', iv.output_price],
['cacheWritePrice', iv.cache_write_price],
['cacheWrite1hPrice', iv.cache_write_1h_price ?? null],
['cacheReadPrice', iv.cache_read_price],
['perRequestPrice', iv.per_request_price],
]
@@ -17,6 +17,22 @@
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortHint") }}</p>
</div>
<div>
<label :for="`${idPrefix}-over-limit`" class="input-label">
{{ t("admin.groups.form.maxReasoningEffortOverLimit") }}
</label>
<Select
:id="`${idPrefix}-over-limit`"
:model-value="overLimit"
:options="overLimitOptions"
:aria-label="t('admin.groups.form.maxReasoningEffortOverLimit')"
:searchable="false"
:disabled="!maxEffort"
@update:model-value="updateOverLimit"
/>
<p class="input-hint">{{ t("admin.groups.form.maxReasoningEffortOverLimitHint") }}</p>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
@@ -201,6 +217,8 @@ import {
createReasoningEffortMappingRow,
normalizeReasoningEffortMatchType,
reasoningEffortOptionsForPlatform,
reasoningEffortOverLimitDeny,
reasoningEffortOverLimitDowngrade,
validateReasoningEffortMappings,
type ReasoningEffortMappingErrorCode,
type ReasoningEffortMappingRow,
@@ -210,11 +228,13 @@ const props = defineProps<{
idPrefix: string;
platform: GroupPlatform;
maxEffort: string;
overLimit: string;
mappings: ReasoningEffortMappingRow[];
}>();
const emit = defineEmits<{
(event: "update:maxEffort", value: string): void;
(event: "update:overLimit", value: string): void;
(event: "update:mappings", value: ReasoningEffortMappingRow[]): void;
}>();
@@ -237,6 +257,16 @@ const matchTypeOptions = computed(() => [
label: t("admin.groups.form.reasoningEffortMatchSuffix"),
},
]);
const overLimitOptions = computed(() => [
{
value: reasoningEffortOverLimitDowngrade,
label: t("admin.groups.form.maxReasoningEffortOverLimitDowngrade"),
},
{
value: reasoningEffortOverLimitDeny,
label: t("admin.groups.form.maxReasoningEffortOverLimitDeny"),
},
]);
const validationErrors = computed(() =>
validateReasoningEffortMappings(props.mappings, props.platform),
);
@@ -251,6 +281,13 @@ const updateMaxEffort = (value: string | number | boolean | null) => {
emit("update:maxEffort", asString(value));
};
const updateOverLimit = (value: string | number | boolean | null) => {
emit(
"update:overLimit",
asString(value) || reasoningEffortOverLimitDowngrade,
);
};
const updateGroup = (
id: string,
field: "match_type" | "model",
@@ -25,6 +25,7 @@ describe("ReasoningEffortPolicyFields", () => {
idPrefix: "create-group-reasoning",
platform: "openai",
maxEffort: "",
overLimit: "downgrade",
mappings: [mapping],
},
global: {
@@ -60,6 +61,7 @@ describe("ReasoningEffortPolicyFields", () => {
idPrefix: "edit-group-reasoning",
platform: "openai",
maxEffort: "",
overLimit: "downgrade",
mappings: [],
},
global: {
@@ -79,11 +79,18 @@
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheWritePrice'))"
:label="t(prefixKey('cacheWrite5mPrice'))"
:value="model.pricing.cache_write_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
v-if="model.pricing.cache_write_1h_price != null"
:label="t(prefixKey('cacheWrite1hPrice'))"
:value="model.pricing.cache_write_1h_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
:label="t(prefixKey('cacheReadPrice'))"
:value="model.pricing.cache_read_price"
@@ -1708,6 +1708,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
const claudeModels = {
'claude-fable-5-1': {
name: 'Claude Fable 5.1',
limit: {
context: 1048576,
output: 128000
},
modalities: {
input: ['text', 'image', 'pdf'],
output: ['text']
},
options: {
thinking: {
type: 'adaptive'
}
}
},
'claude-fable-5': {
name: 'Claude Fable 5',
limit: {
@@ -671,8 +671,13 @@ describe('UseKeyModal', () => {
expect(claudeConfig).toBeDefined()
const parsed = JSON.parse(claudeConfig!)
const fable51 = parsed.provider['antigravity-claude'].models['claude-fable-5-1']
const fable = parsed.provider['antigravity-claude'].models['claude-fable-5']
expect(fable51.name).toBe('Claude Fable 5.1')
expect(fable51.limit).toEqual({ context: 1048576, output: 128000 })
expect(fable51.options.thinking).toEqual({ type: 'adaptive' })
expect(fable51.options.thinking).not.toHaveProperty('budgetTokens')
expect(fable.name).toBe('Claude Fable 5')
expect(fable.limit).toEqual({ context: 1048576, output: 128000 })
expect(fable.options.thinking).toEqual({ type: 'adaptive' })
@@ -138,9 +138,13 @@
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200"
:title="tierHint(m)"
>
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
{{ paidPerMillion(iv.cache_write_price, period) }}
<template v-if="iv.cache_write_1h_price != null"
><span class="font-sans font-normal text-gray-400 dark:text-dark-500"> (1h </span>{{ paidPerMillion(iv.cache_write_1h_price, period)
}}<span class="font-sans font-normal text-gray-400 dark:text-dark-500">)</span></template
>
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
{{ paidPerMillion(iv.cache_read_price, period) }}
</template>
@@ -153,7 +157,11 @@
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ paidPerMillion(m.pricing?.cache_write_price, period) }}
{{ paidPerMillion(m.pricing?.cache_write_price, period)
}}<template v-if="m.pricing?.cache_write_1h_price != null"
><span class="font-sans font-normal text-gray-400 dark:text-dark-500"> (1h </span>{{ paidPerMillion(m.pricing.cache_write_1h_price, period)
}}<span class="font-sans font-normal text-gray-400 dark:text-dark-500">)</span></template
>
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
@@ -228,9 +236,13 @@
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400"
:title="t('modelPlaza.table.tierHint')"
>
<template v-if="iv.cache_write_price != null || iv.cache_read_price != null">
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
<span class="font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
{{ official(iv.cache_write_price) }}
<template v-if="iv.cache_write_1h_price != null"
><span class="font-sans text-gray-400 dark:text-dark-500"> (1h </span>{{ official(iv.cache_write_1h_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">)</span></template
>
<span class="ml-1 font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
{{ official(iv.cache_read_price) }}
</template>
@@ -423,7 +435,7 @@ function perUnitSuffix(m: PlazaModel): string {
}
function hasCachePricing(m: PlazaModel): boolean {
return m.pricing?.cache_write_price != null || m.pricing?.cache_read_price != null
return m.pricing?.cache_write_price != null || m.pricing?.cache_write_1h_price != null || m.pricing?.cache_read_price != null
}
function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boolean {
@@ -476,7 +488,7 @@ function officialIntervals(m: PlazaModel): UserPricingInterval[] {
/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
return intervals.some((iv) => iv.cache_write_price != null || iv.cache_read_price != null)
return intervals.some((iv) => iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null)
}
/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
@@ -204,6 +204,16 @@ describe('PlazaModelPricingTable', () => {
expect(cells[6].text().trim()).toBe('-')
})
it('实付价分别展示自定义 5m 与 1h 缓存写入价', () => {
const model = tokenModel()
model.pricing!.cache_write_1h_price = 7e-6
const wrapper = mountTable([model], 1)
expect(wrapper.text()).toContain('$3.75')
expect(wrapper.text()).toContain('$7.00')
expect(wrapper.text()).toContain('(1h')
})
it('per_request 模型按单次价 × 倍率展示,官方价列显示 -', () => {
const model = tokenModel({
name: 'search-tool',
@@ -37,6 +37,8 @@ describe('useModelWhitelist', () => {
})
it('Claude 模型列表包含新发布的 Claude 模型', () => {
expect(getModelsByPlatform('claude')).toContain('claude-fable-5-1')
expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5-1')
expect(getModelsByPlatform('claude')).toContain('claude-fable-5')
expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5')
expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8')
@@ -35,6 +35,7 @@ export const claudeModels = [
'claude-opus-5',
'claude-sonnet-4-6',
'claude-sonnet-5',
'claude-fable-5-1',
'claude-fable-5'
]
@@ -56,6 +57,7 @@ const geminiModels = [
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
const antigravityModels = [
// Claude 4.5+ 系列
'claude-fable-5-1',
'claude-fable-5',
'claude-opus-4-6',
'claude-opus-4-6-thinking',
@@ -264,6 +266,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
// =====================
const anthropicPresetMappings = [
{ label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'claude-sonnet-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
@@ -328,6 +331,7 @@ const grokPresetMappings = [
const antigravityPresetMappings = [
// Claude 通配符映射
{ label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Sonnet→Sonnet', from: 'claude-sonnet-*', to: 'claude-sonnet-4-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus→Opus', from: 'claude-opus-*', to: 'claude-opus-4-6-thinking', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
@@ -364,6 +368,7 @@ const antigravityPresetMappings = [
// Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致)
const bedrockPresetMappings = [
{ label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'anthropic.claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Fable 5', from: 'claude-fable-5', to: 'anthropic.claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
@@ -30,6 +30,8 @@ export default {
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheWrite5mPrice: 'Cache Write (5m)',
cacheWrite1hPrice: 'Cache Write (1h)',
cacheReadPrice: 'Cache Read',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
@@ -77,6 +79,7 @@ export default {
inputPrice: 'input price',
outputPrice: 'output price',
cacheWritePrice: 'cache write price',
cacheWrite1hPrice: '1h cache write price',
cacheReadPrice: 'cache read price',
perRequestPrice: 'per-request price'
}
@@ -122,8 +125,12 @@ export default {
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheWrite5mPrice: 'Cache Write (5m)',
cacheWrite1hPrice: 'Cache Write (1h)',
cacheReadPrice: 'Cache Read',
cacheWritePriceShort: 'Cache W',
cacheWrite5mPriceShort: 'Cache W 5m',
cacheWrite1hPriceShort: 'Cache W 1h',
cacheReadPriceShort: 'Cache R',
imageInputPrice: 'Image Input',
imageTokenPrice: 'Image Output',
+12 -1
View File
@@ -859,7 +859,11 @@ export default {
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).',
maxReasoningEffort: 'Max reasoning effort',
maxReasoningEffortUnlimited: 'Unlimited (follow request)',
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.',
maxReasoningEffortOverLimit: 'Over-limit access control',
maxReasoningEffortOverLimitDowngrade: 'Automatically downgrade when over limit',
maxReasoningEffortOverLimitDeny: 'Deny access',
maxReasoningEffortOverLimitHint: 'Applies after a ceiling is set. Downgrade rewrites values above the ceiling to the ceiling. Deny rejects the request.',
reasoningEffortMappings: 'Reasoning effort mappings',
reasoningEffortMappingsHint: 'Type and model can both be left empty to match every model. One type and model can hold multiple request mappings, for example prefix gpt mapping both high and xhigh to medium. Exact matches beat affixes, and longer affixes beat shorter ones.',
addReasoningEffortMapping: 'Add mapping',
@@ -1163,6 +1167,13 @@ export default {
unsupportedMessage: 'This Sub2API server cannot generate the required Live attestation. Live will not work even if enabled. Continue anyway?',
enableAnyway: 'Enable anyway'
},
openaiFast: {
title: 'OpenAI Fast mode',
force: 'Force Fast (priority)',
hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.',
free: 'Free Fast',
freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.'
},
invalidRequestFallback: {
title: 'Invalid Request Fallback Group',
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
@@ -585,6 +585,8 @@ export default {
inputPrice: 'Input',
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheWrite5mPrice: 'Cache Write (5m)',
cacheWrite1hPrice: 'Cache Write (1h)',
cacheReadPrice: 'Cache Read',
imageInputPrice: 'Image Input',
imageOutputPrice: 'Image Output',
@@ -30,6 +30,8 @@ export default {
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheWrite5mPrice: '缓存写入(5m)',
cacheWrite1hPrice: '缓存写入(1h)',
cacheReadPrice: '缓存读取',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
@@ -77,6 +79,7 @@ export default {
inputPrice: '输入价格',
outputPrice: '输出价格',
cacheWritePrice: '缓存写入价格',
cacheWrite1hPrice: '1h 缓存写入价格',
cacheReadPrice: '缓存读取价格',
perRequestPrice: '单次价格'
}
@@ -122,8 +125,12 @@ export default {
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheWrite5mPrice: '缓存写入(5m)',
cacheWrite1hPrice: '缓存写入(1h)',
cacheReadPrice: '缓存读取',
cacheWritePriceShort: '缓存写',
cacheWrite5mPriceShort: '缓存写 5m',
cacheWrite1hPriceShort: '缓存写 1h',
cacheReadPriceShort: '缓存读',
imageInputPrice: '图片输入',
imageTokenPrice: '图片输出',
+12 -1
View File
@@ -841,7 +841,11 @@ export default {
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
maxReasoningEffort: '推理强度上限',
maxReasoningEffortUnlimited: '不限制(跟随请求)',
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。不会为缺省请求主动开启推理。上限优先级高于推理强度映射。',
maxReasoningEffortOverLimit: '超限访问控制',
maxReasoningEffortOverLimitDowngrade: '超过上限时自动降档',
maxReasoningEffortOverLimitDeny: '拒绝访问',
maxReasoningEffortOverLimitHint: '设置上限后生效。自动降档会将超过上限的请求改写为上限值后转发;拒绝访问则直接返回错误。',
reasoningEffortMappings: '推理强度映射',
reasoningEffortMappingsHint: '类型和模型均可留空,表示匹配全部模型。同一类型和模型下可添加多条请求值映射,例如前缀 gpt 同时将 high、xhigh 转到 medium。精确优先于前后缀,更长前后缀优先。',
addReasoningEffortMapping: '添加映射',
@@ -1161,6 +1165,13 @@ export default {
unsupportedMessage: '当前 Sub2API 服务端无法生成 Live 所需的设备证明,即使开启也不能使用。是否仍然开启?',
enableAnyway: '仍然开启'
},
openaiFast: {
title: 'OpenAI Fast 模式',
force: '强制使用 Fast(priority)',
hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。',
free: '免费 Fast',
freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。'
},
invalidRequestFallback: {
title: '无效请求兜底分组',
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
@@ -590,6 +590,8 @@ export default {
inputPrice: '输入',
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheWrite5mPrice: '缓存写入(5m)',
cacheWrite1hPrice: '缓存写入(1h)',
cacheReadPrice: '缓存读取',
imageInputPrice: '图片输入',
imageOutputPrice: '图片输出',
+9
View File
@@ -561,6 +561,7 @@ export interface Group {
rate_multiplier: number
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
max_reasoning_effort?: string // OpenAI/Codex reasoning ceiling; empty means unlimited
max_reasoning_effort_over_limit?: string // downgrade (default) or deny when over the ceiling
reasoning_effort_mappings?: ReasoningEffortMapping[]
is_exclusive: boolean
status: 'active' | 'inactive'
@@ -615,6 +616,8 @@ export interface Group {
}
export interface AdminGroup extends Group {
force_openai_fast: boolean
free_openai_fast: boolean
model_pricing: import('@/api/admin/channels').ChannelModelPricing[]
// 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。
@@ -779,6 +782,8 @@ export interface CreateGroupRequest {
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
long_context_pricing_enabled?: boolean
force_openai_fast?: boolean
free_openai_fast?: boolean
model_pricing?: import('@/api/admin/channels').ChannelModelPricing[]
allow_image_generation?: boolean
allow_batch_image_generation?: boolean
@@ -822,6 +827,7 @@ export interface CreateGroupRequest {
model_routing_enabled?: boolean
rpm_limit?: number
max_reasoning_effort?: string
max_reasoning_effort_over_limit?: string
reasoning_effort_mappings?: ReasoningEffortMapping[]
require_oauth_only?: boolean
require_privacy_set?: boolean
@@ -841,6 +847,8 @@ export interface UpdateGroupRequest {
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
long_context_pricing_enabled?: boolean
force_openai_fast?: boolean
free_openai_fast?: boolean
model_pricing?: import('@/api/admin/channels').ChannelModelPricing[]
allow_image_generation?: boolean
allow_batch_image_generation?: boolean
@@ -884,6 +892,7 @@ export interface UpdateGroupRequest {
model_routing_enabled?: boolean
rpm_limit?: number
max_reasoning_effort?: string
max_reasoning_effort_over_limit?: string
reasoning_effort_mappings?: ReasoningEffortMapping[]
require_oauth_only?: boolean
require_privacy_set?: boolean
@@ -861,6 +861,7 @@ function addPricingEntry(sectionIdx: number) {
input_price: null,
output_price: null,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null,
fast_multiplier: null,
flex_multiplier: null,
@@ -897,6 +898,7 @@ async function syncLatestModels(sectionIdx: number) {
input_price: null,
output_price: null,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null,
fast_multiplier: null,
flex_multiplier: null,
@@ -965,6 +967,7 @@ function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
input_price: null,
output_price: null,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
@@ -1082,6 +1085,7 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
input_price: mTokToPerToken(p.input_price),
output_price: mTokToPerToken(p.output_price),
cache_write_price: mTokToPerToken(p.cache_write_price),
cache_write_1h_price: mTokToPerToken(p.cache_write_1h_price),
cache_read_price: mTokToPerToken(p.cache_read_price),
image_input_price: mTokToPerToken(p.image_input_price),
image_output_price: mTokToPerToken(p.image_output_price),
@@ -1124,6 +1128,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_write_1h_price: mTokToPerToken(entry.cache_write_1h_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null,
flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null,
@@ -1225,6 +1230,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
input_price: perTokenToMTok(p.input_price),
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_write_1h_price: perTokenToMTok(p.cache_write_1h_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
fast_multiplier: p.fast_multiplier,
flex_multiplier: p.flex_multiplier,
@@ -1417,6 +1423,7 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
input_price: perTokenToMTok(p.input_price),
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_write_1h_price: perTokenToMTok(p.cache_write_1h_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
image_input_price: perTokenToMTok(p.image_input_price),
image_output_price: perTokenToMTok(p.image_output_price),
+194
View File
@@ -631,6 +631,7 @@
id-prefix="create-group-reasoning"
:platform="createForm.platform"
v-model:max-effort="createForm.max_reasoning_effort"
v-model:over-limit="createForm.max_reasoning_effort_over_limit"
v-model:mappings="createForm.reasoning_effort_mappings"
/>
<div
@@ -1581,6 +1582,74 @@
</div>
</div>
</div>
<!-- OpenAI Fast 开关(OpenAI 与 Composite 平台) -->
<div
v-if="supportsGroupOpenAIFast(createForm.platform)"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiFast.title") }}
</h4>
<div class="flex items-center justify-between gap-4">
<label class="text-sm text-gray-600 dark:text-gray-400">
{{ t("admin.groups.openaiFast.force") }}
</label>
<button
type="button"
role="switch"
:aria-checked="createForm.force_openai_fast"
:aria-label="t('admin.groups.openaiFast.force')"
data-testid="create-force-openai-fast"
@click="createForm.force_openai_fast = !createForm.force_openai_fast"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.force_openai_fast
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
createForm.force_openai_fast ? 'translate-x-6' : 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiFast.hint") }}
</p>
<div class="flex items-center justify-between gap-4 mt-4">
<label class="text-sm text-gray-600 dark:text-gray-400">
{{ t("admin.groups.openaiFast.free") }}
</label>
<button
type="button"
role="switch"
:aria-checked="createForm.free_openai_fast"
:aria-label="t('admin.groups.openaiFast.free')"
data-testid="create-free-openai-fast"
@click="createForm.free_openai_fast = !createForm.free_openai_fast"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.free_openai_fast
? 'bg-emerald-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
createForm.free_openai_fast ? 'translate-x-6' : 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiFast.freeHint") }}
</p>
</div>
<!-- Codex Live 开关(OpenAI 与 Composite 平台) -->
<div
v-if="supportsLivePlatform(createForm.platform)"
@@ -2361,6 +2430,7 @@
id-prefix="edit-group-reasoning"
:platform="editForm.platform"
v-model:max-effort="editForm.max_reasoning_effort"
v-model:over-limit="editForm.max_reasoning_effort_over_limit"
v-model:mappings="editForm.reasoning_effort_mappings"
/>
<div v-if="editForm.subscription_type !== 'subscription'">
@@ -3309,6 +3379,74 @@
</div>
</div>
</div>
<!-- OpenAI Fast 开关(OpenAI 与 Composite 平台) -->
<div
v-if="supportsGroupOpenAIFast(editForm.platform)"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiFast.title") }}
</h4>
<div class="flex items-center justify-between gap-4">
<label class="text-sm text-gray-600 dark:text-gray-400">
{{ t("admin.groups.openaiFast.force") }}
</label>
<button
type="button"
role="switch"
:aria-checked="editForm.force_openai_fast"
:aria-label="t('admin.groups.openaiFast.force')"
data-testid="edit-force-openai-fast"
@click="editForm.force_openai_fast = !editForm.force_openai_fast"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.force_openai_fast
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
editForm.force_openai_fast ? 'translate-x-6' : 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiFast.hint") }}
</p>
<div class="flex items-center justify-between gap-4 mt-4">
<label class="text-sm text-gray-600 dark:text-gray-400">
{{ t("admin.groups.openaiFast.free") }}
</label>
<button
type="button"
role="switch"
:aria-checked="editForm.free_openai_fast"
:aria-label="t('admin.groups.openaiFast.free')"
data-testid="edit-free-openai-fast"
@click="editForm.free_openai_fast = !editForm.free_openai_fast"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.free_openai_fast
? 'bg-emerald-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
editForm.free_openai_fast ? 'translate-x-6' : 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiFast.freeHint") }}
</p>
</div>
<!-- Codex Live 开关(OpenAI 与 Composite 平台) -->
<div
v-if="supportsLivePlatform(editForm.platform)"
@@ -4476,6 +4614,10 @@ import {
supportsMessagesDispatchPlatform,
type MessagesDispatchMappingRow,
} from "./groupsMessagesDispatch";
import {
normalizeGroupOpenAIFast,
supportsGroupOpenAIFast,
} from "./groupsOpenAIFast";
import {
buildModelsListConfig,
createModelsListState as createInitialModelsListState,
@@ -4495,8 +4637,10 @@ import {
} from "./groupsProfitControl";
import {
normalizeReasoningEffortForPlatform,
normalizeReasoningEffortOverLimit,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
reasoningEffortOverLimitDowngrade,
supportsReasoningEffortPolicyPlatform,
type ReasoningEffortMappingRow,
} from "./groupsReasoningEffort";
@@ -4526,6 +4670,7 @@ const emptyGroupPricing = (): PricingFormEntry => ({
input_price: null,
output_price: null,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
@@ -4546,6 +4691,7 @@ const groupPricingFromAPI = (
input_price: perTokenToMTok(entry.input_price),
output_price: perTokenToMTok(entry.output_price),
cache_write_price: perTokenToMTok(entry.cache_write_price),
cache_write_1h_price: perTokenToMTok(entry.cache_write_1h_price),
cache_read_price: perTokenToMTok(entry.cache_read_price),
image_input_price: perTokenToMTok(entry.image_input_price),
image_output_price: perTokenToMTok(entry.image_output_price),
@@ -4567,6 +4713,7 @@ const groupPricingToAPI = (
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_write_1h_price: mTokToPerToken(entry.cache_write_1h_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_input_price: mTokToPerToken(entry.image_input_price),
image_output_price: mTokToPerToken(entry.image_output_price),
@@ -5047,6 +5194,8 @@ const createForm = reactive({
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: true,
force_openai_fast: false,
free_openai_fast: false,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
@@ -5105,6 +5254,7 @@ const createForm = reactive({
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
max_reasoning_effort_over_limit: reasoningEffortOverLimitDowngrade,
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
@@ -5408,6 +5558,8 @@ const editForm = reactive({
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: true,
force_openai_fast: false,
free_openai_fast: false,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
@@ -5467,6 +5619,7 @@ const editForm = reactive({
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
max_reasoning_effort_over_limit: reasoningEffortOverLimitDowngrade,
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
@@ -5882,6 +6035,8 @@ const closeCreateModal = () => {
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.long_context_pricing_enabled = true;
createForm.force_openai_fast = false;
createForm.free_openai_fast = false;
createForm.model_pricing = [];
createForm.web_search_price_per_call = null;
createForm.search_price_per_1k = null;
@@ -5907,6 +6062,7 @@ const closeCreateModal = () => {
createForm.copy_accounts_from_group_ids = [];
createForm.rpm_limit = 0;
createForm.max_reasoning_effort = "";
createForm.max_reasoning_effort_over_limit = reasoningEffortOverLimitDowngrade;
createForm.reasoning_effort_mappings = [];
createReasoningEffortPolicyRef.value?.resetValidation();
resetModelsListState(createModelsListState);
@@ -5982,6 +6138,14 @@ const handleCreateGroup = async () => {
// 构建请求数据,包含模型路由配置
const requestData = {
...createGroupForm,
force_openai_fast: normalizeGroupOpenAIFast(
createForm.platform,
createForm.force_openai_fast,
),
free_openai_fast: normalizeGroupOpenAIFast(
createForm.platform,
createForm.free_openai_fast,
),
model_pricing: groupPricingToAPI(
createForm.model_pricing,
createForm.platform,
@@ -6110,6 +6274,8 @@ const handleEdit = async (group: AdminGroup) => {
editForm.monthly_limit_usd = group.monthly_limit_usd;
editForm.long_context_pricing_enabled =
group.long_context_pricing_enabled ?? true;
editForm.force_openai_fast = group.force_openai_fast ?? false;
editForm.free_openai_fast = group.free_openai_fast ?? false;
editForm.model_pricing = groupPricingFromAPI(group.model_pricing);
editForm.allow_image_generation = group.allow_image_generation ?? false;
editForm.allow_batch_image_generation =
@@ -6177,6 +6343,9 @@ const handleEdit = async (group: AdminGroup) => {
group.platform,
group.max_reasoning_effort,
);
editForm.max_reasoning_effort_over_limit = normalizeReasoningEffortOverLimit(
group.max_reasoning_effort_over_limit,
);
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
group.reasoning_effort_mappings,
group.platform,
@@ -6198,6 +6367,7 @@ const closeEditModal = () => {
showEditModal.value = false;
editingGroup.value = null;
editForm.max_reasoning_effort = "";
editForm.max_reasoning_effort_over_limit = reasoningEffortOverLimitDowngrade;
editForm.reasoning_effort_mappings = [];
editReasoningEffortPolicyRef.value?.resetValidation();
editModelRoutingRules.value = [];
@@ -6216,6 +6386,8 @@ const closeEditModal = () => {
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.long_context_pricing_enabled = true;
editForm.force_openai_fast = false;
editForm.free_openai_fast = false;
editForm.model_pricing = [];
editForm.web_search_price_per_call = null;
editForm.search_price_per_1k = null;
@@ -6249,6 +6421,14 @@ const handleUpdateGroup = async () => {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
force_openai_fast: normalizeGroupOpenAIFast(
editForm.platform,
editForm.force_openai_fast,
),
free_openai_fast: normalizeGroupOpenAIFast(
editForm.platform,
editForm.free_openai_fast,
),
model_pricing: groupPricingToAPI(
editForm.model_pricing,
editForm.platform,
@@ -6673,6 +6853,13 @@ watch(
newVal,
createForm.max_reasoning_effort,
);
createForm.max_reasoning_effort_over_limit = supportsReasoningEffortPolicyPlatform(
newVal,
)
? normalizeReasoningEffortOverLimit(
createForm.max_reasoning_effort_over_limit,
)
: reasoningEffortOverLimitDowngrade;
createForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(createForm.reasoning_effort_mappings),
newVal,
@@ -6723,6 +6910,13 @@ watch(
newVal,
editForm.max_reasoning_effort,
);
editForm.max_reasoning_effort_over_limit = supportsReasoningEffortPolicyPlatform(
newVal,
)
? normalizeReasoningEffortOverLimit(
editForm.max_reasoning_effort_over_limit,
)
: reasoningEffortOverLimitDowngrade;
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(editForm.reasoning_effort_mappings),
newVal,
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
normalizeGroupOpenAIFast,
supportsGroupOpenAIFast,
} from "../groupsOpenAIFast";
import en from "@/i18n/locales/en/admin/overview";
import zh from "@/i18n/locales/zh/admin/overview";
describe("groupsOpenAIFast", () => {
it("supports OpenAI and composite groups", () => {
expect(supportsGroupOpenAIFast("openai")).toBe(true);
expect(supportsGroupOpenAIFast("composite")).toBe(true);
expect(supportsGroupOpenAIFast("anthropic")).toBe(false);
});
it("clears stale enabled state on unsupported platforms", () => {
expect(normalizeGroupOpenAIFast("openai", true)).toBe(true);
expect(normalizeGroupOpenAIFast("composite", true)).toBe(true);
expect(normalizeGroupOpenAIFast("anthropic", true)).toBe(false);
expect(normalizeGroupOpenAIFast("openai", false)).toBe(false);
});
it("provides localized switch labels", () => {
expect(zh.groups.openaiFast).toMatchObject({
title: expect.any(String),
force: expect.any(String),
hint: expect.stringContaining("service_tier=priority"),
free: expect.any(String),
freeHint: expect.stringContaining("Standard"),
});
expect(en.groups.openaiFast).toMatchObject({
title: expect.any(String),
force: expect.any(String),
hint: expect.stringContaining("service_tier=priority"),
free: expect.any(String),
freeHint: expect.stringContaining("Standard"),
});
});
});
@@ -5,9 +5,12 @@ import {
createReasoningEffortMappingRow,
normalizeReasoningEffortForPlatform,
normalizeReasoningEffortMatchType,
normalizeReasoningEffortOverLimit,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
reasoningEffortOptionsForPlatform,
reasoningEffortOverLimitDeny,
reasoningEffortOverLimitDowngrade,
supportsReasoningEffortPolicyPlatform,
validateReasoningEffortMappings,
} from "../groupsReasoningEffort";
@@ -248,4 +251,22 @@ describe("groupsReasoningEffort", () => {
[row.pairs[0].id]: { from: "unsupportedFrom" },
});
});
it("normalizes over-limit access control to downgrade or deny", () => {
expect(normalizeReasoningEffortOverLimit(undefined)).toBe(
reasoningEffortOverLimitDowngrade,
);
expect(normalizeReasoningEffortOverLimit("")).toBe(
reasoningEffortOverLimitDowngrade,
);
expect(normalizeReasoningEffortOverLimit(" downgrade ")).toBe(
reasoningEffortOverLimitDowngrade,
);
expect(normalizeReasoningEffortOverLimit("DENY")).toBe(
reasoningEffortOverLimitDeny,
);
expect(normalizeReasoningEffortOverLimit("block")).toBe(
reasoningEffortOverLimitDowngrade,
);
});
});
@@ -0,0 +1,10 @@
export function supportsGroupOpenAIFast(platform: string): boolean {
return platform === "openai" || platform === "composite";
}
export function normalizeGroupOpenAIFast(
platform: string,
enabled: boolean,
): boolean {
return supportsGroupOpenAIFast(platform) && enabled;
}
@@ -19,6 +19,9 @@ const reasoningEffortMatchTypes: readonly ReasoningEffortMatchType[] = [
"suffix",
];
export const reasoningEffortOverLimitDowngrade = "downgrade";
export const reasoningEffortOverLimitDeny = "deny";
const reasoningEffortValuesForPlatform = (
platform: GroupPlatform,
): readonly string[] =>
@@ -60,6 +63,14 @@ export function normalizeReasoningEffortMatchType(
: "";
}
export function normalizeReasoningEffortOverLimit(
value: string | null | undefined,
): string {
return value?.trim().toLowerCase() === reasoningEffortOverLimitDeny
? reasoningEffortOverLimitDeny
: reasoningEffortOverLimitDowngrade;
}
export interface ReasoningEffortMappingPair {
id: string;
from: string;