Merge commit 'b12ff26f36daaf31e807f4920c61c2a7b3727a64'

This commit is contained in:
gsxdsm
2026-05-20 22:34:32 -07:00
15 changed files with 374 additions and 35 deletions

View File

@@ -112,6 +112,27 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("tavily");
});
it("always includes opencode-go when registry has no opencode models", () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).toContain("opencode-go");
});
it("filters opencode-go from API key providers when OAuth provider id collides", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [{ id: "opencode-go", name: "Opencode Go OAuth" }]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).not.toContain("opencode-go");
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");

View File

@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
@@ -242,6 +242,52 @@ describe("startup-model-sync", () => {
);
});
it("returns refresh result for opencode-go happy path", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
expect(result).toEqual({ registeredCount: 1 });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
}));
});
it("returns no-models reason when cli output has no models", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\n"));
proc.emit("exit", 0);
});
return proc;
});
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider: vi.fn() }, log: vi.fn() });
expect(result).toEqual({ registeredCount: 0, reason: "no-models-from-cli" });
});
it("returns cli-failed reason when spawn errors", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => proc.emit("error", new Error("spawn opencode ENOENT")));
return proc;
});
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider: vi.fn() }, log: vi.fn() });
expect(result.registeredCount).toBe(0);
expect(result.reason).toBe("cli-failed");
expect(result.error).toContain("ENOENT");
});
it("logs failures and continues", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();

View File

@@ -69,7 +69,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -712,6 +712,19 @@ export async function runDaemon(opts: DaemonOptions = {}) {
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -75,7 +75,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
@@ -1626,6 +1626,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
@@ -1930,6 +1943,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -46,6 +46,7 @@ const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];

View File

@@ -69,7 +69,7 @@ import {
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -819,6 +819,19 @@ export async function runServe(
// is configured. The runner logs its own outcome and swallows errors.
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -65,6 +65,12 @@ interface StartupSyncOptions {
log: (scope: string, message: string) => void;
}
export type OpencodeGoRefreshResult = {
registeredCount: number;
reason?: "no-models-from-cli" | "cli-failed" | "disabled-by-settings";
error?: string;
};
function parseCost(value?: string): number {
const n = parseFloat(value || "0");
return Number.isNaN(n) ? 0 : n * 1_000_000;
@@ -197,7 +203,7 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
log("openrouter", `Synced ${models.length} models from OpenRouter API`);
}
function normalizeOpencodeGoModel(modelId: string): ModelConfig {
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
@@ -227,7 +233,7 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
return [...ids];
}
async function discoverOpencodeGoModels(): Promise<string[]> {
export async function discoverOpencodeGoModels(): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
@@ -263,22 +269,32 @@ async function discoverOpencodeGoModels(): Promise<string[]> {
});
}
async function syncOpencodeGoModels(options: StartupSyncOptions): Promise<void> {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return;
}
export async function refreshOpencodeGoModels(options: {
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
}): Promise<OpencodeGoRefreshResult> {
try {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return { registeredCount: 0, reason: "no-models-from-cli" };
}
const models = modelIds.map(normalizeOpencodeGoModel);
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
api: "openai-completions",
models,
});
log("opencode-go", `Synced ${models.length} models from opencode CLI`);
const models = modelIds.map(normalizeOpencodeGoModel);
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
api: "openai-completions",
models,
});
log("opencode-go", `Synced ${models.length} models from opencode CLI`);
return { registeredCount: models.length };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("opencode-go", `Failed to sync models: ${message}`);
return { registeredCount: 0, reason: "cli-failed", error: message };
}
}
export async function syncStartupModels(options: StartupSyncOptions): Promise<void> {
@@ -294,11 +310,6 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
}
if (settings.opencodeGoModelSync !== false) {
try {
await syncOpencodeGoModels(options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("opencode-go", `Failed to sync models: ${message}`);
}
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
}
}

View File

@@ -2054,8 +2054,18 @@ export function cancelProviderLogin(provider: string): Promise<{ success: boolea
}
/** Save an API key for an API-key-backed provider. */
export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/api-key", {
export function saveApiKey(provider: string, apiKey: string): Promise<{
success: boolean;
modelsRefreshed?: number;
refreshReason?: string;
refreshError?: string;
}> {
return api<{
success: boolean;
modelsRefreshed?: number;
refreshReason?: string;
refreshError?: string;
}>("/auth/api-key", {
method: "POST",
body: JSON.stringify({ provider, apiKey }),
});

View File

@@ -577,6 +577,10 @@ export function SettingsModal({
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const [opencodeApiKeyRefreshStatus, setOpencodeApiKeyRefreshStatus] = useState<Record<string, {
tone: "success" | "error";
message: string;
}>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastAutoCopiedDeviceCodesRef = useRef<Record<string, string>>({});
@@ -1358,17 +1362,60 @@ export function SettingsModal({
return next;
});
try {
await saveApiKey(providerId, key);
const saveResult = await saveApiKey(providerId, key);
setApiKeyInputs((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
await loadAuthStatus();
if (providerId === "opencode" || providerId === "opencode-go") {
const modelsRefreshed = saveResult.modelsRefreshed;
const refreshReason = saveResult.refreshReason;
const refreshError = saveResult.refreshError;
if (refreshError) {
setOpencodeApiKeyRefreshStatus((prev) => ({
...prev,
[providerId]: {
tone: "error",
message: `Saved, but model refresh failed: ${refreshError}. Make sure the \`opencode\` CLI is installed on PATH.`,
},
}));
} else if (refreshReason === "no-models-from-cli") {
setOpencodeApiKeyRefreshStatus((prev) => ({
...prev,
[providerId]: {
tone: "error",
message: "Saved. The local `opencode` CLI returned no models — run `opencode auth login` and `opencode models opencode --refresh`, then click Save again.",
},
}));
} else if (typeof modelsRefreshed === "number" && modelsRefreshed > 0) {
setOpencodeApiKeyRefreshStatus((prev) => ({
...prev,
[providerId]: {
tone: "success",
message: `Refreshed ${modelsRefreshed} opencode-go models.`,
},
}));
} else {
setOpencodeApiKeyRefreshStatus((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
}
}
addToast("API key saved", "success");
scrollSettingsToTop();
} catch (err) {
setApiKeyErrors((prev) => ({ ...prev, [providerId]: getErrorMessage(err) || "Failed to save API key" }));
if (providerId === "opencode" || providerId === "opencode-go") {
setOpencodeApiKeyRefreshStatus((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
}
} finally {
setAuthActionInProgress(null);
}
@@ -6907,6 +6954,11 @@ export function SettingsModal({
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
@@ -6989,6 +7041,11 @@ export function SettingsModal({
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>

View File

@@ -2098,6 +2098,60 @@ describe("SettingsModal", () => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
});
});
it("shows opencode-go refresh success message after API key save", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "opencode-go", name: "Opencode (Go)", authenticated: false, type: "api_key" }],
});
mockSaveApiKey.mockResolvedValueOnce({ success: true, modelsRefreshed: 4 });
const { container } = renderModal();
await waitForSettingsModalReady();
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
Object.defineProperty(settingsContent, "scrollTo", { value: vi.fn(), writable: true });
const card = screen.getByTestId("auth-provider-icon-opencode-go").closest(".auth-provider-card") as HTMLElement;
await userEvent.type(within(card).getByPlaceholderText("Enter API key"), "opencode-key");
await userEvent.click(within(card).getByRole("button", { name: "Save" }));
expect(await within(card).findByText("Refreshed 4 opencode-go models.")).toBeInTheDocument();
});
it("shows opencode-go no-models guidance message", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "opencode-go", name: "Opencode (Go)", authenticated: false, type: "api_key" }],
});
mockSaveApiKey.mockResolvedValueOnce({ success: true, modelsRefreshed: 0, refreshReason: "no-models-from-cli" });
const { container } = renderModal();
await waitForSettingsModalReady();
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
Object.defineProperty(settingsContent, "scrollTo", { value: vi.fn(), writable: true });
const card = screen.getByTestId("auth-provider-icon-opencode-go").closest(".auth-provider-card") as HTMLElement;
await userEvent.type(within(card).getByPlaceholderText("Enter API key"), "opencode-key");
await userEvent.click(within(card).getByRole("button", { name: "Save" }));
expect(await within(card).findByText(/returned no models/i)).toBeInTheDocument();
});
it("shows opencode-go refresh error message", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "opencode-go", name: "Opencode (Go)", authenticated: false, type: "api_key" }],
});
mockSaveApiKey.mockResolvedValueOnce({ success: true, refreshError: "spawn opencode ENOENT" });
const { container } = renderModal();
await waitForSettingsModalReady();
const settingsContent = container.querySelector(".settings-content") as HTMLDivElement;
Object.defineProperty(settingsContent, "scrollTo", { value: vi.fn(), writable: true });
const card = screen.getByTestId("auth-provider-icon-opencode-go").closest(".auth-provider-card") as HTMLElement;
await userEvent.type(within(card).getByPlaceholderText("Enter API key"), "opencode-key");
await userEvent.click(within(card).getByRole("button", { name: "Save" }));
expect(await within(card).findByText(/model refresh failed: spawn opencode ENOENT/i)).toBeInTheDocument();
});
});
describe("Droid plugin Settings integration", () => {

View File

@@ -1853,10 +1853,13 @@ describe("POST /auth/api-key", () => {
authStorage = createMockAuthStorage();
});
function buildApp() {
function buildApp(options?: {
onApiKeySaved?: (providerId: string) => Promise<{ registeredCount: number; reason?: string; error?: string } | void>;
modelRegistry?: ModelRegistryLike;
}) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
app.use("/api", createApiRoutes(store, { authStorage, ...(options ?? {}) }));
return app;
}
@@ -1967,6 +1970,57 @@ describe("POST /auth/api-key", () => {
expect(res.body.error).toContain("not supported");
});
it("runs post-save refresh hook and model registry refresh for opencode-go", async () => {
const onApiKeySaved = vi.fn().mockResolvedValue({ registeredCount: 3, reason: "no-models-from-cli" });
const modelRegistry = { refresh: vi.fn(), getAvailable: vi.fn().mockReturnValue([]) } as unknown as ModelRegistryLike;
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
]);
const res = await REQUEST(buildApp({ onApiKeySaved, modelRegistry }), "POST", "/api/auth/api-key", JSON.stringify({
provider: "opencode-go",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(onApiKeySaved).toHaveBeenCalledWith("opencode-go");
expect(modelRegistry.refresh).toHaveBeenCalled();
expect(res.body.modelsRefreshed).toBe(3);
expect(res.body.refreshReason).toBe("no-models-from-cli");
});
it("returns success when post-save refresh hook throws", async () => {
const onApiKeySaved = vi.fn().mockRejectedValue(new Error("opencode missing"));
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
]);
const res = await REQUEST(buildApp({ onApiKeySaved }), "POST", "/api/auth/api-key", JSON.stringify({
provider: "opencode-go",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.refreshError).toContain("opencode missing");
});
it("does not include refresh metadata when callback returns undefined", async () => {
const onApiKeySaved = vi.fn().mockResolvedValue(undefined);
const res = await REQUEST(buildApp({ onApiKeySaved }), "POST", "/api/auth/api-key", JSON.stringify({
provider: "openrouter",
apiKey: "sk-test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(onApiKeySaved).toHaveBeenCalledWith("openrouter");
expect(res.body.modelsRefreshed).toBeUndefined();
expect(res.body.refreshReason).toBeUndefined();
});
it("returns 500 on storage error", async () => {
(authStorage.setApiKey as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("disk full");

View File

@@ -1027,7 +1027,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
* Validates the provider exists, is API-key-backed, and the key is non-empty.
* Never returns the key in any response.
*/
router.post("/auth/api-key", (req, res) => {
router.post("/auth/api-key", async (req, res) => {
try {
const { provider, apiKey } = req.body;
if (!provider || typeof provider !== "string") {
@@ -1052,8 +1052,29 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
storage.setApiKey(provider, apiKey.trim());
let modelsRefreshed: number | undefined;
let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | undefined;
let refreshError: string | undefined;
try {
const refreshResult = await options?.onApiKeySaved?.(provider);
if (refreshResult) {
modelsRefreshed = refreshResult.registeredCount;
refreshReason = refreshResult.reason;
refreshError = refreshResult.error;
}
} catch (error) {
refreshError = error instanceof Error ? error.message : String(error);
}
options?.modelRegistry?.refresh?.();
clearUsageCache();
res.json({ success: true });
res.json({
success: true,
...(modelsRefreshed !== undefined ? { modelsRefreshed } : {}),
...(refreshReason ? { refreshReason } : {}),
...(refreshError ? { refreshError } : {}),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -1081,6 +1102,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
storage.clearApiKey(provider);
// No model refresh needed on delete: removing the key leaves nothing to sync.
clearUsageCache();
res.json({ success: true });
} catch (err: unknown) {

View File

@@ -360,6 +360,12 @@ export interface ServerOptions {
onUseDroidCliToggled?: (prev: boolean, next: boolean) => void;
/** Called when the user toggles the `useLlamaCpp` global setting. */
onUseLlamaCppToggled?: (prev: boolean, next: boolean) => void;
/** Optional hook fired after a successful API-key save. */
onApiKeySaved?: (providerId: string) => Promise<{
registeredCount: number;
reason?: "no-models-from-cli" | "cli-failed" | "disabled-by-settings";
error?: string;
} | void>;
/**
* Returns the host's last-observed resolution of the bundled `droid-cli`
* extension wiring. Populated by serve/daemon/dashboard startup checks.