Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ jobs:
mock_pid=$!
trap 'kill "$mock_pid" 2>/dev/null || true' EXIT
printf '%s\n' \
'{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801"}}}' \
'{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801", "wire_api": "openai_chat_completions"}}}' \
> "$home/providers.json"
printf '%s\n' '{"deepseek": {"type": "api_key", "key": "ci-mock-key"}}' > "$home/auth.json"
chmod 600 "$home/auth.json"
Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,9 +1486,7 @@ class NativeClient {
},
discover: async (params: ProviderDiscoverParams): Promise<ProviderDiscoverResult> => {
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: [] }),
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/main/ipc-handlers-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const logins: Array<Deferred<{ access: string }>> = []
const writes: Array<{
provider: string
access: string
configureConnection?: boolean
gate: Deferred<void>
}> = []
let stored: Record<string, string> = {}
Expand Down Expand Up @@ -90,12 +91,12 @@ mock.module("./provider-oauth", () => ({
mock.module("./devo-manager", () => ({
requestNative: (
method: string,
params: { provider: string; access: string },
params: { provider: string; access: string; configureConnection?: boolean },
) => {
if (method !== "credential/set")
throw new Error(`Unexpected RPC: ${method}`)
const gate = deferred<void>()
writes.push({ provider: params.provider, access: params.access, gate })
writes.push({ provider: params.provider, access: params.access, configureConnection: params.configureConnection, gate })
return gate.promise.then(() => {
stored[params.provider] = params.access
})
Expand Down Expand Up @@ -220,6 +221,17 @@ describe("Desktop window recreation", () => {
})

describe("Desktop OAuth credential cancellation", () => {
test("explicit OAuth login configures a connection only after credentials arrive", async () => {
const pending = login(1, "openai-codex")
expect(writes).toHaveLength(0)
logins[0].resolve({ access: "new" })
await flush()
expect(writes.map(({ provider, configureConnection }) => ({ provider, configureConnection })))
.toEqual([{ provider: "openai-codex", configureConnection: true }])
writes[0].gate.resolve()
await pending
})

test("a late OAuth response after cancel cannot start credential/set", async () => {
const pending = login(1)
expect(cancel(1)).toBe(true)
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async function persistDesktopOAuthCredential(
onSaving()
await requestNative(
"credential/set",
credentialSetParamsFromDesktopOAuth(providerId, credential),
{ ...credentialSetParamsFromDesktopOAuth(providerId, credential), configureConnection: true },
)
} finally {
release()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ async function render() {
),
)
}
async function click(text: string) {
const button = [...mount.querySelectorAll("button")].find((button) =>
async function click(text: string, selector = "button") {
const button = [...mount.querySelectorAll<HTMLButtonElement>(selector)].find((button) =>
button.textContent?.includes(text),
)
if (!button) throw new Error(`Missing button ${text}`)
Expand Down Expand Up @@ -217,7 +217,7 @@ test("onboarding reuses API and OAuth dialogs and catalog refetches preserve OAu
await render()
expect(oauthProps.provider).toBe(original)
await act(async () => oauthProps.onClose())
await click("ChatGPT")
await click("ChatGPT", '[aria-label="Provider catalog"] button')
expect(mount.textContent).toContain("Manage ChatGPT")
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { invalidateProviderDependentQueries } from "../../../lib/invalidate-prov
import { compareConnectedFirst } from "../../../lib/providers"
import { ConnectProviderDialog } from "../../settings/connect-provider-dialog"
import { ConnectionDetailDialog } from "../../settings/connection-detail-dialog"
import { DesktopOAuthActions } from "../../settings/desktop-oauth-actions"
import { isDesktopOAuthProvider } from "../../settings/desktop-oauth-providers"
import { ProviderIcon } from "../../settings/provider-icon"
import { TemplateConnectDialog } from "../../settings/template-connect-dialog"
Expand Down Expand Up @@ -101,6 +102,7 @@ export function ProviderSetupStep({
Choose from the live catalog or search for a model you want to use.
</p>
</div>
<DesktopOAuthActions onConnected={changed} />
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", cost: { input: 2, output: 3 }, capabilities: { vision: true }, contextWindow: 100000 } }
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 },
}),
}))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -115,27 +119,29 @@ 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,
changed: changed.mock.calls.length,
}).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(
Expand All @@ -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: { origin: "user", baseUrl: "https://gateway.example/v1", wireApi: "openai_responses", requestModel: "upstream" } },
} }]])
expect(changed.mock.calls.length).toBe(1)
})

})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,38 +51,72 @@ export function ConnectionDetailDialog({
const [removingModel, setRemovingModel] = useState<string | null>(null)
const [addingModel, setAddingModel] = useState(false)
const [error, setError] = useState<string | null>(null)
const catalogRefresh = provider.id === "openai-codex"
const [discovered, setDiscovered] = useState<Record<string, CatalogModelInfo>>({})
const [savingModel, setSavingModel] = useState<string | null>(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(
([id, m]) =>
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]: {
origin: "user",
baseUrl: model.baseUrl,
wireApi: model.wireApi,
requestModel: model.requestModel,
} } },
})
onChanged()
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to save model")
} finally {
setSavingModel(null)
}
}, [provider, onChanged])

const handleRemoveModel = useCallback(
async (modelId: string) => {
Expand Down Expand Up @@ -118,15 +152,15 @@ export function ConnectionDetailDialog({
<Badge variant="secondary">Connected</Badge>
</div>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>

Expand All @@ -152,7 +186,7 @@ export function ConnectionDetailDialog({
) : (
<RefreshCwIcon className="size-3.5 stroke-[1.5]" />
)}
{catalogRefresh ? "Refresh catalog" : "Discover"}
Discover
</Button>
<Button
variant="outline"
Expand Down Expand Up @@ -200,6 +234,14 @@ export function ConnectionDetailDialog({
{contextLabel}
</span>
)}
{discovered[modelId] && connectionModels[modelId]?.origin !== "user" && (
<Button variant="outline" size="sm"
aria-label={`Save discovered model ${model.name ?? modelId}`}
onClick={() => handleSaveModel(modelId, model)}
disabled={savingModel === modelId}>
Save
</Button>
)}
{connectionModels[modelId]?.origin === "user" &&
provider.models?.[modelId]?.origin !== "remote" && (
<Button
Expand Down
Loading
Loading