refactor(ollama): address P1 review feedback

- Remove dead getOllamaModelsWithFiltering function (redundant /api/tags call)
- Remove unused OllamaModelsResult interface
- Replace brittle regex-based translateMessage with structured message codes
  from the backend (messageCode + messageParams in ExtensionMessage)
- Fix i18n plural keys: convert nested {one, other} objects to standard
  i18next _one/_other suffix format across all 18 locale files
- Remove manual plural safeguard in TranslationContext.tsx (root cause fixed)
- Add OllamaConnectionTestResult type with messageCode/messageParams fields
This commit is contained in:
Roo Code 2026-02-20 18:05:25 +00:00
parent f46a3ebe79
commit 609fbac98e
23 changed files with 99 additions and 229 deletions

View file

@ -149,6 +149,8 @@ export interface ExtensionMessage {
}>
modelsWithoutTools?: string[]
message?: string
messageCode?: string
messageParams?: Record<string, string>
durationMs?: number
lmStudioModels?: ModelRecord
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]

View file

@ -186,10 +186,12 @@ export interface OllamaModelWithTools {
modelInfo: OllamaExtendedModelInfo
}
export interface OllamaModelsResult {
modelsWithTools: Record<string, OllamaExtendedModelInfo>
modelsWithoutTools: string[]
totalCount: number
export interface OllamaConnectionTestResult {
success: boolean
message: string
messageCode: string
messageParams: Record<string, string>
durationMs?: number
}
export interface OllamaModelsDiscoveryResult {
@ -300,56 +302,6 @@ export async function getOllamaModels(
return models
}
export async function getOllamaModelsWithFiltering(
baseUrl = "http://localhost:11434",
apiKey?: string,
config?: {
timeout?: number
modelDiscoveryTimeout?: number
maxRetries?: number
retryDelay?: number
enableLogging?: boolean
},
): Promise<OllamaModelsResult> {
const modelsWithTools = await getOllamaModels(baseUrl, apiKey, config)
const allModelNames = new Set<string>()
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
try {
if (URL.canParse(baseUrl)) {
const axiosInstance = createOllamaAxiosInstance({
baseUrl,
apiKey,
timeout: config?.modelDiscoveryTimeout ?? config?.timeout ?? 10000,
retries: config?.maxRetries ?? 0,
retryDelay: config?.retryDelay ?? 1000,
enableLogging: config?.enableLogging ?? false,
})
const response = await axiosInstance.get<OllamaModelsResponse>("/api/tags")
const parsedResponse = OllamaModelsResponseSchema.safeParse(response.data)
if (parsedResponse.success) {
for (const ollamaModel of parsedResponse.data.models) {
allModelNames.add(ollamaModel.name)
}
}
}
} catch (error: any) {
console.warn(`Failed to fetch all model names: ${error.message}`)
}
const modelsWithToolsNames = new Set(Object.keys(modelsWithTools))
const modelsWithoutTools = Array.from(allModelNames).filter((name) => !modelsWithToolsNames.has(name))
return {
modelsWithTools,
modelsWithoutTools,
totalCount: allModelNames.size,
}
}
export async function discoverOllamaModelsWithSorting(
baseUrl = "http://localhost:11434",
apiKey?: string,
@ -535,7 +487,7 @@ export async function testOllamaConnection(
timeout?: number
enableLogging?: boolean
},
): Promise<{ success: boolean; message: string; durationMs?: number }> {
): Promise<OllamaConnectionTestResult> {
baseUrl = baseUrl === "" ? "http://localhost:11434" : baseUrl
const startTime = Date.now()
@ -544,6 +496,8 @@ export async function testOllamaConnection(
return {
success: false,
message: `Invalid URL: ${baseUrl}`,
messageCode: "connectionInvalidUrl",
messageParams: { baseUrl },
durationMs: Date.now() - startTime,
}
}
@ -572,6 +526,8 @@ export async function testOllamaConnection(
return {
success: true,
message: `Successfully connected to Ollama at ${baseUrl}`,
messageCode: "connectionSuccess",
messageParams: { baseUrl },
durationMs,
}
} catch (error: any) {
@ -592,24 +548,32 @@ export async function testOllamaConnection(
return {
success: false,
message: `Cannot connect to Ollama at ${baseUrl}. Make sure Ollama is running.`,
messageCode: "connectionRefused",
messageParams: { baseUrl },
durationMs,
}
} else if (error?.code === "ETIMEDOUT" || error?.code === "ECONNABORTED") {
return {
success: false,
message: `Connection to Ollama timed out. Check if the URL is correct and Ollama is accessible.`,
messageCode: "connectionTimeout",
messageParams: {},
durationMs,
}
} else if (error?.code === "ERR_NETWORK") {
return {
success: false,
message: `Network error connecting to Ollama. Check your network connection.`,
messageCode: "connectionNetworkError",
messageParams: {},
durationMs,
}
} else if (error?.response) {
return {
success: false,
message: `Ollama returned error: ${error.response.status} ${error.response.statusText}`,
messageCode: "connectionHttpError",
messageParams: { status: String(error.response.status), statusText: error.response.statusText },
durationMs,
}
}
@ -617,6 +581,8 @@ export async function testOllamaConnection(
return {
success: false,
message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
messageCode: "connectionFailed",
messageParams: { error: error instanceof Error ? error.message : String(error) },
durationMs,
}
}

View file

@ -1040,6 +1040,8 @@ export const webviewMessageHandler = async (
type: "ollamaConnectionTestResult",
success: result.success,
message: result.message,
messageCode: result.messageCode,
messageParams: result.messageParams,
durationMs: result.durationMs,
})
} catch (error) {
@ -1047,6 +1049,8 @@ export const webviewMessageHandler = async (
type: "ollamaConnectionTestResult",
success: false,
message: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
messageCode: "connectionTestError",
messageParams: { error: error instanceof Error ? error.message : String(error) },
})
}
break
@ -1112,6 +1116,11 @@ export const webviewMessageHandler = async (
type: "ollamaModelsRefreshResult",
success: true,
message: `Found ${result.modelsWithTools.length} model(s) with tools support (${result.totalCount} total)`,
messageCode: "refreshSuccess",
messageParams: {
count: String(result.modelsWithTools.length),
total: String(result.totalCount),
},
durationMs,
modelsWithoutTools: result.modelsWithoutTools,
})
@ -1120,6 +1129,8 @@ export const webviewMessageHandler = async (
type: "ollamaModelsRefreshResult",
success: false,
message: "No models found. Make sure Ollama is running and has models installed.",
messageCode: "refreshNoModels",
messageParams: {},
durationMs,
modelsWithoutTools: [],
})
@ -1141,6 +1152,8 @@ export const webviewMessageHandler = async (
type: "ollamaModelsRefreshResult",
success: false,
message: `Failed to refresh models: ${error instanceof Error ? error.message : String(error)}`,
messageCode: "refreshFailed",
messageParams: { error: error instanceof Error ? error.message : String(error) },
durationMs,
})
}

View file

@ -68,82 +68,20 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
[setApiConfigurationField],
)
// Helper function to translate backend messages
// Translate backend messages using structured message codes.
// Falls back to the raw message string if no code is provided.
const translateMessage = useCallback(
(msg: string): string => {
if (!msg) return msg
// Successfully connected to Ollama at {url}
const successMatch = msg.match(/^Successfully connected to Ollama at (.+)$/)
if (successMatch) {
return t("settings:providers.ollama.messages.connectionSuccess", { baseUrl: successMatch[1] })
(msg: string, messageCode?: string, messageParams?: Record<string, string>): string => {
if (messageCode) {
const i18nKey = `settings:providers.ollama.messages.${messageCode}`
const translated = t(i18nKey, messageParams)
// If the key was found (translation differs from the key), use it
if (translated !== i18nKey) {
return translated
}
}
// Invalid URL: {url}
const invalidUrlMatch = msg.match(/^Invalid URL: (.+)$/)
if (invalidUrlMatch) {
return t("settings:providers.ollama.messages.connectionInvalidUrl", { baseUrl: invalidUrlMatch[1] })
}
// Cannot connect to Ollama at {url}. Make sure Ollama is running.
const refusedMatch = msg.match(/^Cannot connect to Ollama at (.+)\. Make sure Ollama is running\.$/)
if (refusedMatch) {
return t("settings:providers.ollama.messages.connectionRefused", { baseUrl: refusedMatch[1] })
}
// Connection to Ollama timed out. Check if the URL is correct and Ollama is accessible.
if (msg.includes("Connection to Ollama timed out")) {
return t("settings:providers.ollama.messages.connectionTimeout")
}
// Network error connecting to Ollama. Check your network connection.
if (msg.includes("Network error connecting to Ollama")) {
return t("settings:providers.ollama.messages.connectionNetworkError")
}
// Ollama returned error: {status} {statusText}
const httpErrorMatch = msg.match(/^Ollama returned error: (\d+) (.+)$/)
if (httpErrorMatch) {
return t("settings:providers.ollama.messages.connectionHttpError", {
status: httpErrorMatch[1],
statusText: httpErrorMatch[2],
})
}
// Failed to connect: {error}
const failedMatch = msg.match(/^Failed to connect: (.+)$/)
if (failedMatch) {
return t("settings:providers.ollama.messages.connectionFailed", { error: failedMatch[1] })
}
// Error testing connection: {error}
const testErrorMatch = msg.match(/^Error testing connection: (.+)$/)
if (testErrorMatch) {
return t("settings:providers.ollama.messages.connectionTestError", { error: testErrorMatch[1] })
}
// Found {count} model(s) with tools support ({total} total)
const refreshSuccessMatch = msg.match(/^Found (\d+) model\(s\) with tools support \((\d+) total\)$/)
if (refreshSuccessMatch) {
return t("settings:providers.ollama.messages.refreshSuccess", {
count: refreshSuccessMatch[1],
total: refreshSuccessMatch[2],
})
}
// No models found. Make sure Ollama is running and has models installed.
if (msg === "No models found. Make sure Ollama is running and has models installed.") {
return t("settings:providers.ollama.messages.refreshNoModels")
}
// Failed to refresh models: {error}
const refreshFailedMatch = msg.match(/^Failed to refresh models: (.+)$/)
if (refreshFailedMatch) {
return t("settings:providers.ollama.messages.refreshFailed", { error: refreshFailedMatch[1] })
}
// Unknown message - return as is
return msg
// Fallback to raw message if no code or key not found
return msg || ""
},
[t],
)
@ -166,7 +104,11 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
case "ollamaConnectionTestResult":
setTestResult({
success: message.success ?? false,
message: translateMessage(message.message ?? "Unknown error"),
message: translateMessage(
message.message ?? "Unknown error",
message.messageCode,
message.messageParams,
),
durationMs: message.durationMs,
})
setTestingConnection(false)
@ -178,7 +120,11 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro
case "ollamaModelsRefreshResult":
setRefreshResult({
success: message.success ?? false,
message: translateMessage(message.message ?? "Unknown error"),
message: translateMessage(
message.message ?? "Unknown error",
message.messageCode,
message.messageParams,
),
durationMs: message.durationMs,
})
setRefreshingModels(false)

View file

@ -35,28 +35,7 @@ export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ childre
// Memoize the translation function to prevent unnecessary re-renders
const translate = useCallback(
(key: string, options?: Record<string, any>) => {
const result = i18n.t(key, options)
// Safeguard: ensure we always return a string, not an object
// This handles cases where plural objects might not be resolved correctly
if (typeof result === "object" && result !== null) {
// Type guard for plural object
const pluralResult = result as Record<string, any>
// If it's a plural object and we have a count, try to resolve it
if (options?.count !== undefined && "one" in pluralResult && "other" in pluralResult) {
const count = options.count
// Use i18next's pluralization logic
if (count === 1 && typeof pluralResult.one === "string") {
return pluralResult.one
}
if (typeof pluralResult.other === "string") {
return pluralResult.other
}
}
// Fallback: return the key if we can't resolve it
console.warn(`Translation key "${key}" returned an object instead of string:`, result)
return key
}
return result as string
return i18n.t(key, options) as string
},
[i18n],
)

View file

@ -486,10 +486,8 @@
"connectionSettings": "Configuració de connexió",
"toolsSupport": "Suport d'eines",
"noToolsSupport": "Sense suport d'eines",
"models": {
"one": "model",
"other": "models"
},
"models_one": "model",
"models_other": "models",
"noToolsSupportHelp": "Aquests models no admeten crides a eines natives i no es poden utilitzar amb Roo Code. Es mostren només com a referència.",
"table": {
"modelName": "Nom del model",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Verbindungseinstellungen",
"toolsSupport": "Tools-Unterstützung",
"noToolsSupport": "Keine Tools-Unterstützung",
"models": {
"one": "Modell",
"other": "Modelle"
},
"models_one": "Modell",
"models_other": "Modelle",
"noToolsSupportHelp": "Diese Modelle unterstützen keine nativen Tool-Aufrufe und können nicht mit Roo Code verwendet werden. Sie werden nur zur Referenz angezeigt.",
"table": {
"modelName": "Modellname",

View file

@ -549,10 +549,8 @@
"connectionSettings": "Connection Settings",
"toolsSupport": "Tools Support",
"noToolsSupport": "No Tools Support",
"models": {
"one": "model",
"other": "models"
},
"models_one": "model",
"models_other": "models",
"noToolsSupportHelp": "These models do not support native tool calling and cannot be used with Roo Code. They are shown for reference only.",
"table": {
"modelName": "Model Name",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Configuración de conexión",
"toolsSupport": "Soporte de herramientas",
"noToolsSupport": "Sin soporte de herramientas",
"models": {
"one": "modelo",
"other": "modelos"
},
"models_one": "modelo",
"models_other": "modelos",
"noToolsSupportHelp": "Estos modelos no admiten llamadas a herramientas nativas y no se pueden usar con Roo Code. Se muestran solo como referencia.",
"table": {
"modelName": "Nombre del modelo",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Paramètres de connexion",
"toolsSupport": "Support des outils",
"noToolsSupport": "Pas de support des outils",
"models": {
"one": "modèle",
"other": "modèles"
},
"models_one": "modèle",
"models_other": "modèles",
"noToolsSupportHelp": "Ces modèles ne prennent pas en charge les appels d'outils natifs et ne peuvent pas être utilisés avec Roo Code. Ils sont affichés uniquement à titre de référence.",
"table": {
"modelName": "Nom du modèle",

View file

@ -486,10 +486,8 @@
"connectionSettings": "कनेक्शन सेटिंग्स",
"toolsSupport": "टूल्स सपोर्ट",
"noToolsSupport": "कोई टूल्स सपोर्ट नहीं",
"models": {
"one": "मॉडल",
"other": "मॉडल"
},
"models_one": "मॉडल",
"models_other": "मॉडल",
"noToolsSupportHelp": "ये मॉडल नेटिव टूल कॉलिंग का समर्थन नहीं करते हैं और Roo Code के साथ उपयोग नहीं किए जा सकते हैं। वे केवल संदर्भ के लिए दिखाए गए हैं।",
"table": {
"modelName": "मॉडल नाम",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Pengaturan koneksi",
"toolsSupport": "Dukungan alat",
"noToolsSupport": "Tidak ada dukungan alat",
"models": {
"one": "model",
"other": "model"
},
"models_one": "model",
"models_other": "model",
"noToolsSupportHelp": "Model-model ini tidak mendukung pemanggilan alat native dan tidak dapat digunakan dengan Roo Code. Mereka ditampilkan hanya sebagai referensi.",
"table": {
"modelName": "Nama model",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Impostazioni di connessione",
"toolsSupport": "Supporto strumenti",
"noToolsSupport": "Nessun supporto strumenti",
"models": {
"one": "modello",
"other": "modelli"
},
"models_one": "modello",
"models_other": "modelli",
"noToolsSupportHelp": "Questi modelli non supportano le chiamate a strumenti native e non possono essere utilizzati con Roo Code. Sono mostrati solo come riferimento.",
"table": {
"modelName": "Nome modello",

View file

@ -486,10 +486,8 @@
"connectionSettings": "接続設定",
"toolsSupport": "ツールサポート",
"noToolsSupport": "ツールサポートなし",
"models": {
"one": "モデル",
"other": "モデル"
},
"models_one": "モデル",
"models_other": "モデル",
"noToolsSupportHelp": "これらのモデルはネイティブツール呼び出しをサポートしておらず、Roo Codeでは使用できません。参考としてのみ表示されます。",
"table": {
"modelName": "モデル名",

View file

@ -486,10 +486,8 @@
"connectionSettings": "연결 설정",
"toolsSupport": "도구 지원",
"noToolsSupport": "도구 지원 없음",
"models": {
"one": "모델",
"other": "모델"
},
"models_one": "모델",
"models_other": "모델",
"noToolsSupportHelp": "이 모델들은 네이티브 도구 호출을 지원하지 않으며 Roo Code와 함께 사용할 수 없습니다. 참고용으로만 표시됩니다.",
"table": {
"modelName": "모델 이름",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Verbindingsinstellingen",
"toolsSupport": "Toolondersteuning",
"noToolsSupport": "Geen toolondersteuning",
"models": {
"one": "model",
"other": "modellen"
},
"models_one": "model",
"models_other": "modellen",
"noToolsSupportHelp": "Deze modellen ondersteunen geen native tool-aanroepen en kunnen niet worden gebruikt met Roo Code. Ze worden alleen ter referentie weergegeven.",
"table": {
"modelName": "Modelnaam",

View file

@ -486,12 +486,10 @@
"connectionSettings": "Ustawienia połączenia",
"toolsSupport": "Obsługa narzędzi",
"noToolsSupport": "Brak obsługi narzędzi",
"models": {
"one": "model",
"few": "modele",
"many": "modeli",
"other": "modeli"
},
"models_one": "model",
"models_few": "modele",
"models_many": "modeli",
"models_other": "modeli",
"noToolsSupportHelp": "Te modele nie obsługują natywnych wywołań narzędzi i nie mogą być używane z Roo Code. Są wyświetlane tylko w celach informacyjnych.",
"table": {
"modelName": "Nazwa modelu",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Configurações de conexão",
"toolsSupport": "Suporte a ferramentas",
"noToolsSupport": "Sem suporte a ferramentas",
"models": {
"one": "modelo",
"other": "modelos"
},
"models_one": "modelo",
"models_other": "modelos",
"noToolsSupportHelp": "Esses modelos não suportam chamadas de ferramentas nativas e não podem ser usados com Roo Code. Eles são mostrados apenas como referência.",
"table": {
"modelName": "Nome do modelo",

View file

@ -486,12 +486,10 @@
"connectionSettings": "Настройки подключения",
"toolsSupport": "Поддержка инструментов",
"noToolsSupport": "Без поддержки инструментов",
"models": {
"one": "модель",
"few": "модели",
"many": "моделей",
"other": "моделей"
},
"models_one": "модель",
"models_few": "модели",
"models_many": "моделей",
"models_other": "моделей",
"noToolsSupportHelp": "Эти модели не поддерживают нативные вызовы инструментов и не могут использоваться с Roo Code. Они отображаются только для справки.",
"table": {
"modelName": "Название модели",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Bağlantı ayarları",
"toolsSupport": "Araç desteği",
"noToolsSupport": "Araç desteği yok",
"models": {
"one": "model",
"other": "modeller"
},
"models_one": "model",
"models_other": "modeller",
"noToolsSupportHelp": "Bu modeller yerel araç çağrılarını desteklemez ve Roo Code ile kullanılamaz. Yalnızca referans olarak gösterilirler.",
"table": {
"modelName": "Model adı",

View file

@ -486,10 +486,8 @@
"connectionSettings": "Cài đặt kết nối",
"toolsSupport": "Hỗ trợ công cụ",
"noToolsSupport": "Không hỗ trợ công cụ",
"models": {
"one": "mô hình",
"other": "mô hình"
},
"models_one": "mô hình",
"models_other": "mô hình",
"noToolsSupportHelp": "Các mô hình này không hỗ trợ gọi công cụ gốc và không thể sử dụng với Roo Code. Chúng chỉ được hiển thị để tham khảo.",
"table": {
"modelName": "Tên mô hình",

View file

@ -486,10 +486,8 @@
"connectionSettings": "连接设置",
"toolsSupport": "工具支持",
"noToolsSupport": "无工具支持",
"models": {
"one": "模型",
"other": "模型"
},
"models_one": "模型",
"models_other": "模型",
"noToolsSupportHelp": "这些模型不支持原生工具调用,无法与 Roo Code 一起使用。它们仅作为参考显示。",
"table": {
"modelName": "模型名称",

View file

@ -496,10 +496,8 @@
"connectionSettings": "連線設定",
"toolsSupport": "工具支援",
"noToolsSupport": "無工具支援",
"models": {
"one": "模型",
"other": "模型"
},
"models_one": "模型",
"models_other": "模型",
"noToolsSupportHelp": "這些模型不支援原生工具呼叫,無法與 Roo Code 一起使用。它們僅作為參考顯示。",
"table": {
"modelName": "模型名稱",