feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取

代理节点:
- 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签
- 新增手动节点的 CRUD API 和前端管理界面
- 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id)
- 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用
- 删除节点时自动清除系统默认代理引用并失效缓存
- 健康检查跳过手动节点(无心跳,始终在线)

流式处理:
- CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage
- StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage
- 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
This commit is contained in:
fawney19
2026-02-07 14:30:46 +08:00
parent 86dbbe83c2
commit db96c9a46e
30 changed files with 1182 additions and 243 deletions

View File

@@ -9,7 +9,7 @@
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
export const anyrouterTemplate: AuthTemplate = {
id: 'anyrouter',
@@ -52,7 +52,7 @@ export const anyrouterTemplate: AuthTemplate = {
connector: {
auth_type: 'cookie',
config: {
proxy: buildProxyUrl(formData),
...buildProxyConfig(formData),
},
credentials: {
session_cookie: formData.session_cookie,
@@ -64,7 +64,7 @@ export const anyrouterTemplate: AuthTemplate = {
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',

View File

@@ -9,7 +9,7 @@
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
/**
* 格式化窗口限额显示(百分比格式)
@@ -92,7 +92,7 @@ export const cubenceTemplate: AuthTemplate = {
connector: {
auth_type: 'cookie',
config: {
proxy: buildProxyUrl(formData),
...buildProxyConfig(formData),
},
credentials: {
token_cookie: formData.token_cookie,
@@ -104,7 +104,7 @@ export const cubenceTemplate: AuthTemplate = {
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
token_cookie: config?.connector?.credentials?.token_cookie || '',

View File

@@ -8,7 +8,7 @@
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
export const nekocodeTemplate: AuthTemplate = {
id: 'nekocode',
@@ -51,7 +51,7 @@ export const nekocodeTemplate: AuthTemplate = {
connector: {
auth_type: 'cookie',
config: {
proxy: buildProxyUrl(formData),
...buildProxyConfig(formData),
},
credentials: {
session_cookie: formData.session_cookie,
@@ -63,7 +63,7 @@ export const nekocodeTemplate: AuthTemplate = {
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
session_cookie: config?.connector?.credentials?.session_cookie || '',

View File

@@ -9,7 +9,7 @@
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
export const newApiTemplate: AuthTemplate = {
id: 'new_api',
@@ -80,7 +80,7 @@ export const newApiTemplate: AuthTemplate = {
auth_type: 'api_key',
config: {
auth_method: 'bearer',
proxy: buildProxyUrl(formData),
...buildProxyConfig(formData),
},
credentials: {
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
@@ -95,7 +95,7 @@ export const newApiTemplate: AuthTemplate = {
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
api_key: config?.connector?.credentials?.api_key || '',

View File

@@ -165,8 +165,30 @@ export interface AuthTemplateRegistry {
// ==================== 通用字段定义 ====================
/**
* 代理地址字段
* 代理节点 ID 字段
*/
export const PROXY_NODE_FIELD: AuthTemplateField = {
key: 'proxy_node_id',
label: '代理节点',
type: 'select',
placeholder: '选择代理节点...',
required: false,
}
/**
* 通用代理配置字段组(可折叠,带启用开关)
* 由 ProviderAuthDialog 特殊渲染为代理节点选择器
*/
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
title: '代理配置',
fields: [PROXY_NODE_FIELD],
collapsible: true,
defaultExpanded: false,
hasToggle: true,
toggleKey: 'proxy_enabled',
}
// 兼容旧代码的字段导出
export const PROXY_URL_FIELD: AuthTemplateField = {
key: 'proxy_url',
label: '代理地址',
@@ -174,10 +196,6 @@ export const PROXY_URL_FIELD: AuthTemplateField = {
placeholder: 'http://proxy:port 或 socks5://',
required: false,
}
/**
* 代理用户名字段
*/
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
key: 'proxy_username',
label: '用户名',
@@ -185,10 +203,6 @@ export const PROXY_USERNAME_FIELD: AuthTemplateField = {
placeholder: '可选',
required: false,
}
/**
* 代理密码字段
*/
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
key: 'proxy_password',
label: '密码',
@@ -199,96 +213,46 @@ export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
}
/**
* 通用代理配置字段组(可折叠,带启用开关
*/
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
title: '代理配置',
fields: [PROXY_URL_FIELD, PROXY_USERNAME_FIELD, PROXY_PASSWORD_FIELD],
collapsible: true,
defaultExpanded: false,
hasToggle: true,
toggleKey: 'proxy_enabled',
}
/**
* 构建代理 URL包含认证信息
* 构建代理配置(仅代理节点模式
*
* @param formData 表单数据
* @returns 完整的代理 URL或 undefined
* @returns 代理配置对象,展开到 connector.config 中
*/
export function buildProxyUrl(formData: Record<string, any>): string | undefined {
if (!formData.proxy_enabled || !formData.proxy_url) {
return undefined
}
const proxyUrl = formData.proxy_url.trim()
const username = formData.proxy_username?.trim()
const password = formData.proxy_password?.trim()
// 如果没有认证信息,直接返回 URL
if (!username) {
return proxyUrl
}
// 解析 URL 并添加认证信息
try {
const url = new URL(proxyUrl)
url.username = username
if (password) {
url.password = password
}
return url.toString()
} catch {
// URL 解析失败,尝试简单拼接
const protocol = proxyUrl.includes('://') ? proxyUrl.split('://')[0] : 'http'
const host = proxyUrl.includes('://') ? proxyUrl.split('://')[1] : proxyUrl
const auth = password ? `${username}:${password}` : username
return `${protocol}://${auth}@${host}`
export function buildProxyConfig(formData: Record<string, any>): { proxy_node_id?: string } {
if (!formData.proxy_enabled || !formData.proxy_node_id) {
return {}
}
return { proxy_node_id: formData.proxy_node_id }
}
/**
* 从代理 URL 解析表单数据
* 解析代理配置
*
* @param proxyUrl 代理 URL
* @param config connector.config 对象
* @returns 表单数据
*/
export function parseProxyUrl(proxyUrl: string | undefined): Record<string, any> {
if (!proxyUrl) {
export function parseProxyConfig(config: any): Record<string, any> {
// 代理节点模式
if (config?.proxy_node_id) {
return {
proxy_enabled: false,
proxy_url: '',
proxy_username: '',
proxy_password: '',
proxy_enabled: true,
proxy_node_id: config.proxy_node_id,
}
}
try {
const url = new URL(proxyUrl)
const username = url.username || ''
const password = url.password || ''
// 移除认证信息后的 URL
url.username = ''
url.password = ''
const cleanUrl = url.toString()
// 兼容旧数据(手动 URL 模式)- 标记为启用但无节点
if (config?.proxy) {
return {
proxy_enabled: true,
proxy_url: cleanUrl,
proxy_username: username,
proxy_password: password,
}
} catch {
// 解析失败,直接使用原始 URL
return {
proxy_enabled: true,
proxy_url: proxyUrl,
proxy_username: '',
proxy_password: '',
proxy_node_id: '',
}
}
return {
proxy_enabled: false,
proxy_node_id: '',
}
}
// 兼容旧的导出(已废弃,请使用 PROXY_FIELD_GROUP
// 兼容旧的导出
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD

View File

@@ -10,7 +10,7 @@
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
/**
* 格式化限额显示(百分比格式)
@@ -65,7 +65,7 @@ export const yescodeTemplate: AuthTemplate = {
connector: {
auth_type: 'cookie',
config: {
proxy: buildProxyUrl(formData),
...buildProxyConfig(formData),
},
credentials: {
auth_cookie: formData.auth_cookie,
@@ -77,7 +77,7 @@ export const yescodeTemplate: AuthTemplate = {
},
parseConfig(config: any): Record<string, any> {
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
const proxyData = parseProxyConfig(config?.connector?.config)
return {
base_url: config?.base_url || '',
auth_cookie: config?.connector?.credentials?.auth_cookie || '',

View File

@@ -53,7 +53,7 @@
v-for="(group, groupIndex) in fieldGroups"
:key="groupIndex"
>
<!-- 可折叠的分组代理配置 -->
<!-- 可折叠的分组代理配置 - 代理节点选择 -->
<div
v-if="group.collapsible && group.hasToggle && group.toggleKey"
class="space-y-2"
@@ -65,50 +65,22 @@
<span class="text-xs text-muted-foreground">启用代理</span>
<Switch
:model-value="formData[group.toggleKey] || false"
@update:model-value="formData[group.toggleKey] = $event"
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
/>
</div>
</div>
<!-- 展开内容卡片 -->
<!-- 展开内容卡片- 代理节点选择 -->
<div
v-if="formData[group.toggleKey]"
class="rounded-lg border border-border bg-muted/30 px-4 py-3"
>
<!-- 横向排列的字段代理地址占更多空间 -->
<div class="flex gap-3">
<div
v-for="(field, fieldIndex) in group.fields"
:key="field.key"
class="space-y-1"
:class="fieldIndex === 0 ? 'flex-[2]' : 'flex-1'"
>
<Label class="text-xs text-muted-foreground">
{{ field.label }}
</Label>
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
class="h-8 text-sm"
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
class="h-8 text-sm"
@update:model-value="handleFieldChange(field.key, $event)"
/>
</div>
</div>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
:model-value="formData.proxy_node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
/>
</div>
</div>
@@ -304,6 +276,8 @@ import {
type AuthTemplate,
type AuthTemplateFieldGroup,
} from '../auth-templates'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
open: boolean
@@ -322,6 +296,16 @@ const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cooki
const { success: showSuccess, error: showError } = useToast()
const { confirmDanger } = useConfirm()
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore()
/** 启用代理时加载节点列表(直接调用 store避免 ref 未挂载时静默失败) */
function handleProxyToggle(toggleKey: string, value: boolean) {
formData.value[toggleKey] = value
if (value) {
proxyNodesStore.ensureLoaded()
}
}
// State
const isSaving = ref(false)

View File

@@ -238,47 +238,20 @@
<div class="flex items-center gap-2">
<Switch
:model-value="form.proxy_enabled"
@update:model-value="(v: boolean) => form.proxy_enabled = v"
@update:model-value="handleProxyToggle"
/>
<span class="text-sm text-muted-foreground">启用代理</span>
</div>
</div>
<div
v-if="form.proxy_enabled"
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
class="space-y-1.5 p-3 border rounded-lg bg-muted/50"
>
<div class="space-y-1.5">
<Label class="text-xs">代理地址 *</Label>
<Input
v-model="form.proxy_url"
placeholder="http://proxy:port 或 socks5://proxy:port"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">用户名</Label>
<Input
v-model="form.proxy_username"
placeholder="可选"
autocomplete="off"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">密码</Label>
<Input
v-model="form.proxy_password"
type="password"
placeholder="可选"
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
</div>
<Label class="text-xs">代理节点 *</Label>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
v-model="form.proxy_node_id"
/>
</div>
</div>
</form>
@@ -322,6 +295,8 @@ import { useFormDialog } from '@/composables/useFormDialog'
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
modelValue: boolean
@@ -337,6 +312,16 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore()
/** 启用代理时懒加载节点列表 */
function handleProxyToggle(v: boolean) {
form.value.proxy_enabled = v
if (v) {
proxyNodesStore.ensureLoaded()
}
}
// 内部状态
const internalOpen = computed(() => props.modelValue)
@@ -372,11 +357,9 @@ const form = ref({
// 超时配置(秒)
stream_first_byte_timeout: undefined as number | undefined,
request_timeout: undefined as number | undefined,
// 代理配置(扁平化便于表单绑定)
// 代理配置
proxy_enabled: false,
proxy_url: '',
proxy_username: '',
proxy_password: '',
proxy_node_id: '',
})
// 重置表单
@@ -403,9 +386,7 @@ function resetForm() {
request_timeout: undefined,
// 代理配置
proxy_enabled: false,
proxy_url: '',
proxy_username: '',
proxy_password: '',
proxy_node_id: '',
}
}
@@ -438,9 +419,12 @@ function loadProviderData() {
request_timeout: props.provider.request_timeout ?? undefined,
// 代理配置
proxy_enabled: proxy?.enabled ?? false,
proxy_url: proxy?.url || '',
proxy_username: proxy?.username || '',
proxy_password: proxy?.password || '',
proxy_node_id: proxy?.node_id || '',
}
// 如果有代理配置,确保加载节点列表(直接调用 store避免 ref 未挂载时静默失败)
if (proxy?.enabled) {
proxyNodesStore.ensureLoaded()
}
}
@@ -462,19 +446,17 @@ const handleSubmit = async () => {
return
}
// 启用代理时必须填写代理地址
if (form.value.proxy_enabled && !form.value.proxy_url) {
showError('启用代理时必须填写代理地址', '验证失败')
// 启用代理时的验证
if (form.value.proxy_enabled && !form.value.proxy_node_id) {
showError('请选择代理节点', '验证失败')
return
}
loading.value = true
try {
// 构建代理配置
const proxy = form.value.proxy_enabled ? {
url: form.value.proxy_url,
username: form.value.proxy_username || undefined,
password: form.value.proxy_password || undefined,
const proxy = form.value.proxy_enabled && form.value.proxy_node_id ? {
node_id: form.value.proxy_node_id,
enabled: true,
} : null

View File

@@ -0,0 +1,77 @@
<template>
<div class="space-y-1.5">
<Select
:model-value="modelValue"
:disabled="proxyNodesStore.loading || nodeOptions.length === 0"
@update:model-value="(v: string) => $emit('update:modelValue', v)"
>
<SelectTrigger :class="triggerClass">
<SelectValue
:placeholder="proxyNodesStore.loading
? '加载节点列表中...'
: nodeOptions.length === 0
? '暂无可用节点'
: '选择代理节点...'"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="node in nodeOptions"
:key="node.id"
:value="node.id"
>
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
</SelectItem>
</SelectContent>
</Select>
<p
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
class="text-xs text-muted-foreground"
>
暂无在线代理节点,请在「代理节点」页面添加
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
modelValue: string
triggerClass?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const proxyNodesStore = useProxyNodesStore()
/** 在线节点 + 保留当前已选节点(可能已离线) */
const nodeOptions = computed(() => {
const online = proxyNodesStore.onlineNodes
if (props.modelValue) {
const found = online.find(n => n.id === props.modelValue)
if (!found) {
const allNode = proxyNodesStore.nodes.find(n => n.id === props.modelValue)
if (allNode) return [allNode, ...online]
}
}
return online
})
/** 供父组件调用:启用代理时懒加载节点列表 */
function ensureLoaded() {
proxyNodesStore.ensureLoaded()
}
defineExpose({ ensureLoaded })
</script>