feat(frontend): 澄清模型映射适用范围

This commit is contained in:
MMEXA
2026-07-14 00:30:39 +08:00
parent cfc4894dab
commit f10d631a9c
11 changed files with 657 additions and 111 deletions
@@ -50,9 +50,11 @@ describe('api format display helpers', () => {
])
})
it('applies Responses to Search permissions in one direction', () => {
it('applies Responses companion permissions in one direction', () => {
expect(apiFormatPermissionCovers('OPENAI_RESPONSES', 'openai:search')).toBe(true)
expect(apiFormatPermissionCovers('OPENAI_RESPONSES', 'openai:responses:compact')).toBe(true)
expect(apiFormatPermissionCovers('openai:search', 'openai:responses')).toBe(false)
expect(apiFormatPermissionCovers('openai:responses:compact', 'openai:responses')).toBe(false)
})
it('formats embedding api format ids distinctly from chat formats', () => {
@@ -235,7 +235,9 @@ export function apiFormatPermissionCovers(
return Boolean(allowed)
&& Boolean(requested)
&& (allowed === requested
|| (allowed === API_FORMATS.OPENAI_RESPONSES && requested === API_FORMATS.OPENAI_SEARCH))
|| (allowed === API_FORMATS.OPENAI_RESPONSES
&& (requested === API_FORMATS.OPENAI_RESPONSES_COMPACT
|| requested === API_FORMATS.OPENAI_SEARCH)))
}
// 工具函数:按 family 分组并排序 API 格式数组
@@ -37,42 +37,6 @@
</p>
</div>
<!-- 端点限制 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">限制端点</Label>
<span class="text-xs text-muted-foreground">{{ endpointScopeSummary }}</span>
</div>
<MultiSelect
v-model="selectedEndpointIds"
:options="endpointOptions"
placeholder="全部端点"
empty-text="暂无端点"
no-results-text="未找到端点"
trigger-class="h-9 rounded-md"
dropdown-min-width="24rem"
:search-threshold="4"
/>
<p class="text-xs text-muted-foreground">
默认对全部端点生效选择端点后此映射只在选中的端点上生效
</p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">请求操作</Label>
<span class="text-xs text-muted-foreground">{{ operationScopeSummary }}</span>
</div>
<MultiSelect
v-model="selectedOperations"
:options="operationOptions"
placeholder="全部操作"
empty-text="暂无可选操作"
no-results-text="未找到操作"
trigger-class="h-9 rounded-md"
/>
</div>
<!-- 映射名称选择面板 -->
<div class="space-y-1.5">
<Label class="text-xs">提供商模型</Label>
@@ -259,6 +223,67 @@
</div>
</div>
</div>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="min-w-0 space-y-0.5">
<h3 class="text-sm font-medium text-foreground">
适用范围
</h3>
<p class="text-xs text-muted-foreground">
{{ t('providers.modelMapping.scope.matchHelp') }}
</p>
</div>
<span class="max-w-full break-words text-left text-xs text-muted-foreground sm:max-w-[55%] sm:text-right">
{{ mappingScopeSummary }}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="min-w-0 space-y-1.5">
<Label class="text-xs">适用端点</Label>
<MultiSelect
v-model="selectedEndpointIds"
:options="endpointOptions"
:placeholder="t('providers.modelMapping.scope.allEndpoints')"
empty-text="暂无端点"
no-results-text="未找到端点"
trigger-class="h-9 rounded-md"
:search-threshold="4"
/>
<p class="text-xs leading-5 text-muted-foreground">
{{ t('providers.modelMapping.scope.endpointHelp') }}
</p>
</div>
<div class="min-w-0 space-y-1.5">
<Label class="text-xs">适用请求</Label>
<div
class="flex min-h-9 w-full flex-wrap gap-1 rounded-md bg-muted/50 p-1"
role="radiogroup"
aria-label="适用请求"
>
<Button
v-for="option in requestScopeOptions"
:key="option.value"
type="button"
size="sm"
:variant="requestScopeValue === option.value ? 'secondary' : 'ghost'"
class="h-7 min-w-0 flex-1 basis-[9rem] px-2.5"
role="radio"
:aria-checked="requestScopeValue === option.value"
:title="option.label"
@click="handleRequestScopeChange(option.value)"
>
<span class="truncate">{{ option.label }}</span>
</Button>
</div>
<p class="text-xs leading-5 text-muted-foreground">
{{ requestScopeDescription }}
</p>
</div>
</div>
</div>
</div>
<template #footer>
@@ -276,7 +301,7 @@
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ editingGroup ? '保存' : '添加' }}
{{ editingGroup ? '保存映射' : '添加映射' }}
</Button>
</template>
</Dialog>
@@ -298,6 +323,7 @@ import {
} from '@/components/ui'
import MultiSelect from '@/components/common/MultiSelect.vue'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
type Model,
@@ -306,8 +332,18 @@ import {
type UpstreamModel,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import {
ALL_REQUESTS_SCOPE_VALUE,
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
modelMappingRequestScopeValue,
normalizeModelMappingOperations,
} from '../utils/modelMappingScope'
export interface AliasGroup {
model: Model
@@ -340,6 +376,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
const { t } = useI18n()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
type EndpointOption = {
@@ -347,11 +384,6 @@ type EndpointOption = {
label: string
}
type OperationOption = {
value: string
label: string
}
// 状态
const submitting = ref(false)
const loadingModels = ref(false)
@@ -382,54 +414,77 @@ const selectedEndpointIds = ref<string[]>([])
const selectedOperations = ref<string[]>([])
const operationOptions: OperationOption[] = [
{ value: 'compact', label: '线程压缩' }
]
// 自定义名称列表(手动添加的)
const allCustomNames = ref<string[]>([])
const endpointOptions = computed<EndpointOption[]>(() => {
return (props.endpoints ?? []).map((endpoint) => {
const status = endpoint.is_active ? '' : '(停用)'
return {
value: endpoint.id,
label: `${formatApiFormat(endpoint.api_format)}${status}`,
}
})
const endpoints = props.endpoints ?? []
return endpoints.map(endpoint => ({
value: endpoint.id,
label: formatModelMappingEndpointLabel(endpoint, endpoints),
}))
})
const normalizedSelectedEndpointIds = computed(() => {
const validIds = new Set(endpointOptions.value.map(option => option.value))
const selected = normalizeStringList(selectedEndpointIds.value)
if (selected.length === 0) {
return undefined
}
const invalidSelected = selected.filter(endpointId => !validIds.has(endpointId))
const selectedValidCount = selected.filter(endpointId => validIds.has(endpointId)).length
if (validIds.size > 0 && invalidSelected.length === 0 && selectedValidCount === validIds.size) {
return undefined
}
return selected
return selected.length > 0 ? selected : undefined
})
const endpointScopeSummary = computed(() => {
const selected = normalizedSelectedEndpointIds.value
if (!selected || selected.length === 0) return '全部端点'
return `${selected.length} 个端点`
if (!selected || selected.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
if (selected.length === 1) {
return endpointOptions.value.find(option => option.value === selected[0])?.label
?? t('providers.modelMapping.scope.endpointCount', { count: 1 })
}
return t('providers.modelMapping.scope.endpointCount', { count: selected.length })
})
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
const normalizedSelectedOperations = computed(() => {
const selected = normalizeStringList(selectedOperations.value)
const selected = normalizeModelMappingOperations(selectedOperations.value)
return selected.length > 0 ? selected : undefined
})
const operationScopeSummary = computed(() => {
const selected = normalizedSelectedOperations.value
if (!selected) return '全部操作'
return selected.length === 1 && selected[0] === 'compact'
? '线程压缩'
: `${selected.length} 项操作`
return formatModelMappingRequestScope(
normalizedSelectedOperations.value,
requestScopeLabels.value,
)
})
const mappingScopeSummary = computed(() => {
return `${endpointScopeSummary.value} · ${operationScopeSummary.value}`
})
const requestScopeValue = computed(() => {
return modelMappingRequestScopeValue(selectedOperations.value)
})
const requestScopeOptions = computed(() => {
return modelMappingRequestScopeOptions(selectedOperations.value, requestScopeLabels.value)
})
const requestScopeDescription = computed(() => {
if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
return t('providers.modelMapping.scope.allRequestsDescription')
}
if (requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE) {
return t('providers.modelMapping.scope.sessionCompactionDescription')
}
return t('providers.modelMapping.scope.customOperationsDescription', {
operations: normalizeModelMappingOperations(selectedOperations.value).join(', '),
})
})
// 所有已知名称集合
@@ -559,6 +614,17 @@ function scopesOverlap(left: string[] | undefined, right: string[] | undefined):
return leftValues.some(value => rightSet.has(value))
}
function operationScopesOverlap(
left: string[] | undefined,
right: string[] | undefined,
): boolean {
const leftValues = normalizeModelMappingOperations(left)
const rightValues = normalizeModelMappingOperations(right)
if (leftValues.length === 0 || rightValues.length === 0) return true
const rightSet = new Set(rightValues)
return leftValues.some(value => rightSet.has(value))
}
function findDuplicateNames(
existingAliases: ProviderModelAlias[],
names: string[],
@@ -574,7 +640,7 @@ function findDuplicateNames(
return alias.name === name
&& scopesOverlap(alias.endpoint_ids, endpointIds)
&& scopesOverlap(alias.api_formats, apiFormats)
&& scopesOverlap(alias.operations, operations)
&& operationScopesOverlap(alias.operations, operations)
})
if (duplicate) duplicates.add(name)
}
@@ -640,7 +706,7 @@ function initForm() {
const existingNames = props.editingGroup.aliases.map(a => a.name)
selectedNames.value = [...existingNames]
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
selectedOperations.value = normalizeStringList(props.editingGroup.operations)
selectedOperations.value = normalizeModelMappingOperations(props.editingGroup.operations)
allCustomNames.value = [...existingNames]
} else {
formData.value = {
@@ -662,6 +728,10 @@ function handleModelChange(value: string) {
formData.value.modelId = value
}
function handleRequestScopeChange(value: string) {
selectedOperations.value = modelMappingOperationsFromScopeValue(value) ?? []
}
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
return getScopeKey(formats)
@@ -672,7 +742,7 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
return getScopeKey(operations)
return modelMappingOperationsKey(operations)
}
// 提交表单
@@ -712,7 +782,7 @@ async function handleSubmit() {
if (props.editingGroup) {
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
const oldOperationsKey = props.editingGroup.operationsKey
const oldOperationsKey = modelMappingOperationsKey(props.editingGroup.operations)
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
@@ -0,0 +1,182 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import type { Model, ProviderEndpoint } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, [slots.default?.(), slots.footer?.()])
},
})
return {
Button: defineComponent({
name: 'ButtonStub',
setup(_, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
}),
Dialog: passthrough('DialogStub'),
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
}),
Label: passthrough('LabelStub', 'label'),
Select: passthrough('SelectStub'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub', 'span'),
}
})
vi.mock('@/components/common/MultiSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'MultiSelectStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Check: Icon,
ChevronDown: Icon,
Loader2: Icon,
Plus: Icon,
RefreshCw: Icon,
Search: Icon,
Tag: Icon,
Zap: Icon,
}
})
vi.mock('@/api/endpoints/models', () => ({
updateModel: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}),
}))
vi.mock('../../composables/useUpstreamModelsCache', () => ({
useUpstreamModelsCache: () => ({
fetchModels: vi.fn(),
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
vi.mocked(updateModel).mockClear()
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('ModelMappingDialog', () => {
it('normalizes and replaces an edited compact operation scope', async () => {
const endpoint = {
id: 'endpoint-responses',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [endpoint.id],
operations: ['Compact'],
}],
} as Model
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
apiFormats: [],
endpointIdsKey: endpoint.id,
endpointIds: [endpoint.id],
operationsKey: 'Compact',
operations: ['Compact'],
aliases: model.provider_model_mappings ?? [],
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ModelMappingDialog, {
open: open.value,
providerId: 'provider-1',
endpoints: [endpoint],
models: [model],
editingGroup,
'onUpdate:open': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await nextTick()
await nextTick()
expect(root.textContent).toContain('仅会话压缩')
const scopeButtons = [...root.querySelectorAll('button')]
scopeButtons.find(button => button.textContent?.includes('所有请求'))?.click()
await nextTick()
scopeButtons.find(button => button.textContent?.includes('仅会话压缩'))?.click()
await nextTick()
const saveButton = [...root.querySelectorAll('button')]
.find(button => button.textContent?.includes('保存映射'))
expect(saveButton).toBeDefined()
saveButton?.click()
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
provider_model_mappings: [{
name: 'gpt-5.6-luna',
priority: 1,
endpoint_ids: [endpoint.id],
operations: ['compact'],
}],
})
})
})
@@ -38,21 +38,21 @@
>
<!-- 分组头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
class="flex items-start justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleAliasGroupExpand(getAliasGroupKey(group))"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform"
:class="{ 'rotate-90': expandedAliasGroups.has(getAliasGroupKey(group)) }"
/>
<!-- 模型名称 -->
<span class="font-semibold text-sm truncate">
<span class="min-w-0 flex-[1_1_12rem] truncate text-sm font-semibold">
{{ group.model.global_model_display_name || group.model.provider_model_name }}
</span>
<!-- 作用域标签 -->
<div class="flex items-center gap-1 shrink-0">
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-1">
<Badge
v-if="group.apiFormats.length === 0"
variant="outline"
@@ -76,11 +76,10 @@
{{ getEndpointScopeLabel(group) }}
</Badge>
<Badge
v-if="group.operations.length > 0"
variant="outline"
class="text-xs"
class="min-w-0 max-w-full text-xs"
>
{{ getOperationScopeLabel(group) }}
<span class="truncate">{{ getOperationScopeLabel(group) }}</span>
</Badge>
</div>
<!-- 映射数量 -->
@@ -214,10 +213,16 @@ import {
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { useI18n } from '@/i18n'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { buildExactModelMappingTestRequest } from './model-test-request'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
formatModelMappingRequestScope,
modelMappingOperationsKey,
normalizeModelMappingOperations,
} from '../../utils/modelMappingScope'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -228,6 +233,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { t } = useI18n()
// 状态
const loading = ref(false)
@@ -278,21 +284,31 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
return getScopeKey(operations)
return modelMappingOperationsKey(operations)
}
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
function getAliasGroupKey(group: AliasGroup): string {
return `${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}-${group.operationsKey}`
}
function getEndpointScopeLabel(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
return `${group.endpointIds.length} 端点`
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
return t('providers.modelMapping.scope.endpointCount', { count: group.endpointIds.length })
}
function getOperationScopeLabel(group: AliasGroup): string {
if (group.operations.length === 1 && group.operations[0] === 'compact') return '压缩'
return `${group.operations.length} 项操作`
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 按"模型+作用域"分组的映射列表
@@ -317,7 +333,7 @@ const aliasGroups = computed<AliasGroup[]>(() => {
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
operations: normalizeStringList(alias.operations),
operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
@@ -39,10 +39,10 @@
>
<!-- 行头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
class="flex items-start justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleExpand(item.key)"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
@@ -50,7 +50,7 @@
/>
<!-- 精确映射 -->
<template v-if="item.type === 'exact'">
<div class="flex flex-col min-w-0">
<div class="flex min-w-0 flex-[1_1_12rem] flex-col">
<span class="font-semibold text-sm truncate">
{{ item.targetModelName }}
</span>
@@ -75,21 +75,23 @@
<Badge
v-if="item.group"
variant="outline"
class="text-xs shrink-0"
class="min-w-0 max-w-full text-xs"
:title="getGroupEndpointScopeTitle(item.group)"
>
{{ getGroupEndpointScopeLabel(item.group) }}
<span class="truncate">{{ getGroupEndpointScopeLabel(item.group) }}</span>
</Badge>
<Badge
v-if="item.group && item.group.operations.length > 0"
v-if="item.group"
variant="outline"
class="text-xs shrink-0"
class="min-w-0 max-w-full text-xs"
:title="getGroupOperationScopeLabel(item.group)"
>
{{ getGroupOperationScopeLabel(item.group) }}
<span class="truncate">{{ getGroupOperationScopeLabel(item.group) }}</span>
</Badge>
</template>
<!-- 正则映射 -->
<template v-else>
<div class="flex flex-col min-w-0">
<div class="flex min-w-0 flex-[1_1_12rem] flex-col">
<span class="font-semibold text-sm truncate">
{{ item.targetModelName }}
</span>
@@ -372,8 +374,15 @@ import {
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingOperationsKey,
normalizeModelMappingOperations,
} from '../../utils/modelMappingScope'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
@@ -424,6 +433,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { t } = useI18n()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
@@ -501,17 +511,46 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
return getScopeKey(operations)
return modelMappingOperationsKey(operations)
}
const requestScopeLabels = computed(() => ({
allRequests: t('providers.modelMapping.scope.allRequests'),
sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
customOperations: (operations: string[]) => t(
'providers.modelMapping.scope.customOperations',
{ operations: operations.join(', ') },
),
}))
function getGroupEndpointScopeLabel(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
return `${group.endpointIds.length} 端点`
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
const labels = getGroupEndpointScopeLabels(group)
return labels.length === 1
? labels[0]
: t('providers.modelMapping.scope.endpointCount', { count: labels.length })
}
function getGroupEndpointScopeTitle(group: AliasGroup): string {
if (!group.endpointIds || group.endpointIds.length === 0) {
return t('providers.modelMapping.scope.allEndpoints')
}
return getGroupEndpointScopeLabels(group).join('、')
}
function getGroupEndpointScopeLabels(group: AliasGroup): string[] {
const endpoints = props.endpoints ?? []
return group.endpointIds.map((endpointId) => {
const endpoint = endpoints.find(item => item.id === endpointId)
if (!endpoint) return endpointId
return formatModelMappingEndpointLabel(endpoint, endpoints)
})
}
function getGroupOperationScopeLabel(group: AliasGroup): string {
if (group.operations.length === 1 && group.operations[0] === 'compact') return '压缩'
return `${group.operations.length} 项操作`
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 精确映射分组(来自 provider_model_mappings
@@ -536,7 +575,7 @@ const exactMappingGroups = computed<AliasGroup[]>(() => {
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
operations: normalizeStringList(alias.operations),
operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import {
ALL_REQUESTS_SCOPE_VALUE,
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
modelMappingRequestScopeValue,
normalizeModelMappingOperations,
} from '../modelMappingScope'
describe('model mapping request scope', () => {
it('represents an omitted operation filter as all requests', () => {
expect(modelMappingRequestScopeValue(undefined)).toBe(ALL_REQUESTS_SCOPE_VALUE)
expect(modelMappingOperationsFromScopeValue(ALL_REQUESTS_SCOPE_VALUE)).toBeUndefined()
expect(formatModelMappingRequestScope(undefined)).toBe('所有请求')
})
it('round-trips the compact operation as a dedicated request scope', () => {
expect(modelMappingRequestScopeValue(['compact'])).toBe(COMPACT_REQUEST_SCOPE_VALUE)
expect(modelMappingOperationsFromScopeValue(COMPACT_REQUEST_SCOPE_VALUE)).toEqual(['compact'])
expect(formatModelMappingRequestScope(['compact'])).toBe('仅会话压缩')
})
it('normalizes operation values using the backend matching semantics', () => {
expect(normalizeModelMappingOperations([' Compact ', 'compact', '', 'SEARCH'])).toEqual([
'compact',
'search',
])
expect(modelMappingOperationsKey(['SEARCH', ' compact ', 'Compact'])).toBe('compact,search')
})
it('preserves an unknown operation scope while editing', () => {
const operations = ['future_operation', 'compact']
const value = modelMappingRequestScopeValue(operations)
const options = modelMappingRequestScopeOptions(operations)
expect(modelMappingOperationsFromScopeValue(value)).toEqual(operations)
expect(options).toContainEqual({ value, label: '仅匹配:future_operation, compact' })
})
it('rejects malformed scope values without constructing operations', () => {
expect(modelMappingOperationsFromScopeValue('compact')).toBeUndefined()
expect(modelMappingOperationsFromScopeValue('{"compact":true}')).toBeUndefined()
})
it('disambiguates endpoints that share an API format', () => {
const endpoints = [
{
id: 'endpoint-1',
api_format: 'openai:responses',
base_url: 'https://api.example.com/v1',
is_active: true,
},
{
id: 'endpoint-2',
api_format: 'openai:responses',
base_url: 'https://backup.example.com/v1',
custom_path: '/backend-api/codex/responses',
is_active: false,
},
]
expect(formatModelMappingEndpointLabel(endpoints[0], endpoints)).toBe(
'OpenAI Responses · api.example.com/v1',
)
expect(formatModelMappingEndpointLabel(endpoints[1], endpoints)).toBe(
'OpenAI Responses · backup.example.com/backend-api/codex/responses(停用)',
)
})
})
@@ -0,0 +1,132 @@
import { formatApiFormat } from '@/api/endpoints/types/api-format'
export const MODEL_MAPPING_OPERATION_COMPACT = 'compact'
export const ALL_REQUESTS_SCOPE_VALUE = '[]'
export const COMPACT_REQUEST_SCOPE_VALUE = JSON.stringify([
MODEL_MAPPING_OPERATION_COMPACT,
])
export interface ModelMappingRequestScopeOption {
value: string
label: string
}
export interface ModelMappingRequestScopeLabels {
allRequests: string
sessionCompactionOnly: string
customOperations: (operations: string[]) => string
}
export interface ModelMappingEndpoint {
id: string
api_format: string
base_url: string
custom_path?: string
is_active: boolean
}
const DEFAULT_REQUEST_SCOPE_LABELS: ModelMappingRequestScopeLabels = {
allRequests: '所有请求',
sessionCompactionOnly: '仅会话压缩',
customOperations: operations => `仅匹配:${operations.join(', ')}`,
}
export function normalizeModelMappingOperations(
operations: string[] | undefined,
): string[] {
const seen = new Set<string>()
const normalized: string[] = []
for (const operation of operations ?? []) {
const value = operation.trim().toLowerCase()
if (!value || seen.has(value)) continue
seen.add(value)
normalized.push(value)
}
return normalized
}
export function modelMappingRequestScopeValue(
operations: string[] | undefined,
): string {
return JSON.stringify(normalizeModelMappingOperations(operations))
}
export function modelMappingOperationsKey(
operations: string[] | undefined,
): string {
return normalizeModelMappingOperations(operations).sort().join(',')
}
export function modelMappingOperationsFromScopeValue(
value: string,
): string[] | undefined {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed) || parsed.some(item => typeof item !== 'string')) {
return undefined
}
const operations = normalizeModelMappingOperations(parsed)
return operations.length > 0 ? operations : undefined
} catch {
return undefined
}
}
export function formatModelMappingRequestScope(
operations: string[] | undefined,
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
): string {
const normalized = normalizeModelMappingOperations(operations)
if (normalized.length === 0) return labels.allRequests
if (
normalized.length === 1
&& normalized[0] === MODEL_MAPPING_OPERATION_COMPACT
) {
return labels.sessionCompactionOnly
}
return labels.customOperations(normalized)
}
export function modelMappingRequestScopeOptions(
operations: string[] | undefined,
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
): ModelMappingRequestScopeOption[] {
const options: ModelMappingRequestScopeOption[] = [
{ value: ALL_REQUESTS_SCOPE_VALUE, label: labels.allRequests },
{ value: COMPACT_REQUEST_SCOPE_VALUE, label: labels.sessionCompactionOnly },
]
const currentValue = modelMappingRequestScopeValue(operations)
if (!options.some(option => option.value === currentValue)) {
options.push({
value: currentValue,
label: formatModelMappingRequestScope(operations, labels),
})
}
return options
}
export function formatModelMappingEndpointLabel(
endpoint: ModelMappingEndpoint,
endpoints: ModelMappingEndpoint[],
): string {
const sameFormatCount = endpoints.filter(item => item.api_format === endpoint.api_format).length
const format = formatApiFormat(endpoint.api_format)
const discriminator = sameFormatCount > 1
? formatModelMappingEndpointDiscriminator(endpoint)
: ''
const status = endpoint.is_active ? '' : '(停用)'
return `${format}${discriminator ? ` · ${discriminator}` : ''}${status}`
}
function formatModelMappingEndpointDiscriminator(endpoint: ModelMappingEndpoint): string {
const baseUrl = endpoint.base_url.trim()
try {
const parsed = new URL(baseUrl)
const customPath = endpoint.custom_path?.trim()
const path = (customPath || parsed.pathname).replace(/\/$/, '')
return `${parsed.host}${path && path !== '/' ? path : ''}`
} catch {
return baseUrl || endpoint.id.slice(0, 8)
}
}
@@ -248,7 +248,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="线程压缩"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
@@ -765,7 +765,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="线程压缩"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
@@ -797,7 +797,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
title="线程压缩"
title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
</Badge>
@@ -1630,7 +1630,7 @@ function getReasoningEffort(record: UsageRecord): string | null {
}
function getRequestTypeLabel(record: UsageRecord): string | null {
return record.request_type?.trim().toLowerCase() === 'compact' ? '压缩' : null
return record.request_type?.trim().toLowerCase() === 'compact' ? '会话压缩' : null
}
function getReasoningEffortTitle(record: UsageRecord): string {
@@ -303,7 +303,7 @@ describe('UsageRecordsTable', () => {
request_type: 'compact',
})])
expect(root.textContent).toContain('压缩')
expect(root.textContent).toContain('会话压缩')
})
it('shows fast badge for priority service tier', () => {
+29
View File
@@ -295,6 +295,16 @@ export const messages = {
'nav.cacheMonitoring': '缓存监控',
'nav.moduleManagement': '模块管理',
'nav.systemSettings': '系统设置',
'providers.modelMapping.scope.allRequests': '所有请求',
'providers.modelMapping.scope.sessionCompactionOnly': '仅会话压缩',
'providers.modelMapping.scope.customOperations': '仅匹配:{operations}',
'providers.modelMapping.scope.allRequestsDescription': '普通请求和会话压缩都可使用此映射。',
'providers.modelMapping.scope.sessionCompactionDescription': '只在会话压缩时使用此映射,包括 Responses 输入中的压缩触发和 Responses Compact 端点。',
'providers.modelMapping.scope.customOperationsDescription': '只匹配请求操作:{operations}。',
'providers.modelMapping.scope.allEndpoints': '全部端点',
'providers.modelMapping.scope.endpointCount': '{count} 个端点',
'providers.modelMapping.scope.endpointHelp': '留空时不限制端点;选择后仅匹配指定端点。',
'providers.modelMapping.scope.matchHelp': '端点范围和请求范围必须同时匹配。',
'breadcrumb.personalSettings': '个人设置',
'breadcrumb.routingCreate': '新建调度策略',
'breadcrumb.routingConfig': '调度策略配置',
@@ -595,6 +605,16 @@ export const messages = {
'nav.cacheMonitoring': 'Cache monitoring',
'nav.moduleManagement': 'Modules',
'nav.systemSettings': 'System settings',
'providers.modelMapping.scope.allRequests': 'All requests',
'providers.modelMapping.scope.sessionCompactionOnly': 'Session compaction only',
'providers.modelMapping.scope.customOperations': 'Match only: {operations}',
'providers.modelMapping.scope.allRequestsDescription': 'Regular requests and session compaction can both use this mapping.',
'providers.modelMapping.scope.sessionCompactionDescription': 'Use this mapping only for session compaction, including compaction triggers in Responses input and the Responses Compact endpoint.',
'providers.modelMapping.scope.customOperationsDescription': 'Match only these request operations: {operations}.',
'providers.modelMapping.scope.allEndpoints': 'All endpoints',
'providers.modelMapping.scope.endpointCount': '{count} endpoints',
'providers.modelMapping.scope.endpointHelp': 'Leave empty for no endpoint restriction. Selections match only those endpoints.',
'providers.modelMapping.scope.matchHelp': 'Both the endpoint scope and request scope must match.',
'breadcrumb.personalSettings': 'Profile settings',
'breadcrumb.routingCreate': 'Create routing profile',
'breadcrumb.routingConfig': 'Routing profile',
@@ -673,6 +693,15 @@ const legacyExactEnglishMessages: Record<string, string> = {
'全部分组': 'All groups',
'全部归属': 'All ownership',
'全部方式': 'All methods',
'适用范围': 'Applies to',
'适用端点': 'Applicable endpoints',
'适用请求': 'Applicable requests',
'全部端点': 'All endpoints',
'所有请求': 'All requests',
'仅会话压缩': 'Session compaction only',
'会话压缩': 'Session compaction',
'保存映射': 'Save mapping',
'添加映射': 'Add mapping',
'未设置': 'Not set',
'已设置': 'Configured',
'默认': 'Default',