Merge pull request #74 from Chiron-Brahm/main

fix: 修复模型注册表重复ID导致的唯一约束冲突
This commit is contained in:
coso
2026-01-07 11:00:19 +08:00
committed by GitHub
11 changed files with 277 additions and 92 deletions
+1
View File
@@ -20,3 +20,4 @@ Thumbs.db
# Kiro
.kiro/
.history
+26
View File
@@ -53,6 +53,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.9.1
version: 2.9.1
'@tauri-apps/plugin-deep-link':
specifier: ^2.4.5
version: 2.4.5
'@tauri-apps/plugin-dialog':
specifier: ^2.4.2
version: 2.4.2
@@ -1243,56 +1246,67 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@@ -1416,30 +1430,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.9.6':
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
@@ -1464,6 +1483,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-deep-link@2.4.5':
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
'@tauri-apps/plugin-dialog@2.4.2':
resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==}
@@ -4308,6 +4330,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
'@tauri-apps/plugin-deep-link@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-dialog@2.4.2':
dependencies:
'@tauri-apps/api': 2.9.1
@@ -180,6 +180,10 @@ fn extract_supported_models(
"gemini-3-flash-preview".to_string(),
]
}
CredentialData::IFlowOAuth { .. } | CredentialData::IFlowCookie { .. } => {
// iFlow 是 DeepSeek 的代理服务
vec!["deepseek-chat".to_string(), "deepseek-reasoner".to_string()]
}
_ => vec![],
}
}
@@ -194,6 +198,7 @@ fn map_pool_provider_type(pool_type: &str) -> ProviderType {
"codex" => ProviderType::OpenAI,
"qwen" => ProviderType::Custom,
"antigravity" => ProviderType::Antigravity,
"iflow" | "deepseek" => ProviderType::Custom, // DeepSeek 及其代理 iFlow
_ => ProviderType::Custom,
}
}
@@ -248,6 +248,18 @@ impl ModelRegistryService {
}
}
// 去重:使用 HashMap 按 id 去重,保留第一个出现的模型
let mut seen_ids = std::collections::HashSet::new();
let original_count = models.len();
models.retain(|m| seen_ids.insert(m.id.clone()));
if models.len() < original_count {
tracing::warn!(
"[ModelRegistry] 发现 {} 个重复 ID,已去重",
original_count - models.len()
);
}
// 按 provider_id 和 display_name 排序
models.sort_by(|a, b| {
a.provider_id
@@ -429,58 +441,60 @@ impl ModelRegistryService {
/// 保存模型到数据库
async fn save_models_to_db(&self, models: &[EnhancedModelMetadata]) -> Result<(), String> {
let conn = self.db.lock().map_err(|e| e.to_string())?;
let mut conn = self.db.lock().map_err(|e| e.to_string())?;
// 开始事务
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| e.to_string())?;
// 使用 rusqlite 的事务 API
let tx = conn.transaction().map_err(|e| e.to_string())?;
// 清空现有数据
conn.execute("DELETE FROM model_registry", [])
tx.execute("DELETE FROM model_registry", [])
.map_err(|e| e.to_string())?;
// 插入新数据
let mut stmt = conn
.prepare(
"INSERT INTO model_registry (
id, display_name, provider_id, provider_name, family, tier,
capabilities, pricing, limits, status, release_date, is_latest,
description, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.map_err(|e| e.to_string())?;
// 插入新数据(使用 INSERT OR REPLACE 处理可能的重复 ID)
{
let mut stmt = tx
.prepare(
"INSERT OR REPLACE INTO model_registry (
id, display_name, provider_id, provider_name, family, tier,
capabilities, pricing, limits, status, release_date, is_latest,
description, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.map_err(|e| e.to_string())?;
for model in models {
let capabilities_json = serde_json::to_string(&model.capabilities).unwrap_or_default();
let pricing_json = model
.pricing
.as_ref()
.map(|p| serde_json::to_string(p).unwrap_or_default());
let limits_json = serde_json::to_string(&model.limits).unwrap_or_default();
for model in models {
let capabilities_json =
serde_json::to_string(&model.capabilities).unwrap_or_default();
let pricing_json = model
.pricing
.as_ref()
.map(|p| serde_json::to_string(p).unwrap_or_default());
let limits_json = serde_json::to_string(&model.limits).unwrap_or_default();
stmt.execute(params![
model.id,
model.display_name,
model.provider_id,
model.provider_name,
model.family,
model.tier.to_string(),
capabilities_json,
pricing_json,
limits_json,
model.status.to_string(),
model.release_date,
model.is_latest as i32,
model.description,
model.source.to_string(),
model.created_at,
model.updated_at,
])
.map_err(|e| e.to_string())?;
stmt.execute(params![
model.id,
model.display_name,
model.provider_id,
model.provider_name,
model.family,
model.tier.to_string(),
capabilities_json,
pricing_json,
limits_json,
model.status.to_string(),
model.release_date,
model.is_latest as i32,
model.description,
model.source.to_string(),
model.created_at,
model.updated_at,
])
.map_err(|e| e.to_string())?;
}
}
// 提交事务
conn.execute("COMMIT", []).map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
tracing::info!("[ModelRegistry] 保存了 {} 个模型到数据库", models.len());
@@ -46,6 +46,8 @@ function getTestModel(provider: string): string {
return "gpt-4o";
case "claude":
return "claude-sonnet-4-20250514";
case "deepseek":
return "deepseek-chat";
case "kiro":
default:
return "claude-opus-4-5-20251101";
@@ -446,6 +446,8 @@ export function ApiServerPage() {
return "gpt-4o";
case "claude":
return "claude-sonnet-4-20250514";
case "deepseek":
return "deepseek-chat";
case "kiro":
default:
return "claude-opus-4-5-20251101";
+72 -20
View File
@@ -4,7 +4,7 @@
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
*/
import { useState } from "react";
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import {
Cpu,
RefreshCw,
@@ -40,14 +40,33 @@ export function EnhancedModelsTab() {
} = useModelRegistry();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedTier, setSelectedTier] = useState<ModelTier | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [displayLimit, setDisplayLimit] = useState(50); // 初始显示 50 个
// 防抖搜索
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value);
}, 150);
}, []);
// 当筛选条件变化时,重置显示数量
useEffect(() => {
setDisplayLimit(50);
}, [debouncedSearchQuery, selectedProvider, selectedTier, showFavoritesOnly]);
// 搜索和过滤
const filteredModels = (() => {
let result = searchQuery ? search(searchQuery) : models;
const filteredModels = useMemo(() => {
let result = debouncedSearchQuery ? search(debouncedSearchQuery) : models;
if (selectedProvider) {
result = result.filter((m) => m.provider_id === selectedProvider);
}
@@ -58,9 +77,28 @@ export function EnhancedModelsTab() {
result = result.filter((m) => preferences.get(m.id)?.is_favorite);
}
return result;
})();
}, [
debouncedSearchQuery,
models,
selectedProvider,
selectedTier,
showFavoritesOnly,
preferences,
search,
]);
const providers = Array.from(groupedByProvider.keys());
// 分页显示的模型
const displayedModels = useMemo(() => {
return filteredModels.slice(0, displayLimit);
}, [filteredModels, displayLimit]);
const hasMore = filteredModels.length > displayLimit;
// 缓存 providers 列表,避免每次渲染都重新计算
const providers = useMemo(
() => Array.from(groupedByProvider.keys()),
[groupedByProvider],
);
const copyModelId = (id: string) => {
navigator.clipboard.writeText(id);
@@ -106,7 +144,7 @@ export function EnhancedModelsTab() {
type="text"
placeholder="搜索模型名称、ID、Provider..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
@@ -195,7 +233,9 @@ export function EnhancedModelsTab() {
<div className="flex items-center justify-between">
<span className="font-medium">模型列表</span>
<span className="text-sm text-muted-foreground">
{filteredModels.length} 个模型
{hasMore
? `显示 ${displayedModels.length} / ${filteredModels.length} 个模型`
: `${filteredModels.length} 个模型`}
</span>
</div>
</div>
@@ -210,19 +250,31 @@ export function EnhancedModelsTab() {
<p>暂无模型数据</p>
</div>
) : (
<div className="divide-y max-h-[600px] overflow-y-auto">
{filteredModels.map((model) => (
<ModelRow
key={model.id}
model={model}
isFavorite={preferences.get(model.id)?.is_favorite || false}
usageCount={preferences.get(model.id)?.usage_count || 0}
copied={copied === model.id}
onCopy={() => copyModelId(model.id)}
onToggleFavorite={() => toggleFavorite(model.id)}
/>
))}
</div>
<>
<div className="divide-y max-h-[600px] overflow-y-auto">
{displayedModels.map((model) => (
<ModelRow
key={model.id}
model={model}
isFavorite={preferences.get(model.id)?.is_favorite || false}
usageCount={preferences.get(model.id)?.usage_count || 0}
copied={copied === model.id}
onCopy={() => copyModelId(model.id)}
onToggleFavorite={() => toggleFavorite(model.id)}
/>
))}
</div>
{hasMore && (
<div className="border-t px-4 py-3 flex justify-center">
<button
onClick={() => setDisplayLimit((prev) => prev + 50)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
加载更多 (还有 {filteredModels.length - displayLimit} 个)
</button>
</div>
)}
</>
)}
</div>
@@ -9,14 +9,16 @@ import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import { Eye, Wrench, Brain, Sparkles, Loader2 } from "lucide-react";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { mapProviderTypeToRegistryId } from "./providerTypeMapping";
import { mapProviderIdToRegistryId } from "./providerTypeMapping";
// ============================================================================
// 类型定义
// ============================================================================
export interface ProviderModelListProps {
/** Provider 类型,如 "anthropic", "openai", "gemini" */
/** Provider ID,如 "deepseek", "openai", "anthropic" */
providerId: string;
/** Provider 类型(API 协议),如 "anthropic", "openai", "gemini" */
providerType: string;
/** 额外的 CSS 类名 */
className?: string;
@@ -104,14 +106,15 @@ const ModelItem: React.FC<ModelItemProps> = ({ model }) => {
* ```
*/
export const ProviderModelList: React.FC<ProviderModelListProps> = ({
providerId,
providerType,
className,
maxItems,
}) => {
// 转换 Provider 类型为 registry ID
// 转换 Provider ID 为 registry ID(优先使用 providerId,回退到 providerType)
const registryProviderId = useMemo(
() => mapProviderTypeToRegistryId(providerType),
[providerType],
() => mapProviderIdToRegistryId(providerId, providerType),
[providerId, providerType],
);
// 获取模型数据
@@ -238,7 +238,10 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
{/* 支持的模型列表 */}
<section data-testid="supported-models-section">
<ProviderModelList providerType={provider.type} />
<ProviderModelList
providerId={provider.id}
providerType={provider.type}
/>
</section>
</div>
</div>
@@ -1,26 +1,96 @@
/*
* @Author: Chiron 598621670@qq.com
* @Date: 2026-01-06 17:34:03
* @LastEditors: Chiron 598621670@qq.com
* @LastEditTime: 2026-01-07 00:53:05
* @FilePath: /proxycast/src/components/provider-pool/api-key/providerTypeMapping.ts
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
/**
* @file Provider 类型映射工具
* @description Provider 类型到 model_registry provider_id 的映射
* @description Provider ID/类型到 model_registry provider_id 的映射
* @module components/provider-pool/api-key/providerTypeMapping
*/
/**
* Provider 类型到 model_registry provider_id 的映射
* Provider ID 到 model_registry provider_id 的映射
* 用于将系统 Provider ID(如 deepseek, moonshot)映射到模型注册表中的 provider_id
*/
const PROVIDER_ID_TO_REGISTRY_ID: Record<string, string> = {
// 主流 AI
openai: "openai",
anthropic: "anthropic",
gemini: "google",
deepseek: "deepseek",
moonshot: "moonshot",
groq: "groq",
grok: "grok",
mistral: "mistral",
perplexity: "perplexity",
cohere: "cohere",
// 国内 AI
zhipu: "zhipu",
baichuan: "baichuan",
dashscope: "dashscope",
doubao: "doubao",
minimax: "minimax",
stepfun: "stepfun",
lingyi: "lingyi",
baidu: "baidu",
hunyuan: "hunyuan",
spark: "spark",
// 云服务
"azure-openai": "openai",
vertexai: "google",
"aws-bedrock": "anthropic",
// 代理服务
iflow: "deepseek", // iFlow 是 DeepSeek 的代理
// 其他
ollama: "ollama",
together: "together",
fireworks: "fireworks",
replicate: "replicate",
};
/**
* Provider 类型(API 协议)到 model_registry provider_id 的映射
* 作为 Provider ID 映射的回退
*/
const PROVIDER_TYPE_TO_REGISTRY_ID: Record<string, string> = {
anthropic: "anthropic",
openai: "openai",
"openai-response": "openai",
gemini: "google",
"azure-openai": "openai",
vertexai: "google",
"aws-bedrock": "anthropic",
ollama: "ollama",
"new-api": "custom",
gateway: "custom",
};
/**
* 将 Provider ID 转换为 model_registry 的 provider_id
* 优先使用 Provider ID 映射,回退到 Provider Type 映射
*
* @param providerId Provider ID(如 "deepseek", "openai")
* @param providerType Provider 类型/API 协议(如 "openai", "anthropic")
* @returns model_registry 中的 provider_id
*/
export function mapProviderIdToRegistryId(
providerId: string,
providerType?: string,
): string {
// 优先使用 Provider ID 映射
if (PROVIDER_ID_TO_REGISTRY_ID[providerId]) {
return PROVIDER_ID_TO_REGISTRY_ID[providerId];
}
// 回退到 Provider Type 映射
if (providerType && PROVIDER_TYPE_TO_REGISTRY_ID[providerType]) {
return PROVIDER_TYPE_TO_REGISTRY_ID[providerType];
}
// 最后回退到原始 providerId
return providerId;
}
/**
* @deprecated 使用 mapProviderIdToRegistryId 代替
* 将 Provider 类型转换为 model_registry 的 provider_id
*/
export function mapProviderTypeToRegistryId(providerType: string): string {
+23 -16
View File
@@ -100,36 +100,43 @@ function fuzzySearch(
.map((model) => {
let score = 0;
// 精确匹配 ID
// 精确匹配 ID(最高优先级)
if (model.id.toLowerCase() === queryLower) {
score += 100;
score += 1000;
} else if (model.id.toLowerCase().startsWith(queryLower)) {
// ID 以搜索词开头
score += 500;
} else if (model.id.toLowerCase().includes(queryLower)) {
score += 50;
score += 100;
}
// 显示名称匹配
if (model.display_name.toLowerCase().includes(queryLower)) {
score += 30;
if (model.display_name.toLowerCase().startsWith(queryLower)) {
score += 80;
} else if (model.display_name.toLowerCase().includes(queryLower)) {
score += 40;
}
// Provider 匹配
if (model.provider_name.toLowerCase().includes(queryLower)) {
score += 20;
if (model.provider_id.toLowerCase() === queryLower) {
score += 200;
} else if (model.provider_name.toLowerCase().includes(queryLower)) {
score += 30;
}
// 家族匹配
if (model.family?.toLowerCase().includes(queryLower)) {
score += 15;
score += 20;
}
// 最新版本加分
if (model.is_latest) {
score += 5;
}
// 活跃状态加分
if (model.status === "active") {
score += 3;
// 只有在有匹配的情况下,才给最新版本和活跃状态加分
if (score > 0) {
if (model.is_latest) {
score += 5;
}
if (model.status === "active") {
score += 3;
}
}
return { model, score };