{
e.preventDefault()
setEditing(true)
- setEditingApiMode(apiMode)
+ const isCustomApiMode = apiMode.groupName === 'customApiModelKeys'
+ const providerId = isCustomApiMode
+ ? resolveEditingProviderSelection(
+ apiMode.providerId,
+ effectiveProviders,
+ LEGACY_CUSTOM_PROVIDER_ID,
+ )
+ : ''
+ setEditingApiMode({
+ ...defaultApiMode,
+ ...apiMode,
+ providerId,
+ })
+ setProviderSelector(isCustomApiMode ? providerId : LEGACY_CUSTOM_PROVIDER_ID)
+ setProviderSelectionValidation(isCustomApiMode && !providerId)
+ setProviderDraft(defaultProviderDraft)
+ setProviderDraftValidation(defaultProviderDraftValidation)
+ setIsProviderEditorOpen(false)
+ setProviderEditingId('')
+ clearPendingProviderChanges()
setEditingIndex(index)
}}
>
@@ -223,6 +750,13 @@ export function ApiModes({ config, updateConfig }) {
e.preventDefault()
setEditing(true)
setEditingApiMode(defaultApiMode)
+ setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID)
+ setProviderSelectionValidation(false)
+ setProviderDraft(defaultProviderDraft)
+ setProviderDraftValidation(defaultProviderDraftValidation)
+ setIsProviderEditorOpen(false)
+ setProviderEditingId('')
+ clearPendingProviderChanges()
setEditingIndex(-1)
}}
>
diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx
index 22b46513d..fdcf7efb2 100644
--- a/src/popup/sections/GeneralPart.jsx
+++ b/src/popup/sections/GeneralPart.jsx
@@ -1,16 +1,9 @@
import { useTranslation } from 'react-i18next'
-import { useLayoutEffect, useState } from 'react'
+import { useLayoutEffect, useRef, useState } from 'react'
import FileSaver from 'file-saver'
+import { openUrl, isApiModeSelected, getApiModesFromConfig } from '../../utils/index.mjs'
import {
- modelNameToDesc,
- isApiModeSelected,
- getApiModesFromConfig,
- apiModeToModelName,
-} from '../../utils/index.mjs'
-import {
- isUsingOpenAiApiModel,
isUsingAzureOpenAiApiModel,
- isUsingChatGLMApiModel,
isUsingClaudeApiModel,
isUsingCustomModel,
isUsingOllamaApiModel,
@@ -19,11 +12,7 @@ import {
ModelMode,
ThemeMode,
TriggerMode,
- isUsingMoonshotApiModel,
Models,
- isUsingOpenRouterApiModel,
- isUsingAimlApiModel,
- isUsingDeepSeekApiModel,
} from '../../config/index.mjs'
import Browser from 'webextension-polyfill'
import { languageList } from '../../config/language.mjs'
@@ -31,10 +20,37 @@ import PropTypes from 'prop-types'
import { config as menuConfig } from '../../content-script/menu-tools'
import { PencilIcon } from '@primer/octicons-react'
import { importDataIntoStorage } from './import-data-cleanup.mjs'
+import { resolveOpenAICompatibleRequest } from '../../services/apis/provider-registry.mjs'
+import {
+ checkBilling,
+ formatFiniteBalance,
+ getBalanceCacheKey,
+ normalizeBillingApiBaseUrl,
+ resolveOpenAIBalanceContext,
+ shouldOpenOpenAIUsageFallbackPage,
+ shouldOpenOpenAIUsagePage,
+} from './general-balance-utils.mjs'
+import { getApiModeDisplayLabel } from './api-modes-provider-utils.mjs'
+import {
+ buildProviderOverrideFinalConfigUpdate,
+ resolveOverrideCommitContext,
+ resolveCommittedMigratedSessions,
+ resolveCommittedOverrideSourceProvider,
+} from './general-provider-override-utils.mjs'
+import {
+ buildSelectedModeProviderSecretOverrideUpdate,
+ buildProviderSecretUpdate,
+ createProviderSecretOverrideCommitSelectionSignature,
+ hasSelectedModeOwnProviderSecretOverride,
+ resolveProviderSecretTargetId,
+ rollbackProviderSecretOverrideSessionMigration,
+} from './provider-secret-utils.mjs'
GeneralPart.propTypes = {
config: PropTypes.object.isRequired,
updateConfig: PropTypes.func.isRequired,
+ getPersistedConfig: PropTypes.func.isRequired,
+ awaitConfigWritesSettled: PropTypes.func.isRequired,
setTabIndex: PropTypes.func.isRequired,
}
@@ -42,9 +58,37 @@ function isUsingSpecialCustomModel(configOrSession) {
return isUsingCustomModel(configOrSession) && !configOrSession.apiMode
}
-export function GeneralPart({ config, updateConfig, setTabIndex }) {
+function normalizeLoadedSessionsResult(stored) {
+ return {
+ ok: true,
+ sessions: Array.isArray(stored?.sessions) ? stored.sessions : [],
+ }
+}
+
+function isOverrideCommitCurrent(
+ commitGeneration,
+ currentGeneration,
+ commitSelectionSignature,
+ currentSelectionSignature,
+) {
+ return (
+ commitGeneration === currentGeneration && commitSelectionSignature === currentSelectionSignature
+ )
+}
+
+export function GeneralPart({
+ config,
+ updateConfig,
+ getPersistedConfig,
+ awaitConfigWritesSettled,
+ setTabIndex,
+}) {
const { t, i18n } = useTranslation()
+ const [balance, setBalance] = useState(null)
const [apiModes, setApiModes] = useState([])
+ const [providerApiKeyDraft, setProviderApiKeyDraft] = useState('')
+ const [isOverrideProviderKeyActionPending, setIsOverrideProviderKeyActionPending] =
+ useState(false)
useLayoutEffect(() => {
setApiModes(getApiModesFromConfig(config, true))
@@ -55,6 +99,343 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) {
config.ollamaModelName,
])
+ const selectedProviderSession =
+ config.apiMode && typeof config.apiMode === 'object'
+ ? { apiMode: config.apiMode }
+ : { modelName: config.modelName }
+ const selectedProviderRequest = resolveOpenAICompatibleRequest(config, selectedProviderSession)
+ const selectedProviderId = selectedProviderRequest?.providerId || ''
+ const selectedProviderSecretTargetId = resolveProviderSecretTargetId(selectedProviderRequest)
+ const selectedOpenAIBalanceContext = resolveOpenAIBalanceContext(
+ selectedProviderRequest,
+ selectedProviderSecretTargetId,
+ config.customOpenAiApiUrl,
+ config.apiMode?.sourceProviderId,
+ )
+ const selectedProviderApiKey = selectedProviderRequest?.apiKey || ''
+ const normalizedProviderApiKeyDraft = String(providerApiKeyDraft || '').trim()
+ const normalizedSelectedProviderApiKey = String(selectedProviderApiKey || '').trim()
+ const isProviderApiKeyDraftDirty =
+ normalizedProviderApiKeyDraft !== normalizedSelectedProviderApiKey
+ const isUsingOpenAICompatibleProvider = Boolean(selectedProviderRequest)
+ const resolvedOpenAiApiUrl = selectedOpenAIBalanceContext.apiBaseUrl
+ const isSelectedProviderKeyManagedByModeOverride = hasSelectedModeOwnProviderSecretOverride(
+ config,
+ selectedProviderSecretTargetId,
+ )
+ const shouldShowOpenAIBalanceControls = shouldOpenOpenAIUsagePage(
+ selectedOpenAIBalanceContext.providerId,
+ selectedOpenAIBalanceContext.sourceProviderId,
+ )
+ const selectedOverrideCommitSelectionSignature =
+ createProviderSecretOverrideCommitSelectionSignature(
+ selectedProviderSecretTargetId,
+ config.apiMode,
+ )
+ const balanceCacheKey = getBalanceCacheKey(
+ selectedOpenAIBalanceContext.providerId,
+ selectedProviderApiKey,
+ resolvedOpenAiApiUrl,
+ )
+ const overrideCommitGenerationRef = useRef(0)
+ const overrideCommitPendingCountRef = useRef(0)
+ const overrideCommitQueueRef = useRef(Promise.resolve())
+ const overrideCommitSelectionSignatureRef = useRef(selectedOverrideCommitSelectionSignature)
+
+ useLayoutEffect(() => {
+ overrideCommitSelectionSignatureRef.current = selectedOverrideCommitSelectionSignature
+ overrideCommitGenerationRef.current += 1
+ }, [selectedOverrideCommitSelectionSignature])
+
+ useLayoutEffect(() => {
+ setProviderApiKeyDraft(selectedProviderApiKey)
+ }, [
+ resolvedOpenAiApiUrl,
+ selectedProviderApiKey,
+ selectedProviderId,
+ selectedProviderSecretTargetId,
+ ])
+
+ useLayoutEffect(() => {
+ setBalance(null)
+ }, [balanceCacheKey])
+
+ const loadLatestSessions = async () => {
+ try {
+ const stored = await Browser.storage.local.get('sessions')
+ return normalizeLoadedSessionsResult(stored)
+ } catch {
+ return { ok: false, sessions: [] }
+ }
+ }
+
+ const commitSelectedModeProviderKeyOverride = async (nextApiKey) => {
+ overrideCommitPendingCountRef.current += 1
+ setIsOverrideProviderKeyActionPending(true)
+ const commitGeneration = ++overrideCommitGenerationRef.current
+ const commitSelectionSignature = selectedOverrideCommitSelectionSignature
+ const runCommit = async () => {
+ try {
+ const { committedConfig, existingProviders } = await resolveOverrideCommitContext(
+ awaitConfigWritesSettled,
+ getPersistedConfig,
+ selectedProviderId,
+ )
+ if (
+ !isOverrideCommitCurrent(
+ commitGeneration,
+ overrideCommitGenerationRef.current,
+ commitSelectionSignature,
+ overrideCommitSelectionSignatureRef.current,
+ )
+ ) {
+ return
+ }
+ const { overrideSourceProvider } = resolveCommittedOverrideSourceProvider(
+ committedConfig,
+ selectedProviderSecretTargetId,
+ )
+
+ if (!overrideSourceProvider) {
+ console.warn('[popup] Selected provider disappeared before provider override commit')
+ return
+ }
+
+ const { configUpdate, sessionMigration, cleanupCandidateProviderId } =
+ buildSelectedModeProviderSecretOverrideUpdate(
+ committedConfig,
+ selectedProviderSecretTargetId,
+ nextApiKey,
+ overrideSourceProvider,
+ existingProviders,
+ )
+
+ const committedSessionsResult = await resolveCommittedMigratedSessions(
+ loadLatestSessions,
+ sessionMigration,
+ )
+ if (!committedSessionsResult.ok) {
+ return
+ }
+ const latestSessions = committedSessionsResult.sessions
+ const updatedSessions = committedSessionsResult.migratedSessions
+ if (
+ !isOverrideCommitCurrent(
+ commitGeneration,
+ overrideCommitGenerationRef.current,
+ commitSelectionSignature,
+ overrideCommitSelectionSignatureRef.current,
+ )
+ ) {
+ return
+ }
+
+ if (updatedSessions !== latestSessions) {
+ try {
+ await Browser.storage.local.set({ sessions: updatedSessions })
+ } catch (error) {
+ console.error(
+ '[popup] Failed to persist migrated sessions for provider override',
+ error,
+ )
+ return
+ }
+ }
+
+ const rollbackMigratedSessions = async (message, error) => {
+ if (updatedSessions === latestSessions || !sessionMigration) return
+ if (error) {
+ console.error(message, error)
+ } else {
+ console.error(message)
+ }
+
+ const currentSessionsResult = await loadLatestSessions()
+ if (!currentSessionsResult.ok) {
+ console.error(
+ '[popup] Failed to reload sessions for provider override selective rollback',
+ )
+ return
+ }
+ const rolledBackSessions = rollbackProviderSecretOverrideSessionMigration(
+ currentSessionsResult.sessions,
+ latestSessions,
+ sessionMigration,
+ )
+ if (rolledBackSessions === currentSessionsResult.sessions) return
+ try {
+ await Browser.storage.local.set({ sessions: rolledBackSessions })
+ } catch (rollbackError) {
+ console.error(
+ '[popup] Failed to persist selective rollback for provider override sessions',
+ rollbackError,
+ )
+ }
+ }
+
+ const shouldPreserveCurrentSelection = !isOverrideCommitCurrent(
+ commitGeneration,
+ overrideCommitGenerationRef.current,
+ commitSelectionSignature,
+ overrideCommitSelectionSignatureRef.current,
+ )
+ const finalConfigUpdate = buildProviderOverrideFinalConfigUpdate(
+ cleanupCandidateProviderId,
+ committedConfig,
+ configUpdate,
+ updatedSessions,
+ shouldPreserveCurrentSelection,
+ )
+ if (Object.keys(finalConfigUpdate).length === 0) {
+ await rollbackMigratedSessions(
+ '[popup] Provider override produced no config update; attempting selective session rollback',
+ )
+ return
+ }
+ try {
+ await updateConfig(finalConfigUpdate)
+ } catch (error) {
+ await rollbackMigratedSessions(
+ '[popup] Failed to persist provider override config update; attempting selective session rollback',
+ error,
+ )
+ return
+ }
+ } finally {
+ overrideCommitPendingCountRef.current = Math.max(
+ 0,
+ overrideCommitPendingCountRef.current - 1,
+ )
+ if (overrideCommitPendingCountRef.current === 0) {
+ setIsOverrideProviderKeyActionPending(false)
+ }
+ }
+ }
+ const commitPromise = overrideCommitQueueRef.current.then(runCommit)
+ overrideCommitQueueRef.current = commitPromise.catch(() => {})
+ await commitPromise
+ }
+
+ const commitProviderApiKeyDraft = async (nextApiKey) => {
+ if (!selectedProviderId) return
+ const normalizedNextApiKey = String(nextApiKey || '').trim()
+ if (normalizedNextApiKey === normalizedSelectedProviderApiKey) {
+ if (nextApiKey !== selectedProviderApiKey) {
+ setProviderApiKeyDraft(selectedProviderApiKey)
+ }
+ return
+ }
+
+ if (isSelectedProviderKeyManagedByModeOverride) {
+ if (!normalizedNextApiKey) {
+ overrideCommitGenerationRef.current += 1
+ return
+ }
+ return
+ }
+
+ await updateConfig(
+ buildProviderSecretUpdate(config, selectedProviderSecretTargetId, nextApiKey),
+ )
+ }
+
+ const handleProviderApiKeyDraftChange = (nextApiKey) => {
+ setProviderApiKeyDraft(nextApiKey)
+ }
+
+ const handleProviderOverrideActionMouseDown = (e) => {
+ e.preventDefault()
+ }
+
+ const handleProviderApiKeyBlur = (e) => {
+ if (isSelectedProviderKeyManagedByModeOverride) {
+ if (e.relatedTarget?.closest?.('[data-provider-key-action]')) return
+ if (providerApiKeyDraft !== selectedProviderApiKey) {
+ setProviderApiKeyDraft(selectedProviderApiKey)
+ }
+ return
+ }
+ void commitProviderApiKeyDraft(providerApiKeyDraft)
+ }
+
+ const handleSaveProviderKeyOverride = () => {
+ if (
+ !selectedProviderSecretTargetId ||
+ !isSelectedProviderKeyManagedByModeOverride ||
+ isOverrideProviderKeyActionPending ||
+ normalizedProviderApiKeyDraft.length === 0 ||
+ normalizedProviderApiKeyDraft === normalizedSelectedProviderApiKey
+ ) {
+ return
+ }
+ void commitSelectedModeProviderKeyOverride(providerApiKeyDraft)
+ }
+
+ const handleUseSharedProviderKey = () => {
+ if (
+ !selectedProviderSecretTargetId ||
+ !isSelectedProviderKeyManagedByModeOverride ||
+ isOverrideProviderKeyActionPending
+ )
+ return
+ setProviderApiKeyDraft('')
+ void commitSelectedModeProviderKeyOverride('')
+ }
+
+ const handleProviderApiKeyInputKeyDown = (e) => {
+ if (e.key !== 'Enter') return
+ if (isSelectedProviderKeyManagedByModeOverride) {
+ e.preventDefault()
+ handleSaveProviderKeyOverride()
+ return
+ }
+ e.currentTarget.blur()
+ }
+
+ const getBalance = async () => {
+ const isOpenAIProvider = shouldShowOpenAIBalanceControls
+ if (!isOpenAIProvider) {
+ setBalance(null)
+ return
+ }
+
+ const apiKeyForBalance = normalizedProviderApiKeyDraft
+ const billingApiBaseUrl = normalizeBillingApiBaseUrl(resolvedOpenAiApiUrl)
+ try {
+ const response = await fetch(`${billingApiBaseUrl}/dashboard/billing/credit_grants`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKeyForBalance}`,
+ },
+ })
+ if (response.ok) {
+ const primaryBalance = formatFiniteBalance((await response.json())?.total_available)
+ if (primaryBalance !== null) {
+ setBalance(primaryBalance)
+ return
+ }
+ }
+
+ const billing = await checkBilling(apiKeyForBalance, billingApiBaseUrl)
+ const fallbackBalance = formatFiniteBalance(billing?.[2])
+ if (fallbackBalance !== null) {
+ setBalance(fallbackBalance)
+ return
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ if (
+ shouldOpenOpenAIUsageFallbackPage(
+ selectedOpenAIBalanceContext.providerId,
+ selectedOpenAIBalanceContext.sourceProviderId,
+ billingApiBaseUrl,
+ isSelectedProviderKeyManagedByModeOverride,
+ )
+ ) {
+ openUrl('https://platform.openai.com/account/usage')
+ }
+ }
return (
<>