mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(openai): align GPT-5.6 and Codex request contracts
This commit is contained in:
@@ -270,6 +270,8 @@ export interface UpstreamModel {
|
||||
id: string
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
visibility?: string
|
||||
supported_in_api?: boolean
|
||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="获取上游模型"
|
||||
description="从上游获取所有密钥可用的模型列表。导入的模型需要关联全局模型后才能参与路由。"
|
||||
description="从上游获取所有密钥可用的模型列表。导入时会创建或复用全局模型,并关联到当前提供商。"
|
||||
:icon="Layers"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -135,6 +135,14 @@
|
||||
>
|
||||
已存在
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="model.visibility === 'hide'"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0 text-muted-foreground"
|
||||
title="运行时可调用的内部模型"
|
||||
>
|
||||
内部
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
||||
{{ model.id }}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="模型后缀参数"
|
||||
description="允许通过模型名后缀覆盖推理参数"
|
||||
title="模型参数指令"
|
||||
description="统一管理模型名后缀对应的参数映射"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@@ -28,7 +28,6 @@
|
||||
:config="modelDirectivesConfig"
|
||||
:loading="loading || saving"
|
||||
@save="saveConfig"
|
||||
@update:config="modelDirectivesConfig = $event"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -65,27 +64,27 @@ async function loadConfig() {
|
||||
const normalized = normalizeModelDirectivesConfig(response.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
} catch (err) {
|
||||
error('获取模型后缀参数配置失败')
|
||||
log.error('获取模型后缀参数配置失败:', err)
|
||||
error('获取模型参数指令配置失败')
|
||||
log.error('获取模型参数指令配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
async function saveConfig(nextConfig: ModelDirectivesConfig) {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalized = normalizeModelDirectivesConfig(modelDirectivesConfig.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
const normalized = normalizeModelDirectivesConfig(nextConfig)
|
||||
await adminApi.updateSystemConfig(
|
||||
'model_directives',
|
||||
normalized,
|
||||
'模型后缀参数配置'
|
||||
'模型参数指令配置'
|
||||
)
|
||||
success('模型后缀参数配置已保存')
|
||||
modelDirectivesConfig.value = normalized
|
||||
success('模型参数指令配置已保存')
|
||||
} catch (err) {
|
||||
error(getErrorMessage(err, '保存模型后缀参数配置失败'))
|
||||
log.error('保存模型后缀参数配置失败:', err)
|
||||
error(getErrorMessage(err, '保存模型参数指令配置失败'))
|
||||
log.error('保存模型参数指令配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -3,27 +3,28 @@
|
||||
<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"
|
||||
aria-label="启用模型参数指令"
|
||||
@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 class="hidden gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid 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>自定义映射</div>
|
||||
<div class="text-right">
|
||||
状态
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,51 +35,107 @@
|
||||
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="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
|
||||
API 端点
|
||||
</div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ format.label }}
|
||||
</div>
|
||||
<code class="mt-1 block text-xs text-muted-foreground">
|
||||
{{ format.parameter }}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
|
||||
模型指令
|
||||
</div>
|
||||
<Select
|
||||
:model-value="selectedEfforts[format.key] ?? 'low'"
|
||||
:model-value="selectedSuffixes[format.key] ?? 'low'"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
@update:model-value="value => selectEffort(format.key, value)"
|
||||
@update:model-value="value => selectSuffix(format.key, value)"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-28 rounded-lg">
|
||||
<SelectTrigger
|
||||
class="h-9 w-full rounded-lg lg:w-32"
|
||||
:aria-label="`${format.label} 模型指令`"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="effort in DEFAULT_REASONING_SUFFIXES"
|
||||
:key="effort"
|
||||
:value="effort"
|
||||
v-for="suffix in availableSuffixes(format.key)"
|
||||
:key="suffix"
|
||||
:value="suffix"
|
||||
:text-value="suffixLabel(suffix)"
|
||||
>
|
||||
{{ effort }}
|
||||
{{ suffixLabel(suffix) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ selectedSuffixDescription(format.key) }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">启用此指令</span>
|
||||
<Switch
|
||||
:model-value="selectedSuffixEnabled(format.key)"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
:aria-label="`${format.label} ${selectedSuffixes[format.key] ?? 'low'} 指令`"
|
||||
@update:model-value="value => onSuffixEnabledChange(format.key, value)"
|
||||
/>
|
||||
</div>
|
||||
</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="{"reasoning_effort":"low"}"
|
||||
/>
|
||||
<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="mb-1 text-xs font-medium text-muted-foreground lg:hidden">
|
||||
自定义映射
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Textarea
|
||||
:id="mappingInputId(format.key)"
|
||||
:model-value="localMappingParams[mappingKey(format.key)]"
|
||||
class="h-24 min-h-24 resize-none overflow-auto font-mono text-xs leading-5"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !selectedSuffixEnabled(format.key)"
|
||||
:aria-label="`${format.label} ${selectedSuffixes[format.key] ?? 'low'} 映射参数`"
|
||||
:aria-invalid="Boolean(mappingErrors[mappingKey(format.key)])"
|
||||
:aria-describedby="mappingErrors[mappingKey(format.key)] ? mappingErrorId(format.key) : undefined"
|
||||
title="自定义映射 JSON"
|
||||
placeholder="{}"
|
||||
@update:model-value="value => onMappingDraftChange(format.key, value)"
|
||||
/>
|
||||
<p
|
||||
v-if="mappingErrors[mappingKey(format.key)]"
|
||||
:id="mappingErrorId(format.key)"
|
||||
class="mt-1 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ mappingErrors[mappingKey(format.key)] }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ mappingStatus(format.key) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-9 w-9 shrink-0 text-muted-foreground"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !selectedSuffixEnabled(format.key) || !hasMappingParamChanges(format.key)"
|
||||
:title="`保存 ${format.label} 映射参数`"
|
||||
:aria-label="`保存 ${format.label} 映射参数`"
|
||||
@click="saveMappingParam(format.key)"
|
||||
>
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center md:justify-end">
|
||||
<div class="flex items-center justify-between gap-3 lg:justify-end">
|
||||
<span class="text-xs font-medium text-muted-foreground lg:hidden">状态</span>
|
||||
<Switch
|
||||
:model-value="formatConfig(format.key).enabled"
|
||||
:disabled="loading || !config.reasoning_effort.enabled"
|
||||
:aria-label="`${format.label} 模型参数指令`"
|
||||
@update:model-value="value => onApiFormatEnabledChange(format.key, value)"
|
||||
/>
|
||||
</div>
|
||||
@@ -100,10 +157,13 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Textarea from '@/components/ui/textarea.vue'
|
||||
import {
|
||||
DEFAULT_REASONING_SUFFIXES,
|
||||
MODEL_DIRECTIVE_SUFFIX_METADATA,
|
||||
MODEL_DIRECTIVE_API_FORMATS,
|
||||
defaultModelDirectiveSuffixesForApiFormat,
|
||||
updateModelDirectiveMappingOverride,
|
||||
updateModelDirectiveSuffixEnabled,
|
||||
type ReasoningApiFormatConfig,
|
||||
type ModelDirectivesConfig,
|
||||
} from './modelDirectivesConfig'
|
||||
@@ -114,21 +174,33 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:config': [value: ModelDirectivesConfig]
|
||||
save: [value: ModelDirectivesConfig]
|
||||
}>()
|
||||
|
||||
const selectedEfforts = reactive<Record<string, string>>({})
|
||||
const selectedSuffixes = reactive<Record<string, string>>({})
|
||||
const localMappingParams = reactive<Record<string, string>>({})
|
||||
const mappingErrors = reactive<Record<string, string>>({})
|
||||
const dirtyMappingKeys = reactive(new Set<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)
|
||||
const selectedSuffix = selectedSuffixes[format.key]
|
||||
?? firstConfiguredSuffix(format.key, fc)
|
||||
?? 'low'
|
||||
selectedSuffixes[format.key] = selectedSuffix
|
||||
for (const suffix of availableSuffixes(format.key)) {
|
||||
const key = mappingKey(format.key, suffix)
|
||||
const authoritativeText = mappingOverrideText(fc?.mappings?.[suffix])
|
||||
if (dirtyMappingKeys.has(key)) {
|
||||
if (localMappingParams[key] === authoritativeText) {
|
||||
dirtyMappingKeys.delete(key)
|
||||
delete mappingErrors[key]
|
||||
}
|
||||
continue
|
||||
}
|
||||
localMappingParams[key] = authoritativeText
|
||||
delete mappingErrors[key]
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -136,40 +208,114 @@ watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
|
||||
function formatConfig(apiFormat: string): ReasoningApiFormatConfig {
|
||||
return props.config.reasoning_effort.api_formats[apiFormat] ?? {
|
||||
enabled: true,
|
||||
suffixes: [...defaultModelDirectiveSuffixesForApiFormat(apiFormat)],
|
||||
mappings: {},
|
||||
}
|
||||
}
|
||||
|
||||
function firstMappingEffort(mappings: Record<string, unknown> | undefined): string | undefined {
|
||||
return DEFAULT_REASONING_SUFFIXES.find((effort) => mappings?.[effort] !== undefined)
|
||||
function availableSuffixes(apiFormat: string): string[] {
|
||||
const configured = props.config.reasoning_effort.api_formats[apiFormat]
|
||||
return [...new Set([
|
||||
...defaultModelDirectiveSuffixesForApiFormat(apiFormat),
|
||||
...(configured?.suffixes ?? []),
|
||||
...Object.keys(configured?.mappings ?? {}),
|
||||
])]
|
||||
}
|
||||
|
||||
function mappingKey(apiFormat: string, effort = selectedEfforts[apiFormat] ?? 'low'): string {
|
||||
return `${apiFormat}:${effort}`
|
||||
function firstConfiguredSuffix(
|
||||
apiFormat: string,
|
||||
config: ReasoningApiFormatConfig | undefined,
|
||||
): string | undefined {
|
||||
return availableSuffixes(apiFormat).find(suffix => (
|
||||
config?.suffixes.includes(suffix) || config?.mappings?.[suffix] !== undefined
|
||||
))
|
||||
}
|
||||
|
||||
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 mappingKey(
|
||||
apiFormat: string,
|
||||
suffix: string = selectedSuffixes[apiFormat] ?? 'low',
|
||||
): string {
|
||||
return `${apiFormat}:${suffix}`
|
||||
}
|
||||
|
||||
function selectSuffix(apiFormat: string, suffix: string) {
|
||||
if (!availableSuffixes(apiFormat).includes(suffix)) {
|
||||
throw new Error(`Unsupported model directive suffix: ${suffix}`)
|
||||
}
|
||||
selectedSuffixes[apiFormat] = suffix
|
||||
const key = mappingKey(apiFormat, suffix)
|
||||
if (!Object.prototype.hasOwnProperty.call(localMappingParams, key)) {
|
||||
localMappingParams[key] = mappingOverrideText(formatConfig(apiFormat).mappings[suffix])
|
||||
delete mappingErrors[key]
|
||||
}
|
||||
}
|
||||
|
||||
function onMappingDraftChange(apiFormat: string, value: string) {
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
const key = mappingKey(apiFormat, suffix)
|
||||
localMappingParams[key] = value
|
||||
if (value === mappingOverrideText(formatConfig(apiFormat).mappings[suffix])) {
|
||||
dirtyMappingKeys.delete(key)
|
||||
} else {
|
||||
dirtyMappingKeys.add(key)
|
||||
}
|
||||
delete mappingErrors[key]
|
||||
}
|
||||
|
||||
function suffixLabel(suffix: string): string {
|
||||
return MODEL_DIRECTIVE_SUFFIX_METADATA[suffix as keyof typeof MODEL_DIRECTIVE_SUFFIX_METADATA]?.label
|
||||
?? suffix
|
||||
}
|
||||
|
||||
function selectedSuffixDescription(apiFormat: string): string {
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
return MODEL_DIRECTIVE_SUFFIX_METADATA[suffix as keyof typeof MODEL_DIRECTIVE_SUFFIX_METADATA]?.description
|
||||
?? '自定义模型指令'
|
||||
}
|
||||
|
||||
function selectedSuffixEnabled(apiFormat: string): boolean {
|
||||
return formatConfig(apiFormat).suffixes.includes(selectedSuffixes[apiFormat] ?? 'low')
|
||||
}
|
||||
|
||||
function mappingInputId(apiFormat: string): string {
|
||||
return `model-directive-mapping-${apiFormat.replace(/[^a-z0-9]+/gi, '-')}`
|
||||
}
|
||||
|
||||
function mappingErrorId(apiFormat: string): string {
|
||||
return `${mappingInputId(apiFormat)}-error`
|
||||
}
|
||||
|
||||
function mappingOverrideText(mapping: unknown): string {
|
||||
return mapping === undefined ? '' : JSON.stringify(mapping, null, 2)
|
||||
}
|
||||
|
||||
function hasCustomMapping(apiFormat: string): boolean {
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
return Object.prototype.hasOwnProperty.call(formatConfig(apiFormat).mappings, suffix)
|
||||
}
|
||||
|
||||
function mappingStatus(apiFormat: string): string {
|
||||
return hasCustomMapping(apiFormat)
|
||||
? '自定义映射'
|
||||
: '内置映射'
|
||||
}
|
||||
|
||||
function hasMappingParamChanges(apiFormat: string): boolean {
|
||||
const effort = selectedEfforts[apiFormat] ?? 'low'
|
||||
return (localMappingParams[mappingKey(apiFormat, effort)] ?? '') !== JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
return (localMappingParams[mappingKey(apiFormat, suffix)] ?? '')
|
||||
!== mappingOverrideText(formatConfig(apiFormat).mappings[suffix])
|
||||
}
|
||||
|
||||
function onReasoningEnabledChange(value: boolean) {
|
||||
emit('update:config', {
|
||||
emit('save', {
|
||||
...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', {
|
||||
emit('save', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
@@ -179,22 +325,12 @@ function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function saveMappingParam(apiFormat: string) {
|
||||
function onSuffixEnabledChange(apiFormat: string, value: boolean) {
|
||||
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', {
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
emit('save', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
@@ -202,14 +338,50 @@ function saveMappingParam(apiFormat: string) {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: {
|
||||
...current,
|
||||
mappings: {
|
||||
...current.mappings,
|
||||
[effort]: mapping,
|
||||
},
|
||||
suffixes: updateModelDirectiveSuffixEnabled(current.suffixes, suffix, Boolean(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function saveMappingParam(apiFormat: string) {
|
||||
const current = formatConfig(apiFormat)
|
||||
const suffix = selectedSuffixes[apiFormat] ?? 'low'
|
||||
const key = mappingKey(apiFormat, suffix)
|
||||
const rawMapping = (localMappingParams[key] ?? '').trim()
|
||||
let mapping: Record<string, unknown> | undefined
|
||||
if (rawMapping) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawMapping)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
mappingErrors[key] = '映射参数必须是 JSON 对象'
|
||||
return
|
||||
}
|
||||
mapping = Object.keys(parsed).length > 0
|
||||
? parsed as Record<string, unknown>
|
||||
: undefined
|
||||
} catch {
|
||||
mappingErrors[key] = 'JSON 格式无效,请修正后再保存'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
delete mappingErrors[key]
|
||||
localMappingParams[key] = mappingOverrideText(mapping)
|
||||
const mappings = updateModelDirectiveMappingOverride(current.mappings, suffix, mapping)
|
||||
emit('save', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
api_formats: {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: {
|
||||
...current,
|
||||
mappings,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
|
||||
import ModelDirectivesPanel from '../ModelDirectivesPanel.vue'
|
||||
import { createDefaultModelDirectivesConfig } from '../modelDirectivesConfig'
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (tag: string) => defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.())
|
||||
},
|
||||
})
|
||||
return {
|
||||
Button: passthrough('button'),
|
||||
Select: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: String, disabled: Boolean },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () => h('select', {
|
||||
value: props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onChange: (event: Event) => emit(
|
||||
'update:modelValue',
|
||||
(event.target as HTMLSelectElement).value,
|
||||
),
|
||||
}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
SelectContent: passthrough('optgroup'),
|
||||
SelectItem: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup(props, { slots }) {
|
||||
return () => h('option', { value: props.value }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
SelectTrigger: defineComponent({ setup: () => () => null }),
|
||||
SelectValue: defineComponent({ setup: () => () => null }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/switch.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: Boolean, disabled: Boolean },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
'aria-pressed': props.modelValue,
|
||||
onClick: () => emit('update:modelValue', !props.modelValue),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/textarea.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: String, disabled: Boolean },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('textarea', {
|
||||
...attrs,
|
||||
value: props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onInput: (event: Event) => emit(
|
||||
'update:modelValue',
|
||||
(event.target as HTMLTextAreaElement).value,
|
||||
),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountPanel(onSave?: (value: ReturnType<typeof createDefaultModelDirectivesConfig>) => void) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const config = ref(createDefaultModelDirectivesConfig())
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ModelDirectivesPanel, {
|
||||
config: config.value,
|
||||
loading: false,
|
||||
onSave,
|
||||
})
|
||||
},
|
||||
}))
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return { config, root }
|
||||
}
|
||||
|
||||
function selectSuffix(select: HTMLSelectElement, suffix: string) {
|
||||
select.value = suffix
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ModelDirectivesPanel', () => {
|
||||
it('shows Codex ultra and authoritative custom suffixes in the OpenAI selector', async () => {
|
||||
const { config, root } = mountPanel()
|
||||
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
|
||||
expect([...suffixSelect.options].map(option => option.value)).toContain('ultra')
|
||||
|
||||
const current = config.value.reasoning_effort.api_formats['openai:responses']
|
||||
config.value = {
|
||||
...config.value,
|
||||
reasoning_effort: {
|
||||
...config.value.reasoning_effort,
|
||||
api_formats: {
|
||||
...config.value.reasoning_effort.api_formats,
|
||||
'openai:responses': {
|
||||
...current,
|
||||
suffixes: [...current.suffixes, 'vendor-future'],
|
||||
mappings: {
|
||||
...current.mappings,
|
||||
'mapped-future': { vendor_option: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect([...suffixSelect.options].map(option => option.value)).toEqual(expect.arrayContaining([
|
||||
'ultra',
|
||||
'vendor-future',
|
||||
'mapped-future',
|
||||
]))
|
||||
selectSuffix(suffixSelect, 'ultra')
|
||||
await nextTick()
|
||||
expect(root.textContent).toContain('Codex Ultra 预设,请求推理强度为 max')
|
||||
})
|
||||
|
||||
it('retains per-suffix drafts and validation state while switching suffixes', async () => {
|
||||
const { root } = mountPanel()
|
||||
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
|
||||
const noneMapping = root.querySelector(
|
||||
'textarea[aria-label="OpenAI Responses none 映射参数"]',
|
||||
) as HTMLTextAreaElement
|
||||
|
||||
noneMapping.value = '{'
|
||||
noneMapping.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
const saveButton = root.querySelector(
|
||||
'button[aria-label="保存 OpenAI Responses 映射参数"]',
|
||||
) as HTMLButtonElement
|
||||
saveButton.click()
|
||||
await nextTick()
|
||||
expect(root.textContent).toContain('JSON 格式无效,请修正后再保存')
|
||||
|
||||
selectSuffix(suffixSelect, 'medium')
|
||||
await nextTick()
|
||||
selectSuffix(suffixSelect, 'none')
|
||||
await nextTick()
|
||||
|
||||
const restoredDraft = root.querySelector(
|
||||
'textarea[aria-label="OpenAI Responses none 映射参数"]',
|
||||
) as HTMLTextAreaElement
|
||||
expect(restoredDraft.value).toBe('{')
|
||||
expect(restoredDraft.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(root.textContent).toContain('JSON 格式无效,请修正后再保存')
|
||||
})
|
||||
|
||||
it('refreshes cached clean drafts when authoritative config changes', async () => {
|
||||
const { config, root } = mountPanel()
|
||||
const suffixSelect = root.querySelectorAll('select').item(1) as HTMLSelectElement
|
||||
|
||||
selectSuffix(suffixSelect, 'medium')
|
||||
await nextTick()
|
||||
selectSuffix(suffixSelect, 'none')
|
||||
await nextTick()
|
||||
|
||||
const current = config.value.reasoning_effort.api_formats['openai:responses']
|
||||
config.value = {
|
||||
...config.value,
|
||||
reasoning_effort: {
|
||||
...config.value.reasoning_effort,
|
||||
api_formats: {
|
||||
...config.value.reasoning_effort.api_formats,
|
||||
'openai:responses': {
|
||||
...current,
|
||||
mappings: {
|
||||
...current.mappings,
|
||||
medium: { reasoning: { effort: 'high' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
selectSuffix(suffixSelect, 'medium')
|
||||
await nextTick()
|
||||
const refreshedDraft = root.querySelector(
|
||||
'textarea[aria-label="OpenAI Responses medium 映射参数"]',
|
||||
) as HTMLTextAreaElement
|
||||
expect(JSON.parse(refreshedDraft.value)).toEqual({ reasoning: { effort: 'high' } })
|
||||
})
|
||||
|
||||
it('keeps a valid mapping draft retryable until the saved config becomes authoritative', async () => {
|
||||
const savedConfigs: Array<ReturnType<typeof createDefaultModelDirectivesConfig>> = []
|
||||
const { root } = mountPanel(value => savedConfigs.push(value))
|
||||
const mapping = root.querySelector(
|
||||
'textarea[aria-label="OpenAI Responses none 映射参数"]',
|
||||
) as HTMLTextAreaElement
|
||||
mapping.value = '{"reasoning":{"effort":"medium"}}'
|
||||
mapping.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
const saveButton = root.querySelector(
|
||||
'button[aria-label="保存 OpenAI Responses 映射参数"]',
|
||||
) as HTMLButtonElement
|
||||
saveButton.click()
|
||||
await nextTick()
|
||||
expect(savedConfigs).toHaveLength(1)
|
||||
expect(savedConfigs[0].reasoning_effort.api_formats['openai:responses'].mappings.none)
|
||||
.toEqual({ reasoning: { effort: 'medium' } })
|
||||
expect(saveButton.disabled).toBe(false)
|
||||
|
||||
saveButton.click()
|
||||
await nextTick()
|
||||
expect(savedConfigs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MODEL_DIRECTIVE_API_FORMATS,
|
||||
MODEL_DIRECTIVE_SUFFIXES,
|
||||
REASONING_EFFORTS,
|
||||
createDefaultModelDirectivesConfig,
|
||||
defaultModelDirectiveSuffixesForApiFormat,
|
||||
normalizeModelDirectivesConfig,
|
||||
updateModelDirectiveMappingOverride,
|
||||
updateModelDirectiveSuffixEnabled,
|
||||
} from '../modelDirectivesConfig'
|
||||
|
||||
describe('modelDirectivesConfig', () => {
|
||||
it('defines the complete reasoning effort ladder in protocol order', () => {
|
||||
expect(REASONING_EFFORTS).toEqual([
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
])
|
||||
expect(MODEL_DIRECTIVE_SUFFIXES).toEqual([
|
||||
...REASONING_EFFORTS,
|
||||
'ultra',
|
||||
'fast',
|
||||
])
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('openai:responses')).toContain('ultra')
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('claude:messages')).not.toContain('ultra')
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('gemini:generate_content')).not.toContain('ultra')
|
||||
})
|
||||
|
||||
it('creates a config whose mappings contain overrides only', () => {
|
||||
const config = createDefaultModelDirectivesConfig()
|
||||
|
||||
expect(Object.keys(config.reasoning_effort.api_formats)).toEqual(
|
||||
MODEL_DIRECTIVE_API_FORMATS.map(format => format.key),
|
||||
)
|
||||
for (const format of MODEL_DIRECTIVE_API_FORMATS) {
|
||||
expect(config.reasoning_effort.api_formats[format.key]).toEqual({
|
||||
enabled: true,
|
||||
suffixes: [...defaultModelDirectiveSuffixesForApiFormat(format.key)],
|
||||
mappings: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes configured suffixes without persisting built-in mappings', () => {
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
enabled: true,
|
||||
suffixes: ['low', 'MAX', 'ULTRA'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.reasoning_effort.api_formats['openai:responses'].mappings).toEqual({})
|
||||
expect(config.reasoning_effort.api_formats['openai:responses'].suffixes).toEqual([
|
||||
'low',
|
||||
'max',
|
||||
'ultra',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves every explicit mapping as an authoritative override', () => {
|
||||
const customHigh = { reasoning: { effort: 'high' }, trace: { sample: true } }
|
||||
const futureMapping = { vendor_option: 'keep-me' }
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
enabled: true,
|
||||
mappings: {
|
||||
low: { reasoning: { effort: 'low' } },
|
||||
max: { reasoning: { effort: 'xhigh' } },
|
||||
high: customHigh,
|
||||
future: futureMapping,
|
||||
},
|
||||
},
|
||||
'claude:messages': {
|
||||
enabled: true,
|
||||
mappings: {
|
||||
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
|
||||
max: { thinking: { type: 'enabled', budget_tokens: 65536 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.reasoning_effort.api_formats['openai:responses'].mappings).toEqual({
|
||||
low: { reasoning: { effort: 'low' } },
|
||||
max: { reasoning: { effort: 'xhigh' } },
|
||||
high: customHigh,
|
||||
future: futureMapping,
|
||||
})
|
||||
expect(config.reasoning_effort.api_formats['openai:responses'].suffixes).toEqual([
|
||||
'low',
|
||||
'high',
|
||||
'max',
|
||||
'future',
|
||||
])
|
||||
expect(config.reasoning_effort.api_formats['claude:messages'].mappings).toEqual({
|
||||
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
|
||||
max: { thinking: { type: 'enabled', budget_tokens: 65536 } },
|
||||
})
|
||||
expect(config.reasoning_effort.api_formats['claude:messages'].suffixes).toEqual([
|
||||
'medium',
|
||||
'max',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves custom overrides and unknown fields exactly', () => {
|
||||
const customMax = { reasoning: { effort: 'max' }, trace: { sample: true } }
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
future_option: { keep: true },
|
||||
reasoning_effort: {
|
||||
enabled: false,
|
||||
future_reasoning_option: 'keep-reasoning',
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
enabled: true,
|
||||
future_format_option: 'keep-format',
|
||||
mappings: {
|
||||
max: customMax,
|
||||
future: { vendor_option: 'keep-future' },
|
||||
},
|
||||
},
|
||||
'vendor:future': {
|
||||
enabled: false,
|
||||
mappings: {
|
||||
custom: { vendor_option: 'keep-vendor' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.future_option).toEqual({ keep: true })
|
||||
expect(config.reasoning_effort.future_reasoning_option).toBe('keep-reasoning')
|
||||
expect(config.reasoning_effort.api_formats['openai:responses']).toEqual({
|
||||
enabled: true,
|
||||
future_format_option: 'keep-format',
|
||||
suffixes: ['max', 'future'],
|
||||
mappings: {
|
||||
max: customMax,
|
||||
future: { vendor_option: 'keep-future' },
|
||||
},
|
||||
})
|
||||
expect(config.reasoning_effort.api_formats['vendor:future']).toEqual({
|
||||
enabled: false,
|
||||
suffixes: ['custom'],
|
||||
mappings: {
|
||||
custom: { vendor_option: 'keep-vendor' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('removes empty overrides without mutating unrelated custom mappings', () => {
|
||||
const mappings = {
|
||||
low: { reasoning: { effort: 'custom-low' } },
|
||||
future: { vendor_option: 'keep-future' },
|
||||
}
|
||||
|
||||
expect(updateModelDirectiveMappingOverride(mappings, 'low', {})).toEqual({
|
||||
future: { vendor_option: 'keep-future' },
|
||||
})
|
||||
expect(updateModelDirectiveMappingOverride(mappings, 'max', {
|
||||
reasoning: { effort: 'custom-max' },
|
||||
})).toEqual({
|
||||
low: { reasoning: { effort: 'custom-low' } },
|
||||
max: { reasoning: { effort: 'custom-max' } },
|
||||
future: { vendor_option: 'keep-future' },
|
||||
})
|
||||
expect(mappings).toEqual({
|
||||
low: { reasoning: { effort: 'custom-low' } },
|
||||
future: { vendor_option: 'keep-future' },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives suffixes from configured mapping keys when suffixes are omitted', () => {
|
||||
const configuredMappings = Object.fromEntries([
|
||||
['low', { reasoning_effort: 'low' }],
|
||||
['medium', { reasoning_effort: 'medium' }],
|
||||
['high', { reasoning_effort: 'high' }],
|
||||
['xhigh', { reasoning_effort: 'xhigh' }],
|
||||
['max', { reasoning_effort: 'xhigh' }],
|
||||
['fast', { service_tier: 'priority' }],
|
||||
])
|
||||
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:chat': { enabled: true, mappings: configuredMappings },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.reasoning_effort.api_formats['openai:chat'].suffixes).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'fast',
|
||||
])
|
||||
expect(config.reasoning_effort.api_formats['openai:chat'].mappings).toEqual(configuredMappings)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['none', ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'fast']],
|
||||
['minimal', ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'fast']],
|
||||
['none and minimal', ['low', 'medium', 'high', 'xhigh', 'max', 'fast']],
|
||||
])('keeps explicitly disabled %s efforts disabled across normalization round-trips', (_, suffixes) => {
|
||||
const persisted = {
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
enabled: true,
|
||||
suffixes,
|
||||
mappings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const firstRead = normalizeModelDirectivesConfig(persisted)
|
||||
const secondRead = normalizeModelDirectivesConfig(firstRead)
|
||||
|
||||
expect(firstRead.reasoning_effort.api_formats['openai:responses'].suffixes).toEqual(suffixes)
|
||||
expect(secondRead.reasoning_effort.api_formats['openai:responses'].suffixes).toEqual(suffixes)
|
||||
})
|
||||
|
||||
it('canonicalizes known suffix casing while retaining unknown extensions', () => {
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
suffixes: [' MAX ', 'VendorFuture'],
|
||||
mappings: {
|
||||
MAX: { reasoning: { effort: 'xhigh' } },
|
||||
VendorFuture: { keep: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.reasoning_effort.api_formats['openai:responses']).toMatchObject({
|
||||
suffixes: ['max', 'VendorFuture'],
|
||||
mappings: {
|
||||
max: { reasoning: { effort: 'xhigh' } },
|
||||
VendorFuture: { keep: true },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores malformed suffix entries exactly as the backend parser does', () => {
|
||||
const config = normalizeModelDirectivesConfig({
|
||||
reasoning_effort: {
|
||||
api_formats: {
|
||||
'openai:responses': {
|
||||
suffixes: ['low', null, 42, { future: true }, 'VendorFuture'],
|
||||
mappings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.reasoning_effort.api_formats['openai:responses'].suffixes).toEqual([
|
||||
'low',
|
||||
'VendorFuture',
|
||||
])
|
||||
})
|
||||
|
||||
it('toggles one suffix without changing the rest of the allowlist', () => {
|
||||
expect(updateModelDirectiveSuffixEnabled(['low', 'max', 'fast'], 'max', false)).toEqual([
|
||||
'low',
|
||||
'fast',
|
||||
])
|
||||
expect(updateModelDirectiveSuffixEnabled(['low', 'fast'], 'max', true)).toEqual([
|
||||
'low',
|
||||
'max',
|
||||
'fast',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves unknown extensions when a known suffix is toggled', () => {
|
||||
expect(updateModelDirectiveSuffixEnabled(
|
||||
['VendorFuture', ' MAX ', 'VendorFuture', 'another-extension'],
|
||||
'low',
|
||||
true,
|
||||
)).toEqual([
|
||||
'low',
|
||||
'max',
|
||||
'VendorFuture',
|
||||
'another-extension',
|
||||
])
|
||||
|
||||
expect(updateModelDirectiveSuffixEnabled(
|
||||
['low', 'VendorFuture', 'another-extension'],
|
||||
'low',
|
||||
false,
|
||||
)).toEqual([
|
||||
'VendorFuture',
|
||||
'another-extension',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,52 @@
|
||||
export interface ReasoningApiFormatConfig {
|
||||
[key: string]: unknown
|
||||
enabled: boolean
|
||||
suffixes: string[]
|
||||
mappings: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ReasoningEffortConfig {
|
||||
[key: string]: unknown
|
||||
enabled: boolean
|
||||
api_formats: Record<string, ReasoningApiFormatConfig>
|
||||
}
|
||||
|
||||
export interface ModelDirectivesConfig {
|
||||
reasoning_effort: {
|
||||
enabled: boolean
|
||||
api_formats: Record<string, ReasoningApiFormatConfig>
|
||||
}
|
||||
[key: string]: unknown
|
||||
reasoning_effort: ReasoningEffortConfig
|
||||
}
|
||||
|
||||
export const MODEL_DIRECTIVES_MODULE_NAME = 'model_directives'
|
||||
|
||||
export const REASONING_EFFORTS = [
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
] as const
|
||||
|
||||
export type ReasoningEffort = typeof REASONING_EFFORTS[number]
|
||||
|
||||
export const MODEL_DIRECTIVE_SUFFIXES = [...REASONING_EFFORTS, 'ultra', 'fast'] as const
|
||||
export type ModelDirectiveSuffix = typeof MODEL_DIRECTIVE_SUFFIXES[number]
|
||||
|
||||
export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly<
|
||||
Record<ModelDirectiveSuffix, { label: string, description: string }>
|
||||
> = {
|
||||
none: { label: 'none', description: '不启用推理' },
|
||||
minimal: { label: 'minimal', description: '模型支持时使用最低推理投入' },
|
||||
low: { label: 'low', description: '低推理投入' },
|
||||
medium: { label: 'medium', description: '中等推理投入' },
|
||||
high: { label: 'high', description: '高推理投入' },
|
||||
xhigh: { label: 'xhigh', description: '超高推理投入' },
|
||||
max: { label: 'max', description: '模型支持时使用最大推理投入' },
|
||||
ultra: { label: 'ultra', description: 'Codex Ultra 预设,请求推理强度为 max' },
|
||||
fast: { label: 'fast', description: 'Priority 服务层级' },
|
||||
}
|
||||
|
||||
export const MODEL_DIRECTIVE_API_FORMATS = [
|
||||
{
|
||||
key: 'openai:chat',
|
||||
@@ -40,45 +75,27 @@ export const MODEL_DIRECTIVE_API_FORMATS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
export const DEFAULT_REASONING_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
||||
const CROSS_PROVIDER_SUFFIXES: readonly ModelDirectiveSuffix[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
]
|
||||
|
||||
function defaultMappingsForApiFormat(apiFormat: string): Record<string, unknown> {
|
||||
export function defaultModelDirectiveSuffixesForApiFormat(
|
||||
apiFormat: string,
|
||||
): readonly ModelDirectiveSuffix[] {
|
||||
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: 'max' },
|
||||
}
|
||||
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: 'max' } },
|
||||
}
|
||||
return MODEL_DIRECTIVE_SUFFIXES
|
||||
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 } } },
|
||||
}
|
||||
return CROSS_PROVIDER_SUFFIXES
|
||||
default:
|
||||
return {}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +108,8 @@ export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
|
||||
format.key,
|
||||
{
|
||||
enabled: true,
|
||||
mappings: defaultMappingsForApiFormat(format.key),
|
||||
suffixes: [...defaultModelDirectiveSuffixesForApiFormat(format.key)],
|
||||
mappings: {},
|
||||
},
|
||||
])
|
||||
),
|
||||
@@ -99,58 +117,123 @@ export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
export function updateModelDirectiveMappingOverride(
|
||||
mappings: Record<string, unknown>,
|
||||
suffix: string,
|
||||
override: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const nextMappings = { ...mappings }
|
||||
if (override === undefined || Object.keys(override).length === 0) {
|
||||
delete nextMappings[suffix]
|
||||
} else {
|
||||
nextMappings[suffix] = override
|
||||
}
|
||||
return nextMappings
|
||||
}
|
||||
|
||||
function normalizeMappings(apiFormat: string, value: unknown, legacySuffixes: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return mappingsFromLegacySuffixes(apiFormat, legacySuffixes)
|
||||
export function updateModelDirectiveSuffixEnabled(
|
||||
suffixes: string[],
|
||||
suffix: string,
|
||||
enabled: boolean,
|
||||
): string[] {
|
||||
const normalized = suffixes
|
||||
.map(item => normalizeDirectiveSuffix(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
const next = new Set(normalized)
|
||||
if (enabled) next.add(suffix)
|
||||
else next.delete(suffix)
|
||||
|
||||
const known = MODEL_DIRECTIVE_SUFFIXES.filter(item => next.delete(item))
|
||||
return [...known, ...next]
|
||||
}
|
||||
|
||||
function normalizeMappings(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return {}
|
||||
}
|
||||
return { ...(value as Record<string, unknown>) }
|
||||
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const [rawSuffix, mapping] of Object.entries(value)) {
|
||||
const suffix = normalizeDirectiveSuffix(rawSuffix)
|
||||
if (!suffix) continue
|
||||
normalized[suffix] = mapping
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeSuffixes(
|
||||
apiFormat: string,
|
||||
value: unknown,
|
||||
configuredMappings: unknown,
|
||||
): string[] {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: isRecord(configuredMappings)
|
||||
? Object.keys(configuredMappings)
|
||||
: defaultModelDirectiveSuffixesForApiFormat(apiFormat)
|
||||
const normalized = new Set(
|
||||
source
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => normalizeDirectiveSuffix(item))
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
)
|
||||
|
||||
const known = MODEL_DIRECTIVE_SUFFIXES.filter(item => normalized.delete(item))
|
||||
return [...known, ...normalized]
|
||||
}
|
||||
|
||||
export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesConfig {
|
||||
const defaults = createDefaultModelDirectivesConfig()
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
if (!isRecord(value)) return defaults
|
||||
|
||||
const source = value as Partial<ModelDirectivesConfig>
|
||||
const reasoning = source.reasoning_effort
|
||||
const source = value
|
||||
const reasoning = isRecord(source.reasoning_effort) ? source.reasoning_effort : {}
|
||||
const apiFormats: Record<string, ReasoningApiFormatConfig> = {
|
||||
...defaults.reasoning_effort.api_formats,
|
||||
}
|
||||
const sourceApiFormats = reasoning?.api_formats
|
||||
if (sourceApiFormats && typeof sourceApiFormats === 'object') {
|
||||
const sourceApiFormats = reasoning.api_formats
|
||||
if (isRecord(sourceApiFormats)) {
|
||||
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) {
|
||||
if (typeof rawConfig === 'boolean') {
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: rawConfig,
|
||||
mappings: defaultMappingsForApiFormat(apiFormat),
|
||||
suffixes: [...defaultModelDirectiveSuffixesForApiFormat(apiFormat)],
|
||||
mappings: {},
|
||||
}
|
||||
} else if (rawConfig && typeof rawConfig === 'object') {
|
||||
const value = rawConfig as Partial<ReasoningApiFormatConfig> & { suffixes?: unknown }
|
||||
} else if (isRecord(rawConfig)) {
|
||||
const preservedConfig = { ...rawConfig }
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
|
||||
mappings: normalizeMappings(apiFormat, value.mappings, value.suffixes),
|
||||
...preservedConfig,
|
||||
enabled: typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : true,
|
||||
suffixes: normalizeSuffixes(apiFormat, rawConfig.suffixes, rawConfig.mappings),
|
||||
mappings: normalizeMappings(rawConfig.mappings),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...source,
|
||||
reasoning_effort: {
|
||||
...reasoning,
|
||||
enabled:
|
||||
typeof reasoning?.enabled === 'boolean'
|
||||
typeof reasoning.enabled === 'boolean'
|
||||
? reasoning.enabled
|
||||
: defaults.reasoning_effort.enabled,
|
||||
api_formats: apiFormats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDirectiveSuffix(value: string): string | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = trimmed.toLowerCase()
|
||||
return MODEL_DIRECTIVE_SUFFIXES.some(item => item === normalized)
|
||||
? normalized
|
||||
: trimmed
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user