Merge pull request #5666 from Randark-JMT/feat/cn-providers-kimi-zhipu-deepseek

feat: 国产供应商多协议支持(Kimi/Zhipu/DeepSeek 原生 Anthropic 直通 + DeepSeek Responses)与配额/余额监控
This commit is contained in:
Wesley Liddick
2026-08-17 14:55:20 +08:00
committed by GitHub
78 changed files with 6195 additions and 125 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* Admin CN providers (Kimi / Zhipu / DeepSeek) API endpoints.
* Coding-plan rolling-window quota probe + payg balance probe.
*/
import { apiClient } from '../client'
/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */
export interface CNQuotaTier {
window: '5h' | 'weekly'
used_percent: number
reset_at?: string
}
/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */
export interface CNProviderQuotaProbeResult {
provider: string
source?: string
success: boolean
credential_valid: boolean
tiers?: CNQuotaTier[]
plan_level?: string
status_code?: number
fetched_at: number
persisted: boolean
error?: string
}
/** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */
export interface CNProviderBalanceEntry {
currency: string
balance: number
}
/** payg 余额探测结果(kimi / deepseek),对齐后端 CNProviderBalanceResult。 */
export interface CNProviderBalanceResult {
provider: string
success: boolean
/** 主币种余额(balances 首条,兼容单币种展示)。 */
balance: number
currency?: string
/** 多币种明细;缺省时按主币种展示。 */
balances?: CNProviderBalanceEntry[]
available: boolean
status_code?: number
fetched_at: number
persisted: boolean
error?: string
}
/** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */
export async function queryQuota(id: number): Promise<CNProviderQuotaProbeResult> {
const { data } = await apiClient.get<CNProviderQuotaProbeResult>(
`/admin/cn-providers/accounts/${id}/quota`
)
return data
}
/** 查询 payg 账号余额。 */
export async function queryBalance(id: number): Promise<CNProviderBalanceResult> {
const { data } = await apiClient.get<CNProviderBalanceResult>(
`/admin/cn-providers/accounts/${id}/balance`
)
return data
}
export default {
queryQuota,
queryBalance
}
+3
View File
@@ -18,6 +18,7 @@ import usageAPI from './usage'
import geminiAPI from './gemini'
import antigravityAPI from './antigravity'
import grokAPI from './grok'
import cnProvidersAPI from './cnProviders'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
@@ -54,6 +55,7 @@ export const adminAPI = {
gemini: geminiAPI,
antigravity: antigravityAPI,
grok: grokAPI,
cnProviders: cnProvidersAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI,
@@ -88,6 +90,7 @@ export {
geminiAPI,
antigravityAPI,
grokAPI,
cnProvidersAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI,
+6
View File
@@ -36,13 +36,19 @@ export type SchedulingThresholdPlatformType =
| "openai"
| "anthropic"
| "grok"
| "kimi"
| "zhipu"
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型,
// 走余额检测而非用量阈值)。
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
"openai",
"anthropic",
"grok",
"kimi",
"zhipu",
]
export function normalizeAccountSchedulingThresholdsMap(
@@ -422,6 +422,21 @@
</div>
</template>
<!-- CN providers (Kimi / Zhipu / DeepSeek): coding-plan quota or payg balance -->
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek'">
<div class="space-y-1">
<!-- 子单元格各自按 模式×平台 判定可见;两者都不可见时(智谱 payg 无公开
余额端点、coding 探测也不适用)才回落到占位符。 -->
<div
v-if="!cnQuotaCellVisible && !cnBalanceCellVisible"
class="text-xs text-gray-400"
:title="t('admin.accounts.cnProviders.noBalanceEndpoint')"
>-</div>
<CNProviderQuotaCell :account="account" />
<CNProviderBalanceCell :account="account" />
</div>
</template>
<!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'">
<!-- Auth Type + Tier Badge (first line) -->
@@ -625,7 +640,10 @@ import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
import CNProviderQuotaCell from './CNProviderQuotaCell.vue'
import CNProviderBalanceCell from './CNProviderBalanceCell.vue'
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
import { cnQuotaCellVisible as cnQuotaCellVisibleFn, cnBalanceCellVisible as cnBalanceCellVisibleFn } from './credentialsBuilder'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
@@ -690,6 +708,15 @@ let visibilityObserver: IntersectionObserver | null = null
const showUsageWindows = computed(() => {
// Gemini: we can always compute local usage windows from DB logs (simulated quotas).
if (props.account.platform === 'gemini') return true
// CN providers: apikey 账号也有滚动用量窗口(coding plan)或余额(payg),
// 由 CNProviderQuotaCell / CNProviderBalanceCell 自行探测与展示。
if (
props.account.platform === 'kimi' ||
props.account.platform === 'zhipu' ||
props.account.platform === 'deepseek'
) {
return true
}
return props.account.type === 'oauth' || props.account.type === 'setup-token'
})
@@ -712,6 +739,15 @@ const shouldFetchUsage = computed(() => {
return false
})
// CN 供应商子单元格可见性(与 CNProviderQuotaCell / CNProviderBalanceCell 共用
// credentialsBuilder 的单一实现):都不可见时显示 `-` 占位符。
const cnAccountMode = computed(() => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
})
const cnQuotaCellVisible = computed(() => cnQuotaCellVisibleFn(props.account.platform, cnAccountMode.value))
const cnBalanceCellVisible = computed(() => cnBalanceCellVisibleFn(props.account.platform, cnAccountMode.value))
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
const showGeminiTodayStats = computed(() => {
@@ -0,0 +1,168 @@
<template>
<div v-if="visible" class="space-y-1">
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="[
'inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-600',
platformTextClass(account.platform)
]"
:disabled="loading"
@click="handleProbe"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': loading }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ balanceLabel }}
</button>
<!-- Low balance badge (reactive 402/429 marker or probe-detected) -->
<span
v-if="balanceLow"
class="inline-flex items-center rounded bg-red-100 px-1 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{{ t('admin.accounts.cnProviders.balanceLow') }}
</span>
</div>
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
{{ truncatedError }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { CNProviderBalanceEntry, CNProviderBalanceResult } from '@/api/admin/cnProviders'
import type { Account } from '@/types'
import { platformTextClass } from '@/utils/platformColors'
import { cnBalanceCellVisible } from './credentialsBuilder'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
const readMode = (): string => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
}
// 仅 kimi / deepseek payg 账号有公开余额端点(智谱 payg 无)。
const visible = computed(() => cnBalanceCellVisible(props.account.platform, readMode()))
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<CNProviderBalanceResult | null>(null)
const extraKey = (suffix: string) => `${props.account.platform}_${suffix}`
// 落库快照(后端周期探测/响应式写入 account.Extra)。
const snapshotBalance = computed(() => {
const v = props.account.extra?.[extraKey('balance')]
return typeof v === 'number' ? v : null
})
const snapshotCurrency = computed(() => {
const v = props.account.extra?.[extraKey('balance_currency')]
return typeof v === 'string' ? v : ''
})
// 多币种快照(后端写 <platform>_balances:[{currency, balance}],deepseek CNY+USD)。
const snapshotBalances = computed<CNProviderBalanceEntry[]>(() => {
const v = props.account.extra?.[extraKey('balances')]
if (!Array.isArray(v)) return []
return v.flatMap((item): CNProviderBalanceEntry[] => {
if (!item || typeof item !== 'object') return []
const { currency, balance } = item as Record<string, unknown>
if (typeof currency !== 'string' || typeof balance !== 'number') return []
return [{ currency, balance }]
})
})
const balanceLow = computed(() => props.account.extra?.[extraKey('balance_low')] === true)
// 优先用探测结果,其次落库快照。多币种返回全部明细,否则主币种单条。
const currentEntries = computed<CNProviderBalanceEntry[]>(() => {
if (data.value && data.value.success) {
if (data.value.balances && data.value.balances.length > 0) return data.value.balances
return [{ currency: data.value.currency || '', balance: data.value.balance }]
}
if (snapshotBalances.value.length > 0) return snapshotBalances.value
if (snapshotBalance.value != null) {
return [{ currency: snapshotCurrency.value, balance: snapshotBalance.value }]
}
return []
})
const formatEntry = (entry: CNProviderBalanceEntry): string => {
const fixed = entry.balance >= 100 ? entry.balance.toFixed(0) : entry.balance.toFixed(2)
return `${entry.currency || '¥'} ${fixed}`
}
const balanceLabel = computed(() => {
if (currentEntries.value.length === 0) {
return t('admin.accounts.grokBalance')?.trim() || 'Balance'
}
return currentEntries.value.map(formatEntry).join(' · ')
})
const extractErrorMessage = (e: unknown): string => {
const err = e as {
message?: string
reason?: string
response?: { data?: { message?: string; error?: string } }
}
return (
err?.message ||
err?.reason ||
err?.response?.data?.message ||
err?.response?.data?.error ||
t('common.error')
)
}
const truncatedError = computed(() => {
if (!error.value) return ''
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
})
const handleProbe = async () => {
if (loading.value) return
loading.value = true
error.value = null
try {
const result = await adminAPI.cnProviders.queryBalance(props.account.id)
// 失败时保留快照展示(仅显示错误行),成功才覆盖。
if (result.success) {
data.value = result
} else {
error.value = result.error || t('common.error')
}
} catch (e) {
error.value = extractErrorMessage(e)
} finally {
loading.value = false
}
}
watch(
() => props.account.id,
() => {
data.value = null
error.value = null
loading.value = false
}
)
</script>
@@ -0,0 +1,219 @@
<template>
<div v-if="visible" class="space-y-1">
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="[
'inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-600',
platformTextClass(account.platform)
]"
:disabled="loading"
:title="t('admin.accounts.cnProviders.probeTooltip')"
@click="handleProbe()"
>
<svg
class="h-2.5 w-2.5"
:class="{ 'animate-spin': loading }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ t('admin.accounts.cnProviders.window5h') }}/{{ t('admin.accounts.cnProviders.windowWeekly') }}
</button>
</div>
<!-- Tier rows: 5h + weekly utilization bars -->
<div v-if="data?.success && data.tiers?.length" class="space-y-1">
<div v-for="tier in data.tiers" :key="tier.window" class="flex items-center gap-1.5 text-[10px]">
<span class="w-10 shrink-0 text-gray-500 dark:text-gray-400">{{ windowLabel(tier.window) }}</span>
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-full rounded-full transition-all"
:class="utilizationColor(tier.used_percent)"
:style="{ width: `${Math.min(100, Math.max(0, tier.used_percent))}%` }"
/>
</div>
<span :class="['shrink-0 font-medium', utilizationTextColor(tier.used_percent)]">
{{ Math.round(tier.used_percent) }}%
</span>
<span v-if="tier.reset_at" class="truncate text-gray-400 dark:text-gray-500" :title="tier.reset_at">
· {{ formatReset(tier.reset_at) }}
</span>
</div>
</div>
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
{{ truncatedError }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { CNProviderQuotaProbeResult } from '@/api/admin/cnProviders'
import type { Account } from '@/types'
import { platformTextClass } from '@/utils/platformColors'
import { cnQuotaCellVisible } from './credentialsBuilder'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
const readMode = (): string => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
}
const visible = computed(() => cnQuotaCellVisible(props.account.platform, readMode()))
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<CNProviderQuotaProbeResult | null>(null)
// 后端周期任务/手动探测写入的 extra 快照键(<provider>_ 前缀,与后端
// cnQuotaExtraUpdates 对齐)。页面加载即有数据,无需等待探测。
const SNAPSHOT_STALE_MS = 15 * 60 * 1000
// 自动探测去抖窗口与最近一次自动探测时间(模块级,跨实例共享)。
const AUTO_PROBE_DEBOUNCE_MS = 5 * 60 * 1000
const lastAutoProbeAt = new Map<number, number>()
const readExtraNumber = (key: string): number | null => {
const v = (props.account.extra as Record<string, unknown> | undefined)?.[key]
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
const readExtraString = (key: string): string => {
const v = (props.account.extra as Record<string, unknown> | undefined)?.[key]
return typeof v === 'string' ? v : ''
}
// 从持久化快照构造展示数据(缺少 5h/weekly 两档键时返回 null)。
const snapshotData = computed<CNProviderQuotaProbeResult | null>(() => {
const platform = props.account.platform
const used5h = readExtraNumber(`${platform}_5h_used_percent`)
const usedWeekly = readExtraNumber(`${platform}_weekly_used_percent`)
if (used5h == null && usedWeekly == null) return null
const tiers: CNProviderQuotaProbeResult['tiers'] = []
if (used5h != null) {
tiers.push({ window: '5h', used_percent: used5h, reset_at: readExtraString(`${platform}_5h_reset_at`) || undefined })
}
if (usedWeekly != null) {
tiers.push({ window: 'weekly', used_percent: usedWeekly, reset_at: readExtraString(`${platform}_weekly_reset_at`) || undefined })
}
return { success: true, tiers } as CNProviderQuotaProbeResult
})
// 快照是否过期(无更新时间或超过 staleness 窗口)→ 挂载时需要自动探测。
const snapshotIsStale = computed(() => {
const updatedAt = readExtraString(`${props.account.platform}_usage_updated_at`)
if (!updatedAt) return true
const ts = new Date(updatedAt).getTime()
return Number.isNaN(ts) || Date.now() - ts > SNAPSHOT_STALE_MS
})
// 挂载时:先用持久化快照渲染;快照缺失或过期再自动探测一次(失败显示错误,
// 避免静默失败导致单元格空白无提示)。
onMounted(() => {
if (!visible.value) return
data.value = snapshotData.value
if (!snapshotIsStale.value) return
// 模块级去抖:列表页每行一个实例,翻页/筛选/刷新会重复挂载;同一账号
// 短时间内已自动探测过则跳过,避免对上游形成探测风暴。
const last = lastAutoProbeAt.get(props.account.id) ?? 0
if (Date.now() - last < AUTO_PROBE_DEBOUNCE_MS) return
lastAutoProbeAt.set(props.account.id, Date.now())
handleProbe()
})
const extractErrorMessage = (e: unknown): string => {
const err = e as {
message?: string
reason?: string
response?: { data?: { message?: string; error?: string } }
}
return (
err?.message ||
err?.reason ||
err?.response?.data?.message ||
err?.response?.data?.error ||
t('common.error')
)
}
const truncatedError = computed(() => {
if (!error.value) return ''
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
})
const windowLabel = (window: string) =>
window === 'weekly'
? t('admin.accounts.cnProviders.windowWeekly')
: t('admin.accounts.cnProviders.window5h')
const utilizationColor = (pct: number) => {
if (pct >= 90) return 'bg-red-500'
if (pct >= 75) return 'bg-amber-500'
return 'bg-emerald-500'
}
const utilizationTextColor = (pct: number) => {
if (pct >= 90) return 'text-red-600 dark:text-red-400'
if (pct >= 75) return 'text-amber-600 dark:text-amber-400'
return 'text-emerald-600 dark:text-emerald-400'
}
// 重置时间相对/绝对简短显示。
const formatReset = (iso: string) => {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const now = Date.now()
const diffMs = d.getTime() - now
if (diffMs <= 0) return t('admin.accounts.cnProviders.resetSoon')
if (diffMs < 3_600_000) return `${Math.max(1, Math.round(diffMs / 60_000))}m`
const hours = Math.round(diffMs / 3_600_000)
if (hours < 48) return `${hours}h`
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}-${dd}`
}
const handleProbe = async () => {
if (loading.value) return
loading.value = true
error.value = null
try {
const result = await adminAPI.cnProviders.queryQuota(props.account.id)
// 失败时保留已渲染的快照条形图(仅显示错误行),成功才覆盖。
if (result.success) {
data.value = result
} else {
error.value = result.error || t('common.error')
}
} catch (e) {
error.value = extractErrorMessage(e)
} finally {
loading.value = false
}
}
watch(
() => props.account.id,
() => {
data.value = null
error.value = null
loading.value = false
}
)
</script>
@@ -0,0 +1,53 @@
<template>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.mode + ':' + preset.protocol + ':' + preset.url"
type="button"
data-testid="cn-base-url-preset"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
isActive(preset)
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'bg-gray-100 text-gray-700 hover:bg-primary-50 hover:text-primary-700 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-primary-900/30 dark:hover:text-primary-400'
]"
@click="emit('select', preset)"
>
{{ preset.label }} ({{ displayUrl(preset.url) }})
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CN_BASE_URL_PRESETS, type CnBaseUrlPreset } from './credentialsBuilder'
// 国产供应商快捷端点:点击把预设地址(及对应账号类型/协议)回填到调用方。
// 与 Grok 预设一致,仅作快速填充,输入框仍接受任意第三方转发地址。
// 传入 protocol 时只显示该协议档的预设(协议 × 账号类型正交分档)。
const props = defineProps<{
platform: 'kimi' | 'zhipu' | 'deepseek'
/** 当前已选账号类型,用于过滤和高亮匹配的预设 */
mode?: 'payg' | 'coding'
/** 当前已选 API 协议,用于过滤和高亮匹配的预设 */
protocol?: 'chat_completions' | 'anthropic' | 'responses'
/** 当前输入框中的 base url,用于高亮完全匹配项 */
currentUrl?: string
}>()
const emit = defineEmits<{
(e: 'select', preset: CnBaseUrlPreset): void
}>()
const presets = computed(() => {
const all = CN_BASE_URL_PRESETS[props.platform] ?? []
// 只按协议过滤:同协议下 payg/coding 两档都展示,点击即同时切换账号类型。
if (props.protocol == null) return all
return all.filter(p => p.protocol === props.protocol)
})
const isActive = (preset: CnBaseUrlPreset) =>
(props.mode != null && preset.mode === props.mode) || preset.url === props.currentUrl
const displayUrl = (url: string) => url.replace(/^https?:\/\//i, '')
</script>
@@ -161,6 +161,48 @@
Grok
</button>
</div>
<!-- CN providers row: Kimi / Zhipu GLM / DeepSeek -->
<div class="mt-2 flex flex-wrap rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
@click="selectCNPlatform('kimi')"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'kimi'
? 'bg-white text-pink-600 shadow-sm dark:bg-dark-600 dark:text-pink-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="kimi" size="sm" />
Kimi
</button>
<button
type="button"
@click="selectCNPlatform('zhipu')"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'zhipu'
? 'bg-white text-indigo-600 shadow-sm dark:bg-dark-600 dark:text-indigo-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="zhipu" size="sm" />
Zhipu GLM
</button>
<button
type="button"
@click="selectCNPlatform('deepseek')"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'deepseek'
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="deepseek" size="sm" />
DeepSeek
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) -->
@@ -411,6 +453,100 @@
</div>
</div>
<!-- Account Mode Selection (Kimi / Zhipu / DeepSeek) -->
<div v-if="isCNPlatform">
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-mode">
<!-- Pay-as-you-go (token balance) -->
<button
type="button"
@click="accountMode = 'payg'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountMode === 'payg'
? cnAccentActiveClass
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountMode === 'payg'
? cnAccentIconClass
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="creditCard" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.cnProviders.accountMode.payg') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.cnProviders.accountMode.paygDesc') }}</span>
</div>
</button>
<!-- Coding Plan (kimi / zhipu only — DeepSeek has no coding plan) -->
<button
v-if="form.platform !== 'deepseek'"
type="button"
@click="accountMode = 'coding'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountMode === 'coding'
? cnAccentActiveClass
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountMode === 'coding'
? cnAccentIconClass
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="bolt" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.cnProviders.accountMode.coding') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.cnProviders.accountMode.codingDesc') }}</span>
</div>
</button>
</div>
</div>
<!-- API Protocol Selection (Kimi / Zhipu / DeepSeek) -->
<div v-if="isCNPlatform" class="mt-4">
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
v-for="opt in cnProtocolOptions"
:key="opt.value"
type="button"
@click="apiProtocol = opt.value"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
apiProtocol === opt.value
? cnAccentActiveClass
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
apiProtocol === opt.value
? cnAccentIconClass
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon :name="opt.value === 'anthropic' ? 'sparkles' : opt.value === 'responses' ? 'terminal' : 'chat'" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}`) }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}Desc`) }}</span>
</div>
</button>
</div>
</div>
<!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === 'gemini'">
<div class="flex items-center justify-between">
@@ -1120,15 +1256,7 @@
v-model="apiKeyBaseUrl"
type="text"
class="input"
:placeholder="
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: form.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
"
:placeholder="apiKeyBaseUrlPlaceholder"
/>
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
<GrokBaseUrlPresets
@@ -1136,6 +1264,15 @@
class="mt-2"
@select="apiKeyBaseUrl = $event"
/>
<CnBaseUrlPresets
v-if="isCNPlatform"
class="mt-2"
:platform="cnPresetPlatform"
:mode="accountMode"
:protocol="apiProtocol"
:current-url="apiKeyBaseUrl"
@select="onCnPresetSelect"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
@@ -1144,15 +1281,7 @@
type="password"
required
class="input font-mono"
:placeholder="
form.platform === 'openai'
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: form.platform === 'grok'
? 'xai-...'
: 'sk-ant-...'
"
:placeholder="apiKeyValuePlaceholder"
/>
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
</div>
@@ -3609,13 +3738,17 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import Toggle from '@/components/common/Toggle.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
defaultCNBaseUrl,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
type CnAccountMode,
type CnApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
@@ -3674,6 +3807,42 @@ const apiKeyHint = computed(() => {
return t('admin.accounts.apiKeyHint')
})
// Base URL / API Key 占位符:国产供应商随账号类型变化。
const apiKeyBaseUrlPlaceholder = computed(() => {
if (isCNPlatform.value) {
return defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value) || 'https://api.example.com'
}
switch (form.platform) {
case 'openai':
return 'https://api.openai.com'
case 'gemini':
return 'https://generativelanguage.googleapis.com'
case 'grok':
return 'https://api.x.ai/v1'
default:
return 'https://api.anthropic.com'
}
})
const apiKeyValuePlaceholder = computed(() => {
switch (form.platform) {
case 'openai':
return 'sk-proj-...'
case 'gemini':
return 'AIza...'
case 'grok':
return 'xai-...'
case 'kimi':
return 'sk-...'
case 'zhipu':
return '<api-key>.<secret>'
case 'deepseek':
return 'sk-...'
default:
return 'sk-ant-...'
}
})
interface Props {
show: boolean
proxies: Proxy[]
@@ -3753,6 +3922,86 @@ const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
const upstreamBillingAutoProbeEnabled = ref(true)
// ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ──
const accountMode = ref<CnAccountMode>('payg')
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。
const apiProtocol = ref<CnApiProtocol>('chat_completions')
const isCNPlatform = computed(
() => form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek'
)
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') {
return form.platform
}
return 'kimi'
})
// 当前平台可选的协议档(responses 仅 deepseek)。
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (form.platform === 'deepseek') {
opts.push({ value: 'responses', labelKey: 'responses' })
}
return opts
})
// 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。
const cnAccentActiveClass = computed(() => {
switch (form.platform) {
case 'kimi':
return 'border-pink-500 bg-pink-50 dark:bg-pink-900/20'
case 'zhipu':
return 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
case 'deepseek':
return 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
default:
return 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
}
})
const cnAccentIconClass = computed(() => {
switch (form.platform) {
case 'kimi':
return 'bg-pink-500 text-white'
case 'zhipu':
return 'bg-indigo-500 text-white'
case 'deepseek':
return 'bg-teal-500 text-white'
default:
return 'bg-primary-500 text-white'
}
})
// 切换国产供应商平台:强制 apikey 类型,deepseek 无 coding 套餐故锁定 payg,
// 协议回落 chat_completions,并把 base url 重置为该平台默认端点。
function selectCNPlatform(platform: 'kimi' | 'zhipu' | 'deepseek') {
form.platform = platform
form.type = 'apikey'
accountCategory.value = 'apikey'
apiProtocol.value = 'chat_completions'
if (platform === 'deepseek') {
accountMode.value = 'payg'
}
apiKeyBaseUrl.value = defaultCNBaseUrl(platform, accountMode.value, apiProtocol.value)
}
// 账号类型 / 协议变更时同步默认 base url。
watch(accountMode, (mode) => {
if (!isCNPlatform.value) return
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, mode, apiProtocol.value)
})
watch(apiProtocol, (protocol) => {
if (!isCNPlatform.value) return
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, accountMode.value, protocol)
})
// 点击预设端点:同时回填 base url、账号类型与协议。
function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol; url: string }) {
accountMode.value = preset.mode
apiProtocol.value = preset.protocol
apiKeyBaseUrl.value = preset.url
}
const syncPreviewCredentials = computed(() => {
if (!apiKeyValue.value) return undefined
return {
@@ -4248,14 +4497,18 @@ watch(
() => form.platform,
(newPlatform) => {
// Reset base URL based on platform
apiKeyBaseUrl.value =
(newPlatform === 'openai')
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: newPlatform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
if (newPlatform === 'kimi' || newPlatform === 'zhipu' || newPlatform === 'deepseek') {
apiKeyBaseUrl.value = defaultCNBaseUrl(newPlatform, accountMode.value, apiProtocol.value)
} else {
apiKeyBaseUrl.value =
(newPlatform === 'openai')
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: newPlatform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
}
// Clear model-related settings
allowedModels.value = []
modelMappings.value = []
@@ -4696,6 +4949,8 @@ const resetForm = () => {
form.expires_at = null
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
accountMode.value = 'payg'
apiProtocol.value = 'chat_completions'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
upstreamBillingAutoProbeEnabled.value = true
@@ -5158,6 +5413,20 @@ const handleSubmit = async () => {
credentials.tier_id = geminiTierAIStudio.value
}
// 国产供应商:账号模式 + 协议 + 对应端点写入凭据;后端按 account_mode 路由
// 额度/余额探测,按 api_protocol 路由转发端点与格式。注意 CN apikey 走本函数
// 的通用路径(直接 doCreateAccount),不经过 createAccountAndFinish。
if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') {
credentials.account_mode = accountMode.value
credentials.api_protocol = apiProtocol.value
const resolvedCNBase = (
apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value)
).trim()
if (resolvedCNBase) {
credentials.base_url = resolvedCNBase
}
}
// Add model mapping if configured(OpenAI 开启自动透传时不应用)
if (!isOpenAIModelRestrictionDisabled.value) {
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
@@ -52,6 +52,57 @@
class="mt-2"
@select="editBaseUrl = $event"
/>
<CnBaseUrlPresets
v-if="isCNApiKeyAccount"
class="mt-2"
:platform="cnPresetPlatform"
:mode="editAccountMode"
:protocol="editApiProtocol"
:current-url="editBaseUrl"
@select="onCnPresetSelect"
/>
</div>
<!-- Account Mode Selection (CN providers) -->
<div v-if="isCNApiKeyAccount">
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="opt in cnAccountModeOptions"
:key="opt.value"
type="button"
:class="[
'rounded-lg border-2 px-3 py-1.5 text-xs transition-all',
editAccountMode === opt.value
? 'border-primary-500 bg-primary-50 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'border-gray-200 text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:text-gray-300 dark:hover:border-gray-600'
]"
@click="editAccountMode = opt.value"
>
{{ t(`admin.accounts.cnProviders.accountMode.${opt.labelKey}`) }}
</button>
</div>
<p class="input-hint">{{ t(`admin.accounts.cnProviders.accountMode.${editAccountMode}Desc`) }}</p>
</div>
<!-- API Protocol Selection (CN providers) -->
<div v-if="isCNApiKeyAccount">
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="opt in cnProtocolOptions"
:key="opt.value"
type="button"
:class="[
'rounded-lg border-2 px-3 py-1.5 text-xs transition-all',
editApiProtocol === opt.value
? 'border-primary-500 bg-primary-50 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'border-gray-200 text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:text-gray-300 dark:hover:border-gray-600'
]"
@click="editApiProtocol = opt.value"
>
{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}`) }}
</button>
</div>
<p class="input-hint">{{ t(`admin.accounts.cnProviders.apiProtocol.${cnProtocolDescKey}Desc`) }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
@@ -2710,7 +2761,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
@@ -2737,6 +2788,7 @@ import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
import {
@@ -2750,8 +2802,11 @@ import {
isHeaderOverrideCapable,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
defaultCNBaseUrl,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type CnAccountMode,
type CnApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
@@ -2829,6 +2884,78 @@ interface TempUnschedRuleForm {
const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com')
const editApiKey = ref('')
// ── 国产供应商(Kimi / Zhipu / DeepSeek)account_mode / api_protocol 编辑 ──
// account_mode 决定额度/余额监控路径,api_protocol 决定转发端点与格式;
// 二者均可修正(早期创建的账号可能存错默认值),切换时重置 base_url 预置。
const isCNApiKeyAccount = computed(
() =>
props.account?.type === 'apikey' &&
(props.account.platform === 'kimi' ||
props.account.platform === 'zhipu' ||
props.account.platform === 'deepseek')
)
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
const platform = props.account?.platform
if (platform === 'kimi' || platform === 'zhipu' || platform === 'deepseek') {
return platform
}
return 'kimi'
})
const editApiProtocol = ref<CnApiProtocol>('chat_completions')
const editAccountMode = ref<CnAccountMode>('payg')
// 回填窗口标志:syncFormFromAccount 会同步改写 editAccountMode / editApiProtocol,
// 而 watcher(pre-flush)在同步代码执行完之后才触发——若不抑制,会把刚恢复的
// 存储版 base_url(可能是用户自定义/中转地址)覆盖为官方预设并在下次保存时持久化。
// nextTick 后解除,此后用户主动切换模式/协议仍正常联动重置。
const syncingForm = ref(false)
const cnAccountModeOptions = computed<Array<{ value: CnAccountMode; labelKey: 'payg' | 'coding' }>>(
() => {
// DeepSeek 无 coding 套餐(与创建弹窗一致),仅保留按量付费。
if (props.account?.platform === 'deepseek') {
return [{ value: 'payg', labelKey: 'payg' }]
}
return [
{ value: 'payg', labelKey: 'payg' },
{ value: 'coding', labelKey: 'coding' }
]
}
)
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
if (props.account?.platform === 'deepseek') {
opts.push({ value: 'responses', labelKey: 'responses' })
}
return opts
})
watch(editApiProtocol, (protocol) => {
if (!isCNApiKeyAccount.value || syncingForm.value) return
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol)
})
watch(editAccountMode, (mode) => {
if (!isCNApiKeyAccount.value || syncingForm.value) return
// deepseek 无 coding 套餐:防御性回退(UI 已隐藏该选项)。
const effectiveMode = props.account!.platform === 'deepseek' && mode === 'coding' ? 'payg' : mode
if (effectiveMode !== mode) {
editAccountMode.value = effectiveMode
return
}
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, mode, editApiProtocol.value)
})
const cnProtocolDescKey = computed(
() => cnProtocolOptions.value.find(o => o.value === editApiProtocol.value)?.labelKey ?? 'chatCompletions'
)
// 点击预设端点:回填 base url 与对应模式/协议。
function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol; url: string }) {
editAccountMode.value = preset.mode
editApiProtocol.value = preset.protocol
editBaseUrl.value = preset.url
}
// Bedrock credentials
const editBedrockAccessKeyId = ref('')
const editBedrockSecretAccessKey = ref('')
@@ -3272,6 +3399,15 @@ const defaultBaseUrl = computed(() => {
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
if (props.account?.platform === 'grok') return 'https://api.x.ai/v1'
// CN 供应商:按当前模式/协议回落到官方预设(清空输入框提交时使用),
// 不能落到 anthropic 默认值(会被当 CC base 拼出错误端点)。
if (
props.account?.platform === 'kimi' ||
props.account?.platform === 'zhipu' ||
props.account?.platform === 'deepseek'
) {
return defaultCNBaseUrl(props.account.platform, editAccountMode.value, editApiProtocol.value)
}
return 'https://api.anthropic.com'
})
@@ -3381,6 +3517,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
if (!newAccount) {
return
}
// 进入回填窗口:抑制 CN 模式/协议 watcher 联动重置 base_url(见 syncingForm 注释)。
syncingForm.value = true
void nextTick(() => {
syncingForm.value = false
})
antigravityMixedChannelConfirmed.value = false
showMixedChannelWarning.value = false
mixedChannelWarningDetails.value = null
@@ -3622,6 +3763,17 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
// 国产供应商:读取 account_mode 与 api_protocol 作为可编辑初始值
// (编辑弹窗允许修正两者,用于修复早期存错默认值的账号)。
if (newAccount.platform === 'kimi' || newAccount.platform === 'zhipu' || newAccount.platform === 'deepseek') {
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
const storedProtocol = credentials.api_protocol
editApiProtocol.value =
storedProtocol === 'anthropic' || storedProtocol === 'responses' ? storedProtocol : 'chat_completions'
if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') {
editApiProtocol.value = 'chat_completions'
}
}
const platformDefaultUrl =
newAccount.platform === 'openai'
? 'https://api.openai.com'
@@ -3629,7 +3781,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
? 'https://generativelanguage.googleapis.com'
: newAccount.platform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
: newAccount.platform === 'kimi' ||
newAccount.platform === 'zhipu' ||
newAccount.platform === 'deepseek'
? defaultCNBaseUrl(newAccount.platform, editAccountMode.value, editApiProtocol.value)
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
@@ -4298,6 +4454,12 @@ const handleSubmit = async () => {
base_url: newBaseUrl
}
// 国产供应商:模式与协议写入凭据(决定额度/余额探测与转发端点/格式)。
if (isCNApiKeyAccount.value) {
newCredentials.account_mode = editAccountMode.value
newCredentials.api_protocol = editApiProtocol.value
}
// Handle API key
// 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。
// 用户填入新值则覆盖;留空时优先看 credentials_status.has_api_key;
@@ -199,7 +199,16 @@ const normalizedPlatforms = computed(() => {
)
})
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity', 'grok'])
const upstreamSyncPlatforms = new Set([
'anthropic',
'openai',
'gemini',
'antigravity',
'grok',
'kimi',
'zhipu',
'deepseek'
])
const canSyncUpstream = computed(() => {
if (props.accountId) {
if (normalizedPlatforms.value.length === 0) return true
@@ -242,6 +242,91 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
{ label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' }
]
// ========== 国产供应商(Kimi / Zhipu / DeepSeek)base_url 预设 ==========
// 与后端 service/domain_constants.go 的默认 base url 保持一致。
// 账号类型(payg 按量付费 / coding 编程套餐)决定额度监控方式;
// API 协议(chat_completions / anthropic / responses)决定转发端点与格式,
// 两者正交。同协议请求零转换直通,跨协议组合才走转换链。
export type CnAccountMode = 'payg' | 'coding'
/** 仅 deepseek 支持 responses 协议(官方原生 /responses 端点,适配 Codex)。 */
export type CnApiProtocol = 'chat_completions' | 'anthropic' | 'responses'
export interface CnBaseUrlPreset {
mode: CnAccountMode
protocol: CnApiProtocol
/** 专有名词,不参与 i18n */
label: string
url: string
}
/** 各供应商按账号类型 × API 协议分档的快捷端点(点击快速填充,输入框仍可自由填写)。 */
export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUrlPreset[]> = {
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: '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' }
],
zhipu: [
{ mode: 'payg', protocol: 'chat_completions', label: 'GLM PaaS', url: 'https://open.bigmodel.cn/api/paas/v4' },
{ mode: 'payg', protocol: 'anthropic', label: 'GLM Anthropic', url: 'https://open.bigmodel.cn/api/anthropic' },
{ mode: 'coding', protocol: 'chat_completions', label: 'GLM Coding', url: 'https://open.bigmodel.cn/api/coding/paas/v4' },
{ mode: 'coding', protocol: 'anthropic', label: 'GLM Coding Anthropic', url: 'https://open.bigmodel.cn/api/anthropic' }
],
deepseek: [
{ mode: 'payg', protocol: 'chat_completions', label: 'DeepSeek', url: 'https://api.deepseek.com' },
{ mode: 'payg', protocol: 'anthropic', label: 'DeepSeek Anthropic', url: 'https://api.deepseek.com/anthropic' },
{ mode: 'payg', protocol: 'responses', label: 'DeepSeek Responses', url: 'https://api.deepseek.com' }
]
}
/** 返回指定供应商 + 账号类型 + API 协议的默认 base url。 */
export function defaultCNBaseUrl(
platform: string,
mode: CnAccountMode,
protocol: CnApiProtocol = 'chat_completions'
): string {
if (protocol === 'anthropic') {
switch (platform) {
case 'kimi':
return mode === 'coding' ? 'https://api.kimi.com/coding' : 'https://api.moonshot.cn/anthropic'
case 'zhipu':
return 'https://open.bigmodel.cn/api/anthropic'
case 'deepseek':
return 'https://api.deepseek.com/anthropic'
default:
return ''
}
}
// responses 仅 deepseek:base 与 chat_completions 相同(端点路径差异由后端处理)。
switch (platform) {
case 'kimi':
return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1'
case 'zhipu':
return mode === 'coding'
? 'https://open.bigmodel.cn/api/coding/paas/v4'
: 'https://open.bigmodel.cn/api/paas/v4'
case 'deepseek':
return 'https://api.deepseek.com'
default:
return ''
}
}
// ===== 国产供应商用量单元格可见性(单一事实源) =====
// CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定
// 共用,避免多处复制条件后一处改另一处漏改。
export function cnQuotaCellVisible(platform: string, accountMode: string): boolean {
return (platform === 'kimi' || platform === 'zhipu') && accountMode === 'coding'
}
export function cnBalanceCellVisible(platform: string, accountMode: string): boolean {
return (platform === 'kimi' || platform === 'deepseek') && accountMode !== 'coding'
}
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
@@ -25,6 +25,25 @@
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"
/>
</svg>
<!-- Kimi / Moonshot official logo mark (stylized K) -->
<svg v-else-if="platform === 'kimi'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
d="M21.765.351C22.998.351 24 1.353 24 2.586S22.998 4.82 21.765 4.82h-1.974c-.15 0-.26-.12-.26-.26V2.586A2.237 2.237 0 0 1 21.765.35M9.41 13.388l8.447-8.377c.16-.16.07-.471-.14-.471h-4.55s-.1.02-.14.06l-9.099 9.029c-.14.14-.35.02-.35-.21V4.81c0-.15-.1-.27-.221-.27H.22c-.12 0-.22.12-.22.27v18.57c0 .15.1.27.22.27h3.137c.12 0 .22-.12.22-.27v-3.79c0-.08.03-.16.08-.21l2.826-2.796c.07-.07.16-.08.241-.03l7.546 5.551a8.9 8.9 0 0 0 4.018 1.493c.12.01.23-.11.23-.27V19.76c0-.14-.08-.25-.19-.26a5.8 5.8 0 0 1-2.355-.942l-6.533-4.73c-.14-.09-.15-.32-.03-.441"
/>
</svg>
<!-- Zhipu AI official logo mark (particle constellation) -->
<svg v-else-if="platform === 'zhipu'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248z"
/>
</svg>
<!-- DeepSeek official logo mark (whale) -->
<svg v-else-if="platform === 'deepseek'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"
/>
</svg>
<!-- Composite group icon -->
<svg v-else-if="platform === 'composite'" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="6" cy="12" r="3" />
@@ -90,6 +90,9 @@ const platformLabel = computed(() => {
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
if (props.platform === 'grok') return 'Grok'
if (props.platform === 'kimi') return 'Kimi'
if (props.platform === 'zhipu') return 'Zhipu GLM'
if (props.platform === 'deepseek') return 'DeepSeek'
return 'Gemini'
})
@@ -188,6 +191,15 @@ const platformClass = computed(() => {
if (props.platform === 'grok') {
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'
}
if (props.platform === 'kimi') {
return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400'
}
if (props.platform === 'zhipu') {
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
}
if (props.platform === 'deepseek') {
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -204,6 +216,15 @@ const typeClass = computed(() => {
if (props.platform === 'grok') {
return 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'
}
if (props.platform === 'kimi') {
return 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400'
}
if (props.platform === 'zhipu') {
return 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400'
}
if (props.platform === 'deepseek') {
return 'bg-teal-100 text-teal-600 dark:bg-teal-900/30 dark:text-teal-400'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -182,7 +182,9 @@ const yiModels = [
// Moonshot/Kimi
const moonshotModels = [
'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k',
'kimi-latest'
'kimi-latest',
'kimi-for-coding',
'kimi-k2'
]
// 字节跳动 豆包
@@ -428,7 +430,8 @@ export function getModelsByPlatform(platform: string): string[] {
case 'grok': return xaiModels
case 'cohere': return cohereModels
case 'yi': return yiModels
case 'moonshot': return moonshotModels
case 'moonshot':
case 'kimi': return moonshotModels
case 'doubao': return doubaoModels
case 'minimax': return minimaxModels
case 'baidu': return baiduModels
@@ -104,6 +104,33 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
},
cnProviders: {
accountMode: {
title: 'Account Type',
payg: 'Pay-as-you-go',
paygDesc: 'Consumes account balance, billed per token. Auto-cools down on low balance and recovers after top-up.',
coding: 'Coding Plan',
codingDesc: 'Subscription coding package, rate-limited by 5-hour / weekly rolling usage windows.',
},
apiProtocol: {
title: 'API Protocol',
chatCompletions: 'Chat Completions',
chatCompletionsDesc: 'Standard OpenAI-compatible endpoint; requests in other formats are converted.',
anthropic: 'Anthropic',
anthropicDesc: 'Native passthrough to the provider’s Anthropic endpoint — ideal for Claude Code.',
responses: 'Responses',
responsesDesc: 'Provider’s native Responses endpoint — ideal for Codex.',
},
window5h: '5-hour window',
windowWeekly: 'Weekly window',
probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage',
balanceLow: 'Insufficient balance',
noBalanceEndpoint: 'This platform has no balance query endpoint',
resetSoon: 'reset soon',
},
types: {
oauth: 'OAuth',
@@ -307,6 +307,33 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
},
cnProviders: {
accountMode: {
title: '账号类型',
payg: '按量付费',
paygDesc: '消耗账户余额,按 Token 计费。余额不足自动冷却,充值后恢复。',
coding: 'Coding Plan',
codingDesc: '订阅制编程套餐,按 5 小时 / 每周滚动用量窗口限流。',
},
apiProtocol: {
title: 'API 协议',
chatCompletions: 'Chat Completions',
chatCompletionsDesc: '标准 OpenAI 兼容端点,其他格式请求将被转换。',
anthropic: 'Anthropic',
anthropicDesc: '直通供应商原生 Anthropic 端点,零转换,适配 Claude Code。',
responses: 'Responses',
responsesDesc: '供应商原生 Responses 端点,适配 Codex。',
},
window5h: '5 小时窗口',
windowWeekly: '每周窗口',
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
balanceLow: '余额不足',
noBalanceEndpoint: '该平台暂无余额查询接口',
resetSoon: '即将重置',
},
types: {
oauth: 'OAuth',
+2 -2
View File
@@ -525,7 +525,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite'
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'composite'
export type VideoModelPrices = Record<string, Record<string, number>>
@@ -882,7 +882,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
+63 -2
View File
@@ -5,7 +5,16 @@
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'grok' | 'composite'
export type Platform =
| 'anthropic'
| 'openai'
| 'antigravity'
| 'gemini'
| 'grok'
| 'kimi'
| 'zhipu'
| 'deepseek'
| 'composite'
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
@@ -14,6 +23,9 @@ const BADGE: Record<Platform, string> = {
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
grok: 'bg-zinc-800/10 text-zinc-800 border-zinc-800/30 dark:bg-zinc-500/10 dark:text-zinc-200 dark:border-zinc-500/30',
kimi: 'bg-pink-500/10 text-pink-600 border-pink-500/30 dark:text-pink-400',
zhipu: 'bg-indigo-500/10 text-indigo-600 border-indigo-500/30 dark:text-indigo-400',
deepseek: 'bg-teal-500/10 text-teal-600 border-teal-500/30 dark:text-teal-400',
composite: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/30 dark:text-cyan-300',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
@@ -25,6 +37,9 @@ const BADGE_LIGHT: Record<Platform, string> = {
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
grok: 'bg-zinc-800/10 text-zinc-800 dark:bg-zinc-500/10 dark:text-zinc-200',
kimi: 'bg-pink-500/10 text-pink-600 dark:bg-pink-500/10 dark:text-pink-300',
zhipu: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300',
deepseek: 'bg-teal-500/10 text-teal-600 dark:bg-teal-500/10 dark:text-teal-300',
composite: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-500/10 dark:text-cyan-300',
}
@@ -35,6 +50,9 @@ const BORDER: Record<Platform, string> = {
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
grok: 'border-zinc-800/20 dark:border-zinc-500/20',
kimi: 'border-pink-500/20 dark:border-pink-500/20',
zhipu: 'border-indigo-500/20 dark:border-indigo-500/20',
deepseek: 'border-teal-500/20 dark:border-teal-500/20',
composite: 'border-cyan-500/20 dark:border-cyan-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -46,6 +64,9 @@ const BORDER_STRONG: Record<Platform, string> = {
antigravity: 'border-purple-500/35 dark:border-purple-500/30',
gemini: 'border-blue-500/35 dark:border-blue-500/30',
grok: 'border-zinc-800/35 dark:border-zinc-500/35',
kimi: 'border-pink-500/35 dark:border-pink-500/30',
zhipu: 'border-indigo-500/35 dark:border-indigo-500/30',
deepseek: 'border-teal-500/35 dark:border-teal-500/30',
composite: 'border-cyan-500/35 dark:border-cyan-500/30',
}
const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600'
@@ -58,6 +79,9 @@ const ACCENT: Record<Platform, string> = {
antigravity: '#a855f7', // purple-500
gemini: '#3b82f6', // blue-500
grok: '#71717a', // zinc-500
kimi: '#ec4899', // pink-500
zhipu: '#6366f1', // indigo-500
deepseek: '#14b8a6', // teal-500
composite: '#06b6d4', // cyan-500
}
const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal)
@@ -69,6 +93,9 @@ const ACCENT_BAR: Record<Platform, string> = {
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
grok: 'bg-gradient-to-r from-zinc-700 to-zinc-900',
kimi: 'bg-gradient-to-r from-pink-400 to-pink-500',
zhipu: 'bg-gradient-to-r from-indigo-400 to-indigo-500',
deepseek: 'bg-gradient-to-r from-teal-400 to-teal-500',
composite: 'bg-gradient-to-r from-slate-500 to-cyan-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
@@ -80,6 +107,9 @@ const TEXT: Record<Platform, string> = {
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
grok: 'text-zinc-800 dark:text-zinc-200',
kimi: 'text-pink-600 dark:text-pink-400',
zhipu: 'text-indigo-600 dark:text-indigo-400',
deepseek: 'text-teal-600 dark:text-teal-400',
composite: 'text-cyan-700 dark:text-cyan-300',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
@@ -91,6 +121,9 @@ const ICON: Record<Platform, string> = {
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
grok: 'text-zinc-800 dark:text-zinc-200',
kimi: 'text-pink-500 dark:text-pink-400',
zhipu: 'text-indigo-500 dark:text-indigo-400',
deepseek: 'text-teal-500 dark:text-teal-400',
composite: 'text-cyan-600 dark:text-cyan-300',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
@@ -102,6 +135,9 @@ const BUTTON: Record<Platform, string> = {
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
grok: 'bg-zinc-800 text-white hover:bg-zinc-900 active:bg-black dark:bg-zinc-700 dark:hover:bg-zinc-600',
kimi: 'bg-pink-500 text-white hover:bg-pink-600 active:bg-pink-700 dark:bg-pink-500/80 dark:hover:bg-pink-500',
zhipu: 'bg-indigo-500 text-white hover:bg-indigo-600 active:bg-indigo-700 dark:bg-indigo-500/80 dark:hover:bg-indigo-500',
deepseek: 'bg-teal-500 text-white hover:bg-teal-600 active:bg-teal-700 dark:bg-teal-500/80 dark:hover:bg-teal-500',
composite: 'bg-cyan-700 text-white hover:bg-cyan-800 active:bg-cyan-900 dark:bg-cyan-600 dark:hover:bg-cyan-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
@@ -113,6 +149,9 @@ const DISCOUNT: Record<Platform, string> = {
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
grok: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
kimi: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
zhipu: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
deepseek: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
composite: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -124,6 +163,9 @@ const GRADIENT: Record<Platform, string> = {
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
grok: 'from-zinc-700 to-zinc-900',
kimi: 'from-pink-500 to-pink-600',
zhipu: 'from-indigo-500 to-indigo-600',
deepseek: 'from-teal-500 to-teal-600',
composite: 'from-slate-600 to-cyan-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
@@ -135,6 +177,9 @@ const GRADIENT_TEXT: Record<Platform, string> = {
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
grok: 'text-zinc-100',
kimi: 'text-pink-100',
zhipu: 'text-indigo-100',
deepseek: 'text-teal-100',
composite: 'text-cyan-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
@@ -145,6 +190,9 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
grok: 'text-zinc-300',
kimi: 'text-pink-200',
zhipu: 'text-indigo-200',
deepseek: 'text-teal-200',
composite: 'text-cyan-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
@@ -152,7 +200,17 @@ const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'grok' || p === 'composite'
return (
p === 'anthropic' ||
p === 'openai' ||
p === 'antigravity' ||
p === 'gemini' ||
p === 'grok' ||
p === 'kimi' ||
p === 'zhipu' ||
p === 'deepseek' ||
p === 'composite'
)
}
export function platformBadgeClass(p: string): string {
@@ -214,6 +272,9 @@ export function platformLabel(p: string): string {
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
case 'grok': return 'Grok'
case 'kimi': return 'Kimi'
case 'zhipu': return 'Zhipu GLM'
case 'deepseek': return 'DeepSeek'
case 'composite': return 'Composite'
default: return p || 'API'
}