chore: update Codex channel (#5461)
This commit is contained in:
-40
@@ -46,26 +46,6 @@ const channelActionConfig = (
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
|
||||
export type CodexOAuthStartResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
authorize_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexOAuthCompleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
key?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
expires_at?: string
|
||||
last_refresh?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexUsageResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
@@ -286,26 +266,6 @@ export async function getChannelKey(
|
||||
// Codex Channel Operations
|
||||
// ============================================================================
|
||||
|
||||
export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
|
||||
const res = await api.post(
|
||||
'/api/channel/codex/oauth/start',
|
||||
{},
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function completeCodexOAuth(
|
||||
input: string
|
||||
): Promise<CodexOAuthCompleteResponse> {
|
||||
const res = await api.post(
|
||||
'/api/channel/codex/oauth/complete',
|
||||
{ input },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function refreshCodexCredential(
|
||||
channelId: number
|
||||
): Promise<CodexCredentialRefreshResponse> {
|
||||
|
||||
@@ -622,7 +622,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex min-w-0 max-w-full items-center gap-2 overflow-hidden'>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
@@ -637,12 +637,24 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<ProviderBadge
|
||||
iconKey={iconName}
|
||||
label={typeName}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
/>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className='min-w-0 max-w-full overflow-hidden' />
|
||||
}
|
||||
>
|
||||
<ProviderBadge
|
||||
iconKey={iconName}
|
||||
label={typeName}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='min-w-0 max-w-full overflow-hidden'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>{typeName}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isIonet && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
@@ -692,7 +704,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
return value.includes(String(row.getValue(id)))
|
||||
},
|
||||
size: 140,
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { tryPrettyJson } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { completeCodexOAuth, startCodexOAuth } from '../../api'
|
||||
|
||||
type CodexOAuthDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onKeyGenerated: (key: string) => void
|
||||
}
|
||||
|
||||
export function CodexOAuthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onKeyGenerated,
|
||||
}: CodexOAuthDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
|
||||
const [state, setState] = useState({
|
||||
authorizeUrl: '',
|
||||
callbackUrl: '',
|
||||
isStarting: false,
|
||||
isCompleting: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState({
|
||||
authorizeUrl: '',
|
||||
callbackUrl: '',
|
||||
isStarting: false,
|
||||
isCompleting: false,
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting)
|
||||
const canComplete = useMemo(
|
||||
() => Boolean(state.callbackUrl.trim()) && !state.isCompleting,
|
||||
[state.callbackUrl, state.isCompleting]
|
||||
)
|
||||
|
||||
const handleStart = async () => {
|
||||
setState((prev) => ({ ...prev, isStarting: true }))
|
||||
try {
|
||||
const res = await startCodexOAuth()
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'Failed to start OAuth')
|
||||
}
|
||||
|
||||
const url = res.data?.authorize_url || ''
|
||||
if (!url) {
|
||||
throw new Error('Missing authorize_url in response')
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, authorizeUrl: url }))
|
||||
try {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
toast.success(t('Opened authorization page'))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to open authorization page:', error)
|
||||
toast.warning(t('Please manually copy and open the authorization link'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('OAuth start failed')
|
||||
)
|
||||
} finally {
|
||||
setState((prev) => ({ ...prev, isStarting: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!state.callbackUrl.trim()) return
|
||||
setState((prev) => ({ ...prev, isCompleting: true }))
|
||||
try {
|
||||
const res = await completeCodexOAuth(state.callbackUrl.trim())
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'OAuth failed')
|
||||
}
|
||||
|
||||
const rawKey = res.data?.key || ''
|
||||
if (!rawKey) {
|
||||
throw new Error('Missing key in response')
|
||||
}
|
||||
|
||||
onKeyGenerated(tryPrettyJson(rawKey))
|
||||
toast.success(t('Credential generated'))
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('OAuth failed'))
|
||||
} finally {
|
||||
setState((prev) => ({ ...prev, isCompleting: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Codex Authorization')}
|
||||
description={t(
|
||||
'Generate a Codex OAuth credential and paste it into the channel key field.'
|
||||
)}
|
||||
contentClassName='sm:max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={state.isStarting || state.isCompleting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleComplete} disabled={!canComplete}>
|
||||
{state.isCompleting && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{state.isCompleting ? t('Generating...') : t('Generate credential')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button onClick={handleStart} disabled={state.isStarting}>
|
||||
{state.isStarting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<ExternalLink className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Open authorization page')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={!canCopyAuthorizeUrl}
|
||||
onClick={async () => {
|
||||
if (!state.authorizeUrl) return
|
||||
await copyToClipboard(state.authorizeUrl)
|
||||
}}
|
||||
aria-label={t('Copy authorization link')}
|
||||
title={t('Copy authorization link')}
|
||||
>
|
||||
{copiedText === state.authorizeUrl ? (
|
||||
<Check className='mr-2 h-4 w-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Copy authorization link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>{t('Callback URL')}</div>
|
||||
<Input
|
||||
value={state.callbackUrl}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
|
||||
}
|
||||
placeholder={t(
|
||||
'Paste the full callback URL (includes code & state)'
|
||||
)}
|
||||
autoComplete='off'
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+47
-44
@@ -38,7 +38,6 @@ import {
|
||||
Eraser,
|
||||
Plus,
|
||||
Eye,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Code,
|
||||
Route,
|
||||
@@ -148,7 +147,6 @@ import {
|
||||
} from '../../lib/status-code-risk-guard'
|
||||
import type { Channel } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { CodexOAuthDialog } from '../dialogs/codex-oauth-dialog'
|
||||
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
|
||||
import {
|
||||
MissingModelsConfirmationDialog,
|
||||
@@ -281,7 +279,6 @@ export function ChannelMutateDrawer({
|
||||
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
|
||||
const [channelKey, setChannelKey] = useState<string | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
const [codexOAuthDialogOpen, setCodexOAuthDialogOpen] = useState(false)
|
||||
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
|
||||
useState(false)
|
||||
const initialModelsRef = useRef<string[]>([])
|
||||
@@ -373,6 +370,7 @@ export function ChannelMutateDrawer({
|
||||
const currentName = form.watch('name')
|
||||
const currentModelMapping = form.watch('model_mapping')
|
||||
const awsKeyType = form.watch('aws_key_type')
|
||||
const vertexKeyType = form.watch('vertex_key_type')
|
||||
const upstreamModelUpdateCheckEnabled = form.watch(
|
||||
'upstream_model_update_check_enabled'
|
||||
)
|
||||
@@ -399,6 +397,15 @@ export function ChannelMutateDrawer({
|
||||
const isBatchMode =
|
||||
multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single'
|
||||
const isChannelDetailLoading = isEditing && isChannelLoading
|
||||
const supportsMultiKeyAddMode =
|
||||
currentType !== 57 && !(currentType === 41 && vertexKeyType === 'api_key')
|
||||
const addModeOptions = useMemo(
|
||||
() =>
|
||||
supportsMultiKeyAddMode
|
||||
? ADD_MODE_OPTIONS
|
||||
: ADD_MODE_OPTIONS.filter((option) => option.value === 'single'),
|
||||
[supportsMultiKeyAddMode]
|
||||
)
|
||||
|
||||
// Get all models list
|
||||
const allModelsList = useMemo(
|
||||
@@ -622,6 +629,25 @@ export function ChannelMutateDrawer({
|
||||
}
|
||||
}, [currentType, isEditing, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentType !== 45 || currentBaseUrl !== 'doubao-coding-plan') return
|
||||
|
||||
form.setValue('base_url', 'https://ark.cn-beijing.volces.com', {
|
||||
shouldDirty: false,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}, [currentBaseUrl, currentType, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing || supportsMultiKeyAddMode) return
|
||||
if (multiKeyMode && multiKeyMode !== 'single') {
|
||||
form.setValue('multi_key_mode', 'single', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
}, [form, isEditing, multiKeyMode, supportsMultiKeyAddMode])
|
||||
|
||||
// Validate base_url - warn if it ends with /v1
|
||||
useEffect(() => {
|
||||
if (!currentBaseUrl || !currentBaseUrl.endsWith('/v1')) return
|
||||
@@ -1550,7 +1576,7 @@ export function ChannelMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch('vertex_key_type') === 'json' && (
|
||||
{vertexKeyType === 'json' && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Service account JSON file(s)')}
|
||||
@@ -1682,15 +1708,13 @@ export function ChannelMutateDrawer({
|
||||
'https://ark.ap-southeast.bytepluses.com'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'doubao-coding-plan',
|
||||
label: t('Doubao Coding Plan'),
|
||||
},
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={
|
||||
field.value ||
|
||||
'https://ark.cn-beijing.volces.com'
|
||||
field.value === 'doubao-coding-plan'
|
||||
? 'https://ark.cn-beijing.volces.com'
|
||||
: field.value ||
|
||||
'https://ark.cn-beijing.volces.com'
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -1708,9 +1732,6 @@ export function ChannelMutateDrawer({
|
||||
'https://ark.ap-southeast.bytepluses.com'
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value='doubao-coding-plan'>
|
||||
{t('Doubao Coding Plan')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1806,7 +1827,7 @@ export function ChannelMutateDrawer({
|
||||
<FormLabel>{t('Add Mode')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...ADD_MODE_OPTIONS.map((option) => ({
|
||||
...addModeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
})),
|
||||
@@ -1821,7 +1842,7 @@ export function ChannelMutateDrawer({
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{ADD_MODE_OPTIONS.map((option) => (
|
||||
{addModeOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
@@ -1833,7 +1854,11 @@ export function ChannelMutateDrawer({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(FIELD_DESCRIPTIONS.BATCH_ADD)}
|
||||
{t(
|
||||
supportsMultiKeyAddMode
|
||||
? FIELD_DESCRIPTIONS.BATCH_ADD
|
||||
: FIELD_DESCRIPTIONS.KEY
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1988,26 +2013,12 @@ export function ChannelMutateDrawer({
|
||||
{currentType === 57 && (
|
||||
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='text-sm font-semibold'>
|
||||
{t('Codex Authorization')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Codex channels use an OAuth JSON credential as the key.'
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Codex channels use an OAuth JSON credential as the key.'
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setCodexOAuthDialogOpen(true)}
|
||||
>
|
||||
<Link2 className='mr-2 h-4 w-4' />
|
||||
{t('Authorize')}
|
||||
</Button>
|
||||
{isEditing && channelId && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -2028,24 +2039,16 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.'
|
||||
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel."
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CodexOAuthDialog
|
||||
open={codexOAuthDialogOpen}
|
||||
onOpenChange={setCodexOAuthDialogOpen}
|
||||
onKeyGenerated={(key) => {
|
||||
form.setValue('key', key, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
|
||||
{isEditing && isMultiKeyChannel && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export const CHANNEL_TYPES = {
|
||||
54: 'DoubaoVideo',
|
||||
55: 'Sora',
|
||||
56: 'Replicate',
|
||||
57: 'Codex',
|
||||
57: 'ChatGPT Subscription (Codex)',
|
||||
} as const
|
||||
|
||||
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
|
||||
|
||||
Reference in New Issue
Block a user