From c48823a31490d2845c766abcc25e2584cb515e4a Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 6 Nov 2026 15:28:50 +0800 Subject: [PATCH 1/2] refactor: use live models.dev and pinned user connections --- .../packages/devo-ai-sdk/src/v2/client.ts | 4 +- .../connection-detail-dialog.test.tsx | 31 +- .../settings/connection-detail-dialog.tsx | 61 +- .../components/settings/model-edit-dialog.tsx | 3 +- .../lib/invalidate-provider-queries.ts | 2 +- apps/tui/lib/ai/README.md | 14 +- apps/tui/lib/ai/src/models.generated.ts | 22846 ------------ apps/tui/lib/ai/src/models.ts | 36 +- apps/tui/src/devo-debrand.test.ts | 14 +- apps/tui/src/generated/protocol/native.ts | 10 +- apps/tui/src/generated/protocol/schema.json | 84 +- apps/tui/src/host.ts | 5 +- apps/tui/src/model-catalog-refresh.test.ts | 4 +- apps/tui/src/model-catalog-refresh.ts | 9 +- apps/tui/src/native-model-sources.test.ts | 39 + .../core-concepts/models-and-providers.mdx | 35 +- .../core-concepts/models-and-providers.zh.mdx | 25 +- crates/config/src/app.rs | 17 +- crates/config/src/provider/catalog.rs | 35 +- crates/config/src/provider/runtime.rs | 50 +- crates/config/src/provider_connection.rs | 235 +- .../config/src/provider_connection_tests.rs | 202 + crates/config/src/tests.rs | 4 +- crates/core/README.md | 94 +- crates/core/providers.json | 30517 ---------------- crates/core/src/model_catalog.rs | 557 +- crates/core/src/remote_catalog.rs | 1002 +- crates/protocol/src/provider_catalog.rs | 6 + crates/provider/src/router.rs | 17 +- crates/server/src/bootstrap.rs | 29 +- crates/server/src/execution.rs | 9 +- crates/server/src/lib.rs | 2 + crates/server/src/provider_config.rs | 230 +- crates/server/src/provider_config_tests.rs | 167 + crates/server/src/runtime/catalog_refresh.rs | 48 +- crates/server/src/runtime/connection_tests.rs | 62 +- .../runtime/handlers/acp_config_options.rs | 7 +- crates/server/src/runtime/model_api.rs | 49 +- crates/server/src/runtime/provider_api.rs | 75 +- .../server/src/runtime/provider_discovery.rs | 89 +- .../src/runtime/provider_persistence_tests.rs | 8 +- .../src/runtime/session_actor/actor_loop.rs | 7 +- crates/server/src/session_context.rs | 71 +- crates/server/tests/model_config_e2e.rs | 46 +- .../tests/session_compaction_threshold.rs | 14 +- docs/user-guide.md | 81 +- scripts/import-pi-ai-catalog.mjs | 339 - 47 files changed, 1936 insertions(+), 55355 deletions(-) delete mode 100644 apps/tui/lib/ai/src/models.generated.ts create mode 100644 apps/tui/src/native-model-sources.test.ts create mode 100644 crates/config/src/provider_connection_tests.rs delete mode 100644 crates/core/providers.json create mode 100644 crates/server/src/provider_config_tests.rs delete mode 100644 scripts/import-pi-ai-catalog.mjs diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index bbf9d81b..a64b77d7 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -1486,9 +1486,7 @@ class NativeClient { }, discover: async (params: ProviderDiscoverParams): Promise => { const result = (await this.requestCanonical("provider/discover", params)) as ProviderDiscoverResult - // Discover mutates the connection model directory; composer selectors - // read model/preferences which must not keep a pre-discover snapshot. - this.invalidateConfigOptionCaches() + // Discovery is read-only. Callers may explicitly save selected results. return result }, auth: async () => ({ data: [] }), diff --git a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.test.tsx b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.test.tsx index fe72c31b..958157f1 100644 --- a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.test.tsx +++ b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.test.tsx @@ -25,14 +25,16 @@ mock.module("@devo/ui/components/dialog", () => ({ })) mock.module("./provider-icon", () => ({ ProviderIcon: () => null })) mock.module("./model-edit-dialog", () => ({ ModelEditDialog: () => null })) -const discover = mock(async (_params: unknown) => {}) +const discoveredModels = { fresh: { name: "Fresh model", wireApi: "openai_responses" as const, baseUrl: "https://gateway.example/v1", requestModel: "upstream" } } +const discover = mock(async (_params: unknown) => ({ providerId: "openai-codex", models: discoveredModels })) +const upsert = mock(async (_params: unknown) => ({})) const modelRemove = mock(async (_params: unknown) => {}) const refreshCatalog = mock(async (_params: unknown) => ({ data: { status: "updated" }, })) mock.module("../../services/connection-manager", () => ({ getBaseClient: () => ({ - provider: { discover, modelRemove }, + provider: { discover, modelRemove, upsert }, model: { refreshCatalog }, }), })) @@ -79,6 +81,8 @@ afterEach(async () => { await act(async () => root?.unmount()) mount?.remove() discover.mockReset() + discover.mockImplementation(async () => ({ providerId: "openai-codex", models: discoveredModels })) + upsert.mockReset() modelRemove.mockReset() refreshCatalog.mockReset() changed.mockReset() @@ -115,12 +119,12 @@ describe("connected provider details", () => { changed: 1, }) }) - test("Codex refreshes its authenticated provider catalog", async () => { + test("Codex discovery displays temporary models without changing configuration", async () => { refreshCatalog.mockImplementation(async () => ({ data: { status: "updated" }, })) await renderDialog() - await click("Refresh catalog") + await click("Discover") expect({ refresh: refreshCatalog.mock.calls, discover: discover.mock.calls, @@ -128,14 +132,16 @@ describe("connected provider details", () => { }).toEqual({ refresh: [], discover: [[{ providerId: "openai-codex", forceRefresh: true }]], - changed: 1, + changed: 0, }) + expect(mount.textContent).toContain("Fresh model") + expect(upsert.mock.calls).toEqual([]) }) test("failed discovery is visible and can be retried", async () => { discover.mockImplementationOnce(async () => { throw new Error("Provider unavailable") }) - discover.mockImplementation(async () => {}) + discover.mockImplementation(async () => ({ providerId: "settings-qa", models: discoveredModels })) await renderDialog("settings-qa") await click("Discover") expect(mount.querySelector('[role="alert"]')?.textContent).toBe( @@ -146,6 +152,17 @@ describe("connected provider details", () => { alert: mount.querySelector('[role="alert"]'), attempts: discover.mock.calls.length, changed: changed.mock.calls.length, - }).toEqual({ alert: null, attempts: 2, changed: 1 }) + }).toEqual({ alert: null, attempts: 2, changed: 0 }) + }) + test("saving a discovered model persists only the explicit selection and preserves routing", async () => { + await renderDialog() + await click("Discover") + await click("Save") + expect(upsert.mock.calls).toEqual([[{ provider: { + ...provider, + models: { fresh: { ...discoveredModels.fresh, origin: "user" } }, + } }]]) + expect(changed.mock.calls.length).toBe(1) }) + }) diff --git a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx index 7618b802..6478493a 100644 --- a/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx +++ b/apps/desktop/src/renderer/components/settings/connection-detail-dialog.tsx @@ -22,7 +22,7 @@ import { import { Input } from "@devo/ui/components/input" import { Spinner } from "@devo/ui/components/spinner" import { PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon } from "lucide-react" -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { getBaseClient } from "../../services/connection-manager" import { effectiveContextWindowTokens, @@ -51,10 +51,20 @@ export function ConnectionDetailDialog({ const [removingModel, setRemovingModel] = useState(null) const [addingModel, setAddingModel] = useState(false) const [error, setError] = useState(null) - const catalogRefresh = provider.id === "openai-codex" + const [discovered, setDiscovered] = useState>({}) + const [savingModel, setSavingModel] = useState(null) + const discoveryVersion = useRef(0) + useEffect(() => { + discoveryVersion.current++ + setDiscovering(false) + setDiscovered({}) + setSearch("") + setError(null) + return () => { discoveryVersion.current++ } + }, [provider.id, open]) const models = useMemo(() => { - const entries = Object.entries({ ...provider.models, ...connectionModels }) + const entries = Object.entries({ ...provider.models, ...discovered, ...connectionModels }) if (!search.trim()) return entries const lower = search.toLowerCase() return entries.filter( @@ -62,27 +72,46 @@ export function ConnectionDetailDialog({ id.toLowerCase().includes(lower) || (m.name ?? "").toLowerCase().includes(lower), ) - }, [provider.models, connectionModels, search]) + }, [provider.models, discovered, connectionModels, search]) const handleDiscover = useCallback(async () => { + const version = ++discoveryVersion.current setDiscovering(true) setError(null) try { const client = getBaseClient() if (!client) throw new Error("Not connected to server") - await client.provider.discover({ + const result = await client.provider.discover({ providerId: provider.id, forceRefresh: true, }) - onChanged() + if (version === discoveryVersion.current) setDiscovered(result.models) } catch (error) { + if (version !== discoveryVersion.current) return setError( error instanceof Error ? error.message : "Failed to discover models", ) } finally { - setDiscovering(false) + if (version === discoveryVersion.current) setDiscovering(false) } - }, [provider.id, onChanged]) + }, [provider.id]) + + const handleSaveModel = useCallback(async (modelId: string, model: CatalogModelInfo) => { + setSavingModel(modelId) + setError(null) + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to server") + await client.provider.upsert({ + provider: { ...provider, models: { [modelId]: { ...model, origin: "user" } } }, + }) + onChanged() + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to save model") + } finally { + setSavingModel(null) + } + }, [provider, onChanged]) const handleRemoveModel = useCallback( async (modelId: string) => { @@ -118,15 +147,15 @@ export function ConnectionDetailDialog({ Connected - {Object.keys({ ...provider.models, ...connectionModels }).length}{" "} + {Object.keys({ ...provider.models, ...discovered, ...connectionModels }).length}{" "} model - {Object.keys({ ...provider.models, ...connectionModels }) + {Object.keys({ ...provider.models, ...discovered, ...connectionModels }) .length !== 1 ? "s" : ""}{" "} available. {provider.baseUrl ? ` Endpoint: ${provider.baseUrl}` : ""} - {" Catalog models cannot be removed."} + {" Discovery is temporary. Save a model to keep it in your configuration."} @@ -152,7 +181,7 @@ export function ConnectionDetailDialog({ ) : ( )} - {catalogRefresh ? "Refresh catalog" : "Discover"} + Discover + )} {connectionModels[modelId]?.origin === "user" && provider.models?.[modelId]?.origin !== "remote" && (