feat(openai): align GPT-5.6 and Codex request contracts

This commit is contained in:
MMEXA
2026-07-11 07:40:12 +08:00
parent bc1da3bf3f
commit dfa121dd5b
178 changed files with 17947 additions and 3541 deletions
@@ -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="{&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="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)
}