mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
Merge 0c3f51bcec into 644ae9c1bf
This commit is contained in:
@@ -369,6 +369,13 @@ mod tests {
|
||||
),
|
||||
vec!["openai:search".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:responses".to_string()],
|
||||
&["openai:responses:compact".to_string()],
|
||||
),
|
||||
vec!["openai:responses:compact".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:search".to_string()],
|
||||
|
||||
@@ -3965,6 +3965,93 @@ mod tests {
|
||||
panic!("pending lifecycle usage event was not enqueued");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_lifecycle_generation_keeps_the_current_generation_registered() {
|
||||
let coalescer = super::LifecycleEventCoalescer::default();
|
||||
let request_id = "req-lifecycle-generation";
|
||||
let pending_generation = coalescer
|
||||
.register(request_id.to_string())
|
||||
.await
|
||||
.expect("pending generation should register");
|
||||
let streaming_generation = coalescer
|
||||
.register(request_id.to_string())
|
||||
.await
|
||||
.expect("streaming generation should register");
|
||||
|
||||
assert!(!coalescer.should_emit(request_id, pending_generation).await);
|
||||
assert!(
|
||||
coalescer
|
||||
.should_emit(request_id, streaming_generation)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_lifecycle_event_survives_a_superseded_pending_event() {
|
||||
let config = UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
queue_lifecycle_events: true,
|
||||
lifecycle_enqueue_delay_ms: 40,
|
||||
stream_key: "usage:events:test:pending-streaming-coalescing".to_string(),
|
||||
consumer_group: "usage_consumers_test_pending_streaming_coalescing".to_string(),
|
||||
consumer_block_ms: 1,
|
||||
..UsageRuntimeConfig::default()
|
||||
};
|
||||
let queue_runner: Arc<dyn RuntimeQueueStore> =
|
||||
Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default()));
|
||||
let queue = UsageQueue::new(Arc::clone(&queue_runner), config.clone())
|
||||
.expect("usage queue should build");
|
||||
queue
|
||||
.ensure_consumer_group()
|
||||
.await
|
||||
.expect("consumer group should initialize");
|
||||
let store = CloneQueueConfiguredUsageStore {
|
||||
records: Arc::new(Mutex::new(Vec::new())),
|
||||
queue: queue_runner,
|
||||
};
|
||||
let runtime = UsageRuntime::new(config).expect("usage runtime should build");
|
||||
|
||||
runtime
|
||||
.enqueue_or_write_lifecycle(
|
||||
&store,
|
||||
UsageEvent::new(
|
||||
UsageEventType::Pending,
|
||||
"req-pending-streaming-coalescing",
|
||||
UsageEventData {
|
||||
provider_name: "openai".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
runtime
|
||||
.enqueue_or_write_lifecycle(
|
||||
&store,
|
||||
UsageEvent::new(
|
||||
UsageEventType::Streaming,
|
||||
"req-pending-streaming-coalescing",
|
||||
UsageEventData {
|
||||
provider_name: "openai".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
sleep(Duration::from_millis(90)).await;
|
||||
let entries = queue
|
||||
.read_group("usage-test-consumer-pending-streaming-coalescing")
|
||||
.await
|
||||
.expect("queue read should succeed");
|
||||
assert_eq!(entries.len(), 1);
|
||||
let event = UsageEvent::from_stream_fields(&entries[0].fields)
|
||||
.expect("queued usage event should parse");
|
||||
assert_eq!(event.event_type, UsageEventType::Streaming);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_lifecycle_enqueue_can_be_delayed() {
|
||||
let config = UsageRuntimeConfig {
|
||||
|
||||
@@ -2036,6 +2036,22 @@ fn build_runtime_request_metadata_seed(
|
||||
provider_request_body_ref.as_deref(),
|
||||
plan.body.body_bytes_b64.as_deref(),
|
||||
);
|
||||
let provider_request_body = plan.body.json_body.as_ref().or_else(|| {
|
||||
context_value_ref(context, "provider_request_body").filter(|value| !value.is_null())
|
||||
});
|
||||
let provider_api_format = context_string(context, "provider_api_format")
|
||||
.or_else(|| non_empty_str(Some(plan.provider_api_format.as_str())));
|
||||
let provider_model = context_string(context, "mapped_model")
|
||||
.or_else(|| non_empty_str(plan.model_name.as_deref()));
|
||||
let source_model =
|
||||
context_string(context, "model").or_else(|| non_empty_str(plan.model_name.as_deref()));
|
||||
metadata = attach_provider_request_body_metadata(
|
||||
metadata,
|
||||
provider_api_format.as_deref(),
|
||||
provider_model.as_deref(),
|
||||
source_model.as_deref(),
|
||||
provider_request_body,
|
||||
);
|
||||
if let Some(proxy) = plan.proxy.as_ref() {
|
||||
if let Some(node_id) = proxy
|
||||
.node_id
|
||||
@@ -3635,7 +3651,8 @@ mod tests {
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning": {"effort": "max"}
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "claude:messages".to_string(),
|
||||
@@ -3694,7 +3711,10 @@ mod tests {
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.expect("pending usage should only keep lightweight request metadata");
|
||||
assert_eq!(metadata.len(), 1);
|
||||
assert_eq!(
|
||||
metadata.get("provider_reasoning_effort"),
|
||||
Some(&json!("max"))
|
||||
);
|
||||
let body_size = metadata
|
||||
.get("body_size")
|
||||
.and_then(Value::as_object)
|
||||
|
||||
@@ -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 格式数组
|
||||
|
||||
@@ -940,6 +940,7 @@ export interface ProviderModelMapping {
|
||||
priority: number // 优先级(数字越小优先级越高)
|
||||
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
|
||||
endpoint_ids?: string[] // 作用域(适用的端点 ID),为空表示对所有端点生效
|
||||
operations?: string[] // 作用域(适用的请求操作),为空表示对该格式的全部操作生效
|
||||
}
|
||||
|
||||
// 保留别名以保持向后兼容
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface UsageRecordDetail {
|
||||
id: string
|
||||
provider?: string // 仅管理员可见
|
||||
model: string
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
@@ -369,6 +370,7 @@ export const meApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface UsageRecord {
|
||||
provider_id?: string // UUID
|
||||
provider_name?: string
|
||||
model: string
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
@@ -569,6 +570,7 @@ export const usageApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
|
||||
@@ -37,27 +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">
|
||||
<Label class="text-xs">提供商模型</Label>
|
||||
@@ -244,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>
|
||||
@@ -261,7 +301,7 @@
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
{{ editingGroup ? '保存' : '添加' }}
|
||||
{{ editingGroup ? '保存映射' : '添加映射' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -283,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,
|
||||
@@ -291,8 +332,19 @@ 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,
|
||||
modelMappingEndpointScopeSupportsSessionCompaction,
|
||||
modelMappingOperationsKey,
|
||||
modelMappingOperationsFromScopeValue,
|
||||
modelMappingRequestScopeOptions,
|
||||
modelMappingRequestScopeValue,
|
||||
normalizeModelMappingOperations,
|
||||
} from '../utils/modelMappingScope'
|
||||
|
||||
export interface AliasGroup {
|
||||
model: Model
|
||||
@@ -302,6 +354,8 @@ export interface AliasGroup {
|
||||
apiFormats: string[]
|
||||
endpointIdsKey: string
|
||||
endpointIds: string[]
|
||||
operationsKey: string
|
||||
operations: string[]
|
||||
aliases: ProviderModelAlias[]
|
||||
}
|
||||
|
||||
@@ -323,6 +377,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
const { t } = useI18n()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
type EndpointOption = {
|
||||
@@ -358,39 +413,100 @@ const selectedNames = ref<string[]>([])
|
||||
// 选中的端点 ID;空数组表示全部端点
|
||||
const selectedEndpointIds = ref<string[]>([])
|
||||
|
||||
const selectedOperations = ref<string[]>([])
|
||||
|
||||
// 自定义名称列表(手动添加的)
|
||||
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 sessionCompactionScopeAvailable = computed(() => {
|
||||
return modelMappingEndpointScopeSupportsSessionCompaction(
|
||||
normalizedSelectedEndpointIds.value,
|
||||
props.endpoints ?? [],
|
||||
)
|
||||
})
|
||||
|
||||
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 = normalizeModelMappingOperations(selectedOperations.value)
|
||||
return selected.length > 0 ? selected : undefined
|
||||
})
|
||||
|
||||
const operationScopeSummary = computed(() => {
|
||||
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,
|
||||
{ sessionCompaction: sessionCompactionScopeAvailable.value },
|
||||
requestScopeLabels.value,
|
||||
)
|
||||
})
|
||||
|
||||
const requestScopeDescription = computed(() => {
|
||||
if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
|
||||
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')
|
||||
}
|
||||
return t('providers.modelMapping.scope.customOperationsDescription', {
|
||||
operations: normalizeModelMappingOperations(selectedOperations.value).join(', '),
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.endpoints, () => selectedEndpointIds.value],
|
||||
() => normalizeUnavailableSessionCompactionScope(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 所有已知名称集合
|
||||
const allKnownNames = computed(() => {
|
||||
const set = new Set<string>()
|
||||
@@ -518,11 +634,23 @@ 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[],
|
||||
endpointIds: string[] | undefined,
|
||||
apiFormats: string[] | undefined = undefined,
|
||||
operations: string[] | undefined = undefined,
|
||||
): string[] {
|
||||
const duplicates = new Set<string>()
|
||||
for (const rawName of names) {
|
||||
@@ -532,6 +660,7 @@ function findDuplicateNames(
|
||||
return alias.name === name
|
||||
&& scopesOverlap(alias.endpoint_ids, endpointIds)
|
||||
&& scopesOverlap(alias.api_formats, apiFormats)
|
||||
&& operationScopesOverlap(alias.operations, operations)
|
||||
})
|
||||
if (duplicate) duplicates.add(name)
|
||||
}
|
||||
@@ -597,6 +726,7 @@ function initForm() {
|
||||
const existingNames = props.editingGroup.aliases.map(a => a.name)
|
||||
selectedNames.value = [...existingNames]
|
||||
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
|
||||
selectedOperations.value = normalizeModelMappingOperations(props.editingGroup.operations)
|
||||
allCustomNames.value = [...existingNames]
|
||||
} else {
|
||||
formData.value = {
|
||||
@@ -604,12 +734,14 @@ function initForm() {
|
||||
}
|
||||
selectedNames.value = []
|
||||
selectedEndpointIds.value = []
|
||||
selectedOperations.value = []
|
||||
allCustomNames.value = []
|
||||
}
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
normalizeUnavailableSessionCompactionScope()
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
@@ -617,6 +749,24 @@ function handleModelChange(value: string) {
|
||||
formData.value.modelId = value
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -626,6 +776,10 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
|
||||
return getScopeKey(endpointIds)
|
||||
}
|
||||
|
||||
function getOperationsKey(operations: string[] | undefined): string {
|
||||
return modelMappingOperationsKey(operations)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
@@ -642,6 +796,7 @@ async function handleSubmit() {
|
||||
const currentAliases = targetModel.provider_model_mappings || []
|
||||
let newAliases: ProviderModelAlias[]
|
||||
const nextEndpointIds = normalizedSelectedEndpointIds.value
|
||||
const nextOperations = normalizedSelectedOperations.value
|
||||
|
||||
const buildAliases = (names: string[]): ProviderModelAlias[] => {
|
||||
return names.map((name) => {
|
||||
@@ -652,6 +807,9 @@ async function handleSubmit() {
|
||||
if (nextEndpointIds && nextEndpointIds.length > 0) {
|
||||
alias.endpoint_ids = nextEndpointIds
|
||||
}
|
||||
if (nextOperations && nextOperations.length > 0) {
|
||||
alias.operations = nextOperations
|
||||
}
|
||||
return alias
|
||||
})
|
||||
}
|
||||
@@ -659,15 +817,26 @@ async function handleSubmit() {
|
||||
if (props.editingGroup) {
|
||||
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
|
||||
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
|
||||
const oldOperationsKey = modelMappingOperationsKey(props.editingGroup.operations)
|
||||
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
|
||||
|
||||
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
|
||||
return !(currentKey === oldApiFormatsKey && currentEndpointIdsKey === oldEndpointIdsKey && oldAliasNames.has(a.name))
|
||||
const currentOperationsKey = getOperationsKey(a.operations)
|
||||
return !(currentKey === oldApiFormatsKey
|
||||
&& currentEndpointIdsKey === oldEndpointIdsKey
|
||||
&& currentOperationsKey === oldOperationsKey
|
||||
&& oldAliasNames.has(a.name))
|
||||
})
|
||||
|
||||
const duplicates = findDuplicateNames(filteredAliases, selectedNames.value, nextEndpointIds)
|
||||
const duplicates = findDuplicateNames(
|
||||
filteredAliases,
|
||||
selectedNames.value,
|
||||
nextEndpointIds,
|
||||
undefined,
|
||||
nextOperations,
|
||||
)
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
|
||||
return
|
||||
@@ -678,7 +847,13 @@ async function handleSubmit() {
|
||||
...buildAliases(selectedNames.value)
|
||||
]
|
||||
} else {
|
||||
const duplicates = findDuplicateNames(currentAliases, selectedNames.value, nextEndpointIds)
|
||||
const duplicates = findDuplicateNames(
|
||||
currentAliases,
|
||||
selectedNames.value,
|
||||
nextEndpointIds,
|
||||
undefined,
|
||||
nextOperations,
|
||||
)
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
|
||||
return
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
:title="item.title"
|
||||
class="tabular-nums"
|
||||
>
|
||||
{{ item.displayKey }} {{ formatCodexResetCreditDays(item.remainingSeconds) }}
|
||||
{{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="itemIndex < getVisibleCodexResetCreditItems(key).length - 1"
|
||||
@@ -1038,7 +1038,7 @@ import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
|
||||
import {
|
||||
createCodexResetCreditIdempotencyKey,
|
||||
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
|
||||
formatCodexResetCreditDays,
|
||||
formatCodexResetCreditExpiresAt,
|
||||
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
|
||||
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
|
||||
mergeCodexQuotaDisplays,
|
||||
@@ -1722,7 +1722,7 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
|
||||
|
||||
const earliest = getVisibleCodexResetCreditItems(key)[0]
|
||||
const detailMessage = earliest
|
||||
? `\n当前最早过期项:${earliest.displayKey},约 ${formatCodexResetCreditDays(earliest.remainingSeconds)} 后过期。`
|
||||
? `\n当前最早过期项:${earliest.displayKey},${formatCodexResetCreditExpiresAt(earliest.expiresAt)} 过期。`
|
||||
: ''
|
||||
const confirmed = await confirm({
|
||||
title: legacyT('确认使用 Codex 重置机会'),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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',
|
||||
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,
|
||||
)))
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
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('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',
|
||||
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'],
|
||||
}],
|
||||
})
|
||||
})
|
||||
})
|
||||
+16
-4
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createCodexResetCreditIdempotencyKey,
|
||||
formatCodexResetCreditCount,
|
||||
formatCodexResetCreditDays,
|
||||
formatCodexResetCreditExpiresAt,
|
||||
getCodexResetCreditAvailableCount,
|
||||
getVisibleCodexResetCreditItems,
|
||||
mergeCodexQuotaDisplays,
|
||||
@@ -105,9 +105,21 @@ describe('codex reset credit display helpers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('formats reset credit remaining days with a one-day minimum', () => {
|
||||
expect(formatCodexResetCreditDays(1)).toBe('1天')
|
||||
expect(formatCodexResetCreditDays(86_401)).toBe('2天')
|
||||
it('formats reset credit expiry as a precise local timestamp', () => {
|
||||
const expiresAt = new Date(2026, 6, 12, 22, 4, 41).getTime() / 1000
|
||||
expect(formatCodexResetCreditExpiresAt(expiresAt)).toBe('07-12 22:04:41')
|
||||
expect(formatCodexResetCreditExpiresAt(null)).toBe('-')
|
||||
})
|
||||
|
||||
it('derives a stable expiry timestamp from remaining seconds', () => {
|
||||
const snapshot: QuotaResetCreditsSnapshot = {
|
||||
available_count: 1,
|
||||
updated_at: 1_700_000_000,
|
||||
credits: [{ status: 'available', remaining_seconds: 600 }],
|
||||
}
|
||||
|
||||
expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_300)[0]?.expiresAt)
|
||||
.toBe(1_700_000_600)
|
||||
})
|
||||
|
||||
it('generates a UUID v4 with secure random bytes when randomUUID is unavailable', () => {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function getVisibleCodexResetCreditItems(
|
||||
if (remainingSeconds === null || remainingSeconds <= 0) return null
|
||||
return {
|
||||
id: item.id,
|
||||
expiresAt: item.expires_at,
|
||||
expiresAt: nowUnixSecs + remainingSeconds,
|
||||
remainingSeconds,
|
||||
} satisfies CodexResetCreditDisplayCandidate
|
||||
})
|
||||
@@ -137,7 +137,11 @@ export function getVisibleCodexResetCreditItems(
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCodexResetCreditDays(remainingSeconds: number): string {
|
||||
const days = Math.max(1, Math.ceil(remainingSeconds / 86_400))
|
||||
return `${days}天`
|
||||
export function formatCodexResetCreditExpiresAt(expiresAt: number | null | undefined): string {
|
||||
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return '-'
|
||||
const date = new Date(expiresAt * 1000)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -75,6 +75,12 @@
|
||||
>
|
||||
{{ getEndpointScopeLabel(group) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="min-w-0 max-w-full text-xs"
|
||||
>
|
||||
<span class="truncate">{{ getOperationScopeLabel(group) }}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 映射数量 -->
|
||||
<span class="text-xs text-muted-foreground shrink-0">
|
||||
@@ -130,6 +136,7 @@
|
||||
</div>
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
v-if="group.operations.length === 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@@ -206,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
|
||||
@@ -220,6 +233,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -269,13 +283,32 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
|
||||
return getScopeKey(endpointIds)
|
||||
}
|
||||
|
||||
function getOperationsKey(operations: string[] | undefined): string {
|
||||
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}`
|
||||
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 {
|
||||
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
|
||||
}
|
||||
|
||||
// 按"模型+作用域"分组的映射列表
|
||||
@@ -289,7 +322,8 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
for (const alias of model.provider_model_mappings) {
|
||||
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
|
||||
const endpointIdsKey = getEndpointIdsKey(alias.endpoint_ids)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}`
|
||||
const operationsKey = getOperationsKey(alias.operations)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}|${operationsKey}`
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
const group: AliasGroup = {
|
||||
@@ -298,6 +332,8 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
apiFormats: alias.api_formats || [],
|
||||
endpointIdsKey,
|
||||
endpointIds: normalizeStringList(alias.endpoint_ids),
|
||||
operationsKey,
|
||||
operations: normalizeModelMappingOperations(alias.operations),
|
||||
aliases: []
|
||||
}
|
||||
groupMap.set(groupKey, group)
|
||||
@@ -317,6 +353,7 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB)
|
||||
return a.apiFormatsKey.localeCompare(b.apiFormatsKey)
|
||||
|| a.endpointIdsKey.localeCompare(b.endpointIdsKey)
|
||||
|| a.operationsKey.localeCompare(b.operationsKey)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -339,8 +376,9 @@ const deleteConfirmDescription = computed(() => {
|
||||
const modelName = model.global_model_display_name || model.provider_model_name
|
||||
const scopeText = apiFormats.length === 0 ? '全部' : apiFormats.map(f => formatApiFormat(f)).join(', ')
|
||||
const endpointScope = getEndpointScopeLabel(deletingGroup.value)
|
||||
const operationScope = getOperationScopeLabel(deletingGroup.value)
|
||||
const aliasNames = aliases.map(a => a.name).join(', ')
|
||||
return `确定要删除模型「${modelName}」在作用域「${scopeText} / ${endpointScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
return `确定要删除模型「${modelName}」在作用域「${scopeText} / ${endpointScope} / ${operationScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
})
|
||||
|
||||
// 切换映射组展开状态
|
||||
@@ -383,7 +421,7 @@ function deleteGroup(group: AliasGroup) {
|
||||
async function confirmDelete() {
|
||||
if (!deletingGroup.value) return
|
||||
|
||||
const { model, aliases, apiFormatsKey, endpointIdsKey } = deletingGroup.value
|
||||
const { model, aliases, apiFormatsKey, endpointIdsKey, operationsKey } = deletingGroup.value
|
||||
|
||||
try {
|
||||
const currentAliases = model.provider_model_mappings || []
|
||||
@@ -391,7 +429,11 @@ async function confirmDelete() {
|
||||
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
|
||||
return !(currentKey === apiFormatsKey && currentEndpointIdsKey === endpointIdsKey && aliasNamesToRemove.has(a.name))
|
||||
const currentOperationsKey = getOperationsKey(a.operations)
|
||||
return !(currentKey === apiFormatsKey
|
||||
&& currentEndpointIdsKey === endpointIdsKey
|
||||
&& currentOperationsKey === operationsKey
|
||||
&& aliasNamesToRemove.has(a.name))
|
||||
})
|
||||
|
||||
await updateModel(props.provider.id, model.id, {
|
||||
|
||||
@@ -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,14 +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"
|
||||
variant="outline"
|
||||
class="min-w-0 max-w-full text-xs"
|
||||
:title="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>
|
||||
@@ -160,6 +169,7 @@
|
||||
</span>
|
||||
<!-- 测试按钮(直连测试) -->
|
||||
<Button
|
||||
v-if="!item.group || item.group.operations.length === 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@@ -364,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,
|
||||
@@ -416,6 +433,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 模型测试 composable
|
||||
const modelTest = useModelTest({ providerId: () => props.provider.id })
|
||||
@@ -492,9 +510,47 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
|
||||
return getScopeKey(endpointIds)
|
||||
}
|
||||
|
||||
function getOperationsKey(operations: string[] | undefined): string {
|
||||
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 {
|
||||
return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
|
||||
}
|
||||
|
||||
// 精确映射分组(来自 provider_model_mappings)
|
||||
@@ -508,7 +564,8 @@ const exactMappingGroups = computed<AliasGroup[]>(() => {
|
||||
for (const alias of model.provider_model_mappings) {
|
||||
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
|
||||
const endpointIdsKey = getEndpointIdsKey(alias.endpoint_ids)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}`
|
||||
const operationsKey = getOperationsKey(alias.operations)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}|${endpointIdsKey}|${operationsKey}`
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
const group: AliasGroup = {
|
||||
@@ -517,6 +574,8 @@ const exactMappingGroups = computed<AliasGroup[]>(() => {
|
||||
apiFormats: alias.api_formats || [],
|
||||
endpointIdsKey,
|
||||
endpointIds: normalizeStringList(alias.endpoint_ids),
|
||||
operationsKey,
|
||||
operations: normalizeModelMappingOperations(alias.operations),
|
||||
aliases: []
|
||||
}
|
||||
groupMap.set(groupKey, group)
|
||||
@@ -600,7 +659,7 @@ const combinedMappings = computed<CombinedMapping[]>(() => {
|
||||
// 添加精确映射
|
||||
for (const group of exactMappingGroups.value) {
|
||||
result.push({
|
||||
key: `exact-${group.model.id}-${group.apiFormatsKey}`,
|
||||
key: `exact-${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}-${group.operationsKey}`,
|
||||
type: 'exact',
|
||||
targetModelName: group.model.global_model_display_name || group.model.provider_model_name,
|
||||
targetModelId: group.model.id,
|
||||
@@ -645,7 +704,8 @@ const deleteConfirmDescription = computed(() => {
|
||||
const modelName = model.global_model_display_name || model.provider_model_name
|
||||
const aliasNames = aliases.map(a => a.name).join(', ')
|
||||
const endpointScope = getGroupEndpointScopeLabel(deletingGroup.value)
|
||||
return `确定要删除模型「${modelName}」在「${endpointScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
const operationScope = getGroupOperationScopeLabel(deletingGroup.value)
|
||||
return `确定要删除模型「${modelName}」在「${endpointScope} / ${operationScope}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
})
|
||||
|
||||
// 切换展开状态
|
||||
@@ -692,7 +752,7 @@ function deleteGroup(group: AliasGroup) {
|
||||
async function confirmDelete() {
|
||||
if (!deletingGroup.value) return
|
||||
|
||||
const { model, aliases, apiFormatsKey, endpointIdsKey } = deletingGroup.value
|
||||
const { model, aliases, apiFormatsKey, endpointIdsKey, operationsKey } = deletingGroup.value
|
||||
|
||||
try {
|
||||
const currentAliases = model.provider_model_mappings || []
|
||||
@@ -700,7 +760,11 @@ async function confirmDelete() {
|
||||
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
|
||||
return !(currentKey === apiFormatsKey && currentEndpointIdsKey === endpointIdsKey && aliasNamesToRemove.has(a.name))
|
||||
const currentOperationsKey = getOperationsKey(a.operations)
|
||||
return !(currentKey === apiFormatsKey
|
||||
&& currentEndpointIdsKey === endpointIdsKey
|
||||
&& currentOperationsKey === operationsKey
|
||||
&& aliasNamesToRemove.has(a.name))
|
||||
})
|
||||
|
||||
await updateModel(props.provider.id, model.id, {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ALL_REQUESTS_SCOPE_VALUE,
|
||||
COMPACT_REQUEST_SCOPE_VALUE,
|
||||
formatModelMappingEndpointLabel,
|
||||
formatModelMappingRequestScope,
|
||||
modelMappingEndpointScopeSupportsSessionCompaction,
|
||||
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,
|
||||
{ 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()
|
||||
})
|
||||
|
||||
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,165 @@
|
||||
import {
|
||||
API_FORMATS,
|
||||
formatApiFormat,
|
||||
normalizeApiFormatAlias,
|
||||
} 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 ModelMappingRequestScopeCapabilities {
|
||||
sessionCompaction: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
capabilities: ModelMappingRequestScopeCapabilities,
|
||||
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
|
||||
): ModelMappingRequestScopeOption[] {
|
||||
const options: ModelMappingRequestScopeOption[] = [
|
||||
{ value: ALL_REQUESTS_SCOPE_VALUE, label: labels.allRequests },
|
||||
]
|
||||
if (capabilities.sessionCompaction) {
|
||||
options.push({
|
||||
value: COMPACT_REQUEST_SCOPE_VALUE,
|
||||
label: labels.sessionCompactionOnly,
|
||||
})
|
||||
}
|
||||
const currentValue = modelMappingRequestScopeValue(operations)
|
||||
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),
|
||||
})
|
||||
}
|
||||
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[],
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ import { Badge } from '@/components/ui'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
type ModelBadgeKey = 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
|
||||
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber' | 'reasoning_tokens'
|
||||
|
||||
interface ModelBadgePresentation {
|
||||
key: ModelBadgeKey
|
||||
@@ -84,6 +84,7 @@ interface UsageModelDisplayRecord {
|
||||
model: string
|
||||
target_model?: string | null
|
||||
model_version?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
@@ -132,6 +133,16 @@ const reasoningLabel = computed(() => {
|
||||
|
||||
const modelBadges = computed<ModelBadgePresentation[]>(() => {
|
||||
const badges: ModelBadgePresentation[] = []
|
||||
if (normalizeText(props.record.request_type)?.toLowerCase() === 'compact') {
|
||||
badges.push({
|
||||
key: 'compact',
|
||||
label: '会话压缩',
|
||||
variant: 'outline',
|
||||
className: 'border-sky-500/30 bg-sky-500/5 text-sky-700 dark:text-sky-300',
|
||||
title: '会话压缩',
|
||||
ariaLabel: '会话压缩',
|
||||
})
|
||||
}
|
||||
if (props.showReasoningBadge && reasoningLabel.value) {
|
||||
badges.push({
|
||||
key: 'reasoning',
|
||||
|
||||
+16
-14
@@ -165,19 +165,21 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
const modelLayout = document.body.querySelector(
|
||||
'[data-request-detail-model-layout="stacked"]',
|
||||
'[data-request-detail-model-layout="inline"]',
|
||||
)
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
|
||||
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
|
||||
.toBeNull()
|
||||
const modelBadgesRow = modelLayout?.querySelector(
|
||||
'[data-request-detail-model-badges-row]',
|
||||
)
|
||||
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(modelBadgesRow?.textContent).toContain('Fast')
|
||||
expect(modelBadgesRow?.textContent).toContain('Cyber')
|
||||
const modelRow = modelLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
|
||||
.toBe(true)
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(modelRow?.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(modelLayout?.querySelector('[data-request-detail-model-badges-row]')).toBeNull()
|
||||
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
|
||||
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['上游请求层级', '计费层级'])
|
||||
@@ -359,7 +361,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1')
|
||||
.toBe('->gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
@@ -414,7 +416,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1-2026-07-17')
|
||||
.toBe('->gpt-5.1-2026-07-17')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -306,6 +306,27 @@ describe('UsageRecordsTable', () => {
|
||||
.toBe('Fast')
|
||||
})
|
||||
|
||||
it('shows request reasoning effort while the record is pending', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
requested_reasoning_effort: 'max',
|
||||
reasoning_effort: null,
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('max')
|
||||
})
|
||||
|
||||
it('marks Responses compaction while the record is pending', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
request_type: 'compact',
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
|
||||
.toBe('会话压缩')
|
||||
})
|
||||
|
||||
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -355,6 +355,7 @@ describe('useUsageData', () => {
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
request_type: 'compact',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
@@ -385,6 +386,7 @@ describe('useUsageData', () => {
|
||||
has_format_conversion: undefined,
|
||||
has_retry: false,
|
||||
target_model: undefined,
|
||||
request_type: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: undefined,
|
||||
service_tier: undefined,
|
||||
@@ -420,6 +422,7 @@ describe('useUsageData', () => {
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: null,
|
||||
request_type: 'compact',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
|
||||
@@ -669,6 +669,12 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
? record.target_model
|
||||
: null)
|
||||
: existing.target_model,
|
||||
// Request type is client-request identity, not a provider-candidate fact. Preserve a
|
||||
// known compact operation when a later sparse snapshot omits it.
|
||||
request_type:
|
||||
typeof record.request_type === 'string' && record.request_type.trim()
|
||||
? record.request_type
|
||||
: existing.request_type,
|
||||
requested_reasoning_effort:
|
||||
typeof record.requested_reasoning_effort === 'string'
|
||||
&& record.requested_reasoning_effort.trim()
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface UsageRecord {
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
|
||||
request_type?: string | null // 由请求语义识别出的操作类型
|
||||
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
|
||||
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
|
||||
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
|
||||
|
||||
@@ -295,6 +295,17 @@ 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.allRequestsDefaultDescription': '所选端点的所有请求都可使用此映射。仅当适用端点已明确选择且全部为 OpenAI Responses 时,可限定为会话压缩。',
|
||||
'providers.modelMapping.scope.sessionCompactionDescription': '仅匹配所选 OpenAI Responses 端点中的会话压缩请求。',
|
||||
'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 +606,17 @@ 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.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',
|
||||
'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 +695,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',
|
||||
|
||||
@@ -596,6 +596,9 @@ async function pollActiveRequests() {
|
||||
record.reasoning_effort = typeof update.reasoning_effort === 'string' && update.reasoning_effort.trim()
|
||||
? update.reasoning_effort
|
||||
: null
|
||||
if (typeof update.request_type === 'string' && update.request_type.trim()) {
|
||||
record.request_type = update.request_type
|
||||
}
|
||||
if (typeof update.requested_reasoning_effort === 'string' && update.requested_reasoning_effort.trim()) {
|
||||
record.requested_reasoning_effort = update.requested_reasoning_effort
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user