refactor: update goose2 credential management behind provider-scoped ACP/core API (#8887)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -7,12 +7,6 @@ import { setNotificationHandler, getClient } from "@/shared/api/acpConnection";
|
||||
import notificationHandler from "@/shared/api/acpNotificationHandler";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
|
||||
const INVENTORY_POLL_DELAYS_MS = [250, 500, 750, 1000, 1500, 2000];
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useAppStartup() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -101,27 +95,12 @@ export function useAppStartup() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getProviderInventory, refreshProviderInventory } =
|
||||
await import("@/features/providers/api/inventory");
|
||||
const refresh = await refreshProviderInventory(configuredProviderIds);
|
||||
if (refresh.started.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
inventoryStore.mergeEntries(
|
||||
await getProviderInventory(refresh.started),
|
||||
const { syncProviderInventory } = await import(
|
||||
"@/features/providers/api/inventorySync"
|
||||
);
|
||||
|
||||
for (const delayMs of INVENTORY_POLL_DELAYS_MS) {
|
||||
await sleep(delayMs);
|
||||
const refreshedEntries = await getProviderInventory(
|
||||
refresh.started,
|
||||
);
|
||||
inventoryStore.mergeEntries(refreshedEntries);
|
||||
if (refreshedEntries.every((entry) => !entry.refreshing)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await syncProviderInventory(configuredProviderIds, {
|
||||
onEntries: (entries) => inventoryStore.mergeEntries(entries),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to refresh provider inventory on startup:",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkAllProviderStatus,
|
||||
deleteProviderConfig,
|
||||
getProviderConfig,
|
||||
saveProviderConfig,
|
||||
} from "./credentials";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
configRead: vi.fn(),
|
||||
configSave: vi.fn(),
|
||||
configDelete: vi.fn(),
|
||||
configStatus: vi.fn(),
|
||||
getClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: () => mocks.getClient(),
|
||||
}));
|
||||
|
||||
describe("provider credential API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getClient.mockResolvedValue({
|
||||
goose: {
|
||||
GooseProvidersConfigRead: mocks.configRead,
|
||||
GooseProvidersConfigSave: mocks.configSave,
|
||||
GooseProvidersConfigDelete: mocks.configDelete,
|
||||
GooseProvidersConfigStatus: mocks.configStatus,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads provider config fields through the ACP provider config endpoint", async () => {
|
||||
const fields = [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-********",
|
||||
isSet: true,
|
||||
isSecret: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
mocks.configRead.mockResolvedValue({ fields });
|
||||
|
||||
await expect(getProviderConfig("anthropic")).resolves.toEqual(fields);
|
||||
|
||||
expect(mocks.configRead).toHaveBeenCalledWith({
|
||||
providerId: "anthropic",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves provider config fields as one batch through ACP", async () => {
|
||||
const fields = [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-test",
|
||||
},
|
||||
{
|
||||
key: "ANTHROPIC_HOST",
|
||||
value: "https://api.anthropic.com",
|
||||
},
|
||||
];
|
||||
const response = {
|
||||
status: {
|
||||
providerId: "anthropic",
|
||||
isConfigured: true,
|
||||
},
|
||||
refresh: {
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
},
|
||||
};
|
||||
mocks.configSave.mockResolvedValue(response);
|
||||
|
||||
await expect(saveProviderConfig("anthropic", fields)).resolves.toEqual(
|
||||
response,
|
||||
);
|
||||
|
||||
expect(mocks.configSave).toHaveBeenCalledWith({
|
||||
providerId: "anthropic",
|
||||
fields,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes provider config through ACP", async () => {
|
||||
const response = {
|
||||
status: {
|
||||
providerId: "anthropic",
|
||||
isConfigured: false,
|
||||
},
|
||||
refresh: {
|
||||
started: [],
|
||||
skipped: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
reason: "not_configured",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mocks.configDelete.mockResolvedValue(response);
|
||||
|
||||
await expect(deleteProviderConfig("anthropic")).resolves.toEqual(response);
|
||||
|
||||
expect(mocks.configDelete).toHaveBeenCalledWith({
|
||||
providerId: "anthropic",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks provider status through ACP", async () => {
|
||||
const statuses = [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
isConfigured: true,
|
||||
},
|
||||
];
|
||||
mocks.configStatus.mockResolvedValue({ statuses });
|
||||
|
||||
await expect(checkAllProviderStatus()).resolves.toEqual(statuses);
|
||||
|
||||
expect(mocks.configStatus).toHaveBeenCalledWith({
|
||||
providerIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,44 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ProviderConfigChangeResponse,
|
||||
ProviderConfigFieldUpdate,
|
||||
ProviderConfigStatusDto,
|
||||
} from "@aaif/goose-sdk";
|
||||
import type { ProviderFieldValue } from "@/shared/types/providers";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
|
||||
export interface ProviderStatus {
|
||||
providerId: string;
|
||||
isConfigured: boolean;
|
||||
}
|
||||
export type ProviderStatus = ProviderConfigStatusDto;
|
||||
export type ProviderFieldSaveInput = ProviderConfigFieldUpdate;
|
||||
|
||||
export async function getProviderConfig(
|
||||
providerId: string,
|
||||
): Promise<ProviderFieldValue[]> {
|
||||
return invoke("get_provider_config", { providerId });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseProvidersConfigRead({ providerId });
|
||||
return response.fields.map((field) => ({
|
||||
...field,
|
||||
value: field.value ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function saveProviderField(
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
return invoke("save_provider_field", { key, value });
|
||||
export async function saveProviderConfig(
|
||||
providerId: string,
|
||||
fields: ProviderFieldSaveInput[],
|
||||
): Promise<ProviderConfigChangeResponse> {
|
||||
const client = await getClient();
|
||||
return client.goose.GooseProvidersConfigSave({ providerId, fields });
|
||||
}
|
||||
|
||||
export async function deleteProviderConfig(providerId: string): Promise<void> {
|
||||
return invoke("delete_provider_config", { providerId });
|
||||
export async function deleteProviderConfig(
|
||||
providerId: string,
|
||||
): Promise<ProviderConfigChangeResponse> {
|
||||
const client = await getClient();
|
||||
return client.goose.GooseProvidersConfigDelete({ providerId });
|
||||
}
|
||||
|
||||
export async function checkAllProviderStatus(): Promise<ProviderStatus[]> {
|
||||
return invoke("check_all_provider_status");
|
||||
}
|
||||
|
||||
export async function restartApp(): Promise<void> {
|
||||
return invoke("restart_app");
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseProvidersConfigStatus({
|
||||
providerIds: [],
|
||||
});
|
||||
return response.statuses;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
INVENTORY_POLL_DELAYS_MS,
|
||||
syncProviderInventory,
|
||||
} from "./inventorySync";
|
||||
|
||||
function inventoryEntry(
|
||||
providerId: string,
|
||||
refreshing: boolean,
|
||||
lastRefreshError: string | null = null,
|
||||
): ProviderInventoryEntryDto {
|
||||
return {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
description: "",
|
||||
defaultModel: "default-model",
|
||||
configured: true,
|
||||
providerType: "remote",
|
||||
configKeys: [],
|
||||
setupSteps: [],
|
||||
supportsRefresh: true,
|
||||
refreshing,
|
||||
models: [],
|
||||
lastUpdatedAt: null,
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshError,
|
||||
stale: false,
|
||||
modelSelectionHint: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("syncProviderInventory", () => {
|
||||
const getInventory = vi.fn();
|
||||
const refreshInventory = vi.fn();
|
||||
const onEntries = vi.fn();
|
||||
const sleep = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sleep.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("refreshes and polls with the startup delay schedule until entries settle", async () => {
|
||||
refreshInventory.mockResolvedValue({
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
});
|
||||
getInventory
|
||||
.mockResolvedValueOnce([inventoryEntry("anthropic", true)])
|
||||
.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
|
||||
|
||||
const result = await syncProviderInventory(["anthropic"], {
|
||||
getInventory,
|
||||
refreshInventory,
|
||||
onEntries,
|
||||
sleep,
|
||||
});
|
||||
|
||||
expect(refreshInventory).toHaveBeenCalledWith(["anthropic"]);
|
||||
expect(getInventory).toHaveBeenNthCalledWith(1, ["anthropic"]);
|
||||
expect(getInventory).toHaveBeenNthCalledWith(2, ["anthropic"]);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).toHaveBeenCalledWith(INVENTORY_POLL_DELAYS_MS[0]);
|
||||
expect(onEntries).toHaveBeenCalledTimes(2);
|
||||
expect(result.settled).toBe(true);
|
||||
expect(result.entries).toEqual([inventoryEntry("anthropic", false)]);
|
||||
});
|
||||
|
||||
it("uses an initial refresh acknowledgement without starting another refresh", async () => {
|
||||
const initialRefresh = {
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
};
|
||||
getInventory.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
|
||||
|
||||
const result = await syncProviderInventory(["anthropic"], {
|
||||
getInventory,
|
||||
refreshInventory,
|
||||
initialRefresh,
|
||||
sleep,
|
||||
});
|
||||
|
||||
expect(refreshInventory).not.toHaveBeenCalled();
|
||||
expect(getInventory).toHaveBeenCalledWith(["anthropic"]);
|
||||
expect(result.refresh).toBe(initialRefresh);
|
||||
expect(result.settled).toBe(true);
|
||||
});
|
||||
|
||||
it("polls providers skipped because they were already refreshing", async () => {
|
||||
refreshInventory.mockResolvedValue({
|
||||
started: [],
|
||||
skipped: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
reason: "already_refreshing",
|
||||
},
|
||||
],
|
||||
});
|
||||
getInventory
|
||||
.mockResolvedValueOnce([inventoryEntry("anthropic", true)])
|
||||
.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
|
||||
|
||||
const result = await syncProviderInventory(["anthropic"], {
|
||||
getInventory,
|
||||
refreshInventory,
|
||||
sleep,
|
||||
});
|
||||
|
||||
expect(result.polledProviderIds).toEqual(["anthropic"]);
|
||||
expect(getInventory).toHaveBeenNthCalledWith(2, ["anthropic"]);
|
||||
expect(result.settled).toBe(true);
|
||||
});
|
||||
|
||||
it("merges skipped not-configured entries without forcing a warning state", async () => {
|
||||
refreshInventory.mockResolvedValue({
|
||||
started: [],
|
||||
skipped: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
reason: "not_configured",
|
||||
},
|
||||
],
|
||||
});
|
||||
getInventory.mockResolvedValueOnce([
|
||||
{
|
||||
...inventoryEntry("anthropic", false),
|
||||
configured: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await syncProviderInventory(["anthropic"], {
|
||||
getInventory,
|
||||
refreshInventory,
|
||||
sleep,
|
||||
});
|
||||
|
||||
expect(result.settled).toBe(true);
|
||||
expect(result.entries[0]?.configured).toBe(false);
|
||||
expect(result.entries[0]?.lastRefreshError).toBeNull();
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ProviderInventoryEntryDto,
|
||||
RefreshProviderInventoryResponse,
|
||||
} from "@aaif/goose-sdk";
|
||||
import { getProviderInventory, refreshProviderInventory } from "./inventory";
|
||||
|
||||
export const INVENTORY_POLL_DELAYS_MS = [250, 500, 750, 1000, 1500, 2000];
|
||||
|
||||
type GetProviderInventory = typeof getProviderInventory;
|
||||
type RefreshProviderInventory = typeof refreshProviderInventory;
|
||||
|
||||
interface SyncProviderInventoryOptions {
|
||||
getInventory?: GetProviderInventory;
|
||||
refreshInventory?: RefreshProviderInventory;
|
||||
initialRefresh?: RefreshProviderInventoryResponse;
|
||||
onEntries?: (entries: ProviderInventoryEntryDto[]) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SyncProviderInventoryResult {
|
||||
entries: ProviderInventoryEntryDto[];
|
||||
refresh: RefreshProviderInventoryResponse;
|
||||
settled: boolean;
|
||||
polledProviderIds: string[];
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function mergeEntries(
|
||||
current: Map<string, ProviderInventoryEntryDto>,
|
||||
entries: ProviderInventoryEntryDto[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
current.set(entry.providerId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
function skippedProviderIds(refresh: RefreshProviderInventoryResponse) {
|
||||
return (refresh.skipped ?? []).map((skip) => skip.providerId);
|
||||
}
|
||||
|
||||
function alreadyRefreshingProviderIds(
|
||||
refresh: RefreshProviderInventoryResponse,
|
||||
) {
|
||||
return (refresh.skipped ?? [])
|
||||
.filter((skip) => skip.reason === "already_refreshing")
|
||||
.map((skip) => skip.providerId);
|
||||
}
|
||||
|
||||
export async function syncProviderInventory(
|
||||
providerIds: string[],
|
||||
{
|
||||
getInventory = getProviderInventory,
|
||||
refreshInventory = refreshProviderInventory,
|
||||
initialRefresh,
|
||||
onEntries,
|
||||
sleep = defaultSleep,
|
||||
}: SyncProviderInventoryOptions = {},
|
||||
): Promise<SyncProviderInventoryResult> {
|
||||
const refresh = initialRefresh ?? (await refreshInventory(providerIds));
|
||||
const entriesByProviderId = new Map<string, ProviderInventoryEntryDto>();
|
||||
const immediateProviderIds = unique([
|
||||
...providerIds,
|
||||
...refresh.started,
|
||||
...skippedProviderIds(refresh),
|
||||
]);
|
||||
const immediateEntries = await getInventory(immediateProviderIds);
|
||||
mergeEntries(entriesByProviderId, immediateEntries);
|
||||
onEntries?.(immediateEntries);
|
||||
|
||||
const polledProviderIds = unique([
|
||||
...providerIds,
|
||||
...refresh.started,
|
||||
...alreadyRefreshingProviderIds(refresh),
|
||||
]);
|
||||
if (
|
||||
polledProviderIds.length === 0 ||
|
||||
immediateEntries.every((entry) => !entry.refreshing)
|
||||
) {
|
||||
return {
|
||||
entries: [...entriesByProviderId.values()],
|
||||
refresh,
|
||||
settled: true,
|
||||
polledProviderIds,
|
||||
};
|
||||
}
|
||||
|
||||
for (const delayMs of INVENTORY_POLL_DELAYS_MS) {
|
||||
await sleep(delayMs);
|
||||
const entries = await getInventory(polledProviderIds);
|
||||
mergeEntries(entriesByProviderId, entries);
|
||||
onEntries?.(entries);
|
||||
if (entries.every((entry) => !entry.refreshing)) {
|
||||
return {
|
||||
entries: [...entriesByProviderId.values()],
|
||||
refresh,
|
||||
settled: true,
|
||||
polledProviderIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: [...entriesByProviderId.values()],
|
||||
refresh,
|
||||
settled: false,
|
||||
polledProviderIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCredentials } from "./useCredentials";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkAllProviderStatus: vi.fn(),
|
||||
deleteProviderConfig: vi.fn(),
|
||||
getProviderConfig: vi.fn(),
|
||||
refreshProviderInventory: vi.fn(),
|
||||
saveProviderConfig: vi.fn(),
|
||||
syncProviderInventory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/providers/api/credentials", () => ({
|
||||
checkAllProviderStatus: mocks.checkAllProviderStatus,
|
||||
deleteProviderConfig: mocks.deleteProviderConfig,
|
||||
getProviderConfig: mocks.getProviderConfig,
|
||||
saveProviderConfig: mocks.saveProviderConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/providers/api/inventorySync", () => ({
|
||||
syncProviderInventory: mocks.syncProviderInventory,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/providers/api/inventory", () => ({
|
||||
refreshProviderInventory: mocks.refreshProviderInventory,
|
||||
}));
|
||||
|
||||
describe("useCredentials", () => {
|
||||
const saveResponse = {
|
||||
status: {
|
||||
providerId: "anthropic",
|
||||
isConfigured: true,
|
||||
},
|
||||
refresh: {
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
},
|
||||
};
|
||||
const deleteResponse = {
|
||||
status: {
|
||||
providerId: "anthropic",
|
||||
isConfigured: false,
|
||||
},
|
||||
refresh: {
|
||||
started: [],
|
||||
skipped: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
reason: "not_configured",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkAllProviderStatus.mockResolvedValue([
|
||||
{
|
||||
providerId: "anthropic",
|
||||
isConfigured: true,
|
||||
},
|
||||
]);
|
||||
mocks.saveProviderConfig.mockResolvedValue(saveResponse);
|
||||
mocks.deleteProviderConfig.mockResolvedValue(deleteResponse);
|
||||
mocks.refreshProviderInventory.mockResolvedValue({
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
});
|
||||
mocks.syncProviderInventory.mockResolvedValue({
|
||||
entries: [],
|
||||
refresh: {
|
||||
started: ["anthropic"],
|
||||
skipped: [],
|
||||
},
|
||||
settled: true,
|
||||
polledProviderIds: ["anthropic"],
|
||||
});
|
||||
});
|
||||
|
||||
it("saves secret fields through the credential API and syncs inventory without requiring restart", async () => {
|
||||
const { result } = renderHook(() => useCredentials());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save("anthropic", [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-test",
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-test",
|
||||
},
|
||||
];
|
||||
|
||||
expect(mocks.saveProviderConfig).toHaveBeenCalledWith("anthropic", fields);
|
||||
await waitFor(() =>
|
||||
expect(mocks.syncProviderInventory.mock.calls[0]?.[0]).toEqual([
|
||||
"anthropic",
|
||||
]),
|
||||
);
|
||||
expect(mocks.syncProviderInventory.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
initialRefresh: saveResponse.refresh,
|
||||
}),
|
||||
);
|
||||
expect(result.current).not.toHaveProperty("needsRestart");
|
||||
expect(result.current).not.toHaveProperty("restart");
|
||||
});
|
||||
|
||||
it("records refresh failure as a provider warning without rejecting the save", async () => {
|
||||
mocks.syncProviderInventory.mockRejectedValueOnce(
|
||||
new Error("model list failed"),
|
||||
);
|
||||
const { result } = renderHook(() => useCredentials());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save("anthropic", [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-test",
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mocks.saveProviderConfig).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(result.current.inventoryWarnings.get("anthropic")).toContain(
|
||||
"model list failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses stale refresh errors after deleting provider config", async () => {
|
||||
mocks.syncProviderInventory.mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
lastRefreshError: "old refresh failure",
|
||||
refreshing: false,
|
||||
},
|
||||
],
|
||||
refresh: deleteResponse.refresh,
|
||||
settled: true,
|
||||
polledProviderIds: ["anthropic"],
|
||||
});
|
||||
const { result } = renderHook(() => useCredentials());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove("anthropic");
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.syncingProviderIds.has("anthropic")).toBe(false),
|
||||
);
|
||||
expect(result.current.inventoryWarnings.has("anthropic")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates native OAuth secrets before refreshing provider status", async () => {
|
||||
const refreshResponse = {
|
||||
started: ["chatgpt_codex"],
|
||||
skipped: [],
|
||||
};
|
||||
mocks.checkAllProviderStatus
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
providerId: "chatgpt_codex",
|
||||
isConfigured: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
providerId: "chatgpt_codex",
|
||||
isConfigured: true,
|
||||
},
|
||||
]);
|
||||
mocks.refreshProviderInventory.mockResolvedValueOnce(refreshResponse);
|
||||
|
||||
const { result } = renderHook(() => useCredentials());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.completeNativeSetup("chatgpt_codex");
|
||||
});
|
||||
|
||||
expect(mocks.refreshProviderInventory).toHaveBeenCalledWith([
|
||||
"chatgpt_codex",
|
||||
]);
|
||||
expect(
|
||||
mocks.refreshProviderInventory.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mocks.checkAllProviderStatus.mock.invocationCallOrder[1]);
|
||||
expect(mocks.syncProviderInventory).toHaveBeenCalledWith(
|
||||
["chatgpt_codex"],
|
||||
expect.objectContaining({
|
||||
initialRefresh: refreshResponse,
|
||||
}),
|
||||
);
|
||||
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes native OAuth status when initial inventory refresh fails", async () => {
|
||||
mocks.checkAllProviderStatus
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
providerId: "chatgpt_codex",
|
||||
isConfigured: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
providerId: "chatgpt_codex",
|
||||
isConfigured: true,
|
||||
},
|
||||
]);
|
||||
mocks.refreshProviderInventory.mockRejectedValueOnce(
|
||||
new Error("refresh unavailable"),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCredentials());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.completeNativeSetup("chatgpt_codex");
|
||||
});
|
||||
|
||||
expect(mocks.refreshProviderInventory).toHaveBeenCalledWith([
|
||||
"chatgpt_codex",
|
||||
]);
|
||||
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true);
|
||||
expect(mocks.syncProviderInventory).toHaveBeenCalledWith(
|
||||
["chatgpt_codex"],
|
||||
expect.objectContaining({
|
||||
initialRefresh: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,81 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
getProviderConfig,
|
||||
saveProviderField,
|
||||
saveProviderConfig,
|
||||
deleteProviderConfig,
|
||||
type ProviderStatus,
|
||||
checkAllProviderStatus,
|
||||
restartApp,
|
||||
} from "@/features/providers/api/credentials";
|
||||
import {
|
||||
syncProviderInventory,
|
||||
type SyncProviderInventoryResult,
|
||||
} from "@/features/providers/api/inventorySync";
|
||||
import { refreshProviderInventory } from "@/features/providers/api/inventory";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import type { ProviderFieldValue } from "@/shared/types/providers";
|
||||
|
||||
export interface ProviderFieldSave {
|
||||
key: string;
|
||||
value: string;
|
||||
isSecret: boolean;
|
||||
}
|
||||
|
||||
interface UseCredentialsReturn {
|
||||
configuredIds: Set<string>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
needsRestart: boolean;
|
||||
savingProviderIds: Set<string>;
|
||||
syncingProviderIds: Set<string>;
|
||||
inventoryWarnings: Map<string, string>;
|
||||
getConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
|
||||
save: (key: string, value: string) => Promise<void>;
|
||||
save: (providerId: string, fields: ProviderFieldSave[]) => Promise<void>;
|
||||
remove: (providerId: string) => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
completeNativeSetup: () => Promise<void>;
|
||||
completeNativeSetup: (providerId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function inventoryWarning(
|
||||
providerId: string,
|
||||
result: SyncProviderInventoryResult,
|
||||
): string | null {
|
||||
const entry = result.entries.find((item) => item.providerId === providerId);
|
||||
const skipped = result.refresh.skipped?.find(
|
||||
(item) => item.providerId === providerId,
|
||||
);
|
||||
if (skipped?.reason === "not_configured") {
|
||||
return null;
|
||||
}
|
||||
if (skipped?.reason === "unknown_provider") {
|
||||
return "Provider inventory is unavailable.";
|
||||
}
|
||||
|
||||
if (entry?.lastRefreshError) {
|
||||
return entry.lastRefreshError;
|
||||
}
|
||||
|
||||
if (!result.settled && entry?.refreshing) {
|
||||
return "Model inventory is still refreshing.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useCredentials(): UseCredentialsReturn {
|
||||
const [statuses, setStatuses] = useState<ProviderStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
const [savingProviderIds, setSavingProviderIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [syncingProviderIds, setSyncingProviderIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [inventoryWarnings, setInventoryWarnings] = useState<
|
||||
Map<string, string>
|
||||
>(() => new Map());
|
||||
const syncRunIds = useRef(new Map<string, number>());
|
||||
|
||||
const refreshStatuses = useCallback(async () => {
|
||||
const nextStatuses = await checkAllProviderStatus();
|
||||
@@ -33,6 +83,16 @@ export function useCredentials(): UseCredentialsReturn {
|
||||
return nextStatuses;
|
||||
}, []);
|
||||
|
||||
const updateProviderStatus = useCallback((status: ProviderStatus) => {
|
||||
setStatuses((current) => {
|
||||
const next = current.filter(
|
||||
(item) => item.providerId !== status.providerId,
|
||||
);
|
||||
next.push(status);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatuses()
|
||||
.catch(() => {})
|
||||
@@ -44,57 +104,158 @@ export function useCredentials(): UseCredentialsReturn {
|
||||
new Set(statuses.filter((s) => s.isConfigured).map((s) => s.providerId)),
|
||||
[statuses],
|
||||
);
|
||||
const saving = savingProviderIds.size > 0;
|
||||
|
||||
const getConfig = useCallback(async (providerId: string) => {
|
||||
return getProviderConfig(providerId);
|
||||
}, []);
|
||||
|
||||
const setProviderSaving = useCallback(
|
||||
(providerId: string, isSaving: boolean) => {
|
||||
setSavingProviderIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (isSaving) {
|
||||
next.add(providerId);
|
||||
} else {
|
||||
next.delete(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setProviderSyncing = useCallback(
|
||||
(providerId: string, isSyncing: boolean) => {
|
||||
setSyncingProviderIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (isSyncing) {
|
||||
next.add(providerId);
|
||||
} else {
|
||||
next.delete(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setProviderInventoryWarning = useCallback(
|
||||
(providerId: string, warning: string | null) => {
|
||||
setInventoryWarnings((current) => {
|
||||
const next = new Map(current);
|
||||
if (warning) {
|
||||
next.set(providerId, warning);
|
||||
} else {
|
||||
next.delete(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startInventorySync = useCallback(
|
||||
(
|
||||
providerId: string,
|
||||
initialRefresh?: SyncProviderInventoryResult["refresh"],
|
||||
) => {
|
||||
const runId = (syncRunIds.current.get(providerId) ?? 0) + 1;
|
||||
syncRunIds.current.set(providerId, runId);
|
||||
setProviderSyncing(providerId, true);
|
||||
setProviderInventoryWarning(providerId, null);
|
||||
|
||||
void syncProviderInventory([providerId], {
|
||||
initialRefresh,
|
||||
onEntries: (entries) => {
|
||||
if (syncRunIds.current.get(providerId) !== runId) {
|
||||
return;
|
||||
}
|
||||
useProviderInventoryStore.getState().mergeEntries(entries);
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
if (syncRunIds.current.get(providerId) !== runId) {
|
||||
return;
|
||||
}
|
||||
setProviderInventoryWarning(
|
||||
providerId,
|
||||
inventoryWarning(providerId, result),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (syncRunIds.current.get(providerId) !== runId) {
|
||||
return;
|
||||
}
|
||||
setProviderInventoryWarning(providerId, errorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (syncRunIds.current.get(providerId) !== runId) {
|
||||
return;
|
||||
}
|
||||
setProviderSyncing(providerId, false);
|
||||
});
|
||||
},
|
||||
[setProviderInventoryWarning, setProviderSyncing],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
setSaving(true);
|
||||
async (providerId: string, fields: ProviderFieldSave[]) => {
|
||||
setProviderSaving(providerId, true);
|
||||
try {
|
||||
await saveProviderField(key, value);
|
||||
await refreshStatuses();
|
||||
setNeedsRestart(true);
|
||||
const result = await saveProviderConfig(
|
||||
providerId,
|
||||
fields.map(({ key, value }) => ({ key, value })),
|
||||
);
|
||||
updateProviderStatus(result.status);
|
||||
startInventorySync(providerId, result.refresh);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setProviderSaving(providerId, false);
|
||||
}
|
||||
},
|
||||
[refreshStatuses],
|
||||
[setProviderSaving, startInventorySync, updateProviderStatus],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (providerId: string) => {
|
||||
setSaving(true);
|
||||
setProviderSaving(providerId, true);
|
||||
try {
|
||||
await deleteProviderConfig(providerId);
|
||||
await refreshStatuses();
|
||||
setNeedsRestart(true);
|
||||
const result = await deleteProviderConfig(providerId);
|
||||
updateProviderStatus(result.status);
|
||||
startInventorySync(providerId, result.refresh);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setProviderSaving(providerId, false);
|
||||
}
|
||||
},
|
||||
[refreshStatuses],
|
||||
[setProviderSaving, startInventorySync, updateProviderStatus],
|
||||
);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
await restartApp();
|
||||
}, []);
|
||||
|
||||
const completeNativeSetup = useCallback(async () => {
|
||||
await refreshStatuses();
|
||||
setNeedsRestart(true);
|
||||
}, [refreshStatuses]);
|
||||
const completeNativeSetup = useCallback(
|
||||
async (providerId: string) => {
|
||||
// Native OAuth returns only after the subprocess writes credentials.
|
||||
// Inventory refresh invalidates ACP's secret cache before status reads it.
|
||||
let initialRefresh: SyncProviderInventoryResult["refresh"] | undefined;
|
||||
try {
|
||||
initialRefresh = await refreshProviderInventory([providerId]);
|
||||
} catch (error) {
|
||||
setProviderInventoryWarning(providerId, errorMessage(error));
|
||||
}
|
||||
await refreshStatuses();
|
||||
startInventorySync(providerId, initialRefresh);
|
||||
},
|
||||
[refreshStatuses, setProviderInventoryWarning, startInventorySync],
|
||||
);
|
||||
|
||||
return {
|
||||
configuredIds,
|
||||
loading,
|
||||
saving,
|
||||
needsRestart,
|
||||
savingProviderIds,
|
||||
syncingProviderIds,
|
||||
inventoryWarnings,
|
||||
getConfig,
|
||||
save,
|
||||
remove,
|
||||
restart,
|
||||
completeNativeSetup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface OutputLine {
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const CHECKING_INDICATOR_DELAY_MS = 2000;
|
||||
|
||||
interface AgentProviderCardProps {
|
||||
provider: ProviderDisplayInfo;
|
||||
@@ -38,6 +39,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
|
||||
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [showCheckingIndicator, setShowCheckingIndicator] = useState(false);
|
||||
const [installStatus, setInstallStatus] = useState<InstallStatus>(
|
||||
hasBinary && !isBuiltIn ? "checking" : "installed",
|
||||
);
|
||||
@@ -228,9 +230,6 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
void handleConnect();
|
||||
}
|
||||
|
||||
if (provider.showOnlyWhenInstalled && installStatus !== "installed")
|
||||
return null;
|
||||
|
||||
const isReady =
|
||||
isBuiltIn ||
|
||||
(installStatus === "installed" && !hasAuthCommand) ||
|
||||
@@ -241,12 +240,34 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
authStatus !== "checking" &&
|
||||
authStatus !== "authenticated";
|
||||
const needsInstall = installStatus === "missing" && hasInstallCommand;
|
||||
const isChecking =
|
||||
(installStatus === "checking" && hasBinary) ||
|
||||
(installStatus === "installed" && authStatus === "checking");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChecking) {
|
||||
setShowCheckingIndicator(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCheckingIndicator(false);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowCheckingIndicator(true);
|
||||
}
|
||||
}, CHECKING_INDICATOR_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isChecking]);
|
||||
|
||||
if (provider.showOnlyWhenInstalled && installStatus !== "installed")
|
||||
return null;
|
||||
|
||||
function renderStatusIndicator() {
|
||||
if (isBuiltIn || isReady) {
|
||||
return (
|
||||
<div className="flex h-6 flex-shrink-0 items-center">
|
||||
<IconCheck className="size-4 text-success" />
|
||||
<IconCheck className="size-4 text-success duration-200 motion-safe:animate-in motion-safe:fade-in" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -259,6 +280,26 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if ((isChecking && showCheckingIndicator) || isActive) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={
|
||||
isChecking
|
||||
? t("providers.agents.status.checking")
|
||||
: t("providers.agents.status.inProgress")
|
||||
}
|
||||
className="flex h-6 flex-shrink-0 items-center"
|
||||
>
|
||||
<Spinner
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className="size-4 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsAuth && !isActive) {
|
||||
return (
|
||||
<Button
|
||||
@@ -300,11 +341,6 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
if (isBuiltIn || isReady) return null;
|
||||
if (setupError) return t("providers.agents.status.setupFailed");
|
||||
|
||||
if (installStatus === "checking" && hasBinary)
|
||||
return t("providers.agents.status.checking");
|
||||
if (installStatus === "installed" && authStatus === "checking")
|
||||
return t("providers.agents.status.checking");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,43 @@ import {
|
||||
renderInlineCodeMessage,
|
||||
} from "./modelProviderHelpers";
|
||||
|
||||
interface InventorySyncMessageProps {
|
||||
syncing: boolean;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export function InventorySyncMessage({
|
||||
syncing,
|
||||
warning,
|
||||
}: InventorySyncMessageProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
if (syncing) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<IconLoader2 className="size-3 animate-spin text-accent" />
|
||||
<span>{t("providers.loadingModels")}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (warning) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-border-warning bg-background-warning/20 px-2.5 py-2 text-xs text-text-warning"
|
||||
>
|
||||
{t("providers.modelRefreshWarning", { message: warning })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ConnectedFieldsPanelProps {
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
fields: ProviderField[];
|
||||
@@ -23,6 +60,8 @@ interface ConnectedFieldsPanelProps {
|
||||
editingKey: string | null;
|
||||
draftValues: Record<string, string>;
|
||||
saving: boolean;
|
||||
inventorySyncing: boolean;
|
||||
inventoryWarning?: string | null;
|
||||
showSavedState: boolean;
|
||||
error: string;
|
||||
setupMessage: string | null;
|
||||
@@ -40,6 +79,8 @@ export function ConnectedFieldsPanel({
|
||||
editingKey,
|
||||
draftValues,
|
||||
saving,
|
||||
inventorySyncing,
|
||||
inventoryWarning,
|
||||
showSavedState,
|
||||
error,
|
||||
setupMessage,
|
||||
@@ -158,6 +199,10 @@ export function ConnectedFieldsPanel({
|
||||
</Button>
|
||||
</div>
|
||||
{renderSetupMessage(setupMessage)}
|
||||
<InventorySyncMessage
|
||||
syncing={inventorySyncing}
|
||||
warning={inventoryWarning}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -169,6 +214,8 @@ interface SetupFieldsPanelProps {
|
||||
fieldValueMap: Map<string, ProviderFieldValue>;
|
||||
draftValues: Record<string, string>;
|
||||
saving: boolean;
|
||||
inventorySyncing: boolean;
|
||||
inventoryWarning?: string | null;
|
||||
showSavedState: boolean;
|
||||
error: string;
|
||||
setupMethod: ProviderSetupMethod;
|
||||
@@ -185,6 +232,8 @@ export function SetupFieldsPanel({
|
||||
fieldValueMap,
|
||||
draftValues,
|
||||
saving,
|
||||
inventorySyncing,
|
||||
inventoryWarning,
|
||||
showSavedState,
|
||||
error,
|
||||
setupMethod,
|
||||
@@ -257,6 +306,10 @@ export function SetupFieldsPanel({
|
||||
{setupMethod === "cloud_credentials" && setupMessage
|
||||
? renderSetupMessage(setupMessage)
|
||||
: null}
|
||||
<InventorySyncMessage
|
||||
syncing={inventorySyncing}
|
||||
warning={inventoryWarning}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,24 +34,38 @@ import {
|
||||
getFieldSetupDescription,
|
||||
renderSetupMessage,
|
||||
} from "./modelProviderHelpers";
|
||||
import { ConnectedFieldsPanel, SetupFieldsPanel } from "./ModelProviderPanels";
|
||||
import {
|
||||
ConnectedFieldsPanel,
|
||||
InventorySyncMessage,
|
||||
SetupFieldsPanel,
|
||||
} from "./ModelProviderPanels";
|
||||
|
||||
interface ProviderFieldSaveInput {
|
||||
key: string;
|
||||
value: string;
|
||||
isSecret: boolean;
|
||||
}
|
||||
|
||||
interface ModelProviderRowProps {
|
||||
provider: ProviderDisplayInfo;
|
||||
onGetConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
|
||||
onSaveField: (key: string, value: string, isSecret: boolean) => Promise<void>;
|
||||
onSaveFields: (fields: ProviderFieldSaveInput[]) => Promise<void>;
|
||||
onRemoveConfig?: () => Promise<void>;
|
||||
onCompleteNativeSetup: () => Promise<void>;
|
||||
onCompleteNativeSetup: (providerId: string) => Promise<void>;
|
||||
saving?: boolean;
|
||||
inventorySyncing?: boolean;
|
||||
inventoryWarning?: string | null;
|
||||
}
|
||||
|
||||
export function ModelProviderRow({
|
||||
provider,
|
||||
onGetConfig,
|
||||
onSaveField,
|
||||
onSaveFields,
|
||||
onRemoveConfig,
|
||||
onCompleteNativeSetup,
|
||||
saving = false,
|
||||
inventorySyncing = false,
|
||||
inventoryWarning = null,
|
||||
}: ModelProviderRowProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -161,8 +175,10 @@ export function ModelProviderRow({
|
||||
const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput);
|
||||
|
||||
try {
|
||||
// The native connector exits after writing credentials; only then do we
|
||||
// ask the credentials hook to refresh ACP inventory for this provider.
|
||||
await authenticateModelProvider(provider.id, provider.nativeConnectQuery);
|
||||
await onCompleteNativeSetup();
|
||||
await onCompleteNativeSetup(provider.id);
|
||||
} catch (nextError) {
|
||||
setSetupError(
|
||||
nextError instanceof Error
|
||||
@@ -219,7 +235,9 @@ export function ModelProviderRow({
|
||||
setError("");
|
||||
try {
|
||||
shouldRestorePanelFocus.current = true;
|
||||
await onSaveField(field.key, nextValue, field.secret);
|
||||
await onSaveFields([
|
||||
{ key: field.key, value: nextValue, isSecret: field.secret },
|
||||
]);
|
||||
await loadConfig();
|
||||
setEditingKey(null);
|
||||
setShowSavedState(true);
|
||||
@@ -269,10 +287,13 @@ export function ModelProviderRow({
|
||||
|
||||
setError("");
|
||||
try {
|
||||
for (const field of fieldsToSave) {
|
||||
const nextValue = draftValues[field.key]?.trim() ?? "";
|
||||
await onSaveField(field.key, nextValue, field.secret);
|
||||
}
|
||||
await onSaveFields(
|
||||
fieldsToSave.map((field) => ({
|
||||
key: field.key,
|
||||
value: draftValues[field.key]?.trim() ?? "",
|
||||
isSecret: field.secret,
|
||||
})),
|
||||
);
|
||||
await loadConfig();
|
||||
setShowSavedState(true);
|
||||
setPreserveSetupLayout(true);
|
||||
@@ -364,6 +385,10 @@ export function ModelProviderRow({
|
||||
<span>{t("providers.waitingForSignIn")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<InventorySyncMessage
|
||||
syncing={inventorySyncing}
|
||||
warning={inventoryWarning}
|
||||
/>
|
||||
{setupOutput.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md bg-muted px-3 py-2 font-mono text-xxs text-muted-foreground">
|
||||
{setupOutput.map((line) => (
|
||||
@@ -387,6 +412,8 @@ export function ModelProviderRow({
|
||||
editingKey={editingKey}
|
||||
draftValues={draftValues}
|
||||
saving={saving}
|
||||
inventorySyncing={inventorySyncing}
|
||||
inventoryWarning={inventoryWarning}
|
||||
showSavedState={showSavedState}
|
||||
error={error}
|
||||
setupMessage={setupMessage}
|
||||
@@ -407,6 +434,8 @@ export function ModelProviderRow({
|
||||
fieldValueMap={fieldValueMap}
|
||||
draftValues={draftValues}
|
||||
saving={saving}
|
||||
inventorySyncing={inventorySyncing}
|
||||
inventoryWarning={inventoryWarning}
|
||||
showSavedState={showSavedState}
|
||||
error={error}
|
||||
setupMethod={provider.setupMethod}
|
||||
@@ -426,6 +455,10 @@ export function ModelProviderRow({
|
||||
className="focus-override mx-3 space-y-2 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
{renderSetupMessage(setupMessage)}
|
||||
<InventorySyncMessage
|
||||
syncing={inventorySyncing}
|
||||
warning={inventoryWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -452,6 +485,9 @@ export function ModelProviderRow({
|
||||
{isConnected ? (
|
||||
<IconCheck className="size-4 flex-shrink-0 text-success" />
|
||||
) : null}
|
||||
{inventorySyncing ? (
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
|
||||
) : null}
|
||||
{!isConnected && authenticating ? (
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
|
||||
) : null}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { IconChevronDown, IconRefresh } from "@tabler/icons-react";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import {
|
||||
getAgentProviders,
|
||||
getModelProviders,
|
||||
@@ -45,12 +45,12 @@ export function ProvidersSettings() {
|
||||
const {
|
||||
configuredIds,
|
||||
loading,
|
||||
saving,
|
||||
needsRestart,
|
||||
savingProviderIds,
|
||||
syncingProviderIds,
|
||||
inventoryWarnings,
|
||||
getConfig,
|
||||
save,
|
||||
remove,
|
||||
restart,
|
||||
completeNativeSetup,
|
||||
} = useCredentials();
|
||||
|
||||
@@ -112,23 +112,6 @@ export function ProvidersSettings() {
|
||||
const advancedModels = orderedModels.filter((m) => m.tier === "advanced");
|
||||
const visibleModels = showAllModels ? orderedModels : promotedModels;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
@@ -138,16 +121,6 @@ export function ProvidersSettings() {
|
||||
{t("providers.description")}
|
||||
</p>
|
||||
|
||||
{needsRestart && (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-lg border border-accent bg-background-accent/30 px-3 py-2.5">
|
||||
<p className="flex-1 text-sm">{t("providers.restartMessage")}</p>
|
||||
<Button type="button" size="sm" onClick={() => void restart()}>
|
||||
<IconRefresh className="size-3.5" />
|
||||
{t("providers.restartButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<section>
|
||||
@@ -171,9 +144,17 @@ export function ProvidersSettings() {
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.models.title")}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.models.title")}
|
||||
</h4>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3 text-accent" />
|
||||
{t("providers.models.checkingStatus")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providers.models.description")}
|
||||
</p>
|
||||
@@ -185,10 +166,12 @@ export function ProvidersSettings() {
|
||||
key={model.id}
|
||||
provider={model}
|
||||
onGetConfig={getConfig}
|
||||
onSaveField={save}
|
||||
onSaveFields={(fields) => save(model.id, fields)}
|
||||
onRemoveConfig={() => remove(model.id)}
|
||||
onCompleteNativeSetup={completeNativeSetup}
|
||||
saving={saving}
|
||||
saving={savingProviderIds.has(model.id)}
|
||||
inventorySyncing={syncingProviderIds.has(model.id)}
|
||||
inventoryWarning={inventoryWarnings.get(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/test/render";
|
||||
import { AgentProviderCard } from "../AgentProviderCard";
|
||||
import type { ProviderDisplayInfo } from "@/shared/types/providers";
|
||||
@@ -37,7 +37,12 @@ describe("AgentProviderCard", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not show sign in while auth status is checking", async () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("delays the checking indicator and does not show sign in while auth status is checking", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveAuth!: (authenticated: boolean) => void;
|
||||
const authPromise = new Promise<boolean>((resolve) => {
|
||||
resolveAuth = resolve;
|
||||
@@ -48,21 +53,53 @@ describe("AgentProviderCard", () => {
|
||||
|
||||
renderWithProviders(<AgentProviderCard provider={createProvider()} />);
|
||||
|
||||
expect(await screen.findByText("Checking...")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(checkAgentAuth).toHaveBeenCalled();
|
||||
|
||||
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
|
||||
expect(screen.queryByText("Checking...")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /sign in/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Checking..." }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveAuth(false);
|
||||
await authPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the checking indicator when auth resolves quickly", async () => {
|
||||
vi.useFakeTimers();
|
||||
checkAgentInstalled.mockResolvedValue(true);
|
||||
checkAgentAuth.mockResolvedValue(true);
|
||||
|
||||
renderWithProviders(<AgentProviderCard provider={createProvider()} />);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
|
||||
});
|
||||
|
||||
it("checks installation by provider id after installing", async () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentType } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getModelProviders } from "@/features/providers/providerCatalog";
|
||||
import { ModelProviderRow } from "../ModelProviderRow";
|
||||
|
||||
const Row = ModelProviderRow as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
function modelProvider(id: string, status: "connected" | "not_configured") {
|
||||
const provider = getModelProviders().find((entry) => entry.id === id);
|
||||
if (!provider) {
|
||||
throw new Error(`missing provider fixture: ${id}`);
|
||||
}
|
||||
return {
|
||||
...provider,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModelProviderRow", () => {
|
||||
const onGetConfig = vi.fn();
|
||||
const onSaveFields = vi.fn();
|
||||
const onRemoveConfig = vi.fn();
|
||||
const onCompleteNativeSetup = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onGetConfig.mockResolvedValue([]);
|
||||
onSaveFields.mockResolvedValue(undefined);
|
||||
onRemoveConfig.mockResolvedValue(undefined);
|
||||
onCompleteNativeSetup.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("saves all changed setup fields from one setup submit", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ModelProviderRow
|
||||
provider={modelProvider("databricks", "not_configured")}
|
||||
onGetConfig={onGetConfig}
|
||||
onSaveFields={onSaveFields}
|
||||
onRemoveConfig={onRemoveConfig}
|
||||
onCompleteNativeSetup={onCompleteNativeSetup}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /databricks/i }));
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText(/cloud\.databricks\.com/i),
|
||||
"https://dbc-test.cloud.databricks.com",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/paste your access token/i),
|
||||
"databricks-token",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => expect(onSaveFields).toHaveBeenCalledTimes(1));
|
||||
expect(onSaveFields).toHaveBeenCalledWith([
|
||||
{
|
||||
key: "DATABRICKS_HOST",
|
||||
value: "https://dbc-test.cloud.databricks.com",
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
key: "DATABRICKS_TOKEN",
|
||||
value: "databricks-token",
|
||||
isSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the connected row while model inventory is still loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Row
|
||||
provider={modelProvider("anthropic", "connected")}
|
||||
onGetConfig={onGetConfig}
|
||||
onSaveFields={onSaveFields}
|
||||
onRemoveConfig={onRemoveConfig}
|
||||
onCompleteNativeSetup={onCompleteNativeSetup}
|
||||
inventorySyncing={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /anthropic/i }));
|
||||
|
||||
expect(screen.getByText(/loading models/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-blocking inventory warning without replacing the connected state", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Row
|
||||
provider={modelProvider("anthropic", "connected")}
|
||||
onGetConfig={onGetConfig}
|
||||
onSaveFields={onSaveFields}
|
||||
onRemoveConfig={onRemoveConfig}
|
||||
onCompleteNativeSetup={onCompleteNativeSetup}
|
||||
inventoryWarning="Model refresh failed"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /anthropic/i }));
|
||||
|
||||
expect(screen.getByText(/model refresh failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProvidersSettings } from "../ProvidersSettings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/providers/hooks/useCredentials", () => ({
|
||||
useCredentials: () => mocks.useCredentials(),
|
||||
}));
|
||||
|
||||
describe("ProvidersSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.useCredentials.mockReturnValue({
|
||||
configuredIds: new Set<string>(),
|
||||
loading: false,
|
||||
saving: false,
|
||||
savingProviderIds: new Set<string>(),
|
||||
syncingProviderIds: new Set<string>(),
|
||||
inventoryWarnings: new Map<string, string>(),
|
||||
getConfig: vi.fn(),
|
||||
save: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
completeNativeSetup: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the restart banner for provider credential changes", () => {
|
||||
render(<ProvidersSettings />);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/restart to apply credential changes/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /restart now/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the static provider catalog while credential status is loading", () => {
|
||||
mocks.useCredentials.mockReturnValue({
|
||||
configuredIds: new Set<string>(),
|
||||
loading: true,
|
||||
saving: false,
|
||||
savingProviderIds: new Set<string>(),
|
||||
syncingProviderIds: new Set<string>(),
|
||||
inventoryWarnings: new Map<string, string>(),
|
||||
getConfig: vi.fn(),
|
||||
save: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
completeNativeSetup: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ProvidersSettings />);
|
||||
|
||||
expect(screen.getByText("Providers")).toBeInTheDocument();
|
||||
expect(screen.getByText("Anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("Checking provider status...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches main by ordering connected model providers first after status loads", () => {
|
||||
mocks.useCredentials.mockReturnValue({
|
||||
configuredIds: new Set<string>(["openai", "databricks"]),
|
||||
loading: false,
|
||||
saving: false,
|
||||
savingProviderIds: new Set<string>(),
|
||||
syncingProviderIds: new Set<string>(),
|
||||
inventoryWarnings: new Map<string, string>(),
|
||||
getConfig: vi.fn(),
|
||||
save: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
completeNativeSetup: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ProvidersSettings />);
|
||||
|
||||
const openai = screen.getByText("OpenAI");
|
||||
const databricks = screen.getByText("Databricks");
|
||||
const anthropic = screen.getByText("Anthropic");
|
||||
|
||||
expect(
|
||||
openai.compareDocumentPosition(databricks) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
databricks.compareDocumentPosition(anthropic) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
cancelDictationLocalModelDownload,
|
||||
deleteDictationLocalModel,
|
||||
deleteDictationProviderSecret,
|
||||
downloadDictationLocalModel,
|
||||
getDictationConfig,
|
||||
getDictationLocalModelDownloadProgress,
|
||||
listDictationLocalModels,
|
||||
saveDictationModelSelection,
|
||||
saveDictationProviderSecret,
|
||||
transcribeDictation,
|
||||
} from "../dictation";
|
||||
import { getClient } from "../acpConnection";
|
||||
@@ -67,6 +69,23 @@ describe("dictation SDK wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("saveDictationProviderSecret calls GooseSecretUpsert", async () => {
|
||||
client.goose.GooseSecretUpsert = vi.fn().mockResolvedValue({});
|
||||
await saveDictationProviderSecret("groq", "gsk-test", "GROQ_API_KEY");
|
||||
expect(client.goose.GooseSecretUpsert).toHaveBeenCalledWith({
|
||||
key: "GROQ_API_KEY",
|
||||
value: "gsk-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteDictationProviderSecret calls GooseSecretRemove", async () => {
|
||||
client.goose.GooseSecretRemove = vi.fn().mockResolvedValue({});
|
||||
await deleteDictationProviderSecret("groq", "GROQ_API_KEY");
|
||||
expect(client.goose.GooseSecretRemove).toHaveBeenCalledWith({
|
||||
key: "GROQ_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("listDictationLocalModels returns the models array", async () => {
|
||||
client.goose.GooseDictationModelsList = vi.fn().mockResolvedValue({
|
||||
models: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
DictationDownloadProgress,
|
||||
DictationProvider,
|
||||
@@ -48,22 +47,19 @@ export async function saveDictationProviderSecret(
|
||||
if (!configKey) {
|
||||
throw new Error("No config key for this provider");
|
||||
}
|
||||
return invoke("save_provider_field", { key: configKey, value });
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSecretUpsert({ key: configKey, value });
|
||||
}
|
||||
|
||||
export async function deleteDictationProviderSecret(
|
||||
provider: DictationProvider,
|
||||
_configKey?: string,
|
||||
_provider: DictationProvider,
|
||||
configKey?: string,
|
||||
): Promise<void> {
|
||||
const providerIdMap: Record<string, string> = {
|
||||
groq: "dictation_groq",
|
||||
elevenlabs: "dictation_elevenlabs",
|
||||
};
|
||||
const providerId = providerIdMap[provider];
|
||||
if (!providerId) {
|
||||
if (!configKey) {
|
||||
throw new Error("Cannot delete secrets for this provider");
|
||||
}
|
||||
return invoke("delete_provider_config", { providerId });
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSecretRemove({ key: configKey });
|
||||
}
|
||||
|
||||
export async function listDictationLocalModels(): Promise<
|
||||
|
||||
@@ -220,9 +220,10 @@
|
||||
"signInLabel": "Sign in to {{name}}",
|
||||
"status": {
|
||||
"checking": "Checking...",
|
||||
"inProgress": "Setup in progress",
|
||||
"setupFailed": "Setup failed"
|
||||
},
|
||||
"title": "Agents"
|
||||
"title": "Agent harnesses"
|
||||
},
|
||||
"description": "Connect agents and AI models to use with Goose",
|
||||
"disconnect": "Disconnect",
|
||||
@@ -251,10 +252,11 @@
|
||||
"oauthTerminal": "Run `goose configure` in your terminal to finish sign-in."
|
||||
}
|
||||
},
|
||||
"checkingStatus": "Checking provider status...",
|
||||
"title": "Models"
|
||||
},
|
||||
"restartButton": "Restart now",
|
||||
"restartMessage": "Restart to apply credential changes.",
|
||||
"loadingModels": "Loading models...",
|
||||
"modelRefreshWarning": "Credentials saved. Model refresh needs attention: {{message}}",
|
||||
"saved": "Saved",
|
||||
"showFewer": "Show fewer",
|
||||
"showMore": "Show {{count}} more providers",
|
||||
|
||||
@@ -220,9 +220,10 @@
|
||||
"signInLabel": "Iniciar sesión en {{name}}",
|
||||
"status": {
|
||||
"checking": "Comprobando...",
|
||||
"inProgress": "Configuración en curso",
|
||||
"setupFailed": "La configuración falló"
|
||||
},
|
||||
"title": "Agentes"
|
||||
"title": "Arneses de agentes"
|
||||
},
|
||||
"description": "Conecta agentes y modelos de IA para usar con Goose",
|
||||
"disconnect": "Desconectar",
|
||||
@@ -251,10 +252,11 @@
|
||||
"oauthTerminal": "Ejecuta `goose configure` en tu terminal para terminar de iniciar sesión."
|
||||
}
|
||||
},
|
||||
"checkingStatus": "Comprobando estado del proveedor...",
|
||||
"title": "Modelos"
|
||||
},
|
||||
"restartButton": "Reiniciar ahora",
|
||||
"restartMessage": "Reinicia para aplicar los cambios de credenciales.",
|
||||
"loadingModels": "Cargando modelos...",
|
||||
"modelRefreshWarning": "Credenciales guardadas. La actualización de modelos necesita atención: {{message}}",
|
||||
"saved": "Guardado",
|
||||
"showFewer": "Mostrar menos",
|
||||
"showMore": "Mostrar {{count}} proveedores más",
|
||||
|
||||
Reference in New Issue
Block a user