mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-21 06:06:24 +08:00
feat: add APIKEY.FUN provider support with detailed management and UI integration
- Introduced new provider 'apikeyFun' with support for API key entries, protocols, and configurations. - Implemented UI components for displaying and managing APIKEY.FUN resources, including a dedicated form for input. - Enhanced the ProviderResourcePanel and ProviderResourceTable to accommodate the new provider, including sponsor links and protocol summaries. - Updated localization files to include translations for the new provider and its features. - Refactored provider management logic to handle APIKEY.FUN entries alongside existing providers.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -73,6 +73,9 @@ const getResourceRecentSuccess = (
|
||||
resource: ProviderResource,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
): number => {
|
||||
if (resource.brand === 'apikeyFun') {
|
||||
return 0;
|
||||
}
|
||||
if (resource.brand === 'openaiCompatibility') {
|
||||
return getOpenAIProviderRecentWindowStats(
|
||||
resource.raw as OpenAIProviderConfig,
|
||||
|
||||
@@ -4,10 +4,17 @@ import {
|
||||
stripDisableAllModelsRule,
|
||||
} from '@/components/providers/utils';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
APIKEY_FUN_ANTHROPIC_BASE_URL,
|
||||
APIKEY_FUN_DISPLAY_NAME,
|
||||
APIKEY_FUN_OPENAI_BASE_URL,
|
||||
APIKEY_FUN_PROTOCOLS,
|
||||
} from './sponsor';
|
||||
import type {
|
||||
ProviderBrand,
|
||||
ProviderResource,
|
||||
ProviderResourceSelector,
|
||||
SponsorProviderRaw,
|
||||
} from './types';
|
||||
|
||||
const countHeaders = (headers?: Record<string, string>): number =>
|
||||
@@ -130,3 +137,99 @@ export function openaiToResource(
|
||||
raw: config,
|
||||
};
|
||||
}
|
||||
|
||||
export function apiKeyFunToResource(raw: SponsorProviderRaw): ProviderResource | null {
|
||||
if (raw.openai.length === 0 && raw.claude.length === 0 && raw.codex.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const openaiKeyCount = raw.openai.reduce(
|
||||
(count, item) => count + (item.config.apiKeyEntries?.length ?? 0),
|
||||
0
|
||||
);
|
||||
const codexKeyCount = raw.codex.length;
|
||||
const firstOpenAIEntry = raw.openai
|
||||
.flatMap((item) => item.config.apiKeyEntries ?? [])
|
||||
.find((entry) => entry.apiKey?.trim());
|
||||
const firstCodex = raw.codex.find((item) => item.config.apiKey?.trim());
|
||||
const firstClaude = raw.claude.find((item) => item.config.apiKey?.trim());
|
||||
const apiKey =
|
||||
firstOpenAIEntry?.apiKey ?? firstCodex?.config.apiKey ?? firstClaude?.config.apiKey ?? '';
|
||||
const openaiDisabled =
|
||||
raw.openai.length > 0 && raw.openai.every((item) => item.config.disabled === true);
|
||||
const codexDisabled =
|
||||
raw.codex.length > 0 &&
|
||||
raw.codex.every((item) => hasDisableAllModelsRule(item.config.excludedModels));
|
||||
const claudeDisabled =
|
||||
raw.claude.length > 0 &&
|
||||
raw.claude.every((item) => hasDisableAllModelsRule(item.config.excludedModels));
|
||||
const enabledCount =
|
||||
(raw.openai.length > 0 && !openaiDisabled ? 1 : 0) +
|
||||
(raw.codex.length > 0 && !codexDisabled ? 1 : 0) +
|
||||
(raw.claude.length > 0 && !claudeDisabled ? 1 : 0);
|
||||
const allResourcesConfigured =
|
||||
raw.openai.length > 0 || raw.codex.length > 0 || raw.claude.length > 0;
|
||||
const disabled = allResourcesConfigured && enabledCount === 0;
|
||||
const models = [
|
||||
...raw.openai.flatMap((item) => collectModelNames(item.config.models)),
|
||||
...raw.codex.flatMap((item) => collectModelNames(item.config.models)),
|
||||
...raw.claude.flatMap((item) => collectModelNames(item.config.models)),
|
||||
];
|
||||
const uniqueModels = Array.from(new Set(models));
|
||||
const headerCount =
|
||||
raw.openai.reduce((count, item) => count + countHeaders(item.config.headers), 0) +
|
||||
raw.codex.reduce((count, item) => count + countHeaders(item.config.headers), 0) +
|
||||
raw.claude.reduce((count, item) => count + countHeaders(item.config.headers), 0);
|
||||
const priority = Math.max(
|
||||
0,
|
||||
...raw.openai.map((item) => normalizePriority(item.config.priority)),
|
||||
...raw.codex.map((item) => normalizePriority(item.config.priority)),
|
||||
...raw.claude.map((item) => normalizePriority(item.config.priority))
|
||||
);
|
||||
|
||||
return {
|
||||
id: buildId('apikeyFun', 0, 'sponsor'),
|
||||
brand: 'apikeyFun',
|
||||
originalIndex: 0,
|
||||
name: APIKEY_FUN_DISPLAY_NAME,
|
||||
identifier: APIKEY_FUN_DISPLAY_NAME,
|
||||
apiKeyPreview: apiKey ? maskApiKey(apiKey) : null,
|
||||
apiKey: apiKey || null,
|
||||
authIndex: null,
|
||||
baseUrl: `${APIKEY_FUN_OPENAI_BASE_URL} / ${APIKEY_FUN_ANTHROPIC_BASE_URL}`,
|
||||
proxyUrl:
|
||||
firstOpenAIEntry?.proxyUrl ??
|
||||
raw.codex.find((item) => item.config.proxyUrl)?.config.proxyUrl ??
|
||||
raw.claude.find((item) => item.config.proxyUrl)?.config.proxyUrl ??
|
||||
null,
|
||||
prefix:
|
||||
raw.openai[0]?.config.prefix ??
|
||||
raw.codex[0]?.config.prefix ??
|
||||
raw.claude[0]?.config.prefix ??
|
||||
null,
|
||||
modelCount: uniqueModels.length,
|
||||
models: uniqueModels,
|
||||
priority,
|
||||
headerCount,
|
||||
excludedModelCount:
|
||||
raw.codex.reduce(
|
||||
(count, item) => count + stripDisableAllModelsRule(item.config.excludedModels).length,
|
||||
0
|
||||
) +
|
||||
raw.claude.reduce(
|
||||
(count, item) => count + stripDisableAllModelsRule(item.config.excludedModels).length,
|
||||
0
|
||||
),
|
||||
apiKeyEntryCount: openaiKeyCount + codexKeyCount + raw.claude.length,
|
||||
disabled,
|
||||
flags: {
|
||||
protocols: [...APIKEY_FUN_PROTOCOLS],
|
||||
},
|
||||
selector: {
|
||||
brand: 'apikeyFun',
|
||||
openaiIndices: raw.openai.map((item) => item.index),
|
||||
claudeIndices: raw.claude.map((item) => item.index),
|
||||
codexIndices: raw.codex.map((item) => item.index),
|
||||
} as ProviderResourceSelector,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import codexLogo from '@/assets/icons/codex.svg';
|
||||
import geminiLogo from '@/assets/icons/gemini.svg';
|
||||
import openaiLogo from '@/assets/icons/openai-light.svg';
|
||||
import vertexLogo from '@/assets/icons/vertex.svg';
|
||||
import apikeyFunLogo from '@/assets/icons/apikey-fun.png';
|
||||
import type { ProviderBrand } from './types';
|
||||
|
||||
export interface ProviderBrandLogo {
|
||||
@@ -16,4 +17,5 @@ export const PROVIDER_LOGOS: Record<ProviderBrand, ProviderBrandLogo> = {
|
||||
codex: { src: codexLogo },
|
||||
vertex: { src: vertexLogo },
|
||||
openaiCompatibility: { src: openaiLogo, invertOnDark: true },
|
||||
apikeyFun: { src: apikeyFunLogo },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
@use '../../../styles/mixins' as *;
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.aside {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -15,61 +15,75 @@ export function ProviderCategoryList({
|
||||
onSelect,
|
||||
}: ProviderCategoryListProps) {
|
||||
const { t } = useTranslation();
|
||||
const providerGroups = groups.filter((group) => group.id !== 'apikeyFun');
|
||||
const sponsorGroups = groups.filter((group) => group.id === 'apikeyFun');
|
||||
|
||||
return (
|
||||
<aside className={styles.aside}>
|
||||
<p className={styles.eyebrow}>{t('providersPage.categories.title')}</p>
|
||||
<div className={styles.list}>
|
||||
{groups.map((group) => {
|
||||
const active = group.id === activeBrand;
|
||||
const realResources = group.resources.filter(
|
||||
(r) => !r.flags.isPlaceholder
|
||||
);
|
||||
const total = realResources.length;
|
||||
const activeCount = realResources.filter((r) => !r.disabled).length;
|
||||
const logo = PROVIDER_LOGOS[group.id];
|
||||
const itemClass = `${styles.item} ${active ? styles.active : ''}`;
|
||||
const renderGroups = (items: ProviderGroup[]) => (
|
||||
<div className={styles.list}>
|
||||
{items.map((group) => {
|
||||
const active = group.id === activeBrand;
|
||||
const realResources = group.resources.filter(
|
||||
(r) => !r.flags.isPlaceholder
|
||||
);
|
||||
const total = realResources.length;
|
||||
const activeCount = realResources.filter((r) => !r.disabled).length;
|
||||
const logo = PROVIDER_LOGOS[group.id];
|
||||
const itemClass = `${styles.item} ${active ? styles.active : ''}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={itemClass}
|
||||
onClick={() => onSelect(group.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<span className={styles.itemLeft}>
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`${styles.logo} ${logo.invertOnDark ? styles.logoInvertOnDark : ''}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className={styles.itemText}>
|
||||
<span className={styles.itemTitle}>
|
||||
{t(`providersPage.providerNames.${group.id}`)}
|
||||
</span>
|
||||
<span className={styles.itemSubtitle}>
|
||||
{t('providersPage.categories.activeCount', {
|
||||
active: activeCount,
|
||||
total,
|
||||
})}
|
||||
</span>
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={itemClass}
|
||||
onClick={() => onSelect(group.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<span className={styles.itemLeft}>
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`${styles.logo} ${logo.invertOnDark ? styles.logoInvertOnDark : ''}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className={styles.itemText}>
|
||||
<span className={styles.itemTitle}>
|
||||
{t(`providersPage.providerNames.${group.id}`)}
|
||||
</span>
|
||||
<span className={styles.itemSubtitle}>
|
||||
{t('providersPage.categories.activeCount', {
|
||||
active: activeCount,
|
||||
total,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
total === 0 ? styles.badgeAmber : ''
|
||||
}`}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
total === 0 ? styles.badgeAmber : ''
|
||||
}`}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<aside className={styles.aside}>
|
||||
<p className={styles.eyebrow}>{t('providersPage.categories.title')}</p>
|
||||
{renderGroups(providerGroups)}
|
||||
</aside>
|
||||
{sponsorGroups.length > 0 ? (
|
||||
<aside className={styles.aside}>
|
||||
<p className={styles.eyebrow}>{t('providersPage.categories.sponsors')}</p>
|
||||
{renderGroups(sponsorGroups)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,28 @@
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.sponsorLink {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--primary-color);
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
max-width: min(520px, 100%);
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ProviderResourceToolbar } from './ProviderResourceToolbar';
|
||||
import type { ProviderSortBy, SortDir } from '../types';
|
||||
import styles from './ProviderResourcePanel.module.scss';
|
||||
|
||||
const APIKEY_FUN_AFFILIATE_URL = 'https://apikey.fun/register?aff=AKCPA';
|
||||
|
||||
export interface ProviderPanelControls {
|
||||
sortBy: ProviderSortBy;
|
||||
sortDir: SortDir;
|
||||
@@ -72,6 +74,16 @@ export function ProviderResourcePanel({
|
||||
{t(`providersPage.providerNames.${group.id}`)}
|
||||
</h2>
|
||||
</div>
|
||||
{group.id === 'apikeyFun' ? (
|
||||
<a
|
||||
className={styles.sponsorLink}
|
||||
href={APIKEY_FUN_AFFILIATE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{APIKEY_FUN_AFFILIATE_URL}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
|
||||
@@ -106,6 +106,12 @@ export function ProviderResourceTable({
|
||||
|
||||
const renderModelsSummary = (r: ProviderResource) => {
|
||||
const items: ReactNode[] = [];
|
||||
if (r.brand === 'apikeyFun') {
|
||||
(r.flags.protocols ?? []).forEach((protocol) => {
|
||||
items.push(renderFlagTag(protocol, t(`providersPage.sponsor.protocols.${protocol}`)));
|
||||
});
|
||||
return <div className={styles.metricsCell}>{items}</div>;
|
||||
}
|
||||
if (r.brand === 'openaiCompatibility') {
|
||||
items.push(
|
||||
renderMetric('models', t('providersPage.table.metrics.models'), r.modelCount),
|
||||
@@ -145,6 +151,16 @@ export function ProviderResourceTable({
|
||||
};
|
||||
|
||||
const renderPrimary = (r: ProviderResource) => {
|
||||
if (r.brand === 'apikeyFun') {
|
||||
return (
|
||||
<div className={styles.primaryCell}>
|
||||
<span className={styles.primaryName}>{r.name ?? r.identifier}</span>
|
||||
<span className={styles.primarySub}>
|
||||
{r.apiKeyPreview ?? t('providersPage.status.notConfigured')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (r.brand === 'openaiCompatibility') {
|
||||
const extra = r.apiKeyEntryCount > 1 ? ` · +${r.apiKeyEntryCount - 1}` : '';
|
||||
return (
|
||||
@@ -167,6 +183,13 @@ export function ProviderResourceTable({
|
||||
};
|
||||
|
||||
const renderBaseUrl = (r: ProviderResource) => {
|
||||
if (r.brand === 'apikeyFun') {
|
||||
return (
|
||||
<span className={styles.baseUrl}>
|
||||
{t('providersPage.sponsor.protocolSummary')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (r.brand === 'claude' && !r.baseUrl) {
|
||||
return (
|
||||
<span className={styles.baseUrl}>
|
||||
@@ -214,7 +237,7 @@ export function ProviderResourceTable({
|
||||
<TableCell>
|
||||
<div className={styles.statusCell}>
|
||||
{renderStatus(resource)}
|
||||
{usageByProvider ? (
|
||||
{usageByProvider && resource.brand !== 'apikeyFun' ? (
|
||||
<>
|
||||
{(() => {
|
||||
const stats = resolveTotalStats(resource, usageByProvider);
|
||||
|
||||
@@ -117,6 +117,25 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
|
||||
supportsApiKeyEntries: true,
|
||||
sheetSize: 'lg',
|
||||
},
|
||||
apikeyFun: {
|
||||
id: 'apikeyFun',
|
||||
supportsName: false,
|
||||
supportsApiKey: true,
|
||||
supportsDisabled: true,
|
||||
supportsBaseUrl: false,
|
||||
baseUrlRequired: false,
|
||||
supportsProxyUrl: true,
|
||||
supportsPrefix: true,
|
||||
supportsModels: false,
|
||||
supportsHeaders: false,
|
||||
supportsExcludedModels: false,
|
||||
supportsPriority: true,
|
||||
supportsTestModel: false,
|
||||
supportsWebsockets: false,
|
||||
supportsCloak: false,
|
||||
supportsApiKeyEntries: false,
|
||||
sheetSize: 'md',
|
||||
},
|
||||
};
|
||||
|
||||
export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
|
||||
@@ -125,4 +144,5 @@ export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
|
||||
'claude',
|
||||
'vertex',
|
||||
'openaiCompatibility',
|
||||
'apikeyFun',
|
||||
];
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { UseProviderWorkbenchResult } from '../useProviderWorkbench';
|
||||
import { BaseProviderForm } from './forms/BaseProviderForm';
|
||||
import { ResourceDetailView } from './ResourceDetailView';
|
||||
import { SponsorProviderForm } from './forms/SponsorProviderForm';
|
||||
import styles from './forms/sharedForm.module.scss';
|
||||
|
||||
type SheetMode = 'detail' | 'create' | 'edit';
|
||||
@@ -147,6 +148,19 @@ export function ProviderSheet({
|
||||
return <ResourceDetailView resource={state.resource} usageByProvider={usageByProvider} />;
|
||||
}
|
||||
const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`;
|
||||
if (state.brand === 'apikeyFun') {
|
||||
return (
|
||||
<SponsorProviderForm
|
||||
key={formKey}
|
||||
resource={state.resource}
|
||||
mode={state.mode}
|
||||
mutating={formMutating}
|
||||
formId={formId}
|
||||
onSubmit={state.mode === 'create' ? handleCreate : handleUpdate}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BaseProviderForm
|
||||
key={formKey}
|
||||
@@ -231,7 +245,12 @@ export function ProviderSheet({
|
||||
}
|
||||
title={titleText}
|
||||
description={t('providersPage.table.description', {
|
||||
route: `/ai-providers/${state.brand === 'openaiCompatibility' ? 'openai' : state.brand}`,
|
||||
route:
|
||||
state.brand === 'openaiCompatibility'
|
||||
? '/ai-providers/openai'
|
||||
: state.brand === 'apikeyFun'
|
||||
? '/ai-providers/sponsor/apikey-fun'
|
||||
: `/ai-providers/${state.brand}`,
|
||||
})}
|
||||
footer={footer}
|
||||
closeDisabled={submitting}
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from '@/components/providers/utils';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import type { ProviderResource } from '../types';
|
||||
import {
|
||||
APIKEY_FUN_ANTHROPIC_BASE_URL,
|
||||
APIKEY_FUN_CODEX_BASE_URL,
|
||||
APIKEY_FUN_OPENAI_BASE_URL,
|
||||
} from '../sponsor';
|
||||
import type { ProviderResource, SponsorProviderRaw } from '../types';
|
||||
import styles from './forms/sharedForm.module.scss';
|
||||
|
||||
interface ResourceDetailViewProps {
|
||||
@@ -18,6 +23,74 @@ interface ResourceDetailViewProps {
|
||||
export function ResourceDetailView({ resource, usageByProvider }: ResourceDetailViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (resource.brand === 'apikeyFun') {
|
||||
const raw = resource.raw as SponsorProviderRaw;
|
||||
const openaiKeyCount = raw.openai.reduce(
|
||||
(count, item) => count + (item.config.apiKeyEntries?.length ?? 0),
|
||||
0
|
||||
);
|
||||
const codexKeyCount = raw.codex.length;
|
||||
const firstKey =
|
||||
raw.openai
|
||||
.flatMap((item) => item.config.apiKeyEntries ?? [])
|
||||
.find((entry) => entry.apiKey?.trim())?.apiKey ??
|
||||
raw.codex.find((item) => item.config.apiKey?.trim())?.config.apiKey ??
|
||||
raw.claude.find((item) => item.config.apiKey?.trim())?.config.apiKey;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.sectionTitle}>{resource.name ?? resource.identifier}</div>
|
||||
<p className={styles.sectionDesc}>{t('providersPage.sponsor.detailHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.sponsorProtocolGrid}>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.anthropic')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_ANTHROPIC_BASE_URL}</span>
|
||||
</div>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.openai')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_OPENAI_BASE_URL}</span>
|
||||
</div>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.codexResponses')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_CODEX_BASE_URL}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className={styles.dl} style={{ marginTop: 16 }}>
|
||||
<div>
|
||||
<dt className={styles.dt}>{t('providersPage.detail.fields.identifier')}</dt>
|
||||
<dd className={styles.dd}>{firstKey ? maskApiKey(firstKey) : resource.identifier}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.dt}>{t('providersPage.detail.fields.prefix')}</dt>
|
||||
<dd className={styles.dd}>{resource.prefix ?? t('providersPage.status.none')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.dt}>{t('providersPage.sponsor.openaiEntries')}</dt>
|
||||
<dd className={styles.dd}>{openaiKeyCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.dt}>{t('providersPage.sponsor.codexEntries')}</dt>
|
||||
<dd className={styles.dd}>{codexKeyCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.dt}>{t('providersPage.sponsor.anthropicEntries')}</dt>
|
||||
<dd className={styles.dd}>{raw.claude.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primary: Array<[string, string]> = [
|
||||
['identifier', resource.identifier],
|
||||
['baseUrl', resource.baseUrl ?? t('providersPage.status.notSet')],
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import {
|
||||
APIKEY_FUN_ANTHROPIC_BASE_URL,
|
||||
APIKEY_FUN_CODEX_BASE_URL,
|
||||
APIKEY_FUN_OPENAI_BASE_URL,
|
||||
} from '../../sponsor';
|
||||
import type {
|
||||
ProviderEntryFormInput,
|
||||
ProviderResource,
|
||||
SponsorProviderRaw,
|
||||
} from '../../types';
|
||||
import styles from './sharedForm.module.scss';
|
||||
|
||||
interface SponsorProviderFormProps {
|
||||
resource: ProviderResource | null;
|
||||
mode: 'create' | 'edit';
|
||||
mutating: boolean;
|
||||
formId: string;
|
||||
onSubmit: (input: ProviderEntryFormInput) => Promise<void>;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const emptySponsorForm = (): ProviderEntryFormInput => ({
|
||||
apiKey: '',
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
prefix: '',
|
||||
disabled: false,
|
||||
disableCooling: false,
|
||||
priority: undefined,
|
||||
models: [],
|
||||
headers: [],
|
||||
excludedModelsText: '',
|
||||
});
|
||||
|
||||
const getSponsorRaw = (resource: ProviderResource | null): SponsorProviderRaw | null => {
|
||||
if (!resource || resource.brand !== 'apikeyFun') return null;
|
||||
return resource.raw as SponsorProviderRaw;
|
||||
};
|
||||
|
||||
const firstSponsorProxy = (raw: SponsorProviderRaw | null): string => {
|
||||
const openaiProxy = raw?.openai
|
||||
.flatMap((item) => item.config.apiKeyEntries ?? [])
|
||||
.find((entry) => entry.proxyUrl?.trim())?.proxyUrl;
|
||||
if (openaiProxy) return openaiProxy;
|
||||
const codexProxy = raw?.codex.find((item) => item.config.proxyUrl?.trim())?.config.proxyUrl;
|
||||
if (codexProxy) return codexProxy;
|
||||
return raw?.claude.find((item) => item.config.proxyUrl?.trim())?.config.proxyUrl ?? '';
|
||||
};
|
||||
|
||||
const buildInitialForm = (
|
||||
resource: ProviderResource | null,
|
||||
mode: 'create' | 'edit'
|
||||
): ProviderEntryFormInput => {
|
||||
if (mode === 'create') return emptySponsorForm();
|
||||
const raw = getSponsorRaw(resource);
|
||||
const openai = raw?.openai[0]?.config;
|
||||
const codex = raw?.codex[0]?.config;
|
||||
const claude = raw?.claude[0]?.config;
|
||||
return {
|
||||
...emptySponsorForm(),
|
||||
proxyUrl: firstSponsorProxy(raw),
|
||||
prefix: openai?.prefix ?? codex?.prefix ?? claude?.prefix ?? '',
|
||||
disabled: resource?.disabled === true,
|
||||
disableCooling:
|
||||
openai?.disableCooling === true ||
|
||||
codex?.disableCooling === true ||
|
||||
claude?.disableCooling === true,
|
||||
priority: openai?.priority ?? codex?.priority ?? claude?.priority,
|
||||
};
|
||||
};
|
||||
|
||||
export function SponsorProviderForm({
|
||||
resource,
|
||||
mode,
|
||||
mutating,
|
||||
formId,
|
||||
onSubmit,
|
||||
onDirtyChange,
|
||||
}: SponsorProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderEntryFormInput>(() =>
|
||||
buildInitialForm(resource, mode)
|
||||
);
|
||||
const [initialFormSignature] = useState<string>(() =>
|
||||
JSON.stringify(buildInitialForm(resource, mode))
|
||||
);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(form) !== initialFormSignature,
|
||||
[form, initialFormSignature]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const updateField = <K extends keyof ProviderEntryFormInput>(
|
||||
key: K,
|
||||
value: ProviderEntryFormInput[K]
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (mode === 'create' && !form.apiKey.trim()) {
|
||||
setError(t('providersPage.form.validation.apiKeyRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
await onSubmit(form);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form id={formId} className={styles.form} onSubmit={handleSubmit} noValidate>
|
||||
<div className={styles.sponsorProtocolGrid}>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.anthropic')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_ANTHROPIC_BASE_URL}</span>
|
||||
</div>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.openai')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_OPENAI_BASE_URL}</span>
|
||||
</div>
|
||||
<div className={styles.sponsorProtocolCard}>
|
||||
<span className={styles.sponsorProtocolName}>
|
||||
{t('providersPage.sponsor.protocols.codexResponses')}
|
||||
</span>
|
||||
<span className={styles.sponsorProtocolUrl}>{APIKEY_FUN_CODEX_BASE_URL}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={`${formId}-api-key`}>
|
||||
{t('providersPage.form.apiKey')}
|
||||
</label>
|
||||
<div className={styles.passwordField}>
|
||||
<input
|
||||
id={`${formId}-api-key`}
|
||||
className={styles.passwordInput}
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.apiKey}
|
||||
onChange={(event) => updateField('apiKey', event.target.value)}
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
placeholder={
|
||||
mode === 'edit'
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowApiKey((value) => !value)}
|
||||
disabled={mutating}
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
title={
|
||||
showApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
>
|
||||
{showApiKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.labelHint}>{t('providersPage.sponsor.apiKeyHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={`${formId}-proxy`}>
|
||||
{t('providersPage.form.proxyUrl')}
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-proxy`}
|
||||
className={styles.input}
|
||||
value={form.proxyUrl}
|
||||
onChange={(event) => updateField('proxyUrl', event.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={`${formId}-prefix`}>
|
||||
{t('providersPage.form.prefix')}
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-prefix`}
|
||||
className={styles.input}
|
||||
value={form.prefix}
|
||||
onChange={(event) => updateField('prefix', event.target.value)}
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={`${formId}-priority`}>
|
||||
{t('providersPage.form.priority')}
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-priority`}
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={form.priority ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField(
|
||||
'priority',
|
||||
event.target.value === '' ? undefined : Number(event.target.value)
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={form.disabled}
|
||||
disabled={mutating}
|
||||
onChange={(event) => updateField('disabled', event.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.disabled')}</span>
|
||||
<small>{t('providersPage.form.disabledHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={form.disableCooling ?? false}
|
||||
disabled={mutating}
|
||||
onChange={(event) => updateField('disableCooling', event.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.disableCooling')}</span>
|
||||
<small>{t('providersPage.form.disableCoolingHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.errorBox}>{error}</div> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,41 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sponsorProtocolGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
|
||||
@media (min-width: 560px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.sponsorProtocolCard {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sponsorProtocolName {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sponsorProtocolUrl {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted-foreground);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.connectivityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
|
||||
export const APIKEY_FUN_PROVIDER_NAME = 'apikeyFun';
|
||||
export const APIKEY_FUN_DISPLAY_NAME = 'APIKEY.FUN';
|
||||
export const APIKEY_FUN_OPENAI_BASE_URL = 'https://api.apikey.fun/v1';
|
||||
export const APIKEY_FUN_CODEX_BASE_URL = APIKEY_FUN_OPENAI_BASE_URL;
|
||||
export const APIKEY_FUN_ANTHROPIC_BASE_URL = 'https://api.apikey.fun';
|
||||
|
||||
export const APIKEY_FUN_PROTOCOLS = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'codexResponses',
|
||||
] as const;
|
||||
export type ApiKeyFunProtocol = (typeof APIKEY_FUN_PROTOCOLS)[number];
|
||||
|
||||
const normalizeText = (value: string | undefined | null): string =>
|
||||
String(value ?? '').trim().toLowerCase();
|
||||
|
||||
const normalizeBaseUrl = (value: string | undefined | null): string =>
|
||||
normalizeText(value).replace(/\/+$/, '');
|
||||
|
||||
export const isApiKeyFunOpenAIProvider = (
|
||||
config: OpenAIProviderConfig | undefined | null
|
||||
): boolean => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
normalizeText(config.name) === normalizeText(APIKEY_FUN_PROVIDER_NAME) ||
|
||||
normalizeBaseUrl(config.baseUrl) === normalizeBaseUrl(APIKEY_FUN_OPENAI_BASE_URL)
|
||||
);
|
||||
};
|
||||
|
||||
export const isApiKeyFunClaudeProvider = (
|
||||
config: ProviderKeyConfig | undefined | null
|
||||
): boolean => {
|
||||
if (!config) return false;
|
||||
return normalizeBaseUrl(config.baseUrl) === normalizeBaseUrl(APIKEY_FUN_ANTHROPIC_BASE_URL);
|
||||
};
|
||||
|
||||
export const isApiKeyFunCodexProvider = (
|
||||
config: ProviderKeyConfig | undefined | null
|
||||
): boolean => {
|
||||
if (!config) return false;
|
||||
return normalizeBaseUrl(config.baseUrl) === normalizeBaseUrl(APIKEY_FUN_CODEX_BASE_URL);
|
||||
};
|
||||
@@ -2,12 +2,15 @@
|
||||
* AI 提供商 Workbench 视图模型(归一化各 brand 的异构 config)
|
||||
*/
|
||||
|
||||
import type { OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
|
||||
export type ProviderBrand =
|
||||
| 'gemini'
|
||||
| 'codex'
|
||||
| 'claude'
|
||||
| 'vertex'
|
||||
| 'openaiCompatibility';
|
||||
| 'openaiCompatibility'
|
||||
| 'apikeyFun';
|
||||
|
||||
export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const;
|
||||
export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number];
|
||||
@@ -20,12 +23,19 @@ export type ProviderResourceSelector =
|
||||
| { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'claude'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'vertex'; apiKey: string; baseUrl?: string; index: number }
|
||||
| { brand: 'openaiCompatibility'; name: string; index: number };
|
||||
| { brand: 'openaiCompatibility'; name: string; index: number }
|
||||
| {
|
||||
brand: 'apikeyFun';
|
||||
openaiIndices: number[];
|
||||
claudeIndices: number[];
|
||||
codexIndices: number[];
|
||||
};
|
||||
|
||||
export interface ProviderResourceFlags {
|
||||
cloakEnabled?: boolean;
|
||||
websockets?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface ProviderResource {
|
||||
@@ -75,6 +85,12 @@ export interface ProviderSnapshot {
|
||||
groups: ProviderGroup[];
|
||||
}
|
||||
|
||||
export interface SponsorProviderRaw {
|
||||
openai: Array<{ config: OpenAIProviderConfig; index: number }>;
|
||||
claude: Array<{ config: ProviderKeyConfig; index: number }>;
|
||||
codex: Array<{ config: ProviderKeyConfig; index: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Sheet 表单值。
|
||||
* Gemini/Codex/Claude/Vertex/OpenAI 共用基础字段,各自启用 advanced 区。
|
||||
|
||||
@@ -3,15 +3,18 @@ import { providersApi } from '@/services/api';
|
||||
import { getErrorMessage } from '@/utils/helpers';
|
||||
import { useAuthStore, useConfigStore } from '@/stores';
|
||||
import {
|
||||
stripDisableAllModelsRule,
|
||||
withDisableAllModelsRule,
|
||||
withoutDisableAllModelsRule,
|
||||
} from '@/components/providers/utils';
|
||||
import type {
|
||||
Config,
|
||||
GeminiKeyConfig,
|
||||
OpenAIProviderConfig,
|
||||
ProviderKeyConfig,
|
||||
} from '@/types';
|
||||
import {
|
||||
apiKeyFunToResource,
|
||||
claudeToResource,
|
||||
codexToResource,
|
||||
geminiToResource,
|
||||
@@ -25,7 +28,17 @@ import type {
|
||||
ProviderGroup,
|
||||
ProviderResource,
|
||||
ProviderSnapshot,
|
||||
SponsorProviderRaw,
|
||||
} from './types';
|
||||
import {
|
||||
APIKEY_FUN_ANTHROPIC_BASE_URL,
|
||||
APIKEY_FUN_CODEX_BASE_URL,
|
||||
APIKEY_FUN_OPENAI_BASE_URL,
|
||||
APIKEY_FUN_PROVIDER_NAME,
|
||||
isApiKeyFunClaudeProvider,
|
||||
isApiKeyFunCodexProvider,
|
||||
isApiKeyFunOpenAIProvider,
|
||||
} from './sponsor';
|
||||
|
||||
export interface UseProviderWorkbenchResult {
|
||||
connected: boolean;
|
||||
@@ -180,6 +193,123 @@ const buildOpenAIConfig = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildApiKeyFunRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
|
||||
openai: (config?.openaiCompatibility ?? [])
|
||||
.map((item, index) => ({ config: item, index }))
|
||||
.filter((item) => isApiKeyFunOpenAIProvider(item.config)),
|
||||
claude: (config?.claudeApiKeys ?? [])
|
||||
.map((item, index) => ({ config: item, index }))
|
||||
.filter((item) => isApiKeyFunClaudeProvider(item.config)),
|
||||
codex: (config?.codexApiKeys ?? [])
|
||||
.map((item, index) => ({ config: item, index }))
|
||||
.filter((item) => isApiKeyFunCodexProvider(item.config)),
|
||||
});
|
||||
|
||||
const firstApiKeyFunKey = (raw: SponsorProviderRaw): string => {
|
||||
const openaiKey = raw.openai
|
||||
.flatMap((item) => item.config.apiKeyEntries ?? [])
|
||||
.find((entry) => entry.apiKey?.trim())?.apiKey;
|
||||
if (openaiKey) return openaiKey;
|
||||
const codexKey = raw.codex.find((item) => item.config.apiKey?.trim())?.config.apiKey;
|
||||
if (codexKey) return codexKey;
|
||||
return raw.claude.find((item) => item.config.apiKey?.trim())?.config.apiKey ?? '';
|
||||
};
|
||||
|
||||
const replaceSponsorEntries = <T>(
|
||||
list: T[],
|
||||
indices: number[],
|
||||
nextEntry: T
|
||||
): T[] => {
|
||||
const sponsorIndices = new Set(indices);
|
||||
const out: T[] = [];
|
||||
let inserted = false;
|
||||
list.forEach((item, index) => {
|
||||
if (!sponsorIndices.has(index)) {
|
||||
out.push(item);
|
||||
return;
|
||||
}
|
||||
if (!inserted) {
|
||||
out.push(nextEntry);
|
||||
inserted = true;
|
||||
}
|
||||
});
|
||||
if (!inserted) out.push(nextEntry);
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildApiKeyFunOpenAIConfig = (
|
||||
input: ProviderEntryFormInput,
|
||||
raw: SponsorProviderRaw
|
||||
): OpenAIProviderConfig => {
|
||||
const existing = raw.openai[0]?.config;
|
||||
const existingEntries = existing?.apiKeyEntries ?? [];
|
||||
const apiKey = input.apiKey.trim() || firstApiKeyFunKey(raw);
|
||||
const proxyUrl = input.proxyUrl.trim() || undefined;
|
||||
const firstEntry = {
|
||||
...(existingEntries[0] ?? {}),
|
||||
apiKey,
|
||||
proxyUrl,
|
||||
};
|
||||
const apiKeyEntries = apiKey
|
||||
? [firstEntry, ...existingEntries.slice(1)]
|
||||
: existingEntries;
|
||||
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
name: APIKEY_FUN_PROVIDER_NAME,
|
||||
baseUrl: APIKEY_FUN_OPENAI_BASE_URL,
|
||||
prefix: input.prefix.trim() || undefined,
|
||||
disabled: input.disabled,
|
||||
disableCooling: input.disableCooling === true,
|
||||
priority: input.priority,
|
||||
apiKeyEntries,
|
||||
};
|
||||
};
|
||||
|
||||
const buildApiKeyFunClaudeConfig = (
|
||||
input: ProviderEntryFormInput,
|
||||
raw: SponsorProviderRaw
|
||||
): ProviderKeyConfig => {
|
||||
const existing = raw.claude[0]?.config;
|
||||
const apiKey = input.apiKey.trim() || firstApiKeyFunKey(raw);
|
||||
const excluded = input.disabled
|
||||
? withDisableAllModelsRule(stripDisableAllModelsRule(existing?.excludedModels))
|
||||
: withoutDisableAllModelsRule(existing?.excludedModels);
|
||||
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
apiKey,
|
||||
baseUrl: APIKEY_FUN_ANTHROPIC_BASE_URL,
|
||||
proxyUrl: input.proxyUrl.trim() || undefined,
|
||||
prefix: input.prefix.trim() || undefined,
|
||||
priority: input.priority,
|
||||
disableCooling: input.disableCooling === true,
|
||||
excludedModels: excluded,
|
||||
};
|
||||
};
|
||||
|
||||
const buildApiKeyFunCodexConfig = (
|
||||
input: ProviderEntryFormInput,
|
||||
raw: SponsorProviderRaw
|
||||
): ProviderKeyConfig => {
|
||||
const existing = raw.codex[0]?.config;
|
||||
const apiKey = input.apiKey.trim() || firstApiKeyFunKey(raw);
|
||||
const excluded = input.disabled
|
||||
? withDisableAllModelsRule(stripDisableAllModelsRule(existing?.excludedModels))
|
||||
: withoutDisableAllModelsRule(existing?.excludedModels);
|
||||
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
apiKey,
|
||||
baseUrl: APIKEY_FUN_CODEX_BASE_URL,
|
||||
proxyUrl: input.proxyUrl.trim() || undefined,
|
||||
prefix: input.prefix.trim() || undefined,
|
||||
priority: input.priority,
|
||||
disableCooling: input.disableCooling === true,
|
||||
excludedModels: excluded,
|
||||
};
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -250,17 +380,46 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
resources = (config.geminiApiKeys ?? []).map((c, i) => geminiToResource(c, i));
|
||||
break;
|
||||
case 'codex':
|
||||
resources = (config.codexApiKeys ?? []).map((c, i) => codexToResource(c, i));
|
||||
resources = (config.codexApiKeys ?? []).reduce<ProviderResource[]>(
|
||||
(out, item, index) => {
|
||||
if (!isApiKeyFunCodexProvider(item)) {
|
||||
out.push(codexToResource(item, index));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'claude':
|
||||
resources = (config.claudeApiKeys ?? []).map((c, i) => claudeToResource(c, i));
|
||||
resources = (config.claudeApiKeys ?? []).reduce<ProviderResource[]>(
|
||||
(out, item, index) => {
|
||||
if (!isApiKeyFunClaudeProvider(item)) {
|
||||
out.push(claudeToResource(item, index));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'vertex':
|
||||
resources = (config.vertexApiKeys ?? []).map((c, i) => vertexToResource(c, i));
|
||||
break;
|
||||
case 'openaiCompatibility':
|
||||
resources = (config.openaiCompatibility ?? []).map((c, i) => openaiToResource(c, i));
|
||||
resources = (config.openaiCompatibility ?? []).reduce<ProviderResource[]>(
|
||||
(out, item, index) => {
|
||||
if (!isApiKeyFunOpenAIProvider(item)) {
|
||||
out.push(openaiToResource(item, index));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'apikeyFun': {
|
||||
const sponsorResource = apiKeyFunToResource(buildApiKeyFunRaw(config));
|
||||
resources = sponsorResource ? [sponsorResource] : [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: brand,
|
||||
@@ -315,6 +474,38 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
[updateConfigValue]
|
||||
);
|
||||
|
||||
const persistApiKeyFunConfig = useCallback(
|
||||
async (input: ProviderEntryFormInput) => {
|
||||
const raw = buildApiKeyFunRaw(config);
|
||||
const openaiList = config?.openaiCompatibility ?? [];
|
||||
const claudeList = config?.claudeApiKeys ?? [];
|
||||
const codexList = config?.codexApiKeys ?? [];
|
||||
const nextOpenAIConfig = buildApiKeyFunOpenAIConfig(input, raw);
|
||||
const nextClaudeConfig = buildApiKeyFunClaudeConfig(input, raw);
|
||||
const nextCodexConfig = buildApiKeyFunCodexConfig(input, raw);
|
||||
const nextOpenAIList = replaceSponsorEntries(
|
||||
openaiList,
|
||||
raw.openai.map((item) => item.index),
|
||||
nextOpenAIConfig
|
||||
);
|
||||
const nextClaudeList = replaceSponsorEntries(
|
||||
claudeList,
|
||||
raw.claude.map((item) => item.index),
|
||||
nextClaudeConfig
|
||||
);
|
||||
const nextCodexList = replaceSponsorEntries(
|
||||
codexList,
|
||||
raw.codex.map((item) => item.index),
|
||||
nextCodexConfig
|
||||
);
|
||||
|
||||
await persistCodexConfigs(nextCodexList);
|
||||
await persistClaudeConfigs(nextClaudeList);
|
||||
await persistOpenAIConfigs(nextOpenAIList);
|
||||
},
|
||||
[config, persistClaudeConfigs, persistCodexConfigs, persistOpenAIConfigs]
|
||||
);
|
||||
|
||||
const createProvider = useCallback(
|
||||
async (brand: ProviderBrand, input: ProviderEntryFormInput) => {
|
||||
setMutating(true);
|
||||
@@ -339,6 +530,8 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
const next = [...(config?.openaiCompatibility ?? [])];
|
||||
next.push(buildOpenAIConfig(input));
|
||||
await persistOpenAIConfigs(next);
|
||||
} else if (brand === 'apikeyFun') {
|
||||
await persistApiKeyFunConfig(input);
|
||||
}
|
||||
refreshSnapshot();
|
||||
} finally {
|
||||
@@ -351,6 +544,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
persistCodexConfigs,
|
||||
persistGeminiKeys,
|
||||
persistOpenAIConfigs,
|
||||
persistApiKeyFunConfig,
|
||||
persistVertexConfigs,
|
||||
refreshSnapshot,
|
||||
]
|
||||
@@ -387,6 +581,8 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
const existing = list[idx];
|
||||
list[idx] = buildOpenAIConfig(input, existing);
|
||||
await persistOpenAIConfigs(list);
|
||||
} else if (brand === 'apikeyFun') {
|
||||
await persistApiKeyFunConfig(input);
|
||||
}
|
||||
refreshSnapshot();
|
||||
} finally {
|
||||
@@ -399,6 +595,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
persistCodexConfigs,
|
||||
persistGeminiKeys,
|
||||
persistOpenAIConfigs,
|
||||
persistApiKeyFunConfig,
|
||||
persistVertexConfigs,
|
||||
refreshSnapshot,
|
||||
]
|
||||
@@ -429,13 +626,33 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
await providersApi.deleteOpenAIProvider(sel.index);
|
||||
const next = (config?.openaiCompatibility ?? []).filter((_, i) => i !== sel.index);
|
||||
updateConfigValue('openai-compatibility', next);
|
||||
} else if (sel.brand === 'apikeyFun') {
|
||||
const nextClaude = (config?.claudeApiKeys ?? []).filter(
|
||||
(_, index) => !sel.claudeIndices.includes(index)
|
||||
);
|
||||
const nextCodex = (config?.codexApiKeys ?? []).filter(
|
||||
(_, index) => !sel.codexIndices.includes(index)
|
||||
);
|
||||
const nextOpenAI = (config?.openaiCompatibility ?? []).filter(
|
||||
(_, index) => !sel.openaiIndices.includes(index)
|
||||
);
|
||||
await persistCodexConfigs(nextCodex);
|
||||
await persistClaudeConfigs(nextClaude);
|
||||
await persistOpenAIConfigs(nextOpenAI);
|
||||
}
|
||||
refreshSnapshot();
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[config, refreshSnapshot, updateConfigValue]
|
||||
[
|
||||
config,
|
||||
persistClaudeConfigs,
|
||||
persistCodexConfigs,
|
||||
persistOpenAIConfigs,
|
||||
refreshSnapshot,
|
||||
updateConfigValue,
|
||||
]
|
||||
);
|
||||
|
||||
const toggleDisabled = useCallback(
|
||||
@@ -478,6 +695,27 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
list[idx] = { ...current, disabled };
|
||||
updateConfigValue('openai-compatibility', list);
|
||||
}
|
||||
} else if (brand === 'apikeyFun') {
|
||||
const claudeList = (config?.claudeApiKeys ?? []).map((item) => {
|
||||
if (!isApiKeyFunClaudeProvider(item)) return item;
|
||||
const excluded = disabled
|
||||
? withDisableAllModelsRule(item.excludedModels)
|
||||
: withoutDisableAllModelsRule(item.excludedModels);
|
||||
return { ...item, excludedModels: excluded };
|
||||
});
|
||||
const codexList = (config?.codexApiKeys ?? []).map((item) => {
|
||||
if (!isApiKeyFunCodexProvider(item)) return item;
|
||||
const excluded = disabled
|
||||
? withDisableAllModelsRule(item.excludedModels)
|
||||
: withoutDisableAllModelsRule(item.excludedModels);
|
||||
return { ...item, excludedModels: excluded };
|
||||
});
|
||||
const openaiList = (config?.openaiCompatibility ?? []).map((item) =>
|
||||
isApiKeyFunOpenAIProvider(item) ? { ...item, disabled } : item
|
||||
);
|
||||
await persistCodexConfigs(codexList);
|
||||
await persistClaudeConfigs(claudeList);
|
||||
await persistOpenAIConfigs(openaiList);
|
||||
}
|
||||
refreshSnapshot();
|
||||
} finally {
|
||||
@@ -489,6 +727,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
persistClaudeConfigs,
|
||||
persistCodexConfigs,
|
||||
persistGeminiKeys,
|
||||
persistOpenAIConfigs,
|
||||
persistVertexConfigs,
|
||||
refreshSnapshot,
|
||||
updateConfigValue,
|
||||
|
||||
@@ -1433,14 +1433,16 @@
|
||||
},
|
||||
"categories": {
|
||||
"title": "Providers",
|
||||
"activeCount": "{{active}}/{{total}} active"
|
||||
"activeCount": "{{active}}/{{total}} active",
|
||||
"sponsors": "Sponsors"
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"codex": "Codex",
|
||||
"claude": "Claude",
|
||||
"vertex": "Vertex",
|
||||
"openaiCompatibility": "OpenAI Compatible"
|
||||
"openaiCompatibility": "OpenAI Compatible",
|
||||
"apikeyFun": "APIKEY.FUN"
|
||||
},
|
||||
"table": {
|
||||
"key": "Key",
|
||||
@@ -1461,6 +1463,19 @@
|
||||
"description": "Manage resources under {{route}}",
|
||||
"providerIssue": "This provider has an issue"
|
||||
},
|
||||
"sponsor": {
|
||||
"protocolSummary": "Anthropic / OpenAI / Codex API (Responses)",
|
||||
"detailHint": "This sponsor is persisted as Claude API key, OpenAI-compatible, and Codex API key entries; this view manages them together.",
|
||||
"apiKeyHint": "Saving writes the underlying config for Anthropic, OpenAI-compatible, and Codex API (Responses) access.",
|
||||
"openaiEntries": "OpenAI-compatible keys",
|
||||
"codexEntries": "Codex API keys",
|
||||
"anthropicEntries": "Anthropic keys",
|
||||
"protocols": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI Compatible",
|
||||
"codexResponses": "Codex API (Responses)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"disabled": "Disabled",
|
||||
|
||||
@@ -1411,14 +1411,16 @@
|
||||
},
|
||||
"categories": {
|
||||
"title": "Провайдеры",
|
||||
"activeCount": "{{active}}/{{total}} активных"
|
||||
"activeCount": "{{active}}/{{total}} активных",
|
||||
"sponsors": "Спонсоры"
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"codex": "Codex",
|
||||
"claude": "Claude",
|
||||
"vertex": "Vertex",
|
||||
"openaiCompatibility": "OpenAI-совместимый"
|
||||
"openaiCompatibility": "OpenAI-совместимый",
|
||||
"apikeyFun": "APIKEY.FUN"
|
||||
},
|
||||
"table": {
|
||||
"key": "Ключ",
|
||||
@@ -1439,6 +1441,19 @@
|
||||
"description": "Управление ресурсами {{route}}",
|
||||
"providerIssue": "Проблема с провайдером"
|
||||
},
|
||||
"sponsor": {
|
||||
"protocolSummary": "Anthropic / OpenAI / Codex API (Responses)",
|
||||
"detailHint": "Этот спонсор сохраняется как записи Claude API key, OpenAI-compatible и Codex API key; здесь они управляются вместе.",
|
||||
"apiKeyHint": "Сохранение запишет базовые настройки для Anthropic, OpenAI-compatible и Codex API (Responses).",
|
||||
"openaiEntries": "Ключей OpenAI-compatible",
|
||||
"codexEntries": "Ключей Codex API",
|
||||
"anthropicEntries": "Ключей Anthropic",
|
||||
"protocols": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI-compatible",
|
||||
"codexResponses": "Codex API (Responses)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
"disabled": "Отключён",
|
||||
|
||||
@@ -1433,14 +1433,16 @@
|
||||
},
|
||||
"categories": {
|
||||
"title": "提供商",
|
||||
"activeCount": "{{active}}/{{total}} 活跃"
|
||||
"activeCount": "{{active}}/{{total}} 活跃",
|
||||
"sponsors": "赞助商"
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"codex": "Codex",
|
||||
"claude": "Claude",
|
||||
"vertex": "Vertex",
|
||||
"openaiCompatibility": "OpenAI 兼容"
|
||||
"openaiCompatibility": "OpenAI 兼容",
|
||||
"apikeyFun": "APIKEY.FUN"
|
||||
},
|
||||
"table": {
|
||||
"key": "密钥",
|
||||
@@ -1461,6 +1463,19 @@
|
||||
"description": "在 {{route}} 下管理资源",
|
||||
"providerIssue": "此提供商存在问题"
|
||||
},
|
||||
"sponsor": {
|
||||
"protocolSummary": "Anthropic / OpenAI / Codex API (Responses)",
|
||||
"detailHint": "此赞助商会分别写入 Claude API Key、OpenAI 兼容配置与 Codex API Key,这里只做聚合管理。",
|
||||
"apiKeyHint": "保存后会同步写入 Anthropic、OpenAI 兼容和 Codex API (Responses) 可用的底层配置。",
|
||||
"openaiEntries": "OpenAI 兼容密钥数",
|
||||
"codexEntries": "Codex API 密钥数",
|
||||
"anthropicEntries": "Anthropic 密钥数",
|
||||
"protocols": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI 兼容",
|
||||
"codexResponses": "Codex API (Responses)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"active": "活跃",
|
||||
"disabled": "已停用",
|
||||
|
||||
@@ -1459,14 +1459,16 @@
|
||||
},
|
||||
"categories": {
|
||||
"title": "提供商",
|
||||
"activeCount": "{{active}}/{{total}} 活躍"
|
||||
"activeCount": "{{active}}/{{total}} 活躍",
|
||||
"sponsors": "贊助商"
|
||||
},
|
||||
"providerNames": {
|
||||
"gemini": "Gemini",
|
||||
"codex": "Codex",
|
||||
"claude": "Claude",
|
||||
"vertex": "Vertex",
|
||||
"openaiCompatibility": "OpenAI 相容"
|
||||
"openaiCompatibility": "OpenAI 相容",
|
||||
"apikeyFun": "APIKEY.FUN"
|
||||
},
|
||||
"table": {
|
||||
"key": "金鑰",
|
||||
@@ -1487,6 +1489,19 @@
|
||||
"description": "在 {{route}} 下管理資源",
|
||||
"providerIssue": "此提供商存在問題"
|
||||
},
|
||||
"sponsor": {
|
||||
"protocolSummary": "Anthropic / OpenAI / Codex API (Responses)",
|
||||
"detailHint": "此贊助商會分別寫入 Claude API Key、OpenAI 相容設定與 Codex API Key,此處只做聚合管理。",
|
||||
"apiKeyHint": "儲存後會同步寫入 Anthropic、OpenAI 相容和 Codex API (Responses) 可用的底層設定。",
|
||||
"openaiEntries": "OpenAI 相容金鑰數",
|
||||
"codexEntries": "Codex API 金鑰數",
|
||||
"anthropicEntries": "Anthropic 金鑰數",
|
||||
"protocols": {
|
||||
"anthropic": "Anthropic",
|
||||
"openai": "OpenAI 相容",
|
||||
"codexResponses": "Codex API (Responses)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"active": "活躍",
|
||||
"disabled": "已停用",
|
||||
|
||||
Reference in New Issue
Block a user