feat(interactions): add support for Interactions API, including endpoints, payloads, and configuration management

This commit is contained in:
Supra4E8C
2026-07-30 08:05:30 +08:00
parent 90971e8f6a
commit 530b585bd0
23 changed files with 415 additions and 31 deletions
+29
View File
@@ -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>();
+1
View File
@@ -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;
+5 -1
View File
@@ -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);
}
+1
View File
@@ -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
);
+20
View File
@@ -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,
+2
View File
@@ -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 }
+31 -1
View File
@@ -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') ||
+1
View File
@@ -1394,6 +1394,7 @@
},
"providerNames": {
"gemini": "Gemini",
"interactions": "Interactions API",
"codex": "Codex",
"xai": "xAI",
"claude": "Claude",
+1
View File
@@ -1372,6 +1372,7 @@
},
"providerNames": {
"gemini": "Gemini",
"interactions": "Interactions API",
"codex": "Codex",
"xai": "xAI",
"claude": "Claude",
+1
View File
@@ -1394,6 +1394,7 @@
},
"providerNames": {
"gemini": "Gemini",
"interactions": "Interactions API",
"codex": "Codex",
"xai": "xAI",
"claude": "Claude",
+1
View File
@@ -1420,6 +1420,7 @@
},
"providerNames": {
"gemini": "Gemini",
"interactions": "Interactions API",
"codex": "Codex",
"xai": "xAI",
"claude": "Claude",
+21
View File
@@ -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) =>
+7
View File
@@ -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
+3
View File
@@ -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;
+2
View File
@@ -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'
+15
View File
@@ -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', () => {
+225
View File
@@ -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',
},
]);
});
});