feat(provider): 完成阶段E协议适配、国内预设与模型路由
This commit is contained in:
@@ -10,6 +10,7 @@ export * as skillService from './skillService'
|
||||
export * as pluginService from './pluginService'
|
||||
export * as mcpServerService from './mcpServerService'
|
||||
export * as providerService from './providerService'
|
||||
export * as modelRoutingService from './modelRoutingService'
|
||||
export * as taskService from './taskService'
|
||||
export * as indexService from './indexService'
|
||||
export * as systemService from './systemService'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getModelRouting, saveModelRouting } from './modelRoutingService'
|
||||
|
||||
const config = { version: 7, embedding: { provider_id: 'p1', model: 'embedding', endpoint: '/embeddings', dimensions: 1024 }, transcription: null, speaker_matching: null }
|
||||
const result = { config, local_backends: [{ capability: 'embedding', status: 'placeholder', message: 'hash' }] }
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('model routing service', () => {
|
||||
it('round-trips versioned routing without an extra config wrapper', async () => {
|
||||
vi.mocked(fetch).mockImplementation(async () => json(result))
|
||||
expect(await getModelRouting()).toEqual(result)
|
||||
expect(await saveModelRouting(config)).toEqual(result)
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, '/api/model-routing', expect.objectContaining({ method: 'GET' }))
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, '/api/model-routing', expect.objectContaining({ method: 'PUT', body: JSON.stringify(config) }))
|
||||
})
|
||||
|
||||
it('surfaces load, save and version conflict errors instead of returning local defaults', async () => {
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(json({ error: { code: 'MODEL_ROUTING_VERSION_CONFLICT', message: 'conflict' } }, 409))
|
||||
.mockResolvedValueOnce(json({ error: { code: 'SAVE_FAILED', message: 'disk full' } }, 500))
|
||||
await expect(getModelRouting()).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
|
||||
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'MODEL_ROUTING_VERSION_CONFLICT' })
|
||||
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'SAVE_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ModelRoutingConfig, ModelRoutingResponse } from '@/contracts'
|
||||
import apiClient from './apiClient'
|
||||
|
||||
export function getModelRouting(): Promise<ModelRoutingResponse> {
|
||||
return apiClient.get('/api/model-routing')
|
||||
}
|
||||
|
||||
// version is the last version read from the server (optimistic concurrency).
|
||||
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
|
||||
return apiClient.put('/api/model-routing', config)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createProvider, listProviderPresets, putCredential, updateProvider } from './providerService'
|
||||
|
||||
const provider = { provider_id: 'provider-1', provider_type: 'openai_chat', name: 'Custom', capabilities: [], enabled: true }
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
|
||||
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('provider wire contracts', () => {
|
||||
it('retains preset logos, descriptions and capabilities', async () => {
|
||||
const preset = { preset_id: 'qwen', logo_id: 'qwen', description: '通义千问', capabilities: ['chat', 'embedding'] }
|
||||
vi.mocked(fetch).mockResolvedValue(json({ items: [preset] }))
|
||||
expect(await listProviderPresets()).toEqual([preset])
|
||||
})
|
||||
|
||||
it('persists protocol edits and explicit credential unlinking', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(json(provider))
|
||||
await updateProvider('provider-1', { provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||
expect(fetch).toHaveBeenCalledWith('/api/providers/provider-1', expect.objectContaining({ method: 'PATCH' }))
|
||||
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||
})
|
||||
|
||||
it('sends secrets only to credentials and a reference to provider configuration', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(json({ configured: true })).mockResolvedValueOnce(json(provider))
|
||||
await putCredential('provider-key-test', 'test-only-key')
|
||||
await createProvider({ name: 'Custom', provider_type: 'openai_compatible', default_model: '', enabled: true, credential_id: 'provider-key-test', has_credential: true, capabilities: {} })
|
||||
const calls = vi.mocked(fetch).mock.calls
|
||||
expect(calls[0][0]).toBe('/api/credentials/provider-key-test')
|
||||
expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ api_key: 'test-only-key' })
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ credential_id: 'provider-key-test' })
|
||||
expect(calls[1][1]?.body).not.toContain('test-only-key')
|
||||
expect(calls[1][1]?.body).not.toContain('has_credential')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset, ProviderUpdateRequest } from '@/contracts'
|
||||
|
||||
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
||||
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
||||
@@ -61,8 +61,9 @@ export async function putCredential(credentialId: string, apiKey: string): Promi
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
|
||||
export async function updateProvider(providerId: string, data: ProviderUpdateRequest): Promise<ProviderConfig> {
|
||||
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
||||
provider_type: data.provider_type,
|
||||
name: data.name,
|
||||
base_url: data.base_url,
|
||||
default_model: data.default_model,
|
||||
|
||||
Reference in New Issue
Block a user