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:
Supra4E8C
2026-06-24 15:23:31 +08:00
parent 68578569af
commit 4ebcf51873
21 changed files with 1033 additions and 69 deletions
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,
+103
View File
@@ -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,
};
}
+2
View File
@@ -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);
+20
View File
@@ -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;
+44
View File
@@ -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);
};
+18 -2
View File
@@ -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 区。
+243 -4
View File
@@ -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,
+17 -2
View File
@@ -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",
+17 -2
View File
@@ -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": "Отключён",
+17 -2
View File
@@ -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": "已停用",
+17 -2
View File
@@ -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": "已停用",