Merge pull request #419 from wendaochangsheng/aether-rust-pioneer

修复 Vertex AI 服务账号 JSON 导入入口
This commit is contained in:
fawney19
2026-05-10 09:27:22 +08:00
committed by GitHub
2 changed files with 133 additions and 7 deletions

View File

@@ -32,7 +32,24 @@
data-1p-ignore="true" data-1p-ignore="true"
/> />
</div> </div>
<div> <div v-if="showAuthTypeSelector">
<Label :for="authTypeSelectId">认证类型</Label>
<Select v-model="form.auth_type">
<SelectTrigger :id="authTypeSelectId">
<SelectValue placeholder="选择认证类型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in authTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div :class="showAuthTypeSelector ? 'col-span-2' : undefined">
<Label :for="apiKeyInputId"> <Label :for="apiKeyInputId">
{{ authSecretLabel }} {{ authSecretLabel }}
{{ authSecretRequiredMark }} {{ authSecretRequiredMark }}
@@ -360,7 +377,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui' import {
Dialog,
Button,
Input,
Label,
Switch,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { Key, SquarePen, CircleHelp } from 'lucide-vue-next' import { Key, SquarePen, CircleHelp } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
@@ -527,6 +555,7 @@ function getDefaultApiFormats(): string[] {
const visibleApiFormats = computed(() => getSelectableApiFormats()) const visibleApiFormats = computed(() => getSelectableApiFormats())
const authTypeOptions = computed(() => getAuthTypeOptions(props.providerType)) const authTypeOptions = computed(() => getAuthTypeOptions(props.providerType))
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
const apiFormatHelpOpen = ref(false) const apiFormatHelpOpen = ref(false)
const apiFormatHelpHovered = ref(false) const apiFormatHelpHovered = ref(false)
@@ -677,6 +706,7 @@ const saving = ref(false)
const formNonce = ref(createFieldNonce()) const formNonce = ref(createFieldNonce())
const keyNameInputId = computed(() => `key-name-${formNonce.value}`) const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`) const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`) const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`) const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)

View File

@@ -19,7 +19,8 @@ vi.mock('@/api/endpoints', () => ({
})) }))
vi.mock('@/components/ui', async () => { vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue') const { defineComponent, h, inject, provide } = await import('vue')
const SelectContextKey = Symbol('SelectContext')
const passthrough = (name: string, tag = 'div') => defineComponent({ const passthrough = (name: string, tag = 'div') => defineComponent({
name, name,
@@ -104,17 +105,56 @@ vi.mock('@/components/ui', async () => {
}, },
}) })
const Select = defineComponent({
name: 'SelectStub',
props: {
modelValue: String,
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
provide(SelectContextKey, {
select: (value: string) => emit('update:modelValue', value),
modelValue: props.modelValue,
})
return () => h('div', {
'data-select': 'true',
'data-value': props.modelValue,
}, slots.default?.())
},
})
const SelectItem = defineComponent({
name: 'SelectItemStub',
inheritAttrs: false,
props: {
value: {
type: String,
required: true,
},
},
setup(props, { attrs, slots }) {
const context = inject<{ select: (value: string) => void } | null>(SelectContextKey, null)
return () => h('button', {
...attrs,
type: 'button',
'data-select-item': props.value,
onClick: () => context?.select(props.value),
}, slots.default?.())
},
})
return { return {
Dialog, Dialog,
Button, Button,
Input, Input,
Label, Label,
Switch, Switch,
Select: passthrough('SelectStub'), Select,
SelectTrigger: passthrough('SelectTriggerStub'), SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub', 'span'), SelectValue: passthrough('SelectValueStub', 'span'),
SelectContent: passthrough('SelectContentStub'), SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'), SelectItem,
} }
}) })
@@ -124,8 +164,18 @@ vi.mock('@/components/common/JsonImportInput.vue', async () => {
return { return {
default: defineComponent({ default: defineComponent({
name: 'JsonImportInputStub', name: 'JsonImportInputStub',
setup() { props: {
return () => h('textarea') modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('textarea', {
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
})
}, },
}), }),
} }
@@ -222,6 +272,11 @@ function updateInput(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event('input', { bubbles: true })) input.dispatchEvent(new Event('input', { bubbles: true }))
} }
function updateTextarea(textarea: HTMLTextAreaElement, value: string) {
textarea.value = value
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
async function submit(root: HTMLElement) { async function submit(root: HTMLElement) {
const form = root.querySelector('form') const form = root.querySelector('form')
expect(form).not.toBeNull() expect(form).not.toBeNull()
@@ -254,6 +309,47 @@ afterEach(() => {
}) })
describe('provider key concurrent_limit form behavior', () => { describe('provider key concurrent_limit form behavior', () => {
it('lets Vertex AI keys switch to Service Account JSON and submit auth_config', async () => {
const root = mountDialog(KeyFormDialog, {
open: true,
endpoint: null,
editingKey: null,
providerId: 'provider-vertex',
providerType: 'vertex_ai',
availableApiFormats: ['gemini:generate_content', 'claude:messages'],
})
await settle()
const serviceAccountOption = root.querySelector<HTMLButtonElement>('[data-select-item="service_account"]')
expect(serviceAccountOption).not.toBeNull()
serviceAccountOption?.click()
await settle()
const nameInput = root.querySelector<HTMLInputElement>('input[placeholder="例如:主 Key、备用 Key 1"]')
expect(nameInput).not.toBeNull()
updateInput(nameInput as HTMLInputElement, 'Vertex service account')
const textarea = root.querySelector<HTMLTextAreaElement>('textarea')
expect(textarea).not.toBeNull()
updateTextarea(textarea as HTMLTextAreaElement, JSON.stringify({
client_email: 'svc@example.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\\nTEST\\n-----END PRIVATE KEY-----\\n',
project_id: 'demo-project',
}))
await submit(root)
expect(endpointMocks.addProviderKey).toHaveBeenCalledWith('provider-vertex', expect.objectContaining({
auth_type: 'service_account',
auth_config: expect.objectContaining({
client_email: 'svc@example.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\\nTEST\\n-----END PRIVATE KEY-----\\n',
project_id: 'demo-project',
}),
api_formats: ['gemini:generate_content'],
}))
})
it('hydrates and serializes a positive concurrent_limit number from the normal key form', async () => { it('hydrates and serializes a positive concurrent_limit number from the normal key form', async () => {
const root = mountDialog(KeyFormDialog, { const root = mountDialog(KeyFormDialog, {
open: true, open: true,