From 0e4be8d32857b6b303588acfeafa89186cf999df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:14:32 +0000 Subject: [PATCH 1/2] Fix registered local models missing from the chat picker. Registration now upserts the shared Ollama provider with usable defaults, turns the local service flag on, and notifies the conversation picker so desktop GGUF/Ollama tags can be selected and routed to 11434. Unreachable Ollama shows an actionable error instead of a silent no-op. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + dashboard/src/api/modules/localModels.ts | 1 + dashboard/src/locales/en.json | 3 +- dashboard/src/locales/zh.json | 3 +- .../Chat/hooks/useChatComposerResources.ts | 3 + .../components/LocalHardwarePanel.test.tsx | 29 ++++++- .../Models/components/LocalHardwarePanel.tsx | 48 ++++------- .../components/cards/LocalServiceCard.tsx | 52 ++++++------ dashboard/src/pages/Settings/Models/index.tsx | 2 +- .../pages/Settings/Models/modelsChanged.ts | 6 ++ .../pages/Settings/Models/presetUtils.test.ts | 41 ++++++++++ .../src/pages/Settings/Models/presetUtils.ts | 24 ++++-- dashboard/src/utils/chatStreamError.test.ts | 9 +++ dashboard/src/utils/chatStreamError.ts | 17 ++++ src/octop/api/routers/local_models.py | 80 ++++++++----------- src/octop/api/routers/ollama_models.py | 8 +- src/octop/i18n/domains/stream.py | 12 +++ src/octop/i18n/en.json | 3 +- src/octop/i18n/zh.json | 3 +- .../infra/agents/providers/local_register.py | 43 +++++++--- .../infra/agents/providers/model_flags.py | 21 +++++ src/octop/infra/agents/providers/resolved.py | 19 +++-- src/octop/infra/agents/providers/store.py | 71 +++++++++++----- tests/unit/agents/test_model_flags.py | 16 ++++ tests/unit/agents/test_provider_store.py | 17 ++++ tests/unit/i18n/test_stream.py | 14 ++++ tests/unit/test_local_register.py | 54 ++++++++++++- 27 files changed, 438 insertions(+), 162 deletions(-) create mode 100644 dashboard/src/pages/Settings/Models/modelsChanged.ts create mode 100644 dashboard/src/pages/Settings/Models/presetUtils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e051d2..ad1ac231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- 模型页注册的本机 Ollama / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。 - 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 - 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。 - 嵌入本机 openXYOS 登录不再因 CORS 漏掉 `http://127.0.0.1:3780` 而返回笼统的「服务器内部错误」。启动环境合并 sidecar 自源;无演示种子时写入本地 demo 账号,保证隔空第一台电脑能登录。 diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index e24b12c2..c6b72bf2 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -58,6 +58,7 @@ export interface LocalRuntimeResult { size?: number; source?: string; provider_name?: string; + registered?: boolean; } export interface LocalScanJob { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index caf52f49..2143c3ce 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1264,7 +1264,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "local_runtime": "The local Ollama runtime is not reachable. Start Ollama from Settings → Models, then retry. If it is already running, confirm it is listening on port 11434." }, "chat": { "sharedExpert": { diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 9cbe109e..ecd480ca 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1264,7 +1264,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "local_runtime": "本机 Ollama 未就绪或无法连接。请到「设置 → 模型」启动 Ollama 后再试。若已启动,请确认它在 11434 端口监听。" }, "chat": { "sharedExpert": { diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 6d172ca7..c8c2c0f2 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -10,6 +10,7 @@ import { } from "../../../api/modules/knowledgeBases"; import type { ResolvedModel } from "../../../api/types"; import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils"; +import { MODELS_CHANGED_EVENT } from "../../Settings/Models/modelsChanged"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { useAgent } from "../../../context/AgentContext"; import { activeModelToRef } from "./useChatContextWindow"; @@ -273,9 +274,11 @@ export function useChatComposerResources( loadModels(); const onFocus = () => loadModels(); window.addEventListener("focus", onFocus); + window.addEventListener(MODELS_CHANGED_EVENT, loadModels); return () => { cancelled = true; window.removeEventListener("focus", onFocus); + window.removeEventListener(MODELS_CHANGED_EVENT, loadModels); }; }, []); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 18314076..b6d3c834 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; const probe = vi.fn(); const startOllama = vi.fn(); @@ -76,9 +77,17 @@ beforeEach(() => { }); }); +function renderPanel(props: { onSaved?: () => void | Promise } = {}) { + return render( + + + , + ); +} + describe("", () => { it("shows Start Ollama when the binary exists but the API is down", async () => { - render(); + renderPanel(); await waitFor(() => expect(probe).toHaveBeenCalled()); expect(screen.getByText("models.localStartOllama")).toBeInTheDocument(); await userEvent.click(screen.getByText("models.localStartOllama")); @@ -86,16 +95,30 @@ describe("", () => { }); it("lists discovered GGUF files with a register action", async () => { - render(); + renderPanel(); await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument(); expect(screen.getByText("models.localRegister")).toBeInTheDocument(); }); it("starts a local weight search", async () => { - render(); + renderPanel(); await waitFor(() => expect(probe).toHaveBeenCalled()); await userEvent.click(screen.getByText("models.localSearchModels")); await waitFor(() => expect(startScan).toHaveBeenCalled()); }); + + it("notifies the chat picker after a successful register", async () => { + const onSaved = vi.fn(); + const heard = vi.fn(); + register.mockResolvedValue({ ok: true, name: "tiny" }); + window.addEventListener("octop:models-changed", heard); + renderPanel({ onSaved }); + await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); + await userEvent.click(screen.getByText("models.localRegister")); + await waitFor(() => expect(register).toHaveBeenCalled()); + expect(heard).toHaveBeenCalled(); + expect(onSaved).toHaveBeenCalled(); + window.removeEventListener("octop:models-changed", heard); + }); }); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index a430d65a..ddf40cfd 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -26,32 +26,10 @@ import { canPickDesktopFolder, pickDesktopFolder, } from "../../../../utils/desktopFolder"; -import { providerApi } from "../../../../api/modules/provider"; import { message } from "../../../../utils/antdMessage"; import { apiErrorMessage } from "../../../../utils/apiError"; import { CONVERSATION_LIST_PATH } from "../../../../layouts/conversationHome"; -import { isOllamaProviderRow } from "../presetUtils"; - -async function registerOllamaModel(name: string): Promise { - const providers = await providerApi.listProviders(); - const ollama = providers.find( - (row) => - row.id === "ollama" || - isOllamaProviderRow({ - name: row.name, - base_url: row.current_base_url, - api_key: row.current_api_key, - }), - ); - if (!ollama) return false; - const already = [ - ...(ollama.models ?? []), - ...(ollama.extra_models ?? []), - ].some((model) => model.id === name || model.name === name); - if (already) return true; - await providerApi.addModel(ollama.id, { id: name, name }); - return true; -} +import { notifyModelsChanged } from "../modelsChanged"; function mergeModels( ...groups: Array @@ -77,7 +55,11 @@ function canRegister(item: LocalInstalledModel): boolean { ); } -export function LocalHardwarePanel() { +export function LocalHardwarePanel({ + onSaved, +}: { + onSaved?: () => void | Promise; +} = {}) { const { t } = useTranslation(); const navigate = useNavigate(); const [probe, setProbe] = useState(null); @@ -199,18 +181,14 @@ export function LocalHardwarePanel() { await refresh(); return; } - let registered = false; - try { - registered = await registerOllamaModel(name); - } catch { - registered = false; - } + notifyModelsChanged(); + await onSaved?.(); message.success( t( - registered - ? "models.localInstallRegistered" - : "models.localInstallNeedProvider", - { name }, + result.registered === false + ? "models.localInstallNeedProvider" + : "models.localInstallRegistered", + { name: result.name || name }, ), ); await refresh(); @@ -338,6 +316,8 @@ export function LocalHardwarePanel() { showRuntimeError(result, t("models.localRegisterFailed")); return; } + notifyModelsChanged(); + await onSaved?.(); message.success(t("models.localRegisterDone", { name: result.name })); await refresh(); } catch (err) { diff --git a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx index 378b150e..5b07ab96 100644 --- a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx +++ b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx @@ -21,6 +21,7 @@ import type { ProviderPreset, ProviderRow, } from "../../useProviders"; +import { MODELS_CHANGED_EVENT } from "../../modelsChanged"; import { presetLogoId } from "../../presetUtils"; import { ProviderConfigModal } from "../modals/ProviderConfigModal"; import styles from "../../index.module.less"; @@ -71,36 +72,41 @@ export function LocalServiceCard({ useEffect(() => { let cancelled = false; - void (async () => { - try { - if (isOnnx) { - const st = await onnxModelApi.getStatus(); - if (!cancelled) { - setServiceEnabled(st.enabled); - setServiceRunning(st.ready || st.enabled); - setDepsAvailable(st.deps_available !== false); - setDepsInstallFailed(false); + const refreshService = () => { + void (async () => { + try { + if (isOnnx) { + const st = await onnxModelApi.getStatus(); + if (!cancelled) { + setServiceEnabled(st.enabled); + setServiceRunning(st.ready || st.enabled); + setDepsAvailable(st.deps_available !== false); + setDepsInstallFailed(false); + } + } else { + const st = await ollamaModelApi.getService(); + if (!cancelled) { + setServiceEnabled(st.enabled); + setServiceRunning(st.running); + setDepsAvailable(true); + setDepsInstallFailed(false); + } } - } else { - const st = await ollamaModelApi.getService(); + } catch { if (!cancelled) { - setServiceEnabled(st.enabled); - setServiceRunning(st.running); - setDepsAvailable(true); - setDepsInstallFailed(false); + setServiceEnabled(false); + setServiceRunning(false); } } - } catch { - if (!cancelled) { - setServiceEnabled(false); - setServiceRunning(false); - } - } - })(); + })(); + }; + refreshService(); + window.addEventListener(MODELS_CHANGED_EVENT, refreshService); return () => { cancelled = true; + window.removeEventListener(MODELS_CHANGED_EVENT, refreshService); }; - }, [isOnnx, provider?.id]); + }, [isOnnx, provider?.id, provider?.enabled]); const ensureProvider = async (): Promise => { if (row) return row; diff --git a/dashboard/src/pages/Settings/Models/index.tsx b/dashboard/src/pages/Settings/Models/index.tsx index 41d86d1e..9abbc945 100644 --- a/dashboard/src/pages/Settings/Models/index.tsx +++ b/dashboard/src/pages/Settings/Models/index.tsx @@ -276,7 +276,7 @@ export default function ModelsPage() { - {showLocalTab ? : null} + {showLocalTab ? : null} {showPresetSection && ( <> diff --git a/dashboard/src/pages/Settings/Models/modelsChanged.ts b/dashboard/src/pages/Settings/Models/modelsChanged.ts new file mode 100644 index 00000000..bf5336e3 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/modelsChanged.ts @@ -0,0 +1,6 @@ +export const MODELS_CHANGED_EVENT = "octop:models-changed"; + +export function notifyModelsChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(MODELS_CHANGED_EVENT)); +} diff --git a/dashboard/src/pages/Settings/Models/presetUtils.test.ts b/dashboard/src/pages/Settings/Models/presetUtils.test.ts new file mode 100644 index 00000000..e2e4d98c --- /dev/null +++ b/dashboard/src/pages/Settings/Models/presetUtils.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { findConfiguredProvider } from "./presetUtils"; +import type { ProviderPreset, ProviderRow } from "./useProviders"; + +const ollamaPreset = { + id: "ollama", + name: "Ollama (Local)", +} as ProviderPreset; + +function row( + partial: Partial & Pick, +): ProviderRow { + return { + id: 1, + kind: "openai", + base_url: null, + api_key: null, + models: [], + note: null, + enabled: true, + ...partial, + }; +} + +describe("findConfiguredProvider", () => { + it("matches the Ollama preset by display name or id", () => { + const named = row({ name: "Ollama (Local)" }); + expect(findConfiguredProvider(ollamaPreset, [named])).toEqual(named); + const short = row({ name: "ollama" }); + expect(findConfiguredProvider(ollamaPreset, [short])).toEqual(short); + }); + + it("matches a local Ollama row that only shares the 11434 endpoint", () => { + const local = row({ + name: "Desktop Llama", + base_url: "http://127.0.0.1:11434/v1", + api_key: "ollama", + }); + expect(findConfiguredProvider(ollamaPreset, [local])).toEqual(local); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index fdf7ca48..359b62be 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -65,13 +65,6 @@ export function presetLogoId(preset: ProviderPreset): string { return preset.logo_id || preset.id; } -export function findConfiguredProvider( - preset: ProviderPreset, - providers: ProviderRow[], -): ProviderRow | undefined { - return providers.find((p) => p.name === preset.name || p.name === preset.id); -} - export function groupPresets(presets: ProviderPreset[]): { grouped: PresetGroup[]; ungrouped: ProviderPreset[]; @@ -167,3 +160,20 @@ export function isLocalProviderRow(provider: { }): boolean { return isOnnxProviderRow(provider) || isOllamaProviderRow(provider); } + +export function findConfiguredProvider( + preset: ProviderPreset, + providers: ProviderRow[], +): ProviderRow | undefined { + const exact = providers.find( + (p) => p.name === preset.name || p.name === preset.id, + ); + if (exact) return exact; + if (preset.id === "ollama") { + return providers.find((p) => isOllamaProviderRow(p)); + } + if (preset.id === "onnx") { + return providers.find((p) => isOnnxProviderRow(p)); + } + return undefined; +} diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts index 2faf867c..4ca2ce53 100644 --- a/dashboard/src/utils/chatStreamError.test.ts +++ b/dashboard/src/utils/chatStreamError.test.ts @@ -47,6 +47,15 @@ describe("classifyChatStreamError", () => { }); }); + it("classifies an unreachable local Ollama runtime", () => { + const msg = "Failed to connect to ollama at http://127.0.0.1:11434"; + expect(classifyChatStreamError(msg)).toBe("stream_errors.local_runtime"); + expect(chatStreamErrorAction(msg)).toEqual({ + path: "/models", + labelKey: "models.useInChat", + }); + }); + it("classifies HTTP 5xx as provider_unavailable", () => { expect(classifyChatStreamError("HTTP 503: service overloaded")).toBe( "stream_errors.provider_unavailable", diff --git a/dashboard/src/utils/chatStreamError.ts b/dashboard/src/utils/chatStreamError.ts index 9773e0a2..7b5304ca 100644 --- a/dashboard/src/utils/chatStreamError.ts +++ b/dashboard/src/utils/chatStreamError.ts @@ -12,6 +12,7 @@ const _STREAM_ERROR_KEYS = [ "stream_errors.timeout_network", "stream_errors.provider_unavailable", "stream_errors.model_call_failed", + "stream_errors.local_runtime", ] as const; export type StreamErrorKey = (typeof _STREAM_ERROR_KEYS)[number]; @@ -35,6 +36,10 @@ const STREAM_ERROR_ACTIONS: Partial> = path: "/system-settings/agent-config", labelKey: "chat.goToAgentConfig", }, + "stream_errors.local_runtime": { + path: "/models", + labelKey: "models.useInChat", + }, }; function normalizeMessage(message: string): string { @@ -126,6 +131,18 @@ export function classifyChatStreamError( return "stream_errors.recursion_limit"; } + if ( + (lower.includes("11434") || lower.includes("ollama")) && + (lower.includes("connect") || + lower.includes("refused") || + lower.includes("unreachable") || + lower.includes("not running") || + lower.includes("connection") || + lower.includes("errno 111")) + ) { + return "stream_errors.local_runtime"; + } + if ( compact.includes("internalservererror") || lower.includes("bad gateway") || diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index 761d9092..16c94ce9 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import json import logging import subprocess from typing import Any @@ -13,14 +12,18 @@ from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.providers.local_probe import probe_local_models -from octop.infra.agents.providers.local_register import load_registered, register_local_weight +from octop.infra.agents.providers.local_register import ( + ensure_ollama_service_flag, + load_registered, + register_local_weight, + upsert_ollama_model, +) from octop.infra.agents.providers.local_scan import ( cancel_scan_job, get_scan_job, latest_scan_job, start_scan_job, ) -from octop.infra.agents.providers.model_flags import is_ollama_local_provider from octop.infra.agents.providers.ollama_install import ensure_ollama_runtime from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer @@ -137,37 +140,26 @@ async def local_models_ensure_deps( return await asyncio.to_thread(ensure_ollama_runtime, install=body.install) -def _register_ollama_model(server: OctopServer, name: str) -> bool: +def _register_installed_ollama(server: OctopServer, name: str) -> str: + """Persist *name* on the shared Ollama provider and mark the runtime on.""" services = getattr(server, "services", None) repo = getattr(services, "provider_repo", None) if services is not None else None if repo is None: - return False - model = {"id": name, "name": name, "enabled": True, "input": ["text"]} - row = None - for candidate in repo.list_all(): - if is_ollama_local_provider( - candidate.name, - provider_api_key=candidate.api_key, - provider_base_url=candidate.base_url, - ): - row = candidate - break - if row is None: - repo.create( - name="ollama", - kind="ollama", - base_url="http://127.0.0.1:11434/v1", - api_key="ollama", - models_json=json.dumps([model]), - note="Registered by one-click local model install", - ) - return True - models = row.get_models() - if any(str(item.get("id") or "") == name for item in models if isinstance(item, dict)): - return False - models.append(model) - repo.update(row.id, models_json=json.dumps(models)) - return True + raise OSError("provider store is not available") + provider_name = upsert_ollama_model(repo, name, name) + settings = getattr(services, "settings_repo", None) + if settings is not None: + ensure_ollama_service_flag(settings) + return provider_name + + +async def _reload_after_register(server: OctopServer, provider_name: str, model_name: str) -> None: + if server.app_runtime is None: + return + try: + await server.app_runtime.agent_registry.on_provider_changed(provider_name=provider_name) + except Exception as exc: + logger.warning("Registered %s but provider reload failed: %s", model_name, exc) @router.post("/install", summary="Download and register a recommended Ollama model") @@ -187,16 +179,19 @@ async def local_models_install( info = await asyncio.to_thread(OllamaModelManager.pull_model, body.name) except Exception as exc: raise register_failure(exc) from exc + model_name = info.name or body.name try: - registered = _register_ollama_model(server, info.name or body.name) + provider_name = _register_installed_ollama(server, model_name) except Exception as exc: raise register_failure(exc) from exc + await _reload_after_register(server, provider_name, model_name) return { "ok": True, - "name": info.name, + "name": model_name, "size": info.size, "source": "ollama", - "registered": registered, + "registered": True, + "provider_name": provider_name, } @@ -259,15 +254,10 @@ async def local_models_register( ) except _REGISTER_FAIL_TYPES as exc: raise register_failure(exc) from exc - if server.app_runtime is not None and result.get("ok"): - try: - await server.app_runtime.agent_registry.on_provider_changed( - provider_name=str(result.get("provider_name") or "Ollama (Local)") - ) - except Exception as exc: - logger.warning( - "Registered %s but provider reload failed: %s", - result.get("name"), - exc, - ) + if result.get("ok"): + await _reload_after_register( + server, + str(result.get("provider_name") or "Ollama (Local)"), + str(result.get("name") or body.name or ""), + ) return result diff --git a/src/octop/api/routers/ollama_models.py b/src/octop/api/routers/ollama_models.py index b987a804..4d42e2c8 100644 --- a/src/octop/api/routers/ollama_models.py +++ b/src/octop/api/routers/ollama_models.py @@ -24,6 +24,7 @@ get_tasks, update_status, ) +from octop.infra.agents.providers.model_flags import OLLAMA_SERVICE_SETTINGS_KEY logger = logging.getLogger(__name__) @@ -233,9 +234,6 @@ async def delete_ollama_model( return {"status": "deleted", "name": name} -_SETTINGS_KEY_OLLAMA_SERVICE = "ollama_service_enabled" - - class OllamaServiceBody(BaseModel): enabled: bool = Field(..., description="Whether Octop should keep the Ollama service running") @@ -246,7 +244,7 @@ class OllamaServiceStatus(BaseModel): def _ollama_service_enabled(server: Any) -> bool: - raw = server.services.settings_repo.get(_SETTINGS_KEY_OLLAMA_SERVICE) + raw = server.services.settings_repo.get(OLLAMA_SERVICE_SETTINGS_KEY) if raw is None: return False return raw.strip().lower() in {"1", "true", "yes", "on"} @@ -289,7 +287,7 @@ async def put_ollama_service( ) server.services.settings_repo.set( - _SETTINGS_KEY_OLLAMA_SERVICE, + OLLAMA_SERVICE_SETTINGS_KEY, "true" if body.enabled else "false", ) if body.enabled: diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index 05d4bb6c..4d627087 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -16,11 +16,13 @@ TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" PROVIDER_UNAVAILABLE = f"{_PREFIX}stream_errors.provider_unavailable" MODEL_CALL_FAILED = f"{_PREFIX}stream_errors.model_call_failed" +LOCAL_RUNTIME = f"{_PREFIX}stream_errors.local_runtime" __all__ = [ "AUTH", "CONTEXT_LENGTH", "INSUFFICIENT_BALANCE", + "LOCAL_RUNTIME", "MODEL_CALL_FAILED", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", @@ -118,6 +120,16 @@ def classify_stream_error_message(message: str) -> str | None: ): return RECURSION_LIMIT + if ("11434" in lower or "ollama" in lower) and ( + "connect" in lower + or "refused" in lower + or "unreachable" in lower + or "not running" in lower + or "connection" in lower + or "errno 111" in lower + ): + return LOCAL_RUNTIME + if ( "internalservererror" in compact or "bad gateway" in lower diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index e1cadd18..c6493991 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -596,7 +596,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "local_runtime": "The local Ollama runtime is not reachable. Start Ollama from Settings → Models, then retry. If it is already running, confirm it is listening on port 11434." }, "attachment": { "header": "[Attachment] {filename}", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index a8c5d1e7..28d9d639 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -596,7 +596,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "local_runtime": "本机 Ollama 未就绪或无法连接。请到「设置 → 模型」启动 Ollama 后再试。若已启动,请确认它在 11434 端口监听。" }, "attachment": { "header": "[附件] {filename}", diff --git a/src/octop/infra/agents/providers/local_register.py b/src/octop/infra/agents/providers/local_register.py index 6cb6c9c3..034057ba 100644 --- a/src/octop/infra/agents/providers/local_register.py +++ b/src/octop/infra/agents/providers/local_register.py @@ -8,7 +8,12 @@ from pathlib import Path from typing import Any -from octop.infra.agents.providers.model_flags import is_ollama_local_provider +from octop.infra.agents.providers.model_flags import ( + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_PROVIDER_DISPLAY_NAME, + OLLAMA_SERVICE_SETTINGS_KEY, + is_ollama_local_provider, +) from octop.infra.utils.ollama_manager import OllamaModelManager, create_from_weight logger = logging.getLogger(__name__) @@ -16,7 +21,6 @@ _SETTINGS_KEY = "local_registered_weights" # Keep Ollama tags (``llama3.2:1b``) while sanitizing Windows paths / file stems. _NAME_SAFE = re.compile(r"[^a-z0-9._:-]+") -_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1" _WEIGHT_SOURCES = frozenset({"gguf", "ggml"}) _REGISTER_SOURCES = frozenset({*_WEIGHT_SOURCES, "ollama"}) @@ -70,7 +74,16 @@ def _find_ollama_row(provider_repo: Any) -> Any | None: return None -def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str: +def ensure_ollama_service_flag(settings_repo: Any) -> None: + """Mark the Ollama runtime as enabled so chat / Models cards stay in sync.""" + setter = getattr(settings_repo, "set", None) + if not callable(setter): + return + setter(OLLAMA_SERVICE_SETTINGS_KEY, "true") + + +def upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str: + """Add *model_id* to the local Ollama provider and make it chat-usable.""" row = _find_ollama_row(provider_repo) model = { "id": model_id, @@ -81,19 +94,19 @@ def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str if row is None: try: provider_repo.create( - name="Ollama (Local)", - kind="ollama", - base_url=_OLLAMA_BASE_URL, + name=OLLAMA_PROVIDER_DISPLAY_NAME, + kind="openai", + base_url=OLLAMA_DEFAULT_BASE_URL, api_key="ollama", models_json=json.dumps([model]), ) - return "Ollama (Local)" + return OLLAMA_PROVIDER_DISPLAY_NAME except Exception as exc: existing = getattr(provider_repo, "get_by_name", None) - row = existing("Ollama (Local)") if callable(existing) else None + row = existing(OLLAMA_PROVIDER_DISPLAY_NAME) if callable(existing) else None if row is None: raise OSError(f"Could not save the Ollama provider: {exc}") from exc - models = row.get_models() + models = [item for item in row.get_models() if isinstance(item, dict)] existing = next((item for item in models if str(item.get("id") or "") == model_id), None) if existing is None: models.append(model) @@ -104,7 +117,9 @@ def _upsert_ollama_model(provider_repo: Any, model_id: str, display: str) -> str row.id, models_json=json.dumps(models), enabled=True, - base_url=row.base_url or _OLLAMA_BASE_URL, + kind=getattr(row, "kind", None) or "openai", + api_key=getattr(row, "api_key", None) or "ollama", + base_url=getattr(row, "base_url", None) or OLLAMA_DEFAULT_BASE_URL, ) return str(row.name) @@ -188,7 +203,7 @@ def register_local_weight( except Exception as exc: logger.warning("Ollama list after register failed for %s: %s", model_id, exc) try: - provider_name = _upsert_ollama_model(provider_repo, model_id, model_id) + provider_name = upsert_ollama_model(provider_repo, model_id, model_id) except Exception as exc: return _fail(f"Could not save the model provider: {exc}") entry = { @@ -204,6 +219,12 @@ def register_local_weight( remember_weight(settings_repo, entry) except Exception as exc: return _fail(f"Could not remember the registered model: {exc}") + try: + ensure_ollama_service_flag(settings_repo) + except Exception as exc: + logger.warning( + "Registered %s but could not enable the Ollama service flag: %s", model_id, exc + ) return { "ok": True, "action": "registered", diff --git a/src/octop/infra/agents/providers/model_flags.py b/src/octop/infra/agents/providers/model_flags.py index e654abab..3718b490 100644 --- a/src/octop/infra/agents/providers/model_flags.py +++ b/src/octop/infra/agents/providers/model_flags.py @@ -11,6 +11,9 @@ # Same pattern for Ollama (placeholder api_key + exact preset names). _OLLAMA_PRESET_API_KEY = "ollama" _OLLAMA_PRESET_NAMES = frozenset({"ollama", "ollama (local)"}) +OLLAMA_PROVIDER_DISPLAY_NAME = "Ollama (Local)" +OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1" +OLLAMA_SERVICE_SETTINGS_KEY = "ollama_service_enabled" def is_onnx_local_provider( @@ -52,6 +55,24 @@ def is_ollama_local_provider( return "11434" in url or "ollama" in url +def local_ollama_endpoint( + provider_name: str | None = None, + *, + provider_api_key: str | None = None, + provider_base_url: str | None = None, +) -> tuple[str | None, str | None]: + """Return ``(api_key, base_url)``, filling Ollama defaults for local rows.""" + api_key = (provider_api_key or "").strip() or None + base_url = (provider_base_url or "").strip() or None + if is_ollama_local_provider( + provider_name, + provider_api_key=api_key, + provider_base_url=base_url, + ): + return api_key or _OLLAMA_PRESET_API_KEY, base_url or OLLAMA_DEFAULT_BASE_URL + return api_key, base_url + + def is_local_runtime_provider( provider_name: str | None = None, *, diff --git a/src/octop/infra/agents/providers/resolved.py b/src/octop/infra/agents/providers/resolved.py index 3f09a535..8389db80 100644 --- a/src/octop/infra/agents/providers/resolved.py +++ b/src/octop/infra/agents/providers/resolved.py @@ -4,7 +4,10 @@ from typing import Any -from octop.infra.agents.providers.model_flags import is_chat_eligible_model +from octop.infra.agents.providers.model_flags import ( + is_chat_eligible_model, + local_ollama_endpoint, +) from octop.infra.agents.providers.reasoning import reasoning_capability @@ -16,14 +19,20 @@ def list_resolved_models(providers: list[Any]) -> list[dict[str, Any]]: """ resolved: list[dict[str, Any]] = [] for provider in providers: - if not provider.enabled or not provider.api_key: + if not provider.enabled: continue provider_name = str(getattr(provider, "name", "") or "") provider_api_key = str(getattr(provider, "api_key", "") or "") or None + provider_base_url = str(getattr(provider, "base_url", "") or "") or None + api_key, _base_url = local_ollama_endpoint( + provider_name, + provider_api_key=provider_api_key, + provider_base_url=provider_base_url, + ) + if not api_key: + continue for m in provider.get_models(): - if not is_chat_eligible_model( - m, provider_name=provider_name, provider_api_key=provider_api_key - ): + if not is_chat_eligible_model(m, provider_name=provider_name, provider_api_key=api_key): continue # Total context drives the UI ring; prompt and output caps remain # separately available for runtime budgeting and model settings. diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index e419b241..7e7e1ba7 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -8,7 +8,12 @@ from harness_agent.config import ModelConfig, ProviderConfig -from octop.infra.agents.providers.model_flags import is_chat_eligible_model, is_vision_model +from octop.infra.agents.providers.model_flags import ( + is_chat_eligible_model, + is_ollama_local_provider, + is_vision_model, + local_ollama_endpoint, +) from octop.infra.agents.providers.opencode_session import ( OPENCODE_SESSION_HEADER, is_opencode_go_base_url, @@ -111,18 +116,44 @@ def __init__( ) -> None: self._provider_repo = provider_repo + def _row_credentials(self, row: Any) -> tuple[str | None, str | None]: + return local_ollama_endpoint( + getattr(row, "name", None), + provider_api_key=getattr(row, "api_key", None), + provider_base_url=getattr(row, "base_url", None), + ) + + def _row_for_model_ref(self, ref: str) -> tuple[Any | None, str]: + """Resolve ``provider/model``; local aliases like ``ollama/`` map to the Ollama row.""" + provider_name, _, model_id = ref.partition("/") + if not provider_name or not model_id: + return None, model_id + row = self._provider_repo.get_by_name(provider_name) + if row is not None: + return row, model_id + if is_ollama_local_provider(provider_name): + for candidate in self._provider_repo.list_all(): + if is_ollama_local_provider( + candidate.name, + provider_api_key=candidate.api_key, + provider_base_url=candidate.base_url, + ): + return candidate, model_id + return None, model_id + def iter_usable_rows(self) -> Iterator[Any]: """Yield enabled DB providers that have credentials and at least one enabled model.""" for row in self._provider_repo.list_all(): if not row.enabled: continue - if not row.base_url or not row.api_key: + api_key, base_url = self._row_credentials(row) + if not base_url or not api_key: continue models = row.get_models() if not models: continue if any( - is_chat_eligible_model(m, provider_name=row.name, provider_api_key=row.api_key) + is_chat_eligible_model(m, provider_name=row.name, provider_api_key=api_key) for m in models ): yield row @@ -135,11 +166,12 @@ def build_harness_configs(self) -> list[ProviderConfig]: out: list[ProviderConfig] = [] for row in self.iter_usable_rows(): protocol = KIND_TO_PROTOCOL.get(row.kind, "openai") + api_key, base_url = self._row_credentials(row) raw_models = json.loads(row.models_json) if getattr(row, "models_json", None) else [] models = [ self._model_config_from_row(m) for m in raw_models - if is_chat_eligible_model(m, provider_name=row.name, provider_api_key=row.api_key) + if is_chat_eligible_model(m, provider_name=row.name, provider_api_key=api_key) ] if not models: continue @@ -154,14 +186,14 @@ def build_harness_configs(self) -> list[ProviderConfig]: out.append( ProviderConfig( id=row.name, - base_url=row.base_url, - api_key=row.api_key, + base_url=base_url, + api_key=api_key, protocol=protocol, # type: ignore[arg-type] name=row.name, models=models, headers=headers, session_header=( - OPENCODE_SESSION_HEADER if is_opencode_go_base_url(row.base_url) else None + OPENCODE_SESSION_HEADER if is_opencode_go_base_url(base_url) else None ), ) ) @@ -183,13 +215,13 @@ def is_model_ref_multimodal(self, ref: str) -> bool: ref = ref.strip() if not ref or "/" not in ref: return False - provider_name, _, model_id = ref.partition("/") - row = self._provider_repo.get_by_name(provider_name) - if row is None: + row, model_id = self._row_for_model_ref(ref) + if row is None or not model_id: return False + api_key, _base_url = self._row_credentials(row) for model in row.get_models(): if model.get("id") == model_id and is_chat_eligible_model( - model, provider_name=provider_name, provider_api_key=row.api_key + model, provider_name=row.name, provider_api_key=api_key ): return _model_dict_supports_image(model) return False @@ -239,27 +271,24 @@ def is_model_ref_usable(self, ref: str) -> bool: ref = ref.strip() if not ref or ref.lower() == "auto" or "/" not in ref: return False - provider_name, _, model_id = ref.partition("/") - if not provider_name or not model_id: + row, model_id = self._row_for_model_ref(ref) + if row is None or not row.enabled or not model_id: return False - row = self._provider_repo.get_by_name(provider_name) - if row is None or not row.enabled or not row.api_key or not row.base_url: + api_key, base_url = self._row_credentials(row) + if not api_key or not base_url: return False for model in row.get_models(): if model.get("id") != model_id: continue - return is_chat_eligible_model( - model, provider_name=provider_name, provider_api_key=row.api_key - ) + return is_chat_eligible_model(model, provider_name=row.name, provider_api_key=api_key) return False def get_model_reasoning_capability(self, ref: str) -> dict[str, Any] | None: """Return normalized reasoning metadata for a usable model ref.""" if not self.is_model_ref_usable(ref): return None - provider_name, _, model_id = ref.partition("/") - row = self._provider_repo.get_by_name(provider_name) - if row is None: + row, model_id = self._row_for_model_ref(ref) + if row is None or not model_id: return None for model in row.get_models(): if model.get("id") == model_id and model.get("enabled", True): diff --git a/tests/unit/agents/test_model_flags.py b/tests/unit/agents/test_model_flags.py index fa497566..903d9f69 100644 --- a/tests/unit/agents/test_model_flags.py +++ b/tests/unit/agents/test_model_flags.py @@ -127,3 +127,19 @@ def test_list_resolved_models_skips_embedding() -> None: assert resolved[0]["max_input_tokens"] == 120_000 assert resolved[0]["max_output_tokens"] == 16_384 assert resolved[0]["max_tokens"] == 16_384 + + +def test_list_resolved_models_includes_ollama_without_api_key() -> None: + providers = [ + SimpleNamespace( + id=1, + name="Ollama (Local)", + kind="openai", + enabled=True, + api_key="", + base_url="", + get_models=lambda: [{"id": "tiny", "name": "tiny", "enabled": True}], + ) + ] + resolved = list_resolved_models(providers) + assert [(r["provider_name"], r["model"]) for r in resolved] == [("Ollama (Local)", "tiny")] diff --git a/tests/unit/agents/test_provider_store.py b/tests/unit/agents/test_provider_store.py index df4b3183..b14af7c6 100644 --- a/tests/unit/agents/test_provider_store.py +++ b/tests/unit/agents/test_provider_store.py @@ -322,6 +322,23 @@ def test_resolve_first_model_ref_returns_first_enabled_model(store: ProviderStor assert store.resolve_first_model_ref() == "p1/ready" +def test_ollama_defaults_make_local_ref_usable(store: ProviderStore) -> None: + store._provider_repo.create( + name="Ollama (Local)", + kind="openai", + base_url="http://127.0.0.1:11434/v1", + api_key="", + models_json=json.dumps([{"id": "tiny", "name": "tiny", "enabled": True}]), + ) + assert store.is_model_ref_usable("Ollama (Local)/tiny") is True + assert store.is_model_ref_usable("ollama/tiny") is True + configs = store.build_harness_configs() + assert configs[0].id == "Ollama (Local)" + assert configs[0].base_url == "http://127.0.0.1:11434/v1" + assert configs[0].api_key == "ollama" + assert [model.id for model in configs[0].models] == ["tiny"] + + def test_resolve_multimodal_model_ref_prefers_inferred_vision_model(store: ProviderStore) -> None: store._provider_repo.create( name="p", diff --git a/tests/unit/i18n/test_stream.py b/tests/unit/i18n/test_stream.py index 9e756757..53af13ad 100644 --- a/tests/unit/i18n/test_stream.py +++ b/tests/unit/i18n/test_stream.py @@ -3,6 +3,7 @@ from __future__ import annotations from octop.i18n.domains.stream import ( + LOCAL_RUNTIME, MODEL_CALL_FAILED, RECURSION_LIMIT, STREAM_STALL, @@ -90,6 +91,19 @@ def test_classify_recursion_limit() -> None: ) +def test_classify_local_runtime() -> None: + assert ( + classify_stream_error_message("Failed to connect to ollama at http://127.0.0.1:11434") + == LOCAL_RUNTIME + ) + assert classify_stream_error_message("Connection refused [Errno 111] 127.0.0.1:11434") == ( + LOCAL_RUNTIME + ) + text = format_stream_error("Failed to connect to ollama", "zh") + assert "Ollama" in text + assert "11434" in text + + def test_classify_model_call_failed_fallback() -> None: assert ( classify_stream_error_message("Model call failed after 3 attempts with RuntimeError: boom") diff --git a/tests/unit/test_local_register.py b/tests/unit/test_local_register.py index 505efdb6..35ca7386 100644 --- a/tests/unit/test_local_register.py +++ b/tests/unit/test_local_register.py @@ -44,19 +44,34 @@ def _models() -> list[dict[str, object]]: return data if isinstance(data, list) else [] row = SimpleNamespace( - id=1, + id=len(self.rows) + 1, name=kwargs["name"], kind=kwargs["kind"], base_url=kwargs.get("base_url"), api_key=kwargs.get("api_key"), + enabled=kwargs.get("enabled", True), models_json=models_json, get_models=_models, ) self.rows.append(row) - return 1 + return row.id def update(self, provider_id: int, **kwargs: object) -> None: - del provider_id, kwargs + row = next((item for item in self.rows if item.id == provider_id), None) + if row is None: + return + for key, value in kwargs.items(): + setattr(row, key, value) + models_json = kwargs.get("models_json") + if isinstance(models_json, str): + + def _models() -> list[dict[str, object]]: + import json + + data = json.loads(models_json) + return data if isinstance(data, list) else [] + + row.get_models = _models def test_sanitize_model_name() -> None: @@ -111,6 +126,7 @@ def _list() -> list[SimpleNamespace]: assert providers.rows[0].name == "Ollama (Local)" assert providers.rows[0].base_url == "http://127.0.0.1:11434/v1" assert "tiny.gguf" in settings.data.get("local_registered_weights", "") + assert settings.data.get("ollama_service_enabled") == "true" def test_register_ollama_source_skips_create(monkeypatch: pytest.MonkeyPatch) -> None: @@ -223,6 +239,38 @@ def _boom() -> list[object]: assert created == [("tiny", "/tmp/tiny.gguf")] +def test_register_enables_existing_local_service_row(monkeypatch: pytest.MonkeyPatch) -> None: + import json + + monkeypatch.setattr( + "octop.infra.agents.providers.local_register.OllamaModelManager.list_models", + lambda: [SimpleNamespace(name="llama3.2:1b", size=99)], + ) + providers = _Providers() + providers.create( + name="Ollama (Local)", + kind="openai", + base_url="http://127.0.0.1:11434/v1", + api_key="ollama", + models_json=json.dumps([{"id": "preset", "name": "preset", "enabled": False}]), + enabled=False, + ) + settings = _Settings() + result = register_local_weight( + path="", + name="llama3.2:1b", + source="ollama", + size=99, + provider_repo=providers, + settings_repo=settings, + ) + assert result["ok"] is True + assert providers.rows[0].enabled is True + models = providers.rows[0].get_models() + assert any(item.get("id") == "llama3.2:1b" and item.get("enabled") for item in models) + assert settings.data.get("ollama_service_enabled") == "true" + + def test_remember_weight_keys_ollama_rows_by_name() -> None: settings = _Settings() remember_weight(settings, {"name": "a", "path": "", "source": "ollama"}) From ca26b52895c85da0f232b93d4505420197583471 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:15:52 +0000 Subject: [PATCH 2/2] Narrow Ollama credential types for harness ProviderConfig. Co-authored-by: XYAI Labs --- src/octop/infra/agents/providers/store.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index 7e7e1ba7..611985e7 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -167,6 +167,8 @@ def build_harness_configs(self) -> list[ProviderConfig]: for row in self.iter_usable_rows(): protocol = KIND_TO_PROTOCOL.get(row.kind, "openai") api_key, base_url = self._row_credentials(row) + if not api_key or not base_url: + continue raw_models = json.loads(row.models_json) if getattr(row, "models_json", None) else [] models = [ self._model_config_from_row(m)