diff --git a/src/assets/icons/apikey-fun.png b/src/assets/icons/apikey-fun.png new file mode 100644 index 00000000..0364ec69 Binary files /dev/null and b/src/assets/icons/apikey-fun.png differ diff --git a/src/features/providers/ProvidersWorkbenchPage.tsx b/src/features/providers/ProvidersWorkbenchPage.tsx index bf05f649..ad5a5adc 100644 --- a/src/features/providers/ProvidersWorkbenchPage.tsx +++ b/src/features/providers/ProvidersWorkbenchPage.tsx @@ -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, diff --git a/src/features/providers/adapters.ts b/src/features/providers/adapters.ts index a8664f1a..edd38796 100644 --- a/src/features/providers/adapters.ts +++ b/src/features/providers/adapters.ts @@ -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): 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, + }; +} diff --git a/src/features/providers/brandLogos.ts b/src/features/providers/brandLogos.ts index 7b7ec931..1f592e82 100644 --- a/src/features/providers/brandLogos.ts +++ b/src/features/providers/brandLogos.ts @@ -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 = { codex: { src: codexLogo }, vertex: { src: vertexLogo }, openaiCompatibility: { src: openaiLogo, invertOnDark: true }, + apikeyFun: { src: apikeyFunLogo }, }; diff --git a/src/features/providers/components/ProviderCategoryList.module.scss b/src/features/providers/components/ProviderCategoryList.module.scss index c2bf432b..f8a54072 100644 --- a/src/features/providers/components/ProviderCategoryList.module.scss +++ b/src/features/providers/components/ProviderCategoryList.module.scss @@ -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); diff --git a/src/features/providers/components/ProviderCategoryList.tsx b/src/features/providers/components/ProviderCategoryList.tsx index f86bf458..8361d1c6 100644 --- a/src/features/providers/components/ProviderCategoryList.tsx +++ b/src/features/providers/components/ProviderCategoryList.tsx @@ -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 ( - + + + {total} + + + ); + })} + + ); + + return ( +
+ + {sponsorGroups.length > 0 ? ( + + ) : null} +
); } diff --git a/src/features/providers/components/ProviderResourcePanel.module.scss b/src/features/providers/components/ProviderResourcePanel.module.scss index a78bd868..a2c16448 100644 --- a/src/features/providers/components/ProviderResourcePanel.module.scss +++ b/src/features/providers/components/ProviderResourcePanel.module.scss @@ -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; diff --git a/src/features/providers/components/ProviderResourcePanel.tsx b/src/features/providers/components/ProviderResourcePanel.tsx index 8cfdcaaf..2fa1c123 100644 --- a/src/features/providers/components/ProviderResourcePanel.tsx +++ b/src/features/providers/components/ProviderResourcePanel.tsx @@ -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}`)} + {group.id === 'apikeyFun' ? ( + + {APIKEY_FUN_AFFILIATE_URL} + + ) : null}