feat: add model directive management

This commit is contained in:
fawney19
2026-05-03 14:48:25 +08:00
parent fe27fb17fb
commit c4ea042eb4
53 changed files with 2655 additions and 182 deletions

View File

@@ -381,6 +381,7 @@ import {
Zap,
FileUp,
Server,
SlidersHorizontal,
type LucideIcon,
} from 'lucide-vue-next'
@@ -559,6 +560,7 @@ const navigation = computed(() => {
Shield,
Puzzle,
Server,
SlidersHorizontal,
}
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)

View File

@@ -220,6 +220,12 @@ const routes: RouteRecordRaw[] = [
name: 'ModuleManagement',
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
},
{
path: 'model-directives',
name: 'ModelDirectivesManagement',
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
meta: { module: 'model_directives' }
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -0,0 +1,97 @@
<template>
<PageContainer>
<PageHeader
title="模型后缀参数"
description="允许通过模型名后缀覆盖推理参数"
>
<template #actions>
<Button
variant="outline"
:disabled="loading"
@click="loadConfig"
>
<RefreshCw
class="w-4 h-4 mr-2"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</template>
</PageHeader>
<div class="mt-6 space-y-5">
<Card
variant="default"
class="p-6"
>
<ModelDirectivesPanel
:config="modelDirectivesConfig"
:loading="loading || saving"
@save="saveConfig"
@update:config="modelDirectivesConfig = $event"
/>
</Card>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue'
import { PageContainer, PageHeader } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
import ModelDirectivesPanel from './module-management/ModelDirectivesPanel.vue'
import {
createDefaultModelDirectivesConfig,
normalizeModelDirectivesConfig,
type ModelDirectivesConfig,
} from './module-management/modelDirectivesConfig'
const { success, error } = useToast()
const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig())
const loading = ref(false)
const saving = ref(false)
async function loadConfig() {
loading.value = true
try {
const response = await adminApi.getSystemConfig('model_directives')
const normalized = normalizeModelDirectivesConfig(response.value)
modelDirectivesConfig.value = normalized
} catch (err) {
error('获取模型后缀参数配置失败')
log.error('获取模型后缀参数配置失败:', err)
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
try {
const normalized = normalizeModelDirectivesConfig(modelDirectivesConfig.value)
modelDirectivesConfig.value = normalized
await adminApi.updateSystemConfig(
'model_directives',
normalized,
'模型后缀参数配置'
)
success('模型后缀参数配置已保存')
} catch (err) {
error(getErrorMessage(err, '保存模型后缀参数配置失败'))
log.error('保存模型后缀参数配置失败:', err)
} finally {
saving.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>

View File

@@ -0,0 +1,213 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold">
推理参数
</h3>
<p class="mt-1 text-sm text-muted-foreground">
各端点可以分别启用推理参数并配置推理程度到实际请求参数和值的映射
</p>
</div>
<div class="flex items-center">
<Switch
:model-value="config.reasoning_effort.enabled"
:disabled="loading"
@update:model-value="onReasoningEnabledChange"
/>
</div>
</div>
<div class="overflow-hidden rounded-lg border">
<div class="grid grid-cols-1 gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]">
<div>API 端点</div>
<div>推理程度</div>
<div>映射参数</div>
<div class="md:text-right">状态</div>
</div>
<div class="divide-y">
<div
v-for="format in MODEL_DIRECTIVE_API_FORMATS"
:key="format.key"
class="grid grid-cols-1 items-center gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]"
>
<div>
<div class="text-sm font-medium">
{{ format.label }}
</div>
</div>
<div>
<Select
:model-value="selectedEfforts[format.key] ?? 'low'"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
@update:model-value="value => selectEffort(format.key, value)"
>
<SelectTrigger class="h-9 w-28 rounded-lg">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="effort in DEFAULT_REASONING_SUFFIXES"
:key="effort"
:value="effort"
>
{{ effort }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-2">
<Input
v-model="localMappingParams[mappingKey(format.key)]"
class="h-9 font-mono text-xs"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
placeholder="{&quot;reasoning_effort&quot;:&quot;low&quot;}"
/>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 shrink-0 text-muted-foreground"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !hasMappingParamChanges(format.key)"
@click="saveMappingParam(format.key)"
>
<Save class="w-3.5 h-3.5" />
</Button>
</div>
<div class="flex items-center md:justify-end">
<Switch
:model-value="formatConfig(format.key).enabled"
:disabled="loading || !config.reasoning_effort.enabled"
@update:model-value="value => onApiFormatEnabledChange(format.key, value)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { Save } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue'
import {
DEFAULT_REASONING_SUFFIXES,
MODEL_DIRECTIVE_API_FORMATS,
type ReasoningApiFormatConfig,
type ModelDirectivesConfig,
} from './modelDirectivesConfig'
const props = defineProps<{
config: ModelDirectivesConfig
loading: boolean
}>()
const emit = defineEmits<{
save: []
'update:config': [value: ModelDirectivesConfig]
}>()
const selectedEfforts = reactive<Record<string, string>>({})
const localMappingParams = reactive<Record<string, string>>({})
watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
for (const format of MODEL_DIRECTIVE_API_FORMATS) {
const fc = newFormats[format.key]
const selectedEffort = selectedEfforts[format.key] ?? firstMappingEffort(fc?.mappings) ?? 'low'
selectedEfforts[format.key] = selectedEffort
const key = mappingKey(format.key, selectedEffort)
if (localMappingParams[key] === undefined || !hasMappingParamChanges(format.key)) {
localMappingParams[key] = JSON.stringify(fc?.mappings?.[selectedEffort] ?? {}, null, 2)
}
}
}, { immediate: true })
function formatConfig(apiFormat: string): ReasoningApiFormatConfig {
return props.config.reasoning_effort.api_formats[apiFormat] ?? {
enabled: true,
mappings: {},
}
}
function firstMappingEffort(mappings: Record<string, unknown> | undefined): string | undefined {
return DEFAULT_REASONING_SUFFIXES.find((effort) => mappings?.[effort] !== undefined)
}
function mappingKey(apiFormat: string, effort = selectedEfforts[apiFormat] ?? 'low'): string {
return `${apiFormat}:${effort}`
}
function selectEffort(apiFormat: string, effort: string) {
selectedEfforts[apiFormat] = effort
const key = mappingKey(apiFormat, effort)
localMappingParams[key] = JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
}
function hasMappingParamChanges(apiFormat: string): boolean {
const effort = selectedEfforts[apiFormat] ?? 'low'
return (localMappingParams[mappingKey(apiFormat, effort)] ?? '') !== JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
}
function onReasoningEnabledChange(value: boolean) {
emit('update:config', {
...props.config,
reasoning_effort: { ...props.config.reasoning_effort, enabled: Boolean(value) },
})
emit('save')
}
function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
const current = formatConfig(apiFormat)
emit('update:config', {
...props.config,
reasoning_effort: {
...props.config.reasoning_effort,
api_formats: {
...props.config.reasoning_effort.api_formats,
[apiFormat]: { ...current, enabled: Boolean(value) },
},
},
})
emit('save')
}
function saveMappingParam(apiFormat: string) {
const current = formatConfig(apiFormat)
const effort = selectedEfforts[apiFormat] ?? 'low'
let mapping: unknown
try {
const parsed = JSON.parse(localMappingParams[mappingKey(apiFormat, effort)] || '{}')
mapping = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(current.mappings[effort] ?? {}, null, 2)
return
}
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(mapping, null, 2)
emit('update:config', {
...props.config,
reasoning_effort: {
...props.config.reasoning_effort,
api_formats: {
...props.config.reasoning_effort.api_formats,
[apiFormat]: {
...current,
mappings: {
...current.mappings,
[effort]: mapping,
},
},
},
},
})
emit('save')
}
</script>

View File

@@ -0,0 +1,156 @@
export interface ReasoningApiFormatConfig {
enabled: boolean
mappings: Record<string, unknown>
}
export interface ModelDirectivesConfig {
reasoning_effort: {
enabled: boolean
api_formats: Record<string, ReasoningApiFormatConfig>
}
}
export const MODEL_DIRECTIVES_MODULE_NAME = 'model_directives'
export const MODEL_DIRECTIVE_API_FORMATS = [
{
key: 'openai:chat',
label: 'OpenAI Chat',
parameter: 'reasoning_effort',
},
{
key: 'openai:responses',
label: 'OpenAI Responses',
parameter: 'reasoning.effort',
},
{
key: 'openai:responses:compact',
label: 'OpenAI Responses Compact',
parameter: 'reasoning.effort',
},
{
key: 'claude:messages',
label: 'Claude Messages',
parameter: 'output_config.effort + thinking',
},
{
key: 'gemini:generate_content',
label: 'Gemini GenerateContent',
parameter: 'generationConfig.thinkingConfig',
},
] as const
export const DEFAULT_REASONING_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const
function defaultMappingsForApiFormat(apiFormat: string): Record<string, unknown> {
switch (apiFormat) {
case 'openai:chat':
return {
low: { reasoning_effort: 'low' },
medium: { reasoning_effort: 'medium' },
high: { reasoning_effort: 'high' },
xhigh: { reasoning_effort: 'xhigh' },
max: { reasoning_effort: 'xhigh' },
}
case 'openai:responses':
case 'openai:responses:compact':
return {
low: { reasoning: { effort: 'low' } },
medium: { reasoning: { effort: 'medium' } },
high: { reasoning: { effort: 'high' } },
xhigh: { reasoning: { effort: 'xhigh' } },
max: { reasoning: { effort: 'xhigh' } },
}
case 'claude:messages':
return {
low: { thinking: { type: 'enabled', budget_tokens: 1024 } },
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
high: { thinking: { type: 'enabled', budget_tokens: 8192 } },
xhigh: { thinking: { type: 'enabled', budget_tokens: 16384 } },
max: { thinking: { type: 'enabled', budget_tokens: 32768 } },
}
case 'gemini:generate_content':
return {
low: { generationConfig: { thinkingConfig: { thinkingBudget: 1024 } } },
medium: { generationConfig: { thinkingConfig: { thinkingBudget: 4096 } } },
high: { generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } },
xhigh: { generationConfig: { thinkingConfig: { thinkingBudget: 16384 } } },
max: { generationConfig: { thinkingConfig: { thinkingBudget: -1 } } },
}
default:
return {}
}
}
export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
return {
reasoning_effort: {
enabled: true,
api_formats: Object.fromEntries(
MODEL_DIRECTIVE_API_FORMATS.map((format) => [
format.key,
{
enabled: true,
mappings: defaultMappingsForApiFormat(format.key),
},
])
),
},
}
}
function mappingsFromLegacySuffixes(apiFormat: string, value: unknown): Record<string, unknown> {
if (!Array.isArray(value)) return defaultMappingsForApiFormat(apiFormat)
const supported = new Set<string>(DEFAULT_REASONING_SUFFIXES)
const defaults = defaultMappingsForApiFormat(apiFormat)
return Object.fromEntries(value
.map((item) => String(item).trim().toLowerCase())
.filter((item, index, array) => supported.has(item) && array.indexOf(item) === index)
.map((suffix) => [suffix, defaults[suffix]])
.filter(([, mapping]) => mapping !== undefined))
}
function normalizeMappings(apiFormat: string, value: unknown, legacySuffixes: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return mappingsFromLegacySuffixes(apiFormat, legacySuffixes)
}
return { ...(value as Record<string, unknown>) }
}
export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesConfig {
const defaults = createDefaultModelDirectivesConfig()
if (!value || typeof value !== 'object') return defaults
const source = value as Partial<ModelDirectivesConfig>
const reasoning = source.reasoning_effort
const apiFormats: Record<string, ReasoningApiFormatConfig> = {
...defaults.reasoning_effort.api_formats,
}
const sourceApiFormats = reasoning?.api_formats
if (sourceApiFormats && typeof sourceApiFormats === 'object') {
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) {
if (typeof rawConfig === 'boolean') {
apiFormats[apiFormat] = {
enabled: rawConfig,
mappings: defaultMappingsForApiFormat(apiFormat),
}
} else if (rawConfig && typeof rawConfig === 'object') {
const value = rawConfig as Partial<ReasoningApiFormatConfig> & { suffixes?: unknown }
apiFormats[apiFormat] = {
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
mappings: normalizeMappings(apiFormat, value.mappings, value.suffixes),
}
}
}
}
return {
reasoning_effort: {
enabled:
typeof reasoning?.enabled === 'boolean'
? reasoning.enabled
: defaults.reasoning_effort.enabled,
api_formats: apiFormats,
},
}
}