mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-30 17:56:18 +08:00
feat(interactions): add support for Interactions API, including endpoints, payloads, and configuration management
This commit is contained in:
@@ -89,6 +89,29 @@ export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
|
||||
return `${trimmed}/v1/messages`;
|
||||
};
|
||||
|
||||
export const INTERACTIONS_API_REVISION = '2026-05-20';
|
||||
|
||||
export const buildInteractionsProbePayload = (model: string) => ({
|
||||
model,
|
||||
input: 'Hi',
|
||||
});
|
||||
|
||||
export const buildInteractionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl, DEFAULT_GEMINI_BASE_URL);
|
||||
if (!trimmed) return '';
|
||||
if (/\/v1beta\/interactions$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let root = trimmed.replace(/\/+$/g, '');
|
||||
root = root.replace(/\/v1beta\/models$/i, '');
|
||||
if (/\/v1beta$/i.test(root)) {
|
||||
return `${root}/interactions`;
|
||||
}
|
||||
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
|
||||
return `${root}/v1beta/interactions`;
|
||||
};
|
||||
|
||||
export const buildGeminiGenerateContentEndpoint = (baseUrl: string, model: string): string => {
|
||||
const resource = buildGeminiModelResource(model);
|
||||
if (!resource) return '';
|
||||
@@ -110,6 +133,12 @@ export const buildGeminiGenerateContentEndpoint = (baseUrl: string, model: strin
|
||||
return `${root}/${resource}:generateContent`;
|
||||
};
|
||||
|
||||
export const getProviderUsageKey = (provider: string): string => {
|
||||
if (provider === 'claudeApi') return 'claude';
|
||||
if (provider === 'interactions') return 'gemini-interactions';
|
||||
return provider;
|
||||
};
|
||||
|
||||
export type ProviderRecentUsageMap = Map<string, Map<string, RecentRequestUsageEntry>>;
|
||||
|
||||
const EMPTY_RECENT_USAGE_ENTRY: RecentRequestUsageEntry = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeRecentRequestUsageEntry,
|
||||
type RecentRequestBucket,
|
||||
} from '@/utils/recentRequests';
|
||||
import type { Config } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import {
|
||||
TRAFFIC_BUCKET_MINUTES,
|
||||
@@ -96,6 +97,16 @@ const createAccumulator = (): ProviderAccumulator => ({
|
||||
bucketGroups: [],
|
||||
});
|
||||
|
||||
export const getProviderKeyCounts = (config: Config) => ({
|
||||
gemini: config.geminiApiKeys?.length ?? 0,
|
||||
interactions: config.interactionsApiKeys?.length ?? 0,
|
||||
codex: config.codexApiKeys?.length ?? 0,
|
||||
xai: config.xaiApiKeys?.length ?? 0,
|
||||
claude: config.claudeApiKeys?.length ?? 0,
|
||||
vertex: config.vertexApiKeys?.length ?? 0,
|
||||
openai: config.openaiCompatibility?.length ?? 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* 汇总仪表盘所需的全部数据。
|
||||
*
|
||||
@@ -164,17 +175,7 @@ export function useDashboardOverview() {
|
||||
]);
|
||||
}, [connected, fetchConfig, loadAuthFiles, loadModels, refreshRecentRequests]);
|
||||
|
||||
const providerKeyCounts = useMemo(() => {
|
||||
if (!config) return null;
|
||||
return {
|
||||
gemini: config.geminiApiKeys?.length ?? 0,
|
||||
codex: config.codexApiKeys?.length ?? 0,
|
||||
xai: config.xaiApiKeys?.length ?? 0,
|
||||
claude: config.claudeApiKeys?.length ?? 0,
|
||||
vertex: config.vertexApiKeys?.length ?? 0,
|
||||
openai: config.openaiCompatibility?.length ?? 0,
|
||||
};
|
||||
}, [config]);
|
||||
const providerKeyCounts = useMemo(() => (config ? getProviderKeyCounts(config) : null), [config]);
|
||||
|
||||
const { traffic, providers } = useMemo(() => {
|
||||
const accumulators = new Map<string, ProviderAccumulator>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TRAFFIC_BUCKET_MINUTES } from './types';
|
||||
/** 供应商展示名。均为专有名词,不进入 i18n。 */
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
gemini: 'Gemini',
|
||||
'gemini-interactions': 'Interactions API',
|
||||
aistudio: 'AI Studio',
|
||||
codex: 'Codex',
|
||||
claude: 'Claude',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProviderRecentRequests } from '@/components/providers/hooks/useProvi
|
||||
import {
|
||||
getOpenAIProviderRecentWindowStats,
|
||||
getProviderRecentWindowStats,
|
||||
getProviderUsageKey,
|
||||
type ProviderRecentUsageMap,
|
||||
} from '@/components/providers/utils';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
@@ -88,10 +89,9 @@ const getResourceRecentSuccess = (
|
||||
return getOpenAIProviderRecentWindowStats(resource.raw as OpenAIProviderConfig, usageByProvider)
|
||||
.success;
|
||||
}
|
||||
const usageProvider = resource.brand === 'claudeApi' ? 'claude' : resource.brand;
|
||||
return getProviderRecentWindowStats(
|
||||
usageByProvider,
|
||||
usageProvider,
|
||||
getProviderUsageKey(resource.brand),
|
||||
resource.apiKey ?? undefined,
|
||||
resource.baseUrl ?? undefined
|
||||
).success;
|
||||
|
||||
@@ -66,7 +66,7 @@ const truncateForId = (value: string | undefined | null): string => {
|
||||
};
|
||||
|
||||
function providerKeyToResource(
|
||||
brand: 'gemini' | 'codex' | 'xai' | 'claude' | 'claudeApi' | 'vertex',
|
||||
brand: 'gemini' | 'interactions' | 'codex' | 'xai' | 'claude' | 'claudeApi' | 'vertex',
|
||||
config: GeminiKeyConfig | ProviderKeyConfig,
|
||||
index: number
|
||||
): ProviderResource {
|
||||
@@ -117,6 +117,10 @@ export function geminiToResource(config: GeminiKeyConfig, index: number): Provid
|
||||
return providerKeyToResource('gemini', config, index);
|
||||
}
|
||||
|
||||
export function interactionsToResource(config: GeminiKeyConfig, index: number): ProviderResource {
|
||||
return providerKeyToResource('interactions', config, index);
|
||||
}
|
||||
|
||||
export function codexToResource(config: ProviderKeyConfig, index: number): ProviderResource {
|
||||
return providerKeyToResource('codex', config, index);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ProviderBrandLogo {
|
||||
|
||||
export const PROVIDER_LOGOS: Record<ProviderBrand, ProviderBrandLogo> = {
|
||||
gemini: { src: geminiLogo },
|
||||
interactions: { src: geminiLogo },
|
||||
claude: { src: claudeLogo },
|
||||
claudeApi: { src: claudeApiLogo },
|
||||
codex: { src: codexLogo },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getOpenAIProviderTotalStats,
|
||||
getProviderRecentStatusData,
|
||||
getProviderTotalStats,
|
||||
getProviderUsageKey,
|
||||
type ProviderRecentUsageMap,
|
||||
} from '@/components/providers/utils';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
@@ -47,9 +48,6 @@ const columnWidths = ['180px', '220px', '72px', '138px', '174px', '176px'];
|
||||
const isSponsorResource = (resource: ProviderResource): boolean =>
|
||||
isMultiProtocolSponsorBrand(resource.brand);
|
||||
|
||||
const getUsageProvider = (resource: ProviderResource): string =>
|
||||
resource.brand === 'claudeApi' ? 'claude' : resource.brand;
|
||||
|
||||
const resolveStatusBarData = (
|
||||
resource: ProviderResource,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
@@ -59,7 +57,7 @@ const resolveStatusBarData = (
|
||||
}
|
||||
return getProviderRecentStatusData(
|
||||
usageByProvider,
|
||||
getUsageProvider(resource),
|
||||
getProviderUsageKey(resource.brand),
|
||||
resource.apiKey ?? undefined,
|
||||
resource.baseUrl ?? undefined
|
||||
);
|
||||
@@ -74,7 +72,7 @@ const resolveTotalStats = (
|
||||
}
|
||||
return getProviderTotalStats(
|
||||
usageByProvider,
|
||||
getUsageProvider(resource),
|
||||
getProviderUsageKey(resource.brand),
|
||||
resource.apiKey ?? undefined,
|
||||
resource.baseUrl ?? undefined
|
||||
);
|
||||
|
||||
@@ -41,6 +41,25 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
|
||||
supportsApiKeyEntries: false,
|
||||
sheetSize: 'md',
|
||||
},
|
||||
interactions: {
|
||||
id: 'interactions',
|
||||
supportsName: false,
|
||||
supportsApiKey: true,
|
||||
supportsDisabled: true,
|
||||
supportsBaseUrl: true,
|
||||
baseUrlRequired: false,
|
||||
supportsProxyUrl: true,
|
||||
supportsPrefix: true,
|
||||
supportsModels: true,
|
||||
supportsHeaders: true,
|
||||
supportsExcludedModels: true,
|
||||
supportsPriority: true,
|
||||
supportsTestModel: true,
|
||||
supportsWebsockets: false,
|
||||
supportsCloak: false,
|
||||
supportsApiKeyEntries: false,
|
||||
sheetSize: 'md',
|
||||
},
|
||||
codex: {
|
||||
id: 'codex',
|
||||
supportsName: false,
|
||||
@@ -255,6 +274,7 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
|
||||
export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
|
||||
'kimi',
|
||||
'gemini',
|
||||
'interactions',
|
||||
'codex',
|
||||
'xai',
|
||||
'claude',
|
||||
|
||||
@@ -91,7 +91,8 @@ function buildInitialForm(
|
||||
brand === 'codex' ||
|
||||
brand === 'xai' ||
|
||||
isClaudeLikeBrand(brand) ||
|
||||
brand === 'gemini'
|
||||
brand === 'gemini' ||
|
||||
brand === 'interactions'
|
||||
? ''
|
||||
: undefined,
|
||||
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
|
||||
@@ -182,7 +183,11 @@ function buildInitialForm(
|
||||
? (cfg as ProviderKeyConfig).experimentalCchSigning === true
|
||||
: undefined,
|
||||
testModel:
|
||||
brand === 'codex' || brand === 'xai' || isClaudeLikeBrand(brand) || brand === 'gemini'
|
||||
brand === 'codex' ||
|
||||
brand === 'xai' ||
|
||||
isClaudeLikeBrand(brand) ||
|
||||
brand === 'gemini' ||
|
||||
brand === 'interactions'
|
||||
? ''
|
||||
: undefined,
|
||||
};
|
||||
@@ -428,6 +433,7 @@ export function BaseProviderForm({
|
||||
const actualApiKeyEntries = form.apiKeyEntries ?? [];
|
||||
const supportsDisableCooling =
|
||||
brand === 'gemini' ||
|
||||
brand === 'interactions' ||
|
||||
brand === 'codex' ||
|
||||
brand === 'xai' ||
|
||||
isClaudeLikeBrand(brand) ||
|
||||
@@ -436,7 +442,7 @@ export function BaseProviderForm({
|
||||
const singleConnectivity =
|
||||
brand === 'codex' || brand === 'xai'
|
||||
? { status: connectivity.codexStatus, run: connectivity.runCodex }
|
||||
: brand === 'gemini'
|
||||
: brand === 'gemini' || brand === 'interactions'
|
||||
? { status: connectivity.geminiStatus, run: connectivity.runGemini }
|
||||
: isClaudeLikeBrand(brand)
|
||||
? { status: connectivity.claudeStatus, run: connectivity.runClaude }
|
||||
@@ -624,7 +630,8 @@ export function BaseProviderForm({
|
||||
{brand === 'codex' ||
|
||||
brand === 'xai' ||
|
||||
isClaudeLikeBrand(brand) ||
|
||||
brand === 'gemini' ? (
|
||||
brand === 'gemini' ||
|
||||
brand === 'interactions' ? (
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· {t('providersPage.form.testModelClaudeHint')}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
buildCodexResponsesEndpoint,
|
||||
buildClaudeMessagesEndpoint,
|
||||
buildGeminiGenerateContentEndpoint,
|
||||
buildInteractionsEndpoint,
|
||||
buildInteractionsProbePayload,
|
||||
INTERACTIONS_API_REVISION,
|
||||
buildOpenAIChatCompletionsEndpoint,
|
||||
} from '@/components/providers/utils';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
@@ -359,7 +362,7 @@ export function useConnectivityTest(
|
||||
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
|
||||
|
||||
const runGemini = useCallback(async (): Promise<void> => {
|
||||
if (brand !== 'gemini') return;
|
||||
if (brand !== 'gemini' && brand !== 'interactions') return;
|
||||
|
||||
const model = pickModel(testModel, models);
|
||||
if (!model) {
|
||||
@@ -367,7 +370,10 @@ export function useConnectivityTest(
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = buildGeminiGenerateContentEndpoint(baseUrl ?? '', model);
|
||||
const endpoint =
|
||||
brand === 'interactions'
|
||||
? buildInteractionsEndpoint(baseUrl ?? '')
|
||||
: buildGeminiGenerateContentEndpoint(baseUrl ?? '', model);
|
||||
if (!endpoint) {
|
||||
setGeminiStatus({ state: 'error', message: messages.endpointInvalid });
|
||||
return;
|
||||
@@ -396,6 +402,9 @@ export function useConnectivityTest(
|
||||
headerObj['x-goog-api-key'] = '$TOKEN$';
|
||||
}
|
||||
}
|
||||
if (brand === 'interactions' && !hasHeader(headerObj, 'api-revision')) {
|
||||
headerObj['Api-Revision'] = INTERACTIONS_API_REVISION;
|
||||
}
|
||||
|
||||
setGeminiStatus({ state: 'loading', message: '' });
|
||||
setInFlight((n) => n + 1);
|
||||
@@ -406,10 +415,14 @@ export function useConnectivityTest(
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: headerObj,
|
||||
data: JSON.stringify({
|
||||
contents: [{ parts: [{ text: 'Hi' }] }],
|
||||
generationConfig: { maxOutputTokens: 8 },
|
||||
}),
|
||||
data: JSON.stringify(
|
||||
brand === 'interactions'
|
||||
? buildInteractionsProbePayload(model)
|
||||
: {
|
||||
contents: [{ parts: [{ text: 'Hi' }] }],
|
||||
generationConfig: { maxOutputTokens: 8 },
|
||||
}
|
||||
),
|
||||
},
|
||||
{ timeout: DEFAULT_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ApiKeyEntryInput, ProviderBrand } from '../../types';
|
||||
|
||||
export const MODEL_DISCOVERY_BRANDS: ReadonlyArray<ProviderBrand> = [
|
||||
'gemini',
|
||||
'interactions',
|
||||
'codex',
|
||||
'xai',
|
||||
'claude',
|
||||
@@ -54,7 +55,7 @@ export function useModelDiscovery(args: UseModelDiscoveryArgs): UseModelDiscover
|
||||
const baseHeaders = buildHeaderObject(formHeaders);
|
||||
const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
|
||||
let next: ModelInfo[] = [];
|
||||
if (brand === 'gemini') {
|
||||
if (brand === 'gemini' || brand === 'interactions') {
|
||||
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
|
||||
next = await modelsApi.fetchGeminiModelsViaApiCall(
|
||||
baseUrl,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@
|
||||
|
||||
export type ProviderBrand =
|
||||
| 'gemini'
|
||||
| 'interactions'
|
||||
| 'codex'
|
||||
| 'xai'
|
||||
| 'claude'
|
||||
@@ -28,6 +29,7 @@ export type SortDir = (typeof SORT_DIR_VALUES)[number];
|
||||
|
||||
export type ProviderResourceSelector =
|
||||
| { brand: 'gemini'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'interactions'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'xai'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'claude'; apiKey: string; baseUrl?: string; index: number }
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
codexToResource,
|
||||
fennoAIToResource,
|
||||
geminiToResource,
|
||||
interactionsToResource,
|
||||
openaiToResource,
|
||||
qiniuCloudToResource,
|
||||
kimiToResource,
|
||||
@@ -145,7 +146,7 @@ const buildModelAliases = (
|
||||
.filter((m) => m.name);
|
||||
|
||||
const buildProviderKeyConfig = (
|
||||
brand: 'gemini' | 'codex' | 'xai' | 'claude' | 'vertex',
|
||||
brand: 'gemini' | 'interactions' | 'codex' | 'xai' | 'claude' | 'vertex',
|
||||
input: ProviderEntryFormInput,
|
||||
existing?: ProviderKeyConfig | GeminiKeyConfig | null
|
||||
): ProviderKeyConfig | GeminiKeyConfig => {
|
||||
@@ -435,6 +436,11 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'interactions':
|
||||
resources = (config.interactionsApiKeys ?? []).map((item, index) =>
|
||||
interactionsToResource(item, index)
|
||||
);
|
||||
break;
|
||||
case 'codex':
|
||||
resources = (config.codexApiKeys ?? []).reduce<ProviderResource[]>((out, item, index) => {
|
||||
if (
|
||||
@@ -655,6 +661,10 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
await providersApi.createGeminiKey(
|
||||
buildProviderKeyConfig('gemini', input) as GeminiKeyConfig
|
||||
);
|
||||
} else if (brand === 'interactions') {
|
||||
await providersApi.createInteractionsKey(
|
||||
buildProviderKeyConfig('interactions', input) as GeminiKeyConfig
|
||||
);
|
||||
} else if (brand === 'codex') {
|
||||
await providersApi.createCodexConfig(
|
||||
buildProviderKeyConfig('codex', input) as ProviderKeyConfig
|
||||
@@ -705,6 +715,13 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
selector.baseUrl,
|
||||
buildProviderKeyConfig('gemini', input, existing) as GeminiKeyConfig
|
||||
);
|
||||
} else if (brand === 'interactions' && selector.brand === 'interactions') {
|
||||
const existing = resource.raw as GeminiKeyConfig;
|
||||
await providersApi.updateInteractionsKey(
|
||||
selector.apiKey,
|
||||
selector.baseUrl,
|
||||
buildProviderKeyConfig('interactions', input, existing) as GeminiKeyConfig
|
||||
);
|
||||
} else if (brand === 'codex' && selector.brand === 'codex') {
|
||||
const existing = resource.raw as ProviderKeyConfig;
|
||||
await providersApi.updateCodexConfig(
|
||||
@@ -771,6 +788,10 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
await providersApi.deleteGeminiKey(sel.apiKey, sel.baseUrl);
|
||||
const next = (config?.geminiApiKeys ?? []).filter((_, i) => i !== sel.index);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
} else if (sel.brand === 'interactions') {
|
||||
await providersApi.deleteInteractionsKey(sel.apiKey, sel.baseUrl);
|
||||
const next = (config?.interactionsApiKeys ?? []).filter((_, i) => i !== sel.index);
|
||||
updateConfigValue('interactions-api-key', next);
|
||||
} else if (sel.brand === 'codex') {
|
||||
await providersApi.deleteCodexConfig(sel.apiKey, sel.baseUrl);
|
||||
const next = (config?.codexApiKeys ?? []).filter((_, i) => i !== sel.index);
|
||||
@@ -846,6 +867,15 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
...current,
|
||||
excludedModels: excluded,
|
||||
});
|
||||
} else if (brand === 'interactions' && selector.brand === 'interactions') {
|
||||
const current = resource.raw as GeminiKeyConfig;
|
||||
const excluded = disabled
|
||||
? withDisableAllModelsRule(current.excludedModels)
|
||||
: withoutDisableAllModelsRule(current.excludedModels);
|
||||
await providersApi.updateInteractionsKey(selector.apiKey, selector.baseUrl, {
|
||||
...current,
|
||||
excludedModels: excluded,
|
||||
});
|
||||
} else if (
|
||||
(brand === 'codex' && selector.brand === 'codex') ||
|
||||
(brand === 'xai' && selector.brand === 'xai') ||
|
||||
|
||||
@@ -1394,6 +1394,7 @@
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"interactions": "Interactions API",
|
||||
"codex": "Codex",
|
||||
"xai": "xAI",
|
||||
"claude": "Claude",
|
||||
|
||||
@@ -1372,6 +1372,7 @@
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"interactions": "Interactions API",
|
||||
"codex": "Codex",
|
||||
"xai": "xAI",
|
||||
"claude": "Claude",
|
||||
|
||||
@@ -1394,6 +1394,7 @@
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"interactions": "Interactions API",
|
||||
"codex": "Codex",
|
||||
"xai": "xAI",
|
||||
"claude": "Claude",
|
||||
|
||||
@@ -1420,6 +1420,7 @@
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"interactions": "Interactions API",
|
||||
"codex": "Codex",
|
||||
"xai": "xAI",
|
||||
"claude": "Claude",
|
||||
|
||||
@@ -32,6 +32,7 @@ const PROVIDER_COMMON_KEY_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const GEMINI_KEY_FIELDS = PROVIDER_COMMON_KEY_FIELDS;
|
||||
const INTERACTIONS_KEY_FIELDS = PROVIDER_COMMON_KEY_FIELDS;
|
||||
const CODEX_KEY_FIELDS = [...PROVIDER_COMMON_KEY_FIELDS, 'websockets'] as const;
|
||||
const XAI_KEY_FIELDS = CODEX_KEY_FIELDS;
|
||||
const CLAUDE_KEY_FIELDS = [
|
||||
@@ -447,6 +448,26 @@ export const providersApi = {
|
||||
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
createInteractionsKey: (config: GeminiKeyConfig) =>
|
||||
mutateLatestProviderList('interactions-api-key', (latestItems) =>
|
||||
appendLatestProviderRecord(latestItems, serializeGeminiKey(config), (raw, payload) =>
|
||||
mergeProviderKeyPayload(raw, payload, INTERACTIONS_KEY_FIELDS)
|
||||
)
|
||||
),
|
||||
|
||||
updateInteractionsKey: (apiKey: string, baseUrl: string | undefined, config: GeminiKeyConfig) =>
|
||||
mutateLatestProviderList('interactions-api-key', (latestItems) =>
|
||||
replaceLatestProviderRecord(
|
||||
latestItems,
|
||||
(record) => matchesProviderKey(record, apiKey, baseUrl),
|
||||
serializeGeminiKey(config),
|
||||
(raw, payload) => mergeProviderKeyPayload(raw, payload, INTERACTIONS_KEY_FIELDS)
|
||||
)
|
||||
),
|
||||
|
||||
deleteInteractionsKey: (apiKey: string, baseUrl?: string) =>
|
||||
apiClient.delete(`/interactions-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
|
||||
|
||||
createCodexConfig: (config: ProviderKeyConfig) =>
|
||||
mutateLatestProviderList('codex-api-key', (latestItems) =>
|
||||
appendLatestProviderRecord(latestItems, serializeProviderKey(config), (raw, payload) =>
|
||||
|
||||
@@ -350,6 +350,13 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
.filter(Boolean) as GeminiKeyConfig[];
|
||||
}
|
||||
|
||||
const interactionsList = raw['interactions-api-key'];
|
||||
if (Array.isArray(interactionsList)) {
|
||||
config.interactionsApiKeys = interactionsList
|
||||
.map((item) => normalizeGeminiKeyConfig(item))
|
||||
.filter(Boolean) as GeminiKeyConfig[];
|
||||
}
|
||||
|
||||
const codexList = raw['codex-api-key'];
|
||||
if (Array.isArray(codexList)) {
|
||||
config.codexApiKeys = codexList
|
||||
|
||||
@@ -109,6 +109,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
case 'gemini-api-key':
|
||||
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
|
||||
break;
|
||||
case 'interactions-api-key':
|
||||
nextConfig.interactionsApiKeys = value as Config['interactionsApiKeys'];
|
||||
break;
|
||||
case 'codex-api-key':
|
||||
nextConfig.codexApiKeys = value as Config['codexApiKeys'];
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Config {
|
||||
routingStrategy?: string;
|
||||
apiKeys?: string[];
|
||||
geminiApiKeys?: GeminiKeyConfig[];
|
||||
interactionsApiKeys?: GeminiKeyConfig[];
|
||||
codexApiKeys?: ProviderKeyConfig[];
|
||||
xaiApiKeys?: ProviderKeyConfig[];
|
||||
claudeApiKeys?: ProviderKeyConfig[];
|
||||
@@ -46,6 +47,7 @@ export type RawConfigSection =
|
||||
| 'routing/strategy'
|
||||
| 'api-keys'
|
||||
| 'gemini-api-key'
|
||||
| 'interactions-api-key'
|
||||
| 'codex-api-key'
|
||||
| 'xai-api-key'
|
||||
| 'claude-api-key'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { formatCompactNumber, formatPercent } from '../src/utils/format';
|
||||
import { getProviderKeyCounts } from '../src/features/dashboard/hooks/useDashboardOverview';
|
||||
import {
|
||||
axisMax,
|
||||
niceCeil,
|
||||
@@ -104,10 +105,24 @@ describe('splitWindowMinutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider key counts', () => {
|
||||
test('includes native Interactions API keys in the dashboard total inputs', () => {
|
||||
const counts = getProviderKeyCounts({
|
||||
geminiApiKeys: [{ apiKey: 'gemini-key' }],
|
||||
interactionsApiKeys: [{ apiKey: 'interactions-1' }, { apiKey: 'interactions-2' }],
|
||||
codexApiKeys: [{ apiKey: 'codex-key' }],
|
||||
});
|
||||
|
||||
expect(counts.interactions).toBe(2);
|
||||
expect(Object.values(counts).reduce((sum, count) => sum + count, 0)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('providerLabel', () => {
|
||||
test('uses the brand spelling for known providers', () => {
|
||||
expect(providerLabel('xai', 'Unattributed')).toBe('xAI');
|
||||
expect(providerLabel('aistudio', 'Unattributed')).toBe('AI Studio');
|
||||
expect(providerLabel('gemini-interactions', 'Unattributed')).toBe('Interactions API');
|
||||
});
|
||||
|
||||
test('falls back to a capitalised id, and localises unknown', () => {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildInteractionsEndpoint,
|
||||
buildInteractionsProbePayload,
|
||||
getProviderUsageKey,
|
||||
INTERACTIONS_API_REVISION,
|
||||
} from '../src/components/providers/utils';
|
||||
import { interactionsToResource } from '../src/features/providers/adapters';
|
||||
import { PROVIDER_BRAND_ORDER, PROVIDER_DESCRIPTORS } from '../src/features/providers/descriptors';
|
||||
import { MODEL_DISCOVERY_BRANDS } from '../src/features/providers/sheets/forms/useModelDiscovery';
|
||||
import { apiClient } from '../src/services/api/client';
|
||||
import { providersApi } from '../src/services/api/providers';
|
||||
import { normalizeConfigResponse } from '../src/services/api/transformers';
|
||||
|
||||
const originalGet = apiClient.get;
|
||||
const originalPut = apiClient.put;
|
||||
const originalDelete = apiClient.delete;
|
||||
|
||||
afterEach(() => {
|
||||
apiClient.get = originalGet;
|
||||
apiClient.put = originalPut;
|
||||
apiClient.delete = originalDelete;
|
||||
});
|
||||
|
||||
describe('Interactions API key provider', () => {
|
||||
test('normalizes the backend contract and exposes a dedicated workbench resource', () => {
|
||||
const config = normalizeConfigResponse({
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'interactions-secret',
|
||||
priority: 8,
|
||||
weight: 3,
|
||||
prefix: 'native',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'proxy-url': 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
'excluded-models': ['gemini-2.5-*'],
|
||||
'disable-cooling': true,
|
||||
'auth-index': 'gemini-interactions:apikey:1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.interactionsApiKeys).toEqual([
|
||||
{
|
||||
apiKey: 'interactions-secret',
|
||||
priority: 8,
|
||||
weight: 3,
|
||||
prefix: 'native',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
proxyUrl: 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
excludedModels: ['gemini-2.5-*'],
|
||||
disableCooling: true,
|
||||
authIndex: 'gemini-interactions:apikey:1',
|
||||
},
|
||||
]);
|
||||
|
||||
const resource = interactionsToResource(config.interactionsApiKeys![0], 0);
|
||||
expect(resource.brand).toBe('interactions');
|
||||
expect(resource.models).toEqual(['gemini-3.1-flash-lite']);
|
||||
expect(resource.selector).toEqual({
|
||||
brand: 'interactions',
|
||||
apiKey: 'interactions-secret',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
index: 0,
|
||||
});
|
||||
expect(PROVIDER_DESCRIPTORS.interactions.baseUrlRequired).toBe(false);
|
||||
expect(PROVIDER_DESCRIPTORS.interactions.supportsTestModel).toBe(true);
|
||||
expect(PROVIDER_BRAND_ORDER.indexOf('interactions')).toBe(
|
||||
PROVIDER_BRAND_ORDER.indexOf('gemini') + 1
|
||||
);
|
||||
expect(MODEL_DISCOVERY_BRANDS).toContain('interactions');
|
||||
});
|
||||
|
||||
test('builds the native interactions endpoint from supported base URL forms', () => {
|
||||
expect(buildInteractionsEndpoint('')).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://generativelanguage.googleapis.com')).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://example.com/v1beta')).toBe(
|
||||
'https://example.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://example.com/v1beta/interactions')).toBe(
|
||||
'https://example.com/v1beta/interactions'
|
||||
);
|
||||
});
|
||||
|
||||
test('uses the documented revision and minimal non-streaming probe body', () => {
|
||||
expect(INTERACTIONS_API_REVISION).toBe('2026-05-20');
|
||||
expect(buildInteractionsProbePayload('gemini-3.6-flash')).toEqual({
|
||||
model: 'gemini-3.6-flash',
|
||||
input: 'Hi',
|
||||
});
|
||||
});
|
||||
|
||||
test('maps the UI brand to the backend runtime usage provider', () => {
|
||||
expect(getProviderUsageKey('interactions')).toBe('gemini-interactions');
|
||||
expect(getProviderUsageKey('gemini')).toBe('gemini');
|
||||
expect(getProviderUsageKey('claudeApi')).toBe('claude');
|
||||
});
|
||||
|
||||
test('updates only the matching key and base URL while preserving unknown fields', async () => {
|
||||
let putData: unknown;
|
||||
apiClient.get = (async () => ({
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://first.example.com',
|
||||
'future-field': 'first',
|
||||
},
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://second.example.com',
|
||||
'proxy-url': 'direct',
|
||||
headers: { 'X-Old': 'value' },
|
||||
'excluded-models': ['old-model'],
|
||||
'disable-cooling': true,
|
||||
'future-field': 'preserved',
|
||||
'auth-index': 'response-only',
|
||||
},
|
||||
],
|
||||
})) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
putData = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.updateInteractionsKey('shared-key', 'https://second.example.com', {
|
||||
apiKey: 'shared-key',
|
||||
baseUrl: 'https://updated.example.com',
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
});
|
||||
|
||||
expect(putData).toEqual([
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://first.example.com',
|
||||
'future-field': 'first',
|
||||
},
|
||||
{
|
||||
'future-field': 'preserved',
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://updated.example.com',
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('creates and deletes keys through the interactions management endpoints', async () => {
|
||||
const calls: Array<{ method: string; url: string; data?: unknown }> = [];
|
||||
apiClient.get = (async (url: string) => {
|
||||
calls.push({ method: 'GET', url });
|
||||
return {
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
],
|
||||
};
|
||||
}) as typeof apiClient.get;
|
||||
apiClient.put = (async (url: string, data?: unknown) => {
|
||||
calls.push({ method: 'PUT', url, data });
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
apiClient.delete = (async (url: string) => {
|
||||
calls.push({ method: 'DELETE', url });
|
||||
return undefined;
|
||||
}) as typeof apiClient.delete;
|
||||
|
||||
await providersApi.createInteractionsKey({
|
||||
apiKey: 'interactions-new',
|
||||
priority: 4,
|
||||
weight: 2,
|
||||
prefix: 'native',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
proxyUrl: 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
excludedModels: ['gemini-2.5-*'],
|
||||
disableCooling: true,
|
||||
});
|
||||
await providersApi.deleteInteractionsKey(
|
||||
'interactions-new',
|
||||
'https://generativelanguage.googleapis.com'
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: '/config' },
|
||||
{
|
||||
method: 'PUT',
|
||||
url: '/interactions-api-key',
|
||||
data: [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
{
|
||||
'api-key': 'interactions-new',
|
||||
priority: 4,
|
||||
weight: 2,
|
||||
prefix: 'native',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'proxy-url': 'direct',
|
||||
'disable-cooling': true,
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
'excluded-models': ['gemini-2.5-*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: '/interactions-api-key?api-key=interactions-new&base-url=https%3A%2F%2Fgenerativelanguage.googleapis.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user