Merge commit 'b12ff26f36daaf31e807f4920c61c2a7b3727a64'
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user