mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
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:
@@ -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)
|
||||
|
||||
+20
-1
@@ -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', () => {
|
||||
|
||||
+43
-1
@@ -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 对象',
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user