feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层

模型测试:
- 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验
- 新增端点选择面板,测试前可选择目标端点
- 新增调试检查器,可查看每次尝试的请求/响应头和体
- 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪
- endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化

Vertex AI:
- 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断
- Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key)
- 移除 Gemini Developer API 回退,API Key 仅走 Express 模式
- 端点表单为 Vertex AI 显示格式特定的默认路径模板
- 密钥格式校验仅在 auth_type/api_formats 变更时执行

其他:
- 禁用 ClaudeCode 提供商类型创建入口
- Dialog 组件新增 closeOnBackdrop 属性
This commit is contained in:
fawney19
2026-03-19 23:52:17 +08:00
parent e4ebd5cca1
commit 6984984c22
31 changed files with 1609 additions and 434 deletions

View File

@@ -194,6 +194,7 @@ export interface TestModelFailoverRequest {
api_format?: string api_format?: string
endpoint_id?: string endpoint_id?: string
message?: string message?: string
request_body?: Record<string, unknown>
request_id?: string request_id?: string
concurrency?: number concurrency?: number
} }
@@ -212,6 +213,11 @@ export interface TestAttemptDetail {
error_message?: string | null error_message?: string | null
status_code?: number | null status_code?: number | null
latency_ms?: number | null latency_ms?: number | null
request_url?: string | null
request_headers?: Record<string, unknown> | null
request_body?: unknown
response_headers?: Record<string, unknown> | null
response_body?: unknown
} }
export interface TestModelFailoverResponse { export interface TestModelFailoverResponse {

View File

@@ -109,6 +109,7 @@ const props = defineProps<{
zIndex?: number // Custom z-index for nested dialogs (default: 60) zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding noPadding?: boolean // Disable default content padding
persistent?: boolean // Prevent closing on backdrop click persistent?: boolean // Prevent closing on backdrop click
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
}>() }>()
// Emits 定义 // Emits 定义
@@ -145,7 +146,7 @@ function handleClose() {
// 处理背景点击 // 处理背景点击
function handleBackdropClick() { function handleBackdropClick() {
if (!props.persistent) { if (!props.persistent && props.closeOnBackdrop !== false) {
handleClose() handleClose()
} }
} }

View File

@@ -15,6 +15,7 @@ export interface StartTestParams {
apiFormat?: string apiFormat?: string
endpointId?: string endpointId?: string
message?: string message?: string
requestBody?: Record<string, unknown>
concurrency?: number concurrency?: number
onSuccess?: (result: TestModelFailoverResponse) => void onSuccess?: (result: TestModelFailoverResponse) => void
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */ /** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
@@ -61,6 +62,16 @@ export function useModelTest(options: UseModelTestOptions) {
} }
} }
async function refreshTraceSnapshot(reqId: string) {
try {
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
if (requestId.value !== reqId) return
testTrace.value = trace
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) return
}
}
function stopPolling(opts: { clearState?: boolean } = {}) { function stopPolling(opts: { clearState?: boolean } = {}) {
tracePollToken += 1 tracePollToken += 1
if (tracePollTimer) { if (tracePollTimer) {
@@ -128,6 +139,7 @@ export function useModelTest(options: UseModelTestOptions) {
api_format: params.apiFormat, api_format: params.apiFormat,
endpoint_id: params.endpointId, endpoint_id: params.endpointId,
...(normalizedMessage ? { message: normalizedMessage } : {}), ...(normalizedMessage ? { message: normalizedMessage } : {}),
...(params.requestBody ? { request_body: params.requestBody } : {}),
request_id: reqId, request_id: reqId,
concurrency: params.concurrency, concurrency: params.concurrency,
}, { }, {
@@ -135,6 +147,9 @@ export function useModelTest(options: UseModelTestOptions) {
}) })
if (result.success) { if (result.success) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
testResult.value = result
const successAttempt = result.attempts.find(a => a.status === 'success') const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : '' const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
@@ -142,10 +157,10 @@ export function useModelTest(options: UseModelTestOptions) {
: '' : ''
params.onSuccess?.(result) params.onSuccess?.(result)
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`) showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
resetState()
return return
} }
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false }) stopPolling({ clearState: false })
const handled = params.onFailure?.(result) const handled = params.onFailure?.(result)
if (!handled) { if (!handled) {

View File

@@ -105,7 +105,7 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label> <Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input <Input
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')" :model-value="getDisplayedPath(endpoint)"
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'" :placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
:disabled="isFixedProvider" :disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)" @update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
@@ -1276,10 +1276,19 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
// 获取指定 API 格式的默认路径 // 获取指定 API 格式的默认路径
function getDefaultPath(apiFormat: string, baseUrl?: string): string { function getDefaultPath(apiFormat: string, baseUrl?: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
if (providerType === 'vertex_ai') {
if (apiFormat === 'gemini:chat') {
return '/v1/publishers/google/models/{model}:{action}'
}
if (apiFormat === 'claude:chat') {
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
}
}
const format = apiFormats.value.find(f => f.value === apiFormat) const format = apiFormats.value.find(f => f.value === apiFormat)
const defaultPath = format?.default_path || '' const defaultPath = format?.default_path || ''
// Codex 端点使用 /responses 而非 /v1/responses // Codex 端点使用 /responses 而非 /v1/responses
const providerType = (props.provider?.provider_type || '').toLowerCase()
const isCodex = providerType const isCodex = providerType
? providerType === 'codex' ? providerType === 'codex'
: (!!baseUrl && isCodexUrl(baseUrl)) : (!!baseUrl && isCodexUrl(baseUrl))
@@ -1289,6 +1298,13 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
return defaultPath return defaultPath
} }
function getDisplayedPath(endpoint: ProviderEndpoint): string {
if (isFixedProvider.value) {
return getDefaultPath(endpoint.api_format, endpoint.base_url)
}
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
// 判断是否是 Codex OAuth 端点 // 判断是否是 Codex OAuth 端点
function isCodexUrl(baseUrl: string): boolean { function isCodexUrl(baseUrl: string): boolean {
const url = baseUrl.replace(/\/+$/, '') const url = baseUrl.replace(/\/+$/, '')

View File

@@ -937,6 +937,7 @@
ref="modelMappingTabRef" ref="modelMappingTabRef"
:key="`mapping-${provider.id}`" :key="`mapping-${provider.id}`"
:provider="provider" :provider="provider"
:endpoints="endpoints"
:provider-keys="providerKeys" :provider-keys="providerKeys"
:models="providerModels" :models="providerModels"
:mapping-preview="providerMappingPreview" :mapping-preview="providerMappingPreview"

View File

@@ -45,8 +45,11 @@
<SelectItem value="vertex_ai"> <SelectItem value="vertex_ai">
Vertex AI Vertex AI
</SelectItem> </SelectItem>
<SelectItem value="claude_code"> <SelectItem
ClaudeCode value="claude_code"
disabled
>
ClaudeCode暂不可用
</SelectItem> </SelectItem>
<SelectItem value="codex"> <SelectItem value="codex">
Codex Codex
@@ -432,6 +435,11 @@ watch(() => form.value.provider_type, () => {
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
return
}
// 月卡类型必须设置周期开始时间 // 月卡类型必须设置周期开始时间
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) { if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
showError('月卡类型必须设置周期开始时间', '验证失败') showError('月卡类型必须设置周期开始时间', '验证失败')

View File

@@ -314,13 +314,19 @@
:result="modelTest.testResult.value" :result="modelTest.testResult.value"
mode="direct" mode="direct"
:selecting-model-name="testingModelName" :selecting-model-name="testingModelName"
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="modelTest.testing.value" :testing="modelTest.testing.value"
:trace="modelTest.testTrace.value" :trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value" :request-id="modelTest.requestId.value"
:message-draft="testMessageDraft" :request-body-draft="testRequestBodyDraft"
:request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
@close="handleTestDialogClose" @close="handleTestDialogClose"
@back="handleTestDialogBack"
@select-endpoint="handleSelectTestEndpoint"
@start="handleStartMappingTest" @start="handleStartMappingTest"
@update:message-draft="testMessageDraft = $event" @update:request-body-draft="testRequestBodyDraft = $event"
/> />
</template> </template>
@@ -338,6 +344,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { import {
type Model, type Model,
type ProviderEndpoint,
type ProviderModelAlias, type ProviderModelAlias,
type ProviderMappingPreviewResponse, type ProviderMappingPreviewResponse,
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -345,6 +352,10 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models' import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints' import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
buildDefaultModelTestRequestBody,
parseModelTestRequestBodyDraft,
} from './model-test-request'
interface MappingItem { interface MappingItem {
name: string name: string
@@ -372,6 +383,7 @@ interface CombinedMapping {
const props = defineProps<{ const props = defineProps<{
provider: ProviderWithEndpointsSummary provider: ProviderWithEndpointsSummary
endpoints?: ProviderEndpoint[]
providerKeys?: EndpointAPIKey[] providerKeys?: EndpointAPIKey[]
models?: Model[] models?: Model[]
mappingPreview?: ProviderMappingPreviewResponse | null mappingPreview?: ProviderMappingPreviewResponse | null
@@ -396,7 +408,11 @@ const testingMapping = ref<string | null>(null)
const pendingMappingKey = ref<string | null>(null) const pendingMappingKey = ref<string | null>(null)
const testingModelName = ref<string | null>(null) const testingModelName = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null) const preselectedModelId = ref<string | null>(null)
const testMessageDraft = ref('') const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestBodyDraft = ref('')
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
// 使用 props 传入的数据 // 使用 props 传入的数据
const models = computed(() => props.models ?? []) const models = computed(() => props.models ?? [])
@@ -634,31 +650,60 @@ function handleTestDialogClose() {
pendingMappingKey.value = null pendingMappingKey.value = null
testingModelName.value = null testingModelName.value = null
testingMapping.value = null testingMapping.value = null
selectedTestEndpoint.value = null
testRequestBodyDraft.value = ''
}
function handleTestDialogBack() {
if (modelTest.testing.value) return
modelTest.testResult.value = null
modelTest.stopPolling()
}
function handleSelectTestEndpoint(endpointId: string) {
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
selectedTestEndpoint.value = endpoint
} }
// 测试映射(直连测试,带故障转移和实时进度) // 测试映射(直连测试,带故障转移和实时进度)
function runMappingTest(testingKey: string, modelName: string) { function runMappingTest(testingKey: string, modelName: string) {
if (activeEndpoints.value.length === 0) {
showError('暂无可用于测试的活跃端点')
return
}
pendingMappingKey.value = testingKey pendingMappingKey.value = testingKey
modelTest.testResult.value = null modelTest.testResult.value = null
modelTest.dialogOpen.value = true modelTest.dialogOpen.value = true
testingMapping.value = null testingMapping.value = null
testingModelName.value = modelName testingModelName.value = modelName
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(modelName)
} }
async function handleStartMappingTest() { async function handleStartMappingTest() {
if (modelTest.testing.value || !testingModelName.value || !pendingMappingKey.value) return if (modelTest.testing.value || !testingModelName.value) return
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
if (!endpoint) {
showError('请选择要测试的端点')
return
}
const currentMappingKey = pendingMappingKey.value const { value: requestBody, error } = parsedTestRequestBody.value
testingMapping.value = currentMappingKey if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
return
}
const currentMappingKey = pendingMappingKey.value || testingModelName.value
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
await modelTest.startTest({ await modelTest.startTest({
mode: 'direct', mode: 'direct',
modelName: testingModelName.value, modelName: testingModelName.value,
displayLabel: `映射 "${testingModelName.value}"`, displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
message: testMessageDraft.value, apiFormat: endpoint.api_format,
onSuccess: () => { endpointId: endpoint.id,
pendingMappingKey.value = null requestBody,
testingModelName.value = null
},
}) })
if (pendingMappingKey.value === currentMappingKey) { if (pendingMappingKey.value === currentMappingKey) {
pendingMappingKey.value = null pendingMappingKey.value = null

View File

@@ -221,13 +221,14 @@
:testing="modelTest.testing.value" :testing="modelTest.testing.value"
:trace="modelTest.testTrace.value" :trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value" :request-id="modelTest.requestId.value"
:show-endpoint-selector="activeEndpoints.length > 1" :request-body-draft="testRequestBodyDraft"
:message-draft="testMessageDraft" :request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
@close="handleTestDialogClose" @close="handleTestDialogClose"
@back="handleTestDialogBack" @back="handleTestDialogBack"
@start="handleStartPendingTest" @start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint" @select-endpoint="handleSelectTestEndpoint"
@update:message-draft="testMessageDraft = $event" @update:request-body-draft="testRequestBodyDraft = $event"
/> />
</template> </template>
@@ -250,6 +251,10 @@ import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format' import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints' import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelTestDialog from './ModelTestDialog.vue' import ModelTestDialog from './ModelTestDialog.vue'
import {
buildDefaultModelTestRequestBody,
parseModelTestRequestBodyDraft,
} from './model-test-request'
const props = defineProps<{ const props = defineProps<{
provider: ProviderWithEndpointsSummary provider: ProviderWithEndpointsSummary
@@ -275,8 +280,10 @@ const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null) const togglingModelId = ref<string | null>(null)
const pendingTestModel = ref<Model | null>(null) const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null) const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testMessageDraft = ref('') const testRequestBodyDraft = ref('')
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active)) const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const models = computed(() => props.models ?? localModels.value) const models = computed(() => props.models ?? localModels.value)
// 按名称排序的模型列表 // 按名称排序的模型列表
const sortedModels = computed(() => { const sortedModels = computed(() => {
@@ -442,19 +449,38 @@ function handleTestDialogClose() {
modelTest.resetState() modelTest.resetState()
pendingTestModel.value = null pendingTestModel.value = null
selectedTestEndpoint.value = null selectedTestEndpoint.value = null
testRequestBodyDraft.value = ''
} }
function handleTestDialogBack() { function handleTestDialogBack() {
if (modelTest.testing.value) return if (modelTest.testing.value) return
modelTest.testResult.value = null modelTest.testResult.value = null
selectedTestEndpoint.value = null modelTest.stopPolling()
} }
async function handleSelectTestEndpoint(endpointId: string) { function handleSelectTestEndpoint(endpointId: string) {
if (!pendingTestModel.value) return
const endpoint = activeEndpoints.value.find(item => item.id === endpointId) const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return if (!endpoint) return
selectedTestEndpoint.value = endpoint selectedTestEndpoint.value = endpoint
}
async function handleStartPendingTest() {
if (modelTest.testing.value) return
if (!pendingTestModel.value) return
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
if (!endpoint) {
showError('请选择要测试的端点')
return
}
const { value: requestBody, error } = parsedTestRequestBody.value
if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
return
}
selectedTestEndpoint.value = endpoint
const model = pendingTestModel.value const model = pendingTestModel.value
const modelName = model.global_model_name || model.provider_model_name const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] ` const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
@@ -464,28 +490,16 @@ async function handleSelectTestEndpoint(endpointId: string) {
displayLabel: `${endpointPrefix}${modelName}`, displayLabel: `${endpointPrefix}${modelName}`,
apiFormat: endpoint.api_format, apiFormat: endpoint.api_format,
endpointId: endpoint.id, endpointId: endpoint.id,
message: testMessageDraft.value, requestBody,
concurrency: 5, concurrency: 5,
onSuccess: () => {
pendingTestModel.value = null
selectedTestEndpoint.value = null
},
onError: () => { onError: () => {
if (activeEndpoints.value.length > 1) { if (activeEndpoints.value.length > 1) {
selectedTestEndpoint.value = null
return true return true
} }
}, },
}) })
} }
async function handleStartPendingTest() {
if (modelTest.testing.value) return
const endpoint = activeEndpoints.value[0]
if (!endpoint) return
await handleSelectTestEndpoint(endpoint.id)
}
async function testModelConnection(model: Model) { async function testModelConnection(model: Model) {
if (modelTest.testing.value) return if (modelTest.testing.value) return
@@ -495,7 +509,10 @@ async function testModelConnection(model: Model) {
} }
pendingTestModel.value = model pendingTestModel.value = model
selectedTestEndpoint.value = null selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(
model.global_model_name || model.provider_model_name,
)
modelTest.testResult.value = null modelTest.testResult.value = null
modelTest.dialogOpen.value = true modelTest.dialogOpen.value = true
} }

View File

@@ -0,0 +1,47 @@
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
export function buildDefaultModelTestRequestBody(modelName: string): string {
return JSON.stringify({
model: modelName,
messages: [
{
role: 'user',
content: DEFAULT_MODEL_TEST_MESSAGE,
},
],
max_tokens: 30,
temperature: 0.7,
stream: true,
}, null, 2)
}
export function parseModelTestRequestBodyDraft(
draft: string,
): { value: Record<string, unknown> | null; error: string | null } {
const normalized = draft.trim()
if (!normalized) {
return {
value: null,
error: '测试请求体不能为空',
}
}
try {
const parsed = JSON.parse(normalized)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
value: null,
error: '测试请求体必须是 JSON 对象',
}
}
return {
value: parsed as Record<string, unknown>,
error: null,
}
} catch (error) {
return {
value: null,
error: error instanceof Error ? error.message : '无效的 JSON',
}
}
}

View File

@@ -240,6 +240,10 @@
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!-- -->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
-->)</span> -->)</span>
</span> </span>
<code
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
class="text-xs font-mono text-muted-foreground"
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
</span> </span>
</div> </div>
<div <div
@@ -462,7 +466,7 @@ interface UsageData {
} }
const props = defineProps<{ const props = defineProps<{
requestId: string requestId?: string | null
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */ /** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
overrideStatusCode?: number overrideStatusCode?: number
/** 请求侧 API 格式(客户端入口格式) */ /** 请求侧 API 格式(客户端入口格式) */
@@ -471,6 +475,12 @@ const props = defineProps<{
usageData?: UsageData | null usageData?: UsageData | null
/** 请求元数据(用于号池调度组装) */ /** 请求元数据(用于号池调度组装) */
requestMetadata?: Record<string, unknown> | null requestMetadata?: Record<string, unknown> | null
/** 已获取的追踪数据;传入时不再内部拉取 */
traceData?: RequestTrace | null
}>()
const emit = defineEmits<{
selectAttempt: [attempt: CandidateRecord | null]
}>() }>()
// 用量数据(从 props 获取) // 用量数据(从 props 获取)
@@ -525,7 +535,8 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const trace = ref<RequestTrace | null>(null) const internalTrace = ref<RequestTrace | null>(null)
const trace = computed(() => props.traceData ?? internalTrace.value)
const selectedGroupIndex = ref(0) const selectedGroupIndex = ref(0)
const selectedAttemptIndex = ref(0) const selectedAttemptIndex = ref(0)
const hoveredGroupIndex = ref<number | null>(null) const hoveredGroupIndex = ref<number | null>(null)
@@ -1045,6 +1056,10 @@ const currentAttempt = computed(() => {
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
}) })
watch(currentAttempt, (attempt) => {
emit('selectAttempt', attempt ?? null)
}, { immediate: true })
const currentGroupTitle = computed(() => { const currentGroupTitle = computed(() => {
if (!selectedGroup.value || !currentAttempt.value) return '' if (!selectedGroup.value || !currentAttempt.value) return ''
if (selectedGroup.value.isPoolGroup) { if (selectedGroup.value.isPoolGroup) {
@@ -1224,7 +1239,7 @@ const navigateGroup = (direction: number) => {
// 加载请求追踪数据 // 加载请求追踪数据
const isSilentRefresh = ref(false) const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => { const loadTrace = async (silent = false) => {
if (!props.requestId) return if (!props.requestId || props.traceData) return
isSilentRefresh.value = silent isSilentRefresh.value = silent
@@ -1234,7 +1249,7 @@ const loadTrace = async (silent = false) => {
error.value = null error.value = null
try { try {
trace.value = await requestTraceApi.getRequestTrace(props.requestId) internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
} catch (err: unknown) { } catch (err: unknown) {
if (!silent) { if (!silent) {
error.value = parseApiError(err, '加载失败') error.value = parseApiError(err, '加载失败')
@@ -1304,12 +1319,31 @@ watch(groupedTimeline, (newGroups) => {
selectedAttemptIndex.value = 0 selectedAttemptIndex.value = 0
}, { immediate: true }) }, { immediate: true })
// 监听 requestId 变化 // 监听 requestId / 外部 trace 变化
watch(() => props.requestId, () => { watch(
selectedGroupIndex.value = 0 [() => props.requestId, () => props.traceData],
selectedAttemptIndex.value = 0 () => {
loadTrace() selectedGroupIndex.value = 0
}, { immediate: true }) selectedAttemptIndex.value = 0
if (props.traceData) {
internalTrace.value = null
loading.value = false
error.value = null
return
}
if (!props.requestId) {
internalTrace.value = null
loading.value = false
error.value = null
return
}
void loadTrace()
},
{ immediate: true },
)
defineExpose({ refresh: () => loadTrace(true) }) defineExpose({ refresh: () => loadTrace(true) })

View File

@@ -9,6 +9,7 @@ import asyncio
import json import json
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import uuid4 from uuid import uuid4
@@ -46,6 +47,11 @@ from src.services.model.upstream_fetcher import (
from src.services.provider.oauth_token import resolve_oauth_access_token from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.proxy_node.resolver import resolve_effective_proxy from src.services.proxy_node.resolver import resolve_effective_proxy
from src.services.request.candidate import RequestCandidateService from src.services.request.candidate import RequestCandidateService
from src.services.request.model_test_debug import (
get_model_test_debug_from_extra_data,
merge_model_test_debug,
set_candidate_model_test_debug,
)
from src.utils.auth_utils import get_current_user from src.utils.auth_utils import get_current_user
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -239,6 +245,7 @@ class TestModelFailoverRequest(BaseModel):
api_format: str | None = None # 指定 API 格式endpoint signature api_format: str | None = None # 指定 API 格式endpoint signature
endpoint_id: str | None = None # 指定仅使用该端点测试 endpoint_id: str | None = None # 指定仅使用该端点测试
message: str | None = None message: str | None = None
request_body: dict[str, Any] | None = None
request_id: str | None = None request_id: str | None = None
concurrency: int = Field(default=1, ge=1, le=20) concurrency: int = Field(default=1, ge=1, le=20)
@@ -259,6 +266,11 @@ class TestAttemptDetail(BaseModel):
error_message: str | None = None error_message: str | None = None
status_code: int | None = None status_code: int | None = None
latency_ms: int | None = None latency_ms: int | None = None
request_url: str | None = None
request_headers: dict[str, Any] | None = None
request_body: Any = None
response_headers: dict[str, Any] | None = None
response_body: Any = None
class TestModelFailoverResponse(BaseModel): class TestModelFailoverResponse(BaseModel):
@@ -285,6 +297,38 @@ def _resolve_test_message(message: str | None) -> str:
return normalized or DEFAULT_MODEL_TEST_MESSAGE return normalized or DEFAULT_MODEL_TEST_MESSAGE
def _build_test_request_payload(request: TestModelFailoverRequest) -> dict[str, Any]:
if isinstance(request.request_body, dict):
payload = deepcopy(request.request_body)
payload["model"] = request.model_name
return payload
return {
"model": request.model_name,
"messages": [{"role": "user", "content": _resolve_test_message(request.message)}],
"max_tokens": 30,
"temperature": 0.7,
"stream": True,
}
def _extract_test_debug_payload(response: dict[str, Any]) -> dict[str, Any] | None:
debug = response.get("debug")
if not isinstance(debug, dict):
return None
payload: dict[str, Any] = {}
request_url = debug.get("request_url")
if isinstance(request_url, str) and request_url.strip():
payload["request_url"] = request_url.strip()
for key in ("request_headers", "request_body", "response_headers", "response_body"):
if key in debug and debug.get(key) is not None:
payload[key] = deepcopy(debug.get(key))
return payload or None
def _test_check_response_has_error(resp: dict[str, Any]) -> bool: def _test_check_response_has_error(resp: dict[str, Any]) -> bool:
"""快速判断 check_endpoint 结果是否失败。""" """快速判断 check_endpoint 结果是否失败。"""
if resp.get("error"): if resp.get("error"):
@@ -1800,6 +1844,7 @@ async def _run_concurrent_test(
local_provider: Provider | None = None local_provider: Provider | None = None
local_endpoint: ProviderEndpoint | None = None local_endpoint: ProviderEndpoint | None = None
local_key: ProviderAPIKey | None = None local_key: ProviderAPIKey | None = None
debug_payload: dict[str, Any] | None = None
try: try:
if success_event.is_set() or await is_cancelled(): if success_event.is_set() or await is_cancelled():
@@ -1833,6 +1878,7 @@ async def _run_concurrent_test(
user=user, user=user,
db=None, db=None,
) )
debug_payload = _extract_test_debug_payload(response)
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000)) elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
with create_session() as parse_db: with create_session() as parse_db:
@@ -1856,6 +1902,7 @@ async def _run_concurrent_test(
candidate_id=record_id, candidate_id=record_id,
status_code=200, status_code=200,
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
extra_data=merge_model_test_debug(None, debug_payload),
) )
if not success_event.is_set(): if not success_event.is_set():
@@ -1898,6 +1945,7 @@ async def _run_concurrent_test(
), ),
status_code=status_code, status_code=status_code,
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
extra_data=merge_model_test_debug(None, debug_payload),
) )
return {"status": "failed", "error": exc} return {"status": "failed", "error": exc}
@@ -2137,6 +2185,9 @@ def _build_test_attempts_from_candidate_keys(
meta = candidate_meta_by_pair.get((candidate_index, key_id)) or candidate_meta_by_index.get( meta = candidate_meta_by_pair.get((candidate_index, key_id)) or candidate_meta_by_index.get(
candidate_index, {} candidate_index, {}
) )
debug_payload = get_model_test_debug_from_extra_data(
getattr(candidate_key, "extra_data", None)
)
attempts.append( attempts.append(
TestAttemptDetail( TestAttemptDetail(
@@ -2155,6 +2206,23 @@ def _build_test_attempts_from_candidate_keys(
error_message=getattr(candidate_key, "error_message", None), error_message=getattr(candidate_key, "error_message", None),
status_code=getattr(candidate_key, "status_code", None), status_code=getattr(candidate_key, "status_code", None),
latency_ms=getattr(candidate_key, "latency_ms", None), latency_ms=getattr(candidate_key, "latency_ms", None),
request_url=(
str(debug_payload.get("request_url"))
if debug_payload and debug_payload.get("request_url")
else None
),
request_headers=(
dict(debug_payload.get("request_headers"))
if debug_payload and isinstance(debug_payload.get("request_headers"), dict)
else None
),
request_body=debug_payload.get("request_body") if debug_payload else None,
response_headers=(
dict(debug_payload.get("response_headers"))
if debug_payload and isinstance(debug_payload.get("response_headers"), dict)
else None
),
response_body=debug_payload.get("response_body") if debug_payload else None,
) )
) )
@@ -2285,13 +2353,7 @@ async def test_model_failover(
error="No available candidates found for this model", error="No available candidates found for this model",
).model_dump() ).model_dump()
request_payload = { request_payload = _build_test_request_payload(request)
"model": request.model_name,
"messages": [{"role": "user", "content": _resolve_test_message(request.message)}],
"max_tokens": 30,
"temperature": 0.7,
"stream": True,
}
request_id = str(request.request_id or f"provider-test-{uuid4().hex[:12]}") request_id = str(request.request_id or f"provider-test-{uuid4().hex[:12]}")
request_timeout = float(getattr(provider, "request_timeout", 0) or TimeoutDefaults.HTTP_REQUEST) request_timeout = float(getattr(provider, "request_timeout", 0) or TimeoutDefaults.HTTP_REQUEST)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() provider_type = str(getattr(provider, "provider_type", "") or "").lower()
@@ -2315,6 +2377,7 @@ async def test_model_failover(
user=current_user, user=current_user,
db=db, db=db,
) )
set_candidate_model_test_debug(candidate, _extract_test_debug_payload(response))
return _extract_test_response_or_raise( return _extract_test_response_or_raise(
response=response, response=response,
endpoint=endpoint, endpoint=endpoint,

View File

@@ -122,6 +122,18 @@ async def run_endpoint_check(
if result.usage_data: if result.usage_data:
response_data["usage"] = result.usage_data response_data["usage"] = result.usage_data
response_data["debug"] = {
"request_url": request.url,
"request_headers": _redact_headers(request.headers),
"request_body": request.json_body,
"response_headers": _redact_headers(result.headers) if result.headers else None,
"response_body": (
result.raw_response_body
if result.raw_response_body is not None
else result.response_data
),
}
return response_data return response_data
@@ -600,6 +612,7 @@ class EndpointCheckResult:
response_data: dict[str, Any] | None = None response_data: dict[str, Any] | None = None
error_message: str | None = None error_message: str | None = None
usage_data: dict[str, Any] | None = None usage_data: dict[str, Any] | None = None
raw_response_body: Any | None = None
class HttpRequestExecutor: class HttpRequestExecutor:
@@ -665,6 +678,7 @@ class HttpRequestExecutor:
request_id=request_id, request_id=request_id,
response_data=None, response_data=None,
error_message=response_data.get("error"), error_message=response_data.get("error"),
raw_response_body=response_data.get("response_body"),
) )
return EndpointCheckResult( return EndpointCheckResult(
@@ -673,6 +687,7 @@ class HttpRequestExecutor:
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
request_id=request_id, request_id=request_id,
response_data=response_data.get("final_response"), response_data=response_data.get("final_response"),
raw_response_body=response_data.get("final_response"),
) )
else: else:
# 非流式请求:直接读取响应 # 非流式请求:直接读取响应
@@ -705,6 +720,9 @@ class HttpRequestExecutor:
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
request_id=request_id, request_id=request_id,
response_data=response_data, response_data=response_data,
raw_response_body=(
response_data if response_data is not None else response.text
),
) )
else: else:
error_body = response.text[:500] if response.text else "(empty)" error_body = response.text[:500] if response.text else "(empty)"
@@ -743,10 +761,12 @@ class HttpRequestExecutor:
headers = dict(response.headers) headers = dict(response.headers)
if response.status_code != 200: if response.status_code != 200:
# 读取上限 16KB 用于 debug response_body
# error 字段仅取前 500 字符,避免日志/展示过长
error_body = "" error_body = ""
async for chunk in response.aiter_text(): async for chunk in response.aiter_text():
error_body += chunk error_body += chunk
if len(error_body) > 500: if len(error_body) > 16384:
break break
logger.debug( logger.debug(
"[{}] check_endpoint | stream error | {}", "[{}] check_endpoint | stream error | {}",
@@ -757,6 +777,7 @@ class HttpRequestExecutor:
"error": f"HTTP {response.status_code}: {error_body[:500]}", "error": f"HTTP {response.status_code}: {error_body[:500]}",
"status_code": response.status_code, "status_code": response.status_code,
"headers": headers, "headers": headers,
"response_body": error_body,
} }
# 收集 SSE 事件(兼容多种 API 格式) # 收集 SSE 事件(兼容多种 API 格式)
@@ -841,7 +862,7 @@ class HttpRequestExecutor:
except Exception as e: except Exception as e:
logger.warning("[{}] check_endpoint | stream error | {}", request.api_format, e) logger.warning("[{}] check_endpoint | stream error | {}", request.api_format, e)
return {"error": str(e), "status_code": 500, "headers": {}} return {"error": str(e), "status_code": 500, "headers": {}, "response_body": None}
class UsageCalculator: class UsageCalculator:
@@ -1109,6 +1130,7 @@ class ErrorHandler:
"original_error": str(error), "original_error": str(error),
"retryable": True, "retryable": True,
}, },
raw_response_body=None,
) )
@staticmethod @staticmethod
@@ -1129,6 +1151,7 @@ class ErrorHandler:
"retryable": True, "retryable": True,
"timeout_seconds": request.timeout, "timeout_seconds": request.timeout,
}, },
raw_response_body=None,
) )
@staticmethod @staticmethod
@@ -1175,6 +1198,7 @@ class ErrorHandler:
"response_body": error.response.text[:500] if error.response.text else "", "response_body": error.response.text[:500] if error.response.text else "",
"retryable": retryable, "retryable": retryable,
}, },
raw_response_body=error.response.text if error.response.text else None,
) )
@staticmethod @staticmethod
@@ -1196,6 +1220,7 @@ class ErrorHandler:
"details": error.details, "details": error.details,
"retryable": error.status_code >= 500 or error.status_code == 429, "retryable": error.status_code >= 500 or error.status_code == 429,
}, },
raw_response_body=None,
) )
@staticmethod @staticmethod
@@ -1215,6 +1240,7 @@ class ErrorHandler:
"original_error": str(error), "original_error": str(error),
"retryable": False, "retryable": False,
}, },
raw_response_body=None,
) )
@staticmethod @staticmethod
@@ -1238,6 +1264,7 @@ class ErrorHandler:
"original_error": str(error), "original_error": str(error),
"retryable": False, "retryable": False,
}, },
raw_response_body=None,
) )

View File

@@ -372,11 +372,17 @@ class HandlerAdapterBase(ApiAdapter):
) )
from src.core.api_format.headers import HeaderBuilder from src.core.api_format.headers import HeaderBuilder
from src.core.provider_types import ProviderType from src.core.provider_types import ProviderType
from src.services.provider.adapters.vertex_ai.transport import is_vertex_ai_context
validated_base_url = cls._validate_test_base_url(base_url) validated_base_url = cls._validate_test_base_url(base_url)
is_antigravity = provider_type == ProviderType.ANTIGRAVITY is_antigravity = provider_type == ProviderType.ANTIGRAVITY
is_gemini_cli = provider_type == ProviderType.GEMINI_CLI is_gemini_cli = provider_type == ProviderType.GEMINI_CLI
is_vertex = provider_type == ProviderType.VERTEX_AI is_vertex = is_vertex_ai_context(
base_url=validated_base_url,
provider_type=provider_type,
endpoint=provider_endpoint,
key=provider_api_key,
)
is_kiro = provider_type == ProviderType.KIRO is_kiro = provider_type == ProviderType.KIRO
is_oauth = auth_type == "oauth" is_oauth = auth_type == "oauth"
vertex_auth_info: Any | None = None vertex_auth_info: Any | None = None

View File

@@ -253,6 +253,7 @@ class GeminiChatAdapter(ChatAdapterBase):
evaluate_condition, evaluate_condition,
) )
from src.core.api_format.headers import HeaderBuilder from src.core.api_format.headers import HeaderBuilder
from src.services.provider.adapters.vertex_ai.transport import is_vertex_ai_context
# Gemini需要从request_data或model_name参数获取model名称 # Gemini需要从request_data或model_name参数获取model名称
effective_model_name = model_name or request_data.get("model", "") effective_model_name = model_name or request_data.get("model", "")
@@ -264,7 +265,12 @@ class GeminiChatAdapter(ChatAdapterBase):
is_antigravity = provider_type and provider_type.lower() == "antigravity" is_antigravity = provider_type and provider_type.lower() == "antigravity"
is_gemini_cli = provider_type and provider_type.lower() == "gemini_cli" is_gemini_cli = provider_type and provider_type.lower() == "gemini_cli"
is_vertex = provider_type and provider_type.lower() == "vertex_ai" is_vertex = is_vertex_ai_context(
base_url=base_url,
provider_type=provider_type,
endpoint=provider_endpoint,
key=provider_api_key,
)
is_oauth = auth_type == "oauth" is_oauth = auth_type == "oauth"
vertex_auth_info: Any | None = None vertex_auth_info: Any | None = None

View File

@@ -45,6 +45,7 @@ class CandidateRecorder:
error_message=getattr(row, "error_message", None), error_message=getattr(row, "error_message", None),
status_code=getattr(row, "status_code", None), status_code=getattr(row, "status_code", None),
latency_ms=getattr(row, "latency_ms", None), latency_ms=getattr(row, "latency_ms", None),
extra_data=getattr(row, "extra_data", None),
) )
) )

View File

@@ -32,6 +32,7 @@ class CandidateKey:
error_message: str | None = None error_message: str | None = None
status_code: int | None = None status_code: int | None = None
latency_ms: int | None = None latency_ms: int | None = None
extra_data: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = { data: dict[str, Any] = {
@@ -52,6 +53,7 @@ class CandidateKey:
"error_message": self.error_message, "error_message": self.error_message,
"status_code": self.status_code, "status_code": self.status_code,
"latency_ms": self.latency_ms, "latency_ms": self.latency_ms,
"extra_data": self.extra_data,
} }
# drop Nones for compact audit payload # drop Nones for compact audit payload
return {k: v for k, v in data.items() if v is not None} return {k: v for k, v in data.items() if v is not None}

View File

@@ -54,9 +54,7 @@ KEY_FETCH_TIMEOUT_SECONDS = 120
MODEL_FETCH_HTTP_TIMEOUT = 10.0 MODEL_FETCH_HTTP_TIMEOUT = 10.0
# 启动时首次自动获取开关与延迟 # 启动时首次自动获取开关与延迟
MODEL_FETCH_STARTUP_ENABLED = ( MODEL_FETCH_STARTUP_ENABLED = os.getenv("MODEL_FETCH_STARTUP_ENABLED", "true").lower() == "true"
os.getenv("MODEL_FETCH_STARTUP_ENABLED", "true").lower() == "true"
)
MODEL_FETCH_STARTUP_DELAY_SECONDS = max( MODEL_FETCH_STARTUP_DELAY_SECONDS = max(
0, 0,
int(os.getenv("MODEL_FETCH_STARTUP_DELAY_SECONDS", "10")), int(os.getenv("MODEL_FETCH_STARTUP_DELAY_SECONDS", "10")),
@@ -487,13 +485,21 @@ class ModelFetchScheduler:
self._update_key_error(prepared.key_id, f"OAuth token resolution failed: {e}") self._update_key_error(prepared.key_id, f"OAuth token resolution failed: {e}")
return "error" return "error"
else: else:
try: is_vertex_service_account = (
api_key_value = crypto_service.decrypt(prepared.encrypted_api_key) prepared.provider_type.lower() == ProviderType.VERTEX_AI.value
except Exception: and prepared.auth_type in ("service_account", "vertex_ai")
self._update_key_error(prepared.key_id, "Decrypt error") )
return "error"
# Best-effort: decrypt auth_config if present (e.g. Antigravity project_id). if is_vertex_service_account:
api_key_value = "__placeholder__"
else:
try:
api_key_value = crypto_service.decrypt(prepared.encrypted_api_key)
except Exception:
self._update_key_error(prepared.key_id, "Decrypt error")
return "error"
# Best-effort: decrypt auth_config if present (e.g. Antigravity project_id / Vertex SA JSON).
if prepared.encrypted_auth_config: if prepared.encrypted_auth_config:
try: try:
parsed = json.loads(crypto_service.decrypt(prepared.encrypted_auth_config)) parsed = json.loads(crypto_service.decrypt(prepared.encrypted_auth_config))
@@ -582,9 +588,13 @@ class ModelFetchScheduler:
db.commit() db.commit()
return "error" return "error"
# Service Account 类型不支持自动获取模型Vertex AI SA / 旧 vertex_ai auth_type
auth_type = getattr(key, "auth_type", "api_key") or "api_key" auth_type = getattr(key, "auth_type", "api_key") or "api_key"
if auth_type in ("service_account", "vertex_ai"): provider_type = str(getattr(provider, "provider_type", "") or "")
is_vertex_service_account = (
provider_type.strip().lower() == ProviderType.VERTEX_AI.value
and auth_type in ("service_account", "vertex_ai")
)
if auth_type in ("service_account", "vertex_ai") and not is_vertex_service_account:
key.last_models_fetch_error = ( key.last_models_fetch_error = (
"auto_fetch_models 暂不支持 Service Account 类型的 Key" "auto_fetch_models 暂不支持 Service Account 类型的 Key"
) )
@@ -610,9 +620,7 @@ class ModelFetchScheduler:
key.last_models_fetch_at = now key.last_models_fetch_at = now
db.commit() db.commit()
return "error" return "error"
encrypted_auth_config = getattr(key, "auth_config", None) encrypted_auth_config = getattr(key, "auth_config", None)
provider_type = str(getattr(provider, "provider_type", "") or "")
return PreparedModelsFetchContext( return PreparedModelsFetchContext(
key_id=key_id, key_id=key_id,

View File

@@ -1,9 +1,9 @@
"""Vertex AI provider plugin — 统一注册入口。 """Vertex AI provider plugin — 统一注册入口。
注册 Vertex AI 对各通用 registry / capability registry 的 hooks 注册 Vertex AI 对各通用 registry / capability registry 的 hooks
- Transport Hook (URL 构建,支持 API Key / Service Account 双策略) - Transport Hook (URL 构建Gemini 走 Express modeClaude 走 Service Account)
- Model Fetcher (专用上游模型获取链路,不走通用 /v1beta/models / /v1/models) - Model Fetcher (专用上游模型获取链路)
- Provider Format Capability跨格式支持同一 Provider 同时访问 Gemini Claude 模型 - Provider Format Capability跨格式支持同一 Provider 可配置 Gemini / Claude
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,8 +18,6 @@ from src.services.provider.adapters.vertex_ai.transport import get_effective_for
# Vertex AI 公共 API 根 # Vertex AI 公共 API 根
_VERTEX_API_BASE = "https://aiplatform.googleapis.com" _VERTEX_API_BASE = "https://aiplatform.googleapis.com"
# Gemini Developer APIAPI Key 场景兜底)
_GEMINI_DEV_BASE = "https://generativelanguage.googleapis.com"
_MODEL_PAGE_SIZE = 100 _MODEL_PAGE_SIZE = 100
_MODEL_MAX_PAGES = 20 _MODEL_MAX_PAGES = 20
@@ -261,6 +259,7 @@ async def _fetch_models_vertex_api_key(
ctx: Any, ctx: Any,
auth_config: dict[str, Any] | None, auth_config: dict[str, Any] | None,
) -> tuple[list[dict[str, Any]], list[str], bool]: ) -> tuple[list[dict[str, Any]], list[str], bool]:
"""API Key 仅抓取 Vertex AI Express mode 的 Google publisher models。"""
api_key = str(ctx.api_key_value or "").strip() api_key = str(ctx.api_key_value or "").strip()
if not api_key or api_key == "__placeholder__": if not api_key or api_key == "__placeholder__":
return [], ["vertex_ai(api_key): missing api key"], False return [], ["vertex_ai(api_key): missing api key"], False
@@ -275,7 +274,7 @@ async def _fetch_models_vertex_api_key(
_build_google_publisher_list_url(base) for base in _iter_endpoint_base_urls(ctx) _build_google_publisher_list_url(base) for base in _iter_endpoint_base_urls(ctx)
] ]
# 1) Vertex API list (publisher=google) # Vertex Express mode list (publisher=google)
for url in vertex_list_urls: for url in vertex_list_urls:
headers = {"Accept": "application/json", **endpoint_headers} headers = {"Accept": "application/json", **endpoint_headers}
models, err, success = await _fetch_models_from_url( models, err, success = await _fetch_models_from_url(
@@ -297,29 +296,6 @@ async def _fetch_models_vertex_api_key(
continue continue
all_models.extend(models) all_models.extend(models)
# 2) 兜底Gemini Developer API
if not all_models:
fallback_url = f"{_GEMINI_DEV_BASE}/v1beta/models"
headers = {"Accept": "application/json", **endpoint_headers}
models, err, success = await _fetch_models_from_url(
client,
url=fallback_url,
headers=headers,
params={"key": api_key, "pageSize": _MODEL_PAGE_SIZE},
auth_config=auth_config,
fallback_publisher="google",
)
if success:
has_success = True
if err:
labeled = f"{fallback_url}: {err}"
if _is_soft_not_found(err):
soft_errors.append(labeled)
else:
hard_errors.append(labeled)
else:
all_models.extend(models)
deduped = _dedupe_models(all_models) deduped = _dedupe_models(all_models)
if deduped: if deduped:
return deduped, hard_errors, has_success or True return deduped, hard_errors, has_success or True
@@ -338,6 +314,7 @@ async def _fetch_models_vertex_service_account(
auth_config: dict[str, Any] | None, auth_config: dict[str, Any] | None,
client_kwargs: dict[str, Any], client_kwargs: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[str], bool]: ) -> tuple[list[dict[str, Any]], list[str], bool]:
"""Service Account 抓取 Vertex AI Google + Anthropic publisher models。"""
if not isinstance(auth_config, dict): if not isinstance(auth_config, dict):
return [], ["vertex_ai(service_account): missing auth_config"], False return [], ["vertex_ai(service_account): missing auth_config"], False
@@ -420,8 +397,8 @@ async def fetch_models_vertex_ai(
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]: ) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""Vertex AI 专用模型获取链路。 """Vertex AI 专用模型获取链路。
- API Key: 优先请求 Vertex publisher models失败时兜底 Gemini Developer API - API Key: 请求 Vertex AI Express mode 的 Gemini models
- Service Account: 使用 SA 凭证换取 Bearer Token按 region + publisher 查询 - Service Account: 使用 SA 凭证换取 Bearer Token按 region 查询 Gemini + Claude models
""" """
from src.services.proxy_node.resolver import build_proxy_client_kwargs from src.services.proxy_node.resolver import build_proxy_client_kwargs

View File

@@ -1,12 +1,16 @@
"""Vertex AI URL 构建Transport Hook """Vertex AI URL 构建Transport Hook
根据 auth_type 选择两种完全不同的 URL 构建策略 Vertex AI Gemini / Imagen 支持两种认证路径
- API Key: 全局端点,简化路径 - API Key + Gemini/Imagen (Express mode):
https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY} https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
- Service Account + Gemini/Imagen:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}
- Service Account: 区域端点,完整路径 Claude 仍走标准 Vertex AI Service Account 路径
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/models/{model}:{action}
- Service Account + Claude:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,6 +20,7 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_types import ProviderType, normalize_provider_type
from src.services.provider.adapters.vertex_ai.constants import ( from src.services.provider.adapters.vertex_ai.constants import (
API_KEY_BASE_URL, API_KEY_BASE_URL,
DEFAULT_FORMAT, DEFAULT_FORMAT,
@@ -23,7 +28,34 @@ from src.services.provider.adapters.vertex_ai.constants import (
MODEL_FORMAT_MAPPING, MODEL_FORMAT_MAPPING,
) )
from src.services.provider.format import normalize_endpoint_signature from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.transport import redact_url_for_log from src.services.provider.transport import looks_like_vertex_ai_host, redact_url_for_log
def is_vertex_ai_context(
*,
base_url: str | None = None,
provider_type: Any = None,
endpoint: Any = None,
key: Any = None,
) -> bool:
"""Best-effort 判断当前测试/请求上下文是否应视为 Vertex AI。"""
if normalize_provider_type(provider_type) == ProviderType.VERTEX_AI.value:
return True
for obj in (endpoint, key):
provider = getattr(obj, "provider", None) if obj is not None else None
if normalize_provider_type(getattr(provider, "provider_type", None)) == (
ProviderType.VERTEX_AI.value
):
return True
candidate_base_url = str(base_url or getattr(endpoint, "base_url", "") or "").strip()
if not candidate_base_url:
return False
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip()
auth_type = str(getattr(key, "auth_type", "") or "").strip()
return looks_like_vertex_ai_host(candidate_base_url, endpoint_sig, auth_type)
def get_effective_format( def get_effective_format(
@@ -95,28 +127,36 @@ def build_vertex_ai_url(
key: Any = None, key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None, decrypted_auth_config: dict[str, Any] | None = None,
) -> str: ) -> str:
"""Vertex AI transport hook — 统一 URL 构建入口。 """Vertex AI transport hook — 统一 URL 构建入口。"""
from src.core.exceptions import InvalidRequestException
根据 key.auth_type 分派到 API Key 或 Service Account 两种策略。 model = str((path_params or {}).get("model", "") or "").strip()
""" if not model:
auth_type = getattr(key, "auth_type", "api_key") if key else "api_key" raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").strip().lower()
is_claude_model = model.startswith("claude-")
if auth_type == "api_key": if auth_type == "api_key":
if is_claude_model:
raise InvalidRequestException(
"Vertex API Key 不支持 Claude 模型,请改用 Service Account 认证。"
)
return _build_api_key_url( return _build_api_key_url(
key=key, key=key,
path_params=path_params, path_params=path_params,
query_params=effective_query_params, query_params=effective_query_params,
is_stream=is_stream, is_stream=is_stream,
) )
else:
# service_account以及向后兼容旧的 "vertex_ai" auth_type # service_account以及向后兼容旧的 "vertex_ai" auth_type
return _build_service_account_url( return _build_service_account_url(
key=key, key=key,
path_params=path_params, path_params=path_params,
query_params=effective_query_params, query_params=effective_query_params,
is_stream=is_stream, is_stream=is_stream,
decrypted_auth_config=decrypted_auth_config, decrypted_auth_config=decrypted_auth_config,
) )
def _build_api_key_url( def _build_api_key_url(
@@ -137,11 +177,6 @@ def _build_api_key_url(
if not model: if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数") raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
if str(model).startswith("claude-"):
raise InvalidRequestException(
"Vertex API Key 不支持 Claude 模型,请改用 Service Account 认证。"
)
action = "streamGenerateContent" if is_stream else "generateContent" action = "streamGenerateContent" if is_stream else "generateContent"
path = f"/v1/publishers/google/models/{model}:{action}" path = f"/v1/publishers/google/models/{model}:{action}"
url = f"{API_KEY_BASE_URL}{path}" url = f"{API_KEY_BASE_URL}{path}"
@@ -177,10 +212,7 @@ def _build_service_account_url(
is_stream: bool = False, is_stream: bool = False,
decrypted_auth_config: dict[str, Any] | None = None, decrypted_auth_config: dict[str, Any] | None = None,
) -> str: ) -> str:
"""构建 Service Account 认证的区域端点 URL。 """构建 Service Account 认证的 Vertex AI 区域端点 URL。"""
格式: https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/models/{model}:{action}
"""
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException from src.core.exceptions import InvalidRequestException
@@ -227,11 +259,7 @@ def _build_service_account_url(
else: else:
region = "global" region = "global"
# 判断是 Claude 还是 Gemini 模型 if model.startswith("claude-"):
is_claude_model = model.startswith("claude-")
# 根据模型类型确定 publisher 和 action
if is_claude_model:
publisher = "anthropic" publisher = "anthropic"
action = "streamRawPredict" if is_stream else "rawPredict" action = "streamRawPredict" if is_stream else "rawPredict"
else: else:
@@ -249,7 +277,7 @@ def _build_service_account_url(
# 添加查询参数 # 添加查询参数
effective_query_params = dict(query_params) if query_params else {} effective_query_params = dict(query_params) if query_params else {}
# Gemini 流式请求使用 SSE 格式Claude 不需要 # Gemini 流式请求使用 SSE 格式Claude 不需要
if is_stream and not is_claude_model: if is_stream and not model.startswith("claude-"):
effective_query_params.setdefault("alt", "sse") effective_query_params.setdefault("alt", "sse")
# 移除不适用于 Vertex AI 的参数 # 移除不适用于 Vertex AI 的参数
effective_query_params.pop("beta", None) effective_query_params.pop("beta", None)

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import re import re
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import urlencode from urllib.parse import urlencode, urlparse
from src.core.api_format import ( from src.core.api_format import (
EndpointKind, EndpointKind,
@@ -72,6 +72,34 @@ def redact_url_for_log(url: str) -> str:
return _SENSITIVE_QUERY_PARAMS_PATTERN.sub(r"\1\2=***", url) return _SENSITIVE_QUERY_PARAMS_PATTERN.sub(r"\1\2=***", url)
# Vertex AI host 白名单aiplatform.googleapis.com 及其区域子域名
_VERTEX_AI_HOST = "aiplatform.googleapis.com"
_VERTEX_AI_ENDPOINT_SIGS = {"gemini:chat", "claude:chat"}
_VERTEX_AI_AUTH_TYPES = {"api_key", "service_account", "vertex_ai"}
def looks_like_vertex_ai_host(
base_url: str,
endpoint_sig: str = "",
auth_type: str = "",
) -> bool:
"""根据 base_url + endpoint_sig/auth_type 判断是否属于 Vertex AI。
用于历史数据兼容:部分旧记录缺少 provider_type但 base_url 已固定到 aiplatform。
"""
try:
host = (urlparse(base_url).netloc or "").strip().lower()
except Exception:
return False
if not host:
return False
if host != _VERTEX_AI_HOST and not host.endswith(f".{_VERTEX_AI_HOST}"):
return False
sig = endpoint_sig.strip().lower()
at = auth_type.strip().lower()
return sig in _VERTEX_AI_ENDPOINT_SIGS or at in _VERTEX_AI_AUTH_TYPES
def _normalize_base_url(base_url: str, path: str) -> str: def _normalize_base_url(base_url: str, path: str) -> str:
""" """
规范化 base_url去除末尾的斜杠和可能与 path 重复的版本前缀。 规范化 base_url去除末尾的斜杠和可能与 path 重复的版本前缀。
@@ -138,6 +166,16 @@ def _get_provider_type(
if isinstance(pt, str) and pt.strip(): if isinstance(pt, str) and pt.strip():
return pt.strip().lower() return pt.strip().lower()
# Fallback: 历史 Vertex 数据可能缺少 provider_type但 base_url 已固定到 aiplatform。
try:
base_url = str(getattr(endpoint, "base_url", "") or "").strip()
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip()
auth_type = str(getattr(key, "auth_type", "") or "").strip() if key else ""
if base_url and looks_like_vertex_ai_host(base_url, endpoint_sig, auth_type):
return ProviderType.VERTEX_AI.value
except Exception:
pass
return None return None

View File

@@ -59,7 +59,7 @@ def _validate_vertex_api_formats(
if auth_type == "api_key": if auth_type == "api_key":
allowed = {"gemini:chat"} allowed = {"gemini:chat"}
elif auth_type in {"service_account", "vertex_ai"}: elif auth_type in {"service_account", "vertex_ai"}:
allowed = {"gemini:chat", "claude:chat"} allowed = {"claude:chat", "gemini:chat"}
else: else:
return return
@@ -310,14 +310,16 @@ def _prepare_update_key_payload(
exclude_key_id=key_id, exclude_key_id=key_id,
) )
# Vertex Provider: auth_typeapi_formats 的组合必须合法 # Vertex Provider: 仅在 auth_type/api_formats 变更时校验组合,
provider = getattr(key, "provider", None) # 避免历史旧数据在无关编辑时被强制阻断。
effective_api_formats = update_data.get("api_formats", key.api_formats) if "auth_type" in update_data or "api_formats" in update_data:
_validate_vertex_api_formats( provider = getattr(key, "provider", None)
getattr(provider, "provider_type", None), effective_api_formats = update_data.get("api_formats", key.api_formats)
target_auth_type, _validate_vertex_api_formats(
effective_api_formats, getattr(provider, "provider_type", None),
) target_auth_type,
effective_api_formats,
)
if "api_key" in update_data: if "api_key" in update_data:
api_key_raw = update_data["api_key"] api_key_raw = update_data["api_key"]

View File

@@ -21,6 +21,10 @@ from src.services.provider.format import normalize_endpoint_signature
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.request.candidate import RequestCandidateService from src.services.request.candidate import RequestCandidateService
from src.services.request.model_test_debug import (
get_candidate_model_test_debug,
merge_model_test_debug,
)
@dataclass @dataclass
@@ -219,6 +223,13 @@ class RequestExecutor:
_pi = await resolve_proxy_info_async(_eff_proxy) _pi = await resolve_proxy_info_async(_eff_proxy)
if _pi: if _pi:
_extra["proxy"] = _pi _extra["proxy"] = _pi
_extra = (
merge_model_test_debug(
_extra,
get_candidate_model_test_debug(candidate),
)
or _extra
)
RequestCandidateService.mark_candidate_success( RequestCandidateService.mark_candidate_success(
db=self.db, db=self.db,
candidate_id=candidate_id, candidate_id=candidate_id,

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
MODEL_TEST_DEBUG_KEY = "model_test_debug"
MODEL_TEST_DEBUG_ATTR = "_model_test_debug"
def normalize_model_test_debug_payload(debug_payload: Any) -> dict[str, Any] | None:
if not isinstance(debug_payload, dict):
return None
normalized: dict[str, Any] = {}
for key in (
"request_url",
"request_headers",
"request_body",
"response_headers",
"response_body",
):
value = debug_payload.get(key)
if value is None:
continue
normalized[key] = deepcopy(value)
return normalized or None
def set_candidate_model_test_debug(candidate: Any, debug_payload: Any) -> None:
normalized = normalize_model_test_debug_payload(debug_payload)
if normalized is None:
return
setattr(candidate, MODEL_TEST_DEBUG_ATTR, normalized)
def get_candidate_model_test_debug(candidate: Any) -> dict[str, Any] | None:
return normalize_model_test_debug_payload(getattr(candidate, MODEL_TEST_DEBUG_ATTR, None))
def merge_model_test_debug(
extra_data: dict[str, Any] | None,
debug_payload: Any,
) -> dict[str, Any] | None:
normalized = normalize_model_test_debug_payload(debug_payload)
if normalized is None:
return extra_data
merged = dict(extra_data or {})
merged[MODEL_TEST_DEBUG_KEY] = normalized
return merged
def get_model_test_debug_from_extra_data(extra_data: Any) -> dict[str, Any] | None:
if not isinstance(extra_data, dict):
return None
return normalize_model_test_debug_payload(extra_data.get(MODEL_TEST_DEBUG_KEY))

View File

@@ -17,6 +17,10 @@ from src.core.exceptions import (
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_types import ProviderType from src.core.provider_types import ProviderType
from src.services.request.candidate import RequestCandidateService from src.services.request.candidate import RequestCandidateService
from src.services.request.model_test_debug import (
get_candidate_model_test_debug,
merge_model_test_debug,
)
from src.services.task.execute.pool import TaskPoolOperationsService from src.services.task.execute.pool import TaskPoolOperationsService
from src.services.task.request_state import RequestBodyState from src.services.task.request_state import RequestBodyState
@@ -206,6 +210,9 @@ class TaskErrorOperationsService:
) )
_proxy_info = await resolve_proxy_info_async(_eff_proxy) _proxy_info = await resolve_proxy_info_async(_eff_proxy)
_proxy_extra: dict[str, Any] | None = {"proxy": _proxy_info} if _proxy_info else None _proxy_extra: dict[str, Any] | None = {"proxy": _proxy_info} if _proxy_info else None
_proxy_extra = merge_model_test_debug(
_proxy_extra, get_candidate_model_test_debug(candidate)
)
if not isinstance(exec_err, ExecutionError): if not isinstance(exec_err, ExecutionError):
RequestCandidateService.mark_candidate_failed( RequestCandidateService.mark_candidate_failed(
@@ -410,6 +417,13 @@ class TaskErrorOperationsService:
} }
if _proxy_info: if _proxy_info:
serializable_extra_data["proxy"] = _proxy_info serializable_extra_data["proxy"] = _proxy_info
serializable_extra_data = (
merge_model_test_debug(
serializable_extra_data,
get_candidate_model_test_debug(candidate),
)
or serializable_extra_data
)
if isinstance(converted_error, ThinkingSignatureException): if isinstance(converted_error, ThinkingSignatureException):
action = self.handle_thinking_signature_error( action = self.handle_thinking_signature_error(

View File

@@ -6,7 +6,9 @@ import pytest
import src.services.model.fetch_scheduler as fetch_scheduler_module import src.services.model.fetch_scheduler as fetch_scheduler_module
from src.services.model.fetch_scheduler import ( from src.services.model.fetch_scheduler import (
EndpointFetchConfig,
ModelFetchScheduler, ModelFetchScheduler,
PreparedModelsFetchContext,
_aggregate_models_for_cache, _aggregate_models_for_cache,
_run_key_fetch_workers, _run_key_fetch_workers,
) )
@@ -108,3 +110,70 @@ async def test_perform_fetch_all_keys_scans_in_batches(monkeypatch: pytest.Monke
assert batch_requests == [None, "b"] assert batch_requests == [None, "b"]
assert processed_batches == [["a", "b"], ["c"]] assert processed_batches == [["a", "b"], ["c"]]
@pytest.mark.asyncio
async def test_fetch_models_for_key_by_id_vertex_service_account_uses_auth_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scheduler = ModelFetchScheduler()
captured: dict[str, object] = {}
prepared = PreparedModelsFetchContext(
key_id="key-vertex-sa",
provider_id="provider-vertex",
provider_name="Vertex",
provider_type="vertex_ai",
auth_type="service_account",
encrypted_api_key="ENC_PLACEHOLDER",
encrypted_auth_config="ENC_AUTH_CONFIG",
format_to_endpoint={
"gemini:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
},
proxy_config=None,
)
monkeypatch.setattr(scheduler, "_prepare_fetch_context", lambda key_id: prepared)
async def fake_fetch_models_for_key(ctx, *, timeout_seconds: float):
captured["api_key_value"] = ctx.api_key_value
captured["auth_config"] = ctx.auth_config
captured["timeout_seconds"] = timeout_seconds
return ([], [], True, None)
async def fake_update_key_after_fetch(
key_id: str,
provider_id: str,
provider_name: str,
all_models: list[dict],
errors: list[str],
has_success: bool,
upstream_metadata=None,
) -> str:
captured["update_key_id"] = key_id
captured["update_provider_id"] = provider_id
return "success"
def fake_decrypt(value: str) -> str:
if value == "ENC_AUTH_CONFIG":
return (
'{"project_id":"demo-project","client_email":"svc@example.com",'
'"private_key":"-----BEGIN PRIVATE KEY-----\\nTEST\\n-----END PRIVATE KEY-----\\n"}'
)
raise AssertionError(f"unexpected decrypt call for {value}")
monkeypatch.setattr(fetch_scheduler_module, "fetch_models_for_key", fake_fetch_models_for_key)
monkeypatch.setattr(scheduler, "_update_key_after_fetch", fake_update_key_after_fetch)
monkeypatch.setattr(fetch_scheduler_module.crypto_service, "decrypt", fake_decrypt)
result = await scheduler._fetch_models_for_key_by_id("key-vertex-sa")
assert result == "success"
assert captured["api_key_value"] == "__placeholder__"
assert captured["auth_config"] == {
"project_id": "demo-project",
"client_email": "svc@example.com",
"private_key": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n",
}
assert captured["update_key_id"] == "key-vertex-sa"
assert captured["update_provider_id"] == "provider-vertex"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import sys import sys
import types import types
from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
@@ -167,6 +168,42 @@ def test_prepare_update_payload_vertex_to_oauth_clears_auth_config(
assert prepared.update_data["api_key"] == "ENC:__placeholder__" assert prepared.update_data["api_key"] == "ENC:__placeholder__"
def test_validate_vertex_api_formats_api_key_allows_gemini_only() -> None:
command_module._validate_vertex_api_formats("vertex_ai", "api_key", ["gemini:chat"])
with pytest.raises(InvalidRequestException, match="claude:chat"):
command_module._validate_vertex_api_formats("vertex_ai", "api_key", ["claude:chat"])
def test_validate_vertex_api_formats_service_account_allows_gemini_and_claude() -> None:
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["claude:chat"])
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["gemini:chat"])
command_module._validate_vertex_api_formats(
"vertex_ai", "service_account", ["gemini:chat", "claude:chat"]
)
with pytest.raises(InvalidRequestException, match="openai:chat"):
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["openai:chat"])
def test_prepare_update_payload_allows_unrelated_update_for_legacy_vertex_combo() -> None:
key = _build_key(
auth_type="service_account",
api_formats=["gemini:chat"],
provider=SimpleNamespace(provider_type="vertex_ai"),
)
key_data = EndpointAPIKeyUpdate.model_validate({"name": "legacy-key"})
prepared = command_module._prepare_update_key_payload(
db=cast(Any, _NoQueryDB()),
key=cast(Any, key),
key_id="key-1",
key_data=key_data,
)
assert prepared.update_data["name"] == "legacy-key"
def test_clear_oauth_invalid_response_invalidates_caches( def test_clear_oauth_invalid_response_invalidates_caches(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -346,6 +383,12 @@ async def test_batch_delete_endpoint_keys_response_cleans_related_references(
] ]
db = _FakeBatchDeleteDB(keys) db = _FakeBatchDeleteDB(keys)
@contextmanager
def _fake_get_db_context() -> Any:
yield db
monkeypatch.setattr(command_module, "get_db_context", _fake_get_db_context)
result = await command_module.batch_delete_endpoint_keys_response( result = await command_module.batch_delete_endpoint_keys_response(
cast(Any, db), cast(Any, db),
["key-1", "key-2"], ["key-1", "key-2"],

View File

@@ -1,4 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from types import SimpleNamespace
import pytest
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
@@ -61,3 +64,52 @@ def test_gemini_non_stream_does_not_add_alt() -> None:
assert url.endswith("/v1beta/models/gemini-1.5-pro:generateContent") assert url.endswith("/v1beta/models/gemini-1.5-pro:generateContent")
assert "alt=" not in url assert "alt=" not in url
def test_vertex_gemini_api_key_base_url_uses_vertex_transport_without_provider_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
endpoint = _DummyEndpoint(
base_url="https://aiplatform.googleapis.com",
api_format="gemini:chat",
)
key = SimpleNamespace(auth_type="api_key", api_key="enc-key")
from src.core.crypto import crypto_service
monkeypatch.setattr(crypto_service, "decrypt", lambda value: "test-key")
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-3.1-pro-preview"},
is_stream=False,
key=key, # type: ignore[arg-type] - test stub
)
assert (
url == "https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-3.1-pro-preview:generateContent?key=test-key"
)
def test_vertex_gemini_service_account_base_url_uses_vertex_transport_without_provider_type() -> (
None
):
endpoint = _DummyEndpoint(
base_url="https://aiplatform.googleapis.com",
api_format="gemini:chat",
)
key = SimpleNamespace(auth_type="service_account", auth_config={"project_id": "demo-project"})
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-3.1-pro-preview"},
is_stream=False,
key=key, # type: ignore[arg-type] - test stub
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
assert (
url == "https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/google/models/gemini-3.1-pro-preview:generateContent"
)

View File

@@ -52,7 +52,7 @@ async def test_fetch_models_for_key_vertex_api_key_custom_fetcher() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when_success() -> None: async def test_fetch_models_for_key_vertex_service_account_fetches_google_and_claude() -> None:
auth_config = { auth_config = {
"project_id": "demo-project", "project_id": "demo-project",
"client_email": "svc@example.iam.gserviceaccount.com", "client_email": "svc@example.iam.gserviceaccount.com",
@@ -74,16 +74,27 @@ async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when
( (
[ [
{ {
"id": "gemini-2.0-flash", "id": "gemini-3.1-pro-preview",
"owned_by": "google", "owned_by": "google",
"display_name": "Gemini 2.0 Flash", "display_name": "Gemini 3.1 Pro Preview",
"api_format": "gemini:chat", "api_format": "gemini:chat",
} }
], ],
None, None,
True, True,
), ),
([], "HTTP 404: not found", False), (
[
{
"id": "claude-3-7-sonnet@20250219",
"owned_by": "anthropic",
"display_name": "Claude 3.7 Sonnet",
"api_format": "claude:chat",
}
],
None,
True,
),
] ]
with ( with (
@@ -111,7 +122,8 @@ async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when
assert errors == [] assert errors == []
assert meta is None assert meta is None
ids = {m.get("id") for m in models} ids = {m.get("id") for m in models}
assert "gemini-2.0-flash" in ids assert "gemini-3.1-pro-preview" in ids
assert "claude-3-7-sonnet@20250219" in ids
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,7 +144,6 @@ async def test_fetch_models_for_key_vertex_api_key_returns_soft_404_when_all_fai
AsyncMock( AsyncMock(
side_effect=[ side_effect=[
([], "HTTP 404: not found", False), ([], "HTTP 404: not found", False),
([], "HTTP 404: not found", False),
] ]
), ),
), ),

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import pytest
from src.core.exceptions import InvalidRequestException
from src.services.provider.adapters.vertex_ai.transport import build_vertex_ai_url
def test_build_vertex_ai_url_uses_express_mode_for_gemini_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key = SimpleNamespace(auth_type="api_key", api_key="enc-key")
from src.core.crypto import crypto_service
monkeypatch.setattr(crypto_service, "decrypt", lambda value: "test-key")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=True,
effective_query_params={"foo": "bar"},
path_params={"model": "gemini-2.5-pro"},
key=key,
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert parsed.path == "/v1/publishers/google/models/gemini-2.5-pro:streamGenerateContent"
assert parse_qs(parsed.query) == {
"foo": ["bar"],
"key": ["test-key"],
"alt": ["sse"],
}
def test_build_vertex_ai_url_uses_standard_vertex_path_for_gemini_service_account() -> None:
key = SimpleNamespace(auth_type="service_account")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={"foo": "bar", "beta": "1"},
path_params={"model": "gemini-3.1-pro-preview"},
key=key,
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert (
parsed.path
== "/v1/projects/demo-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:generateContent"
)
assert parse_qs(parsed.query) == {"foo": ["bar"]}
def test_build_vertex_ai_url_rejects_claude_api_key() -> None:
key = SimpleNamespace(auth_type="api_key")
with pytest.raises(InvalidRequestException, match="Claude 模型"):
build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={},
path_params={"model": "claude-3-7-sonnet@20250219"},
key=key,
)
def test_build_vertex_ai_url_uses_standard_vertex_path_for_claude_service_account() -> None:
key = SimpleNamespace(auth_type="service_account")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={"foo": "bar", "beta": "1"},
path_params={"model": "claude-3-7-sonnet@20250219"},
key=key,
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert (
parsed.path
== "/v1/projects/demo-project/locations/global/publishers/anthropic/models/claude-3-7-sonnet@20250219:rawPredict"
)
assert parse_qs(parsed.query) == {"foo": ["bar"]}

View File

@@ -1,9 +1,11 @@
from types import SimpleNamespace
from typing import Any from typing import Any
import pytest import pytest
from src.api.handlers.claude.adapter import ClaudeChatAdapter from src.api.handlers.claude.adapter import ClaudeChatAdapter
from src.api.handlers.gemini.adapter import GeminiChatAdapter from src.api.handlers.gemini.adapter import GeminiChatAdapter
from src.core.provider_auth_types import ProviderAuthInfo
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,3 +134,333 @@ async def test_gemini_check_endpoint_passes_original_body_to_body_rules(
assert captured["original_body"] == captured["json_body"] assert captured["original_body"] == captured["json_body"]
assert result["status_code"] == 200 assert result["status_code"] == 200
assert result["json_body"] == captured["json_body"] assert result["json_body"] == captured["json_body"]
@pytest.mark.asyncio
async def test_gemini_check_endpoint_uses_provider_transport_for_vertex_ai(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.api.handlers.base import endpoint_checker as endpoint_checker_module
from src.services.provider import auth as provider_auth_module
from src.services.provider import transport as provider_transport_module
captured: dict[str, Any] = {}
async def fake_run_endpoint_check(**kwargs: Any) -> dict[str, Any]:
captured["url"] = kwargs["url"]
captured["headers"] = kwargs["headers"]
captured["json_body"] = kwargs["json_body"]
return {"status_code": 200}
async def fake_get_provider_auth(endpoint: Any, key: Any) -> None:
captured["provider_auth_endpoint"] = endpoint
captured["provider_auth_key"] = key
return None
def fake_build_provider_url(
endpoint: Any,
*,
query_params: dict[str, Any] | None = None,
path_params: dict[str, Any] | None = None,
is_stream: bool = False,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
captured["build_provider_url"] = {
"endpoint": endpoint,
"query_params": query_params,
"path_params": path_params,
"is_stream": is_stream,
"key": key,
"decrypted_auth_config": decrypted_auth_config,
}
return (
"https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-2.5-pro:streamGenerateContent?key=test-key"
)
def fake_build_request_body(
cls: type[GeminiChatAdapter],
request_data: dict[str, Any] | None = None,
*,
base_url: str | None = None,
provider_type: str | None = None,
) -> dict[str, Any]:
del cls, request_data, base_url, provider_type
return {
"contents": [{"role": "user", "parts": [{"text": "hello"}]}],
"generationConfig": {"temperature": 0.1},
}
monkeypatch.setattr(endpoint_checker_module, "run_endpoint_check", fake_run_endpoint_check)
monkeypatch.setattr(provider_auth_module, "get_provider_auth", fake_get_provider_auth)
monkeypatch.setattr(provider_transport_module, "build_provider_url", fake_build_provider_url)
monkeypatch.setattr(
GeminiChatAdapter, "build_request_body", classmethod(fake_build_request_body)
)
endpoint = SimpleNamespace(id="ep-vertex-gemini")
key = SimpleNamespace(id="key-vertex-gemini", auth_type="api_key")
result = await GeminiChatAdapter.check_endpoint(
client=None, # type: ignore[arg-type]
base_url="https://generativelanguage.googleapis.com",
api_key="test-key",
request_data={"model": "gemini-2.5-pro", "stream": True},
provider_type="vertex_ai",
provider_endpoint=endpoint,
provider_api_key=key,
model_name="gemini-2.5-pro",
)
assert result["status_code"] == 200
assert (
captured["url"] == "https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-2.5-pro:streamGenerateContent?key=test-key"
)
assert captured["build_provider_url"]["path_params"] == {"model": "gemini-2.5-pro"}
assert captured["build_provider_url"]["is_stream"] is True
assert captured["build_provider_url"]["key"] is key
assert captured["headers"] == {}
@pytest.mark.asyncio
async def test_gemini_check_endpoint_infers_vertex_ai_from_base_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.api.handlers.base import endpoint_checker as endpoint_checker_module
from src.services.provider import auth as provider_auth_module
from src.services.provider import transport as provider_transport_module
captured: dict[str, Any] = {}
async def fake_run_endpoint_check(**kwargs: Any) -> dict[str, Any]:
captured["url"] = kwargs["url"]
return {"status_code": 200}
async def fake_get_provider_auth(endpoint: Any, key: Any) -> None:
captured["provider_auth_endpoint"] = endpoint
captured["provider_auth_key"] = key
return None
def fake_build_provider_url(
endpoint: Any,
*,
query_params: dict[str, Any] | None = None,
path_params: dict[str, Any] | None = None,
is_stream: bool = False,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
captured["build_provider_url"] = {
"endpoint": endpoint,
"query_params": query_params,
"path_params": path_params,
"is_stream": is_stream,
"key": key,
"decrypted_auth_config": decrypted_auth_config,
}
return (
"https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-3.1-pro-preview:generateContent?key=test-key"
)
monkeypatch.setattr(endpoint_checker_module, "run_endpoint_check", fake_run_endpoint_check)
monkeypatch.setattr(provider_auth_module, "get_provider_auth", fake_get_provider_auth)
monkeypatch.setattr(provider_transport_module, "build_provider_url", fake_build_provider_url)
endpoint = SimpleNamespace(id="ep-aiplatform", api_format="gemini:chat")
key = SimpleNamespace(id="key-aiplatform", auth_type="api_key")
result = await GeminiChatAdapter.check_endpoint(
client=None, # type: ignore[arg-type]
base_url="https://aiplatform.googleapis.com",
api_key="test-key",
request_data={"model": "gemini-3.1-pro-preview", "stream": False},
provider_endpoint=endpoint,
provider_api_key=key,
model_name="gemini-3.1-pro-preview",
)
assert result["status_code"] == 200
assert (
captured["url"] == "https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-3.1-pro-preview:generateContent?key=test-key"
)
assert captured["build_provider_url"]["path_params"] == {"model": "gemini-3.1-pro-preview"}
assert captured["build_provider_url"]["is_stream"] is False
@pytest.mark.asyncio
async def test_gemini_check_endpoint_uses_provider_transport_for_vertex_service_account(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.api.handlers.base import endpoint_checker as endpoint_checker_module
from src.services.provider import auth as provider_auth_module
from src.services.provider import transport as provider_transport_module
captured: dict[str, Any] = {}
auth_info = ProviderAuthInfo(
auth_header="Authorization",
auth_value="Bearer vertex-token",
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
async def fake_run_endpoint_check(**kwargs: Any) -> dict[str, Any]:
captured["url"] = kwargs["url"]
captured["headers"] = kwargs["headers"]
return {"status_code": 200}
async def fake_get_provider_auth(endpoint: Any, key: Any) -> ProviderAuthInfo:
captured["provider_auth_endpoint"] = endpoint
captured["provider_auth_key"] = key
return auth_info
def fake_build_provider_url(
endpoint: Any,
*,
query_params: dict[str, Any] | None = None,
path_params: dict[str, Any] | None = None,
is_stream: bool = False,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
captured["build_provider_url"] = {
"endpoint": endpoint,
"query_params": query_params,
"path_params": path_params,
"is_stream": is_stream,
"key": key,
"decrypted_auth_config": decrypted_auth_config,
}
return (
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/google/models/gemini-3.1-pro-preview:generateContent"
)
monkeypatch.setattr(endpoint_checker_module, "run_endpoint_check", fake_run_endpoint_check)
monkeypatch.setattr(provider_auth_module, "get_provider_auth", fake_get_provider_auth)
monkeypatch.setattr(provider_transport_module, "build_provider_url", fake_build_provider_url)
endpoint = SimpleNamespace(id="ep-vertex-gemini-sa", api_format="gemini:chat")
key = SimpleNamespace(id="key-vertex-gemini-sa", auth_type="service_account")
result = await GeminiChatAdapter.check_endpoint(
client=None, # type: ignore[arg-type]
base_url="https://aiplatform.googleapis.com",
api_key="ignored",
request_data={"model": "gemini-3.1-pro-preview", "stream": False},
provider_endpoint=endpoint,
provider_api_key=key,
model_name="gemini-3.1-pro-preview",
)
assert result["status_code"] == 200
assert (
captured["url"]
== "https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/google/models/gemini-3.1-pro-preview:generateContent"
)
assert captured["build_provider_url"]["path_params"] == {"model": "gemini-3.1-pro-preview"}
assert captured["build_provider_url"]["is_stream"] is False
assert (
captured["build_provider_url"]["decrypted_auth_config"] == auth_info.decrypted_auth_config
)
assert captured["headers"]["Authorization"] == "Bearer vertex-token"
@pytest.mark.asyncio
async def test_claude_check_endpoint_uses_provider_transport_for_vertex_ai(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.api.handlers.base import endpoint_checker as endpoint_checker_module
from src.services.provider import auth as provider_auth_module
from src.services.provider import transport as provider_transport_module
captured: dict[str, Any] = {}
auth_info = ProviderAuthInfo(
auth_header="Authorization",
auth_value="Bearer vertex-token",
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
async def fake_run_endpoint_check(**kwargs: Any) -> dict[str, Any]:
captured["url"] = kwargs["url"]
captured["headers"] = kwargs["headers"]
captured["json_body"] = kwargs["json_body"]
return {"status_code": 200}
async def fake_get_provider_auth(endpoint: Any, key: Any) -> ProviderAuthInfo:
captured["provider_auth_endpoint"] = endpoint
captured["provider_auth_key"] = key
return auth_info
def fake_build_provider_url(
endpoint: Any,
*,
query_params: dict[str, Any] | None = None,
path_params: dict[str, Any] | None = None,
is_stream: bool = False,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
captured["build_provider_url"] = {
"endpoint": endpoint,
"query_params": query_params,
"path_params": path_params,
"is_stream": is_stream,
"key": key,
"decrypted_auth_config": decrypted_auth_config,
}
return (
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/anthropic/models/claude-3-7-sonnet@20250219:rawPredict"
)
def fake_build_request_body(
cls: type[ClaudeChatAdapter],
request_data: dict[str, Any] | None = None,
*,
base_url: str | None = None,
provider_type: str | None = None,
) -> dict[str, Any]:
del cls, request_data, base_url, provider_type
return {
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 32,
}
monkeypatch.setattr(endpoint_checker_module, "run_endpoint_check", fake_run_endpoint_check)
monkeypatch.setattr(provider_auth_module, "get_provider_auth", fake_get_provider_auth)
monkeypatch.setattr(provider_transport_module, "build_provider_url", fake_build_provider_url)
monkeypatch.setattr(
ClaudeChatAdapter, "build_request_body", classmethod(fake_build_request_body)
)
endpoint = SimpleNamespace(id="ep-vertex-claude")
key = SimpleNamespace(id="key-vertex-claude", auth_type="service_account")
result = await ClaudeChatAdapter.check_endpoint(
client=None, # type: ignore[arg-type]
base_url="https://api.anthropic.com/v1",
api_key="ignored",
request_data={"model": "claude-3-7-sonnet@20250219", "stream": False},
provider_type="vertex_ai",
provider_endpoint=endpoint,
provider_api_key=key,
model_name="claude-3-7-sonnet@20250219",
)
assert result["status_code"] == 200
assert (
captured["url"]
== "https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/anthropic/models/claude-3-7-sonnet@20250219:rawPredict"
)
assert captured["build_provider_url"]["path_params"] == {"model": "claude-3-7-sonnet@20250219"}
assert captured["build_provider_url"]["is_stream"] is False
assert captured["build_provider_url"]["key"] is key
assert (
captured["build_provider_url"]["decrypted_auth_config"] == auth_info.decrypted_auth_config
)
assert captured["headers"]["Authorization"] == "Bearer vertex-token"