Merge remote-tracking branch 'origin/pr-483' into merge-pr-483

# Conflicts:
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs
#	apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs
#	apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
This commit is contained in:
fawney19
2026-05-19 02:23:14 +08:00
53 changed files with 8081 additions and 305 deletions
@@ -94,6 +94,24 @@
</div>
</div>
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<Checkbox
:checked="isImageGenerationEnabled"
class="mt-0.5"
@update:checked="setImageGenerationEnabled"
/>
<div class="space-y-1">
<div class="text-sm font-medium">
图片模型
</div>
<p class="text-xs text-muted-foreground">
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
</p>
</div>
</div>
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
@@ -103,6 +121,7 @@
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
:show-image-pricing="isImageGenerationEnabled"
/>
<!-- 按次计费 -->
@@ -249,6 +268,7 @@ import {
SelectContent,
SelectItem,
Badge,
Checkbox,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
@@ -290,10 +310,28 @@ const selectedGlobalModel = computed(() => {
})
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
const selectedGlobalModelSupportsImageGeneration = computed(() => modelSupportsImageGeneration(selectedGlobalModel.value))
const editingModelSupportsEmbedding = computed(() => {
return props.editingModel?.effective_supports_embedding === true
|| modelSupportsEmbedding(props.editingModel)
})
const editingModelSupportsImageGeneration = computed(() => {
return props.editingModel?.effective_supports_image_generation === true
|| modelSupportsImageGeneration(props.editingModel)
})
const isImageGenerationEnabled = computed(() => {
if (imageGenerationExplicitOverride.value !== null) {
return imageGenerationExplicitOverride.value
}
if (form.value.supports_image_generation !== undefined) {
return form.value.supports_image_generation === true
}
const supportsImageGeneration = isEditing.value
? editingModelSupportsImageGeneration.value
: selectedGlobalModelSupportsImageGeneration.value
return supportsImageGeneration || tieredPricingHasImageOutputPricing(tieredPricing.value)
})
// 1h 缓存定价始终显示
const showCache1h = true
@@ -349,6 +387,7 @@ const form = ref({
supports_image_generation: undefined as boolean | undefined,
is_active: true
})
const imageGenerationExplicitOverride = ref<boolean | null>(null)
const canSubmitCreate = computed(() => {
if (isEditing.value) return true
@@ -364,6 +403,7 @@ watch(() => props.open, async (newOpen) => {
// 编辑模式:填充表单
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
form.value = {
global_model_id: props.editingModel.global_model_id || '',
provider_model_name: props.editingModel.provider_model_name || '',
@@ -374,7 +414,7 @@ watch(() => props.open, async (newOpen) => {
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 从有效配置中加载视频费用
@@ -425,6 +465,7 @@ watch(tieredPricing, (newValue) => {
// 重置表单
function resetForm() {
imageGenerationExplicitOverride.value = null
form.value = {
global_model_id: '',
provider_model_name: '',
@@ -446,11 +487,66 @@ function resetForm() {
}
function handleGlobalModelSelect(value: string) {
imageGenerationExplicitOverride.value = null
form.value.supports_image_generation = undefined
form.value.global_model_id = value
const selectedModel = availableGlobalModels.value.find(model => model.id === value)
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
}
function modelSupportsImageGeneration(model: {
supported_capabilities?: string[] | null
supports_image_generation?: boolean | null
effective_supports_image_generation?: boolean | null
default_tiered_pricing?: TieredPricingConfig | null
tiered_pricing?: TieredPricingConfig | null
effective_tiered_pricing?: TieredPricingConfig | null
config?: Record<string, unknown> | null
} | null | undefined): boolean {
if (!model) return false
if (model.effective_supports_image_generation === true) return true
if (model.supports_image_generation === true) return true
const config = model.config || {}
return model.supported_capabilities?.includes('image_generation') === true
|| config.image_generation === true
|| config.model_type === 'image'
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':image')))
|| tieredPricingHasImageOutputPricing(model.default_tiered_pricing)
|| tieredPricingHasImageOutputPricing(model.tiered_pricing)
|| tieredPricingHasImageOutputPricing(model.effective_tiered_pricing)
}
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
if (!pricing) return false
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
if (!prices || typeof prices !== 'object') return false
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})) return true
return (pricing.image_output_price_ranges || []).some((range) => {
if (!range || typeof range !== 'object') return false
const prices = range.prices && typeof range.prices === 'object'
? range.prices
: range as Record<string, unknown>
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
})
}
function toFinitePrice(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
const enabled = value === true
imageGenerationExplicitOverride.value = enabled
form.value.supports_image_generation = enabled
}
function getNested(obj: Record<string, unknown>, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
@@ -631,8 +727,9 @@ async function handleSubmit() {
submitting.value = true
try {
// 获取包含自动计算缓存价格的最终数据
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
const supportsImageGeneration = isImageGenerationEnabled.value
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
// Apply billing (video) pricing into config.
applyVideoPricingToConfig(form.value.config)
@@ -651,7 +748,7 @@ async function handleSubmit() {
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
supportsImageGeneration,
isActive: form.value.is_active
}))
showSuccess('模型配置已更新')
@@ -674,7 +771,7 @@ async function handleSubmit() {
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
supportsImageGeneration,
isActive: form.value.is_active
}))
showSuccess('模型已添加')
@@ -443,9 +443,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
&& (endpoint.active_keys > 0
|| isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type))
}
return isModelTestableEndpoint(endpoint, providerKeysState.value)
return isModelTestableEndpoint(endpoint, providerKeysState.value, props.provider.provider_type)
}))
const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value)
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
@@ -313,9 +313,10 @@ const activeEndpoints = computed(() => (props.endpoints ?? [])
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
&& (endpoint.active_keys > 0
|| isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type))
}
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [])
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [], props.provider.provider_type)
}))
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
@@ -58,7 +58,7 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.input).toBeUndefined()
})
it('uses prompt payloads for openai image api formats', () => {
it('uses image prompt payloads for OpenAI image test requests', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
expect(body).toEqual({
@@ -240,6 +240,8 @@ describe('isModelTestableApiFormat', () => {
it.each([
'openai:chat',
'openai:responses',
'openai:responses:compact',
'openai:image',
'claude:messages',
'gemini:generate_content',
'openai:image',
@@ -350,6 +352,23 @@ describe('isModelTestableEndpoint', () => {
is_active: true,
}, keys)).toBe(true)
})
it('lets fixed provider OAuth keys inherit testable endpoint formats', () => {
const keys = [{
api_formats: ['legacy:mismatch'],
auth_type: 'oauth',
is_active: true,
}]
expect(isModelTestableEndpoint({
api_format: 'openai:image',
is_active: true,
}, keys, 'chatgpt_web')).toBe(true)
expect(isModelTestableEndpoint({
api_format: 'openai:image',
is_active: true,
}, keys, 'custom')).toBe(false)
})
})
describe('formatModelTestDiagnostic', () => {
@@ -15,6 +15,9 @@ export type ModelTestImageSource = {
export type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
auth_type?: string | null
credential_kind?: string | null
oauth_managed?: boolean | null
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
@@ -23,6 +26,20 @@ const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'gemini:files',
])
const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'claude_code',
'codex',
'chatgpt_web',
'gemini_cli',
'vertex_ai',
'antigravity',
'kiro',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
@@ -41,12 +58,15 @@ export function isModelTestableApiFormat(apiFormat: string | null | undefined):
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
providerType?: string | null,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
const keyFormats = normalizeModelTestStringList(key.api_formats)
if (keyFormats.length === 0) return true
@@ -56,10 +76,32 @@ export function modelTestKeySupportsEndpoint(
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
providerType?: string | null,
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
}
function modelTestKeyInheritsProviderFormats(
key: ModelTestKeySource,
providerType: string | null | undefined,
): boolean {
const normalizedProviderType = providerType?.trim().toLowerCase()
if (!normalizedProviderType) return false
const authType = key.auth_type?.trim().toLowerCase()
const credentialKind = key.credential_kind?.trim().toLowerCase()
const oauthManaged = key.oauth_managed === true
|| credentialKind === 'oauth_session'
|| authType === 'oauth'
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
return true
}
return authType === 'bearer'
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
}
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(
@@ -149,14 +149,16 @@ export function buildDefaultModelTestRequestBody(
apiFormat?: string | null,
model?: ModelTestImageSource | null,
): string {
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
if (normalizedApiFormat.endsWith(':embedding')) {
return JSON.stringify({
model: modelName,
input: 'This is a test embedding input.',
}, null, 2)
}
if (apiFormat?.trim().toLowerCase().endsWith(':rerank')) {
if (normalizedApiFormat.endsWith(':rerank')) {
return JSON.stringify({
model: modelName,
query: 'Apple',
@@ -171,7 +173,7 @@ export function buildDefaultModelTestRequestBody(
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
if (normalizedApiFormat === 'openai:image') {
return JSON.stringify({
model: modelName,
prompt: DEFAULT_MODEL_TEST_MESSAGE,
@@ -181,7 +183,7 @@ export function buildDefaultModelTestRequestBody(
}, null, 2)
}
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:responses' && modelSupportsImageGeneration(model)) {
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
@@ -271,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
emptyError: null,
invalidTypeError: '测试请求头必须是 JSON 对象',
})
}
}