feat(saas): add model groups for cross-provider failover
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

Model Groups provide logical model names that map to multiple physical
models across providers, with automatic failover when one provider's
key pool is exhausted.

Backend:
- New model_groups + model_group_members tables with FK constraints
- Full CRUD API (7 endpoints) with admin-only write permissions
- Cache layer: DashMap-backed CachedModelGroup with load_from_db
- Relay integration: ModelResolution enum for Direct/Group routing
- Cross-provider failover: sort_candidates_by_quota + OnceLock cache
- Relay failure path: record failure usage + relay_dequeue (fixes
  queue counter leak that caused connection pool exhaustion)
- add_group_member: validate model_id exists before insert

Frontend:
- saas-relay-client: accept getModel() callback for dynamic model selection
- connectionStore: prefer conversationStore.currentModel over first available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-04 09:56:21 +08:00
parent 9af7b0dd46
commit be0a78a523
11 changed files with 849 additions and 64 deletions

View File

@@ -492,12 +492,22 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
const kernelClient = getKernelClient();
// Use first available model (TODO: let user choose preferred model)
const relayModel = relayModels[0];
// Use first available model as fallback; prefer conversationStore.currentModel if set
const fallbackModel = relayModels[0];
// 优先使用 conversationStore 的 currentModel如果设置了的话
let preferredModel: string | undefined;
try {
const { useConversationStore } = require('./chat/conversationStore');
preferredModel = useConversationStore.getState().currentModel;
} catch {
// conversationStore 可能尚未初始化
}
const modelToUse = preferredModel || fallbackModel.id;
kernelClient.setConfig({
provider: 'custom',
model: relayModel.id,
model: modelToUse,
apiKey: session.token,
baseUrl: `${session.saasUrl}/api/v1/relay`,
apiProtocol: 'openai',
@@ -522,14 +532,23 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
set({ gatewayVersion: 'saas-relay', connectionState: 'connected' });
log.debug('Connected via SaaS relay (kernel backend):', {
model: relayModel.id,
model: modelToUse,
baseUrl: `${session.saasUrl}/api/v1/relay`,
});
} else {
// Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat
const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client');
const relayModelId = relayModels[0].id;
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, relayModelId);
const fallbackModelId = relayModels[0].id;
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => {
// 每次调用时读取 conversationStore 的 currentModelfallback 到第一个可用模型
try {
const { useConversationStore } = require('./chat/conversationStore');
const current = useConversationStore.getState().currentModel;
return current || fallbackModelId;
} catch {
return fallbackModelId;
}
});
set({
connectionState: 'connected',
@@ -540,7 +559,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
const { initializeStores } = await import('./index');
initializeStores();
log.debug('Connected to SaaS relay (browser mode)', { relayModel: relayModelId });
log.debug('Connected to SaaS relay (browser mode)', { relayModel: fallbackModelId });
}
return;
}