Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ const PROVIDER_MARKS: Record<string, ProviderMark> = {
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 },
};
Expand All @@ -135,6 +137,8 @@ const ROW_MARKS: Record<string, ProviderMark> = {
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!,
Expand Down
13 changes: 13 additions & 0 deletions apps/ade-cli/src/tuiClient/providerMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions apps/ade-cli/src/tuiClient/providerMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const PROVIDER_TOKEN_LABELS: Record<string, string> = {
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",
};
Expand Down
77 changes: 76 additions & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ 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,
clearProviderKey as clearOpenCodeProviderKey,
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,
Expand Down Expand Up @@ -168,7 +178,7 @@ export const ADE_ACTION_CTO_ONLY: Partial<Record<AdeActionDomain, readonly strin
// cancelScheduledCleanup can silently defeat a cleanup policy another
// automation scheduled, so it is operator-only like the webhook lifecycle.
automations: ["setWebhookGatewayPublicUrl", "linearIngressSetup", "linearIngressTeardown", "cancelScheduledCleanup"],
ai: ["updateConfig", "storeApiKey", "deleteApiKey"],
ai: ["updateConfig", "storeApiKey", "deleteApiKey", "opencodeOAuthStart", "opencodeOAuthCancel", "setOpencodeProviderKey", "clearOpencodeProviderKey", "refreshModelsDev"],
budget: ["updateConfig"],
feedback: ["submitPreparedDraft"],
usage: ["forceRefresh", "refreshHistory", "poll", "start", "stop"],
Expand Down Expand Up @@ -551,6 +561,12 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"deleteApiKey",
"listApiKeys",
"updateConfig",
"opencodeAuthMethods",
"opencodeOAuthStart",
"opencodeOAuthCancel",
"setOpencodeProviderKey",
"clearOpencodeProviderKey",
"refreshModelsDev",
"listCursorCloudRepositories",
"listCursorCloudAgents",
"listCursorCloudRuns",
Expand Down Expand Up @@ -2031,12 +2047,71 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService {
};
}

// Bridge OpenCode OAuth status transitions onto each runtime's event buffer so
// remote/web clients (which drain runtimeEvents over the sync/relay channel)
// mirror them, exactly like desktop windows do over IPC. Registered once per
// runtime; the listener is detached when the runtime is disposed.
const oauthStatusBridgedRuntimes = new WeakSet<AdeRuntime>();
function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void {
if (!runtime.eventBuffer || oauthStatusBridgedRuntimes.has(runtime)) return;
oauthStatusBridgedRuntimes.add(runtime);
const unsubscribe = 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.
}
});
const dispose = runtime.dispose;
runtime.dispose = () => {
unsubscribe();
dispose();
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string, string> }) =>
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"),
}),
clearOpencodeProviderKey: (args?: { providerId?: string }) =>
clearOpenCodeProviderKey(buildOpenCodeAuthDeps(), {
providerId: requireNonEmptyString(args?.providerId, "providerId"),
}),
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();
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/services/ai/aiIntegrationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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", () => ({
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -450,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<Record<string, unknown>>) => void) | null = null;
Expand Down
74 changes: 41 additions & 33 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentModelDescriptor, AgentProvider, ExecutorOpts } from "./agentE
import type {
AiApiKeyVerificationResult,
AiClaudeAvailability,
AiCustomProviderConfig,
AiLocalProviderConfigs,
AiProviderConnections,
AiRuntimeConnections,
Expand All @@ -29,7 +30,6 @@ import {
LOCAL_PROVIDER_LABELS,
replaceDynamicOpenCodeModelDescriptors,
resolveModelAlias,
enrichModelRegistry,
resolveProviderGroupForModel,
type LocalProviderFamily,
} from "../../../shared/modelRegistry";
Expand All @@ -43,6 +43,7 @@ import {
} from "./authDetector";
import {
clearOpenCodeInventoryCache,
loadPersistedOpenCodeInventory,
peekOpenCodeInventoryCache,
probeOpenCodeProviderInventory,
} from "../opencode/openCodeInventory";
Expand All @@ -52,8 +53,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 {
Expand Down Expand Up @@ -143,6 +146,14 @@ 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;
/** 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;
Expand Down Expand Up @@ -894,32 +905,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<string, { input: number; output: number }> = {};
const enrichments = new Map<string, { contextWindow?: number; maxOutputTokens?: number }>();

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) });
});

Expand Down Expand Up @@ -1799,30 +1790,43 @@ export function createAiIntegrationService(args: {
modelIds: [] as string[],
catalogModelIds: [] as string[],
providers: [] as NonNullable<AiIntegrationStatus["opencodeProviders"]>,
stale: false,
};
}
if (options?.refreshOpenCodeInventory === true) {
return await probeOpenCodeProviderInventory({
const probed = await probeOpenCodeProviderInventory({
projectRoot,
projectConfig: effectiveConfig,
logger,
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({
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<AiIntegrationStatus["opencodeProviders"]>,
providers: loadPersistedOpenCodeInventory(projectRoot),
stale: true,
};
});

Expand Down Expand Up @@ -1855,6 +1859,10 @@ export function createAiIntegrationService(args: {
opencodeBinarySource,
opencodeInventoryError: opencodeInventory.error,
opencodeProviders: opencodeInventory.providers,
opencodeProvidersStale: opencodeInventory.stale,
modelsDevLastFetchedAt: getModelsDevLastFetchedAt(),
customProviders: effectiveConfig?.ai?.customProviders,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use scoped custom-provider lists for saves

When .ade/local.json contains ai.customProviders, this effective value is the local list because config merging gives local custom providers replacement precedence. The Settings save path uses this status field as the full-list write source for ai.updateConfig, which writes shared config, so adding a provider can disappear on the next refresh while also copying local-only provider definitions into .ade/config.json. Please expose the shared list/scope separately or save back to local when local owns this field.

Useful? React with 👍 / 👎.

customModelSlugs: effectiveConfig?.ai?.customModelSlugs,
apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()),
};
if (requestGeneration === providerReadinessCacheGeneration) {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/services/ai/aiSettingsStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export function getUnavailableAiStatus(): AiSettingsStatus {
opencodeBinarySource: "missing",
opencodeInventoryError: null,
opencodeProviders: [],
opencodeProvidersStale: false,
modelsDevLastFetchedAt: null,
};
}

Expand Down Expand Up @@ -136,6 +138,10 @@ export async function buildAiSettingsStatus(
opencodeBinarySource: status.opencodeBinarySource,
opencodeInventoryError: status.opencodeInventoryError,
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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/ai/apiKeyStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ENV_KEY_PROVIDERS: Record<string, string> = {
together: "TOGETHER_API_KEY",
openrouter: "OPENROUTER_API_KEY",
cursor: "CURSOR_API_KEY",
moonshotai: "MOONSHOT_API_KEY",
};

const MACOS_SECURITY_BIN = "/usr/bin/security";
Expand Down
Loading