diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts
index 1b3f4035892..6e92519f98b 100644
--- a/web/default/src/features/channels/api.ts
+++ b/web/default/src/features/channels/api.ts
@@ -16,8 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import type { AxiosRequestConfig } from 'axios'
-import { api } from '@/lib/api'
+import { api, type ApiRequestConfig } from '@/lib/api'
import { getGroups as getUserGroups } from '@/features/users/api'
import type {
AddChannelRequest,
@@ -39,11 +38,13 @@ import type {
TagOperationParams,
} from './types'
-// Extended API config types
-interface ExtendedApiConfig extends AxiosRequestConfig {
- skipBusinessError?: boolean
- disableDuplicate?: boolean
-}
+const channelActionConfig = (
+ config: ApiRequestConfig = {}
+): ApiRequestConfig => ({
+ ...config,
+ skipBusinessError: true,
+ skipErrorHandler: true,
+})
export type CodexOAuthStartResponse = {
success: boolean
@@ -125,7 +126,7 @@ export async function getChannel(id: number): Promise {
export async function createChannel(
data: AddChannelRequest
): Promise<{ success: boolean; message?: string }> {
- const res = await api.post('/api/channel', data)
+ const res = await api.post('/api/channel', data, channelActionConfig())
return res.data
}
@@ -136,7 +137,11 @@ export async function updateChannel(
id: number,
data: Partial
): Promise<{ success: boolean; message?: string; data?: Channel }> {
- const res = await api.put('/api/channel/', { id, ...data })
+ const res = await api.put(
+ '/api/channel/',
+ { id, ...data },
+ channelActionConfig()
+ )
return res.data
}
@@ -146,7 +151,7 @@ export async function updateChannel(
export async function deleteChannel(
id: number
): Promise<{ success: boolean; message?: string }> {
- const res = await api.delete(`/api/channel/${id}`)
+ const res = await api.delete(`/api/channel/${id}`, channelActionConfig())
return res.data
}
@@ -156,7 +161,7 @@ export async function deleteChannel(
export async function batchDeleteChannels(
data: BatchDeleteParams
): Promise<{ success: boolean; message?: string; data?: number }> {
- const res = await api.post('/api/channel/batch', data)
+ const res = await api.post('/api/channel/batch', data, channelActionConfig())
return res.data
}
@@ -166,7 +171,11 @@ export async function batchDeleteChannels(
export async function batchSetChannelTag(
data: BatchSetTagParams
): Promise<{ success: boolean; message?: string; data?: number }> {
- const res = await api.post('/api/channel/batch/tag', data)
+ const res = await api.post(
+ '/api/channel/batch/tag',
+ data,
+ channelActionConfig()
+ )
return res.data
}
@@ -181,7 +190,10 @@ export async function testChannel(
id: number,
params?: { model?: string; endpoint_type?: string; stream?: boolean }
): Promise {
- const res = await api.get(`/api/channel/test/${id}`, { params })
+ const res = await api.get(
+ `/api/channel/test/${id}`,
+ channelActionConfig({ params })
+ )
return res.data
}
@@ -191,7 +203,10 @@ export async function testChannel(
export async function updateChannelBalance(
id: number
): Promise {
- const res = await api.get(`/api/channel/update_balance/${id}`)
+ const res = await api.get(
+ `/api/channel/update_balance/${id}`,
+ channelActionConfig()
+ )
return res.data
}
@@ -201,7 +216,10 @@ export async function updateChannelBalance(
export async function fetchUpstreamModels(
id: number
): Promise {
- const res = await api.get(`/api/channel/fetch_models/${id}`)
+ const res = await api.get(
+ `/api/channel/fetch_models/${id}`,
+ channelActionConfig()
+ )
return res.data
}
@@ -212,7 +230,11 @@ export async function copyChannel(
id: number,
params: CopyChannelParams = {}
): Promise {
- const res = await api.post(`/api/channel/copy/${id}`, null, { params })
+ const res = await api.post(
+ `/api/channel/copy/${id}`,
+ null,
+ channelActionConfig({ params })
+ )
return res.data
}
@@ -224,7 +246,11 @@ export async function fixChannelAbilities(): Promise<{
message?: string
data?: { success: number; fails: number }
}> {
- const res = await api.post('/api/channel/fix')
+ const res = await api.post(
+ '/api/channel/fix',
+ undefined,
+ channelActionConfig()
+ )
return res.data
}
@@ -236,7 +262,7 @@ export async function deleteDisabledChannels(): Promise<{
message?: string
data?: number
}> {
- const res = await api.delete('/api/channel/disabled')
+ const res = await api.delete('/api/channel/disabled', channelActionConfig())
return res.data
}
@@ -248,7 +274,11 @@ export async function getChannelKey(
code?: string
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const payload = code ? { code } : undefined
- const res = await api.post(`/api/channel/${id}/key`, payload)
+ const res = await api.post(
+ `/api/channel/${id}/key`,
+ payload,
+ channelActionConfig()
+ )
return res.data
}
@@ -257,19 +287,21 @@ export async function getChannelKey(
// ============================================================================
export async function startCodexOAuth(): Promise {
- const config: ExtendedApiConfig = { skipBusinessError: true }
- const res = await api.post('/api/channel/codex/oauth/start', {}, config)
+ const res = await api.post(
+ '/api/channel/codex/oauth/start',
+ {},
+ channelActionConfig()
+ )
return res.data
}
export async function completeCodexOAuth(
input: string
): Promise {
- const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
'/api/channel/codex/oauth/complete',
{ input },
- config
+ channelActionConfig()
)
return res.data
}
@@ -277,11 +309,10 @@ export async function completeCodexOAuth(
export async function refreshCodexCredential(
channelId: number
): Promise {
- const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
`/api/channel/${channelId}/codex/refresh`,
{},
- config
+ channelActionConfig()
)
return res.data
}
@@ -289,11 +320,10 @@ export async function refreshCodexCredential(
export async function getCodexUsage(
channelId: number
): Promise {
- const config: ExtendedApiConfig = {
- skipBusinessError: true,
- disableDuplicate: true,
- }
- const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
+ const res = await api.get(
+ `/api/channel/${channelId}/codex/usage`,
+ channelActionConfig({ disableDuplicate: true })
+ )
return res.data
}
@@ -307,7 +337,11 @@ export async function getCodexUsage(
export async function manageMultiKeys(
params: MultiKeyManageParams
): Promise {
- const res = await api.post('/api/channel/multi_key/manage', params)
+ const res = await api.post(
+ '/api/channel/multi_key/manage',
+ params,
+ channelActionConfig()
+ )
return res.data
}
@@ -417,7 +451,11 @@ export async function deleteDisabledMultiKeys(
export async function enableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
- const res = await api.post('/api/channel/tag/enabled', { tag })
+ const res = await api.post(
+ '/api/channel/tag/enabled',
+ { tag },
+ channelActionConfig()
+ )
return res.data
}
@@ -427,7 +465,11 @@ export async function enableTagChannels(
export async function disableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
- const res = await api.post('/api/channel/tag/disabled', { tag })
+ const res = await api.post(
+ '/api/channel/tag/disabled',
+ { tag },
+ channelActionConfig()
+ )
return res.data
}
@@ -437,7 +479,7 @@ export async function disableTagChannels(
export async function editTagChannels(
params: TagOperationParams
): Promise<{ success: boolean; message?: string }> {
- const res = await api.put('/api/channel/tag', params)
+ const res = await api.put('/api/channel/tag', params, channelActionConfig())
return res.data
}
@@ -463,7 +505,11 @@ export async function fetchModels(data: {
type: number
key: string
}): Promise {
- const res = await api.post('/api/channel/fetch_models', data)
+ const res = await api.post(
+ '/api/channel/fetch_models',
+ data,
+ channelActionConfig()
+ )
return res.data
}
@@ -474,7 +520,10 @@ export async function deleteOllamaModel(params: {
channel_id: number
model_name: string
}): Promise<{ success: boolean; message?: string }> {
- const res = await api.delete('/api/channel/ollama/delete', { data: params })
+ const res = await api.delete(
+ '/api/channel/ollama/delete',
+ channelActionConfig({ data: params })
+ )
return res.data
}
@@ -485,7 +534,7 @@ export async function testAllChannels(): Promise<{
success: boolean
message?: string
}> {
- const res = await api.get('/api/channel/test')
+ const res = await api.get('/api/channel/test', channelActionConfig())
return res.data
}
@@ -496,7 +545,10 @@ export async function updateAllChannelsBalance(): Promise<{
success: boolean
message?: string
}> {
- const res = await api.get('/api/channel/update_balance')
+ const res = await api.get(
+ '/api/channel/update_balance',
+ channelActionConfig()
+ )
return res.data
}
diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
index d87b7fa858d..33b1f542ae4 100644
--- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
@@ -135,6 +135,8 @@ export function MultiKeyManageDialog({
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
+ } else {
+ toast.error(response.message || t('Failed to load key status'))
}
} catch (error: unknown) {
toast.error(
diff --git a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx
index cee1b57fbb7..c883aa9c4a7 100644
--- a/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/ollama-models-dialog.tsx
@@ -211,14 +211,22 @@ export function OllamaModelsDialog({
? Array.from(new Set(selected))
: Array.from(new Set([...existingModels, ...selected]))
- const res = await updateChannel(currentRow.id, { models: next.join(',') })
- if (res.success) {
- toast.success(
- mode === 'replace'
- ? t('Models updated successfully')
- : t('Models appended successfully')
+ try {
+ const res = await updateChannel(currentRow.id, { models: next.join(',') })
+ if (res.success) {
+ toast.success(
+ mode === 'replace'
+ ? t('Models updated successfully')
+ : t('Models appended successfully')
+ )
+ queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+ } else {
+ toast.error(res.message || t('Failed to update models'))
+ }
+ } catch (err: unknown) {
+ toast.error(
+ err instanceof Error ? err.message : t('Failed to update models')
)
- queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
}
}
diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
index 39a6e1527b5..55949de537b 100644
--- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
+++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
@@ -1033,6 +1033,8 @@ export function ChannelMutateDrawer({
if (response.success) {
toast.success(t(SUCCESS_MESSAGES.UPDATED))
handleSuccess()
+ } else {
+ throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
}
} else {
// Create new channel(s)
@@ -1041,6 +1043,8 @@ export function ChannelMutateDrawer({
if (response.success) {
toast.success(t(SUCCESS_MESSAGES.CREATED))
handleSuccess()
+ } else {
+ throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
}
}
} catch (error: unknown) {
@@ -3381,7 +3385,9 @@ export function ChannelMutateDrawer({
redirectSourceModels={redirectModelKeyList}
customFetcher={!isEditing ? createModeFetcher : undefined}
existingModelsOverride={
- !isEditing ? parseModelsString(form.getValues('models') || '') : undefined
+ !isEditing
+ ? parseModelsString(form.getValues('models') || '')
+ : undefined
}
/>
diff --git a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts
index f643b72f4a1..e28c21d9048 100644
--- a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts
+++ b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts
@@ -19,9 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import { useRef, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
-import { api } from '@/lib/api'
+import { api, type ApiRequestConfig } from '@/lib/api'
import { normalizeModelList } from '../lib/upstream-update-utils'
+const upstreamUpdateRequestConfig = {
+ skipBusinessError: true,
+ skipErrorHandler: true,
+} satisfies ApiRequestConfig
+
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record | null = null
if (settings && typeof settings === 'object')
@@ -117,7 +122,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) {
ignore_models: ignoreModels,
remove_models: normalizeModelList(selectedRemove),
},
- { skipErrorHandler: true } as Record
+ upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@@ -162,7 +167,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) {
const res = await api.post(
'/api/channel/upstream_updates/apply_all',
{},
- { skipErrorHandler: true } as Record
+ upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@@ -206,7 +211,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) {
const res = await api.post(
'/api/channel/upstream_updates/detect',
{ id: ch.id },
- { skipErrorHandler: true } as Record
+ upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@@ -244,7 +249,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) {
const res = await api.post(
'/api/channel/upstream_updates/detect_all',
{},
- { skipErrorHandler: true } as Record
+ upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
diff --git a/web/default/src/features/channels/lib/channel-actions.ts b/web/default/src/features/channels/lib/channel-actions.ts
index 884f81fede3..bdbddbbebb1 100644
--- a/web/default/src/features/channels/lib/channel-actions.ts
+++ b/web/default/src/features/channels/lib/channel-actions.ts
@@ -70,6 +70,8 @@ export async function handleEnableChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
@@ -92,6 +94,8 @@ export async function handleDisableChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
@@ -128,6 +132,8 @@ export async function handleDeleteChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.DELETED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
@@ -257,6 +263,8 @@ export async function handleCopyChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data.id)
+ } else {
+ toast.error(response.message || i18next.t('Failed to copy channel'))
}
} catch (_error) {
toast.error(i18next.t('Failed to copy channel'))
@@ -325,6 +333,8 @@ export async function handleBatchDelete(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || ids.length)
+ } else {
+ toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
@@ -351,8 +361,10 @@ export async function handleBatchEnable(
)
const results = await Promise.allSettled(promises)
- const successCount = results.filter((r) => r.status === 'fulfilled').length
- const failCount = results.filter((r) => r.status === 'rejected').length
+ const successCount = results.filter(
+ (r) => r.status === 'fulfilled' && r.value.success
+ ).length
+ const failCount = results.length - successCount
if (successCount > 0) {
toast.success(
@@ -392,8 +404,10 @@ export async function handleBatchDisable(
)
const results = await Promise.allSettled(promises)
- const successCount = results.filter((r) => r.status === 'fulfilled').length
- const failCount = results.filter((r) => r.status === 'rejected').length
+ const successCount = results.filter(
+ (r) => r.status === 'fulfilled' && r.value.success
+ ).length
+ const failCount = results.length - successCount
if (successCount > 0) {
toast.success(
@@ -435,6 +449,8 @@ export async function handleBatchSetTag(
toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(response.message || i18next.t('Failed to set tag'))
}
} catch (_error) {
toast.error(i18next.t('Failed to set tag'))
@@ -461,6 +477,10 @@ export async function handleEnableTagChannels(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(
+ response.message || i18next.t('Failed to enable tag channels')
+ )
}
} catch (_error) {
toast.error(i18next.t('Failed to enable tag channels'))
@@ -483,6 +503,10 @@ export async function handleDisableTagChannels(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
+ } else {
+ toast.error(
+ response.message || i18next.t('Failed to disable tag channels')
+ )
}
} catch (_error) {
toast.error(i18next.t('Failed to disable tag channels'))
@@ -510,6 +534,10 @@ export async function handleDeleteAllDisabled(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || 0)
+ } else {
+ toast.error(
+ response.message || i18next.t('Failed to delete disabled channels')
+ )
}
} catch (_error) {
toast.error(i18next.t('Failed to delete disabled channels'))
@@ -534,6 +562,8 @@ export async function handleFixAbilities(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
+ } else {
+ toast.error(response.message || i18next.t('Failed to fix abilities'))
}
} catch (_error) {
toast.error(i18next.t('Failed to fix abilities'))
diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts
index b3bf17eb19d..f6cc88057c5 100644
--- a/web/default/src/lib/api.ts
+++ b/web/default/src/lib/api.ts
@@ -16,11 +16,21 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import axios from 'axios'
-import i18next from 'i18next'
+import axios, { type AxiosRequestConfig } from 'axios'
+import { t } from 'i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
+declare module 'axios' {
+ export interface AxiosRequestConfig {
+ skipBusinessError?: boolean
+ skipErrorHandler?: boolean
+ disableDuplicate?: boolean
+ }
+}
+
+export type ApiRequestConfig = AxiosRequestConfig
+
// ============================================================================
// Axios Instance Configuration
// ============================================================================
@@ -46,14 +56,11 @@ export const api = axios.create({
const inFlightGet = new Map>()
const originalGet = api.get.bind(api)
-api.get = ((url: string, config = {}) => {
- const disableDuplicate = (config as unknown as Record)
- ?.disableDuplicate
+api.get = ((url: string, config: ApiRequestConfig = {}) => {
+ const disableDuplicate = config.disableDuplicate
if (disableDuplicate) return originalGet(url, config)
- const params = (config as unknown as Record)?.params
- ? JSON.stringify((config as unknown as Record).params)
- : '{}'
+ const params = config.params ? JSON.stringify(config.params) : '{}'
const key = `${url}?${params}`
// Return existing in-flight request if available
@@ -72,8 +79,7 @@ api.get = ((url: string, config = {}) => {
// Handle business logic errors and HTTP errors globally
api.interceptors.response.use(
(response) => {
- const skipBusiness = (response.config as unknown as Record)
- ?.skipBusinessError
+ const skipBusiness = response.config.skipBusinessError
// Unified business response format: { success, message, data }
if (
@@ -84,7 +90,7 @@ api.interceptors.response.use(
) {
if (!response.data.success) {
// Show error toast for business failures
- const msg = response.data.message || 'Request failed'
+ const msg = response.data.message || t('Request failed')
toast.error(msg)
}
}
@@ -92,23 +98,23 @@ api.interceptors.response.use(
},
(error) => {
const skip = error?.config?.skipErrorHandler
- if (!skip) {
- const status = error?.response?.status
-
- if (status === 401) {
- // Unauthorized: clear auth state and show toast
- toast.error(i18next.t('Session expired!'))
- try {
- useAuthStore.getState().auth.reset()
- } catch {
- /* empty */
- }
- } else {
- // Other errors: show error message from response or default
- const msg =
- error?.response?.data?.message || error?.message || 'Request error'
- toast.error(msg)
+ const status = error?.response?.status
+
+ if (status === 401) {
+ try {
+ useAuthStore.getState().auth.reset()
+ } catch {
+ /* empty */
+ }
+
+ if (!skip) {
+ toast.error(t('Session expired!'))
}
+ } else if (!skip) {
+ // Other errors: show error message from response or default
+ const msg =
+ error?.response?.data?.message || error?.message || t('Request failed')
+ toast.error(msg)
}
return Promise.reject(error)
}
@@ -175,7 +181,7 @@ export async function getSelf() {
const res = await api.get('/api/user/self', {
// Avoid global 401 toast during guards/preloads
skipErrorHandler: true,
- } as Record)
+ })
return res.data
}