mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 13:10:21 +08:00
fix(frontend): 按端点能力约束会话压缩映射
This commit is contained in:
@@ -338,6 +338,7 @@ import {
|
||||
COMPACT_REQUEST_SCOPE_VALUE,
|
||||
formatModelMappingEndpointLabel,
|
||||
formatModelMappingRequestScope,
|
||||
modelMappingEndpointScopeSupportsSessionCompaction,
|
||||
modelMappingOperationsKey,
|
||||
modelMappingOperationsFromScopeValue,
|
||||
modelMappingRequestScopeOptions,
|
||||
@@ -430,6 +431,13 @@ const normalizedSelectedEndpointIds = computed(() => {
|
||||
return selected.length > 0 ? selected : undefined
|
||||
})
|
||||
|
||||
const sessionCompactionScopeAvailable = computed(() => {
|
||||
return modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
normalizedSelectedEndpointIds.value,
|
||||
props.endpoints ?? [],
|
||||
)
|
||||
})
|
||||
|
||||
const endpointScopeSummary = computed(() => {
|
||||
const selected = normalizedSelectedEndpointIds.value
|
||||
if (!selected || selected.length === 0) {
|
||||
@@ -472,12 +480,18 @@ const requestScopeValue = computed(() => {
|
||||
})
|
||||
|
||||
const requestScopeOptions = computed(() => {
|
||||
return modelMappingRequestScopeOptions(selectedOperations.value, requestScopeLabels.value)
|
||||
return modelMappingRequestScopeOptions(
|
||||
selectedOperations.value,
|
||||
{ sessionCompaction: sessionCompactionScopeAvailable.value },
|
||||
requestScopeLabels.value,
|
||||
)
|
||||
})
|
||||
|
||||
const requestScopeDescription = computed(() => {
|
||||
if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
|
||||
return t('providers.modelMapping.scope.allRequestsDescription')
|
||||
return sessionCompactionScopeAvailable.value
|
||||
? t('providers.modelMapping.scope.allRequestsDescription')
|
||||
: t('providers.modelMapping.scope.allRequestsDefaultDescription')
|
||||
}
|
||||
if (requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE) {
|
||||
return t('providers.modelMapping.scope.sessionCompactionDescription')
|
||||
@@ -487,6 +501,12 @@ const requestScopeDescription = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.endpoints, () => selectedEndpointIds.value],
|
||||
() => normalizeUnavailableSessionCompactionScope(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 所有已知名称集合
|
||||
const allKnownNames = computed(() => {
|
||||
const set = new Set<string>()
|
||||
@@ -721,6 +741,7 @@ function initForm() {
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
normalizeUnavailableSessionCompactionScope()
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
@@ -729,9 +750,23 @@ function handleModelChange(value: string) {
|
||||
}
|
||||
|
||||
function handleRequestScopeChange(value: string) {
|
||||
if (
|
||||
value === COMPACT_REQUEST_SCOPE_VALUE
|
||||
&& !sessionCompactionScopeAvailable.value
|
||||
) return
|
||||
selectedOperations.value = modelMappingOperationsFromScopeValue(value) ?? []
|
||||
}
|
||||
|
||||
function normalizeUnavailableSessionCompactionScope() {
|
||||
if (props.endpoints === undefined) return
|
||||
if (
|
||||
requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE
|
||||
&& !sessionCompactionScopeAvailable.value
|
||||
) {
|
||||
selectedOperations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
return getScopeKey(formats)
|
||||
|
||||
@@ -52,8 +52,21 @@ vi.mock('@/components/common/MultiSelect.vue', async () => {
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'MultiSelectStub',
|
||||
setup() {
|
||||
return () => h('div')
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', (props.options as Array<{ value: string, label: string }>).map(option => h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'data-endpoint-id': option.value,
|
||||
onClick: () => emit('update:modelValue', [option.value]),
|
||||
},
|
||||
option.label,
|
||||
)))
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -108,6 +121,136 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ModelMappingDialog', () => {
|
||||
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
|
||||
const chatEndpoint = {
|
||||
id: 'endpoint-chat',
|
||||
api_format: 'openai:chat',
|
||||
base_url: 'https://api.example.com/v1',
|
||||
is_active: true,
|
||||
} as ProviderEndpoint
|
||||
const responsesEndpoint = {
|
||||
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: [],
|
||||
} as Model
|
||||
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: [chatEndpoint, responsesEndpoint],
|
||||
models: [model],
|
||||
preselectedModelId: model.id,
|
||||
'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('所有请求')
|
||||
expect(root.textContent).not.toContain('仅会话压缩')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
|
||||
await nextTick()
|
||||
expect(root.textContent).not.toContain('仅会话压缩')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-responses"]')?.click()
|
||||
await nextTick()
|
||||
expect(root.textContent).toContain('仅会话压缩')
|
||||
})
|
||||
|
||||
it('returns to all requests when a compact mapping switches away from Responses', async () => {
|
||||
const responsesEndpoint = {
|
||||
id: 'endpoint-responses',
|
||||
api_format: 'openai:responses',
|
||||
base_url: 'https://api.example.com/v1',
|
||||
is_active: true,
|
||||
} as ProviderEndpoint
|
||||
const chatEndpoint = {
|
||||
id: 'endpoint-chat',
|
||||
api_format: 'openai:chat',
|
||||
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: [responsesEndpoint.id],
|
||||
operations: ['compact'],
|
||||
}],
|
||||
} as Model
|
||||
const editingGroup: AliasGroup = {
|
||||
model,
|
||||
apiFormatsKey: '',
|
||||
apiFormats: [],
|
||||
endpointIdsKey: responsesEndpoint.id,
|
||||
endpointIds: [responsesEndpoint.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: [responsesEndpoint, chatEndpoint],
|
||||
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('仅会话压缩')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
|
||||
await nextTick()
|
||||
expect(root.textContent).not.toContain('仅会话压缩')
|
||||
expect(root.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]')?.textContent)
|
||||
.toContain('所有请求')
|
||||
|
||||
const saveButton = [...root.querySelectorAll('button')]
|
||||
.find(button => button.textContent?.includes('保存映射'))
|
||||
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: [chatEndpoint.id],
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and replaces an edited compact operation scope', async () => {
|
||||
const endpoint = {
|
||||
id: 'endpoint-responses',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
COMPACT_REQUEST_SCOPE_VALUE,
|
||||
formatModelMappingEndpointLabel,
|
||||
formatModelMappingRequestScope,
|
||||
modelMappingEndpointScopeSupportsSessionCompaction,
|
||||
modelMappingOperationsKey,
|
||||
modelMappingOperationsFromScopeValue,
|
||||
modelMappingRequestScopeOptions,
|
||||
@@ -36,12 +37,50 @@ describe('model mapping request scope', () => {
|
||||
it('preserves an unknown operation scope while editing', () => {
|
||||
const operations = ['future_operation', 'compact']
|
||||
const value = modelMappingRequestScopeValue(operations)
|
||||
const options = modelMappingRequestScopeOptions(operations)
|
||||
const options = modelMappingRequestScopeOptions(
|
||||
operations,
|
||||
{ sessionCompaction: true },
|
||||
)
|
||||
|
||||
expect(modelMappingOperationsFromScopeValue(value)).toEqual(operations)
|
||||
expect(options).toContainEqual({ value, label: '仅匹配:future_operation, compact' })
|
||||
})
|
||||
|
||||
it('offers compact scope only when the selected endpoint scope supports it', () => {
|
||||
expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: false }))
|
||||
.toEqual([{ value: ALL_REQUESTS_SCOPE_VALUE, label: '所有请求' }])
|
||||
expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: true }))
|
||||
.toContainEqual({ value: COMPACT_REQUEST_SCOPE_VALUE, label: '仅会话压缩' })
|
||||
})
|
||||
|
||||
it('requires every explicitly selected endpoint to use OpenAI Responses', () => {
|
||||
const responsesEndpoint = {
|
||||
id: 'responses',
|
||||
api_format: 'OPENAI_RESPONSES',
|
||||
base_url: 'https://api.example.com/v1',
|
||||
is_active: true,
|
||||
}
|
||||
const chatEndpoint = {
|
||||
id: 'chat',
|
||||
api_format: 'openai:chat',
|
||||
base_url: 'https://api.example.com/v1',
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
expect(modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
undefined,
|
||||
[responsesEndpoint, chatEndpoint],
|
||||
)).toBe(false)
|
||||
expect(modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
[responsesEndpoint.id],
|
||||
[responsesEndpoint, chatEndpoint],
|
||||
)).toBe(true)
|
||||
expect(modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
[responsesEndpoint.id, chatEndpoint.id],
|
||||
[responsesEndpoint, chatEndpoint],
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed scope values without constructing operations', () => {
|
||||
expect(modelMappingOperationsFromScopeValue('compact')).toBeUndefined()
|
||||
expect(modelMappingOperationsFromScopeValue('{"compact":true}')).toBeUndefined()
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
API_FORMATS,
|
||||
formatApiFormat,
|
||||
normalizeApiFormatAlias,
|
||||
} from '@/api/endpoints/types/api-format'
|
||||
|
||||
export const MODEL_MAPPING_OPERATION_COMPACT = 'compact'
|
||||
|
||||
@@ -12,6 +16,10 @@ export interface ModelMappingRequestScopeOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ModelMappingRequestScopeCapabilities {
|
||||
sessionCompaction: boolean
|
||||
}
|
||||
|
||||
export interface ModelMappingRequestScopeLabels {
|
||||
allRequests: string
|
||||
sessionCompactionOnly: string
|
||||
@@ -90,14 +98,22 @@ export function formatModelMappingRequestScope(
|
||||
|
||||
export function modelMappingRequestScopeOptions(
|
||||
operations: string[] | undefined,
|
||||
capabilities: ModelMappingRequestScopeCapabilities,
|
||||
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 },
|
||||
]
|
||||
if (capabilities.sessionCompaction) {
|
||||
options.push({
|
||||
value: COMPACT_REQUEST_SCOPE_VALUE,
|
||||
label: labels.sessionCompactionOnly,
|
||||
})
|
||||
}
|
||||
const currentValue = modelMappingRequestScopeValue(operations)
|
||||
if (!options.some(option => option.value === currentValue)) {
|
||||
const compactScopeUnavailable = currentValue === COMPACT_REQUEST_SCOPE_VALUE
|
||||
&& !capabilities.sessionCompaction
|
||||
if (!compactScopeUnavailable && !options.some(option => option.value === currentValue)) {
|
||||
options.push({
|
||||
value: currentValue,
|
||||
label: formatModelMappingRequestScope(operations, labels),
|
||||
@@ -106,6 +122,23 @@ export function modelMappingRequestScopeOptions(
|
||||
return options
|
||||
}
|
||||
|
||||
export function modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
endpointIds: string[] | undefined,
|
||||
endpoints: ModelMappingEndpoint[],
|
||||
): boolean {
|
||||
const selectedIds = [...new Set(
|
||||
(endpointIds ?? []).map(id => id.trim()).filter(Boolean),
|
||||
)]
|
||||
if (selectedIds.length === 0) return false
|
||||
|
||||
const endpointsById = new Map(endpoints.map(endpoint => [endpoint.id, endpoint]))
|
||||
return selectedIds.every((endpointId) => {
|
||||
const endpoint = endpointsById.get(endpointId)
|
||||
return endpoint !== undefined
|
||||
&& normalizeApiFormatAlias(endpoint.api_format) === API_FORMATS.OPENAI_RESPONSES
|
||||
})
|
||||
}
|
||||
|
||||
export function formatModelMappingEndpointLabel(
|
||||
endpoint: ModelMappingEndpoint,
|
||||
endpoints: ModelMappingEndpoint[],
|
||||
|
||||
@@ -299,7 +299,8 @@ export const messages = {
|
||||
'providers.modelMapping.scope.sessionCompactionOnly': '仅会话压缩',
|
||||
'providers.modelMapping.scope.customOperations': '仅匹配:{operations}',
|
||||
'providers.modelMapping.scope.allRequestsDescription': '普通请求和会话压缩都可使用此映射。',
|
||||
'providers.modelMapping.scope.sessionCompactionDescription': '只在会话压缩时使用此映射,包括 Responses 输入中的压缩触发和 Responses Compact 端点。',
|
||||
'providers.modelMapping.scope.allRequestsDefaultDescription': '所选端点的所有请求都可使用此映射。仅当适用端点已明确选择且全部为 OpenAI Responses 时,可限定为会话压缩。',
|
||||
'providers.modelMapping.scope.sessionCompactionDescription': '仅匹配所选 OpenAI Responses 端点中的会话压缩请求。',
|
||||
'providers.modelMapping.scope.customOperationsDescription': '只匹配请求操作:{operations}。',
|
||||
'providers.modelMapping.scope.allEndpoints': '全部端点',
|
||||
'providers.modelMapping.scope.endpointCount': '{count} 个端点',
|
||||
@@ -609,7 +610,8 @@ export const messages = {
|
||||
'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.allRequestsDefaultDescription': 'This mapping applies to all requests on the selected endpoints. Session compaction can be selected only when every explicitly selected endpoint uses OpenAI Responses.',
|
||||
'providers.modelMapping.scope.sessionCompactionDescription': 'Match only session compaction requests on the selected OpenAI Responses endpoints.',
|
||||
'providers.modelMapping.scope.customOperationsDescription': 'Match only these request operations: {operations}.',
|
||||
'providers.modelMapping.scope.allEndpoints': 'All endpoints',
|
||||
'providers.modelMapping.scope.endpointCount': '{count} endpoints',
|
||||
|
||||
Reference in New Issue
Block a user