feat(acp+): models/providers in desktop on ACP+ (#9987)
This commit is contained in:
@@ -63,6 +63,15 @@ vi.mock('./sessions', () => ({
|
||||
createSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ACP providers module used by OnboardingGuard so it doesn't try to
|
||||
// open a real ACP client connection during tests. Returning null defaults
|
||||
// keeps the app in the "brand new" (no provider configured) onboarding state.
|
||||
vi.mock('./acp/providers', () => ({
|
||||
acpReadDefaults: vi.fn().mockResolvedValue({ providerId: null, modelId: null }),
|
||||
acpSaveDefaults: vi.fn().mockResolvedValue(undefined),
|
||||
acpListProviderDetails: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock the ConfigContext module
|
||||
vi.mock('./components/ConfigContext', () => ({
|
||||
useConfig: () => ({
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
CanonicalModelInfoDto,
|
||||
CustomProviderCreateRequest_unstable,
|
||||
CustomProviderReadResponse_unstable,
|
||||
ProviderSecretDto,
|
||||
ProviderTemplateCatalogEntryDto,
|
||||
ProviderTemplateDto,
|
||||
} from '@aaif/goose-sdk';
|
||||
import type { ProviderDetails, ThinkingEffort, UpdateCustomProviderRequest } from '../api';
|
||||
import { getAcpClient } from './acpConnection';
|
||||
|
||||
export type { CanonicalModelInfoDto, ProviderSecretDto };
|
||||
|
||||
function updateRequestToCreate(
|
||||
request: UpdateCustomProviderRequest
|
||||
): CustomProviderCreateRequest_unstable {
|
||||
return {
|
||||
engine: request.engine,
|
||||
displayName: request.display_name,
|
||||
apiUrl: request.api_url,
|
||||
apiKey: request.api_key || null,
|
||||
models: request.models,
|
||||
supportsStreaming: request.supports_streaming ?? null,
|
||||
headers: request.headers ?? undefined,
|
||||
requiresAuth: request.requires_auth ?? true,
|
||||
catalogProviderId: request.catalog_provider_id ?? null,
|
||||
basePath: request.base_path ?? null,
|
||||
preservesThinking: request.preserves_thinking ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acpListProviderDetails(): Promise<ProviderDetails[]> {
|
||||
const client = await getAcpClient();
|
||||
const { entries } = await client.goose.providersList_unstable({});
|
||||
return entries.map((entry) => ({
|
||||
name: entry.providerId,
|
||||
is_configured: entry.configured,
|
||||
provider_type: entry.providerType as ProviderDetails['provider_type'],
|
||||
metadata: {
|
||||
name: entry.providerId,
|
||||
display_name: entry.providerName,
|
||||
description: entry.description,
|
||||
default_model: entry.defaultModel,
|
||||
model_doc_link: '',
|
||||
model_selection_hint: entry.modelSelectionHint ?? null,
|
||||
config_keys: entry.configKeys.map((key) => ({
|
||||
name: key.name,
|
||||
required: key.required,
|
||||
secret: key.secret,
|
||||
default: key.default ?? null,
|
||||
oauth_flow: key.oauthFlow ?? false,
|
||||
device_code_flow: key.deviceCodeFlow ?? false,
|
||||
primary: key.primary ?? false,
|
||||
})),
|
||||
known_models: entry.models.map((model) => ({
|
||||
name: model.id,
|
||||
context_limit: model.contextLimit ?? 0,
|
||||
reasoning: model.reasoning ?? undefined,
|
||||
})),
|
||||
setup_steps: entry.setupSteps,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export async function acpListProviderModels(providerId: string) {
|
||||
const client = await getAcpClient();
|
||||
const { entries } = await client.goose.providersList_unstable({ providerIds: [providerId] });
|
||||
return entries.find((e) => e.providerId === providerId)?.models ?? [];
|
||||
}
|
||||
|
||||
export async function acpListProviderCatalogEntries(
|
||||
format?: string
|
||||
): Promise<ProviderTemplateCatalogEntryDto[]> {
|
||||
const client = await getAcpClient();
|
||||
const { providers } = await client.goose.providersCatalogList_unstable(format ? { format } : {});
|
||||
return providers;
|
||||
}
|
||||
|
||||
export async function acpGetProviderTemplate(providerId: string): Promise<ProviderTemplateDto> {
|
||||
const client = await getAcpClient();
|
||||
const { template } = await client.goose.providersCatalogTemplate_unstable({ providerId });
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function acpGetCustomProvider(
|
||||
providerId: string
|
||||
): Promise<CustomProviderReadResponse_unstable> {
|
||||
const client = await getAcpClient();
|
||||
return client.goose.providersCustomRead_unstable({ providerId });
|
||||
}
|
||||
|
||||
export async function acpCreateCustomProviderFromRequest(
|
||||
request: UpdateCustomProviderRequest
|
||||
): Promise<{ provider_name: string }> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.providersCustomCreate_unstable(
|
||||
updateRequestToCreate(request)
|
||||
);
|
||||
return { provider_name: response.providerId };
|
||||
}
|
||||
|
||||
export async function acpUpdateCustomProviderFromRequest(
|
||||
providerId: string,
|
||||
request: UpdateCustomProviderRequest
|
||||
): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersCustomUpdate_unstable({
|
||||
providerId,
|
||||
...updateRequestToCreate(request),
|
||||
});
|
||||
}
|
||||
|
||||
export async function acpDeleteCustomProvider(providerId: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersCustomDelete_unstable({ providerId });
|
||||
}
|
||||
|
||||
export async function acpReadProviderConfig(providerId: string) {
|
||||
const client = await getAcpClient();
|
||||
const { fields } = await client.goose.providersConfigRead_unstable({ providerId });
|
||||
return fields;
|
||||
}
|
||||
|
||||
export async function acpDeleteProviderConfig(providerId: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersConfigDelete_unstable({ providerId });
|
||||
}
|
||||
|
||||
export async function acpSaveProviderConfig(
|
||||
providerId: string,
|
||||
fields: { key: string; value: string }[]
|
||||
): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersConfigSave_unstable({ providerId, fields });
|
||||
}
|
||||
|
||||
export async function acpAuthenticateProvider(providerId: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersConfigAuthenticate_unstable({ providerId });
|
||||
}
|
||||
|
||||
export async function acpListProviderSecrets(): Promise<ProviderSecretDto[]> {
|
||||
const client = await getAcpClient();
|
||||
const { secrets } = await client.goose.providersSecretsList_unstable({});
|
||||
return secrets;
|
||||
}
|
||||
|
||||
export async function acpDeleteProviderSecret(id: string): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.providersSecretsDelete_unstable({ id });
|
||||
}
|
||||
|
||||
export async function acpGetCanonicalModelInfo(
|
||||
provider: string,
|
||||
model: string
|
||||
): Promise<CanonicalModelInfoDto | null> {
|
||||
const client = await getAcpClient();
|
||||
const { modelInfo } = await client.goose.providersCanonicalModelInfo_unstable({
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
return modelInfo ?? null;
|
||||
}
|
||||
|
||||
export async function acpReadDefaults(): Promise<{
|
||||
providerId: string | null;
|
||||
modelId: string | null;
|
||||
}> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.defaultsRead_unstable({});
|
||||
return {
|
||||
providerId: response.providerId ?? null,
|
||||
modelId: response.modelId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acpSaveDefaults(providerId: string, modelId?: string | null): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null });
|
||||
}
|
||||
|
||||
export async function acpClearDefaults(): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.defaultsClear_unstable({});
|
||||
}
|
||||
|
||||
export async function acpReadThinkingEffort(): Promise<ThinkingEffort | null> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.preferencesRead_unstable({ keys: ['gooseThinkingEffort'] });
|
||||
const value = response.values.find((v) => v.key === 'gooseThinkingEffort')?.value;
|
||||
return typeof value === 'string' ? (value as ThinkingEffort) : null;
|
||||
}
|
||||
|
||||
export async function acpSaveThinkingEffort(effort: ThinkingEffort): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.preferencesSave_unstable({
|
||||
values: [{ key: 'gooseThinkingEffort', value: effort }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the provider (and model) for an active session via ACP config options.
|
||||
*
|
||||
* Changing the provider on the server resets the session's model, so the model
|
||||
* is applied as a follow-up step when supplied.
|
||||
*/
|
||||
export async function acpSetSessionProviderModel(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId?: string | null
|
||||
): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.setSessionConfigOption({ sessionId, configId: 'provider', value: providerId });
|
||||
if (modelId) {
|
||||
await client.setSessionConfigOption({ sessionId, configId: 'model', value: modelId });
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2719,32 +2719,6 @@ export type GetProviderModelsResponses = {
|
||||
|
||||
export type GetProviderModelsResponse = GetProviderModelsResponses[keyof GetProviderModelsResponses];
|
||||
|
||||
export type ConfigureProviderOauthData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Provider name
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/config/providers/{name}/oauth';
|
||||
};
|
||||
|
||||
export type ConfigureProviderOauthErrors = {
|
||||
/**
|
||||
* OAuth configuration failed
|
||||
*/
|
||||
400: unknown;
|
||||
};
|
||||
|
||||
export type ConfigureProviderOauthResponses = {
|
||||
/**
|
||||
* OAuth configuration completed
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type ReadConfigData = {
|
||||
body: ConfigKeyQuery;
|
||||
path?: never;
|
||||
|
||||
@@ -14,8 +14,8 @@ import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
|
||||
import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection';
|
||||
import { cn } from '../utils';
|
||||
import { AlertType, useAlerts } from './alerts';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { useModelAndProvider } from './ModelAndProviderContext';
|
||||
import { acpListProviderDetails } from '../acp/providers';
|
||||
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
|
||||
import { useAudioRecorder } from '../hooks/useAudioRecorder';
|
||||
import { toastError } from '../toasts';
|
||||
@@ -286,7 +286,6 @@ export default function ChatInput({
|
||||
null
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
const intl = useIntl();
|
||||
const { getProviders } = useConfig();
|
||||
const {
|
||||
getCurrentModelAndProvider,
|
||||
currentModel: configModel,
|
||||
@@ -609,14 +608,14 @@ export default function ChatInput({
|
||||
|
||||
// Priority 2: Check canonical model info (source of truth)
|
||||
const canonicalInfo = await fetchCanonicalModelInfo(provider, model);
|
||||
if (canonicalInfo?.context_limit) {
|
||||
setTokenLimit(canonicalInfo.context_limit);
|
||||
if (canonicalInfo?.contextLimit) {
|
||||
setTokenLimit(canonicalInfo.contextLimit);
|
||||
setIsTokenLimitLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: Fall back to provider metadata known_models (may be outdated)
|
||||
const providers = await getProviders(true);
|
||||
const providers = await acpListProviderDetails();
|
||||
const currentProvider = providers.find((p) => p.name === provider);
|
||||
if (currentProvider?.metadata?.known_models) {
|
||||
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { readAllConfig, readConfig, removeConfig, upsertConfig, providers } from '../api';
|
||||
import { readAllConfig, readConfig, removeConfig, upsertConfig } from '../api';
|
||||
import { acpListProviderDetails } from '../acp/providers';
|
||||
import {
|
||||
getConfiguredExtensions,
|
||||
addConfigExtension,
|
||||
@@ -150,8 +151,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
const getProviders = useCallback(async (forceRefresh = false): Promise<ProviderDetails[]> => {
|
||||
if (forceRefresh || providersListRef.current.length === 0) {
|
||||
try {
|
||||
const response = await providers();
|
||||
const providersData = response.data || [];
|
||||
const providersData = await acpListProviderDetails();
|
||||
providersListRef.current = providersData;
|
||||
setProvidersList(providersData);
|
||||
return providersData;
|
||||
@@ -172,8 +172,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
|
||||
// Load providers
|
||||
try {
|
||||
const providersResponse = await providers();
|
||||
const providersData = providersResponse.data || [];
|
||||
const providersData = await acpListProviderDetails();
|
||||
providersListRef.current = providersData;
|
||||
setProvidersList(providersData);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { toastError, toastSuccess } from '../toasts';
|
||||
import Model, { getProviderMetadata } from './settings/models/modelInterface';
|
||||
import { ProviderMetadata, setConfigProvider, updateAgentProvider } from '../api';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { ProviderMetadata } from '../api';
|
||||
import { acpReadDefaults, acpSaveDefaults, acpSetSessionProviderModel } from '../acp/providers';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
import {
|
||||
getModelDisplayName,
|
||||
@@ -60,7 +60,6 @@ export { i18n as modelAndProviderMessages };
|
||||
export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> = ({ children }) => {
|
||||
const [currentModel, setCurrentModel] = useState<string | null>(null);
|
||||
const [currentProvider, setCurrentProvider] = useState<string | null>(null);
|
||||
const { read, getProviders } = useConfig();
|
||||
const intl = useIntl();
|
||||
|
||||
const changeModel = useCallback(
|
||||
@@ -71,31 +70,14 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
|
||||
try {
|
||||
if (sessionId) {
|
||||
const response = await updateAgentProvider({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
provider: providerName,
|
||||
model: modelName,
|
||||
context_limit: model.context_limit,
|
||||
request_params: model.request_params,
|
||||
},
|
||||
});
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to update agent provider: ${response.error}`);
|
||||
}
|
||||
await acpSetSessionProviderModel(sessionId, providerName, modelName);
|
||||
}
|
||||
|
||||
// Only update the global config default when there's no session
|
||||
// (i.e. changing from settings, not from within an existing chat)
|
||||
if (!sessionId) {
|
||||
phase = 'config';
|
||||
await setConfigProvider({
|
||||
body: {
|
||||
provider: providerName,
|
||||
model: modelName,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpSaveDefaults(providerName, modelName);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -132,13 +114,7 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
const model = window.appConfig.get('GOOSE_DEFAULT_MODEL') as string;
|
||||
if (provider && model) {
|
||||
try {
|
||||
await setConfigProvider({
|
||||
body: {
|
||||
provider: provider,
|
||||
model: model,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpSaveDefaults(provider, model);
|
||||
} catch (error) {
|
||||
console.error('[getFallbackModelAndProvider] Failed to write to config', error);
|
||||
}
|
||||
@@ -147,22 +123,22 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
}, []);
|
||||
|
||||
const getCurrentModelAndProvider = useCallback(async () => {
|
||||
let model: string;
|
||||
let provider: string;
|
||||
let model: string | null;
|
||||
let provider: string | null;
|
||||
|
||||
// read from config
|
||||
try {
|
||||
model = (await read('GOOSE_MODEL', false)) as string;
|
||||
provider = (await read('GOOSE_PROVIDER', false)) as string;
|
||||
const defaults = await acpReadDefaults();
|
||||
model = defaults.modelId;
|
||||
provider = defaults.providerId;
|
||||
} catch {
|
||||
console.error(`Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config`);
|
||||
throw new Error('Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config');
|
||||
console.error(`Failed to read default model or provider`);
|
||||
throw new Error('Failed to read default model or provider');
|
||||
}
|
||||
if (!model || !provider) {
|
||||
return getFallbackModelAndProvider();
|
||||
}
|
||||
return { model: model, provider: provider };
|
||||
}, [read, getFallbackModelAndProvider]);
|
||||
}, [getFallbackModelAndProvider]);
|
||||
|
||||
const getCurrentModelAndProviderForDisplay = useCallback(async () => {
|
||||
const modelProvider = await getCurrentModelAndProvider();
|
||||
@@ -173,28 +149,28 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
let metadata: ProviderMetadata;
|
||||
|
||||
try {
|
||||
metadata = await getProviderMetadata(String(gooseProvider), getProviders);
|
||||
metadata = await getProviderMetadata(String(gooseProvider));
|
||||
} catch {
|
||||
return { model: gooseModel, provider: gooseProvider };
|
||||
}
|
||||
const providerDisplayName = metadata.display_name;
|
||||
|
||||
return { model: gooseModel, provider: providerDisplayName };
|
||||
}, [getCurrentModelAndProvider, getProviders]);
|
||||
}, [getCurrentModelAndProvider]);
|
||||
|
||||
const getCurrentModelDisplayName = useCallback(async () => {
|
||||
try {
|
||||
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
|
||||
return getModelDisplayName(currentModelName);
|
||||
const { modelId } = await acpReadDefaults();
|
||||
return getModelDisplayName(modelId ?? '');
|
||||
} catch {
|
||||
return intl.formatMessage(i18n.selectModel);
|
||||
}
|
||||
}, [read, intl]);
|
||||
}, [intl]);
|
||||
|
||||
const getCurrentProviderDisplayName = useCallback(async () => {
|
||||
try {
|
||||
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
|
||||
const providerDisplayName = getProviderDisplayName(currentModelName);
|
||||
const { modelId } = await acpReadDefaults();
|
||||
const providerDisplayName = getProviderDisplayName(modelId ?? '');
|
||||
if (providerDisplayName) {
|
||||
return providerDisplayName;
|
||||
}
|
||||
@@ -204,7 +180,7 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [read, getCurrentModelAndProviderForDisplay]);
|
||||
}, [getCurrentModelAndProviderForDisplay]);
|
||||
|
||||
const refreshCurrentModelAndProvider = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CoinIcon } from '../icons';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { fetchCanonicalModelInfo } from '../../utils/canonical';
|
||||
import type { ModelInfoData } from '../../api';
|
||||
import { fetchCanonicalModelInfo, type CanonicalModelInfo } from '../../utils/canonical';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
@@ -40,7 +39,7 @@ export function CostTracker({
|
||||
provider: currentProvider,
|
||||
}: CostTrackerProps) {
|
||||
const intl = useIntl();
|
||||
const [costInfo, setCostInfo] = useState<ModelInfoData | null>(null);
|
||||
const [costInfo, setCostInfo] = useState<CanonicalModelInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPricing, setShowPricing] = useState(true);
|
||||
const [pricingFailed, setPricingFailed] = useState(false);
|
||||
@@ -118,7 +117,7 @@ export function CostTracker({
|
||||
if (
|
||||
accumulatedCost == null &&
|
||||
(!costInfo ||
|
||||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined))
|
||||
(costInfo.inputTokenCost === undefined && costInfo.outputTokenCost === undefined))
|
||||
) {
|
||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
||||
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
||||
@@ -170,14 +169,14 @@ export function CostTracker({
|
||||
return intl.formatMessage(i18n.totalSessionCost, { cost: `${currency}${totalCost.toFixed(4)}` })
|
||||
+ `\n` + intl.formatMessage(i18n.inputOutputTooltip, {
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
inputCost: `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`,
|
||||
inputCost: `${currency}${((inputTokens * (costInfo?.inputTokenCost || 0)) / 1_000_000).toFixed(6)}`,
|
||||
outputTokens: outputTokens.toLocaleString(),
|
||||
outputCost: `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`,
|
||||
outputCost: `${currency}${((outputTokens * (costInfo?.outputTokenCost || 0)) / 1_000_000).toFixed(6)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const inputCostStr = `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
const outputCostStr = `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
const inputCostStr = `${currency}${((inputTokens * (costInfo?.inputTokenCost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
const outputCostStr = `${currency}${((outputTokens * (costInfo?.outputTokenCost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
return intl.formatMessage(i18n.inputOutputTooltip, {
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
inputCost: inputCostStr,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { acpListProviderDetails, acpReadDefaults, acpSaveDefaults } from '../../acp/providers';
|
||||
import { Goose } from '../icons';
|
||||
import { Button } from '../ui/button';
|
||||
import ProviderSelector from './ProviderSelector';
|
||||
@@ -47,7 +48,7 @@ interface OnboardingGuardProps {
|
||||
export default function OnboardingGuard({ children }: OnboardingGuardProps) {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { read, upsert, getProviders } = useConfig();
|
||||
const { upsert } = useConfig();
|
||||
const { getFallbackModelAndProvider, refreshCurrentModelAndProvider } = useModelAndProvider();
|
||||
|
||||
const [isCheckingProvider, setIsCheckingProvider] = useState(true);
|
||||
@@ -66,7 +67,7 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
|
||||
setCheckProviderError(false);
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const provider = (await read('GOOSE_PROVIDER', false, { throwOnError: true })) as string | null;
|
||||
const { providerId: provider } = await acpReadDefaults();
|
||||
if (provider?.trim()) {
|
||||
setHasProvider(true);
|
||||
setIsCheckingProvider(false);
|
||||
@@ -75,8 +76,8 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
|
||||
|
||||
const fallback = await getFallbackModelAndProvider();
|
||||
if (fallback.provider?.trim() && fallback.model?.trim()) {
|
||||
const configuredProvider = (await read('GOOSE_PROVIDER', false)) as string | null;
|
||||
const configuredModel = (await read('GOOSE_MODEL', false)) as string | null;
|
||||
const { providerId: configuredProvider, modelId: configuredModel } =
|
||||
await acpReadDefaults();
|
||||
if (configuredProvider?.trim() && configuredModel?.trim()) {
|
||||
await refreshCurrentModelAndProvider();
|
||||
setHasProvider(true);
|
||||
@@ -113,16 +114,11 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
|
||||
|
||||
const handleConfigured = async (providerName: string, modelId?: string) => {
|
||||
trackOnboardingProviderSelected({ provider: providerName });
|
||||
await upsert('GOOSE_PROVIDER', providerName, false);
|
||||
const providers = await getProviders(true);
|
||||
const providers = await acpListProviderDetails();
|
||||
const matchedProvider = providers.find((p) => p.name === providerName);
|
||||
if (modelId) {
|
||||
await upsert('GOOSE_MODEL', modelId, false);
|
||||
setConfiguredModel(modelId);
|
||||
} else if (matchedProvider) {
|
||||
await upsert('GOOSE_MODEL', matchedProvider.metadata.default_model, false);
|
||||
setConfiguredModel(matchedProvider.metadata.default_model);
|
||||
}
|
||||
const resolvedModel = modelId ?? matchedProvider?.metadata.default_model ?? null;
|
||||
await acpSaveDefaults(providerName, resolvedModel);
|
||||
setConfiguredModel(resolvedModel);
|
||||
await refreshCurrentModelAndProvider();
|
||||
setConfiguredProvider(providerName);
|
||||
setConfiguredProviderDisplayName(matchedProvider?.metadata.display_name || providerName);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { configureProviderOauth, ProviderDetails } from '../../api';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import { ProviderDetails } from '../../api';
|
||||
import { acpAuthenticateProvider } from '../../acp/providers';
|
||||
import DefaultProviderSetupForm, {
|
||||
ConfigInput,
|
||||
} from '../settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm';
|
||||
@@ -78,10 +78,7 @@ function OAuthForm({
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: provider.name },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpAuthenticateProvider(provider.name);
|
||||
onConfigured(provider.name);
|
||||
} catch (err) {
|
||||
onError(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -101,7 +98,9 @@ function OAuthForm({
|
||||
size="lg"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
{isLoading ? intl.formatMessage(i18n.signingIn) : intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })}
|
||||
{isLoading
|
||||
? intl.formatMessage(i18n.signingIn)
|
||||
: intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })}
|
||||
</Button>
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
{isDeviceCodeFlow
|
||||
@@ -122,7 +121,6 @@ function ApiKeyForm({
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const { upsert } = useConfig();
|
||||
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -158,7 +156,7 @@ function ApiKeyForm({
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await providerConfigSubmitHandler(upsert, provider, toSubmit);
|
||||
await providerConfigSubmitHandler(provider, toSubmit);
|
||||
onConfigured(provider.name);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ProviderDetails, UpdateCustomProviderRequest } from '../../api';
|
||||
import {
|
||||
providers as fetchProviders,
|
||||
createCustomProvider,
|
||||
ProviderDetails,
|
||||
UpdateCustomProviderRequest,
|
||||
} from '../../api';
|
||||
acpCreateCustomProviderFromRequest,
|
||||
acpListProviderDetails,
|
||||
} from '../../acp/providers';
|
||||
import { Select } from '../ui/Select';
|
||||
import ProviderConfigForm from './ProviderConfigForm';
|
||||
import FreeOptionCards from './FreeOptionCards';
|
||||
@@ -73,13 +72,8 @@ export default function ProviderSelector({
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetchProviders({ throwOnError: true });
|
||||
if (response.data) {
|
||||
const list = Array.isArray(response.data)
|
||||
? response.data
|
||||
: (response.data as { providers: ProviderDetails[] }).providers || [];
|
||||
setProviderList(list);
|
||||
}
|
||||
const list = await acpListProviderDetails();
|
||||
setProviderList(list);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err);
|
||||
}
|
||||
@@ -127,10 +121,10 @@ export default function ProviderSelector({
|
||||
};
|
||||
|
||||
const handleCreateCustomProvider = async (data: UpdateCustomProviderRequest) => {
|
||||
const result = await createCustomProvider({ body: data, throwOnError: true });
|
||||
const result = await acpCreateCustomProviderFromRequest(data);
|
||||
setShowCustomModal(false);
|
||||
if (result.data?.provider_name) {
|
||||
onConfigured(result.data.provider_name);
|
||||
if (result.provider_name) {
|
||||
onConfigured(result.provider_name);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Input } from '../../ui/input';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { acpListProviderDetails } from '../../../acp/providers';
|
||||
import { fetchModelsForProviders } from '../../settings/models/modelInterface';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
|
||||
@@ -70,7 +70,6 @@ export const RecipeModelSelector = ({
|
||||
onModelChange,
|
||||
}: RecipeModelSelectorProps) => {
|
||||
const intl = useIntl();
|
||||
const { getProviders } = useConfig();
|
||||
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<
|
||||
{ options: { value: string; label: string; provider: string }[] }[]
|
||||
@@ -83,7 +82,7 @@ export const RecipeModelSelector = ({
|
||||
(async () => {
|
||||
try {
|
||||
setFetchError(null);
|
||||
const providersResponse = await getProviders(false);
|
||||
const providersResponse = await acpListProviderDetails();
|
||||
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
|
||||
|
||||
setProviderOptions([
|
||||
@@ -132,7 +131,7 @@ export const RecipeModelSelector = ({
|
||||
setLoadingModels(false);
|
||||
}
|
||||
})();
|
||||
}, [getProviders, intl]);
|
||||
}, [intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingModels && selectedModel && selectedProvider) {
|
||||
|
||||
@@ -3,23 +3,19 @@ import { render, screen, waitFor, type RenderOptions } from '@testing-library/re
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AuthSettingsSection from './AuthSettingsSection';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
acpAuthenticateProvider,
|
||||
acpDeleteProviderSecret,
|
||||
acpListProviderSecrets,
|
||||
type ProviderSecretDto,
|
||||
} from '../../../acp/providers';
|
||||
import { IntlTestWrapper } from '../../../i18n/test-utils';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
vi.mock('../../../api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../api')>('../../../api');
|
||||
return {
|
||||
...actual,
|
||||
configureProviderOauth: vi.fn(),
|
||||
listProviderSecrets: vi.fn(),
|
||||
deleteProviderSecret: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../acp/providers', () => ({
|
||||
acpAuthenticateProvider: vi.fn(),
|
||||
acpListProviderSecrets: vi.fn(),
|
||||
acpDeleteProviderSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../ModelAndProviderContext', () => ({
|
||||
useModelAndProvider: () => ({
|
||||
@@ -34,41 +30,35 @@ vi.mock('react-toastify', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedListProviderSecrets = vi.mocked(listProviderSecrets);
|
||||
const mockedDeleteProviderSecret = vi.mocked(deleteProviderSecret);
|
||||
const mockedConfigureProviderOauth = vi.mocked(configureProviderOauth);
|
||||
const mockedListProviderSecrets = vi.mocked(acpListProviderSecrets);
|
||||
const mockedDeleteProviderSecret = vi.mocked(acpDeleteProviderSecret);
|
||||
const mockedAcpAuthenticateProvider = vi.mocked(acpAuthenticateProvider);
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
const providerSecret: ProviderSecret = {
|
||||
const providerSecret: ProviderSecretDto = {
|
||||
id: 'secret_store:openai:OPENAI_API_KEY',
|
||||
provider: 'openai',
|
||||
provider_display_name: 'OpenAI',
|
||||
providerDisplayName: 'OpenAI',
|
||||
name: 'OPENAI_API_KEY',
|
||||
storage: 'secret_store',
|
||||
expires_at: null,
|
||||
expiresAt: null,
|
||||
status: 'unknown',
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: null,
|
||||
hasSecret: true,
|
||||
canDelete: true,
|
||||
canConfigure: false,
|
||||
configureProvider: null,
|
||||
};
|
||||
|
||||
const apiResult = <T,>(data: T) => ({
|
||||
data,
|
||||
request: {} as never,
|
||||
response: {} as never,
|
||||
});
|
||||
|
||||
describe('AuthSettingsSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [] }));
|
||||
mockedDeleteProviderSecret.mockResolvedValue(apiResult('ok'));
|
||||
mockedConfigureProviderOauth.mockResolvedValue(apiResult('ok'));
|
||||
mockedListProviderSecrets.mockResolvedValue([]);
|
||||
mockedDeleteProviderSecret.mockResolvedValue(undefined);
|
||||
mockedAcpAuthenticateProvider.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders an empty state when no credentials are stored', async () => {
|
||||
@@ -79,17 +69,13 @@ describe('AuthSettingsSection', () => {
|
||||
});
|
||||
|
||||
it('renders provider credentials with storage and expiry status', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...providerSecret,
|
||||
expires_at: '2027-01-01T12:00:00Z',
|
||||
status: 'valid',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
mockedListProviderSecrets.mockResolvedValue([
|
||||
{
|
||||
...providerSecret,
|
||||
expiresAt: '2027-01-01T12:00:00Z',
|
||||
status: 'valid',
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -100,7 +86,7 @@ describe('AuthSettingsSection', () => {
|
||||
});
|
||||
|
||||
it('does not render an expiry badge when expiry is unknown', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [providerSecret] }));
|
||||
mockedListProviderSecrets.mockResolvedValue([providerSecret]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -113,8 +99,8 @@ describe('AuthSettingsSection', () => {
|
||||
it('deletes a credential after confirmation and refreshes the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [providerSecret] }))
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [] }));
|
||||
.mockResolvedValueOnce([providerSecret])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -132,10 +118,7 @@ describe('AuthSettingsSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith({
|
||||
path: { id: 'secret_store:openai:OPENAI_API_KEY' },
|
||||
throwOnError: true,
|
||||
});
|
||||
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith('secret_store:openai:OPENAI_API_KEY');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential deleted');
|
||||
@@ -145,35 +128,31 @@ describe('AuthSettingsSection', () => {
|
||||
|
||||
it('configures the permanent Hugging Face credential row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const huggingFaceSecret: ProviderSecret = {
|
||||
const huggingFaceSecret: ProviderSecretDto = {
|
||||
id: 'provider_cache:huggingface',
|
||||
provider: 'huggingface',
|
||||
provider_display_name: 'Hugging Face',
|
||||
providerDisplayName: 'Hugging Face',
|
||||
name: 'OAuth token',
|
||||
storage: 'provider_cache',
|
||||
expires_at: null,
|
||||
expiresAt: null,
|
||||
status: 'unknown',
|
||||
configured: false,
|
||||
has_secret: false,
|
||||
can_delete: false,
|
||||
can_configure: true,
|
||||
configure_provider: 'huggingface',
|
||||
hasSecret: false,
|
||||
canDelete: false,
|
||||
canConfigure: true,
|
||||
configureProvider: 'huggingface',
|
||||
};
|
||||
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [huggingFaceSecret] }))
|
||||
.mockResolvedValueOnce(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...huggingFaceSecret,
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
.mockResolvedValueOnce([huggingFaceSecret])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...huggingFaceSecret,
|
||||
configured: true,
|
||||
hasSecret: true,
|
||||
canDelete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -182,10 +161,7 @@ describe('AuthSettingsSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedConfigureProviderOauth).toHaveBeenCalledWith({
|
||||
path: { name: 'huggingface' },
|
||||
throwOnError: true,
|
||||
});
|
||||
expect(mockedAcpAuthenticateProvider).toHaveBeenCalledWith('huggingface');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential configured');
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, Loader2, LogIn, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
acpAuthenticateProvider,
|
||||
acpDeleteProviderSecret,
|
||||
acpListProviderSecrets,
|
||||
type ProviderSecretDto,
|
||||
} from '../../../acp/providers';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -97,26 +97,26 @@ const i18n = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
function storageLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
function storageLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
|
||||
if (secret.storage === 'provider_cache') {
|
||||
return intl.formatMessage(i18n.storageProviderCache);
|
||||
}
|
||||
return intl.formatMessage(i18n.storageSecretStore);
|
||||
}
|
||||
|
||||
function expiryLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
if (!secret.expires_at) {
|
||||
function expiryLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
|
||||
if (!secret.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return intl.formatMessage(i18n.expiresAt, {
|
||||
date: intl.formatDate(new Date(secret.expires_at), {
|
||||
date: intl.formatDate(new Date(secret.expiresAt), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function expiryClass(secret: ProviderSecret) {
|
||||
function expiryClass(secret: ProviderSecretDto) {
|
||||
if (secret.status === 'expired') {
|
||||
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
|
||||
}
|
||||
@@ -126,17 +126,17 @@ function expiryClass(secret: ProviderSecret) {
|
||||
export default function AuthSettingsSection() {
|
||||
const intl = useIntl();
|
||||
const { currentProvider } = useModelAndProvider();
|
||||
const [secrets, setSecrets] = useState<ProviderSecret[]>([]);
|
||||
const [secrets, setSecrets] = useState<ProviderSecretDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||
const [secretToDelete, setSecretToDelete] = useState<ProviderSecret | null>(null);
|
||||
const [secretToDelete, setSecretToDelete] = useState<ProviderSecretDto | null>(null);
|
||||
|
||||
const loadSecrets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
setSecrets(response.data?.secrets ?? []);
|
||||
const secrets = await acpListProviderSecrets();
|
||||
setSecrets(secrets);
|
||||
} catch {
|
||||
toast.error(intl.formatMessage(i18n.failedToLoad));
|
||||
setSecrets([]);
|
||||
@@ -156,10 +156,7 @@ export default function AuthSettingsSection() {
|
||||
|
||||
setDeletingId(secretToDelete.id);
|
||||
try {
|
||||
await deleteProviderSecret({
|
||||
path: { id: secretToDelete.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpDeleteProviderSecret(secretToDelete.id);
|
||||
toast.success(intl.formatMessage(i18n.deleted));
|
||||
setSecretToDelete(null);
|
||||
await loadSecrets();
|
||||
@@ -174,17 +171,14 @@ export default function AuthSettingsSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const configureSecret = async (secret: ProviderSecret) => {
|
||||
if (!secret.configure_provider) {
|
||||
const configureSecret = async (secret: ProviderSecretDto) => {
|
||||
if (!secret.configureProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfiguringId(secret.id);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: secret.configure_provider },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpAuthenticateProvider(secret.configureProvider);
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
await loadSecrets();
|
||||
} catch (error) {
|
||||
@@ -229,7 +223,7 @@ export default function AuthSettingsSection() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
{secret.provider_display_name}
|
||||
{secret.providerDisplayName}
|
||||
</h3>
|
||||
<span className="rounded border border-border-primary bg-background-secondary px-2 py-0.5 text-xs text-text-secondary">
|
||||
{storageLabel(secret, intl)}
|
||||
@@ -247,7 +241,7 @@ export default function AuthSettingsSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
{secret.can_configure && secret.configure_provider && (
|
||||
{secret.canConfigure && secret.configureProvider && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -257,17 +251,17 @@ export default function AuthSettingsSection() {
|
||||
>
|
||||
{configuringId === secret.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : secret.has_secret || secret.configured ? (
|
||||
) : secret.hasSecret || secret.configured ? (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{secret.has_secret || secret.configured
|
||||
{secret.hasSecret || secret.configured
|
||||
? intl.formatMessage(i18n.reauthorize)
|
||||
: intl.formatMessage(i18n.signIn)}
|
||||
</Button>
|
||||
)}
|
||||
{secret.can_delete && (
|
||||
{secret.canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -300,7 +294,7 @@ export default function AuthSettingsSection() {
|
||||
secretToDelete
|
||||
? intl.formatMessage(i18n.deleteMessage, {
|
||||
name: secretToDelete.name,
|
||||
provider: secretToDelete.provider_display_name,
|
||||
provider: secretToDelete.providerDisplayName,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2, LogIn } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { configureProviderOauth, listProviderSecrets } from '../../../api';
|
||||
import { acpAuthenticateProvider, acpListProviderSecrets } from '../../../acp/providers';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -51,11 +51,11 @@ export default function HuggingFaceSignInPrompt({
|
||||
const loadStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
const huggingFaceSecret = response.data?.secrets.find(
|
||||
const secrets = await acpListProviderSecrets();
|
||||
const huggingFaceSecret = secrets.find(
|
||||
(secret) => secret.id === HUGGINGFACE_OAUTH_SECRET_ID
|
||||
);
|
||||
setLoggedIn(Boolean(huggingFaceSecret?.has_secret && huggingFaceSecret.status !== 'expired'));
|
||||
setLoggedIn(Boolean(huggingFaceSecret?.hasSecret && huggingFaceSecret.status !== 'expired'));
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
} finally {
|
||||
@@ -70,10 +70,7 @@ export default function HuggingFaceSignInPrompt({
|
||||
const signIn = async () => {
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: HUGGINGFACE_PROVIDER },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpAuthenticateProvider(HUGGINGFACE_PROVIDER);
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
setLoggedIn(true);
|
||||
onSignedIn?.();
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
getLocalModelDownloadProgress,
|
||||
cancelLocalModelDownload,
|
||||
deleteLocalModel,
|
||||
setConfigProvider,
|
||||
type DownloadProgress,
|
||||
type DownloadModelRequest,
|
||||
type LocalModelResponse,
|
||||
@@ -28,6 +27,7 @@ import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
|
||||
import { ModelSettingsPanel } from './ModelSettingsPanel';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
import HuggingFaceSignInPrompt from '../auth/HuggingFaceSignInPrompt';
|
||||
import { acpSaveDefaults } from '../../../acp/providers';
|
||||
|
||||
const i18n = defineMessages({
|
||||
title: {
|
||||
@@ -247,10 +247,7 @@ export const LocalInferenceSettings = () => {
|
||||
|
||||
const selectModel = async (modelId: string) => {
|
||||
try {
|
||||
await setConfigProvider({
|
||||
body: { provider: 'local', model: modelId },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpSaveDefaults('local', modelId);
|
||||
await refreshCurrentModelAndProvider();
|
||||
} catch (error) {
|
||||
console.error('Failed to select model:', error);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View } from '../../../utils/navigationUtils';
|
||||
import ModelSettingsButtons from './subcomponents/ModelSettingsButtons';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import {
|
||||
modelAndProviderMessages,
|
||||
useModelAndProvider,
|
||||
} from '../../ModelAndProviderContext';
|
||||
import { acpListProviderDetails, acpReadDefaults } from '../../../acp/providers';
|
||||
import { modelAndProviderMessages, useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import { toastError } from '../../../toasts';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
@@ -32,7 +29,6 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
|
||||
const [provider, setProvider] = useState<string | null>(null);
|
||||
const [displayModelName, setDisplayModelName] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const { read, getProviders } = useConfig();
|
||||
const {
|
||||
getCurrentModelDisplayName,
|
||||
getCurrentProviderDisplayName,
|
||||
@@ -54,8 +50,8 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
|
||||
setProvider(providerDisplayName);
|
||||
} else {
|
||||
// Fallback to original provider lookup
|
||||
const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string;
|
||||
const providers = await getProviders(true);
|
||||
const { providerId: gooseProvider } = await acpReadDefaults();
|
||||
const providers = await acpListProviderDetails();
|
||||
const providerDetailsList = providers.filter((provider) => provider.name === gooseProvider);
|
||||
|
||||
if (providerDetailsList.length != 1) {
|
||||
@@ -74,7 +70,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [read, getProviders, getCurrentModelDisplayName, getCurrentProviderDisplayName, intl]);
|
||||
}, [getCurrentModelDisplayName, getCurrentProviderDisplayName, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadModelData();
|
||||
@@ -117,9 +113,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) {
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="">{intl.formatMessage(i18n.resetTitle)}</CardTitle>
|
||||
<CardDescription>
|
||||
{intl.formatMessage(i18n.resetDescription)}
|
||||
</CardDescription>
|
||||
<CardDescription>{intl.formatMessage(i18n.resetDescription)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2">
|
||||
<ResetProviderSection setView={setView} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../../ui/dropdown-menu';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import { getProviderMetadata } from '../modelInterface';
|
||||
import { getModelDisplayName } from '../predefinedModelsUtils';
|
||||
|
||||
@@ -77,9 +76,10 @@ export default function ModelsBottomBar({
|
||||
const currentProvider = sessionProvider ?? configProvider;
|
||||
|
||||
const intl = useIntl();
|
||||
const { getProviders } = useConfig();
|
||||
const [displayProvider, setDisplayProvider] = useState<string | null>(null);
|
||||
const [displayModelName, setDisplayModelName] = useState<string>(intl.formatMessage(i18n.selectModel));
|
||||
const [displayModelName, setDisplayModelName] = useState<string>(
|
||||
intl.formatMessage(i18n.selectModel)
|
||||
);
|
||||
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
|
||||
const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false);
|
||||
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
|
||||
@@ -91,10 +91,10 @@ export default function ModelsBottomBar({
|
||||
const resolvedModel = latestInference?.resolvedModel ?? null;
|
||||
const shouldShowResolvedModel = Boolean(
|
||||
!isModelLoading &&
|
||||
resolvedModel &&
|
||||
latestInference?.provider === currentProvider &&
|
||||
latestInference?.requestedModel === currentModel &&
|
||||
resolvedModel !== currentModel
|
||||
resolvedModel &&
|
||||
latestInference?.provider === currentProvider &&
|
||||
latestInference?.requestedModel === currentModel &&
|
||||
resolvedModel !== currentModel
|
||||
);
|
||||
const loadingModelLabel = intl.formatMessage(i18n.loadingModel);
|
||||
const triggerLabel = isModelLoading ? loadingModelLabel : displayModel;
|
||||
@@ -102,21 +102,21 @@ export default function ModelsBottomBar({
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProvider) return;
|
||||
getProviderMetadata(currentProvider, getProviders)
|
||||
getProviderMetadata(currentProvider)
|
||||
.then((metadata) => {
|
||||
setDisplayProvider(metadata.display_name || currentProvider);
|
||||
})
|
||||
.catch(() => {
|
||||
setDisplayProvider(currentProvider);
|
||||
});
|
||||
}, [currentProvider, currentModel, getProviders]);
|
||||
}, [currentProvider, currentModel]);
|
||||
|
||||
// Fetch provider default model when provider changes and no current model
|
||||
useEffect(() => {
|
||||
if (currentProvider && !currentModel) {
|
||||
(async () => {
|
||||
try {
|
||||
const metadata = await getProviderMetadata(currentProvider, getProviders);
|
||||
const metadata = await getProviderMetadata(currentProvider);
|
||||
setProviderDefaultModel(metadata.default_model);
|
||||
} catch (error) {
|
||||
console.error('Failed to get provider default model:', error);
|
||||
@@ -126,7 +126,7 @@ export default function ModelsBottomBar({
|
||||
} else if (currentModel) {
|
||||
setProviderDefaultModel(null);
|
||||
}
|
||||
}, [currentProvider, currentModel, getProviders]);
|
||||
}, [currentProvider, currentModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentModel) return;
|
||||
@@ -162,14 +162,18 @@ export default function ModelsBottomBar({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="center" className="w-64 text-sm">
|
||||
<h6 className="text-xs text-text-primary mt-2 ml-2">{intl.formatMessage(i18n.currentModel)}</h6>
|
||||
<h6 className="text-xs text-text-primary mt-2 ml-2">
|
||||
{intl.formatMessage(i18n.currentModel)}
|
||||
</h6>
|
||||
<p className="flex items-center justify-between text-sm mx-2 pb-2 border-b mb-2">
|
||||
{menuModelLabel}
|
||||
{!isModelLoading && displayProvider && ` — ${displayProvider}`}
|
||||
</p>
|
||||
{shouldShowResolvedModel && resolvedDisplayModelName && (
|
||||
<div className="mx-2 pb-2 border-b mb-2">
|
||||
<h6 className="text-xs text-text-primary">{intl.formatMessage(i18n.resolvedModel)}</h6>
|
||||
<h6 className="text-xs text-text-primary">
|
||||
{intl.formatMessage(i18n.resolvedModel)}
|
||||
</h6>
|
||||
<p className="text-xs text-text-primary truncate" title={resolvedModel ?? undefined}>
|
||||
{resolvedDisplayModelName}
|
||||
</p>
|
||||
@@ -204,7 +208,9 @@ export default function ModelsBottomBar({
|
||||
<div className="bg-background-primary border border-border-primary rounded-lg shadow-lg w-[480px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
|
||||
<h3 className="text-sm font-medium text-text-default">
|
||||
{intl.formatMessage(i18n.localModelSettingsTitle, { modelName: getModelDisplayName(currentModel) })}
|
||||
{intl.formatMessage(i18n.localModelSettingsTitle, {
|
||||
modelName: getModelDisplayName(currentModel),
|
||||
})}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsLocalModelSettingsOpen(false)}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
ProviderDetails,
|
||||
ThinkingEffort,
|
||||
getProviderModelInfo,
|
||||
getProviderModels,
|
||||
listLocalModels,
|
||||
} from '../../../api';
|
||||
import { ProviderDetails, ThinkingEffort, listLocalModels } from '../../../api';
|
||||
import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers';
|
||||
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
|
||||
|
||||
export default interface Model {
|
||||
@@ -38,11 +33,8 @@ export function createModelStruct(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProviderMetadata(
|
||||
providerName: string,
|
||||
getProvidersFunc: (b: boolean) => Promise<ProviderDetails[]>
|
||||
) {
|
||||
const providers = await getProvidersFunc(false);
|
||||
export async function getProviderMetadata(providerName: string) {
|
||||
const providers = await acpListProviderDetails();
|
||||
const matches = providers.find((providerMatch) => providerMatch.name === providerName);
|
||||
if (!matches) {
|
||||
throw Error(`No match for provider: ${providerName}`);
|
||||
@@ -72,16 +64,13 @@ export async function fetchModelsForProviders(
|
||||
return { provider: p, models: downloadedModels, error: null, warning: null };
|
||||
}
|
||||
|
||||
const response = await getProviderModels({
|
||||
path: { name: p.name },
|
||||
throwOnError: true,
|
||||
});
|
||||
const models = (response.data || []).map(
|
||||
const providerModels = await acpListProviderModels(p.name);
|
||||
const models = providerModels.map(
|
||||
(m) =>
|
||||
({
|
||||
name: m.name,
|
||||
name: m.id,
|
||||
provider: p.name,
|
||||
context_limit: m.context_limit,
|
||||
context_limit: m.contextLimit ?? undefined,
|
||||
reasoning: m.reasoning ?? undefined,
|
||||
}) as Model
|
||||
);
|
||||
@@ -129,11 +118,9 @@ export async function fetchModelReasoning(
|
||||
fallback?: boolean
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
const response = await getProviderModelInfo({
|
||||
path: { name: provider },
|
||||
body: { model },
|
||||
});
|
||||
return response.data?.reasoning ?? fallback ?? null;
|
||||
const models = await acpListProviderModels(provider);
|
||||
const match = models.find((m) => m.id === model);
|
||||
return match?.reasoning ?? fallback ?? null;
|
||||
} catch {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import { Button } from '../../../ui/button';
|
||||
import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { Select } from '../../../ui/Select';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import {
|
||||
acpListProviderDetails,
|
||||
acpReadThinkingEffort,
|
||||
acpSaveThinkingEffort,
|
||||
} from '../../../../acp/providers';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import type { View } from '../../../../utils/navigationUtils';
|
||||
import Model, {
|
||||
@@ -109,7 +113,8 @@ const i18n = defineMessages({
|
||||
},
|
||||
localModelsDescription: {
|
||||
id: 'switchModelModal.localModelsDescription',
|
||||
defaultMessage: 'To use local inference, you need to download a model to your computer first. Go to Settings → Models to manage local models.',
|
||||
defaultMessage:
|
||||
'To use local inference, you need to download a model to your computer first. Go to Settings → Models to manage local models.',
|
||||
},
|
||||
goToSettings: {
|
||||
id: 'switchModelModal.goToSettings',
|
||||
@@ -263,7 +268,6 @@ export const SwitchModelModal = ({
|
||||
{ value: 'max', label: intl.formatMessage(i18n.claudeEffortMax) },
|
||||
];
|
||||
|
||||
const { getProviders, read, upsert } = useConfig();
|
||||
const {
|
||||
changeModel,
|
||||
currentModel: configModel,
|
||||
@@ -327,13 +331,13 @@ export const SwitchModelModal = ({
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const effort = (await read('GOOSE_THINKING_EFFORT', false)) as ThinkingEffort;
|
||||
const effort = await acpReadThinkingEffort();
|
||||
if (effort) setThinkingEffort(effort);
|
||||
} catch (e) {
|
||||
console.warn('Could not read GOOSE_THINKING_EFFORT, using default:', e);
|
||||
console.warn('Could not read thinking effort, using default:', e);
|
||||
}
|
||||
})();
|
||||
}, [read]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || !model) return;
|
||||
@@ -399,7 +403,7 @@ export const SwitchModelModal = ({
|
||||
if (usePredefinedModels && selectedPredefinedModel) {
|
||||
modelObj = selectedPredefinedModel;
|
||||
} else {
|
||||
const providerMetaData = await getProviderMetadata(provider || '', getProviders);
|
||||
const providerMetaData = await getProviderMetadata(provider || '');
|
||||
const providerDisplayName = providerMetaData.display_name;
|
||||
modelObj = {
|
||||
name: model,
|
||||
@@ -418,7 +422,7 @@ export const SwitchModelModal = ({
|
||||
...modelObj,
|
||||
request_params: { ...modelObj.request_params, thinking_effort: effort },
|
||||
};
|
||||
upsert('GOOSE_THINKING_EFFORT', effort, false).catch(console.warn);
|
||||
acpSaveThinkingEffort(effort).catch(console.warn);
|
||||
}
|
||||
|
||||
const success = await changeModel(sessionId, modelObj);
|
||||
@@ -477,10 +481,7 @@ export const SwitchModelModal = ({
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Force a refresh so the list reflects providers (un)configured since the
|
||||
// cache was populated; otherwise a provider deleted in Settings still
|
||||
// shows up here (#9364).
|
||||
const providersResponse = await getProviders(true);
|
||||
const providersResponse = await acpListProviderDetails();
|
||||
const activeProviders = providersResponse.filter((provider) => provider.is_configured);
|
||||
setActiveProvidersList(activeProviders);
|
||||
setProviderOptions([
|
||||
@@ -497,7 +498,7 @@ export const SwitchModelModal = ({
|
||||
console.error('Failed to query providers:', error);
|
||||
}
|
||||
})();
|
||||
}, [getProviders, usePredefinedModels, read, intl]);
|
||||
}, [usePredefinedModels, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || usePredefinedModels) return;
|
||||
@@ -617,7 +618,15 @@ export const SwitchModelModal = ({
|
||||
setModel(preferredModel);
|
||||
}
|
||||
}
|
||||
}, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel, activeProvidersList]);
|
||||
}, [
|
||||
provider,
|
||||
modelOptions,
|
||||
loadingModels,
|
||||
model,
|
||||
isCustomModel,
|
||||
userClearedModel,
|
||||
activeProvidersList,
|
||||
]);
|
||||
|
||||
const handlePredefinedModelChange = (model: Model) => {
|
||||
setSelectedPredefinedModel(model);
|
||||
@@ -732,16 +741,16 @@ export const SwitchModelModal = ({
|
||||
<Bot size={24} className="text-text-primary" />
|
||||
{titleOverride || intl.formatMessage(i18n.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{intl.formatMessage(i18n.description)}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{intl.formatMessage(i18n.description)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
{usePredefinedModels ? (
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-medium text-text-primary">{intl.formatMessage(i18n.chooseModel)}</label>
|
||||
<label className="text-sm font-medium text-text-primary">
|
||||
{intl.formatMessage(i18n.chooseModel)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
@@ -876,7 +885,9 @@ export const SwitchModelModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="text-sm text-text-secondary">{intl.formatMessage(i18n.customModelName)}</label>
|
||||
<label className="text-sm text-text-secondary">
|
||||
{intl.formatMessage(i18n.customModelName)}
|
||||
</label>
|
||||
<Input
|
||||
className="border-2 px-4 py-5"
|
||||
placeholder={intl.formatMessage(i18n.typeModelName)}
|
||||
@@ -901,7 +912,11 @@ export const SwitchModelModal = ({
|
||||
onInputChange={handleInputChange}
|
||||
value={
|
||||
loadingModels
|
||||
? { value: '', label: intl.formatMessage(i18n.loadingModels), isDisabled: true }
|
||||
? {
|
||||
value: '',
|
||||
label: intl.formatMessage(i18n.loadingModels),
|
||||
isDisabled: true,
|
||||
}
|
||||
: model
|
||||
? { value: model, label: model }
|
||||
: null
|
||||
@@ -925,7 +940,9 @@ export const SwitchModelModal = ({
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-sm text-text-secondary">{intl.formatMessage(i18n.customModelName)}</label>
|
||||
<label className="text-sm text-text-secondary">
|
||||
{intl.formatMessage(i18n.customModelName)}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setIsCustomModel(false)}
|
||||
className="text-sm text-text-secondary"
|
||||
|
||||
@@ -2,11 +2,14 @@ import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||
import { ProviderCard } from './subcomponents/ProviderCard';
|
||||
import CardContainer from './subcomponents/CardContainer';
|
||||
import ProviderConfigurationModal from './modal/ProviderConfigurationModal';
|
||||
import { ProviderDetails, UpdateCustomProviderRequest } from '../../../api';
|
||||
import type { CustomProviderConfigDto } from '@aaif/goose-sdk';
|
||||
import {
|
||||
DeclarativeProviderConfig,
|
||||
ProviderDetails,
|
||||
UpdateCustomProviderRequest,
|
||||
} from '../../../api';
|
||||
acpCreateCustomProviderFromRequest,
|
||||
acpGetCustomProvider,
|
||||
acpDeleteCustomProvider,
|
||||
acpUpdateCustomProviderFromRequest,
|
||||
} from '../../../acp/providers';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
|
||||
@@ -102,7 +105,7 @@ function ProviderCards({
|
||||
const { getCurrentModelAndProvider } = useModelAndProvider();
|
||||
const [editingProvider, setEditingProvider] = useState<{
|
||||
id: string;
|
||||
config: DeclarativeProviderConfig;
|
||||
config: CustomProviderConfigDto;
|
||||
isEditable: boolean;
|
||||
providerType: string;
|
||||
} | null>(null);
|
||||
@@ -120,14 +123,13 @@ function ProviderCards({
|
||||
const configureProviderViaModal = useCallback(
|
||||
async (provider: ProviderDetails) => {
|
||||
if (provider.provider_type === 'Custom') {
|
||||
const { getCustomProvider } = await import('../../../api');
|
||||
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
|
||||
const result = await acpGetCustomProvider(provider.name);
|
||||
|
||||
if (result.data) {
|
||||
if (result) {
|
||||
setEditingProvider({
|
||||
id: provider.name,
|
||||
config: result.data.config,
|
||||
isEditable: result.data.is_editable,
|
||||
config: result.provider,
|
||||
isEditable: result.editable,
|
||||
providerType: provider.provider_type,
|
||||
});
|
||||
|
||||
@@ -152,12 +154,7 @@ function ProviderCards({
|
||||
async (data: UpdateCustomProviderRequest) => {
|
||||
if (!editingProvider) return;
|
||||
|
||||
const { updateCustomProvider } = await import('../../../api');
|
||||
await updateCustomProvider({
|
||||
path: { id: editingProvider.id },
|
||||
body: data,
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpUpdateCustomProviderFromRequest(editingProvider.id, data);
|
||||
const providerId = editingProvider.id;
|
||||
setShowCustomProviderModal(false);
|
||||
setEditingProvider(null);
|
||||
@@ -173,11 +170,7 @@ function ProviderCards({
|
||||
const handleDeleteCustomProvider = useCallback(async () => {
|
||||
if (!editingProvider) return;
|
||||
|
||||
const { removeCustomProvider } = await import('../../../api');
|
||||
await removeCustomProvider({
|
||||
path: { id: editingProvider.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpDeleteCustomProvider(editingProvider.id);
|
||||
setShowCustomProviderModal(false);
|
||||
setEditingProvider(null);
|
||||
setIsActiveProvider(false);
|
||||
@@ -227,9 +220,8 @@ function ProviderCards({
|
||||
|
||||
const handleCreateCustomProvider = useCallback(
|
||||
async (data: UpdateCustomProviderRequest) => {
|
||||
const { createCustomProvider } = await import('../../../api');
|
||||
const result = await createCustomProvider({ body: data, throwOnError: true });
|
||||
const providerId = result.data?.provider_name;
|
||||
const result = await acpCreateCustomProviderFromRequest(data);
|
||||
const providerId = result.provider_name;
|
||||
setShowCustomProviderModal(false);
|
||||
if (refreshProviders) {
|
||||
await refreshProviders();
|
||||
@@ -266,15 +258,15 @@ function ProviderCards({
|
||||
|
||||
const initialData = editingProvider && {
|
||||
engine: editingProvider.config.engine,
|
||||
display_name: editingProvider.config.display_name,
|
||||
api_url: editingProvider.config.base_url,
|
||||
base_path: editingProvider.config.base_path ?? undefined,
|
||||
display_name: editingProvider.config.displayName,
|
||||
api_url: editingProvider.config.apiUrl,
|
||||
base_path: editingProvider.config.basePath ?? undefined,
|
||||
api_key: '',
|
||||
models: editingProvider.config.models.map((m) => m.name),
|
||||
supports_streaming: editingProvider.config.supports_streaming ?? true,
|
||||
requires_auth: editingProvider.config.requires_auth ?? true,
|
||||
models: editingProvider.config.models ?? [],
|
||||
supports_streaming: editingProvider.config.supportsStreaming ?? true,
|
||||
requires_auth: editingProvider.config.requiresAuth ?? true,
|
||||
headers: editingProvider.config.headers ?? undefined,
|
||||
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
|
||||
catalog_provider_id: editingProvider.config.catalogProviderId ?? undefined,
|
||||
};
|
||||
|
||||
const editable = editingProvider ? editingProvider.isEditable : true;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import BackButton from '../../ui/BackButton';
|
||||
import ProviderGrid from './ProviderGrid';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { acpListProviderDetails } from '../../../acp/providers';
|
||||
import { ProviderDetails } from '../../../api';
|
||||
import { createNavigationHandler } from '../../../utils/navigationUtils';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
@@ -40,7 +40,6 @@ export default function ProviderSettings({
|
||||
onProviderLaunched,
|
||||
}: ProviderSettingsProps) {
|
||||
const intl = useIntl();
|
||||
const { getProviders } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providers, setProviders] = useState<ProviderDetails[]>([]);
|
||||
@@ -52,8 +51,7 @@ export default function ProviderSettings({
|
||||
const loadProviders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Only force refresh when explicitly requested, not on initial load
|
||||
const result = await getProviders(!initialLoadDone.current);
|
||||
const result = await acpListProviderDetails();
|
||||
if (result) {
|
||||
setProviders(result);
|
||||
initialLoadDone.current = true;
|
||||
@@ -63,7 +61,7 @@ export default function ProviderSettings({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getProviders]);
|
||||
}, []);
|
||||
|
||||
// Load providers only once when component mounts
|
||||
useEffect(() => {
|
||||
@@ -74,10 +72,10 @@ export default function ProviderSettings({
|
||||
// This function will be passed to ProviderGrid for manual refreshes after config changes
|
||||
const refreshProviders = useCallback(async () => {
|
||||
if (initialLoadDone.current) {
|
||||
const result = await getProviders(true);
|
||||
const result = await acpListProviderDetails();
|
||||
if (result) setProviders(result);
|
||||
}
|
||||
}, [getProviders]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background-primary text-text-primary">
|
||||
|
||||
@@ -14,15 +14,15 @@ import ProviderSetupActions from './subcomponents/ProviderSetupActions';
|
||||
import ProviderLogo from './subcomponents/ProviderLogo';
|
||||
import { SecureStorageNotice } from './subcomponents/SecureStorageNotice';
|
||||
import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import {
|
||||
acpAuthenticateProvider,
|
||||
acpDeleteCustomProvider,
|
||||
acpDeleteProviderConfig,
|
||||
acpSaveProviderConfig,
|
||||
} from '../../../../acp/providers';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { AlertTriangle, LogIn } from 'lucide-react';
|
||||
import {
|
||||
ProviderDetails,
|
||||
removeCustomProvider,
|
||||
configureProviderOauth,
|
||||
cleanupProviderCache,
|
||||
} from '../../../../api';
|
||||
import { ProviderDetails } from '../../../../api';
|
||||
import { Button } from '../../../../components/ui/button';
|
||||
import { errorMessage } from '../../../../utils/conversionUtils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
@@ -165,7 +165,6 @@ export default function ProviderConfigurationModal({
|
||||
}: ProviderConfigurationModalProps) {
|
||||
const intl = useIntl();
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const { upsert, remove } = useConfig();
|
||||
const { getCurrentModelAndProvider } = useModelAndProvider();
|
||||
const [configValues, setConfigValues] = useState<Record<string, ConfigInput>>({});
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
@@ -211,25 +210,20 @@ export default function ProviderConfigurationModal({
|
||||
setError(null);
|
||||
try {
|
||||
if (hasConfig) {
|
||||
const fields: { key: string; value: string }[] = [];
|
||||
for (const key of configKeys) {
|
||||
const entry = configValues[key.name];
|
||||
const value =
|
||||
entry?.value ?? (typeof entry?.serverValue === 'string' ? entry.serverValue : null);
|
||||
if (value) {
|
||||
await upsert(key.name, value, key.secret);
|
||||
fields.push({ key: key.name, value });
|
||||
}
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
await acpSaveProviderConfig(provider.name, fields);
|
||||
}
|
||||
}
|
||||
const oauthResult = await configureProviderOauth({
|
||||
path: { name: provider.name },
|
||||
});
|
||||
if (oauthResult.error) {
|
||||
const err = oauthResult.error as Record<string, unknown>;
|
||||
const errDetail = typeof oauthResult.error === 'string'
|
||||
? oauthResult.error
|
||||
: (err?.message as string) ?? (err?.detail as string) ?? JSON.stringify(oauthResult.error);
|
||||
throw new Error(errDetail);
|
||||
}
|
||||
await acpAuthenticateProvider(provider.name);
|
||||
if (onConfigured) {
|
||||
onConfigured(provider);
|
||||
} else {
|
||||
@@ -280,7 +274,7 @@ export default function ProviderConfigurationModal({
|
||||
);
|
||||
|
||||
try {
|
||||
await providerConfigSubmitHandler(upsert, provider, toSubmit);
|
||||
await providerConfigSubmitHandler(provider, toSubmit);
|
||||
if (onConfigured) {
|
||||
onConfigured(provider);
|
||||
} else {
|
||||
@@ -316,30 +310,14 @@ export default function ProviderConfigurationModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up provider-specific cache files (e.g., OAuth tokens) before removing config
|
||||
try {
|
||||
await cleanupProviderCache({ path: { name: provider.name } });
|
||||
} catch {
|
||||
// Cleanup is best-effort — proceed with deletion even if it fails
|
||||
}
|
||||
|
||||
const isCustomProvider = provider.provider_type === 'Custom';
|
||||
|
||||
if (isCustomProvider) {
|
||||
await removeCustomProvider({
|
||||
path: { id: provider.name },
|
||||
});
|
||||
await acpDeleteCustomProvider(provider.name);
|
||||
} else {
|
||||
const params = provider.metadata.config_keys;
|
||||
for (const param of params) {
|
||||
await remove(param.name, param.secret);
|
||||
}
|
||||
|
||||
const hasOAuthKey = params.some((key) => key.oauth_flow);
|
||||
if (hasOAuthKey) {
|
||||
const configuredMarker = `${provider.name}_configured`;
|
||||
await remove(configuredMarker, false);
|
||||
}
|
||||
// Deletes all config/secret fields and cleans up provider-specific cache
|
||||
// (e.g. OAuth tokens) server-side.
|
||||
await acpDeleteProviderConfig(provider.name);
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
+34
-28
@@ -3,12 +3,11 @@ import { Button } from '../../../../ui/button';
|
||||
import { Search, ExternalLink, Check } from 'lucide-react';
|
||||
import { Input } from '../../../../ui/input';
|
||||
import { Select } from '../../../../ui/Select';
|
||||
import type { ProviderTemplateCatalogEntryDto, ProviderTemplateDto } from '@aaif/goose-sdk';
|
||||
import {
|
||||
getProviderCatalog,
|
||||
getProviderCatalogTemplate,
|
||||
type ProviderCatalogEntry,
|
||||
type ProviderTemplate,
|
||||
} from '../../../../../api';
|
||||
acpGetProviderTemplate,
|
||||
acpListProviderCatalogEntries,
|
||||
} from '../../../../../acp/providers';
|
||||
import { defineMessages, useIntl } from '../../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
@@ -67,7 +66,7 @@ const i18n = defineMessages({
|
||||
});
|
||||
|
||||
interface ProviderCatalogPickerProps {
|
||||
onSelect: (template: ProviderTemplate) => void;
|
||||
onSelect: (template: ProviderTemplateDto) => void;
|
||||
onCancel: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
@@ -79,8 +78,8 @@ export default function ProviderCatalogPicker({
|
||||
}: ProviderCatalogPickerProps) {
|
||||
const intl = useIntl();
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('openai');
|
||||
const [providers, setProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [filteredProviders, setFilteredProviders] = useState<ProviderCatalogEntry[]>([]);
|
||||
const [providers, setProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
|
||||
const [filteredProviders, setFilteredProviders] = useState<ProviderTemplateCatalogEntryDto[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -103,7 +102,7 @@ export default function ProviderCatalogPicker({
|
||||
const query = searchQuery.toLowerCase();
|
||||
setFilteredProviders(
|
||||
providers.filter(
|
||||
(p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query)
|
||||
(p) => p.name.toLowerCase().includes(query) || p.providerId.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -113,10 +112,7 @@ export default function ProviderCatalogPicker({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await getProviderCatalog({
|
||||
query: { format },
|
||||
throwOnError: true,
|
||||
});
|
||||
const data = await acpListProviderCatalogEntries(format);
|
||||
setProviders(data || []);
|
||||
setFilteredProviders(data || []);
|
||||
} catch (err) {
|
||||
@@ -130,10 +126,7 @@ export default function ProviderCatalogPicker({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data: template } = await getProviderCatalogTemplate({
|
||||
path: { id: providerId },
|
||||
throwOnError: true,
|
||||
});
|
||||
const template = await acpGetProviderTemplate(providerId);
|
||||
if (template) {
|
||||
onSelect(template);
|
||||
}
|
||||
@@ -148,7 +141,9 @@ export default function ProviderCatalogPicker({
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-textStandard mb-2">{intl.formatMessage(i18n.chooseProvider)}</h3>
|
||||
<h3 className="text-lg font-semibold text-textStandard mb-2">
|
||||
{intl.formatMessage(i18n.chooseProvider)}
|
||||
</h3>
|
||||
<p className="text-sm text-textSubtle">
|
||||
{intl.formatMessage(i18n.selectFormatDescription)}
|
||||
</p>
|
||||
@@ -156,7 +151,9 @@ export default function ProviderCatalogPicker({
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.apiFormat)}</label>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">
|
||||
{intl.formatMessage(i18n.apiFormat)}
|
||||
</label>
|
||||
<Select
|
||||
options={formatOptions}
|
||||
value={formatOptions.find((opt) => opt.value === selectedFormat)}
|
||||
@@ -183,8 +180,16 @@ export default function ProviderCatalogPicker({
|
||||
</div>
|
||||
|
||||
{/* Loading/Error */}
|
||||
{loading && <div className="text-center py-8 text-textSubtle">{intl.formatMessage(i18n.loadingProviders)}</div>}
|
||||
{error && <div className="text-center py-8 text-red-500">{intl.formatMessage(i18n.errorPrefix, { error })}</div>}
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-textSubtle">
|
||||
{intl.formatMessage(i18n.loadingProviders)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
{intl.formatMessage(i18n.errorPrefix, { error })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider List */}
|
||||
{!loading && !error && (
|
||||
@@ -198,17 +203,17 @@ export default function ProviderCatalogPicker({
|
||||
) : (
|
||||
filteredProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => handleProviderSelect(provider.id)}
|
||||
key={provider.providerId}
|
||||
onClick={() => handleProviderSelect(provider.providerId)}
|
||||
className="w-full p-4 text-left border border-border rounded-lg hover:bg-surfaceHover hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-textStandard">{provider.name}</div>
|
||||
{provider.doc_url && (
|
||||
{provider.docUrl && (
|
||||
<a
|
||||
href={provider.doc_url}
|
||||
href={provider.docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -218,10 +223,11 @@ export default function ProviderCatalogPicker({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-textSubtle mt-1 break-all">{provider.api_url}</div>
|
||||
<div className="text-sm text-textSubtle mt-1 break-all">{provider.apiUrl}</div>
|
||||
<div className="text-xs text-textSubtle mt-2">
|
||||
{intl.formatMessage(i18n.modelsAvailable, { count: provider.model_count })}
|
||||
{provider.env_var && intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.env_var })}
|
||||
{intl.formatMessage(i18n.modelsAvailable, { count: provider.modelCount })}
|
||||
{provider.envVar &&
|
||||
intl.formatMessage(i18n.requiresEnvVar, { envVar: provider.envVar })}
|
||||
</div>
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
|
||||
+48
-33
@@ -3,7 +3,8 @@ import { Input } from '../../../../../ui/input';
|
||||
import { Select } from '../../../../../ui/Select';
|
||||
import { Button } from '../../../../../ui/button';
|
||||
import { SecureStorageNotice } from '../SecureStorageNotice';
|
||||
import { UpdateCustomProviderRequest, type ProviderTemplate } from '../../../../../../api';
|
||||
import { UpdateCustomProviderRequest } from '../../../../../../api';
|
||||
import type { ProviderTemplateDto } from '@aaif/goose-sdk';
|
||||
import { Plus, X, Trash2, AlertTriangle, ExternalLink, Search, Settings } from 'lucide-react';
|
||||
import { cn } from '../../../../../../utils';
|
||||
import ProviderCatalogPicker from '../ProviderCatalogPicker';
|
||||
@@ -92,7 +93,8 @@ const i18n = defineMessages({
|
||||
},
|
||||
apiBasePathHint: {
|
||||
id: 'customProviderForm.apiBasePathHint',
|
||||
defaultMessage: "Override the default API path. Leave blank to use the provider's default path.",
|
||||
defaultMessage:
|
||||
"Override the default API path. Leave blank to use the provider's default path.",
|
||||
},
|
||||
authentication: {
|
||||
id: 'customProviderForm.authentication',
|
||||
@@ -264,7 +266,7 @@ export default function CustomProviderForm({
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
|
||||
// Template + step state
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplate | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ProviderTemplateDto | null>(null);
|
||||
const [step, setStep] = useState<Step>(initialData ? 'form' : 'choice');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -294,14 +296,14 @@ export default function CustomProviderForm({
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleTemplateSelect = (template: ProviderTemplate) => {
|
||||
const handleTemplateSelect = (template: ProviderTemplateDto) => {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// Prefill fields from template
|
||||
setDisplayName(template.name);
|
||||
setApiUrl(template.api_url);
|
||||
setApiUrl(template.apiUrl);
|
||||
setBasePath('');
|
||||
setSupportsStreaming(template.supports_streaming);
|
||||
setSupportsStreaming(template.supportsStreaming);
|
||||
setRequiresAuth(true);
|
||||
|
||||
const formatToEngine: Record<string, string> = {
|
||||
@@ -411,7 +413,8 @@ export default function CustomProviderForm({
|
||||
if (!displayName) errors.displayName = intl.formatMessage(i18n.displayNameRequired);
|
||||
if (!apiUrl) errors.apiUrl = intl.formatMessage(i18n.apiUrlRequired);
|
||||
const existingHadAuth = initialData && (initialData.requires_auth ?? true);
|
||||
if (requiresAuth && !apiKey && !existingHadAuth) errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
|
||||
if (requiresAuth && !apiKey && !existingHadAuth)
|
||||
errors.apiKey = intl.formatMessage(i18n.apiKeyRequired);
|
||||
if (!models) errors.models = intl.formatMessage(i18n.modelsRequired);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
@@ -456,7 +459,8 @@ export default function CustomProviderForm({
|
||||
supports_streaming: supportsStreaming,
|
||||
requires_auth: requiresAuth,
|
||||
headers: headersObject,
|
||||
catalog_provider_id: selectedTemplate?.id ?? initialData?.catalog_provider_id ?? undefined,
|
||||
catalog_provider_id:
|
||||
selectedTemplate?.providerId ?? initialData?.catalog_provider_id ?? undefined,
|
||||
base_path: basePath || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -470,7 +474,7 @@ export default function CustomProviderForm({
|
||||
.filter((m) => !m.deprecated)
|
||||
.reduce(
|
||||
(acc, m) => {
|
||||
if (m.capabilities.tool_call) acc.tool_call = true;
|
||||
if (m.capabilities.toolCall) acc.tool_call = true;
|
||||
if (m.capabilities.reasoning) acc.reasoning = true;
|
||||
if (m.capabilities.attachment) acc.attachment = true;
|
||||
return acc;
|
||||
@@ -491,7 +495,9 @@ export default function CustomProviderForm({
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">{intl.formatMessage(i18n.startFromTemplate)}</div>
|
||||
<div className="font-medium text-textStandard">
|
||||
{intl.formatMessage(i18n.startFromTemplate)}
|
||||
</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
{intl.formatMessage(i18n.startFromTemplateDesc)}
|
||||
</div>
|
||||
@@ -506,7 +512,9 @@ export default function CustomProviderForm({
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-textSubtle flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-textStandard">{intl.formatMessage(i18n.configureManually)}</div>
|
||||
<div className="font-medium text-textStandard">
|
||||
{intl.formatMessage(i18n.configureManually)}
|
||||
</div>
|
||||
<div className="text-sm text-textSubtle mt-0.5">
|
||||
{intl.formatMessage(i18n.configureManuallyDesc)}
|
||||
</div>
|
||||
@@ -550,12 +558,12 @@ export default function CustomProviderForm({
|
||||
<div className="font-medium text-textStandard">
|
||||
{intl.formatMessage(i18n.usingTemplate, { name: selectedTemplate.name })}
|
||||
</div>
|
||||
<div className="text-textSubtle mt-1">{selectedTemplate.api_url}</div>
|
||||
<div className="text-textSubtle mt-1">{selectedTemplate.apiUrl}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTemplate.doc_url && (
|
||||
{selectedTemplate.docUrl && (
|
||||
<a
|
||||
href={selectedTemplate.doc_url}
|
||||
href={selectedTemplate.docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm flex items-center gap-1"
|
||||
@@ -600,7 +608,10 @@ export default function CustomProviderForm({
|
||||
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
|
||||
options={[
|
||||
{ value: 'openai_compatible', label: intl.formatMessage(i18n.openaiCompatible) },
|
||||
{ value: 'anthropic_compatible', label: intl.formatMessage(i18n.anthropicCompatible) },
|
||||
{
|
||||
value: 'anthropic_compatible',
|
||||
label: intl.formatMessage(i18n.anthropicCompatible),
|
||||
},
|
||||
{ value: 'ollama_compatible', label: intl.formatMessage(i18n.ollamaCompatible) },
|
||||
]}
|
||||
value={{
|
||||
@@ -695,18 +706,16 @@ export default function CustomProviderForm({
|
||||
onChange={(e) => setBasePath(e.target.value)}
|
||||
placeholder={intl.formatMessage(i18n.apiBasePathPlaceholder)}
|
||||
/>
|
||||
<p className="text-xs text-textSubtle mt-1">
|
||||
{intl.formatMessage(i18n.apiBasePathHint)}
|
||||
</p>
|
||||
<p className="text-xs text-textSubtle mt-1">{intl.formatMessage(i18n.apiBasePathHint)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">{intl.formatMessage(i18n.authentication)}</label>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{intl.formatMessage(i18n.authHint)}
|
||||
</p>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{intl.formatMessage(i18n.authentication)}
|
||||
</label>
|
||||
<p className="text-sm text-text-secondary mb-3">{intl.formatMessage(i18n.authHint)}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -727,9 +736,9 @@ export default function CustomProviderForm({
|
||||
className="flex items-center text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{intl.formatMessage(i18n.apiKey)}
|
||||
{selectedTemplate?.env_var && (
|
||||
{selectedTemplate?.envVar && (
|
||||
<span className="text-textSubtle ml-1 font-normal">
|
||||
({selectedTemplate.env_var})
|
||||
({selectedTemplate.envVar})
|
||||
</span>
|
||||
)}
|
||||
{!initialData && <span className="text-red-500 ml-1">*</span>}
|
||||
@@ -739,7 +748,11 @@ export default function CustomProviderForm({
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={initialData ? intl.formatMessage(i18n.apiKeyPlaceholderExisting) : intl.formatMessage(i18n.apiKeyPlaceholderNew)}
|
||||
placeholder={
|
||||
initialData
|
||||
? intl.formatMessage(i18n.apiKeyPlaceholderExisting)
|
||||
: intl.formatMessage(i18n.apiKeyPlaceholderNew)
|
||||
}
|
||||
aria-invalid={!!validationErrors.apiKey}
|
||||
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
|
||||
className={validationErrors.apiKey ? 'border-red-500' : ''}
|
||||
@@ -819,7 +832,9 @@ export default function CustomProviderForm({
|
||||
{/* Custom headers */}
|
||||
{isEditable && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">{intl.formatMessage(i18n.customHeaders)}</label>
|
||||
<label className="text-sm font-medium text-textStandard mb-2 block">
|
||||
{intl.formatMessage(i18n.customHeaders)}
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mb-4">
|
||||
{intl.formatMessage(i18n.customHeadersHint)}
|
||||
</p>
|
||||
@@ -900,16 +915,12 @@ export default function CustomProviderForm({
|
||||
<div className="px-4 py-3 bg-yellow-600/20 border border-yellow-500/30 rounded">
|
||||
<p className="text-yellow-500 text-sm flex items-start">
|
||||
<AlertTriangle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{intl.formatMessage(i18n.cannotDeleteActive)}
|
||||
</span>
|
||||
<span>{intl.formatMessage(i18n.cannotDeleteActive)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3 bg-red-900/20 border border-red-500/30 rounded">
|
||||
<p className="text-red-400 text-sm">
|
||||
{intl.formatMessage(i18n.deleteConfirmation)}
|
||||
</p>
|
||||
<p className="text-red-400 text-sm">{intl.formatMessage(i18n.deleteConfirmation)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2">
|
||||
@@ -944,7 +955,11 @@ export default function CustomProviderForm({
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button type="submit">{initialData ? intl.formatMessage(i18n.updateProvider) : intl.formatMessage(i18n.createProvider)}</Button>
|
||||
<Button type="submit">
|
||||
{initialData
|
||||
? intl.formatMessage(i18n.updateProvider)
|
||||
: intl.formatMessage(i18n.createProvider)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
+19
-8
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { Input } from '../../../../../ui/input';
|
||||
import { useConfig } from '../../../../../ConfigContext';
|
||||
import { acpReadProviderConfig } from '../../../../../../acp/providers';
|
||||
import { ProviderDetails, ConfigKey } from '../../../../../../api';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible';
|
||||
import { configLabels, configPlaceholders } from '../../../../../../utils/configUtils';
|
||||
@@ -95,19 +95,30 @@ export default function DefaultProviderSetupForm({
|
||||
const intl = useIntl();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [optionalExpanded, setOptionalExpanded] = useState(false);
|
||||
const { read } = useConfig();
|
||||
|
||||
const loadConfigValues = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const values: { [k: string]: ConfigInput } = {};
|
||||
|
||||
for (const parameter of parameters) {
|
||||
const configKey = `${parameter.name}`;
|
||||
const configValue = (await read(configKey, parameter.secret || false)) as ConfigValue;
|
||||
let fields: Awaited<ReturnType<typeof acpReadProviderConfig>> = [];
|
||||
try {
|
||||
fields = await acpReadProviderConfig(provider.name);
|
||||
} catch {
|
||||
// Provider may not be in the registry yet; fall back to defaults below.
|
||||
}
|
||||
const fieldByKey = new Map(fields.map((field) => [field.key, field]));
|
||||
|
||||
if (configValue !== undefined && configValue !== null) {
|
||||
values[parameter.name] = { serverValue: configValue };
|
||||
for (const parameter of parameters) {
|
||||
const field = fieldByKey.get(parameter.name);
|
||||
|
||||
if (field?.isSet && field.value != null) {
|
||||
// Secrets come back masked from the server; preserve the masked shape
|
||||
// so the form renders a placeholder rather than the raw value.
|
||||
const serverValue: ConfigValue = parameter.secret
|
||||
? { maskedValue: field.value }
|
||||
: field.value;
|
||||
values[parameter.name] = { serverValue };
|
||||
} else if (parameter.default !== undefined && parameter.default !== null) {
|
||||
values[parameter.name] = { value: parameter.default };
|
||||
}
|
||||
@@ -120,7 +131,7 @@ export default function DefaultProviderSetupForm({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [parameters, read, setConfigValues]);
|
||||
}, [parameters, provider.name, setConfigValues]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigValues();
|
||||
|
||||
+14
-97
@@ -1,112 +1,29 @@
|
||||
import { getProviderModels, readConfig } from '../../../../../../api';
|
||||
import { acpSaveProviderConfig } from '../../../../../../acp/providers';
|
||||
|
||||
/**
|
||||
* Standalone function to submit provider configuration
|
||||
* Useful for components that don't want to use the hook
|
||||
* Submit provider configuration through ACP.
|
||||
*
|
||||
* The ACP server validates the supplied fields, persists config/secret values,
|
||||
* and triggers an inventory refresh in a single call, so no client-side
|
||||
* rollback is required.
|
||||
*/
|
||||
export const providerConfigSubmitHandler = async (
|
||||
upsertFn: (key: string, value: unknown, isSecret: boolean) => Promise<void>,
|
||||
provider: {
|
||||
name: string;
|
||||
metadata: {
|
||||
config_keys?: Array<{
|
||||
name: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
secret?: boolean;
|
||||
}>;
|
||||
config_keys?: Array<{ name: string; default?: unknown }>;
|
||||
};
|
||||
},
|
||||
configValues: Record<string, string>
|
||||
) => {
|
||||
const parameters = provider.metadata.config_keys || [];
|
||||
|
||||
// Save current NON-SECRET config values for rollback on failure
|
||||
// We skip secrets because readConfig returns masked values for secrets,
|
||||
// and upserting those masked values would corrupt the actual secret
|
||||
const previousConfigValues: Record<string, { value: unknown; isSecret: boolean }> = {};
|
||||
const nonSecretParams = parameters.filter((param) => !param.secret);
|
||||
|
||||
await Promise.all(
|
||||
nonSecretParams.map(async (param) => {
|
||||
try {
|
||||
const currentValue = await readConfig({
|
||||
body: { key: param.name, is_secret: false },
|
||||
});
|
||||
if (currentValue.data) {
|
||||
previousConfigValues[param.name] = {
|
||||
value: currentValue.data,
|
||||
isSecret: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// No previous value exists, that's fine
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const requiredParams = parameters.filter((param) => param.required);
|
||||
if (requiredParams.length === 0 && parameters.length > 0) {
|
||||
const allOptionalWithDefaults = parameters.every(
|
||||
(param) => !param.required && param.default !== undefined
|
||||
);
|
||||
if (allOptionalWithDefaults) {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const param of parameters) {
|
||||
if (param.default !== undefined) {
|
||||
const value =
|
||||
configValues[param.name] !== undefined ? configValues[param.name] : param.default;
|
||||
promises.push(upsertFn(param.name, value, param.secret === true));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return;
|
||||
const fields: { key: string; value: string }[] = [];
|
||||
for (const { name, default: defaultValue } of provider.metadata.config_keys ?? []) {
|
||||
const value = configValues[name] ?? defaultValue;
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
fields.push({ key: name, value: String(value) });
|
||||
}
|
||||
|
||||
const upsertPromises = parameters.map(
|
||||
async (parameter: {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
secret?: boolean;
|
||||
}) => {
|
||||
if (!configValues[parameter.name] && !parameter.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value =
|
||||
configValues[parameter.name] !== undefined
|
||||
? configValues[parameter.name]
|
||||
: parameter.default;
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configKey = `${parameter.name}`;
|
||||
const isSecret = parameter.secret === true;
|
||||
|
||||
await upsertFn(configKey, value, isSecret);
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(upsertPromises);
|
||||
|
||||
try {
|
||||
await getProviderModels({
|
||||
path: { name: provider.name },
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const rollbackPromises: Promise<void>[] = [];
|
||||
for (const [key, { value, isSecret }] of Object.entries(previousConfigValues)) {
|
||||
rollbackPromises.push(upsertFn(key, value, isSecret));
|
||||
}
|
||||
await Promise.all(rollbackPromises);
|
||||
|
||||
throw error;
|
||||
}
|
||||
await acpSaveProviderConfig(provider.name, fields);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from '../../ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { acpClearDefaults } from '../../../acp/providers';
|
||||
import { View, ViewOptions } from '../../../utils/navigationUtils';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
|
||||
@@ -21,12 +21,10 @@ interface ResetProviderSectionProps {
|
||||
|
||||
export default function ResetProviderSection(_props: ResetProviderSectionProps) {
|
||||
const intl = useIntl();
|
||||
const { remove } = useConfig();
|
||||
|
||||
const handleResetProvider = async () => {
|
||||
try {
|
||||
await remove('GOOSE_PROVIDER', false);
|
||||
await remove('GOOSE_MODEL', false);
|
||||
await acpClearDefaults();
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* Utilities for fetching canonical model information from the backend
|
||||
*/
|
||||
|
||||
import { getCanonicalModelInfo, type ModelInfoData } from '../api';
|
||||
import { acpGetCanonicalModelInfo, type CanonicalModelInfoDto } from '../acp/providers';
|
||||
|
||||
export type CanonicalModelInfo = CanonicalModelInfoDto;
|
||||
|
||||
/**
|
||||
* Fetch canonical model info (pricing + context limits) for a specific provider/model
|
||||
@@ -10,14 +12,9 @@ import { getCanonicalModelInfo, type ModelInfoData } from '../api';
|
||||
export async function fetchCanonicalModelInfo(
|
||||
provider: string,
|
||||
model: string
|
||||
): Promise<ModelInfoData | null> {
|
||||
): Promise<CanonicalModelInfoDto | null> {
|
||||
try {
|
||||
const response = await getCanonicalModelInfo({
|
||||
body: { provider, model },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
return response.data.model_info ?? null;
|
||||
return await acpGetCanonicalModelInfo(provider, model);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user