From 5a6a76270523aef1e62166c0403a458a48564a80 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:15:09 -0400 Subject: [PATCH 1/8] feat(providers): runtime cards + fully-managed OpenCode subscriptions, Kimi, custom providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure Settings → Providers into Coding Agent cards plus a single "OpenCode — Universal Model Access" group. Seed subscription OAuth (GitHub Copilot, ChatGPT, GitLab, Poe, DigitalOcean) into ADE's isolated managed OpenCode dir via the server auth API with a device-code modal; persist provider inventory stale-while-revalidate so the long tail renders on first open; add first-class Moonshot AI key + Kimi for Coding membership entries; inject custom providers and model slugs into the managed OpenCode config; re-apply models.dev enrichment on refresh with last-synced visibility; type OpenCode launch failures (incl. Gatekeeper quarantine fix); dim configuration-gated models in the picker with a settings deep-link; wire web-client adapter + TUI Kimi parity. Co-Authored-By: Claude Fable 5 --- .../ModelPicker/ModelPickerPane.tsx | 4 + .../src/tuiClient/providerMetadata.test.ts | 13 + .../ade-cli/src/tuiClient/providerMetadata.ts | 5 + .../src/main/services/adeActions/registry.ts | 66 +- .../services/ai/aiIntegrationService.test.ts | 4 + .../main/services/ai/aiIntegrationService.ts | 58 +- .../src/main/services/ai/aiSettingsStatus.ts | 4 + .../src/main/services/ai/apiKeyStore.ts | 1 + .../src/main/services/ai/authDetector.ts | 11 + .../main/services/ai/modelsDevService.test.ts | 96 + .../src/main/services/ai/modelsDevService.ts | 64 +- .../services/config/projectConfigService.ts | 37 + .../src/main/services/ipc/registerIpc.ts | 97 + .../opencode/openCodeAuthService.test.ts | 198 ++ .../services/opencode/openCodeAuthService.ts | 300 +++ .../opencode/openCodeBinaryManager.ts | 33 + .../services/opencode/openCodeInventory.ts | 76 + .../openCodeInventoryPersistence.test.ts | 53 + .../services/opencode/openCodeRuntime.test.ts | 105 +- .../main/services/opencode/openCodeRuntime.ts | 148 +- .../opencode/openCodeServerManager.test.ts | 88 + .../opencode/openCodeServerManager.ts | 121 +- apps/desktop/src/preload/global.d.ts | 16 + apps/desktop/src/preload/preload.ts | 48 + apps/desktop/src/renderer/browserMock.ts | 6 + .../components/settings/OAuthConnectModal.tsx | 428 +++++ .../settings/ProvidersSection.test.tsx | 140 +- .../components/settings/ProvidersSection.tsx | 1655 +++++++++++------ .../shared/ModelPicker/ModelPicker.test.tsx | 99 + .../shared/ModelPicker/ModelPickerContent.tsx | 16 + .../components/shared/ProviderLogos.tsx | 5 + .../adapter/__tests__/adapter.test.ts | 127 ++ .../src/renderer/webclient/adapter/misc.ts | 145 +- apps/desktop/src/shared/ipc.ts | 6 + apps/desktop/src/shared/types/config.ts | 40 + configuration/ai-providers.mdx | 123 +- docs/features/chat/README.md | 51 +- 37 files changed, 3786 insertions(+), 701 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/providerMetadata.test.ts create mode 100644 apps/desktop/src/main/services/ai/modelsDevService.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeAuthService.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts create mode 100644 apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index f93ae5ec5..dec1c51cd 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -117,6 +117,8 @@ const PROVIDER_MARKS: Record = { cursor: { label: "Cursor", short: "CU", terminal: "⬢", color: "#0EA5E9", svg: CURSOR_SVG }, kimi: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, moonshot: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, + moonshotai: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, + kimiforcoding: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, ollama: { label: "Ollama", short: "OL", terminal: "◕", color: "#F0F0F2", iconFill: "#000000", svgPath: OLLAMA_PATH }, lmstudio: { label: "LM Studio", short: "LM", terminal: "≋", color: "#8B5CF6", svgPaths: LMSTUDIO_PATHS }, }; @@ -135,6 +137,8 @@ const ROW_MARKS: Record = { groq: PROVIDER_MARKS.groq!, kimi: PROVIDER_MARKS.kimi!, moonshot: PROVIDER_MARKS.moonshot!, + moonshotai: PROVIDER_MARKS.moonshotai!, + kimiforcoding: PROVIDER_MARKS.kimiforcoding!, openrouter: PROVIDER_MARKS.openrouter!, opencode: PROVIDER_MARKS.opencode!, droid: PROVIDER_MARKS.droid!, diff --git a/apps/ade-cli/src/tuiClient/providerMetadata.test.ts b/apps/ade-cli/src/tuiClient/providerMetadata.test.ts new file mode 100644 index 000000000..cad0d4a3d --- /dev/null +++ b/apps/ade-cli/src/tuiClient/providerMetadata.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { titleCaseProviderName } from "./providerMetadata"; + +describe("providerMetadata Kimi aliases", () => { + it("maps every Kimi/Moonshot catalog id to the Kimi brand label", () => { + // The opencode catalog emits `moonshotai` (and `kimi-for-coding`) as the + // canonical provider ids; both must render as "Kimi" like `kimi`/`moonshot`. + expect(titleCaseProviderName("kimi")).toBe("Kimi"); + expect(titleCaseProviderName("moonshot")).toBe("Kimi"); + expect(titleCaseProviderName("moonshotai")).toBe("Kimi"); + expect(titleCaseProviderName("kimi-for-coding")).toBe("Kimi"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/providerMetadata.ts b/apps/ade-cli/src/tuiClient/providerMetadata.ts index 84ee4bfee..aba9d24ed 100644 --- a/apps/ade-cli/src/tuiClient/providerMetadata.ts +++ b/apps/ade-cli/src/tuiClient/providerMetadata.ts @@ -44,6 +44,11 @@ export const PROVIDER_TOKEN_LABELS: Record = { cursor: "Cursor", kimi: "Kimi", moonshot: "Kimi", + // Canonical opencode catalog ids for the Kimi/Moonshot brand. Keys are the + // normalizeProviderToken() form (lowercased, non-alphanumerics stripped), so + // "kimi-for-coding" resolves as "kimiforcoding". + moonshotai: "Kimi", + kimiforcoding: "Kimi", ollama: "Ollama", lmstudio: "LM Studio", }; diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 3dd9131d9..bd729ca32 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -2,6 +2,15 @@ import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; +import { + addOpenCodeOAuthStatusListener, + cancelOAuth as cancelOpenCodeOAuth, + listAuthMethods as listOpenCodeAuthMethods, + setProviderKey as setOpenCodeProviderKey, + startOAuth as startOpenCodeOAuth, + type OpenCodeAuthDeps, +} from "../opencode/openCodeAuthService"; +import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import { BUILT_IN_BROWSER_DESKTOP_BRIDGE_METHODS } from "../../../../../ade-cli/src/services/builtInBrowser/desktopBridgeMethods"; import type { AutomationManualTriggerRequest, @@ -168,7 +177,7 @@ export const ADE_ACTION_CTO_ONLY: Partial(); +function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { + if (!runtime.eventBuffer || oauthStatusBridgedRuntimes.has(runtime)) return; + oauthStatusBridgedRuntimes.add(runtime); + addOpenCodeOAuthStatusListener((event) => { + try { + runtime.eventBuffer.push({ + timestamp: new Date().toISOString(), + category: "runtime", + payload: { kind: "opencodeOAuthStatus", event }, + }); + } catch { + // A full/broken buffer must not break the OAuth flow. + } + }); +} + function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { const aiIntegrationService = runtime.aiIntegrationService; if (!aiIntegrationService) return null; + ensureOpenCodeOAuthStatusRelayBridge(runtime); + const buildOpenCodeAuthDeps = (): OpenCodeAuthDeps => ({ + projectRoot: runtime.projectRoot, + projectConfig: runtime.projectConfigService.getEffective(), + logger: runtime.logger, + }); return { getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => buildAiSettingsStatus(aiIntegrationService, args), + opencodeAuthMethods: () => listOpenCodeAuthMethods(buildOpenCodeAuthDeps()), + opencodeOAuthStart: (args?: { providerId?: string; methodIndex?: number; inputs?: Record }) => + startOpenCodeOAuth(buildOpenCodeAuthDeps(), { + providerId: requireNonEmptyString(args?.providerId, "providerId"), + methodIndex: typeof args?.methodIndex === "number" ? args.methodIndex : 0, + inputs: args?.inputs, + }), + opencodeOAuthCancel: (args?: { providerId?: string }) => { + cancelOpenCodeOAuth({ providerId: requireNonEmptyString(args?.providerId, "providerId") }); + }, + setOpencodeProviderKey: (args?: { providerId?: string; key?: string }) => + setOpenCodeProviderKey(buildOpenCodeAuthDeps(), { + providerId: requireNonEmptyString(args?.providerId, "providerId"), + key: requireNonEmptyString(args?.key, "key"), + }), + refreshModelsDev: async () => { + try { + await refreshModelsDevNow(); + } catch { + // Surfaced via lastFetchedAt staleness; never throw from a refresh nudge. + } + return { lastFetchedAt: getModelsDevLastFetchedAt() }; + }, getOpenCodeRuntimeDiagnostics: async () => { const { getOpenCodeRuntimeSnapshot } = await import("../opencode/openCodeRuntime"); return getOpenCodeRuntimeSnapshot(); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index e9c4484d6..1a16e8ec8 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -20,6 +20,8 @@ const mockState = vi.hoisted(() => ({ clearOpenCodeInventoryCache: vi.fn(), peekOpenCodeInventoryCache: vi.fn(), probeOpenCodeProviderInventory: vi.fn(), + loadPersistedOpenCodeInventory: vi.fn((..._args: unknown[]) => [] as unknown[]), + getModelsDevLastFetchedAt: vi.fn((..._args: unknown[]) => null as number | null), clearOpenCodeBinaryCache: vi.fn(), resolveOpenCodeBinary: vi.fn(), })); @@ -53,6 +55,7 @@ vi.mock("./apiKeyStore", () => ({ vi.mock("./modelsDevService", () => ({ initialize: (...args: unknown[]) => mockState.initModelsDevService(...args), + getLastFetchedAt: (...args: unknown[]) => mockState.getModelsDevLastFetchedAt(...args), })); vi.mock("./claudeRuntimeProbe", () => ({ @@ -68,6 +71,7 @@ vi.mock("../opencode/openCodeInventory", () => ({ clearOpenCodeInventoryCache: (...args: unknown[]) => mockState.clearOpenCodeInventoryCache(...args), peekOpenCodeInventoryCache: (...args: unknown[]) => mockState.peekOpenCodeInventoryCache(...args), probeOpenCodeProviderInventory: (...args: unknown[]) => mockState.probeOpenCodeProviderInventory(...args), + loadPersistedOpenCodeInventory: (...args: unknown[]) => mockState.loadPersistedOpenCodeInventory(...args), })); vi.mock("../opencode/openCodeBinaryManager", () => ({ diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index aad6def54..165fac3fd 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -29,7 +29,6 @@ import { LOCAL_PROVIDER_LABELS, replaceDynamicOpenCodeModelDescriptors, resolveModelAlias, - enrichModelRegistry, resolveProviderGroupForModel, type LocalProviderFamily, } from "../../../shared/modelRegistry"; @@ -43,6 +42,7 @@ import { } from "./authDetector"; import { clearOpenCodeInventoryCache, + loadPersistedOpenCodeInventory, peekOpenCodeInventoryCache, probeOpenCodeProviderInventory, } from "../opencode/openCodeInventory"; @@ -52,8 +52,10 @@ import { resolveOpenCodeBinary, type OpenCodeBinarySource, } from "../opencode/openCodeBinaryManager"; -import { initialize as initModelsDevService } from "./modelsDevService"; -import { updateModelPricing } from "../../../shared/modelProfiles"; +import { + initialize as initModelsDevService, + getLastFetchedAt as getModelsDevLastFetchedAt, +} from "./modelsDevService"; import { isRecord } from "../shared/utils"; import { parseStructuredOutput } from "./utils"; import { @@ -143,6 +145,10 @@ export type AiIntegrationStatus = { opencodeInventoryError?: string | null; /** All providers reported by OpenCode's provider.list() — used to dynamically populate the settings UI and model picker. */ opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number; availableModelCount?: number }>; + /** True when opencodeProviders came from the persisted disk cache rather than a live/warm probe. */ + opencodeProvidersStale?: boolean; + /** Epoch ms of the last successful models.dev fetch (or cache mtime on fallback); null if never fetched. */ + modelsDevLastFetchedAt?: number | null; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -894,32 +900,12 @@ export function createAiIntegrationService(args: { }) { const { db, logger, projectConfigService, projectRoot } = args; - // Non-blocking: fetch models.dev data and enrich pricing + registry. - // Headless CLI readiness commands disable this so default doctor/auth runs - // remain local-only and do not touch provider/model networks. - if (args.enableDynamicModelMetadata !== false) initModelsDevService().then((modelData) => { - if (modelData.size === 0) return; - - // Update MODEL_PRICING with fresh cost data - const pricingUpdates: Record = {}; - const enrichments = new Map(); - - for (const [modelId, data] of modelData) { - if (data.cost) { - pricingUpdates[modelId] = data.cost; - } - if (data.contextWindow || data.maxOutputTokens) { - enrichments.set(modelId, { - contextWindow: data.contextWindow, - maxOutputTokens: data.maxOutputTokens, - }); - } - } - - const pricingCount = updateModelPricing(pricingUpdates); - const enrichCount = enrichModelRegistry(enrichments); - logger.info("ai.modelsdev.enriched", { pricingCount, enrichCount }); - }).catch((err) => { + // Non-blocking: fetch models.dev data and enrich pricing + registry. The + // enrichment step lives inside modelsDevService.initialize() so the periodic + // 6h refresh and explicit refreshNow() re-apply it too. Headless CLI readiness + // commands disable this so default doctor/auth runs remain local-only and do + // not touch provider/model networks. + if (args.enableDynamicModelMetadata !== false) initModelsDevService().catch((err) => { logger.warn("ai.modelsdev.init_failed", { error: err instanceof Error ? err.message : String(err) }); }); @@ -1799,30 +1785,34 @@ export function createAiIntegrationService(args: { modelIds: [] as string[], catalogModelIds: [] as string[], providers: [] as NonNullable, + stale: false, }; } if (options?.refreshOpenCodeInventory === true) { - return await probeOpenCodeProviderInventory({ + const probed = await probeOpenCodeProviderInventory({ projectRoot, projectConfig: effectiveConfig, logger, force: true, discoveredLocalModels, }); + return { ...probed, stale: false }; } const peeked = peekOpenCodeInventoryCache({ projectRoot, projectConfig: effectiveConfig, }); - if (peeked) return peeked; + if (peeked) return { ...peeked, stale: false }; // Cold status reads stay cheap. Runtime catalog refreshes are owned // by agentChatService.getModelCatalog() and only run when a client - // opens a dynamic runtime rail. + // opens a dynamic runtime rail. Surface the last persisted provider + // list (flagged stale) so chips render before the first warm probe. return { error: null as string | null, modelIds: [] as string[], catalogModelIds: [] as string[], - providers: [] as NonNullable, + providers: loadPersistedOpenCodeInventory(projectRoot), + stale: true, }; }); @@ -1855,6 +1845,8 @@ export function createAiIntegrationService(args: { opencodeBinarySource, opencodeInventoryError: opencodeInventory.error, opencodeProviders: opencodeInventory.providers, + opencodeProvidersStale: opencodeInventory.stale, + modelsDevLastFetchedAt: getModelsDevLastFetchedAt(), apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()), }; if (requestGeneration === providerReadinessCacheGeneration) { diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts index fb024940a..c9d993b08 100644 --- a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -109,6 +109,8 @@ export function getUnavailableAiStatus(): AiSettingsStatus { opencodeBinarySource: "missing", opencodeInventoryError: null, opencodeProviders: [], + opencodeProvidersStale: false, + modelsDevLastFetchedAt: null, }; } @@ -136,6 +138,8 @@ export async function buildAiSettingsStatus( opencodeBinarySource: status.opencodeBinarySource, opencodeInventoryError: status.opencodeInventoryError, opencodeProviders: status.opencodeProviders, + opencodeProvidersStale: status.opencodeProvidersStale, + modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, apiKeyStore: status.apiKeyStore, features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ feature, diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index c7b9ba7fb..8c0e39cdf 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -50,6 +50,7 @@ const ENV_KEY_PROVIDERS: Record = { together: "TOGETHER_API_KEY", openrouter: "OPENROUTER_API_KEY", cursor: "CURSOR_API_KEY", + moonshotai: "MOONSHOT_API_KEY", }; const MACOS_SECURITY_BIN = "/usr/bin/security"; diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 2593f2cb8..7508b1fde 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -479,6 +479,7 @@ const ENV_KEY_MAP: Record = { GROQ_API_KEY: "groq", TOGETHER_API_KEY: "together", CURSOR_API_KEY: "cursor", + MOONSHOT_API_KEY: "moonshotai", }; const LOCAL_ENDPOINT_CHECK_TIMEOUT_MS = 500; @@ -730,6 +731,16 @@ function buildApiVerificationRequest(provider: string, key: string): { }, }, }; + case "moonshotai": + return { + url: "https://api.moonshot.ai/v1/models", + init: { + method: "GET", + headers: { + Authorization: `Bearer ${key}`, + }, + }, + }; default: return null; } diff --git a/apps/desktop/src/main/services/ai/modelsDevService.test.ts b/apps/desktop/src/main/services/ai/modelsDevService.test.ts new file mode 100644 index 000000000..344be1f20 --- /dev/null +++ b/apps/desktop/src/main/services/ai/modelsDevService.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const enrichMocks = vi.hoisted(() => ({ + updateModelPricing: vi.fn(() => 1), + enrichModelRegistry: vi.fn(() => 1), +})); + +vi.mock("../../../shared/modelProfiles", () => ({ + updateModelPricing: enrichMocks.updateModelPricing, +})); +vi.mock("../../../shared/modelRegistry", () => ({ + enrichModelRegistry: enrichMocks.enrichModelRegistry, +})); + +// Keep cache writes off disk. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFile: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; +}); + +const API_RESPONSE = { + moonshotai: { + id: "moonshotai", + name: "Moonshot", + models: { + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + cost: { input: 1, output: 2 }, + limit: { context: 200000, output: 8000 }, + reasoning: true, + tool_call: true, + }, + }, + }, +}; + +import * as modelsDev from "./modelsDevService"; + +beforeEach(() => { + modelsDev.shutdown(); + enrichMocks.updateModelPricing.mockClear(); + enrichMocks.enrichModelRegistry.mockClear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T00:00:00.000Z")); + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => API_RESPONSE, + })) as unknown as typeof fetch; +}); + +afterEach(() => { + modelsDev.shutdown(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("modelsDevService", () => { + it("enriches after the initial fetch and records lastFetchedAt", async () => { + await modelsDev.initialize(); + expect(enrichMocks.updateModelPricing).toHaveBeenCalledTimes(1); + expect(enrichMocks.enrichModelRegistry).toHaveBeenCalledTimes(1); + expect(modelsDev.getLastFetchedAt()).toBe(Date.parse("2026-07-17T00:00:00.000Z")); + }); + + it("re-applies enrichment on the periodic 6h refresh", async () => { + await modelsDev.initialize(); + enrichMocks.updateModelPricing.mockClear(); + enrichMocks.enrichModelRegistry.mockClear(); + + // Advancing fake timers also advances the clock; the 6h timer fires at 06:00. + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(enrichMocks.updateModelPricing).toHaveBeenCalledTimes(1); + expect(enrichMocks.enrichModelRegistry).toHaveBeenCalledTimes(1); + expect(modelsDev.getLastFetchedAt()).toBe(Date.parse("2026-07-17T06:00:00.000Z")); + }); + + it("refreshNow re-fetches, re-enriches, and updates lastFetchedAt", async () => { + await modelsDev.initialize(); + enrichMocks.updateModelPricing.mockClear(); + enrichMocks.enrichModelRegistry.mockClear(); + + vi.setSystemTime(new Date("2026-07-17T03:30:00.000Z")); + await modelsDev.refreshNow(); + + expect(enrichMocks.updateModelPricing).toHaveBeenCalledTimes(1); + expect(enrichMocks.enrichModelRegistry).toHaveBeenCalledTimes(1); + expect(modelsDev.getLastFetchedAt()).toBe(Date.parse("2026-07-17T03:30:00.000Z")); + }); +}); diff --git a/apps/desktop/src/main/services/ai/modelsDevService.ts b/apps/desktop/src/main/services/ai/modelsDevService.ts index a04230fc7..268970438 100644 --- a/apps/desktop/src/main/services/ai/modelsDevService.ts +++ b/apps/desktop/src/main/services/ai/modelsDevService.ts @@ -2,9 +2,11 @@ // models.dev Integration — fetches dynamic model metadata from the open API // --------------------------------------------------------------------------- -import { writeFile, readFile, mkdir } from "node:fs/promises"; +import { writeFile, readFile, mkdir, stat } from "node:fs/promises"; import { join } from "node:path"; import { homedir } from "node:os"; +import { updateModelPricing } from "../../../shared/modelProfiles"; +import { enrichModelRegistry } from "../../../shared/modelRegistry"; // --------------------------------------------------------------------------- // Types @@ -80,6 +82,8 @@ const CACHE_FILE = join(CACHE_DIR, "models-dev-cache.json"); let modelDataMap: Map = new Map(); let refreshTimer: ReturnType | null = null; let initialized = false; +/** Epoch ms of the last successful API fetch, or the cache file's mtime on fallback. Null until known. */ +let lastFetchedAt: number | null = null; // --------------------------------------------------------------------------- // Helpers @@ -204,12 +208,43 @@ async function loadFromCache(): Promise> { try { const raw = await readFile(CACHE_FILE, "utf-8"); const parsed = JSON.parse(raw) as Record; + // Fall back to the cache file's mtime as the effective freshness stamp. + try { + const stats = await stat(CACHE_FILE); + lastFetchedAt = stats.mtimeMs; + } catch { + // Non-critical — leave lastFetchedAt untouched. + } return new Map(Object.entries(parsed)); } catch { return new Map(); } } +/** + * Apply the current models.dev metadata to the pricing table and model registry. + * Invoked after each successful fetch (init + 6h refresh) and on explicit refresh. + */ +function applyEnrichment(): { pricingCount: number; enrichCount: number } { + if (modelDataMap.size === 0) return { pricingCount: 0, enrichCount: 0 }; + const pricingUpdates: Record = {}; + const enrichments = new Map(); + for (const [modelId, data] of modelDataMap) { + if (data.cost) { + pricingUpdates[modelId] = data.cost; + } + if (data.contextWindow || data.maxOutputTokens) { + enrichments.set(modelId, { + contextWindow: data.contextWindow, + maxOutputTokens: data.maxOutputTokens, + }); + } + } + const pricingCount = updateModelPricing(pricingUpdates); + const enrichCount = enrichModelRegistry(enrichments); + return { pricingCount, enrichCount }; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -221,6 +256,7 @@ async function loadFromCache(): Promise> { export async function initialize(): Promise> { try { modelDataMap = await fetchFromApi(); + lastFetchedAt = Date.now(); await persistToCache(modelDataMap); console.info(`[models.dev] Fetched metadata for ${modelDataMap.size} models`); } catch (err) { @@ -233,13 +269,18 @@ export async function initialize(): Promise> { } } + const { pricingCount, enrichCount } = applyEnrichment(); + console.info(`[models.dev] Enriched ${pricingCount} prices, ${enrichCount} registry entries`); + // Schedule periodic refreshes (non-blocking) if (!refreshTimer) { refreshTimer = setInterval(async () => { try { const fresh = await fetchFromApi(); modelDataMap = fresh; + lastFetchedAt = Date.now(); await persistToCache(fresh); + applyEnrichment(); console.info(`[models.dev] Refreshed metadata for ${fresh.size} models`); } catch (err) { console.warn(`[models.dev] Background refresh failed: ${err instanceof Error ? err.message : err}`); @@ -254,6 +295,25 @@ export async function initialize(): Promise> { return modelDataMap; } +/** + * Force an immediate models.dev fetch, re-persist, and re-apply enrichment. + * Throws if the fetch fails so callers can surface the error; on success the + * in-memory map, cache, pricing table, and registry are all updated. + */ +export async function refreshNow(): Promise> { + const fresh = await fetchFromApi(); + modelDataMap = fresh; + lastFetchedAt = Date.now(); + await persistToCache(fresh); + applyEnrichment(); + return modelDataMap; +} + +/** Epoch ms of the last successful fetch (or cache mtime on fallback); null if never. */ +export function getLastFetchedAt(): number | null { + return lastFetchedAt; +} + /** Get model data by providerModelId. Returns undefined if not found. */ export function getModelData(providerModelId: string): ModelsDevModelData | undefined { return modelDataMap.get(providerModelId); @@ -275,4 +335,6 @@ export function shutdown(): void { clearInterval(refreshTimer); refreshTimer = null; } + initialized = false; + lastFetchedAt = null; } diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 61fa208db..1bbebb4d8 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -6,6 +6,7 @@ import cron from "node-cron"; import { z } from "zod"; import type { AiConfig, + AiCustomProviderConfig, AiFeatureKey, AiTaskRoutingKey, AiTaskRoutingRule, @@ -1625,12 +1626,39 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const localProviders = coerceAiLocalProviders(value.localProviders); if (localProviders) out.localProviders = localProviders; + const customProviders = coerceAiCustomProviders(value.customProviders); + if (customProviders) out.customProviders = customProviders; + + const customModelSlugs = asStringArray(value.customModelSlugs) + ?.map((slug) => slug.trim()) + .filter(Boolean); + if (customModelSlugs?.length) out.customModelSlugs = customModelSlugs; + const workerSafety = coerceWorkerSafetyPolicy(value.workerSafety); if (workerSafety) out.workerSafety = workerSafety; return Object.keys(out).length ? out : undefined; } +function coerceAiCustomProviders(value: unknown): AiConfig["customProviders"] { + if (!Array.isArray(value)) return undefined; + const out = value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const id = asString(entry.id)?.trim(); + const baseURL = asString(entry.baseURL)?.trim(); + const models = asStringArray(entry.models)?.map((model) => model.trim()).filter(Boolean) ?? []; + if (!id || !baseURL || !models.length) return []; + const npmRaw = asString(entry.npm)?.trim(); + const npm: AiCustomProviderConfig["npm"] = + npmRaw === "@ai-sdk/openai-compatible" || npmRaw === "@ai-sdk/openai" || npmRaw === "@ai-sdk/anthropic" + ? npmRaw + : undefined; + const name = asString(entry.name)?.trim(); + return [{ id, name: name || id, baseURL, ...(npm ? { npm } : {}), models }]; + }); + return out.length ? out : undefined; +} + function normalizePriorityLabels(value: unknown): Array<"urgent" | "high" | "normal" | "low" | "none"> | undefined { const labels = asStringArray(value); if (!labels) return undefined; @@ -1958,6 +1986,13 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(sharedAi?.apiKeys ?? {}), ...(localAi?.apiKeys ?? {}) }; + const customProvidersById = new Map( + [...(sharedAi?.customProviders ?? []), ...(localAi?.customProviders ?? [])] + .filter((entry) => entry?.id) + .map((entry) => [entry.id, entry] as const), + ); + const customProviders = [...customProvidersById.values()]; + const customModelSlugs = [...new Set([...(sharedAi?.customModelSlugs ?? []), ...(localAi?.customModelSlugs ?? [])])]; const localProvidersEntries = (["ollama", "lmstudio"] as const) .map((provider) => { const mergedProvider = { @@ -1984,6 +2019,8 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(Object.keys(featureModelOverrides).length ? { featureModelOverrides } : {}), ...(Object.keys(featureReasoningOverrides).length ? { featureReasoningOverrides } : {}), ...(Object.keys(apiKeys).length ? { apiKeys } : {}), + ...(customProviders.length ? { customProviders } : {}), + ...(customModelSlugs.length ? { customModelSlugs } : {}), ...(localProvidersEntries.length ? { localProviders } : {}), ...(workerSafety ? { workerSafety } : {}), }; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5db779d9d..1494e0819 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -462,6 +462,9 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, + OpenCodeProviderAuthMethods, OpenCodeRuntimeSnapshot, SyncDesktopConnectionDraft, SyncCloudRelayStatus, @@ -570,6 +573,15 @@ import { } from "../diffs/diffService"; import type { createFileService } from "../files/fileService"; import { mergeAiConfig, type createProjectConfigService } from "../config/projectConfigService"; +import { + addOpenCodeOAuthStatusListener, + cancelOAuth as cancelOpenCodeOAuth, + listAuthMethods as listOpenCodeAuthMethods, + setProviderKey as setOpenCodeProviderKey, + startOAuth as startOpenCodeOAuth, + type OpenCodeAuthDeps, +} from "../opencode/openCodeAuthService"; +import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import type { createProcessService } from "../processes/processService"; import type { createTestService } from "../tests/testService"; import type { createGitOperationsService } from "../git/gitOperationsService"; @@ -4310,6 +4322,8 @@ export function registerIpc({ opencodeBinarySource: status.opencodeBinarySource, opencodeInventoryError: status.opencodeInventoryError, opencodeProviders: status.opencodeProviders, + opencodeProvidersStale: status.opencodeProvidersStale, + modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, apiKeyStore: status.apiKeyStore, features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ feature, @@ -4402,6 +4416,89 @@ export function registerIpc({ void ctx.agentChatService?.refreshScheduledWork(); }); + // Broadcast OpenCode OAuth status transitions to all renderer windows. The + // relay/web fan-out is registered separately in the adeActions AI domain, + // which has the runtime event buffer remote/web clients subscribe to. + addOpenCodeOAuthStatusListener((event: OpenCodeOAuthStatusEvent) => { + for (const win of BrowserWindow.getAllWindows()) { + try { + win.webContents.send(IPC.aiOpencodeOAuthStatus, event); + } catch { + // ignore broadcast failures + } + } + }); + + const buildOpenCodeAuthDeps = (): OpenCodeAuthDeps => { + const ctx = getCtx(); + requireAppContextServices(ctx, ["projectConfigService"] as const); + const projectRoot = ctx.project?.rootPath; + if (!projectRoot) { + throw new Error("No project is open."); + } + return { + projectRoot, + projectConfig: ctx.projectConfigService.getEffective(), + logger: ctx.logger, + }; + }; + + ipcMain.handle( + IPC.aiOpencodeAuthMethods, + async (): Promise<{ methods: OpenCodeProviderAuthMethods }> => { + return await listOpenCodeAuthMethods(buildOpenCodeAuthDeps()); + }, + ); + + ipcMain.handle( + IPC.aiOpencodeOAuthStart, + async ( + _event, + arg: { providerId: string; methodIndex: number; inputs?: Record }, + ): Promise => { + return await startOpenCodeOAuth(buildOpenCodeAuthDeps(), arg); + }, + ); + + ipcMain.handle( + IPC.aiOpencodeOAuthCancel, + async (_event, arg: { providerId: string }): Promise => { + cancelOpenCodeOAuth(arg); + }, + ); + + ipcMain.handle( + IPC.aiSetOpencodeProviderKey, + async ( + _event, + arg: { providerId: string; key: string }, + ): Promise<{ ok: boolean; error?: string }> => { + const ctx = getCtx(); + const result = await setOpenCodeProviderKey(buildOpenCodeAuthDeps(), arg); + if (result.ok) { + try { + ctx.aiIntegrationService?.invalidateProviderReadinessCaches(); + } catch (error) { + ctx.logger.warn("ai.api_key_cache_invalidation_failed", { + provider: arg.providerId, + error: getErrorMessage(error), + }); + } + } + return result; + }, + ); + + ipcMain.handle(IPC.aiRefreshModelsDev, async (): Promise<{ lastFetchedAt: number | null }> => { + const ctx = getCtx(); + try { + await refreshModelsDevNow(); + } catch (error) { + ctx.logger.warn("ai.modelsdev.refresh_failed", { error: getErrorMessage(error) }); + } + return { lastFetchedAt: getModelsDevLastFetchedAt() }; + }); + ipcMain.handle(IPC.projectSecretsList, async (): Promise => { const ctx = getCtx(); requireAppContextServices(ctx, ["projectSecretService"] as const); diff --git a/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts b/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts new file mode 100644 index 000000000..338b939bd --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Logger } from "../logging/logger"; +import type { OpenCodeOAuthStatusEvent } from "../../../shared/types/config"; +import { + __getActiveOAuthProviderIdsForTests, + __resetOpenCodeAuthServiceForTests, + __setOpenCodeAuthHooksForTests, + addOpenCodeOAuthStatusListener, + cancelOAuth, + listAuthMethods, + setProviderKey, + startOAuth, + type OpenCodeAuthDeps, +} from "./openCodeAuthService"; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} as unknown as Logger; + +const deps: OpenCodeAuthDeps = { + projectRoot: "/repo", + projectConfig: {} as OpenCodeAuthDeps["projectConfig"], + logger, +}; + +function collectEvents(): OpenCodeOAuthStatusEvent[] { + const events: OpenCodeOAuthStatusEvent[] = []; + addOpenCodeOAuthStatusListener((event) => events.push(event)); + return events; +} + +beforeEach(() => { + __resetOpenCodeAuthServiceForTests(); +}); + +afterEach(() => { + __resetOpenCodeAuthServiceForTests(); + vi.useRealTimers(); +}); + +describe("openCodeAuthService", () => { + it("lists auth methods from GET /provider/auth", async () => { + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async (url) => { + expect(url).toBe("http://127.0.0.1:4200/provider/auth"); + return { ok: true, status: 200, body: { openai: [{ type: "oauth", label: "ChatGPT" }] } }; + }, + }); + + const result = await listAuthMethods(deps); + expect(result.methods.openai?.[0]?.label).toBe("ChatGPT"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("runs an auto OAuth flow: authorize, open browser, poll to connected, refresh inventory", async () => { + vi.useFakeTimers(); + const events = collectEvents(); + const release = vi.fn(); + const openExternal = vi.fn(async () => {}); + const probeInventory = vi.fn(async () => {}); + let connected: string[] = []; + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async (url) => { + expect(url).toContain("/provider/github-copilot/oauth/authorize"); + return { + ok: true, + status: 200, + body: { url: "https://github.com/login/device", method: "auto", instructions: "Enter code: ABCD-1234" }, + }; + }, + listConnectedProviders: async () => connected, + openExternal, + probeInventory, + }); + + const result = await startOAuth(deps, { providerId: "github-copilot", methodIndex: 0 }); + expect(result).toEqual({ + url: "https://github.com/login/device", + method: "auto", + instructions: "Enter code: ABCD-1234", + }); + expect(openExternal).toHaveBeenCalledWith("https://github.com/login/device"); + expect(events).toEqual([{ providerId: "github-copilot", state: "pending" }]); + expect(__getActiveOAuthProviderIdsForTests()).toEqual(["github-copilot"]); + + // First poll: still not connected. + await vi.advanceTimersByTimeAsync(2000); + expect(events.at(-1)?.state).toBe("pending"); + + // Provider connects; next poll completes the flow. + connected = ["github-copilot"]; + await vi.advanceTimersByTimeAsync(2000); + + expect(events.at(-1)).toEqual({ providerId: "github-copilot", state: "connected" }); + expect(release).toHaveBeenCalledTimes(1); + expect(probeInventory).toHaveBeenCalledTimes(1); + expect(__getActiveOAuthProviderIdsForTests()).toEqual([]); + }); + + it("emits timeout and releases the lease when the provider never connects", async () => { + vi.useFakeTimers(); + const events = collectEvents(); + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async () => ({ ok: true, status: 200, body: { url: "https://x", method: "auto", instructions: "" } }), + listConnectedProviders: async () => [], + openExternal: async () => {}, + probeInventory: async () => {}, + }); + + await startOAuth(deps, { providerId: "openai", methodIndex: 0 }); + // Advance past the 5 minute timeout. + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 2000); + + expect(events.at(-1)?.state).toBe("timeout"); + expect(release).toHaveBeenCalledTimes(1); + expect(__getActiveOAuthProviderIdsForTests()).toEqual([]); + }); + + it("cancel stops the poller, releases the lease, and emits cancelled", async () => { + vi.useFakeTimers(); + const events = collectEvents(); + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async () => ({ ok: true, status: 200, body: { url: "https://x", method: "auto", instructions: "" } }), + listConnectedProviders: async () => [], + openExternal: async () => {}, + probeInventory: async () => {}, + }); + + await startOAuth(deps, { providerId: "openai", methodIndex: 0 }); + cancelOAuth({ providerId: "openai" }); + + expect(events.at(-1)).toEqual({ providerId: "openai", state: "cancelled" }); + expect(release).toHaveBeenCalledTimes(1); + expect(__getActiveOAuthProviderIdsForTests()).toEqual([]); + }); + + it("starting a new flow for the same provider cancels the prior one", async () => { + vi.useFakeTimers(); + const events = collectEvents(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release: vi.fn() }), + httpJson: async () => ({ ok: true, status: 200, body: { url: "https://x", method: "auto", instructions: "" } }), + listConnectedProviders: async () => [], + openExternal: async () => {}, + probeInventory: async () => {}, + }); + + await startOAuth(deps, { providerId: "openai", methodIndex: 0 }); + await startOAuth(deps, { providerId: "openai", methodIndex: 1 }); + + expect(events.some((e) => e.state === "cancelled")).toBe(true); + expect(__getActiveOAuthProviderIdsForTests()).toEqual(["openai"]); + }); + + it("setProviderKey PUTs the key and mirrors it into the ADE key store", async () => { + const release = vi.fn(); + const storeApiKey = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async (url, init) => { + expect(url).toBe("http://127.0.0.1:4200/auth/moonshotai"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toEqual({ type: "api", key: "sk-test" }); + return { ok: true, status: 200, body: true }; + }, + storeApiKey, + }); + + const result = await setProviderKey(deps, { providerId: "moonshotai", key: "sk-test" }); + expect(result).toEqual({ ok: true }); + expect(storeApiKey).toHaveBeenCalledWith("moonshotai", "sk-test"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("setProviderKey returns an error without mirroring when the PUT fails", async () => { + const storeApiKey = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release: vi.fn() }), + httpJson: async () => ({ ok: false, status: 400, body: null }), + storeApiKey, + }); + + const result = await setProviderKey(deps, { providerId: "moonshotai", key: "bad" }); + expect(result.ok).toBe(false); + expect(result.error).toContain("400"); + expect(storeApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeAuthService.ts b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts new file mode 100644 index 000000000..6b7c26134 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts @@ -0,0 +1,300 @@ +// --------------------------------------------------------------------------- +// OpenCode subscription/OAuth auth service +// +// Drives the OpenCode server's auth API to (a) enumerate the auth methods each +// provider supports, (b) run a subscription OAuth flow (open the browser, then +// poll provider.list() until the provider reports connected), and (c) seed a +// plain API key (PUT /auth) while mirroring it into ADE's key store so the key +// is re-injected on future server launches. +// +// All server access reuses the shared managed OpenCode server lease (the same +// server the inventory probe uses) — we never spawn our own process. +// --------------------------------------------------------------------------- + +import { createOpencodeClient } from "@opencode-ai/sdk"; +import type { Logger } from "../logging/logger"; +import type { EffectiveProjectConfig, ProjectConfigFile } from "../../../shared/types"; +import type { + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, + OpenCodeProviderAuthMethods, +} from "../../../shared/types/config"; +import { buildOpenCodeMergedConfig, buildSharedOpenCodeServerKey } from "./openCodeRuntime"; +import { acquireSharedOpenCodeServer } from "./openCodeServerManager"; +import { probeOpenCodeProviderInventory } from "./openCodeInventory"; +import { storeApiKey as storeStoredApiKey } from "../ai/apiKeyStore"; + +/** How long an OAuth flow's shared lease stays alive between poll ticks. */ +const OAUTH_LEASE_IDLE_TTL_MS = 10_000; +/** OAuth completion poll cadence. */ +const POLL_INTERVAL_MS = 2_000; +/** Give up on an OAuth flow after this long without the provider connecting. */ +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; + +export type OpenCodeAuthDeps = { + projectRoot: string; + projectConfig: ProjectConfigFile | EffectiveProjectConfig; + logger: Logger; +}; + +type SharedLease = { url: string; release: () => void }; + +type HttpJsonResult = { ok: boolean; status: number; body: unknown }; + +type OpenCodeAuthHooks = { + acquireLease(deps: OpenCodeAuthDeps): Promise; + httpJson(url: string, init?: RequestInit): Promise; + listConnectedProviders(baseUrl: string, directory: string): Promise; + openExternal(url: string): Promise; + probeInventory(deps: OpenCodeAuthDeps): Promise; + storeApiKey(providerId: string, key: string): void; + now(): number; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const defaultHooks: OpenCodeAuthHooks = { + async acquireLease(deps) { + const config = buildOpenCodeMergedConfig({ projectConfig: deps.projectConfig }); + const lease = await acquireSharedOpenCodeServer({ + config, + key: buildSharedOpenCodeServerKey(config), + ownerKind: "inventory", + ownerId: deps.projectRoot, + idleTtlMs: OAUTH_LEASE_IDLE_TTL_MS, + logger: deps.logger, + }); + return { url: lease.url, release: () => lease.release("handle_close") }; + }, + async httpJson(url, init) { + const res = await fetch(url, init); + let body: unknown = null; + try { + body = await res.json(); + } catch { + body = null; + } + return { ok: res.ok, status: res.status, body }; + }, + async listConnectedProviders(baseUrl, directory) { + const client = createOpencodeClient({ baseUrl, directory }); + const listed = await client.provider.list({ query: { directory }, throwOnError: true }); + const data = listed.data as { connected?: string[] } | undefined; + return Array.isArray(data?.connected) ? data.connected : []; + }, + async openExternal(url) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { shell } = require("electron") as { shell: { openExternal(u: string): Promise } }; + await shell.openExternal(url); + }, + async probeInventory(deps) { + await probeOpenCodeProviderInventory({ + projectRoot: deps.projectRoot, + projectConfig: deps.projectConfig, + logger: deps.logger, + force: true, + }); + }, + storeApiKey(providerId, key) { + storeStoredApiKey(providerId, key); + }, + now() { + return Date.now(); + }, +}; + +let hooks: OpenCodeAuthHooks = { ...defaultHooks }; +type StatusListener = (event: OpenCodeOAuthStatusEvent) => void; +const statusListeners = new Set(); + +type ActiveFlow = { + release: () => void; + timer: ReturnType; +}; +const activeFlows = new Map(); + +/** + * Subscribe a sink to OAuth status transitions. Multiple sinks may coexist so + * the same transition can fan out to renderer windows (desktop) and the runtime + * event buffer (remote/web clients). Returns an unsubscribe function. + */ +export function addOpenCodeOAuthStatusListener(listener: StatusListener): () => void { + statusListeners.add(listener); + return () => { + statusListeners.delete(listener); + }; +} + +function emit(event: OpenCodeOAuthStatusEvent): void { + for (const listener of statusListeners) { + try { + listener(event); + } catch { + // A broken sink must not break the flow or starve other listeners. + } + } +} + +/** Tear down an active flow (clear the poll timer, release the lease) and emit `state`. */ +function finishFlow(providerId: string, state: OpenCodeOAuthStatusEvent["state"], error?: string): void { + const flow = activeFlows.get(providerId); + if (!flow) return; + clearInterval(flow.timer); + flow.release(); + activeFlows.delete(providerId); + emit({ providerId, state, ...(error ? { error } : {}) }); +} + +/** List the auth methods each provider supports (GET /provider/auth). */ +export async function listAuthMethods(deps: OpenCodeAuthDeps): Promise<{ methods: OpenCodeProviderAuthMethods }> { + const lease = await hooks.acquireLease(deps); + try { + const res = await hooks.httpJson(`${lease.url}/provider/auth`, { method: "GET" }); + if (!res.ok) { + throw new Error(`OpenCode GET /provider/auth failed (${res.status}).`); + } + const methods = (res.body ?? {}) as OpenCodeProviderAuthMethods; + return { methods }; + } finally { + lease.release(); + } +} + +/** + * Start an OAuth flow: authorize, open the returned URL, then poll until the + * provider reports connected (or timeout). Only one flow per providerId is + * active at a time — starting a new one cancels the prior flow. + */ +export async function startOAuth( + deps: OpenCodeAuthDeps, + args: { providerId: string; methodIndex: number; inputs?: Record }, +): Promise { + const { providerId, methodIndex, inputs } = args; + // Supersede any in-flight flow for this provider. + cancelOAuth({ providerId }); + + const lease = await hooks.acquireLease(deps); + let handedOff = false; + try { + const res = await hooks.httpJson( + `${lease.url}/provider/${encodeURIComponent(providerId)}/oauth/authorize`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ method: methodIndex, ...(inputs ? { inputs } : {}) }), + }, + ); + if (!res.ok) { + throw new Error(`OpenCode oauth/authorize failed (${res.status}).`); + } + const body = (res.body ?? {}) as { url?: unknown; method?: unknown; instructions?: unknown }; + const url = typeof body.url === "string" ? body.url : ""; + const method: OpenCodeOAuthStartResult["method"] = body.method === "code" ? "code" : "auto"; + const instructions = typeof body.instructions === "string" ? body.instructions : ""; + + if (url) { + void hooks.openExternal(url).catch((err) => { + deps.logger.warn("opencode.oauth_open_external_failed", { providerId, error: errorMessage(err) }); + }); + } + + emit({ providerId, state: "pending" }); + const startedAt = hooks.now(); + const timer = setInterval(() => { + void (async () => { + try { + if (hooks.now() - startedAt >= OAUTH_TIMEOUT_MS) { + finishFlow(providerId, "timeout"); + return; + } + const connected = await hooks.listConnectedProviders(lease.url, deps.projectRoot); + if (!activeFlows.has(providerId)) return; // cancelled while awaiting + if (connected.includes(providerId)) { + finishFlow(providerId, "connected"); + void hooks.probeInventory(deps).catch((err) => { + deps.logger.warn("opencode.oauth_post_connect_probe_failed", { + providerId, + error: errorMessage(err), + }); + }); + } + } catch (err) { + deps.logger.warn("opencode.oauth_poll_failed", { providerId, error: errorMessage(err) }); + } + })(); + }, POLL_INTERVAL_MS); + if (timer.unref) timer.unref(); + + activeFlows.set(providerId, { release: () => lease.release(), timer }); + handedOff = true; + return { url, method, instructions }; + } catch (err) { + emit({ providerId, state: "failed", error: errorMessage(err) }); + throw err; + } finally { + // If we never handed the lease to an active flow, release it now. + if (!handedOff) lease.release(); + } +} + +/** Cancel an in-flight OAuth flow (if any), stopping the poller and emitting `cancelled`. */ +export function cancelOAuth(args: { providerId: string }): void { + finishFlow(args.providerId, "cancelled"); +} + +/** + * Seed a plain API key for a provider: PUT /auth/{id} on the OpenCode server and + * mirror the key into ADE's key store so it is re-injected on future launches. + */ +export async function setProviderKey( + deps: OpenCodeAuthDeps, + args: { providerId: string; key: string }, +): Promise<{ ok: boolean; error?: string }> { + const { providerId, key } = args; + const lease = await hooks.acquireLease(deps); + try { + const res = await hooks.httpJson(`${lease.url}/auth/${encodeURIComponent(providerId)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api", key }), + }); + if (!res.ok) { + return { ok: false, error: `OpenCode PUT /auth failed (${res.status}).` }; + } + try { + hooks.storeApiKey(providerId, key); + } catch (err) { + deps.logger.warn("opencode.oauth_key_mirror_failed", { providerId, error: errorMessage(err) }); + } + return { ok: true }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } finally { + lease.release(); + } +} + +// --- Test hooks ------------------------------------------------------------ + +export function __setOpenCodeAuthHooksForTests(partial: Partial): void { + hooks = { ...hooks, ...partial }; +} + +export function __resetOpenCodeAuthServiceForTests(): void { + for (const providerId of [...activeFlows.keys()]) { + const flow = activeFlows.get(providerId); + if (flow) { + clearInterval(flow.timer); + flow.release(); + } + activeFlows.delete(providerId); + } + hooks = { ...defaultHooks }; + statusListeners.clear(); +} + +export function __getActiveOAuthProviderIdsForTests(): string[] { + return [...activeFlows.keys()]; +} diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts index dbe2f859a..e48c4ac90 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts @@ -1,4 +1,5 @@ // OpenCode binary resolution with bundled fallback +import { execFileSync } from "node:child_process"; import { accessSync, constants } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -170,3 +171,35 @@ export function resolveOpenCodeBinaryPath(): string | null { export function clearOpenCodeBinaryCache(): void { cachedInfo = null; } + +export type OpenCodeBinaryQuarantineState = "quarantined" | "clean" | "unknown"; + +/** + * Best-effort probe for the macOS `com.apple.quarantine` extended attribute on + * the OpenCode binary. Used by launch diagnostics to distinguish a Gatekeeper + * quarantine (fixable via `xattr -d`) from a genuine bad signature. Returns + * `"unknown"` off darwin, without a path, or when `xattr` is unavailable / times + * out; `"clean"` when the attribute is absent; `"quarantined"` when present. + */ +export function probeOpenCodeBinaryQuarantine(binaryPath: string | null | undefined): OpenCodeBinaryQuarantineState { + if (process.platform !== "darwin") return "unknown"; + const trimmed = binaryPath?.trim(); + if (!trimmed) return "unknown"; + try { + const output = execFileSync("xattr", ["-p", "com.apple.quarantine", trimmed], { + encoding: "utf8", + timeout: 1_000, + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }); + return output.trim().length > 0 ? "quarantined" : "clean"; + } catch (error) { + // `xattr -p` exits non-zero when the attribute is absent — treat that as + // "clean". A missing `xattr` binary (ENOENT) or a timeout leaves `status` + // null, which we surface as "unknown" so callers don't over-claim. + const status = (error && typeof error === "object" && "status" in error) + ? (error as { status?: number | null }).status + : undefined; + return typeof status === "number" && status !== 0 ? "clean" : "unknown"; + } +} diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index f750c9868..d6f068d71 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { createOpencodeClient } from "@opencode-ai/sdk"; import type { Logger } from "../logging/logger"; import type { EffectiveProjectConfig, ProjectConfigFile } from "../../../shared/types"; @@ -44,6 +47,78 @@ type CacheEntry = { let inventoryCache: CacheEntry | null = null; const probeInFlightMap = new Map>(); +// --------------------------------------------------------------------------- +// Cross-launch inventory persistence +// +// A cold settings read (no warm in-memory cache) would otherwise report zero +// providers until the first probe completes, so the settings UI flashes empty +// on every app start. We persist each successful probe's provider list, keyed +// by project root, to Electron userData and reload it (flagged stale) on cold +// reads so provider chips render immediately. +// --------------------------------------------------------------------------- + +type PersistedInventoryFile = Record; + +type ElectronLikeApp = { app?: { getPath(name: string): string } }; + +let persistPathOverride: string | null = null; +let persistedInventoryMemo: PersistedInventoryFile | null = null; + +function resolvePersistedInventoryPath(): string { + if (persistPathOverride) return persistPathOverride; + const envOverride = process.env.ADE_OPENCODE_INVENTORY_CACHE_FILE?.trim(); + if (envOverride) return path.resolve(envOverride); + try { + const electron = require("electron") as ElectronLikeApp; + const userDataPath = electron.app?.getPath?.("userData"); + if (typeof userDataPath === "string" && userDataPath.trim().length > 0) { + return path.resolve(userDataPath, "opencode-inventory-cache.json"); + } + } catch { + // Not running inside Electron (e.g. unit tests) — fall through. + } + const homeDir = os.homedir().trim(); + const baseDir = homeDir.length > 0 ? path.resolve(homeDir, ".ade") : os.tmpdir(); + return path.resolve(baseDir, "opencode-inventory-cache.json"); +} + +function readPersistedInventoryFile(): PersistedInventoryFile { + if (persistedInventoryMemo) return persistedInventoryMemo; + try { + const raw = fs.readFileSync(resolvePersistedInventoryPath(), "utf8"); + const parsed = JSON.parse(raw) as PersistedInventoryFile; + persistedInventoryMemo = parsed && typeof parsed === "object" ? parsed : {}; + } catch { + persistedInventoryMemo = {}; + } + return persistedInventoryMemo; +} + +/** Persist a successful probe's provider list, keyed by project root. Best-effort. */ +export function persistOpenCodeInventory(projectRoot: string, providers: OpenCodeProviderInfo[]): void { + try { + const all = { ...readPersistedInventoryFile() }; + all[projectRoot] = { providers, savedAt: Date.now() }; + persistedInventoryMemo = all; + const filePath = resolvePersistedInventoryPath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(all), "utf8"); + } catch { + // Non-critical — persistence failures must not break the probe. + } +} + +/** Load the last persisted provider list for a project (empty when none). */ +export function loadPersistedOpenCodeInventory(projectRoot: string): OpenCodeProviderInfo[] { + return readPersistedInventoryFile()[projectRoot]?.providers ?? []; +} + +/** Test hook: point persistence at a temp file and drop the in-memory memo. */ +export function __setOpenCodeInventoryPersistencePathForTests(filePath: string | null): void { + persistPathOverride = filePath; + persistedInventoryMemo = null; +} + export type OpenCodeInventoryResult = { /** Selectable model ids for connected providers only. */ modelIds: string[]; @@ -450,6 +525,7 @@ export async function probeOpenCodeProviderInventory(args: { providers: providerInfos, error: null, }; + persistOpenCodeInventory(args.projectRoot, providerInfos); return { modelIds, catalogModelIds, providers: providerInfos, error: null, descriptors }; } finally { lease.release("handle_close"); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts new file mode 100644 index 000000000..ada57f76d --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + __setOpenCodeInventoryPersistencePathForTests, + loadPersistedOpenCodeInventory, + persistOpenCodeInventory, + type OpenCodeProviderInfo, +} from "./openCodeInventory"; + +let cacheFile: string; + +const providers: OpenCodeProviderInfo[] = [ + { id: "openai", name: "OpenAI", connected: true, modelCount: 12 }, + { id: "moonshotai", name: "Moonshot", connected: false, modelCount: 4 }, +]; + +beforeEach(() => { + cacheFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ade-oc-inv-")), "inventory.json"); + __setOpenCodeInventoryPersistencePathForTests(cacheFile); +}); + +afterEach(() => { + __setOpenCodeInventoryPersistencePathForTests(null); + try { + fs.rmSync(path.dirname(cacheFile), { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } +}); + +describe("openCode inventory persistence", () => { + it("persists a probe's provider list and reloads it from disk on a cold read", () => { + persistOpenCodeInventory("/repo", providers); + + // Drop the in-memory memo so the read comes straight from disk (cold path). + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + + expect(loadPersistedOpenCodeInventory("/repo")).toEqual(providers); + }); + + it("keeps provider lists isolated per project root", () => { + persistOpenCodeInventory("/repo", providers); + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + + expect(loadPersistedOpenCodeInventory("/other")).toEqual([]); + }); + + it("returns an empty list when nothing has been persisted", () => { + expect(loadPersistedOpenCodeInventory("/repo")).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index d47c74a82..05d1b3261 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => { let nextSessionId = 1; @@ -91,6 +91,13 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({ })), })); +const apiKeyState = vi.hoisted(() => ({ keys: {} as Record })); + +vi.mock("../ai/apiKeyStore", () => ({ + getAllApiKeys: () => ({ ...apiKeyState.keys }), + getApiKey: (id: string) => apiKeyState.keys[id.trim().toLowerCase()] ?? null, +})); + vi.mock("./openCodeBinaryManager", () => ({ resolveOpenCodeBinaryPath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), })); @@ -107,6 +114,7 @@ vi.mock("./openCodeServerManager", () => ({ import { __resetOpenCodeRuntimeDiagnosticsForTests, + buildOpenCodeConfig, getOpenCodeRuntimeSnapshot, runOpenCodeTextPrompt, startOpenCodeSession, @@ -203,3 +211,98 @@ describe("openCodeRuntime", () => { expect(Object.keys(snapshot).sort()).toEqual(["dedicatedCount", "entries", "sharedCount"]); }); }); + +describe("buildOpenCodeConfig provider injection", () => { + beforeEach(() => { + apiKeyState.keys = {}; + }); + + const providerOf = (ai: Record): Record => + (buildOpenCodeConfig({ projectConfig: { ai } as any }).provider ?? {}) as Record; + + it("emits a full provider block for each custom provider", () => { + const provider = providerOf({ + customProviders: [ + { + id: "acme", + name: "Acme AI", + baseURL: "https://acme.example/v1", + models: ["acme-large", "acme-small"], + }, + ], + }); + + expect(provider.acme).toEqual({ + npm: "@ai-sdk/openai-compatible", + name: "Acme AI", + options: { baseURL: "https://acme.example/v1" }, + models: { "acme-large": {}, "acme-small": {} }, + }); + }); + + it("honors an explicit npm package and injects the configured api key", () => { + // Key injection is asserted through the project-config path: the encrypted + // key store resolves via a CJS require that vitest's ESM mocks can't + // intercept (it degrades to a no-op here by design), and both paths merge + // through the same provider options object. + const provider = providerOf({ + apiKeys: { acme: "sk-acme" }, + customProviders: [ + { + id: "acme", + name: "Acme", + baseURL: "https://acme.example/v1", + npm: "@ai-sdk/anthropic", + models: ["m1"], + }, + ], + }); + + expect(provider.acme.npm).toBe("@ai-sdk/anthropic"); + expect(provider.acme.options).toEqual({ + baseURL: "https://acme.example/v1", + apiKey: "sk-acme", + }); + }); + + it("skips custom providers missing an id, baseURL, or models", () => { + const provider = providerOf({ + customProviders: [ + { id: "", name: "no id", baseURL: "https://x/v1", models: ["m"] }, + { id: "nourl", name: "no url", baseURL: " ", models: ["m"] }, + { id: "nomodels", name: "no models", baseURL: "https://x/v1", models: [] }, + ], + }); + + expect(provider[""]).toBeUndefined(); + expect(provider.nourl).toBeUndefined(); + expect(provider.nomodels).toBeUndefined(); + }); + + it("merges a custom model slug into an existing custom provider", () => { + const provider = providerOf({ + customProviders: [ + { id: "acme", name: "Acme", baseURL: "https://acme.example/v1", models: ["m1"] }, + ], + customModelSlugs: ["acme/m2"], + }); + + expect(Object.keys(provider.acme.models).sort()).toEqual(["m1", "m2"]); + }); + + it("materializes a bare block for a known-catalog provider slug", () => { + const provider = providerOf({ customModelSlugs: ["openai/o5-preview"] }); + expect(provider.openai).toEqual({ models: { "o5-preview": {} } }); + }); + + it("keeps model ids that contain slashes intact", () => { + const provider = providerOf({ customModelSlugs: ["openrouter/anthropic/claude-x"] }); + expect(provider.openrouter).toEqual({ models: { "anthropic/claude-x": {} } }); + }); + + it("drops malformed and unknown-provider slugs", () => { + const provider = providerOf({ customModelSlugs: ["noslash", "mysteryco/model"] }); + expect(provider.noslash).toBeUndefined(); + expect(provider.mysteryco).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index bf5b5ea84..3a114f7d5 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -21,6 +21,7 @@ import { type ModelDescriptor, } from "../../../shared/modelRegistry"; import type { + AiCustomProviderConfig, AiLocalProviderConfigs, EffectiveProjectConfig, OpenCodeRuntimeSnapshot, @@ -246,6 +247,50 @@ export function buildOpenCodeMergedConfig(args: BuildOpenCodeConfigArgs): OpenCo return buildOpenCodeConfig(args); } +type OpenCodeProviderMap = NonNullable; +type OpenCodeProviderEntry = OpenCodeProviderMap[string]; +type OpenCodeModelsMap = NonNullable; +type OpenCodeModelEntry = OpenCodeModelsMap[string]; + +/** + * Provider ids OpenCode ships with in its built-in models.dev catalog. A custom + * model slug (`providerId/modelId`) whose provider is not otherwise present in + * the generated config may still be materialised as a bare provider block for + * these — OpenCode already knows their npm package + base URL, so an empty + * `models` entry is enough to surface the model in `provider.list()`. Slugs for + * providers outside this set (and not user-configured) are dropped, since a + * bare block would leave OpenCode unable to load the provider. Keep this list in + * sync with OpenCode's catalog as new mainstream providers are added. + */ +const KNOWN_OPENCODE_CATALOG_PROVIDER_IDS: ReadonlySet = new Set([ + "openai", + "anthropic", + "google", + "google-vertex", + "google-vertex-anthropic", + "azure", + "amazon-bedrock", + "openrouter", + "groq", + "mistral", + "deepseek", + "xai", + "togetherai", + "fireworks-ai", + "cerebras", + "cohere", + "perplexity", + "deepinfra", + "github-copilot", + "github-models", + "huggingface", + "moonshotai", + "zhipuai", + "opencode", + "ollama", + "lmstudio", +]); + function buildProviderConfig( projectConfig: ProjectConfigFile | EffectiveProjectConfig, discoveredLocalModels?: DiscoveredLocalModelEntry[], @@ -253,7 +298,7 @@ function buildProviderConfig( const ai = projectConfig.ai ?? {}; const apiKeys = ai.apiKeys ?? {}; const localProviders = ai.localProviders ?? {}; - const provider: NonNullable = {}; + const provider: OpenCodeProviderMap = {}; const addApiProvider = ( id: string, @@ -270,13 +315,21 @@ function buildProviderConfig( }; }; + // Resolve a stored API key for a specific provider id. Defaults to a no-op so + // the config still builds when the key store is unavailable (e.g. unit tests). + let resolveStoredApiKey: (id: string) => string | null = () => null; + // Merge keys from the encrypted local store first (lower priority). try { // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getAllApiKeys } = require("../ai/apiKeyStore") as { getAllApiKeys: () => Record }; - for (const [providerId, key] of Object.entries(getAllApiKeys())) { + const store = require("../ai/apiKeyStore") as { + getAllApiKeys: () => Record; + getApiKey: (id: string) => string | null; + }; + for (const [providerId, key] of Object.entries(store.getAllApiKeys())) { addApiProvider(providerId.trim().toLowerCase(), key); } + resolveStoredApiKey = (id: string) => store.getApiKey(id); } catch { // Key store may not be available (e.g. unit tests). } @@ -324,9 +377,98 @@ function buildProviderConfig( addLocalProvider("ollama", localProviders.ollama); addLocalProvider("lmstudio", localProviders.lmstudio); + // User-defined custom providers: emit a full OpenCode provider block each. + addCustomProviders(provider, ai.customProviders, resolveStoredApiKey); + + // Custom model slugs (`providerId/modelId`): surface extra models on providers + // OpenCode already knows about so its `provider.list()` includes them. + mergeCustomModelSlugs(provider, ai.customModelSlugs); + return Object.keys(provider).length > 0 ? provider : undefined; } +function addCustomProviders( + provider: OpenCodeProviderMap, + customProviders: AiCustomProviderConfig[] | undefined, + resolveStoredApiKey: (id: string) => string | null, +): void { + if (!customProviders?.length) return; + for (const entry of customProviders) { + const id = entry?.id?.trim(); + const baseURL = entry?.baseURL?.trim(); + const models = (entry?.models ?? []) + .map((model) => model?.trim()) + .filter((model): model is string => Boolean(model)); + if (!id || !baseURL || models.length === 0) { + console.warn("opencode.custom_provider_skipped", { + id: entry?.id, + reason: !id ? "missing-id" : !baseURL ? "missing-baseURL" : "no-models", + }); + continue; + } + const modelsMap: OpenCodeModelsMap = {}; + for (const modelId of models) { + modelsMap[modelId] = {} as OpenCodeModelEntry; + } + const apiKey = trimToUndefined(resolveStoredApiKey(id)); + const existing = provider[id]; + provider[id] = { + ...(existing ?? {}), + npm: entry.npm ?? "@ai-sdk/openai-compatible", + name: trimToUndefined(entry.name) ?? id, + options: { + ...(existing?.options ?? {}), + baseURL, + ...(apiKey ? { apiKey } : {}), + }, + models: { + ...(existing?.models ?? {}), + ...modelsMap, + }, + }; + } +} + +function mergeCustomModelSlugs( + provider: OpenCodeProviderMap, + customModelSlugs: string[] | undefined, +): void { + if (!customModelSlugs?.length) return; + for (const raw of customModelSlugs) { + const slug = raw?.trim(); + if (!slug) continue; + const slashIndex = slug.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= slug.length - 1) { + console.warn("opencode.custom_model_slug_skipped", { slug: raw, reason: "malformed" }); + continue; + } + const providerId = slug.slice(0, slashIndex).trim(); + const modelId = slug.slice(slashIndex + 1).trim(); + if (!providerId || !modelId) { + console.warn("opencode.custom_model_slug_skipped", { slug: raw, reason: "malformed" }); + continue; + } + const existing = provider[providerId]; + if (existing) { + provider[providerId] = { + ...existing, + models: { + ...(existing.models ?? {}), + [modelId]: existing.models?.[modelId] ?? ({} as OpenCodeModelEntry), + }, + }; + continue; + } + if (KNOWN_OPENCODE_CATALOG_PROVIDER_IDS.has(providerId.toLowerCase())) { + provider[providerId] = { + models: { [modelId]: {} as OpenCodeModelEntry }, + }; + continue; + } + console.warn("opencode.custom_model_slug_skipped", { slug: raw, reason: "unknown-provider" }); + } +} + export function buildOpenCodeConfig(args: BuildOpenCodeConfigArgs): OpenCodeConfig { const provider = buildProviderConfig(args.projectConfig, args.discoveredLocalModels); const helperPermission = { diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts index e583a8a45..3ada77505 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -5,10 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ created: [] as Array<{ close: ReturnType; url: string }>, resolveOpenCodeBinaryPath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), + probeOpenCodeBinaryQuarantine: vi.fn(() => "unknown" as "quarantined" | "clean" | "unknown"), })); vi.mock("./openCodeBinaryManager", () => ({ resolveOpenCodeBinaryPath: mockState.resolveOpenCodeBinaryPath, + probeOpenCodeBinaryQuarantine: mockState.probeOpenCodeBinaryQuarantine, })); import { @@ -22,9 +24,12 @@ import { __terminateOpenCodeServerProcessesForTests, acquireDedicatedOpenCodeServer, acquireSharedOpenCodeServer, + classifyOpenCodeLaunchFailure, getOpenCodeRuntimeDiagnostics, + OpenCodeLaunchError, parseWindowsWmicProcessCsv, recoverManagedOpenCodeOrphans, + renderOpenCodeDiagnostic, } from "./openCodeServerManager"; const originalProcessPlatform = process.platform; @@ -78,6 +83,89 @@ describe("Windows managed OpenCode command detection", () => { }); }); +describe("classifyOpenCodeLaunchFailure", () => { + afterEach(() => { + setProcessPlatform(originalProcessPlatform); + mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("unknown"); + }); + + it("classifies a missing resolved binary as not-installed", () => { + expect( + classifyOpenCodeLaunchFailure(new Error("boom"), { port: 4096, binaryPath: null }), + ).toEqual({ kind: "not-installed" }); + }); + + it("classifies ENOENT spawn errors as not-installed", () => { + const error = Object.assign(new Error("spawn opencode ENOENT"), { code: "ENOENT" }); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "not-installed" }); + }); + + it("classifies EADDRINUSE as port-conflict with the attempted port", () => { + const error = Object.assign(new Error("listen EADDRINUSE"), { code: "EADDRINUSE" }); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4310, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "port-conflict", port: 4310 }); + }); + + it("classifies a startup deadline as launch-timeout", () => { + const error = new Error("Timeout waiting for server to start after 15000ms"); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "launch-timeout" }); + }); + + it("classifies a darwin Gatekeeper kill with the quarantine xattr as quarantined", () => { + setProcessPlatform("darwin"); + mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("quarantined"); + const error = new Error("Server exited with code null\nServer output: killed"); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ + kind: "quarantined", + binaryPath: "/bin/opencode", + fixCommand: 'xattr -d com.apple.quarantine "/bin/opencode"', + }); + }); + + it("classifies a darwin code-signature failure without quarantine as bad-signature", () => { + setProcessPlatform("darwin"); + mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("clean"); + const error = new Error("dyld: code signature invalid for opencode"); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "bad-signature", binaryPath: "/bin/opencode" }); + }); + + it("classifies unrecognized failures as unknown, preserving the message", () => { + setProcessPlatform("linux"); + expect( + classifyOpenCodeLaunchFailure(new Error("weird failure"), { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "unknown", message: "weird failure" }); + }); + + it("renders stable prefix-keyed messages and wraps them in OpenCodeLaunchError", () => { + expect( + renderOpenCodeDiagnostic({ + kind: "quarantined", + binaryPath: "/bin/opencode", + fixCommand: 'xattr -d com.apple.quarantine "/bin/opencode"', + }), + ).toBe( + 'OpenCode: quarantined: OpenCode binary is quarantined by macOS Gatekeeper. Fix: xattr -d com.apple.quarantine "/bin/opencode"', + ); + expect(renderOpenCodeDiagnostic({ kind: "port-conflict", port: 4310 })).toBe( + "OpenCode: port-conflict: Port 4310 is already in use. Free the port or retry.", + ); + + const err = new OpenCodeLaunchError({ kind: "not-installed" }); + expect(err).toBeInstanceOf(Error); + expect(err.diagnostic).toEqual({ kind: "not-installed" }); + expect(err.message.startsWith("OpenCode: not-installed:")).toBe(true); + }); +}); + describe("openCodeServerManager", () => { const originalEnv = { PATH: process.env.PATH, diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index 8c080fc61..38b54545c 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -12,7 +12,7 @@ import { quoteWindowsCmdArg, resolveWindowsCmdLineInvocation, } from "../shared/processExecution"; -import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; +import { probeOpenCodeBinaryQuarantine, resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; export type OpenCodeServerLeaseKind = "shared" | "dedicated"; export type OpenCodeServerOwnerKind = "inventory" | "oneshot" | "chat" | "coordinator"; @@ -30,6 +30,20 @@ export type OpenCodeServerShutdownReason = | "config_changed" | "error"; +/** + * Typed classification of an OpenCode binary/server launch failure. Surfaced to + * the UI (via a stable, single-line error message; see + * {@link renderOpenCodeDiagnostic}) so it can offer a precise fix instead of a + * generic error. + */ +export type OpenCodeDiagnostic = + | { kind: "not-installed" } + | { kind: "quarantined"; binaryPath: string; fixCommand: string } + | { kind: "bad-signature"; binaryPath: string } + | { kind: "port-conflict"; port: number } + | { kind: "launch-timeout" } + | { kind: "unknown"; message: string }; + type OpenCodeServerInstance = { url: string; close(): void; @@ -488,6 +502,102 @@ function isPortConflict(error: unknown): boolean { return false; } +function errorCode(error: unknown): string { + if (error && typeof error === "object" && "code" in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "string") return code; + } + return ""; +} + +/** + * Classify a raw OpenCode launch/probe failure into a typed diagnostic. See the + * task heuristics: ENOENT / no resolved binary → not-installed; EADDRINUSE → + * port-conflict; startup deadline → launch-timeout; darwin Gatekeeper signals + * (EPERM / "operation not permitted" / "killed" / "code signature") → + * quarantined (when the `com.apple.quarantine` xattr is present) else + * bad-signature; anything else → unknown. + */ +export function classifyOpenCodeLaunchFailure( + error: unknown, + context: { port: number; binaryPath: string | null }, +): OpenCodeDiagnostic { + const binaryPath = context.binaryPath; + const code = errorCode(error); + const message = error instanceof Error ? error.message : String(error ?? ""); + const lower = message.toLowerCase(); + + if (!binaryPath || code === "ENOENT" || lower.includes("enoent") || lower.includes("executable is not available")) { + return { kind: "not-installed" }; + } + if (isPortConflict(error)) { + return { kind: "port-conflict", port: context.port }; + } + if (lower.includes("timeout waiting for server to start")) { + return { kind: "launch-timeout" }; + } + if ( + process.platform === "darwin" + && ( + code === "EPERM" + || lower.includes("operation not permitted") + || lower.includes("killed") + || lower.includes("code signature") + || lower.includes("developer cannot be verified") + ) + ) { + const quarantine = probeOpenCodeBinaryQuarantine(binaryPath); + if (quarantine === "quarantined") { + return { + kind: "quarantined", + binaryPath, + fixCommand: `xattr -d com.apple.quarantine "${binaryPath}"`, + }; + } + return { kind: "bad-signature", binaryPath }; + } + return { kind: "unknown", message: message || "OpenCode server failed to launch." }; +} + +/** + * Render a diagnostic into a stable, single-line, actionable message. The + * `OpenCode: :` prefix is contract-stable — UI surfaces may key off it. + */ +export function renderOpenCodeDiagnostic(diagnostic: OpenCodeDiagnostic): string { + switch (diagnostic.kind) { + case "not-installed": + return "OpenCode: not-installed: OpenCode binary could not be found. Install OpenCode or ensure it is on your PATH."; + case "quarantined": + return `OpenCode: quarantined: OpenCode binary is quarantined by macOS Gatekeeper. Fix: ${diagnostic.fixCommand}`; + case "bad-signature": + return `OpenCode: bad-signature: OpenCode binary at "${diagnostic.binaryPath}" failed macOS code-signature verification. Reinstall OpenCode from a trusted source.`; + case "port-conflict": + return `OpenCode: port-conflict: Port ${diagnostic.port} is already in use. Free the port or retry.`; + case "launch-timeout": + return "OpenCode: launch-timeout: OpenCode server did not become ready in time. Retry, or kill any hung opencode process and try again."; + case "unknown": + return `OpenCode: unknown: ${diagnostic.message}`; + } +} + +/** Launch failure carrying its typed {@link OpenCodeDiagnostic} for callers that can key off it. */ +export class OpenCodeLaunchError extends Error { + readonly diagnostic: OpenCodeDiagnostic; + constructor(diagnostic: OpenCodeDiagnostic) { + super(renderOpenCodeDiagnostic(diagnostic)); + this.name = "OpenCodeLaunchError"; + this.diagnostic = diagnostic; + } +} + +function toOpenCodeLaunchError( + error: unknown, + context: { port: number; binaryPath: string | null }, +): OpenCodeLaunchError { + if (error instanceof OpenCodeLaunchError) return error; + return new OpenCodeLaunchError(classifyOpenCodeLaunchFailure(error, context)); +} + function stopChildProcess(proc: ChildProcess): void { if (proc.exitCode !== null || proc.signalCode !== null) return; if (process.platform === "win32" && proc.pid && openCodeProcessController.killProcessTree(proc.pid)) { @@ -956,19 +1066,24 @@ function parseOpenCodeServerListenUrl(line: string): string | null { async function createOpencodeServerWithRetry( config: OpenCodeConfig, ): Promise { + const binaryPath = resolveOpenCodeBinaryPath(); let lastError: unknown; + let lastPort = 0; for (let attempt = 0; attempt < PORT_RETRY_ATTEMPTS; attempt += 1) { const port = await findAvailablePort(); + lastPort = port; protectedLaunchPorts.add(port); try { return await openCodeServerLauncher({ port, config }); } catch (error) { protectedLaunchPorts.delete(port); lastError = error; - if (!isPortConflict(error)) throw error; + if (!isPortConflict(error)) { + throw toOpenCodeLaunchError(error, { port, binaryPath }); + } } } - throw lastError; + throw toOpenCodeLaunchError(lastError, { port: lastPort, binaryPath }); } function logRuntimeEvent( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 41720f151..36303462e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -219,6 +219,9 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, + OpenCodeProviderAuthMethods, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -935,6 +938,19 @@ declare global { listApiKeys: () => Promise; verifyApiKey: (provider: string) => Promise; updateConfig: (config: Partial) => Promise; + opencodeAuthMethods: () => Promise<{ methods: OpenCodeProviderAuthMethods }>; + opencodeOAuthStart: (args: { + providerId: string; + methodIndex: number; + inputs?: Record; + }) => Promise; + opencodeOAuthCancel: (args: { providerId: string }) => Promise; + setOpencodeProviderKey: (args: { + providerId: string; + key: string; + }) => Promise<{ ok: boolean; error?: string }>; + refreshModelsDev: () => Promise<{ lastFetchedAt: number | null }>; + onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => () => void; cursorCloudListRepositories: () => Promise; cursorCloudListAgents: (args?: { includeArchived?: boolean; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 07a81235b..df8232102 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -93,6 +93,9 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, + OpenCodeProviderAuthMethods, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -3883,6 +3886,51 @@ contextBridge.exposeInMainWorld("ade", { () => ipcRenderer.invoke(IPC.aiUpdateConfig, config), ), ), + opencodeAuthMethods: async (): Promise<{ methods: OpenCodeProviderAuthMethods }> => + callProjectRuntimeActionOr("ai", "opencodeAuthMethods", {}, () => + ipcRenderer.invoke(IPC.aiOpencodeAuthMethods), + ), + opencodeOAuthStart: async (args: { + providerId: string; + methodIndex: number; + inputs?: Record; + }): Promise => + callProjectRuntimeActionOr("ai", "opencodeOAuthStart", { args }, () => + ipcRenderer.invoke(IPC.aiOpencodeOAuthStart, args), + ), + opencodeOAuthCancel: async (args: { providerId: string }): Promise => + callProjectRuntimeActionOr("ai", "opencodeOAuthCancel", { args }, () => + ipcRenderer.invoke(IPC.aiOpencodeOAuthCancel, args), + ), + setOpencodeProviderKey: async (args: { + providerId: string; + key: string; + }): Promise<{ ok: boolean; error?: string }> => + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr("ai", "setOpencodeProviderKey", { args }, () => + ipcRenderer.invoke(IPC.aiSetOpencodeProviderKey, args), + ), + ), + refreshModelsDev: async (): Promise<{ lastFetchedAt: number | null }> => + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr("ai", "refreshModelsDev", {}, () => + ipcRenderer.invoke(IPC.aiRefreshModelsDev), + ), + ), + onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: OpenCodeOAuthStatusEvent, + ) => cb(payload); + ipcRenderer.on(IPC.aiOpencodeOAuthStatus, listener); + return () => { + ipcRenderer.removeListener(IPC.aiOpencodeOAuthStatus, listener); + }; + }, cursorCloudListRepositories: async (): Promise => callProjectRuntimeActionOr("ai", "listCursorCloudRepositories", {}, () => ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index b3b3d8b8d..37c0c27c5 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3817,6 +3817,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { verifiedAt: now, } as any), updateConfig: resolvedArg(undefined), + opencodeAuthMethods: resolved({ methods: {} }), + opencodeOAuthStart: resolvedArg({ url: "", method: "auto", instructions: "" } as any), + opencodeOAuthCancel: resolvedArg(undefined), + setOpencodeProviderKey: resolvedArg({ ok: false, error: "browser" } as any), + refreshModelsDev: resolved({ lastFetchedAt: null }), + onOpencodeOAuthStatus: () => () => {}, }, agentTools: { detect: resolved([]), diff --git a/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx new file mode 100644 index 000000000..f7b3a357e --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx @@ -0,0 +1,428 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { CheckCircle, Copy, Info, WarningCircle, X } from "@phosphor-icons/react"; +import type { + OpenCodeProviderAuthMethod, + OpenCodeProviderAuthPrompt, + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, +} from "../../../shared/types/config"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; + +const CODE_PATTERN = /[A-Z0-9]{4,}-[A-Z0-9]{4,}/; + +/** Pull a human-typeable device code out of the CLI instructions blob. */ +function extractDeviceCode(instructions: string | undefined): string | null { + if (!instructions) return null; + const match = instructions.match(CODE_PATTERN); + return match?.[0] ?? null; +} + +function extractHost(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).host; + } catch { + return null; + } +} + +function promptVisible( + prompt: OpenCodeProviderAuthPrompt, + inputs: Record, +): boolean { + if (!prompt.when) return true; + const current = inputs[prompt.when.key] ?? ""; + return prompt.when.op === "eq" + ? current === prompt.when.value + : current !== prompt.when.value; +} + +type Phase = "form" | "starting" | "waiting" | "error"; + +export function OAuthConnectModal({ + providerId, + providerName, + methods, + onClose, + onConnected, +}: { + providerId: string; + providerName: string; + /** The provider's full auth-method list (index is meaningful to the backend). */ + methods: OpenCodeProviderAuthMethod[]; + onClose: () => void; + onConnected: () => void; +}) { + const oauthMethods = useMemo( + () => methods.map((method, index) => ({ method, index })).filter((entry) => entry.method.type === "oauth"), + [methods], + ); + const [methodIndex, setMethodIndex] = useState(() => oauthMethods[0]?.index ?? 0); + const method = methods[methodIndex]; + const prompts = method?.prompts ?? []; + + const [inputs, setInputs] = useState>(() => { + const seed: Record = {}; + for (const prompt of prompts) { + if (prompt.type === "select") seed[prompt.key] = prompt.options?.[0]?.value ?? ""; + else seed[prompt.key] = ""; + } + return seed; + }); + const [phase, setPhase] = useState("form"); + const [startResult, setStartResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [copied, setCopied] = useState(false); + + const visiblePrompts = prompts.filter((prompt) => promptVisible(prompt, inputs)); + const deviceCode = extractDeviceCode(startResult?.instructions); + const host = extractHost(startResult?.url); + + // Subscribe to backend OAuth status pushes for this provider. + useEffect(() => { + const unsubscribe = window.ade.ai.onOpencodeOAuthStatus((event: OpenCodeOAuthStatusEvent) => { + if (event.providerId !== providerId) return; + if (event.state === "connected") { + onConnected(); + onClose(); + } else if (event.state === "failed" || event.state === "timeout" || event.state === "cancelled") { + setPhase("error"); + setErrorMessage( + event.error + ?? (event.state === "timeout" + ? "Timed out waiting for approval." + : event.state === "cancelled" + ? "Sign-in was cancelled." + : "Sign-in failed."), + ); + } + }); + return () => { + unsubscribe(); + }; + }, [providerId, onClose, onConnected]); + + // Close on Escape. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") void handleCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startFlow = async () => { + setPhase("starting"); + setErrorMessage(null); + try { + const filteredInputs: Record = {}; + for (const prompt of visiblePrompts) { + filteredInputs[prompt.key] = inputs[prompt.key] ?? ""; + } + const result = await window.ade.ai.opencodeOAuthStart({ + providerId, + methodIndex, + inputs: Object.keys(filteredInputs).length ? filteredInputs : undefined, + }); + setStartResult(result); + setPhase("waiting"); + } catch (err) { + setPhase("error"); + setErrorMessage(err instanceof Error ? err.message : String(err)); + } + }; + + const handleCancel = async () => { + try { + await window.ade.ai.opencodeOAuthCancel({ providerId }); + } catch { + // Best-effort — closing regardless. + } + onClose(); + }; + + const copyCode = async () => { + const text = deviceCode ?? startResult?.instructions ?? ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable; ignore. + } + }; + + const labelStyle: React.CSSProperties = { + fontSize: 10, + fontFamily: MONO_FONT, + textTransform: "uppercase", + letterSpacing: "1px", + color: COLORS.textMuted, + }; + const fieldStyle: React.CSSProperties = { + width: "100%", + background: COLORS.cardBgSolid, + border: `1px solid ${COLORS.border}`, + color: COLORS.textPrimary, + padding: "8px 10px", + fontSize: 12, + fontFamily: SANS_FONT, + outline: "none", + }; + + return createPortal( +
void handleCancel()} + > +
event.stopPropagation()} + > +
+
+ +
+ Connect {providerName} +
+
+ +
+ +
+ {phase === "error" ? ( +
+ + {errorMessage} +
+ ) : null} + + {phase === "form" || phase === "starting" ? ( + <> + {oauthMethods.length > 1 ? ( + + ) : null} + + {visiblePrompts.map((prompt) => + prompt.type === "select" ? ( + + ) : ( + + ), + )} + +
+ We'll open your browser to finish signing in with {providerName}. +
+ +
+ + +
+ + ) : null} + + {phase === "waiting" ? ( + <> +
+ {host ? `We opened ${host} in your browser.` : "We opened your browser."} +
+ + {deviceCode ? ( +
+ Enter this code +
+ {deviceCode} +
+
+ ) : startResult?.instructions ? ( + + {startResult.instructions} + + ) : null} + +
    +
  1. + Approve the request in the page we opened. +
  2. + {deviceCode ? ( +
  3. + Confirm the code above matches. +
  4. + ) : null} +
  5. + Return here — this closes automatically once approved. +
  6. +
+ +
+ + Waiting for approval… +
+ +
+ {deviceCode || startResult?.instructions ? ( + + ) : null} + +
+ + ) : null} + + {phase === "error" ? ( +
+ + +
+ ) : null} +
+
+
, + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 39b296af7..273206861 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -49,6 +49,10 @@ function buildStatus( localRuntimeAvailable?: boolean; localRuntimeHealth?: "ready" | "reachable" | "reachable_no_models" | "not_configured" | "unreachable"; localRuntimeBlocker?: string | null; + opencodeBinaryInstalled?: boolean; + opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>; + opencodeProvidersStale?: boolean; + modelsDevLastFetchedAt?: number | null; }, ): AiSettingsStatus { const claudeBinaryPresent = options?.claudeBinaryPresent ?? claudeRuntimeAvailable; @@ -177,7 +181,12 @@ function buildStatus( legacyPlaintextDetected: false, decryptionFailed: false, }, - }; + opencodeBinaryInstalled: options?.opencodeBinaryInstalled ?? true, + opencodeBinarySource: (options?.opencodeBinaryInstalled ?? true) ? "bundled" : "missing", + opencodeProviders: options?.opencodeProviders ?? [], + ...(options?.opencodeProvidersStale != null ? { opencodeProvidersStale: options.opencodeProvidersStale } : {}), + ...(options?.modelsDevLastFetchedAt !== undefined ? { modelsDevLastFetchedAt: options.modelsDevLastFetchedAt } : {}), + } as AiSettingsStatus; } function renderProvidersSection() { @@ -191,9 +200,11 @@ function renderProvidersSection() { describe("ProvidersSection", () => { const originalAde = globalThis.window.ade; let emitChatEvent: ((envelope: AgentChatEventEnvelope) => void) | null = null; + let emitOAuthStatus: ((event: { providerId: string; state: string; error?: string }) => void) | null = null; beforeEach(() => { emitChatEvent = null; + emitOAuthStatus = null; globalThis.window.ade = { ai: { @@ -214,6 +225,21 @@ describe("ProvidersSection", () => { verifiedAt: "2026-03-17T19:00:00.000Z", }), updateConfig: vi.fn().mockResolvedValue(undefined), + opencodeAuthMethods: vi.fn().mockResolvedValue({ methods: {} }), + opencodeOAuthStart: vi.fn().mockResolvedValue({ + url: "https://auth.openai.com/device", + method: "auto", + instructions: "Open the page and enter code ABCD-1234 to continue.", + }), + opencodeOAuthCancel: vi.fn().mockResolvedValue(undefined), + setOpencodeProviderKey: vi.fn().mockResolvedValue({ ok: true }), + refreshModelsDev: vi.fn().mockResolvedValue({ lastFetchedAt: Date.now() }), + onOpencodeOAuthStatus: vi.fn((cb: (event: { providerId: string; state: string; error?: string }) => void) => { + emitOAuthStatus = cb; + return () => { + if (emitOAuthStatus === cb) emitOAuthStatus = null; + }; + }), }, projectConfig: { get: vi.fn().mockResolvedValue({ @@ -250,7 +276,7 @@ describe("ProvidersSection", () => { }); expect(ade.ai.getStatus).toHaveBeenNthCalledWith(1, { force: false, - refreshOpenCodeInventory: false, + refreshOpenCodeInventory: true, }); expect((await screen.findAllByText("/Users/arul/ADE/apps/desktop/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude")).length).toBeGreaterThan(0); @@ -285,7 +311,7 @@ describe("ProvidersSection", () => { }); expect((await screen.findAllByText("Ready")).length).toBeGreaterThan(0); - expect(screen.getByText("Bundled Claude Agent SDK runtime")).toBeTruthy(); + expect(screen.getByText("Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.")).toBeTruthy(); expect(screen.getAllByText("/Users/arul/ADE/apps/desktop/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude").length).toBeGreaterThan(0); }); @@ -457,4 +483,112 @@ describe("ProvidersSection", () => { }); }); }); + + it("renders the Coding Agents section and the OpenCode group with the Moonshot key row", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [])); + + renderProvidersSection(); + + expect(await screen.findByText("Coding Agents")).toBeTruthy(); + expect(screen.getByText("OpenCode — Universal Model Access")).toBeTruthy(); + expect(screen.getByText("API Provider Keys")).toBeTruthy(); + // Moonshot AI was added to the API key grid. + expect(screen.getByText("Moonshot AI")).toBeTruthy(); + expect(screen.getByText("MOONSHOT_API_KEY")).toBeTruthy(); + // Kimi for Coding is always surfaced as a subscription/membership row. + expect(screen.getByLabelText("Connect Kimi for Coding")).toBeTruthy(); + }); + + it("collapses the OpenCode group to an install card when the binary is missing", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { opencodeBinaryInstalled: false })); + + renderProvidersSection(); + + expect(await screen.findByText("npm i -g opencode-ai")).toBeTruthy(); + expect(screen.getByText("brew install anomalyco/tap/opencode")).toBeTruthy(); + expect(screen.getByRole("button", { name: /Re-check/ })).toBeTruthy(); + // The group body (API keys, subscriptions) must be hidden while uninstalled. + expect(screen.queryByText("API Provider Keys")).toBeNull(); + }); + + it("renders subscription connect rows and keeps chips visible while the provider catalog is stale", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + opencodeProvidersStale: true, + opencodeProviders: [ + { id: "openai", name: "OpenAI", connected: false, modelCount: 12 }, + { id: "fireworks", name: "Fireworks", connected: false, modelCount: 7 }, + ], + })); + const authMethodsMock = window.ade.ai.opencodeAuthMethods as ReturnType; + authMethodsMock.mockReset(); + authMethodsMock.mockResolvedValue({ + methods: { + openai: [{ type: "oauth", label: "Sign in with ChatGPT" }], + }, + }); + + renderProvidersSection(); + + // OAuth subscription row built dynamically from auth methods. + expect(await screen.findByLabelText("Connect OpenAI")).toBeTruthy(); + // Stale label surfaces without blocking the catalog chips. + expect(screen.getByText("updating…")).toBeTruthy(); + // Fireworks is not an API_KEY_PROVIDER, so it appears as a "More providers" chip. + expect(screen.getByText(/Fireworks/)).toBeTruthy(); + }); + + it("drives the OAuth connect modal happy path", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + opencodeProviders: [{ id: "openai", name: "OpenAI", connected: false, modelCount: 12 }], + })); + const authMethodsMock = window.ade.ai.opencodeAuthMethods as ReturnType; + authMethodsMock.mockReset(); + authMethodsMock.mockResolvedValue({ + methods: { + openai: [{ type: "oauth", label: "Sign in with ChatGPT" }], + }, + }); + + renderProvidersSection(); + + const connectButton = await screen.findByLabelText("Connect OpenAI"); + await act(async () => { + connectButton.click(); + }); + + // Modal opened. + expect(screen.getByRole("dialog", { name: "Connect OpenAI" })).toBeTruthy(); + + await act(async () => { + screen.getByRole("button", { name: "Connect" }).click(); + }); + + await waitFor(() => { + expect(window.ade.ai.opencodeOAuthStart).toHaveBeenCalledWith({ + providerId: "openai", + methodIndex: 0, + inputs: undefined, + }); + }); + + // Waiting state renders the extracted device code. + expect(await screen.findByText("ABCD-1234")).toBeTruthy(); + + // Backend reports success → modal closes. + await act(async () => { + emitOAuthStatus?.({ providerId: "openai", state: "connected" }); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Connect OpenAI" })).toBeNull(); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 8e1a15b75..151180f02 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -9,6 +9,11 @@ import type { AiSettingsStatus, ProjectConfigSnapshot, } from "../../../shared/types"; +import type { + AiCustomProviderConfig, + OpenCodeProviderAuthMethod, + OpenCodeProviderAuthMethods, +} from "../../../shared/types/config"; import { getLocalModelIdTail, getLocalProviderDefaultEndpoint, @@ -20,8 +25,11 @@ import { import { ArrowsClockwise, CheckCircle, + Copy, Cpu, + DotsThree, Info, + MagnifyingGlass, WarningCircle, X, XCircle, @@ -33,50 +41,76 @@ import { MONO_FONT, SANS_FONT, LABEL_STYLE, - cardStyle, outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; +import { showToast } from "../app/toast/toastStore"; import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; +import { OAuthConnectModal } from "./OAuthConnectModal"; type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; +/** + * Status payload plus the OpenCode inventory freshness fields the AI service + * adds alongside the provider catalog. Typed locally as optional so this file + * stays correct before those additive members land in shared types. + */ +type ProvidersStatus = AiSettingsStatus & { + runtimeConnections?: Record; + opencodeProvidersStale?: boolean; + modelsDevLastFetchedAt?: number | null; +}; + +const KIMI_PROVIDER_ID = "kimi-for-coding"; + +const OPENCODE_INSTALL_COMMANDS = [ + "brew install anomalyco/tap/opencode", + "npm i -g opencode-ai", + "curl -fsSL https://opencode.ai/install | bash", +]; + +const CUSTOM_PROVIDER_NPM_OPTIONS = [ + "@ai-sdk/openai-compatible", + "@ai-sdk/openai", + "@ai-sdk/anthropic", +]; + const CLI_TOOLS: Array<{ cli: CliName; label: string; - description: string; + authStory: string; loginCmd: string; installHint: string; }> = [ { cli: "claude", label: "Claude Code", - description: "Claude Agent SDK runtime", + authStory: "Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.", loginCmd: "claude auth login or set ANTHROPIC_API_KEY", installHint: "npm install -g @anthropic-ai/claude-code", }, { cli: "codex", - label: "Codex", - description: "OpenAI Codex subscription", + label: "Codex CLI", + authStory: "Uses your ChatGPT sign-in — Plus/Pro subscription or OPENAI_API_KEY.", loginCmd: "codex login", installHint: "npm install -g @openai/codex", }, { cli: "cursor", label: "Cursor", - description: "Cursor SDK runtime", + authStory: "Uses CURSOR_API_KEY.", loginCmd: "Add a Cursor API key", installHint: "Get a Cursor API key from https://cursor.com/dashboard/api", }, { cli: "droid", - label: "Factory Droid", - description: "Factory Droid SDK via local CLI", + label: "Droid", + authStory: "Uses your Factory login or FACTORY_API_KEY.", loginCmd: "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)", installHint: "Install from https://docs.factory.ai/cli/getting-started/quickstart — ensure `droid` is on PATH", }, @@ -107,6 +141,7 @@ const API_KEY_PROVIDERS: Array<{ { provider: "groq", label: "Groq", envVar: "GROQ_API_KEY", placeholder: "gsk_...", accent: "#F43F5E" }, { provider: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", placeholder: "tg_...", accent: "#22C55E" }, { provider: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", placeholder: "sk-or-...", accent: "#A78BFA" }, + { provider: "moonshotai", label: "Moonshot AI", envVar: "MOONSHOT_API_KEY", placeholder: "sk-...", accent: "#7C5CFF" }, ]; type LocalProviderDraft = { @@ -116,6 +151,24 @@ type LocalProviderDraft = { preferredModelId: string; }; +type CustomProviderDraft = { + id: string; + name: string; + baseUrl: string; + npm: string; + slugs: string; + apiKey: string; +}; + +const EMPTY_CUSTOM_PROVIDER: CustomProviderDraft = { + id: "", + name: "", + baseUrl: "", + npm: CUSTOM_PROVIDER_NPM_OPTIONS[0], + slugs: "", + apiKey: "", +}; + const groupLabelStyle: React.CSSProperties = { ...LABEL_STYLE, fontSize: 11, @@ -123,6 +176,45 @@ const groupLabelStyle: React.CSSProperties = { color: COLORS.textSecondary, }; +const sectionLabelStyle: React.CSSProperties = { + fontSize: 10, + fontFamily: MONO_FONT, + textTransform: "uppercase", + letterSpacing: "1px", + color: COLORS.textMuted, + fontWeight: 700, +}; + +/** Squared bordered surface — the shared "ledger" panel used across this section. */ +function panel(overrides?: React.CSSProperties): React.CSSProperties { + return { + border: `1px solid ${COLORS.border}`, + background: COLORS.recessedBg, + padding: 12, + ...overrides, + }; +} + +function prettifyProviderId(id: string): string { + return id + .split(/[-_/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatSyncedAgo(ts: number | null | undefined): string { + if (ts == null) return "—"; + const diffMs = Date.now() - ts; + if (diffMs < 60_000) return "just now"; + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + function AlertBanner({ tone, message, @@ -203,6 +295,64 @@ function SourceBadge({ source }: { source: ApiKeySource }) { ); } +function ConnectedTag() { + return ( + + Connected + + ); +} + +function CopyableCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable. + } + }; + return ( + + ); +} + function getStatusTone(connection: AiProviderConnectionStatus | null | undefined): { color: string; label: string } { if (connection?.runtimeAvailable) return { color: COLORS.success, label: "Connected" }; if (connection?.runtimeDetected || connection?.authAvailable) return { color: COLORS.warning, label: "Sign-In Required" }; @@ -267,7 +417,7 @@ function formatLocalModelLabel(modelId: string): string { function buildLocalProviderDrafts( snapshot: ProjectConfigSnapshot | null | undefined, - status: (AiSettingsStatus & { runtimeConnections?: Record }) | null | undefined, + status: ProvidersStatus | null | undefined, ): Record { const configured = snapshot?.effective.ai?.localProviders ?? {}; return Object.fromEntries( @@ -290,7 +440,7 @@ function buildLocalProviderDrafts( export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefreshOnMount?: boolean }) { const navigate = useNavigate(); - const [status, setStatus] = useState<(AiSettingsStatus & { runtimeConnections?: Record }) | null>(null); + const [status, setStatus] = useState(null); const [projectConfigSnapshot, setProjectConfigSnapshot] = useState(null); const [storedProviders, setStoredProviders] = useState([]); const [loading, setLoading] = useState(true); @@ -306,12 +456,22 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const [dismissedApiKeyStoreWarning, setDismissedApiKeyStoreWarning] = useState(null); const [verifyingProvider, setVerifyingProvider] = useState(null); const [verificationByProvider, setVerificationByProvider] = useState>({}); + const [authMethods, setAuthMethods] = useState(null); + const [oauthTarget, setOauthTarget] = useState<{ providerId: string; providerName: string; methods: OpenCodeProviderAuthMethod[] } | null>(null); + const [kimiDialogOpen, setKimiDialogOpen] = useState(false); + const [providerSearch, setProviderSearch] = useState(""); + const [verifyingAll, setVerifyingAll] = useState(false); + const [openRowMenu, setOpenRowMenu] = useState(null); + const [refreshingCatalog, setRefreshingCatalog] = useState(false); + const [customProviderDraft, setCustomProviderDraft] = useState(EMPTY_CUSTOM_PROVIDER); + const [customModelSlugs, setCustomModelSlugs] = useState(""); + const [savingAdvanced, setSavingAdvanced] = useState(false); const pendingRefreshTimerRef = useRef(null); const revealClaudeLoginTerminalInWork = useCallback((terminal: { terminalId: string; laneId: string }) => { revealTerminalSessionInWork(navigate, terminal); }, [navigate]); - const refreshStatus = useCallback(async (options?: { force?: boolean; silent?: boolean; refreshOpenCodeInventory?: boolean }) => { + const refreshStatus = useCallback(async (options?: { force?: boolean; silent?: boolean; refreshOpenCodeInventory?: boolean }): Promise => { if (!options?.silent) { setLoading(true); } @@ -325,14 +485,16 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh window.ade.ai.listApiKeys(), window.ade.projectConfig.get(), ]); - setStatus(nextStatus); + setStatus(nextStatus as ProvidersStatus); setProjectConfigSnapshot(nextProjectConfig); if (editingLocalProvider == null && savingLocalProvider == null) { - setLocalProviderDrafts(buildLocalProviderDrafts(nextProjectConfig, nextStatus)); + setLocalProviderDrafts(buildLocalProviderDrafts(nextProjectConfig, nextStatus as ProvidersStatus)); } setStoredProviders(nextStoredProviders.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); + return nextStatus as ProvidersStatus; } catch (err) { setError(err instanceof Error ? err.message : String(err)); + return null; } finally { if (!options?.silent) { setLoading(false); @@ -340,11 +502,24 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } }, [editingLocalProvider, savingLocalProvider]); + const loadAuthMethods = useCallback(async () => { + try { + const result = await window.ade.ai.opencodeAuthMethods(); + setAuthMethods(result.methods ?? {}); + } catch { + // Best-effort — subscription connect rows simply stay hidden if this + // capability is not available yet. + } + }, []); + useEffect(() => { void refreshStatus({ force: forceRefreshOnMount, + refreshOpenCodeInventory: true, }); - }, [forceRefreshOnMount, refreshStatus]); + void loadAuthMethods(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [forceRefreshOnMount]); useEffect(() => { const unsubscribe = window.ade.agentChat.onEvent((envelope) => { @@ -368,6 +543,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const providerConnections = status?.providerConnections; const isInitialCheckInFlight = loading && status == null; const catalogModelIds = useMemo(() => deriveConfiguredModelIds(status), [status]); + const opencodeInstalled = status?.opencodeBinaryInstalled !== false; + const opencodeProviders = useMemo(() => status?.opencodeProviders ?? [], [status?.opencodeProviders]); + const providersStale = status?.opencodeProvidersStale === true; const apiKeySources = useMemo(() => { const map = new Map(); @@ -381,6 +559,11 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh return map; }, [detectedAuth]); + const hasKeyFor = useCallback( + (providerId: string) => apiKeySources.has(providerId) || storedProviders.includes(providerId), + [apiKeySources, storedProviders], + ); + const localRuntimes = useMemo(() => { const availableModelIds = status?.availableModelIds ?? []; const runtimeConnections = status?.runtimeConnections ?? {}; @@ -429,11 +612,61 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh ? apiKeyStoreWarning : null; + // ── Subscription (OAuth + Kimi membership) rows ── + const subscriptionRows = useMemo(() => { + const nameById = new Map(opencodeProviders.map((p) => [p.id, p.name] as const)); + const oauthIds = authMethods + ? Object.keys(authMethods).filter((id) => authMethods[id]?.some((m) => m.type === "oauth")) + : []; + const rows: Array<{ + id: string; + name: string; + methods: OpenCodeProviderAuthMethod[]; + kind: "oauth" | "kimi"; + connected: boolean; + }> = oauthIds + .sort((a, b) => a.localeCompare(b)) + .map((id) => ({ + id, + name: nameById.get(id) ?? prettifyProviderId(id), + methods: authMethods?.[id] ?? [], + kind: "oauth" as const, + connected: opencodeProviders.find((p) => p.id === id)?.connected === true, + })); + // Kimi for Coding is an API-membership key, not OAuth — always surfaced here. + rows.push({ + id: KIMI_PROVIDER_ID, + name: "Kimi for Coding", + methods: [], + kind: "kimi" as const, + connected: + opencodeProviders.find((p) => p.id === KIMI_PROVIDER_ID)?.connected === true + || hasKeyFor(KIMI_PROVIDER_ID), + }); + return rows; + }, [authMethods, opencodeProviders, hasKeyFor]); + + const keyCount = useMemo(() => { + const ids = new Set(); + for (const p of API_KEY_PROVIDERS) { + if (hasKeyFor(p.provider)) ids.add(p.provider); + } + for (const id of storedProviders) ids.add(id); + for (const id of apiKeySources.keys()) ids.add(id); + return ids.size; + }, [hasKeyFor, storedProviders, apiKeySources]); + + const connectedSubscriptionCount = useMemo( + () => subscriptionRows.filter((row) => row.connected).length, + [subscriptionRows], + ); + const beginEditing = (provider: string) => { setEditingProvider(provider); setEditValue(""); setError(null); setNotice(null); + setOpenRowMenu(null); }; const cancelEditing = () => { @@ -441,7 +674,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setEditValue(""); }; - const saveApiKey = async (provider: string) => { + const saveApiKey = async (provider: string, options?: { alsoOpenCode?: boolean }) => { const trimmed = editValue.trim(); if (!trimmed) return; @@ -449,6 +682,13 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setNotice(null); try { await window.ade.ai.storeApiKey(provider, trimmed); + if (options?.alsoOpenCode) { + try { + await window.ade.ai.setOpencodeProviderKey({ providerId: provider, key: trimmed }); + } catch { + // OpenCode registration is best-effort; the stored key still applies. + } + } invalidateAiDiscoveryCache(); setVerificationByProvider((prev) => { const next = { ...prev }; @@ -466,6 +706,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const deleteApiKey = async (provider: string) => { setError(null); setNotice(null); + setOpenRowMenu(null); try { await window.ade.ai.deleteApiKey(provider); invalidateAiDiscoveryCache(); @@ -485,6 +726,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const verifyApiKey = async (provider: string) => { setError(null); setNotice(null); + setOpenRowMenu(null); setVerifyingProvider(provider); setVerificationByProvider((prev) => { const next = { ...prev }; @@ -509,6 +751,30 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } }; + const verifyAllKeys = async () => { + const targets = API_KEY_PROVIDERS.filter((p) => hasKeyFor(p.provider)).map((p) => p.provider); + if (!targets.length) return; + setVerifyingAll(true); + setError(null); + setNotice(null); + try { + for (const provider of targets) { + setVerifyingProvider(provider); + try { + const result = await window.ade.ai.verifyApiKey(provider); + setVerificationByProvider((prev) => ({ ...prev, [provider]: result })); + } catch { + // Continue verifying the remaining providers. + } + } + invalidateAiDiscoveryCache(); + await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + } finally { + setVerifyingProvider(null); + setVerifyingAll(false); + } + }; + const saveCursorApiKey = async () => { const trimmed = editValue.trim(); if (!trimmed) return; @@ -542,6 +808,109 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } }; + const handleRefreshCatalog = async () => { + setRefreshingCatalog(true); + try { + await window.ade.ai.refreshModelsDev(); + await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRefreshingCatalog(false); + } + }; + + const handleSubscriptionConnected = useCallback(async (providerId: string, providerName: string) => { + const before = status?.availableModelIds?.length ?? 0; + const next = await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + void loadAuthMethods(); + const after = next?.availableModelIds?.length ?? before; + const modelCount = + next?.opencodeProviders?.find((p) => p.id === providerId)?.modelCount + ?? Math.max(0, after - before); + showToast({ + tone: "success", + title: `${providerName} connected`, + message: `${modelCount} model${modelCount === 1 ? "" : "s"} added`, + }); + }, [status?.availableModelIds, refreshStatus, loadAuthMethods]); + + const saveKimiKey = async (key: string) => { + const trimmed = key.trim(); + if (!trimmed) return; + setError(null); + setNotice(null); + try { + const result = await window.ade.ai.setOpencodeProviderKey({ providerId: KIMI_PROVIDER_ID, key: trimmed }); + if (result && result.ok === false) { + setError(result.error || "Failed to save Kimi for Coding key."); + return; + } + invalidateAiDiscoveryCache(); + setKimiDialogOpen(false); + setNotice("Kimi for Coding connected."); + await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const saveAdvancedProvider = async () => { + const draft = customProviderDraft; + const id = draft.id.trim(); + const slugs = draft.slugs.split(",").map((s) => s.trim()).filter(Boolean); + if (!id || slugs.length === 0) { + setError("A custom provider needs an id and at least one model slug."); + return; + } + setSavingAdvanced(true); + setError(null); + setNotice(null); + try { + if (draft.apiKey.trim()) { + await window.ade.ai.storeApiKey(id, draft.apiKey.trim()); + } + await window.ade.ai.updateConfig({ + customProviders: [ + { + id, + name: draft.name.trim() || prettifyProviderId(id), + baseURL: draft.baseUrl.trim(), + npm: draft.npm as AiCustomProviderConfig["npm"], + models: slugs, + }, + ], + }); + invalidateAiDiscoveryCache(); + setNotice(`Custom provider ${id} saved.`); + setCustomProviderDraft(EMPTY_CUSTOM_PROVIDER); + await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingAdvanced(false); + } + }; + + const saveCustomModelSlugs = async () => { + const slugs = customModelSlugs.split(",").map((s) => s.trim()).filter(Boolean); + setSavingAdvanced(true); + setError(null); + setNotice(null); + try { + await window.ade.ai.updateConfig({ + customModelSlugs: slugs, + }); + invalidateAiDiscoveryCache(); + setNotice("Custom model slugs saved."); + await refreshStatus({ force: true, refreshOpenCodeInventory: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingAdvanced(false); + } + }; + const updateLocalProviderDraft = useCallback(( provider: LocalProviderFamily, patch: Partial, @@ -594,6 +963,19 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } }, [localProviderDrafts, refreshStatus]); + // ── More Providers chip cloud ── + const moreProviders = useMemo(() => { + const query = providerSearch.trim().toLowerCase(); + const filtered = opencodeProviders + .filter((p) => !p.connected + && p.id !== "cursor" + && !API_KEY_PROVIDERS.some((a) => a.provider === p.id) + && !["ollama", "lmstudio"].includes(p.id)) + .filter((p) => !query || p.id.toLowerCase().includes(query) || p.name.toLowerCase().includes(query)) + .sort((a, b) => b.modelCount - a.modelCount); + return { list: filtered, query }; + }, [opencodeProviders, providerSearch]); + return (
{notice && ( @@ -612,411 +994,156 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh /> )} - {/* ── Model Availability Summary ── */} - {(() => { - const readyCount = catalogModelIds.length; - const cliProviders = CLI_TOOLS.map((t) => ({ - label: t.label, - connected: providerConnections?.[t.cli]?.runtimeAvailable === true, - accent: undefined as string | undefined, - })); - // Use dynamic OpenCode provider list when available, fall back to hardcoded - const ocProviders = status?.opencodeProviders; - const opencodeProviderDots = ocProviders?.length - ? ocProviders - .filter((p) => p.connected) - .map((p) => ({ label: p.name, connected: true, accent: undefined as string | undefined })) - : []; - const allProviders = [...cliProviders, ...opencodeProviderDots]; - const connectedCount = allProviders.filter((p) => p.connected).length; - return ( -
-
-
- {readyCount} model{readyCount !== 1 ? "s" : ""} ready across {connectedCount} provider{connectedCount !== 1 ? "s" : ""} -
- -
-
- {allProviders.map((p) => ( - - - {p.label} - - ))} -
-
- ); - })()} - - {/* ── CLI Runtimes ── */} -
CLI Runtimes
- - {/* ── Claude ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "claude")!; - const connection = providerConnections?.[tool.cli] ?? null; - const availability = status?.availableProviders?.claude ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getClaudeAvailabilityTone(availability); - const message = isInitialCheckInFlight ? "Checking Claude SDK binary and login status." : buildClaudeAvailabilityMessage(availability); - const binaryPath = availability?.binary.path ?? connection?.path ?? null; - const binaryLabel = availability?.binary.source === "bundled" - ? "Bundled Claude Agent SDK runtime" - : availability?.binary.source === "path" - ? "Claude runtime from PATH" - : "Claude Agent SDK runtime"; - return ( -
-
-
- -
-
Claude
-
- {binaryLabel} -
-
-
-
- {isInitialCheckInFlight ? : availability?.auth.ready ? : availability?.binary.present ? : } - {tone.label} -
-
-
{message}
- {!isInitialCheckInFlight && availability?.binary.present && !availability.auth.ready ? ( -
- -
- ) : null} - {credentialSourceDesc && !availability?.auth.ready && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {binaryPath && !isInitialCheckInFlight ? {binaryPath} : null} -
- ); - })()} - - {/* ── Codex ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "codex")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); - return ( -
-
-
- -
-
Codex
-
- OpenAI native runtime via Codex CLI + {/* ══ Coding Agents ══ */} +
+
Coding Agents
+ + {/* ── Claude Code ── */} + {(() => { + const tool = CLI_TOOLS.find((t) => t.cli === "claude")!; + const connection = providerConnections?.[tool.cli] ?? null; + const availability = status?.availableProviders?.claude ?? null; + const credentialSourceDesc = describeCredentialSource(connection); + const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getClaudeAvailabilityTone(availability); + const message = isInitialCheckInFlight ? "Checking Claude SDK binary and login status." : buildClaudeAvailabilityMessage(availability); + const binaryPath = availability?.binary.path ?? connection?.path ?? null; + return ( +
+
+
+ +
+
Claude Code
+
{tool.authStory}
-
-
- {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} -
- ); - })()} - - {/* ── Cursor ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "cursor")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const keySource = apiKeySources.get("cursor") ?? (storedProviders.includes("cursor") ? "store" : undefined); - const verification = verificationByProvider.cursor; - const isEditing = editingProvider === "cursor"; - const isVerifying = verifyingProvider === "cursor"; - const isVerified = !isVerifying && verification?.ok; - const isInvalid = !isVerifying && verification && !verification.ok; - const isKeyConnected = Boolean(isVerified || (!isInvalid && keySource && connection?.runtimeAvailable)); - const tone = isVerifying - ? { color: COLORS.info, label: "Verifying" } - : isVerified - ? { color: COLORS.success, label: "Connected" } - : isInvalid - ? { color: COLORS.danger, label: "Verification failed" } - : isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isVerifying - ? "Verifying Cursor API key with the Cursor SDK." - : isVerified - ? "Cursor SDK connected. ADE uses this key for Cursor chat and Cursor Cloud agents." - : isInvalid - ? verification.message - : isInitialCheckInFlight ? "Checking Cursor SDK API key." : (connection?.blocker ?? "Enter a Cursor API key."); - return ( -
-
-
- -
-
Cursor
-
- Cursor native runtime via the Cursor SDK -
+
+ {isInitialCheckInFlight ? : availability?.auth.ready ? : availability?.binary.present ? : } + {tone.label}
-
- {isVerifying || isInitialCheckInFlight ? : isVerified ? : isInvalid ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} -
- Get key from Cursor API dashboard:{" "} - - https://cursor.com/dashboard/api - -
-
-
-
API key
-
CURSOR_API_KEY
-
-
- {isEditing ? ( - setEditValue(event.target.value)} - placeholder="crsr_..." - type="password" - disabled={isVerifying} - style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} +
{message}
+ {!isInitialCheckInFlight && availability?.binary.present && !availability.auth.ready ? ( +
+ - ) : keySource ? ( -
- - {isVerifying ? ( - - - Verifying... - - ) : isKeyConnected ? ( - - - Connected - - ) : verification ? ( - - {verification.ok ? : } - {verification.ok ? "Verified" : verification.message} - - ) : ( - - {keySource === "env" ? "Loaded from environment" : keySource === "config" ? "Defined in project config" : "Stored locally"} - - )} -
- ) : ( - No Cursor API key configured - )} -
-
- {isEditing ? ( - <> - - - - ) : keySource ? ( - <> - {isKeyConnected ? ( - - Connected - - ) : ( - - )} - {keySource === "store" ? ( - <> - - - - ) : null} - - ) : ( - - )} -
-
-
- ); - })()} - - {/* ── Droid ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "droid")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); - return ( -
-
-
- -
-
Factory Droid
-
- Factory Droid SDK runtime via local Droid CLI +
+ ) : null} + {credentialSourceDesc && !availability?.auth.ready && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} + {binaryPath && !isInitialCheckInFlight ? {binaryPath} : null} +
+ ); + })()} + + {/* ── Codex CLI ── */} + {(() => { + const tool = CLI_TOOLS.find((t) => t.cli === "codex")!; + const connection = providerConnections?.[tool.cli] ?? null; + const credentialSourceDesc = describeCredentialSource(connection); + const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); + const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); + return ( +
+
+
+ +
+
Codex CLI
+
{tool.authStory}
+
+ {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } + {tone.label} +
-
- {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} - - ); - })()} - - {/* ── OpenCode Status ── */} -
-
-
- -
-
OpenCode Runtime
-
- Powers all API-backed and local model chats -
-
-
-
- {status?.opencodeBinaryInstalled === false ? ( - - ) : status?.opencodeInventoryError ? ( - - ) : ( - - )} - - {status?.opencodeBinaryInstalled === false ? "Not found" : status?.opencodeInventoryError ? "Error" : "Installed"} - -
-
- - {status?.opencodeBinaryInstalled === false ? ( -
- OpenCode CLI was not found on your PATH. Install OpenCode and ensure the opencode binary is discoverable, then use Refresh. - The API keys and local model servers below require OpenCode to function. -
- ) : status?.opencodeInventoryError ? ( -
- Model inventory failed: {status.opencodeInventoryError} -
- ) : null} -
+
{message}
+ {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} + {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} + + ); + })()} - {/* ── API Provider Keys ── */} -
-
API Provider Keys
-
- Add API keys to unlock API-backed models. Most API-backed models run through OpenCode. - {status?.opencodeProviders?.length ? ( - - Showing popular providers. OpenCode supports {status.opencodeProviders.length} total — add any provider by ID below. - - ) : null} -
-
- {API_KEY_PROVIDERS.map((provider) => { - const keySource = apiKeySources.get(provider.provider) ?? (storedProviders.includes(provider.provider) ? "store" : undefined); - const verification = verificationByProvider[provider.provider]; - const isEditing = editingProvider === provider.provider; - return ( -
-
- -
-
{provider.label}
-
{provider.envVar}
+ {/* ── Cursor ── */} + {(() => { + const tool = CLI_TOOLS.find((t) => t.cli === "cursor")!; + const connection = providerConnections?.[tool.cli] ?? null; + const credentialSourceDesc = describeCredentialSource(connection); + const keySource = apiKeySources.get("cursor") ?? (storedProviders.includes("cursor") ? "store" : undefined); + const verification = verificationByProvider.cursor; + const isEditing = editingProvider === "cursor"; + const isVerifying = verifyingProvider === "cursor"; + const isVerified = !isVerifying && verification?.ok; + const isInvalid = !isVerifying && verification && !verification.ok; + const isKeyConnected = Boolean(isVerified || (!isInvalid && keySource && connection?.runtimeAvailable)); + const tone = isVerifying + ? { color: COLORS.info, label: "Verifying" } + : isVerified + ? { color: COLORS.success, label: "Connected" } + : isInvalid + ? { color: COLORS.danger, label: "Verification failed" } + : isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); + const message = isVerifying + ? "Verifying Cursor API key with the Cursor SDK." + : isVerified + ? "Cursor SDK connected. ADE uses this key for Cursor chat and Cursor Cloud agents." + : isInvalid + ? verification.message + : isInitialCheckInFlight ? "Checking Cursor SDK API key." : (connection?.blocker ?? "Enter a Cursor API key."); + return ( +
+
+
+ +
+
Cursor
+
{tool.authStory}
+
+ {isVerifying || isInitialCheckInFlight ? : isVerified ? : isInvalid ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } + {tone.label} +
+
+
{message}
+ {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} +
+
+
API key
+
CURSOR_API_KEY
+
{isEditing ? ( setEditValue(event.target.value)} - placeholder={provider.placeholder} + placeholder="crsr_..." type="password" + disabled={isVerifying} style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> ) : keySource ? (
- {verification ? ( - - {verification.ok ? : } + {isVerifying ? ( + + + Verifying... + + ) : isKeyConnected ? ( + + + Connected + + ) : verification ? ( + + {verification.ok ? : } {verification.ok ? "Verified" : verification.message} ) : ( @@ -1026,264 +1153,580 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh )}
) : ( - No key configured + No Cursor API key configured )}
-
+
{isEditing ? ( <> - - + + ) : keySource ? ( <> - {verification?.ok ? ( + {isKeyConnected ? ( - Verified - - ) : verification && !verification.ok ? ( - - Failed + Connected ) : ( - )} {keySource === "store" ? ( <> - - + + ) : null} ) : ( - + )}
- ); - })} - - {/* ── Add custom provider ── */} - {(() => { - const dynamicProviders = (status?.opencodeProviders ?? []) - .filter((p) => !p.connected && p.id !== "cursor" && !API_KEY_PROVIDERS.some((a) => a.provider === p.id) && !["ollama", "lmstudio"].includes(p.id)) - .sort((a, b) => b.modelCount - a.modelCount); - if (!dynamicProviders.length && !editingProvider?.startsWith("__custom:")) return null; - return ( -
-
- More providers ({dynamicProviders.length} available) +
+ ); + })()} + + {/* ── Droid ── */} + {(() => { + const tool = CLI_TOOLS.find((t) => t.cli === "droid")!; + const connection = providerConnections?.[tool.cli] ?? null; + const credentialSourceDesc = describeCredentialSource(connection); + const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); + const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); + return ( +
+
+
+ +
+
Droid
+
{tool.authStory}
+
-
- {dynamicProviders.slice(0, 30).map((p) => { - const hasKey = storedProviders.includes(p.id) || apiKeySources.has(p.id); - const isEditing = editingProvider === `__custom:${p.id}`; - return isEditing ? ( -
- {p.name} - setEditValue(event.target.value)} - placeholder="API key" - type="password" - style={{ flex: 1, background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} - /> - - -
- ) : ( - - ); - })} - {dynamicProviders.length > 30 ? ( - - +{dynamicProviders.length - 30} more - - ) : null} +
+ {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } + {tone.label}
- ); - })()} -
+
{message}
+ {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} + {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} +
+ ); + })()}
- {/* ── Local Model Servers ── */} -
- {status?.opencodeBinaryInstalled === false && ( -
- - Install OpenCode to use local models + {/* ══ OpenCode — Universal Model Access ══ */} +
+ {/* Group header */} +
+
+ +
+
+ OpenCode — Universal Model Access +
+
+ {status?.opencodeBinarySource ? `${status.opencodeBinarySource} · ` : ""}managed by ADE +
+
+
+
+ {!opencodeInstalled ? : status?.opencodeInventoryError ? : } + + {!opencodeInstalled ? "Not found" : status?.opencodeInventoryError ? "Error" : "Installed"}
- )} -
-
Local Model Servers
- -
-
- Connect to LM Studio or Ollama running on your machine.
-
- {localRuntimes.map((entry) => { - const isEditing = editingLocalProvider === entry.provider; - const isSaving = savingLocalProvider === entry.provider; - const draft = localProviderDrafts[entry.provider]; - const hasReadyRuntime = entry.runtimeAvailable || (entry.detected && entry.hasModels); - const needsModelLoad = !hasReadyRuntime && !entry.hasModels && (entry.health === "reachable" || entry.health === "reachable_no_models"); - const tone = hasReadyRuntime - ? { color: COLORS.success, label: entry.hasModels ? "Ready" : "Connected" } - : needsModelLoad - ? { color: COLORS.warning, label: "Load a model" } - : entry.blocker - ? { color: COLORS.warning, label: "Blocked" } - : { color: COLORS.warning, label: "Not detected" }; - const loadedModels = entry.modelIds.slice(0, 4); - const extraModelCount = Math.max(0, entry.modelIds.length - loadedModels.length); - const message = entry.blocker - ? entry.blocker - : entry.detected - ? entry.hasModels - ? `${entry.label} is reachable at ${entry.endpoint}. ADE can use ${entry.modelIds.length} loaded model${entry.modelIds.length === 1 ? "" : "s"} from this runtime${entry.health ? ` (${entry.health})` : ""}.` - : `${entry.label} responded, but no loaded models were reported yet. Load a model in ${entry.label} and refresh.` - : `${entry.label} was not detected. Start it, load at least one model, then refresh so ADE can discover its OpenAI-compatible server.`; - - return ( -
+
+ OpenCode powers every subscription, API key, and local model below. Install it, then re-check: +
+
+ {OPENCODE_INSTALL_COMMANDS.map((cmd) => ( + + ))} +
+
+ +
+
+ ) : ( +
+ {/* Summary strip + catalog freshness */} +
+
+ {connectedSubscriptionCount} subscription{connectedSubscriptionCount === 1 ? "" : "s"} · {keyCount} key{keyCount === 1 ? "" : "s"} · {catalogModelIds.length} model{catalogModelIds.length === 1 ? "" : "s"} unlocked + {providersStale ? updating… : null} +
+ +
+ +
+ {/* ── a. Subscriptions ── */} +
+
Subscriptions
+
+ {subscriptionRows.map((row) => ( +
+
+
+ + {row.name} +
+ {row.connected ? ( + + ) : ( + + )} +
+ {row.id === "openai" ? ( +
+ Also powers OpenAI models inside OpenCode. For the Codex agent, connect the Codex CLI above. +
+ ) : null}
+ ))} +
+ {subscriptionRows.length <= 1 && !authMethods ? ( +
+ {providersStale ? "Loading available subscriptions…" : "No OAuth subscriptions are available from OpenCode yet."}
-
- {hasReadyRuntime ? : needsModelLoad || entry.blocker ? : } - {tone.label} -
+ ) : null} +
+ + {/* ── b. API Provider Keys ── */} +
+
+
API Provider Keys
+
+
+ {API_KEY_PROVIDERS.map((provider) => { + const keySource = apiKeySources.get(provider.provider) ?? (storedProviders.includes(provider.provider) ? "store" : undefined); + const verification = verificationByProvider[provider.provider]; + const isEditing = editingProvider === provider.provider; + const isVerifying = verifyingProvider === provider.provider; + const menuOpen = openRowMenu === provider.provider; + return ( +
+
+ +
+
{provider.label}
+
{provider.envVar}
+
+ {!isEditing ? ( +
+ {isVerifying ? ( + Checking… + ) : verification?.ok ? ( + + Verified + + ) : verification && !verification.ok ? ( + + Failed + + ) : keySource ? ( + + ) : ( + + )} + {keySource ? ( + + ) : null} +
+ ) : null} +
-
{message}
+ {isEditing ? ( +
+ setEditValue(event.target.value)} + placeholder={provider.placeholder} + type="password" + style={{ flex: 1, minWidth: 0, background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + + +
+ ) : null} -
- - {draft?.enabled === false ? "Disabled" : "Enabled"} - - - {draft?.autoDetect === false ? "Manual only" : "Auto-detect fallback"} - + {menuOpen && keySource ? ( + <> +
setOpenRowMenu(null)} /> +
+ + {keySource === "store" ? ( + <> + + + + ) : null} +
+ + ) : null} +
+ ); + })}
+
- - {draft?.endpoint?.trim() || entry.endpoint} - + {/* ── c. More Providers ── */} +
+
+
More Providers
+
+ + setProviderSearch(event.target.value)} + placeholder="Search providers" + style={{ flex: 1, minWidth: 0, background: "transparent", border: "none", outline: "none", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary }} + /> +
+
+ {moreProviders.list.length === 0 ? ( +
+ {moreProviders.query ? "No providers match your search." : "No additional providers available."} +
+ ) : ( +
+ {(moreProviders.query ? moreProviders.list : moreProviders.list.slice(0, 30)).map((p) => { + const hasKey = hasKeyFor(p.id); + const isEditing = editingProvider === `__custom:${p.id}`; + return isEditing ? ( +
+ {p.name} + setEditValue(event.target.value)} + placeholder="API key" + type="password" + style={{ flex: 1, background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + + +
+ ) : ( + + ); + })} + {!moreProviders.query && moreProviders.list.length > 30 ? ( + + +{moreProviders.list.length - 30} more — search to filter + + ) : null} +
+ )} +
-
- {loadedModels.length > 0 ? ( - <> - {loadedModels.map((modelId) => ( - - - {formatLocalModelLabel(modelId)} - - ))} - {extraModelCount > 0 ? ( - - +{extraModelCount} more - - ) : null} - - ) : ( - No loaded models reported yet. - )} + {/* ── d. Local Model Servers ── */} +
+
+
Local Model Servers
+
+
+ {localRuntimes.map((entry) => { + const isEditing = editingLocalProvider === entry.provider; + const isSaving = savingLocalProvider === entry.provider; + const draft = localProviderDrafts[entry.provider]; + const hasReadyRuntime = entry.runtimeAvailable || (entry.detected && entry.hasModels); + const needsModelLoad = !hasReadyRuntime && !entry.hasModels && (entry.health === "reachable" || entry.health === "reachable_no_models"); + const tone = hasReadyRuntime + ? { color: COLORS.success, label: entry.hasModels ? "Ready" : "Connected" } + : needsModelLoad + ? { color: COLORS.warning, label: "Load a model" } + : entry.blocker + ? { color: COLORS.warning, label: "Blocked" } + : { color: COLORS.warning, label: "Not detected" }; + const loadedModels = entry.modelIds.slice(0, 4); + const extraModelCount = Math.max(0, entry.modelIds.length - loadedModels.length); + const message = entry.blocker + ? entry.blocker + : entry.detected + ? entry.hasModels + ? `${entry.label} is reachable at ${entry.endpoint}. ADE can use ${entry.modelIds.length} loaded model${entry.modelIds.length === 1 ? "" : "s"} from this runtime${entry.health ? ` (${entry.health})` : ""}.` + : `${entry.label} responded, but no loaded models were reported yet. Load a model in ${entry.label} and refresh.` + : `${entry.label} was not detected. Start it, load at least one model, then refresh so ADE can discover its OpenAI-compatible server.`; + + return ( +
+
+
+ +
+
{entry.label}
+
{entry.description}
+
+
+
+ {hasReadyRuntime ? : needsModelLoad || entry.blocker ? : } + {tone.label} +
+
+ +
{message}
- {isEditing && draft ? ( -
- - - - + + + +
+ ) : null} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+
+ + {/* ── e. Advanced ── */} +
+ + Advanced — custom providers & model slugs + +
+ {/* Custom provider */} +
+
Custom provider
+
+ setCustomProviderDraft((d) => ({ ...d, id: e.target.value }))} placeholder="provider-id" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + setCustomProviderDraft((d) => ({ ...d, name: e.target.value }))} placeholder="Display name" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textPrimary, outline: "none" }} /> + setCustomProviderDraft((d) => ({ ...d, baseUrl: e.target.value }))} placeholder="https://api.example.com/v1" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + - + setCustomProviderDraft((d) => ({ ...d, slugs: e.target.value }))} placeholder="model-a, model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + setCustomProviderDraft((d) => ({ ...d, apiKey: e.target.value }))} placeholder="API key (optional)" type="password" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> +
+
+ +
- ) : null} -
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} + {/* Custom model slugs */} +
+
Custom model slugs
+ setCustomModelSlugs(e.target.value)} placeholder="provider/model-a, provider/model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> +
+ +
+
-
- ); - })} +
+
+
+ )} +
+ + {oauthTarget ? ( + setOauthTarget(null)} + onConnected={() => void handleSubscriptionConnected(oauthTarget.providerId, oauthTarget.providerName)} + /> + ) : null} + + {kimiDialogOpen ? ( + setKimiDialogOpen(false)} + onSave={(key) => void saveKimiKey(key)} + /> + ) : null} +
+ ); +} + +function KimiKeyDialog({ onClose, onSave }: { onClose: () => void; onSave: (key: string) => void }) { + const [value, setValue] = useState(""); + return ( +
+
event.stopPropagation()} + > +
+
+ +
Connect Kimi for Coding
+
+
-
- - If LM Studio is running but ADE does not show it, load at least one model in LM Studio, then use Refresh. ADE only marks a local runtime as ready after /v1/models returns loaded models. +
+
+ Paste your Kimi for Coding membership key. It is stored via OpenCode. +
+ setValue(event.target.value)} + placeholder="sk-..." + type="password" + style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 12, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> +
+ + +
-
); } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index e9633af66..9ad39f422 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -770,6 +770,105 @@ describe("ModelPicker", () => { expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); }); + it("dims configuration-gated models and routes their click to sign-in even when the family is authed", async () => { + const user = userEvent.setup(); + // Anthropic family is fully authed, so the family-ready shortcut would + // otherwise mark every Claude row available. The per-model + // requiresConfiguration annotation must still dim the gated row. + providerAuthStatusInternal = { anthropic: "ok" }; + const onOpenSignIn = vi.fn(); + const catalog = { + groups: [ + { + key: "claude", + displayName: "Claude", + providers: [ + { + key: "anthropic", + displayName: "Anthropic", + badgeColor: "#D97706", + modelCount: 2, + subsections: [ + { + key: "__default__", + label: "", + models: [ + { + id: SONNET.id, + runtimeModelId: SONNET.id, + provider: "anthropic", + providerKey: "anthropic", + providerId: "anthropic", + providerName: "Anthropic", + groupKey: "claude", + displayName: SONNET.displayName, + isDefault: true, + isAvailable: true, + requiresConfiguration: false, + supportsReasoning: true, + supportsTools: true, + }, + { + id: OPUS.id, + runtimeModelId: OPUS.id, + provider: "anthropic", + providerKey: "anthropic", + providerId: "anthropic", + providerName: "Anthropic", + groupKey: "claude", + displayName: OPUS.displayName, + isDefault: false, + isAvailable: false, + requiresConfiguration: true, + supportsReasoning: true, + supportsTools: true, + }, + ], + }, + ], + }, + ], + }, + ], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + } as unknown as AgentChatModelCatalog; + const modelCatalog = vi.fn(async () => catalog); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + const { onChange } = renderPicker({ + value: SONNET.id, + models: [], + filter: (m: ModelDescriptor) => m.id === SONNET.id || m.id === OPUS.id, + onOpenSignIn, + }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + await waitFor(() => { + expect( + screen.getAllByRole("option").some((el) => el.getAttribute("data-model-id") === OPUS.id), + ).toBe(true); + }); + + const rows = screen.getAllByRole("option"); + const opusRow = rows.find((el) => el.getAttribute("data-model-id") === OPUS.id)!; + const sonnetRow = rows.find((el) => el.getAttribute("data-model-id") === SONNET.id)!; + + // Gated row is dimmed (aria-disabled); the available sibling is not. + expect(opusRow.getAttribute("aria-disabled")).toBe("true"); + expect(sonnetRow.getAttribute("aria-disabled")).toBeNull(); + + // Clicking the gated row routes to the settings/connect affordance and does + // not select the model. + await user.click(opusRow); + expect(onOpenSignIn.mock.calls[0]?.[0]).toBe("anthropic"); + expect(onChange).not.toHaveBeenCalled(); + }); + it("does not render the Set up banner when the active rail is authed", async () => { const user = userEvent.setup(); providerAuthStatusInternal = { anthropic: "ok", openai: "unauthed" }; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index 84d73eb24..cd7716dfa 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -27,6 +27,7 @@ import { useProviderAuthStatus } from "./useProviderAuthStatus"; import { scoreModelPickerSearch } from "./modelPickerSearch"; import { sortModelItems } from "./modelOrdering"; import { ProviderEmptyState, ProviderSetupBanner } from "./providerEmptyState"; +import type { RuntimeCatalogModelDescriptor } from "./modelCatalog"; import type { AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; const MODEL_ROW_ESTIMATED_HEIGHT = 44; @@ -109,6 +110,15 @@ function providerAuthEstablishesModelAvailability(model: ModelDescriptor): boole return provider === "claude" || provider === "codex" || provider === "droid"; } +// The runtime catalog flags a model as requiring configuration when it is +// listed but not usable yet (an unconfigured OpenCode sub-provider, a missing +// API key, etc.). This boolean is precomputed in modelCatalog.ts, so reading it +// here costs nothing on the hot render path — it just makes the annotation +// authoritative for the picker's dim + connect-routing decision. +function modelRequiresConfiguration(model: ModelDescriptor): boolean { + return (model as RuntimeCatalogModelDescriptor).catalogRequiresConfiguration === true; +} + export type ModelPickerContentProps = { value: string; surfaceKey: string; @@ -452,6 +462,12 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const isAvailableForUse = useCallback( (m: ModelDescriptor): boolean => { if (!matchesCursorAvailabilityMode(m)) return false; + // A configuration-gated model is never directly selectable — the row + // dims and its click routes to the connect affordance instead. This + // overrides the family-ready shortcut below, which would otherwise mark + // a gated Claude/Codex/Droid model available just because its provider + // family is authed. + if (modelRequiresConfiguration(m)) return false; if (cursorAvailabilityMode === "cli" && m.family === "cursor" && m.cursorAvailability?.cli === true) { return Object.keys(effectiveAuth).length > 0 ? familyIsReady(m.family) : true; } diff --git a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx index 917498d8c..02da17828 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx @@ -177,6 +177,11 @@ export function ProviderLogo({ return ; case "lmstudio": return ; + case "moonshotai": + case "moonshot": + case "kimi": + case "kimi-for-coding": + return ; default: { const lobeSrc = lobeProviderIconSrc(raw); if (lobeSrc) { diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 9a968ae38..e02d52f97 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -907,6 +907,133 @@ describe("createAdeWebAdapter", () => { expect(fake.terminalInputs).toEqual([{ sessionId: "session-1", data: "echo ok\n" }]); adapter.dispose(); }); + + it("routes the OpenCode AI methods to their host actions and bridges the OAuth status event", async () => { + fake.descriptors = descriptors([ + "ai.opencodeAuthMethods", + "ai.opencodeOAuthStart", + "ai.opencodeOAuthCancel", + "ai.setOpencodeProviderKey", + "ai.refreshModelsDev", + ]); + fake.commandResults.set("ai.opencodeAuthMethods", { + methods: { anthropic: { type: "oauth", label: "Claude" } }, + }); + fake.commandResults.set("ai.opencodeOAuthStart", { + url: "https://opencode.example/oauth", + method: "auto", + instructions: "Open the link to finish signing in.", + }); + fake.commandResults.set("ai.setOpencodeProviderKey", { ok: true }); + fake.commandResults.set("ai.refreshModelsDev", { lastFetchedAt: "2026-07-17T00:00:00.000Z" }); + + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + // The web adapter builds `ai` as a Record and casts it, so type the surface + // locally instead of depending on the preload global contract landing first. + const ai = adapter.ade.ai as unknown as { + opencodeAuthMethods: () => Promise<{ methods: Record }>; + opencodeOAuthStart: (args: unknown) => Promise<{ url: string; method: string; instructions: string }>; + opencodeOAuthCancel: (args: unknown) => Promise; + setOpencodeProviderKey: (args: unknown) => Promise<{ ok: boolean; error?: string }>; + refreshModelsDev: () => Promise<{ lastFetchedAt: string | null }>; + onOpencodeOAuthStatus: (cb: (status: unknown) => void) => () => void; + }; + + await expect(ai.opencodeAuthMethods()).resolves.toEqual({ + methods: { anthropic: { type: "oauth", label: "Claude" } }, + }); + await expect(ai.opencodeOAuthStart({ providerId: "anthropic" })).resolves.toMatchObject({ + url: "https://opencode.example/oauth", + method: "auto", + }); + await ai.opencodeOAuthCancel({ providerId: "anthropic" }); + await expect(ai.setOpencodeProviderKey({ providerId: "anthropic", key: "sk-test" })).resolves.toEqual({ ok: true }); + await expect(ai.refreshModelsDev()).resolves.toEqual({ lastFetchedAt: "2026-07-17T00:00:00.000Z" }); + + const statusEvents: unknown[] = []; + const unsubscribe = ai.onOpencodeOAuthStatus((status) => statusEvents.push(status)); + expect(typeof unsubscribe).toBe("function"); + unsubscribe(); + + expect(fake.commandCalls.map((call) => call.action)).toEqual([ + "ai.opencodeAuthMethods", + "ai.opencodeOAuthStart", + "ai.opencodeOAuthCancel", + "ai.setOpencodeProviderKey", + "ai.refreshModelsDev", + ]); + + adapter.dispose(); + }); + + it("rejects OpenCode sign-in offline instead of resolving a fabricated result", async () => { + // No descriptor registered = host unreachable. opencodeOAuthStart has no + // meaningful fallback shape, so it must reject rather than resolve. + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const ai = adapter.ade.ai as unknown as { + opencodeOAuthStart: (args: unknown) => Promise; + opencodeAuthMethods: () => Promise<{ methods: Record }>; + }; + + await expect(ai.opencodeOAuthStart({ providerId: "anthropic" })).rejects.toThrow(/unavailable/i); + // Reads still degrade to their typed fallback. + await expect(ai.opencodeAuthMethods()).resolves.toEqual({ methods: {} }); + + adapter.dispose(); + }); + + it("makes onOpencodeOAuthStatus live by draining OAuth status from the runtime buffer", async () => { + vi.useFakeTimers(); + fake.descriptors = descriptors(["ai.opencodeOAuthStart", "personalChats.streamEvents"]); + fake.commandResults.set("ai.opencodeOAuthStart", { + url: "https://opencode.example/oauth", + method: "auto", + instructions: "Finish signing in.", + }); + // The web adapter pulls the shared runtime event buffer; the OAuth status + // transition arrives as a category:"runtime" buffered event keyed by kind. + fake.commandResults.set("personalChats.streamEvents", { + events: [ + { + id: 5, + timestamp: "2026-07-17T00:00:00.000Z", + category: "runtime", + payload: { kind: "opencodeOAuthStatus", event: { providerId: "anthropic", state: "connected" } }, + }, + ], + nextCursor: 5, + hasMore: false, + }); + + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const ai = adapter.ade.ai as unknown as { + opencodeOAuthStart: (args: unknown) => Promise; + onOpencodeOAuthStatus: (cb: (status: { providerId: string; state: string }) => void) => () => void; + }; + + const statusEvents: Array<{ providerId: string; state: string }> = []; + const unsubscribe = ai.onOpencodeOAuthStatus((status) => statusEvents.push(status)); + + await ai.opencodeOAuthStart({ providerId: "anthropic" }); + // The status transition is drained on the next poll tick and re-emitted onto + // the adapter bus, reaching the live subscription. + await vi.advanceTimersByTimeAsync(1_100); + + expect(statusEvents).toEqual([{ providerId: "anthropic", state: "connected" }]); + // A terminal state stops the scoped drain; no further polling is scheduled. + const streamCallsAfter = fake.commandCalls.filter((call) => call.action === "personalChats.streamEvents").length; + await vi.advanceTimersByTimeAsync(3_000); + expect( + fake.commandCalls.filter((call) => call.action === "personalChats.streamEvents").length, + ).toBe(streamCallsAfter); + + unsubscribe(); + adapter.dispose(); + }); }); function descriptors(actions: string[]): SyncRemoteCommandDescriptor[] { diff --git a/apps/desktop/src/renderer/webclient/adapter/misc.ts b/apps/desktop/src/renderer/webclient/adapter/misc.ts index 631f1469c..375d145ff 100644 --- a/apps/desktop/src/renderer/webclient/adapter/misc.ts +++ b/apps/desktop/src/renderer/webclient/adapter/misc.ts @@ -1,6 +1,7 @@ import { peerToRuntimeDeviceState, type GitHubStatus, + type PersonalChatStreamEventsResult, type SyncDeviceRuntimeState, type SyncRoleSnapshot, } from "../../../shared/types"; @@ -41,10 +42,28 @@ export type MiscNamespaces = { automations: AdeNamespace<"automations">; }; +// Local mirrors of the desktop `window.ade.ai` OpenCode contract. The preload's +// global.d.ts is the source of truth; these keep the web adapter self-contained +// (and typed) even if that contract lands separately. +type OpenCodeProviderAuthMethods = Record; +type OpenCodeOAuthStartResult = { url: string; method: "auto" | "code"; instructions: string }; +type OpenCodeOAuthStatusEvent = { + providerId: string; + state: "pending" | "connected" | "failed" | "cancelled" | "timeout"; + error?: string; +}; + export function createMiscNamespaces(infra: AdapterInfra): MiscNamespaces { const { client, commands, events, localState, state } = infra; - function call(action: string, args: unknown, fallback: T, idempotent = true): Promise { + function call( + action: string, + args: unknown, + // Mirror CommandCaller's Fallback: an eager value, or a lazy resolver + // that may throw/reject (used by must-succeed calls with no offline shape). + fallback: T | (() => T | Promise), + idempotent = true, + ): Promise { return commands.call(action, asRecord(args), { fallback, idempotent }); } @@ -306,6 +325,91 @@ export function createMiscNamespaces(infra: AdapterInfra): MiscNamespaces { }, }; + // Desktop delivers ai.opencodeOAuthStatus over IPC. The web sync protocol has + // no push channel for runtime-buffered events, but OAuth status transitions land + // in the shared runtime event buffer, which we can pull (unfiltered and + // non-destructively — the cursor is client-driven, so this never starves the + // personal-chats drain) via personalChats.streamEvents. Drain it only while an + // OAuth flow is active and re-emit each opencodeOAuthStatus payload onto the + // adapter bus, so the onOpencodeOAuthStatus subscription becomes live instead of + // inert. Scoped to active flows to avoid a perpetual background poll. + const OAUTH_STATUS_POLL_MS = 1_000; + const OAUTH_STATUS_MAX_MS = 5 * 60_000; + const OAUTH_TERMINAL_STATES = new Set(["connected", "failed", "cancelled", "timeout"]); + const oauthActiveProviders = new Set(); + let oauthDrainCursor: number | null = null; + let oauthDrainTimer: ReturnType | null = null; + let oauthDrainDeadline = 0; + + const streamRuntimeEvents = (cursor: number, limit: number): Promise => + commands.call( + "personalChats.streamEvents", + { cursor, limit }, + { fallback: { events: [], nextCursor: cursor, hasMore: false }, idempotent: true, requireProject: false }, + ); + + const stopOAuthDrain = (): void => { + if (oauthDrainTimer != null) { + clearTimeout(oauthDrainTimer); + oauthDrainTimer = null; + } + oauthDrainCursor = null; + oauthActiveProviders.clear(); + }; + + const scheduleOAuthPoll = (): void => { + if (oauthDrainTimer != null) return; + oauthDrainTimer = setTimeout(() => { + void pollOAuthStatus(); + }, OAUTH_STATUS_POLL_MS); + }; + + async function pollOAuthStatus(): Promise { + oauthDrainTimer = null; + if (oauthDrainCursor == null) return; + let page: PersonalChatStreamEventsResult; + try { + page = await streamRuntimeEvents(oauthDrainCursor, 200); + } catch { + if (oauthActiveProviders.size > 0 && Date.now() <= oauthDrainDeadline) scheduleOAuthPoll(); + else stopOAuthDrain(); + return; + } + if (oauthDrainCursor == null) return; // torn down mid-poll + oauthDrainCursor = page.nextCursor; + for (const event of page.events) { + if (event.category !== "runtime") continue; + const payload = event.payload as { kind?: unknown; event?: unknown }; + if (payload?.kind !== "opencodeOAuthStatus" || !payload.event || typeof payload.event !== "object") continue; + const statusEvent = payload.event as OpenCodeOAuthStatusEvent; + events.emit("opencodeOAuthStatus" as never, statusEvent as never); + if (typeof statusEvent.providerId === "string" && OAUTH_TERMINAL_STATES.has(statusEvent.state)) { + oauthActiveProviders.delete(statusEvent.providerId); + } + } + if (oauthActiveProviders.size === 0 || Date.now() > oauthDrainDeadline) stopOAuthDrain(); + else scheduleOAuthPoll(); + } + + const startOAuthDrain = async (providerId: string): Promise => { + if (providerId) oauthActiveProviders.add(providerId); + oauthDrainDeadline = Date.now() + OAUTH_STATUS_MAX_MS; + if (oauthDrainCursor == null && oauthDrainTimer == null) { + // Advance to the buffer tail before the flow emits, so we skip stale + // statuses left by a prior flow instead of replaying them. + let cursor = 0; + for (let i = 0; i < 64; i++) { + const page = await streamRuntimeEvents(cursor, 1_000); + cursor = page.nextCursor; + if (!page.hasMore) break; + } + if (oauthDrainCursor == null) oauthDrainCursor = cursor; + } + scheduleOAuthPoll(); + }; + + infra.addDispose(stopOAuthDrain); + const ai: Record = { getStatus: (args?: unknown) => call("ai.getStatus", args, aiStatus()), getOpenCodeRuntimeDiagnostics: async () => ({ installed: false, available: false, diagnostics: [] }), @@ -321,6 +425,37 @@ export function createMiscNamespaces(infra: AdapterInfra): MiscNamespaces { updateConfig: async (config: unknown) => { await call("ai.updateConfig", { config }, undefined, false); }, + opencodeAuthMethods: () => + call<{ methods: OpenCodeProviderAuthMethods }>("ai.opencodeAuthMethods", undefined, { methods: {} }), + // Starting an OAuth flow has no meaningful offline fallback shape, so let it + // reject when the host is unreachable rather than resolving to a fake result. + // Begin draining the runtime buffer for status transitions before issuing the + // start so the flow's own events aren't missed. + opencodeOAuthStart: async (args: unknown) => { + const providerId = oauthProviderId(args); + await startOAuthDrain(providerId); + try { + return await call("ai.opencodeOAuthStart", args, () => { + throw new Error("OpenCode sign-in is unavailable in the web client while offline"); + }, false); + } catch (error) { + if (providerId) oauthActiveProviders.delete(providerId); + if (oauthActiveProviders.size === 0) stopOAuthDrain(); + throw error; + } + }, + opencodeOAuthCancel: (args: unknown) => call("ai.opencodeOAuthCancel", args, undefined, false), + setOpencodeProviderKey: (args: unknown) => + call<{ ok: boolean; error?: string }>( + "ai.setOpencodeProviderKey", + args, + { ok: false, error: "OpenCode provider keys are unavailable in the web client while offline" }, + false, + ), + refreshModelsDev: () => + call<{ lastFetchedAt: string | null }>("ai.refreshModelsDev", undefined, { lastFetchedAt: null }), + onOpencodeOAuthStatus: (cb: (status: OpenCodeOAuthStatusEvent) => void) => + events.on("opencodeOAuthStatus" as never, cb as never), }; const github: Record = { @@ -607,6 +742,9 @@ function aiStatus(): Record { models: { claude: [], codex: [], cursor: [], droid: [] }, features: [], availableModelIds: [], + opencodeProviders: [], + opencodeProvidersStale: true, + modelsDevLastFetchedAt: null, }; } @@ -649,6 +787,11 @@ function asRecord(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; } +function oauthProviderId(args: unknown): string { + const providerId = asRecord(args).providerId; + return typeof providerId === "string" ? providerId : ""; +} + function zoomFactor(level: number): number { return Math.round(Math.pow(1.2, level) * 1_000) / 1_000; } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 0889c2a31..b2c21449e 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -510,6 +510,12 @@ export const IPC = { aiListApiKeys: "ade.ai.listApiKeys", aiVerifyApiKey: "ade.ai.verifyApiKey", aiUpdateConfig: "ade.ai.updateConfig", + aiOpencodeAuthMethods: "ade.ai.opencodeAuthMethods", + aiOpencodeOAuthStart: "ade.ai.opencodeOAuthStart", + aiOpencodeOAuthCancel: "ade.ai.opencodeOAuthCancel", + aiSetOpencodeProviderKey: "ade.ai.setOpencodeProviderKey", + aiRefreshModelsDev: "ade.ai.refreshModelsDev", + aiOpencodeOAuthStatus: "ade.ai.opencodeOAuthStatus", projectSecretsList: "ade.projectSecrets.list", projectSecretsGet: "ade.projectSecrets.get", projectSecretsSet: "ade.projectSecrets.set", diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 1404e166b..8237e22ab 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1175,6 +1175,38 @@ export type AiClaudeAvailability = { }; }; +export interface OpenCodeProviderAuthPrompt { + type: "text" | "select"; + key: string; + message: string; + placeholder?: string; + options?: Array<{ label: string; value: string; hint?: string }>; + when?: { key: string; op: "eq" | "neq"; value: string }; +} +export interface OpenCodeProviderAuthMethod { + type: "oauth" | "api"; + label: string; + prompts?: OpenCodeProviderAuthPrompt[]; +} +export type OpenCodeProviderAuthMethods = Record; +export interface OpenCodeOAuthStartResult { + url: string; + method: "auto" | "code"; + instructions: string; +} +export interface OpenCodeOAuthStatusEvent { + providerId: string; + state: "pending" | "connected" | "failed" | "cancelled" | "timeout"; + error?: string; +} +export interface AiCustomProviderConfig { + id: string; + name: string; + baseURL: string; + npm?: "@ai-sdk/openai-compatible" | "@ai-sdk/openai" | "@ai-sdk/anthropic"; + models: string[]; +} + export type AiSettingsStatus = { mode: "guest" | "subscription"; availableProviders: { @@ -1198,6 +1230,10 @@ export type AiSettingsStatus = { opencodeBinarySource?: "user-installed" | "bundled" | "missing"; opencodeInventoryError?: string | null; opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>; + /** True when opencodeProviders came from the persisted disk cache rather than a live/warm probe. */ + opencodeProvidersStale?: boolean; + /** Epoch ms of the last successful models.dev fetch (or cache mtime on fallback); null if never fetched. */ + modelsDevLastFetchedAt?: number | null; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -1359,6 +1395,10 @@ export type AiConfig = { defaultModel?: ModelId; apiKeys?: Record; localProviders?: AiLocalProviderConfigs; + /** User-defined OpenAI-compatible providers injected into the OpenCode server config. */ + customProviders?: AiCustomProviderConfig[]; + /** Extra model slugs (provider/model) the user pinned as selectable beyond probed inventory. */ + customModelSlugs?: string[]; workerSafety?: WorkerSafetyPolicy; /** Per-feature model overrides, e.g. { pr_descriptions: "claude-sonnet-5" } */ featureModelOverrides?: Partial>; diff --git a/configuration/ai-providers.mdx b/configuration/ai-providers.mdx index f1288cf23..6860127dd 100644 --- a/configuration/ai-providers.mdx +++ b/configuration/ai-providers.mdx @@ -1,24 +1,75 @@ --- title: "AI providers" -description: "Connect Claude Code, Codex, Cursor, Factory Droid, and OpenCode — with your own key or an existing subscription." +description: "Connect coding agents with your own key or subscription, and reach 160+ more models through OpenCode — fully managed by ADE." icon: "brain" --- -ADE runs **Claude Code, Codex, Cursor, Factory Droid, and OpenCode** — every chat, CTO task, and worker routes through one of these five agents. Connect at least one before starting agent work. Each can use your own API key or, where supported, an existing subscription sign-in. +ADE runs your chats, CTO tasks, and workers through **coding agents** you already know — Claude Code, Codex, Cursor, and Droid — plus **OpenCode**, a managed layer that opens up subscriptions, provider keys, and 160+ more models behind one roof. Connect at least one before starting agent work. -## The five agents +Settings → Providers is organized into two groups: the coding agents, and everything OpenCode unlocks. -| Agent | Connect with | Typical use | -|-------|--------------|-------------| -| **Claude Code** | Claude subscription or Anthropic API key | General coding, planning, review, worker tasks. | -| **Codex** | OpenAI sign-in or API key | OpenAI coding workflows and CLI-backed sessions. | -| **Cursor** | Cursor API key (`CURSOR_API_KEY`) | Cursor agent sessions through the Cursor SDK. | -| **Factory Droid** | Factory sign-in or API key | Droid agent sessions, including AGI mode. | -| **OpenCode** | API key, OpenRouter, or local endpoint | Broadest model reach; local or provider-backed. | +## Coding agents -## The models you can reach through ADE's agents +Each coding agent uses the login or key you already have. ADE detects your existing CLI credentials automatically, so most of these light up green without any extra setup. -These agents — plus OpenCode and OpenRouter routing — give you access to models from every major lab. Pick whichever fits the task. +| Agent | Connect with | +|-------|--------------| +| **Claude Code** | Your `claude` login — a Claude Pro/Max subscription or `ANTHROPIC_API_KEY`. | +| **Codex CLI** | Your ChatGPT sign-in — a Plus/Pro subscription or `OPENAI_API_KEY`. | +| **Cursor** | A Cursor API key (`CURSOR_API_KEY`). ADE verifies it and lists Cursor's models. | +| **Droid** | Your Factory login or `FACTORY_API_KEY`. | + + + + Sign in with a Claude subscription or paste an Anthropic API key. ADE runs Claude through the bundled Claude Agent SDK, so the `claude` CLI does not need to be installed separately. If sign-in is required, the card offers a one-click login. See [Claude Code](/ai-tools/claude-code) for lane context and permission modes. + + + Sign in with your ChatGPT account or provide an API key. Codex runs as a CLI-backed session and reaches OpenAI models. + + + Add a Cursor API key (or set `CURSOR_API_KEY` in the environment ADE launches from). ADE verifies it with the Cursor SDK and discovers your available models. See [Cursor](/ai-tools/cursor). + + + Sign in with Factory or set `FACTORY_API_KEY`. Droid runs as an agent provider and supports AGI mode for larger autonomous tasks. + + + +## OpenCode — universal model access + +OpenCode is the managed layer that reaches everything else. ADE downloads and runs it for you, keeps its credentials in an isolated ADE-managed store (never your own `~/.local/share/opencode`), and refreshes its model catalog on its own. One group, four ways in: + + + + Connect a subscription you already pay for — currently GitHub Copilot, ChatGPT Plus/Pro, GitLab, Poe, and DigitalOcean. Click **Connect**, finish the sign-in in your browser, and the row flips to Connected when the provider reports back. The available list comes straight from OpenCode, so it grows over time. + + **Kimi for Coding** connects here too, with a membership key instead of a browser sign-in. + + + Claude Pro/Max cannot be used through OpenCode — Anthropic's terms of service don't allow it. Connect Claude through the **Claude Code** agent above instead. + + + + Paste a key for any first-class provider — Anthropic, OpenAI, Google AI, Mistral, DeepSeek, xAI, Groq, Together AI, OpenRouter, or **Moonshot AI** (`MOONSHOT_API_KEY`). Use **Verify all** to check every stored key at once. Keys are held in local encrypted storage. + + + Beyond the first-class rows, OpenCode reaches **160+ more providers**. Search the chip cloud, then paste a key against any provider by its id. The catalog is cached between sessions, so it renders the moment Settings opens. + + + Point ADE at **LM Studio** or **Ollama** running locally. ADE auto-detects the OpenAI-compatible endpoint, lists the models you've loaded, and lets you set a default per runtime. + + + +### Catalog freshness + +The group header shows when the model catalog (from models.dev) last synced. ADE refreshes it on launch and every six hours; hit **refresh** in the header to pull the latest metadata on demand. + +### Advanced — custom providers & model slugs + +Open **Advanced** to add a custom OpenAI- or Anthropic-compatible provider (name, base URL, key, and model slugs) or to register extra model slugs by `providerId/modelId`. Both flow straight into the managed OpenCode config. + +## The models you can reach + +Between the coding agents and OpenCode's routing, you get models from every major lab. Pick whichever fits the task.
@@ -36,42 +87,7 @@ These agents — plus OpenCode and OpenRouter routing — give you access to mod
-## Connect a provider - - - - Choose the agent you want to connect. - - - Paste an API key, sign in with a subscription, configure a local endpoint, or authenticate the provider CLI. - - - ADE probes the provider CLI and lists its available models. A green status means it's ready to start a session. - - - Set your default chat model and any role-specific overrides for CTO, workers, or specific lanes. - - - -Per-agent key steps are below. - - - - Sign in with a Claude subscription or paste an Anthropic API key. ADE runs Claude through the bundled Claude Agent SDK, so the `claude` CLI does not need to be installed separately. See [Claude Code](/ai-tools/claude-code) for lane context and permission modes. - - - Sign in with your OpenAI account or provide an API key. Codex runs as a CLI-backed session and reaches OpenAI models. - - - Provide a Cursor API key (or set `CURSOR_API_KEY` in the environment ADE launches from). ADE recognizes the key as valid auth and discovers Cursor's available models at startup. See [Cursor](/ai-tools/cursor). - - - Sign in with Factory or provide an API key. Droid runs as an agent provider and supports AGI mode for larger autonomous tasks. - - - Toggle OpenCode on; ADE downloads and manages the binary and local server. OpenCode is what powers API-backed and local-model chats — back it with an Anthropic or OpenAI key, an OpenRouter key for any model, or a local OpenAI-compatible endpoint such as LM Studio or Ollama. See [OpenCode & Windsurf](/ai-tools/windsurf). - - +A model that still needs a key or sign-in shows dimmed in the model picker with a set-up shortcut that jumps straight back to this page. ## Permission presets @@ -94,6 +110,17 @@ Running several agents in parallel can spend quickly. Use cheaper models for rou Per-provider usage and quota windows in ADE +## Troubleshooting + + + + If OpenCode is blocked by macOS Gatekeeper, its diagnostics report a quarantined binary and the exact fix — clear the quarantine attribute with `xattr -d com.apple.quarantine ` (the card shows the full path), then re-check. + + + Run the login command the card suggests, then hit **Refresh**. If a CLI is installed but not found, make sure it is on the shell PATH that ADE launches from. + + + Provider calls go directly from your machine to the provider or local endpoint you configure. Keep keys out of committed files — ADE stores them in local encrypted settings. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 840b41bec..f1825896a 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -65,7 +65,8 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/droidSdkEventMapper.ts` | Per-session `DroidSdkEventMapperState` + `mapDroidSdkMessageToChatEvents` / `mapDroidSdkRunResultToDoneEvent`. Tracks streaming text/thinking/image item ids, maps tool calls and results, maps `mission_worker_started` / `mission_worker_completed` notifications to provider-neutral subagent lifecycle events keyed by worker session id, surfaces image content as compact generation rows, and reports token usage. Replaces the deleted `droidAcpPool.ts` + `droidAcpEventMapper` path. | | `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | SDK-driven model probe (`listDroidModelsFromSdk`) plus the `~/.factory/config.json` custom-proxy merge. Normalizes retired factory Claude ids (Sonnet 4.6 -> Sonnet 5, basic Opus 4.7 -> Opus 4.8) before descriptors reach desktop, mobile, or TUI model pickers. Exposes `discoverDroidSdkModelDescriptors` (alias for the legacy `discoverDroidCliModelDescriptors` while callers migrate). | | `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | -| `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | +| `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. **Cross-launch persistence:** `persistOpenCodeInventory(projectRoot, providers)` writes each successful probe's provider list (keyed by project root, with `savedAt`) to `opencode-inventory-cache.json` under Electron `userData` (override via `ADE_OPENCODE_INVENTORY_CACHE_FILE`); on a cold start the Settings page reloads that persisted list flagged stale (`opencodeProvidersStale`) so the ~160-provider chip cloud renders immediately instead of blanking until the first live probe (stale-while-revalidate). Writes are best-effort and never break the probe. | +| `apps/desktop/src/main/services/opencode/openCodeAuthService.ts` | Drives the managed OpenCode server's auth API for subscription connect + API-key seeding, reusing the shared inventory server lease (never spawning its own process). `listAuthMethods` reads `GET /provider/auth`; `startOAuth` authorizes (`POST /provider/{id}/oauth/authorize`), opens the returned URL, and polls `provider.list().connected` every 2s until connected or a 5-min timeout, re-probing inventory on success; `cancelOAuth` stops the poller; `setProviderKey` does `PUT /auth/{id}` and mirrors the key into ADE's `apiKeyStore` so it is re-injected on future launches. One flow per `providerId` at a time (a new start supersedes the prior). Transitions are published through `addOpenCodeOAuthStatusListener` (`pending`/`connected`/`cancelled`/`timeout`/`failed`), a multi-sink fan-out so the same event reaches desktop windows and the remote/web runtime event buffer. Seeded credentials land in ADE's isolated managed OpenCode dir (XDG roots under `userData/opencode-runtime/xdg-v*`), never the user's `~/.local/share/opencode`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), the stable `groupPaneSectionItems` partition and pane caps, `buildSubagentPaneRows`, tagged pane click targets, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. The partition keeps source order, forces pinned rows into the active cap, and excludes visually cleared Completed ids. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `isNonAgentTaskRun`, `subagentAgentKey`) — `isNonAgentTaskRun` flags a `task_type` `other` run with no agent metadata (a plain Claude Code task, not a subagent) so both the idle-turn and foreground paths keep it out of the roster. Claude's raw `local_bash` kind is normalized only after explicit background evidence (`background_tasks_changed`, `is_backgrounded`, or `run_in_background`) because foreground Bash emits the same kind. The file also owns summary-quality helpers and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`). Desktop consumes the partition directly; ADE Code consumes the expanded row model; iOS mirrors the same predicates and caps. | | `apps/desktop/src/shared/chatScheduledWork.ts` | Cross-target scheduled-work derivation. Folds `scheduled_work_update` envelopes into stable snapshots for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work, then merges the transcript projection with the KV-backed management snapshot from `AgentChatSessionSummary.scheduledWork`. The merge removes stale active durable transcript rows that no longer exist in the management store, preserves provider-only/non-durable activity for display, and marks only ADE-managed rows as cancellable. It also partitions rows by surface: `deriveScheduleItems` returns schedule kinds (`wakeup` / `cron` / `loop` / `remote_trigger`) while `deriveBackgroundItems` returns `background_task` rows that do not duplicate a real subagent with the same `sourceTaskId`. A parent turn's terminal event does not coerce surviving background work to stopped; only an explicit work terminal state or runtime teardown does. `isEarlierBackgroundItem`, `isFiredOneShotWakeup`, and `isEarlierScheduleItem` define the shared Earlier membership mirrored by ADE Code and iOS. | @@ -980,6 +981,17 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.getTurnFileDiff` | invoke | Lazy diff expansion for a turn-file-summary row. | | `ade.agentChat.event` | push | Stream of `AgentChatEventEnvelope` into the renderer. | +Provider connection management lives on the `ade.ai.*` surface (handled in `registerIpc.ts`, backed by `openCodeAuthService.ts` and `modelsDevService.ts`), consumed by `ProvidersSection.tsx`: + +| Channel | Direction | Purpose | +|---|---|---| +| `ade.ai.opencodeAuthMethods` | invoke | List the auth methods each OpenCode provider supports (`GET /provider/auth`). The Settings page derives its OAuth subscription rows from the providers whose methods include an `oauth` entry. Best-effort — rows stay hidden if unavailable. | +| `ade.ai.opencodeOAuthStart` | invoke | Start a subscription OAuth flow for `{ providerId, methodIndex, inputs? }`; opens the browser and begins polling. Returns `{ url, method, instructions }`. | +| `ade.ai.opencodeOAuthCancel` | invoke | Cancel the in-flight OAuth flow for `{ providerId }`. | +| `ade.ai.setOpencodeProviderKey` | invoke | Seed a plain API key for a provider (`PUT /auth/{id}`) and mirror it into ADE's key store. Backs the Kimi for Coding membership key and the `alsoOpenCode` key-save path for API/More-Providers rows. Invalidates provider-readiness caches on success. | +| `ade.ai.refreshModelsDev` | invoke | Force a models.dev metadata refresh now; returns `{ lastFetchedAt }` for the group-header "catalog synced" timestamp. `modelsDevService` also refreshes on boot and every 6h on its own timer. | +| `ade.ai.opencodeOAuthStatus` | push | Stream of `OpenCodeOAuthStatusEvent` (`pending`/`connected`/`cancelled`/`timeout`/`failed`) so the connect modal updates without polling. | + ## Fragile and tricky wiring - **Event emission ordering in `agentChatService.ts`.** The service emits @@ -1187,6 +1199,37 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. server runs at a time per project; switching provider config or between chats with different configs recycles the pool instead of stacking processes. +- **OpenCode OAuth status has two independent fan-out paths.** + `openCodeAuthService.addOpenCodeOAuthStatusListener` is a multi-sink + emitter, and both sinks must be registered or one client class goes + dark. `registerIpc.ts` broadcasts every event to all `BrowserWindow` + renderers over `IPC.aiOpencodeOAuthStatus` (desktop). Separately, + `adeActions/registry.ts` `ensureOpenCodeOAuthStatusRelayBridge` pushes + the same event into the runtime event buffer as + `{ kind: "opencodeOAuthStatus", event }` for remote/web clients. The + poll timer is `unref`'d and the shared server lease is held only for + the flow's lifetime, released on connect/cancel/timeout/failure. +- **OpenCode inventory persistence is stale-while-revalidate.** A cold + start would otherwise blank the ~160-provider chip cloud until the + first live probe. `persistOpenCodeInventory` writes each successful + probe (keyed by project root) to `opencode-inventory-cache.json` under + `userData`; `aiSettingsStatus` reloads it flagged `opencodeProvidersStale` + so Settings renders the last-known catalog immediately, then a real + probe replaces it and clears the flag. Persistence is best-effort and + must never throw into the probe path. The in-memory `persistedInventoryMemo` + and the `peekOpenCodeInventoryCache` passive-read cache are distinct; + clearing one does not clear the other. +- **New `ai.*` config fields must be added to BOTH `coerceAiConfig` and + `mergeAiConfig`.** In `projectConfigService.ts`, `coerceAiConfig` + validates/parses a config field off disk and `mergeAiConfig` folds the + shared + local layers into the effective config. A field added to only + one is silently dropped — it either fails to load or fails to survive + the layer merge, with no error. This bit `ai.customProviders` and + `ai.customModelSlugs` during the OpenCode providers build (custom + providers/model slugs written by `ProvidersSection` never reaching the + managed OpenCode config). Both keys are now merged by id/set-union in + `mergeAiConfig` and coerced in `coerceAiConfig`; add any future `ai.*` + field to both, plus `AiConfig` in `shared/types/config.ts`. ## Configuration @@ -1201,6 +1244,12 @@ config service): (`claudePermissionMode`, Codex approval/sandbox defaults, OpenCode permission). - `ai.taskRouting` -- provider/model selection per task type. +- `ai.customProviders` -- Advanced custom OpenAI-/Anthropic-compatible + providers (`{ id, name, baseURL, npm, models[] }`) that flow into the + managed OpenCode config. +- `ai.customModelSlugs` -- extra `providerId/modelId` slugs to surface in + the picker. Like every `ai.*` field, both keys must be handled in + `coerceAiConfig` and `mergeAiConfig` (see Fragile and tricky wiring). ## Related docs From ef60ec5c599783cc94d926d30332fa313300e847 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:34:20 -0400 Subject: [PATCH 2/8] fix(providers): quality-pass fixes from dual-review Replace semantics for ai.customProviders/customModelSlugs (union made removals impossible); surface both lists on the AI status payload and seed the settings form from config so saves are authoritative full-list writes; fall back to persisted OpenCode inventory when a forced probe fails; cancel an active OAuth flow on modal unmount; dedupe the web adapter's OpenCode types against shared/types (fixing a latent array-shape divergence) and the modelsDev refresh duplication; drop a dead inventory parameter. Co-Authored-By: Claude Fable 5 --- .../main/services/ai/aiIntegrationService.ts | 16 ++++++++++++++++ .../src/main/services/ai/aiSettingsStatus.ts | 2 ++ .../src/main/services/ai/modelsDevService.ts | 6 +----- .../services/config/projectConfigService.ts | 12 +++++------- .../main/services/opencode/openCodeInventory.ts | 11 +---------- .../components/settings/OAuthConnectModal.tsx | 16 ++++++++++++++++ .../components/settings/ProvidersSection.tsx | 17 +++++++++++++++++ .../src/renderer/webclient/adapter/misc.ts | 17 ++++++----------- apps/desktop/src/shared/types/config.ts | 2 ++ 9 files changed, 66 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 165fac3fd..94accdc8d 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -6,6 +6,7 @@ import type { AgentModelDescriptor, AgentProvider, ExecutorOpts } from "./agentE import type { AiApiKeyVerificationResult, AiClaudeAvailability, + AiCustomProviderConfig, AiLocalProviderConfigs, AiProviderConnections, AiRuntimeConnections, @@ -149,6 +150,10 @@ export type AiIntegrationStatus = { opencodeProvidersStale?: boolean; /** Epoch ms of the last successful models.dev fetch (or cache mtime on fallback); null if never fetched. */ modelsDevLastFetchedAt?: number | null; + /** Effective ai.customProviders — surfaced so the settings UI can do authoritative full-list writes. */ + customProviders?: AiCustomProviderConfig[]; + /** Effective ai.customModelSlugs — surfaced so the settings UI can do authoritative full-list writes. */ + customModelSlugs?: string[]; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -1796,6 +1801,15 @@ export function createAiIntegrationService(args: { force: true, discoveredLocalModels, }); + // A transient probe failure (e.g. server launch hiccup) must not + // collapse the settings chips to empty when we have a persisted + // list — serve it flagged stale, keeping the error visible. + if (probed.error && !probed.providers.length) { + const persisted = loadPersistedOpenCodeInventory(projectRoot); + if (persisted.length) { + return { ...probed, providers: persisted, stale: true }; + } + } return { ...probed, stale: false }; } const peeked = peekOpenCodeInventoryCache({ @@ -1847,6 +1861,8 @@ export function createAiIntegrationService(args: { opencodeProviders: opencodeInventory.providers, opencodeProvidersStale: opencodeInventory.stale, modelsDevLastFetchedAt: getModelsDevLastFetchedAt(), + customProviders: effectiveConfig?.ai?.customProviders, + customModelSlugs: effectiveConfig?.ai?.customModelSlugs, apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()), }; if (requestGeneration === providerReadinessCacheGeneration) { diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts index c9d993b08..4a2b6a6ff 100644 --- a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -140,6 +140,8 @@ export async function buildAiSettingsStatus( opencodeProviders: status.opencodeProviders, opencodeProvidersStale: status.opencodeProvidersStale, modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, + customProviders: status.customProviders, + customModelSlugs: status.customModelSlugs, apiKeyStore: status.apiKeyStore, features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ feature, diff --git a/apps/desktop/src/main/services/ai/modelsDevService.ts b/apps/desktop/src/main/services/ai/modelsDevService.ts index 268970438..7fe640f60 100644 --- a/apps/desktop/src/main/services/ai/modelsDevService.ts +++ b/apps/desktop/src/main/services/ai/modelsDevService.ts @@ -276,11 +276,7 @@ export async function initialize(): Promise> { if (!refreshTimer) { refreshTimer = setInterval(async () => { try { - const fresh = await fetchFromApi(); - modelDataMap = fresh; - lastFetchedAt = Date.now(); - await persistToCache(fresh); - applyEnrichment(); + const fresh = await refreshNow(); console.info(`[models.dev] Refreshed metadata for ${fresh.size} models`); } catch (err) { console.warn(`[models.dev] Background refresh failed: ${err instanceof Error ? err.message : err}`); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 1bbebb4d8..b9069ebf2 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1986,13 +1986,11 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(sharedAi?.apiKeys ?? {}), ...(localAi?.apiKeys ?? {}) }; - const customProvidersById = new Map( - [...(sharedAi?.customProviders ?? []), ...(localAi?.customProviders ?? [])] - .filter((entry) => entry?.id) - .map((entry) => [entry.id, entry] as const), - ); - const customProviders = [...customProvidersById.values()]; - const customModelSlugs = [...new Set([...(sharedAi?.customModelSlugs ?? []), ...(localAi?.customModelSlugs ?? [])])]; + // Replace semantics (not union): the UI writes the full authoritative list, + // and this merge also runs on the ai.updateConfig write-patch path — a union + // would make removals impossible to persist. Absent = keep, [] = clear. + const customProviders = localAi?.customProviders ?? sharedAi?.customProviders ?? []; + const customModelSlugs = localAi?.customModelSlugs ?? sharedAi?.customModelSlugs ?? []; const localProvidersEntries = (["ollama", "lmstudio"] as const) .map((provider) => { const mergedProvider = { diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index d6f068d71..a59882a9c 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -240,7 +240,6 @@ function readOpenCodeModelCapabilities(model: Record): { function normalizeOpenCodeProviderModel( providerId: string, modelId: string, - _availableProviderModelIds: Set, displayName?: string, ): { modelId: string; @@ -426,14 +425,6 @@ export async function probeOpenCodeProviderInventory(args: { // local-provider catalog; only show models ADE just discovered as loaded. if (isLocal && !discoveryExists) continue; const models = provider.models ?? {}; - const availableProviderModelIds = new Set( - Object.values(models) - .map((model) => { - const record = model as Record; - return typeof record.id === "string" ? record.id.trim().toLowerCase() : ""; - }) - .filter(Boolean), - ); for (const model of Object.values(models)) { const modelRecord = model as Record; const mid = typeof modelRecord.id === "string" ? modelRecord.id.trim() : ""; @@ -442,7 +433,7 @@ export async function probeOpenCodeProviderInventory(args: { if (discoveryExists && (!allowedModels || !allowedModels.has(mid))) continue; const variants = classifyOpenCodeVariants(modelRecord); const rawDisplayName = typeof modelRecord.name === "string" && modelRecord.name.trim().length ? modelRecord.name.trim() : undefined; - const normalizedModel = normalizeOpenCodeProviderModel(provider.id, mid, availableProviderModelIds, rawDisplayName); + const normalizedModel = normalizeOpenCodeProviderModel(provider.id, mid, rawDisplayName); const limit = typeof modelRecord.limit === "object" && modelRecord.limit ? modelRecord.limit as { context?: number; output?: number } : null; diff --git a/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx index f7b3a357e..01036a2d9 100644 --- a/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx +++ b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx @@ -80,10 +80,24 @@ export function OAuthConnectModal({ const deviceCode = extractDeviceCode(startResult?.instructions); const host = extractHost(startResult?.url); + // Tracks whether a backend flow is in flight so unmount can cancel it — + // in-modal close paths cancel explicitly, but a parent unmount (nav away, + // settings close) would otherwise leave the poller running to its timeout. + const flowActiveRef = useRef(false); + useEffect( + () => () => { + if (flowActiveRef.current) { + void window.ade.ai.opencodeOAuthCancel({ providerId }).catch(() => undefined); + } + }, + [providerId], + ); + // Subscribe to backend OAuth status pushes for this provider. useEffect(() => { const unsubscribe = window.ade.ai.onOpencodeOAuthStatus((event: OpenCodeOAuthStatusEvent) => { if (event.providerId !== providerId) return; + if (event.state !== "pending") flowActiveRef.current = false; if (event.state === "connected") { onConnected(); onClose(); @@ -128,6 +142,7 @@ export function OAuthConnectModal({ inputs: Object.keys(filteredInputs).length ? filteredInputs : undefined, }); setStartResult(result); + flowActiveRef.current = true; setPhase("waiting"); } catch (err) { setPhase("error"); @@ -136,6 +151,7 @@ export function OAuthConnectModal({ }; const handleCancel = async () => { + flowActiveRef.current = false; try { await window.ade.ai.opencodeOAuthCancel({ providerId }); } catch { diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 151180f02..f11cc6311 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -467,6 +467,10 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const [customModelSlugs, setCustomModelSlugs] = useState(""); const [savingAdvanced, setSavingAdvanced] = useState(false); const pendingRefreshTimerRef = useRef(null); + // Seed the slugs field from config exactly once — saves send the full list + // (replace semantics), so the field must start from what's persisted or a + // save would silently wipe existing entries. + const slugsSeededRef = useRef(false); const revealClaudeLoginTerminalInWork = useCallback((terminal: { terminalId: string; laneId: string }) => { revealTerminalSessionInWork(navigate, terminal); }, [navigate]); @@ -521,6 +525,15 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceRefreshOnMount]); + useEffect(() => { + if (slugsSeededRef.current) return; + const persisted = status?.customModelSlugs; + if (!persisted) return; + slugsSeededRef.current = true; + // Never clobber text the user typed while the initial probe was loading. + setCustomModelSlugs((current) => (current === "" && persisted.length ? persisted.join(", ") : current)); + }, [status?.customModelSlugs]); + useEffect(() => { const unsubscribe = window.ade.agentChat.onEvent((envelope) => { if (!shouldRefreshAiStatusForChatEvent(envelope)) return; @@ -870,8 +883,12 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh if (draft.apiKey.trim()) { await window.ade.ai.storeApiKey(id, draft.apiKey.trim()); } + // Full-list write: config merge uses replace semantics, so include every + // existing provider (replacing any same-id entry) or they'd be dropped. + const existingProviders = (status?.customProviders ?? []).filter((entry) => entry.id !== id); await window.ade.ai.updateConfig({ customProviders: [ + ...existingProviders, { id, name: draft.name.trim() || prettifyProviderId(id), diff --git a/apps/desktop/src/renderer/webclient/adapter/misc.ts b/apps/desktop/src/renderer/webclient/adapter/misc.ts index 375d145ff..eba4a832b 100644 --- a/apps/desktop/src/renderer/webclient/adapter/misc.ts +++ b/apps/desktop/src/renderer/webclient/adapter/misc.ts @@ -42,16 +42,11 @@ export type MiscNamespaces = { automations: AdeNamespace<"automations">; }; -// Local mirrors of the desktop `window.ade.ai` OpenCode contract. The preload's -// global.d.ts is the source of truth; these keep the web adapter self-contained -// (and typed) even if that contract lands separately. -type OpenCodeProviderAuthMethods = Record; -type OpenCodeOAuthStartResult = { url: string; method: "auto" | "code"; instructions: string }; -type OpenCodeOAuthStatusEvent = { - providerId: string; - state: "pending" | "connected" | "failed" | "cancelled" | "timeout"; - error?: string; -}; +import type { + OpenCodeOAuthStartResult, + OpenCodeOAuthStatusEvent, + OpenCodeProviderAuthMethods, +} from "../../../shared/types"; export function createMiscNamespaces(infra: AdapterInfra): MiscNamespaces { const { client, commands, events, localState, state } = infra; @@ -453,7 +448,7 @@ export function createMiscNamespaces(infra: AdapterInfra): MiscNamespaces { false, ), refreshModelsDev: () => - call<{ lastFetchedAt: string | null }>("ai.refreshModelsDev", undefined, { lastFetchedAt: null }), + call<{ lastFetchedAt: number | null }>("ai.refreshModelsDev", undefined, { lastFetchedAt: null }), onOpencodeOAuthStatus: (cb: (status: OpenCodeOAuthStatusEvent) => void) => events.on("opencodeOAuthStatus" as never, cb as never), }; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 8237e22ab..fba5ed60f 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1232,6 +1232,8 @@ export type AiSettingsStatus = { opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>; /** True when opencodeProviders came from the persisted disk cache rather than a live/warm probe. */ opencodeProvidersStale?: boolean; + customProviders?: AiCustomProviderConfig[]; + customModelSlugs?: string[]; /** Epoch ms of the last successful models.dev fetch (or cache mtime on fallback); null if never fetched. */ modelsDevLastFetchedAt?: number | null; apiKeyStore?: { From 7b6ec2ee83811e4976122fab5c12fce6a747d115 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:46:31 -0400 Subject: [PATCH 3/8] test(providers): regression pins, suite consolidation, docs parity Pin the quality-gate fixes as named regressions (mergeAiConfig replace semantics; stale persisted-inventory fallback on failed forced probes), fold the inventory-persistence test fragment into the module suite, and bring internal docs (settings/config-schema/chat/architecture) in line with the shipped providers overhaul. Co-Authored-By: Claude Fable 5 --- .../services/ai/aiIntegrationService.test.ts | 22 ++++++++ .../config/projectConfigService.test.ts | 30 +++++++++++ .../opencode/openCodeInventory.test.ts | 52 +++++++++++++++++- .../openCodeInventoryPersistence.test.ts | 53 ------------------- docs/ARCHITECTURE.md | 5 +- docs/features/chat/README.md | 11 ++-- .../onboarding-and-settings/README.md | 52 ++++++++++++++++-- .../configuration-schema.md | 37 +++++++++++++ 8 files changed, 201 insertions(+), 61 deletions(-) delete mode 100644 apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 1a16e8ec8..3603d5ca4 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -454,6 +454,28 @@ describe("aiIntegrationService", () => { expect(status.availableModelIds).toContain(`opencode/lmstudio/${modelId}`); }); + // Regression pin (quality gate): a transient probe failure on a forced + // refresh must serve the persisted provider list flagged stale — not + // collapse the settings chips to empty while keeping the error visible. + it("serves the persisted provider list as stale when a forced probe fails", async () => { + const { service } = makeService(); + const persisted = [{ id: "moonshotai", name: "Moonshot AI", connected: false, modelCount: 10 }]; + mockState.probeOpenCodeProviderInventory.mockResolvedValue({ + modelIds: [], + catalogModelIds: [], + providers: [], + error: "OpenCode: launch-timeout: OpenCode server did not become ready in time.", + descriptors: [], + }); + mockState.loadPersistedOpenCodeInventory.mockReturnValueOnce(persisted); + + const status = await service.getStatus({ refreshOpenCodeInventory: true }); + + expect(status.opencodeProviders).toEqual(persisted); + expect(status.opencodeProvidersStale).toBe(true); + expect(status.opencodeInventoryError).toContain("launch-timeout"); + }); + it("coalesces concurrent getStatus calls for the same request shape", async () => { const { service } = makeService(); let resolveAuth: ((value: Array>) => void) | null = null; diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index ac7262c10..a0848c3f5 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -626,6 +626,36 @@ describe("projectConfigService - AI mode migration", () => { expect(merged?.sessionIntelligence?.summaries?.reasoningEffort).toBeNull(); expect(merged?.featureReasoningOverrides?.terminal_summaries).toBeNull(); }); + + // Regression pin (quality gate): custom providers/slugs must use REPLACE + // semantics, not union — a union made removals impossible to persist because + // the settings UI writes the full authoritative list on every save. + it("replaces custom providers and model slugs on write instead of unioning", () => { + const shared = { + customProviders: [ + { id: "acme", name: "Acme", baseURL: "https://acme.example/v1", models: ["m1"] }, + { id: "beta", name: "Beta", baseURL: "https://beta.example/v1", models: ["b1"] }, + ], + customModelSlugs: ["acme/m1", "beta/b1"], + }; + + const merged = mergeAiConfig(shared, { + customProviders: [{ id: "acme", name: "Acme", baseURL: "https://acme.example/v1", models: ["m1", "m2"] }], + customModelSlugs: ["acme/m2"], + }); + expect(merged?.customProviders).toEqual([ + { id: "acme", name: "Acme", baseURL: "https://acme.example/v1", models: ["m1", "m2"] }, + ]); + expect(merged?.customModelSlugs).toEqual(["acme/m2"]); + + const kept = mergeAiConfig(shared, { defaultModel: "openai/gpt-5.4" }); + expect(kept?.customProviders).toEqual(shared.customProviders); + expect(kept?.customModelSlugs).toEqual(shared.customModelSlugs); + + const cleared = mergeAiConfig(shared, { customProviders: [], customModelSlugs: [] }); + expect(cleared?.customProviders).toBeUndefined(); + expect(cleared?.customModelSlugs).toBeUndefined(); + }); }); describe("projectConfigService - PR transcript gists", () => { diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts index 8080d0782..4ac66d8be 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ acquireSharedOpenCodeServer: vi.fn(async () => ({ @@ -57,10 +60,14 @@ vi.mock("./openCodeServerManager", () => ({ })); import { + __setOpenCodeInventoryPersistencePathForTests, clearOpenCodeInventoryCache, + loadPersistedOpenCodeInventory, peekOpenCodeInventoryCache, + persistOpenCodeInventory, probeOpenCodeProviderInventory, shutdownInventoryServer, + type OpenCodeProviderInfo, } from "./openCodeInventory"; describe("openCodeInventory", () => { @@ -535,3 +542,46 @@ describe("openCodeInventory", () => { })); }); }); + +describe("openCode inventory persistence", () => { + let cacheFile: string; + + const providers: OpenCodeProviderInfo[] = [ + { id: "openai", name: "OpenAI", connected: true, modelCount: 12 }, + { id: "moonshotai", name: "Moonshot", connected: false, modelCount: 4 }, + ]; + + beforeEach(() => { + cacheFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ade-oc-inv-")), "inventory.json"); + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + }); + + afterEach(() => { + __setOpenCodeInventoryPersistencePathForTests(null); + try { + fs.rmSync(path.dirname(cacheFile), { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + }); + + it("persists a probe's provider list and reloads it from disk on a cold read", () => { + persistOpenCodeInventory("/repo", providers); + + // Drop the in-memory memo so the read comes straight from disk (cold path). + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + + expect(loadPersistedOpenCodeInventory("/repo")).toEqual(providers); + }); + + it("keeps provider lists isolated per project root", () => { + persistOpenCodeInventory("/repo", providers); + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + + expect(loadPersistedOpenCodeInventory("/other")).toEqual([]); + }); + + it("returns an empty list when nothing has been persisted", () => { + expect(loadPersistedOpenCodeInventory("/repo")).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts deleted file mode 100644 index ada57f76d..000000000 --- a/apps/desktop/src/main/services/opencode/openCodeInventoryPersistence.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - __setOpenCodeInventoryPersistencePathForTests, - loadPersistedOpenCodeInventory, - persistOpenCodeInventory, - type OpenCodeProviderInfo, -} from "./openCodeInventory"; - -let cacheFile: string; - -const providers: OpenCodeProviderInfo[] = [ - { id: "openai", name: "OpenAI", connected: true, modelCount: 12 }, - { id: "moonshotai", name: "Moonshot", connected: false, modelCount: 4 }, -]; - -beforeEach(() => { - cacheFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ade-oc-inv-")), "inventory.json"); - __setOpenCodeInventoryPersistencePathForTests(cacheFile); -}); - -afterEach(() => { - __setOpenCodeInventoryPersistencePathForTests(null); - try { - fs.rmSync(path.dirname(cacheFile), { recursive: true, force: true }); - } catch { - // ignore cleanup failures - } -}); - -describe("openCode inventory persistence", () => { - it("persists a probe's provider list and reloads it from disk on a cold read", () => { - persistOpenCodeInventory("/repo", providers); - - // Drop the in-memory memo so the read comes straight from disk (cold path). - __setOpenCodeInventoryPersistencePathForTests(cacheFile); - - expect(loadPersistedOpenCodeInventory("/repo")).toEqual(providers); - }); - - it("keeps provider lists isolated per project root", () => { - persistOpenCodeInventory("/repo", providers); - __setOpenCodeInventoryPersistencePathForTests(cacheFile); - - expect(loadPersistedOpenCodeInventory("/other")).toEqual([]); - }); - - it("returns an empty list when nothing has been persisted", () => { - expect(loadPersistedOpenCodeInventory("/repo")).toEqual([]); - }); -}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff9b0c1cc..4387b43ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -547,9 +547,12 @@ modelPicker.* # cross-surface model favorites/recents backed by # per-project CRR tables (`model_picker_favorites`, # `model_picker_recents`) and shared by desktop, # TUI, and iOS sync commands. -ade.ai.* # AI integration status + provider auth (storeApiKey/deleteApiKey/getStatus/...). +ade.ai.* # AI integration status + provider auth (storeApiKey/deleteApiKey/getStatus/updateConfig/...). # ade.ai.isOpenCodeInstalled is a cheap probe (no runtime spin-up) # used to gate the ModelPicker OpenCode rail + Settings install CTA. + # OpenCode subscription auth + catalog: opencodeAuthMethods, opencodeOAuthStart / + # opencodeOAuthCancel / opencodeOAuthStatus (push), setOpencodeProviderKey, refreshModelsDev. + # See features/chat/README.md for the channel table + fan-out wiring. ade.ai.cursorCloud.* # Cursor background-agents bridge: listRepositories, listAgents, listRuns, getAgent, createRun, followUp, streamRun, cancelRun, archiveAgent / unarchiveAgent / deleteAgent, listArtifacts / downloadArtifact, openChat (mirror an existing cloud agent into an ADE chat session) ade.automations.* ade.orchestration.* # work-tab orchestration: runCreate, bundleRead, manifestReadSection, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index f1825896a..e8a98ebb8 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -1227,9 +1227,14 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi the layer merge, with no error. This bit `ai.customProviders` and `ai.customModelSlugs` during the OpenCode providers build (custom providers/model slugs written by `ProvidersSection` never reaching the - managed OpenCode config). Both keys are now merged by id/set-union in - `mergeAiConfig` and coerced in `coerceAiConfig`; add any future `ai.*` - field to both, plus `AiConfig` in `shared/types/config.ts`. + managed OpenCode config). Both keys are now folded by `mergeAiConfig` + and validated by `coerceAiConfig`. The merge is **replace semantics, + not union**: `local` supplies the full authoritative list and wins + outright (`localAi ?? sharedAi ?? []`), because the same merge runs on + the `ai.updateConfig` write-patch path where a union would make + removals impossible — absent keeps the existing list, `[]` clears it. + Add any future `ai.*` field to both functions, plus `AiConfig` in + `shared/types/config.ts`. ## Configuration diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index feb2a4390..39251baa2 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -272,8 +272,46 @@ Renderer — settings: confirms deletion, and a load/cancel failure renders explicitly rather than being mistaken for an empty list. - `apps/desktop/src/renderer/components/settings/ProvidersSection.tsx` - — AI Connections settings for provider CLIs, authentication, API keys, - and model availability. + — AI Connections settings, organized into two top-level groups. **Coding + Agents** renders four provider-CLI cards in fixed order — Claude Code, + Codex CLI, Cursor, and Droid — each showing readiness/auth tone, credential + source, and CLI path. Claude offers a `ClaudeLoginPromptButton` when the + binary is present but signed out; Cursor is the only card with an inline + `CURSOR_API_KEY` field (Add key stores then verifies). **OpenCode — + Universal Model Access** is the managed universal-model surface with a + models.dev "catalog synced … · refresh" freshness control (backed by + `modelsDevLastFetchedAt` + `refreshModelsDev`) and five sub-sections: + **Subscriptions** (one card per OpenCode provider whose auth methods + include an `oauth` entry — Connect opens `OAuthConnectModal` — plus an + always-present **Kimi for Coding** membership-key row that opens + `KimiKeyDialog`); **API Provider Keys** (a fixed grid of Anthropic, OpenAI, + Google AI, Mistral, DeepSeek, xAI, Groq, Together AI, OpenRouter, and + Moonshot AI, each saved with `alsoOpenCode: true` so the key is registered + with OpenCode via `setOpencodeProviderKey`); **More Providers** (a + searchable ~160-provider chip cloud sourced from the OpenCode inventory, + first 30 shown until the user searches; a keyless chip seeds a provider key + inline); **Local Model Servers** (LM Studio and Ollama endpoints/preferred + model, written through `updateConfig({ localProviders })`); and **Advanced — + custom providers & model slugs** (a collapsed `
` that adds + OpenAI-/Anthropic-compatible custom providers — id, name, baseURL, `npm` + select, models, optional key — writing `ai.customProviders`, and extra + `provider/model` slugs writing `ai.customModelSlugs`, both via + `updateConfig`). When the OpenCode provider inventory is served from the + persisted disk cache on a cold start, `opencodeProvidersStale` renders an + italic "updating…" hint until the first live probe. When the OpenCode + binary is missing the group collapses to an install card. +- `apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx` + — subscription OAuth connect dialog for OpenCode providers. Runs a + `form → starting → waiting → error` phase machine: it renders the + provider's `oauth` auth-method selection and any typed `text`/`select` + prompts (respecting a prompt's `when` conditional), calls + `opencodeOAuthStart({ providerId, methodIndex, inputs })` to open the + browser, extracts and displays a device code from the returned + instructions when present, and subscribes to `onOpencodeOAuthStatus` to + settle on `connected` / `failed` / `timeout` / `cancelled` without polling. + Cancel, Escape, backdrop click, and unmount-while-active all call + `opencodeOAuthCancel`. Success force-refreshes status, reloads auth + methods, and toasts the added-model count. - `apps/desktop/src/renderer/components/settings/SecretsSection.tsx` — Settings > Secrets. Lists project-scoped ADE secrets without values, adds/replaces secrets, reveals values on demand, copies them to the @@ -597,7 +635,7 @@ changing rather than which service backs it: |---|---|---| | General | `GeneralSection.tsx` (GitHub/Linear connections, product analytics, voice input, launch prompts, completion sound, PR transcripts, project files, environment) | Consolidated day-to-day preferences and integrations. Product analytics exposes only status and the machine-wide opt-out. GitHub and Linear auth live here (not a separate Integrations tab). Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` redirect to General with hash anchors (`#github-connection`, `#linear-connection`). Also receives `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` via `TAB_ALIASES`. | | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, chat font size, transcript density, chrome tint, shell geometry, and the user-message minimap toggle. Persisted to `localStorage` under `ade.userPreferences.v1`. | -| AI Connections | `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | +| AI Connections | `ProvidersSection.tsx`, `OAuthConnectModal.tsx` | Two groups: **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid) and **OpenCode — Universal Model Access** (models.dev catalog freshness + Subscriptions/OAuth & Kimi, API Provider Keys incl. Moonshot AI, a searchable ~160-provider chip cloud, Local Model Servers, and Advanced custom providers/model slugs). Subscription connects run through `OAuthConnectModal`; custom providers/slugs persist to `ai.customProviders` / `ai.customModelSlugs`. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | | Background Jobs | `AiFeaturesSection.tsx` | AI-powered automations: summaries, PR descriptions, commit messages, auto-naming, plus project-wide scheduled-work recovery. **Pause all scheduled work** keeps Claude wakeups, cron tasks, and loops armed while suppressing `nextWakeAt`; on resume each overdue schedule runs once before cron work returns to its normal cadence. **Active scheduled work** lists KV-backed durable jobs from every chat with per-job Cancel and an explicit unavailable/error state. Legacy `?tab=automations` lands here. Each feature row has an independent reasoning-effort override (`ReasoningEffortPicker` with `useFamilyDefaults={false}`). | | Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | | Storage | `StorageSection.tsx`, `storage/StorageCleanupDialog.tsx`, `storage/storageView.ts` | Disk-usage dashboard: current volume pressure, ADE storage broken down by category (lanes/worktrees, chats & terminal history, caches, build & release, proof & attachments, recovery backups, database), preview-confirmed cleanup of the removable subset, and a manual "compress old history" action. Reads `window.ade.storage.getPressure` / `getSnapshot` and mutates through `compressNow` / `cleanupPreview` / `cleanup`. Deep links from `?tab=storage` and `?tab=disk` (via `TAB_ALIASES`). See [Storage and recovery](../storage-and-recovery/README.md). | @@ -661,6 +699,14 @@ Onboarding and settings follow a simple rule: callbacks (the config service iterates listeners). A hot reload is best-effort — some changes only take full effect on app restart (e.g. proxy port changes). +- **New `ai.*` config fields need both `coerceAiConfig` and + `mergeAiConfig`.** A field added to only one of `projectConfigService`'s + two functions is silently dropped (it fails to load off disk, or fails + to survive the shared+local merge). This bit `ai.customProviders` / + `ai.customModelSlugs` written by the AI Connections **Advanced** block. + Both use replace semantics rather than the id-matched merges used + elsewhere — the UI writes the full authoritative list. See + [configuration-schema.md](./configuration-schema.md#custom-providers-and-model-slugs). - **Onboarding status.** `freshProject` is computed at `createOnboardingService` construction and does not update at runtime. Passing the wrong boolean flips the "first-run" surface on diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 23957bee7..c62ebba8a 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -296,6 +296,8 @@ type AiConfig = { defaultModel?: ModelId; apiKeys?: Record; // stored encrypted per provider localProviders?: AiLocalProviderConfigs; + customProviders?: AiCustomProviderConfig[]; // user-defined OpenAI-/Anthropic-compatible providers + customModelSlugs?: string[]; // extra provider/model slugs pinned as selectable workerSafety?: WorkerSafetyPolicy; featureModelOverrides?: Partial>; featureReasoningOverrides?: Partial>; @@ -307,6 +309,41 @@ type AiConfig = { behavior. Legacy `providers.mode` migration is still in the service but idempotent. +### Custom providers and model slugs + +`ai.customProviders` and `ai.customModelSlugs` back the **Advanced — +custom providers & model slugs** block in AI Connections settings. They +let a user add an OpenAI-/Anthropic-compatible provider (or extra model +slugs) that flow into ADE's managed OpenCode server config and the model +picker. + +```ts +type AiCustomProviderConfig = { + id: string; + name: string; // falls back to id when omitted + baseURL: string; + npm?: "@ai-sdk/openai-compatible" | "@ai-sdk/openai" | "@ai-sdk/anthropic"; + models: string[]; // provider-local model ids +}; +``` + +`coerceAiConfig` drops any custom-provider entry missing `id`, +`baseURL`, or a non-empty `models` list, and coerces an unrecognized +`npm` value back to `undefined`. `customModelSlugs` are trimmed +`providerId/modelId` strings. + +**Both fields must be handled in two places in `projectConfigService`:** +`coerceAiConfig` (validate/parse off disk) and `mergeAiConfig` (fold +shared + local into `effective`). A field added to only one is silently +dropped. Unlike the id-matched array merges elsewhere in this schema, +these two use **replace semantics**: `local` provides the full +authoritative list and wins outright, because the same merge runs on the +`ai.updateConfig` write-patch path where a union would make removals +impossible. Absent keeps the existing list; `[]` clears it. The AI +Connections UI always writes the complete list, never a delta. Any new +`ai.*` field follows the same both-places rule and must also be added to +`AiConfig` in `shared/types/config.ts`. + `AiChatConfig.scheduledWorkPaused?: boolean` is the project-runtime-wide pause for durable Claude wakeups, cron tasks, and `/loop`. It suppresses fires and `nextWakeAt` without deleting schedule records. Clearing the pause From e07297d739295e5fbad17913492d90f2d6afdbaa Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:07:14 -0400 Subject: [PATCH 4/8] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix=20b?= =?UTF-8?q?uild-runtime-binaries=20(guard=20electron=20require=20in=20open?= =?UTF-8?q?CodeAuthService),=20address=20Greptile=20#3606770990=20(ai.getS?= =?UTF-8?q?tatus=20drops=20customProviders/customModelSlugs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/desktop/src/main/services/ipc/registerIpc.ts | 2 ++ .../main/services/opencode/openCodeAuthService.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 1494e0819..32e3874cd 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -4324,6 +4324,8 @@ export function registerIpc({ opencodeProviders: status.opencodeProviders, opencodeProvidersStale: status.opencodeProvidersStale, modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, + customProviders: status.customProviders, + customModelSlugs: status.customModelSlugs, apiKeyStore: status.apiKeyStore, features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ feature, diff --git a/apps/desktop/src/main/services/opencode/openCodeAuthService.ts b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts index 6b7c26134..e5e67a46c 100644 --- a/apps/desktop/src/main/services/opencode/openCodeAuthService.ts +++ b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts @@ -85,9 +85,16 @@ const defaultHooks: OpenCodeAuthHooks = { return Array.isArray(data?.connected) ? data.connected : []; }, async openExternal(url) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { shell } = require("electron") as { shell: { openExternal(u: string): Promise } }; - await shell.openExternal(url); + // try/catch keeps esbuild treating this as a runtime-optional require so + // the ade-cli static runtime (no electron) can bundle this module; there + // the OAuth URL is still surfaced to the caller, just not auto-opened. + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { shell } = require("electron") as { shell: { openExternal(u: string): Promise } }; + await shell.openExternal(url); + } catch { + // Not running inside Electron — caller shows the URL instead. + } }, async probeInventory(deps) { await probeOpenCodeProviderInventory({ From c077777400be09cdfff1daa2ddc44dd6dd89bf18 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:46:43 -0400 Subject: [PATCH 5/8] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20address?= =?UTF-8?q?=20Codex=20#3606867631=20(require=20base=20URL=20before=20savin?= =?UTF-8?q?g=20custom=20providers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/renderer/components/settings/ProvidersSection.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index f11cc6311..7e73e50f7 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -871,9 +871,10 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const saveAdvancedProvider = async () => { const draft = customProviderDraft; const id = draft.id.trim(); + const baseURL = draft.baseUrl.trim(); const slugs = draft.slugs.split(",").map((s) => s.trim()).filter(Boolean); - if (!id || slugs.length === 0) { - setError("A custom provider needs an id and at least one model slug."); + if (!id || !baseURL || slugs.length === 0) { + setError("A custom provider needs an id, a base URL, and at least one model slug."); return; } setSavingAdvanced(true); @@ -892,7 +893,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh { id, name: draft.name.trim() || prettifyProviderId(id), - baseURL: draft.baseUrl.trim(), + baseURL, npm: draft.npm as AiCustomProviderConfig["npm"], models: slugs, }, From 720e412b5bd5e2b813a0baec3d074734665cec86 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:33:57 -0400 Subject: [PATCH 6/8] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?= =?UTF-8?q?=20OAuth,=20inventory,=20and=20picker=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/adeActions/registry.ts | 17 +- .../src/main/services/ipc/registerIpc.ts | 23 +++ .../opencode/openCodeAuthService.test.ts | 117 +++++++++++++ .../services/opencode/openCodeAuthService.ts | 154 ++++++++++++++---- .../opencode/openCodeBinaryManager.test.ts | 55 +++++++ .../opencode/openCodeBinaryManager.ts | 30 ++-- .../opencode/openCodeInventory.test.ts | 82 +++++++++- .../services/opencode/openCodeInventory.ts | 36 +++- .../opencode/openCodeServerManager.test.ts | 22 ++- .../opencode/openCodeServerManager.ts | 24 +-- apps/desktop/src/preload/global.d.ts | 3 + apps/desktop/src/preload/preload.ts | 52 +++++- apps/desktop/src/renderer/browserMock.ts | 1 + .../components/settings/OAuthConnectModal.tsx | 40 +++-- .../settings/ProvidersSection.test.tsx | 61 +++++++ .../components/settings/ProvidersSection.tsx | 17 +- .../shared/ModelPicker/ModelPicker.test.tsx | 17 ++ .../shared/ModelPicker/ModelPickerContent.tsx | 1 + .../adapter/__tests__/adapter.test.ts | 21 ++- .../src/renderer/webclient/adapter/misc.ts | 7 + apps/desktop/src/shared/ipc.ts | 1 + configuration/ai-providers.mdx | 2 +- 22 files changed, 684 insertions(+), 99 deletions(-) diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index bd729ca32..1954f6769 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -5,6 +5,7 @@ import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; import { addOpenCodeOAuthStatusListener, cancelOAuth as cancelOpenCodeOAuth, + clearProviderKey as clearOpenCodeProviderKey, listAuthMethods as listOpenCodeAuthMethods, setProviderKey as setOpenCodeProviderKey, startOAuth as startOpenCodeOAuth, @@ -177,7 +178,7 @@ export const ADE_ACTION_CTO_ONLY: Partial(); function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { if (!runtime.eventBuffer || oauthStatusBridgedRuntimes.has(runtime)) return; oauthStatusBridgedRuntimes.add(runtime); - addOpenCodeOAuthStatusListener((event) => { + const unsubscribe = addOpenCodeOAuthStatusListener((event) => { try { runtime.eventBuffer.push({ timestamp: new Date().toISOString(), @@ -2064,6 +2066,11 @@ function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { // A full/broken buffer must not break the OAuth flow. } }); + const dispose = runtime.dispose; + runtime.dispose = () => { + unsubscribe(); + dispose(); + }; } function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { @@ -2093,6 +2100,10 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { providerId: requireNonEmptyString(args?.providerId, "providerId"), key: requireNonEmptyString(args?.key, "key"), }), + clearOpencodeProviderKey: (args?: { providerId?: string }) => + clearOpenCodeProviderKey(buildOpenCodeAuthDeps(), { + providerId: requireNonEmptyString(args?.providerId, "providerId"), + }), refreshModelsDev: async () => { try { await refreshModelsDevNow(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 32e3874cd..02e4318dd 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -576,6 +576,7 @@ import { mergeAiConfig, type createProjectConfigService } from "../config/projec import { addOpenCodeOAuthStatusListener, cancelOAuth as cancelOpenCodeOAuth, + clearProviderKey as clearOpenCodeProviderKey, listAuthMethods as listOpenCodeAuthMethods, setProviderKey as setOpenCodeProviderKey, startOAuth as startOpenCodeOAuth, @@ -4491,6 +4492,28 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.aiClearOpencodeProviderKey, + async ( + _event, + arg: { providerId: string }, + ): Promise<{ ok: boolean; error?: string }> => { + const ctx = getCtx(); + const result = await clearOpenCodeProviderKey(buildOpenCodeAuthDeps(), arg); + if (result.ok) { + try { + ctx.aiIntegrationService?.invalidateProviderReadinessCaches(); + } catch (error) { + ctx.logger.warn("ai.api_key_cache_invalidation_failed", { + provider: arg.providerId, + error: getErrorMessage(error), + }); + } + } + return result; + }, + ); + ipcMain.handle(IPC.aiRefreshModelsDev, async (): Promise<{ lastFetchedAt: number | null }> => { const ctx = getCtx(); try { diff --git a/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts b/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts index 338b939bd..8ebffe49c 100644 --- a/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeAuthService.test.ts @@ -3,10 +3,12 @@ import type { Logger } from "../logging/logger"; import type { OpenCodeOAuthStatusEvent } from "../../../shared/types/config"; import { __getActiveOAuthProviderIdsForTests, + __isAllowedOAuthExternalUrlForTests, __resetOpenCodeAuthServiceForTests, __setOpenCodeAuthHooksForTests, addOpenCodeOAuthStatusListener, cancelOAuth, + clearProviderKey, listAuthMethods, setProviderKey, startOAuth, @@ -103,6 +105,55 @@ describe("openCodeAuthService", () => { expect(__getActiveOAuthProviderIdsForTests()).toEqual([]); }); + it("serializes OAuth polls and aborts a stalled provider-list request", async () => { + vi.useFakeTimers(); + const signals: AbortSignal[] = []; + let activeRequests = 0; + let maxActiveRequests = 0; + const listConnectedProviders = vi.fn( + async (_baseUrl: string, _directory: string, signal?: AbortSignal): Promise => { + signals.push(signal!); + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + return await new Promise((_, reject) => { + signal?.addEventListener("abort", () => { + activeRequests -= 1; + reject(signal.reason); + }, { once: true }); + }); + }, + ); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release: vi.fn() }), + httpJson: async () => ({ ok: true, status: 200, body: { url: "https://x", method: "auto", instructions: "" } }), + listConnectedProviders, + openExternal: async () => {}, + probeInventory: async () => {}, + }); + + await startOAuth(deps, { providerId: "openai", methodIndex: 0 }); + await vi.advanceTimersByTimeAsync(2_000); + expect(listConnectedProviders).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(10_000); + expect(signals[0]?.aborted).toBe(true); + expect(listConnectedProviders).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_000); + expect(listConnectedProviders).toHaveBeenCalledTimes(2); + expect(maxActiveRequests).toBe(1); + }); + + it("allows HTTPS and loopback HTTP OAuth URLs only", () => { + expect(__isAllowedOAuthExternalUrlForTests("https://example.com/oauth")).toBe(true); + expect(__isAllowedOAuthExternalUrlForTests("http://localhost:3000/callback")).toBe(true); + expect(__isAllowedOAuthExternalUrlForTests("http://127.0.0.2/callback")).toBe(true); + expect(__isAllowedOAuthExternalUrlForTests("http://[::1]/callback")).toBe(true); + expect(__isAllowedOAuthExternalUrlForTests("http://example.com/oauth")).toBe(false); + expect(__isAllowedOAuthExternalUrlForTests("file:///tmp/token")).toBe(false); + expect(__isAllowedOAuthExternalUrlForTests("custom-protocol://oauth")).toBe(false); + }); + it("emits timeout and releases the lease when the provider never connects", async () => { vi.useFakeTimers(); const events = collectEvents(); @@ -195,4 +246,70 @@ describe("openCodeAuthService", () => { expect(result.error).toContain("400"); expect(storeApiKey).not.toHaveBeenCalled(); }); + + it("setProviderKey reports durable key-store failures", async () => { + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async () => ({ ok: true, status: 200, body: true }), + storeApiKey: () => { + throw new Error("keychain unavailable"); + }, + }); + + const result = await setProviderKey(deps, { providerId: "moonshotai", key: "sk-test" }); + expect(result).toEqual({ + ok: false, + error: "OpenCode accepted the key, but ADE could not store it durably: keychain unavailable", + }); + expect(logger.warn).toHaveBeenCalledWith("opencode.oauth_key_mirror_failed", { + providerId: "moonshotai", + error: "keychain unavailable", + }); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("setProviderKey rejects empty inputs before acquiring a lease", async () => { + const acquireLease = vi.fn(); + __setOpenCodeAuthHooksForTests({ acquireLease }); + + await expect(setProviderKey(deps, { providerId: " ", key: "sk-test" })).resolves.toEqual({ + ok: false, + error: "Provider ID is required.", + }); + await expect(setProviderKey(deps, { providerId: "openai", key: " " })).resolves.toEqual({ + ok: false, + error: "Provider key is required.", + }); + expect(acquireLease).not.toHaveBeenCalled(); + }); + + it("clearProviderKey DELETEs the managed OpenCode credential", async () => { + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async (url, init) => { + expect(url).toBe("http://127.0.0.1:4200/auth/github-copilot"); + expect(init?.method).toBe("DELETE"); + return { ok: true, status: 204, body: null }; + }, + }); + + await expect(clearProviderKey(deps, { providerId: " github-copilot " })).resolves.toEqual({ ok: true }); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("clearProviderKey reports server failures and releases the lease", async () => { + const release = vi.fn(); + __setOpenCodeAuthHooksForTests({ + acquireLease: async () => ({ url: "http://127.0.0.1:4200", release }), + httpJson: async () => ({ ok: false, status: 405, body: null }), + }); + + await expect(clearProviderKey(deps, { providerId: "openai" })).resolves.toEqual({ + ok: false, + error: "OpenCode DELETE /auth failed (405).", + }); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeAuthService.ts b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts index e5e67a46c..b33a4f7cd 100644 --- a/apps/desktop/src/main/services/opencode/openCodeAuthService.ts +++ b/apps/desktop/src/main/services/opencode/openCodeAuthService.ts @@ -28,6 +28,8 @@ import { storeApiKey as storeStoredApiKey } from "../ai/apiKeyStore"; const OAUTH_LEASE_IDLE_TTL_MS = 10_000; /** OAuth completion poll cadence. */ const POLL_INTERVAL_MS = 2_000; +/** Bound each provider-list request so a stalled server cannot wedge polling. */ +const POLL_REQUEST_TIMEOUT_MS = 10_000; /** Give up on an OAuth flow after this long without the provider connecting. */ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; @@ -44,7 +46,7 @@ type HttpJsonResult = { ok: boolean; status: number; body: unknown }; type OpenCodeAuthHooks = { acquireLease(deps: OpenCodeAuthDeps): Promise; httpJson(url: string, init?: RequestInit): Promise; - listConnectedProviders(baseUrl: string, directory: string): Promise; + listConnectedProviders(baseUrl: string, directory: string, signal?: AbortSignal): Promise; openExternal(url: string): Promise; probeInventory(deps: OpenCodeAuthDeps): Promise; storeApiKey(providerId: string, key: string): void; @@ -55,6 +57,21 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function isAllowedOAuthExternalUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:") return true; + if (parsed.protocol !== "http:") return false; + const hostname = parsed.hostname.toLowerCase(); + return hostname === "localhost" + || hostname === "[::1]" + || hostname === "::1" + || /^127(?:\.\d{1,3}){3}$/.test(hostname); + } catch { + return false; + } +} + const defaultHooks: OpenCodeAuthHooks = { async acquireLease(deps) { const config = buildOpenCodeMergedConfig({ projectConfig: deps.projectConfig }); @@ -78,13 +95,16 @@ const defaultHooks: OpenCodeAuthHooks = { } return { ok: res.ok, status: res.status, body }; }, - async listConnectedProviders(baseUrl, directory) { + async listConnectedProviders(baseUrl, directory, signal) { const client = createOpencodeClient({ baseUrl, directory }); - const listed = await client.provider.list({ query: { directory }, throwOnError: true }); + const listed = await client.provider.list({ query: { directory }, throwOnError: true, signal }); const data = listed.data as { connected?: string[] } | undefined; return Array.isArray(data?.connected) ? data.connected : []; }, async openExternal(url) { + if (!isAllowedOAuthExternalUrl(url)) { + throw new Error("OpenCode returned an unsafe OAuth URL."); + } // try/catch keeps esbuild treating this as a runtime-optional require so // the ade-cli static runtime (no electron) can bundle this module; there // the OAuth URL is still surfaced to the caller, just not auto-opened. @@ -118,7 +138,8 @@ const statusListeners = new Set(); type ActiveFlow = { release: () => void; - timer: ReturnType; + timer: ReturnType | null; + requestController: AbortController | null; }; const activeFlows = new Map(); @@ -144,13 +165,14 @@ function emit(event: OpenCodeOAuthStatusEvent): void { } } -/** Tear down an active flow (clear the poll timer, release the lease) and emit `state`. */ +/** Tear down an active flow (clear/abort polling, release the lease) and emit `state`. */ function finishFlow(providerId: string, state: OpenCodeOAuthStatusEvent["state"], error?: string): void { const flow = activeFlows.get(providerId); if (!flow) return; - clearInterval(flow.timer); - flow.release(); activeFlows.delete(providerId); + if (flow.timer) clearTimeout(flow.timer); + flow.requestController?.abort(new Error("OpenCode OAuth polling stopped.")); + flow.release(); emit({ providerId, state, ...(error ? { error } : {}) }); } @@ -209,32 +231,60 @@ export async function startOAuth( emit({ providerId, state: "pending" }); const startedAt = hooks.now(); - const timer = setInterval(() => { - void (async () => { - try { - if (hooks.now() - startedAt >= OAUTH_TIMEOUT_MS) { - finishFlow(providerId, "timeout"); - return; - } - const connected = await hooks.listConnectedProviders(lease.url, deps.projectRoot); - if (!activeFlows.has(providerId)) return; // cancelled while awaiting - if (connected.includes(providerId)) { - finishFlow(providerId, "connected"); - void hooks.probeInventory(deps).catch((err) => { - deps.logger.warn("opencode.oauth_post_connect_probe_failed", { - providerId, - error: errorMessage(err), - }); + const flow: ActiveFlow = { + release: () => lease.release(), + timer: null, + requestController: null, + }; + const scheduleNextPoll = () => { + flow.timer = setTimeout(() => void poll(), POLL_INTERVAL_MS); + if (flow.timer.unref) flow.timer.unref(); + }; + const poll = async (): Promise => { + if (activeFlows.get(providerId) !== flow) return; + const elapsed = hooks.now() - startedAt; + if (elapsed >= OAUTH_TIMEOUT_MS) { + finishFlow(providerId, "timeout"); + return; + } + + const controller = new AbortController(); + flow.requestController = controller; + const requestTimeout = setTimeout( + () => controller.abort(new Error("OpenCode provider list request timed out.")), + Math.min(POLL_REQUEST_TIMEOUT_MS, OAUTH_TIMEOUT_MS - elapsed), + ); + if (requestTimeout.unref) requestTimeout.unref(); + try { + const connected = await hooks.listConnectedProviders(lease.url, deps.projectRoot, controller.signal); + if (activeFlows.get(providerId) !== flow) return; + if (connected.includes(providerId)) { + finishFlow(providerId, "connected"); + void hooks.probeInventory(deps).catch((err) => { + deps.logger.warn("opencode.oauth_post_connect_probe_failed", { + providerId, + error: errorMessage(err), }); - } - } catch (err) { - deps.logger.warn("opencode.oauth_poll_failed", { providerId, error: errorMessage(err) }); + }); + return; } - })(); - }, POLL_INTERVAL_MS); - if (timer.unref) timer.unref(); + } catch (err) { + if (activeFlows.get(providerId) !== flow) return; + deps.logger.warn("opencode.oauth_poll_failed", { providerId, error: errorMessage(err) }); + } finally { + clearTimeout(requestTimeout); + if (activeFlows.get(providerId) === flow) flow.requestController = null; + } - activeFlows.set(providerId, { release: () => lease.release(), timer }); + if (hooks.now() - startedAt >= OAUTH_TIMEOUT_MS) { + finishFlow(providerId, "timeout"); + return; + } + scheduleNextPoll(); + }; + + activeFlows.set(providerId, flow); + scheduleNextPoll(); handedOff = true; return { url, method, instructions }; } catch (err) { @@ -259,7 +309,11 @@ export async function setProviderKey( deps: OpenCodeAuthDeps, args: { providerId: string; key: string }, ): Promise<{ ok: boolean; error?: string }> { - const { providerId, key } = args; + const providerId = args.providerId.trim(); + const key = args.key.trim(); + if (!providerId) return { ok: false, error: "Provider ID is required." }; + if (!key) return { ok: false, error: "Provider key is required." }; + const lease = await hooks.acquireLease(deps); try { const res = await hooks.httpJson(`${lease.url}/auth/${encodeURIComponent(providerId)}`, { @@ -273,7 +327,36 @@ export async function setProviderKey( try { hooks.storeApiKey(providerId, key); } catch (err) { - deps.logger.warn("opencode.oauth_key_mirror_failed", { providerId, error: errorMessage(err) }); + const error = errorMessage(err); + deps.logger.warn("opencode.oauth_key_mirror_failed", { providerId, error }); + return { + ok: false, + error: `OpenCode accepted the key, but ADE could not store it durably: ${error}`, + }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } finally { + lease.release(); + } +} + +/** Remove a provider credential from the managed OpenCode server. */ +export async function clearProviderKey( + deps: OpenCodeAuthDeps, + args: { providerId: string }, +): Promise<{ ok: boolean; error?: string }> { + const providerId = args.providerId.trim(); + if (!providerId) return { ok: false, error: "Provider ID is required." }; + + const lease = await hooks.acquireLease(deps); + try { + const res = await hooks.httpJson(`${lease.url}/auth/${encodeURIComponent(providerId)}`, { + method: "DELETE", + }); + if (!res.ok) { + return { ok: false, error: `OpenCode DELETE /auth failed (${res.status}).` }; } return { ok: true }; } catch (err) { @@ -293,7 +376,8 @@ export function __resetOpenCodeAuthServiceForTests(): void { for (const providerId of [...activeFlows.keys()]) { const flow = activeFlows.get(providerId); if (flow) { - clearInterval(flow.timer); + if (flow.timer) clearTimeout(flow.timer); + flow.requestController?.abort(new Error("OpenCode OAuth service reset.")); flow.release(); } activeFlows.delete(providerId); @@ -305,3 +389,7 @@ export function __resetOpenCodeAuthServiceForTests(): void { export function __getActiveOAuthProviderIdsForTests(): string[] { return [...activeFlows.keys()]; } + +export function __isAllowedOAuthExternalUrlForTests(url: string): boolean { + return isAllowedOAuthExternalUrl(url); +} diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts index add5c2f0a..1b439d83c 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts @@ -2,8 +2,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const childProcessMockState = vi.hoisted(() => ({ + execFileSync: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFileSync: (...args: unknown[]) => childProcessMockState.execFileSync(...args), + }; +}); + import { clearOpenCodeBinaryCache, + probeOpenCodeBinaryQuarantine, resolveOpenCodeBinary, } from "./openCodeBinaryManager"; @@ -15,6 +29,14 @@ const originalEnv = { PATH: process.env.PATH, SHELL: process.env.SHELL, }; +const originalProcessPlatform = process.platform; + +function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -42,6 +64,7 @@ describe("openCodeBinaryManager", () => { let homeDir: string; beforeEach(() => { + childProcessMockState.execFileSync.mockReset(); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-opencode-bin-")); homeDir = path.join(tempRoot, "home"); fs.mkdirSync(homeDir, { recursive: true }); @@ -65,6 +88,7 @@ describe("openCodeBinaryManager", () => { }); afterEach(() => { + setProcessPlatform(originalProcessPlatform); clearOpenCodeBinaryCache(); vi.restoreAllMocks(); restoreEnv("ADE_DISABLE_BUNDLED_OPENCODE"); @@ -128,4 +152,35 @@ describe("openCodeBinaryManager", () => { source: "bundled", }); }); + + it("treats any successful quarantine attribute read as quarantined", () => { + setProcessPlatform("darwin"); + childProcessMockState.execFileSync.mockReturnValue(""); + + expect(probeOpenCodeBinaryQuarantine("/bin/opencode")).toBe("quarantined"); + }); + + it("returns clean only for an explicit missing quarantine attribute", () => { + setProcessPlatform("darwin"); + childProcessMockState.execFileSync.mockImplementation(() => { + throw Object.assign(new Error("xattr failed"), { + status: 1, + stderr: "xattr: /bin/opencode: No such xattr: com.apple.quarantine", + }); + }); + + expect(probeOpenCodeBinaryQuarantine("/bin/opencode")).toBe("clean"); + }); + + it("keeps other quarantine probe failures unknown", () => { + setProcessPlatform("darwin"); + childProcessMockState.execFileSync.mockImplementation(() => { + throw Object.assign(new Error("xattr failed"), { + status: 1, + stderr: "xattr: /bin/opencode: Permission denied", + }); + }); + + expect(probeOpenCodeBinaryQuarantine("/bin/opencode")).toBe("unknown"); + }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts index e48c4ac90..28f2b0cb6 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts @@ -186,20 +186,30 @@ export function probeOpenCodeBinaryQuarantine(binaryPath: string | null | undefi const trimmed = binaryPath?.trim(); if (!trimmed) return "unknown"; try { - const output = execFileSync("xattr", ["-p", "com.apple.quarantine", trimmed], { + execFileSync("xattr", ["-p", "com.apple.quarantine", trimmed], { encoding: "utf8", timeout: 1_000, windowsHide: true, - stdio: ["ignore", "pipe", "ignore"], + stdio: ["ignore", "pipe", "pipe"], }); - return output.trim().length > 0 ? "quarantined" : "clean"; + return "quarantined"; } catch (error) { - // `xattr -p` exits non-zero when the attribute is absent — treat that as - // "clean". A missing `xattr` binary (ENOENT) or a timeout leaves `status` - // null, which we surface as "unknown" so callers don't over-claim. - const status = (error && typeof error === "object" && "status" in error) - ? (error as { status?: number | null }).status - : undefined; - return typeof status === "number" && status !== 0 ? "clean" : "unknown"; + // A non-zero exit can also mean permission/I/O failures, so only the + // platform's explicit missing-attribute diagnostic proves the binary is + // clean. All other failures remain inconclusive. + const record = error && typeof error === "object" + ? error as { code?: unknown; message?: unknown; stderr?: unknown } + : null; + const stderr = typeof record?.stderr === "string" + ? record.stderr + : Buffer.isBuffer(record?.stderr) ? record.stderr.toString("utf8") : ""; + const detail = `${typeof record?.message === "string" ? record.message : ""}\n${stderr}`; + if ( + record?.code === "ENOATTR" + || /no such (?:xattr|extended attribute)|attribute not found/i.test(detail) + ) { + return "clean"; + } + return "unknown"; } } diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts index 4ac66d8be..62633eae2 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts @@ -98,6 +98,60 @@ describe("openCodeInventory", () => { expect(mockState.shutdownOpenCodeServers).toHaveBeenCalledWith({ leaseKind: "shared", ownerKind: "inventory" }); }); + it("invalidates cached inventory when custom provider config changes", async () => { + const logger = { warn: vi.fn() } as any; + + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { + ai: { + customProviders: [{ + id: "acme", + name: "Acme", + baseURL: "https://old.example.test/v1", + models: ["old-model"], + }], + }, + }, + logger, + force: true, + }); + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { + ai: { + customProviders: [{ + id: "acme", + name: "Acme", + baseURL: "https://new.example.test/v1", + models: ["new-model"], + }], + }, + }, + logger, + }); + + expect(mockState.providerList).toHaveBeenCalledTimes(2); + }); + + it("invalidates cached inventory when custom model slugs change", async () => { + const logger = { warn: vi.fn() } as any; + + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: { customModelSlugs: ["acme/old-model"] } }, + logger, + force: true, + }); + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: { customModelSlugs: ["acme/new-model"] } }, + logger, + }); + + expect(mockState.providerList).toHaveBeenCalledTimes(2); + }); + it("filters local providers when discovery data is absent", async () => { const logger = { warn: vi.fn() } as any; mockState.providerList.mockResolvedValueOnce({ @@ -326,7 +380,7 @@ describe("openCodeInventory", () => { expect(opusDescriptor?.serviceTiers).toEqual(["fast"]); }); - it("normalizes retired-only Anthropic OpenCode rows to canonical launch ids", async () => { + it("normalizes retired-only Anthropic display ids while routing through advertised aliases", async () => { const logger = { warn: vi.fn() } as any; mockState.providerList.mockResolvedValueOnce({ data: { @@ -371,8 +425,8 @@ describe("openCodeInventory", () => { expect(result.modelIds).not.toContain("opencode/anthropic/opus"); expect(result.descriptors.find((entry) => entry.id === "opencode/anthropic/claude-sonnet-5")).toMatchObject({ displayName: "Claude Sonnet 5", - openCodeModelId: "claude-sonnet-5", - providerModelId: "anthropic/claude-sonnet-5", + openCodeModelId: "claude-sonnet-4-6", + providerModelId: "anthropic/claude-sonnet-4-6", contextWindow: 1_000_000, maxOutputTokens: 128_000, capabilities: expect.objectContaining({ @@ -384,8 +438,8 @@ describe("openCodeInventory", () => { }); expect(result.descriptors.find((entry) => entry.id === "opencode/anthropic/claude-opus-4-8")).toMatchObject({ displayName: "Claude Opus 4.8 1M", - openCodeModelId: "claude-opus-4-8", - providerModelId: "anthropic/claude-opus-4-8", + openCodeModelId: "opus", + providerModelId: "anthropic/opus", contextWindow: 1_000_000, maxOutputTokens: 128_000, capabilities: expect.objectContaining({ @@ -584,4 +638,22 @@ describe("openCode inventory persistence", () => { it("returns an empty list when nothing has been persisted", () => { expect(loadPersistedOpenCodeInventory("/repo")).toEqual([]); }); + + it("discards malformed persisted entries while preserving valid projects", () => { + fs.writeFileSync(cacheFile, JSON.stringify({ + "/valid": { providers, savedAt: Date.now() }, + "/providers-not-array": { providers: "invalid", savedAt: Date.now() }, + "/invalid-provider": { + providers: [{ id: "openai", name: "OpenAI", connected: "yes", modelCount: 12 }], + savedAt: Date.now(), + }, + "/invalid-timestamp": { providers, savedAt: "yesterday" }, + }), "utf8"); + __setOpenCodeInventoryPersistencePathForTests(cacheFile); + + expect(loadPersistedOpenCodeInventory("/valid")).toEqual(providers); + expect(loadPersistedOpenCodeInventory("/providers-not-array")).toEqual([]); + expect(loadPersistedOpenCodeInventory("/invalid-provider")).toEqual([]); + expect(loadPersistedOpenCodeInventory("/invalid-timestamp")).toEqual([]); + }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index a59882a9c..4e24bef0d 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -86,8 +86,32 @@ function readPersistedInventoryFile(): PersistedInventoryFile { if (persistedInventoryMemo) return persistedInventoryMemo; try { const raw = fs.readFileSync(resolvePersistedInventoryPath(), "utf8"); - const parsed = JSON.parse(raw) as PersistedInventoryFile; - persistedInventoryMemo = parsed && typeof parsed === "object" ? parsed : {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + persistedInventoryMemo = {}; + } else { + persistedInventoryMemo = Object.fromEntries( + Object.entries(parsed).filter(([, entry]) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const record = entry as Record; + if (typeof record.savedAt !== "number" || !Number.isFinite(record.savedAt)) return false; + if (!Array.isArray(record.providers)) return false; + return record.providers.every((provider) => { + if (!provider || typeof provider !== "object" || Array.isArray(provider)) return false; + const info = provider as Record; + return typeof info.id === "string" + && typeof info.name === "string" + && typeof info.connected === "boolean" + && typeof info.modelCount === "number" + && Number.isFinite(info.modelCount) + && ( + info.availableModelCount === undefined + || (typeof info.availableModelCount === "number" && Number.isFinite(info.availableModelCount)) + ); + }); + }), + ) as PersistedInventoryFile; + } } catch { persistedInventoryMemo = {}; } @@ -147,6 +171,8 @@ function fingerprintOpenCodeConfig( return stableStringify({ apiKeys: ai.apiKeys ?? {}, localProviders: ai.localProviders ?? {}, + customProviders: ai.customProviders ?? [], + customModelSlugs: ai.customModelSlugs ?? [], discoveredModels: discoveredLocalModels?.map((m) => `${m.provider}/${m.modelId}`).sort() ?? [], }); } @@ -459,6 +485,12 @@ export async function probeOpenCodeProviderInventory(args: { ...(normalizedModel.serviceTiers?.length ? { serviceTiers: normalizedModel.serviceTiers } : {}), capabilities: normalizedModel.capabilities ?? readOpenCodeModelCapabilities(modelRecord), }); + // Keep ADE's normalized identity/display metadata, but always route + // through the exact model ID OpenCode advertised. An alias row may + // normalize to a newer canonical ADE ID that this provider cannot + // actually launch. + descriptor.openCodeModelId = mid; + descriptor.providerModelId = `${provider.id}/${mid}`; const existingIndex = descriptorIds.get(descriptor.id); if (existingIndex !== undefined) { if ( diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts index 3ada77505..51c139e67 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -116,10 +116,10 @@ describe("classifyOpenCodeLaunchFailure", () => { ).toEqual({ kind: "launch-timeout" }); }); - it("classifies a darwin Gatekeeper kill with the quarantine xattr as quarantined", () => { + it("classifies explicit darwin developer-verification evidence with the quarantine xattr as quarantined", () => { setProcessPlatform("darwin"); mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("quarantined"); - const error = new Error("Server exited with code null\nServer output: killed"); + const error = new Error("OpenCode developer cannot be verified"); expect( classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), ).toEqual({ @@ -138,6 +138,24 @@ describe("classifyOpenCodeLaunchFailure", () => { ).toEqual({ kind: "bad-signature", binaryPath: "/bin/opencode" }); }); + it("keeps generic darwin kills unknown even when a quarantine attribute exists", () => { + setProcessPlatform("darwin"); + mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("quarantined"); + const error = new Error("Server exited with code null\nServer output: killed"); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "unknown", message: error.message }); + }); + + it("keeps explicit signature failures unknown when the quarantine probe is inconclusive", () => { + setProcessPlatform("darwin"); + mockState.probeOpenCodeBinaryQuarantine.mockReturnValue("unknown"); + const error = new Error("dyld: code signature invalid for opencode"); + expect( + classifyOpenCodeLaunchFailure(error, { port: 4096, binaryPath: "/bin/opencode" }), + ).toEqual({ kind: "unknown", message: error.message }); + }); + it("classifies unrecognized failures as unknown, preserving the message", () => { setProcessPlatform("linux"); expect( diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index 38b54545c..fbe7b54b1 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -513,10 +513,9 @@ function errorCode(error: unknown): string { /** * Classify a raw OpenCode launch/probe failure into a typed diagnostic. See the * task heuristics: ENOENT / no resolved binary → not-installed; EADDRINUSE → - * port-conflict; startup deadline → launch-timeout; darwin Gatekeeper signals - * (EPERM / "operation not permitted" / "killed" / "code signature") → - * quarantined (when the `com.apple.quarantine` xattr is present) else - * bad-signature; anything else → unknown. + * port-conflict; startup deadline → launch-timeout; explicit darwin + * signature/developer-verification evidence → quarantined or bad-signature + * only when the quarantine probe is conclusive; anything else → unknown. */ export function classifyOpenCodeLaunchFailure( error: unknown, @@ -536,16 +535,9 @@ export function classifyOpenCodeLaunchFailure( if (lower.includes("timeout waiting for server to start")) { return { kind: "launch-timeout" }; } - if ( - process.platform === "darwin" - && ( - code === "EPERM" - || lower.includes("operation not permitted") - || lower.includes("killed") - || lower.includes("code signature") - || lower.includes("developer cannot be verified") - ) - ) { + const hasExplicitSignatureEvidence = lower.includes("code signature") + || lower.includes("developer cannot be verified"); + if (process.platform === "darwin" && hasExplicitSignatureEvidence) { const quarantine = probeOpenCodeBinaryQuarantine(binaryPath); if (quarantine === "quarantined") { return { @@ -554,7 +546,9 @@ export function classifyOpenCodeLaunchFailure( fixCommand: `xattr -d com.apple.quarantine "${binaryPath}"`, }; } - return { kind: "bad-signature", binaryPath }; + if (quarantine === "clean") { + return { kind: "bad-signature", binaryPath }; + } } return { kind: "unknown", message: message || "OpenCode server failed to launch." }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 36303462e..fb88eacdf 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -949,6 +949,9 @@ declare global { providerId: string; key: string; }) => Promise<{ ok: boolean; error?: string }>; + clearOpencodeProviderKey: (args: { + providerId: string; + }) => Promise<{ ok: boolean; error?: string }>; refreshModelsDev: () => Promise<{ lastFetchedAt: number | null }>; onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => () => void; cursorCloudListRepositories: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index df8232102..4e47521df 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1609,6 +1609,9 @@ const remoteLaneProxyEventCallbacks = new Set< const remoteLaneOAuthEventCallbacks = new Set< (payload: OAuthRedirectEvent) => void >(); +const remoteOpenCodeOAuthStatusCallbacks = new Set< + (payload: OpenCodeOAuthStatusEvent) => void +>(); const remoteLaneDiagnosticsEventCallbacks = new Set< (payload: RuntimeDiagnosticsEvent) => void >(); @@ -1725,6 +1728,11 @@ const subscribeLocalFeedbackEvents = IPC.feedbackOnUpdate, "feedback event", ); +const subscribeLocalOpenCodeOAuthStatusEvents = + createLocalIpcEventSubscription( + IPC.aiOpencodeOAuthStatus, + "OpenCode OAuth status", + ); let remoteRuntimeEventTimer: ReturnType | null = null; let remoteRuntimeEventInFlight = false; @@ -1791,6 +1799,7 @@ function hasRemoteRuntimeEventSubscribers(): boolean { remoteLanePortEventCallbacks.size > 0 || remoteLaneProxyEventCallbacks.size > 0 || remoteLaneOAuthEventCallbacks.size > 0 || + remoteOpenCodeOAuthStatusCallbacks.size > 0 || remoteLaneDiagnosticsEventCallbacks.size > 0 || remotePtyDataEventCallbacks.size > 0 || remotePtyExitEventCallbacks.size > 0 || @@ -2103,6 +2112,19 @@ ipcRenderer.on(IPC.runtimeEvent, (_event, payload: unknown) => { function dispatchRemoteRuntimeEventPayload( payload: Record, ): void { + if (payload.kind === "opencodeOAuthStatus" && isRecord(payload.event)) { + const event = payload.event; + if (typeof event.providerId === "string" && typeof event.state === "string") { + for (const cb of [...remoteOpenCodeOAuthStatusCallbacks]) { + try { + cb(event as unknown as OpenCodeOAuthStatusEvent); + } catch (error) { + console.error("preload remote OpenCode OAuth status listener failed", error); + } + } + } + } + if (payload.type === "sync-status" && isRecord(payload.snapshot)) { for (const cb of [...remoteSyncStatusEventCallbacks]) { try { @@ -2651,6 +2673,16 @@ function subscribeRemoteLaneOAuthEvents( }; } +function subscribeRemoteOpenCodeOAuthStatusEvents( + cb: (payload: OpenCodeOAuthStatusEvent) => void, +): () => void { + remoteOpenCodeOAuthStatusCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteOpenCodeOAuthStatusCallbacks.delete(cb); + }; +} + function subscribeRemoteLaneDiagnosticsEvents( cb: (payload: RuntimeDiagnosticsEvent) => void, ): () => void { @@ -3913,6 +3945,16 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.aiSetOpencodeProviderKey, args), ), ), + clearOpencodeProviderKey: async (args: { + providerId: string; + }): Promise<{ ok: boolean; error?: string }> => + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr("ai", "clearOpencodeProviderKey", { args }, () => + ipcRenderer.invoke(IPC.aiClearOpencodeProviderKey, args), + ), + ), refreshModelsDev: async (): Promise<{ lastFetchedAt: number | null }> => clearAround( () => aiStatusCache.clear(), @@ -3922,13 +3964,11 @@ contextBridge.exposeInMainWorld("ade", { ), ), onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: OpenCodeOAuthStatusEvent, - ) => cb(payload); - ipcRenderer.on(IPC.aiOpencodeOAuthStatus, listener); + const removeLocal = subscribeLocalOpenCodeOAuthStatusEvents(cb); + const removeRemote = subscribeRemoteOpenCodeOAuthStatusEvents(cb); return () => { - ipcRenderer.removeListener(IPC.aiOpencodeOAuthStatus, listener); + removeRemote(); + removeLocal(); }; }, cursorCloudListRepositories: async (): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 37c0c27c5..98f8fad7d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3821,6 +3821,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { opencodeOAuthStart: resolvedArg({ url: "", method: "auto", instructions: "" } as any), opencodeOAuthCancel: resolvedArg(undefined), setOpencodeProviderKey: resolvedArg({ ok: false, error: "browser" } as any), + clearOpencodeProviderKey: resolvedArg({ ok: false, error: "browser" } as any), refreshModelsDev: resolved({ lastFetchedAt: null }), onOpencodeOAuthStatus: () => () => {}, }, diff --git a/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx index 01036a2d9..c454a7c76 100644 --- a/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx +++ b/apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx @@ -7,6 +7,7 @@ import type { OpenCodeOAuthStartResult, OpenCodeOAuthStatusEvent, } from "../../../shared/types/config"; +import { openUrlInAdeBrowser } from "../../lib/openExternal"; import { ProviderLogo } from "../shared/ProviderLogos"; import { COLORS, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; @@ -39,6 +40,14 @@ function promptVisible( : current !== prompt.when.value; } +function buildPromptDefaults(prompts: OpenCodeProviderAuthPrompt[]): Record { + const defaults: Record = {}; + for (const prompt of prompts) { + defaults[prompt.key] = prompt.type === "select" ? prompt.options?.[0]?.value ?? "" : ""; + } + return defaults; +} + type Phase = "form" | "starting" | "waiting" | "error"; export function OAuthConnectModal({ @@ -63,14 +72,7 @@ export function OAuthConnectModal({ const method = methods[methodIndex]; const prompts = method?.prompts ?? []; - const [inputs, setInputs] = useState>(() => { - const seed: Record = {}; - for (const prompt of prompts) { - if (prompt.type === "select") seed[prompt.key] = prompt.options?.[0]?.value ?? ""; - else seed[prompt.key] = ""; - } - return seed; - }); + const [inputs, setInputs] = useState>(() => buildPromptDefaults(prompts)); const [phase, setPhase] = useState("form"); const [startResult, setStartResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -84,9 +86,12 @@ export function OAuthConnectModal({ // in-modal close paths cancel explicitly, but a parent unmount (nav away, // settings close) would otherwise leave the poller running to its timeout. const flowActiveRef = useRef(false); + const startPendingRef = useRef(false); + const cancelRequestedRef = useRef(false); useEffect( () => () => { - if (flowActiveRef.current) { + cancelRequestedRef.current = true; + if (startPendingRef.current || flowActiveRef.current) { void window.ade.ai.opencodeOAuthCancel({ providerId }).catch(() => undefined); } }, @@ -129,6 +134,8 @@ export function OAuthConnectModal({ }, []); const startFlow = async () => { + cancelRequestedRef.current = false; + startPendingRef.current = true; setPhase("starting"); setErrorMessage(null); try { @@ -141,16 +148,25 @@ export function OAuthConnectModal({ methodIndex, inputs: Object.keys(filteredInputs).length ? filteredInputs : undefined, }); + startPendingRef.current = false; + if (cancelRequestedRef.current) { + void window.ade.ai.opencodeOAuthCancel({ providerId }).catch(() => undefined); + return; + } + openUrlInAdeBrowser(result.url); setStartResult(result); flowActiveRef.current = true; setPhase("waiting"); } catch (err) { + startPendingRef.current = false; + if (cancelRequestedRef.current) return; setPhase("error"); setErrorMessage(err instanceof Error ? err.message : String(err)); } }; const handleCancel = async () => { + cancelRequestedRef.current = true; flowActiveRef.current = false; try { await window.ade.ai.opencodeOAuthCancel({ providerId }); @@ -271,7 +287,11 @@ export function OAuthConnectModal({ Method