feat: openai codex support (#292)

* feat(web): add provider oauth management ui

* feat: add OAuth callback support on port 1455

* feat: enhance reasoning effort options and support for OpenAI Codex OAuth

* feat: update twilight-ai dependency to v0.3.4

* refactor: promote openai-codex to first-class client_type, remove auth_type

Replace the previous openai-responses + metadata auth_type=openai-codex-oauth
combo with a dedicated openai-codex client_type. OAuth requirement is now
determined solely by client_type, eliminating the auth_type concept from the
LLM provider domain entirely.

- Add openai-codex to DB CHECK constraint (migration 0047) with data migration
- Add ClientTypeOpenAICodex constant and dedicated SDK/probe branches
- Remove AuthType from SDKModelConfig, ModelCredentials, TriggerConfig, etc.
- Simplify supportsOAuth to check client_type == openai-codex
- Add conf/providers/codex.yaml preset with Codex catalog models
- Frontend: replace auth_type selector with client_type-driven OAuth UI

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
This commit is contained in:
Yiming Qi
2026-03-27 19:30:45 +08:00
committed by GitHub
parent 44c92f198b
commit 64378d29ed
44 changed files with 1663 additions and 160 deletions
+25 -3
View File
@@ -47,6 +47,7 @@
</FormItem>
</FormField>
<FormField
v-if="form.values.client_type !== 'openai-codex'"
v-slot="{ componentField }"
name="api_key"
>
@@ -68,6 +69,12 @@
</FormControl>
</FormItem>
</FormField>
<div
v-else
class="rounded-lg border p-3 text-sm text-muted-foreground"
>
{{ $t('provider.oauth.createHint') }}
</div>
<FormField
v-slot="{ componentField }"
name="base_url"
@@ -161,7 +168,7 @@ import { useDialogMutation } from '@/composables/useDialogMutation'
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types'
import { toast } from 'vue-sonner'
import { computed } from 'vue'
import { computed, watch } from 'vue'
const open = defineModel<boolean>('open')
const { t } = useI18n()
@@ -208,11 +215,19 @@ const { mutateAsync: createProviderMutation, isLoading } = useMutation({
})
const providerSchema = toTypedSchema(z.object({
api_key: z.string().min(1),
api_key: z.string().optional(),
base_url: z.string().min(1),
name: z.string().min(1),
client_type: z.string().min(1),
auto_import: z.boolean().optional(),
}).superRefine((value, ctx) => {
if (value.client_type !== 'openai-codex' && !value.api_key?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_key'],
message: 'API key is required',
})
}
}))
const form = useForm({
@@ -223,12 +238,19 @@ const form = useForm({
},
})
watch(() => form.values.client_type, (clientType) => {
if (clientType !== 'openai-codex') return
if (!form.values.base_url) {
form.setFieldValue('base_url', 'https://chatgpt.com/backend-api')
}
})
const createProvider = form.handleSubmit(async (value) => {
await run(
() => createProviderMutation(value),
{
fallbackMessage: t('common.saveFailed'),
onSuccess: () => {
onSuccess: () => {
open.value = false
form.resetForm()
},
+5
View File
@@ -15,6 +15,11 @@ export const CLIENT_TYPE_META: Record<string, ClientTypeMeta> = {
label: 'OpenAI Completions',
hint: 'Chat Completions API (widely compatible)',
},
'openai-codex': {
value: 'openai-codex',
label: 'OpenAI Codex',
hint: 'Codex API (OAuth, coding-optimized)',
},
'anthropic-messages': {
value: 'anthropic-messages',
label: 'Anthropic Messages',
+24 -4
View File
@@ -34,9 +34,7 @@
"loadFailed": "Failed to load",
"saveFailed": "Failed to save",
"createdAt": "Created at",
"none": "None",
"searchTimezone": "Search timezones…",
"noTimezoneFound": "No timezone found."
"none": "None"
},
"auth": {
"welcome": "Welcome Back",
@@ -230,7 +228,27 @@
"icon": "Icon",
"iconPlaceholder": "Icon URL or identifier (optional)",
"enable": "Enable",
"enableHint": "Only models from enabled providers appear in the available model list"
"enableHint": "Only models from enabled providers appear in the available model list",
"oauth": {
"title": "OpenAI OAuth",
"description": "Authorize this provider with your ChatGPT account for Codex-compatible OpenAI access.",
"createHint": "Save the provider first, then authorize it from the provider details panel.",
"authorize": "Authorize",
"authorizeFailed": "Failed to start authorization",
"authorizeSuccess": "Authorization successful",
"revoke": "Revoke",
"revokeFailed": "Failed to revoke authorization",
"revokeSuccess": "Authorization revoked",
"callback": "Callback URL",
"statusFailed": "Failed to load OAuth status",
"status": {
"checking": "Checking authorization status...",
"authorized": "Authorized",
"expired": "Authorization expired. Re-authorize to continue.",
"missing": "Not authorized yet.",
"notConfigured": "This provider is not configured for OAuth."
}
}
},
"searchProvider": {
"title": "Search Providers",
@@ -784,9 +802,11 @@
"language": "Language",
"reasoningEnabled": "Enable Reasoning",
"reasoningEffort": "Reasoning Effort",
"reasoningEffortNone": "None",
"reasoningEffortLow": "Low",
"reasoningEffortMedium": "Medium",
"reasoningEffortHigh": "High",
"reasoningEffortXHigh": "X-High",
"heartbeatEnabled": "Enable Heartbeat",
"heartbeatDescription": "Periodically trigger agent to check for items that need attention",
"heartbeatInterval": "Heartbeat Interval (minutes)",
+24 -4
View File
@@ -34,9 +34,7 @@
"loadFailed": "加载失败",
"saveFailed": "保存失败",
"createdAt": "创建时间",
"none": "无",
"searchTimezone": "搜索时区…",
"noTimezoneFound": "未找到时区"
"none": "无"
},
"auth": {
"welcome": "欢迎回来",
@@ -226,7 +224,27 @@
"icon": "图标",
"iconPlaceholder": "图标 URL 或标识(可选)",
"enable": "启用",
"enableHint": "只有启用的供应商的模型才会出现在可用模型列表中"
"enableHint": "只有启用的供应商的模型才会出现在可用模型列表中",
"oauth": {
"title": "OpenAI OAuth",
"description": "使用你的 ChatGPT 账号为该提供商授权,以启用 Codex 兼容的 OpenAI 访问。",
"createHint": "请先保存提供商,再到详情面板完成授权。",
"authorize": "授权",
"authorizeFailed": "启动授权失败",
"authorizeSuccess": "授权成功",
"revoke": "撤销授权",
"revokeFailed": "撤销授权失败",
"revokeSuccess": "授权已撤销",
"callback": "回调地址",
"statusFailed": "加载 OAuth 状态失败",
"status": {
"checking": "正在检查授权状态...",
"authorized": "已授权",
"expired": "授权已过期,请重新授权。",
"missing": "尚未授权。",
"notConfigured": "当前提供商未正确配置 OAuth。"
}
}
},
"searchProvider": {
"title": "搜索提供方",
@@ -780,9 +798,11 @@
"language": "语言",
"reasoningEnabled": "启用推理",
"reasoningEffort": "推理等级",
"reasoningEffortNone": "无",
"reasoningEffortLow": "低",
"reasoningEffortMedium": "中",
"reasoningEffortHigh": "高",
"reasoningEffortXHigh": "超高",
"heartbeatEnabled": "启用心跳",
"heartbeatDescription": "定期触发 Agent 检查是否有需要关注的事项",
"heartbeatInterval": "心跳间隔(分钟)",
@@ -197,21 +197,6 @@
/>
</div>
<!-- Timezone -->
<div class="space-y-2">
<Label>{{ $t('bots.timezone') }}</Label>
<TimezoneSelect
:model-value="timezoneModel"
:placeholder="$t('bots.timezonePlaceholder')"
allow-empty
:empty-label="$t('bots.timezoneInherited')"
@update:model-value="onTimezoneChange"
/>
<p class="text-xs text-muted-foreground">
{{ $t('bots.timezoneInheritedHint') }}
</p>
</div>
<Separator />
<!-- Max Context Load Time -->
@@ -272,15 +257,36 @@
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="low">
<SelectItem
v-if="availableReasoningEfforts.includes('none')"
value="none"
>
{{ $t('bots.settings.reasoningEffortNone') }}
</SelectItem>
<SelectItem
v-if="availableReasoningEfforts.includes('low')"
value="low"
>
{{ $t('bots.settings.reasoningEffortLow') }}
</SelectItem>
<SelectItem value="medium">
<SelectItem
v-if="availableReasoningEfforts.includes('medium')"
value="medium"
>
{{ $t('bots.settings.reasoningEffortMedium') }}
</SelectItem>
<SelectItem value="high">
<SelectItem
v-if="availableReasoningEfforts.includes('high')"
value="high"
>
{{ $t('bots.settings.reasoningEffortHigh') }}
</SelectItem>
<SelectItem
v-if="availableReasoningEfforts.includes('xhigh')"
value="xhigh"
>
{{ $t('bots.settings.reasoningEffortXHigh') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -349,20 +355,18 @@ import {
SelectTrigger,
SelectValue,
} from '@memohai/ui'
import { reactive, computed, watch, ref } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import TimezoneSelect from '@/components/timezone-select/index.vue'
import { emptyTimezoneValue } from '@/utils/timezones'
import ModelSelect from './model-select.vue'
import SearchProviderSelect from './search-provider-select.vue'
import MemoryProviderSelect from './memory-provider-select.vue'
import TtsModelSelect from './tts-model-select.vue'
import BrowserContextSelect from './browser-context-select.vue'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { getBotsById, putBotsById, getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk'
import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk'
import type { SettingsSettings } from '@memohai/sdk'
import type { Ref } from 'vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
@@ -379,15 +383,6 @@ const botIdRef = computed(() => props.botId) as Ref<string>
// ---- Data ----
const queryCache = useQueryCache()
const { data: bot } = useQuery({
key: () => ['bot', botIdRef.value],
query: async () => {
const { data } = await getBotsById({ path: { id: botIdRef.value }, throwOnError: true })
return data
},
enabled: () => !!botIdRef.value,
})
const { data: settings } = useQuery({
key: () => ['bot-settings', botIdRef.value],
query: async () => {
@@ -503,31 +498,6 @@ const form = reactive({
reasoning_effort: 'medium',
})
const timezone = ref('')
const timezoneModel = computed(() => timezone.value || emptyTimezoneValue)
function onTimezoneChange(value: string) {
timezone.value = value === emptyTimezoneValue ? '' : value
}
watch(bot, (val) => {
if (val) {
timezone.value = val.timezone || ''
}
}, { immediate: true })
const { mutateAsync: updateBot } = useMutation({
mutation: async ({ id, ...body }: Record<string, unknown> & { id: string }) => {
const { data } = await putBotsById({ path: { id }, body, throwOnError: true })
return data
},
onSettled: () => {
queryCache.invalidateQueries({ key: ['bots'] })
queryCache.invalidateQueries({ key: ['bot'] })
},
})
const selectedMemoryProvider = computed(() =>
memoryProviders.value.find((provider) => provider.id === form.memory_provider_id),
)
@@ -582,6 +552,20 @@ const chatModelSupportsReasoning = computed(() => {
return !!m?.config?.compatibilities?.includes('reasoning')
})
const availableReasoningEfforts = computed(() => {
if (!form.chat_model_id) return ['low', 'medium', 'high']
const model = models.value.find((m) => m.id === form.chat_model_id)
const efforts = ((model?.config as { reasoning_efforts?: string[] } | undefined)?.reasoning_efforts ?? [])
.filter((effort) => ['none', 'low', 'medium', 'high', 'xhigh'].includes(effort))
return efforts.length > 0 ? efforts : ['low', 'medium', 'high']
})
watch(availableReasoningEfforts, (efforts) => {
if (!efforts.includes(form.reasoning_effort)) {
form.reasoning_effort = efforts.includes('medium') ? 'medium' : efforts[0] ?? 'medium'
}
}, { immediate: true })
const { data: memoryStatusData, isLoading: isMemoryStatusLoading } = useQuery({
key: () => ['bot-memory-status', botIdRef.value, persistedMemoryProviderID.value],
query: async () => {
@@ -641,12 +625,10 @@ watch(settings, (val) => {
}, { immediate: true })
const hasChanges = computed(() => {
const timezoneChanged = timezone.value !== (bot.value?.timezone || '')
if (!settings.value) return true
const s = settings.value
let changed =
timezoneChanged
|| form.chat_model_id !== (s.chat_model_id ?? '')
form.chat_model_id !== (s.chat_model_id ?? '')
|| form.title_model_id !== (s.title_model_id ?? '')
|| form.search_provider_id !== (s.search_provider_id ?? '')
|| form.memory_provider_id !== (s.memory_provider_id ?? '')
@@ -662,11 +644,7 @@ const hasChanges = computed(() => {
async function handleSave() {
try {
const promises: Promise<unknown>[] = [updateSettings({ ...form })]
if (timezone.value !== (bot.value?.timezone || '')) {
promises.push(updateBot({ id: botIdRef.value, timezone: timezone.value }))
}
await Promise.all(promises)
await updateSettings({ ...form })
toast.success(t('bots.settings.saveSuccess'))
} catch {
return
@@ -30,6 +30,14 @@
>
{{ $t(`models.compatibility.${cap}`, cap) }}
</Badge>
<Badge
v-for="effort in reasoningEfforts"
:key="effort"
variant="secondary"
class="text-xs"
>
{{ effort }}
</Badge>
<span
v-if="model.config?.context_window"
class="text-xs text-muted-foreground"
@@ -101,6 +109,10 @@ import { postModelsByIdTest } from '@memohai/sdk'
import type { ModelsGetResponse, ModelsTestResponse } from '@memohai/sdk'
import { ref, computed } from 'vue'
type ModelConfigWithReasoning = {
reasoning_efforts?: string[]
}
const props = defineProps<{
model: ModelsGetResponse
deleteLoading: boolean
@@ -113,6 +125,7 @@ defineEmits<{
const testLoading = ref(false)
const testResult = ref<ModelsTestResponse | null>(null)
const reasoningEfforts = computed(() => ((props.model.config as ModelConfigWithReasoning | undefined)?.reasoning_efforts ?? []))
const statusDotClass = computed(() => {
switch (testResult.value?.status) {
@@ -44,7 +44,10 @@
</FormField>
</section>
<section class="space-y-2">
<section
v-if="form.values.client_type !== 'openai-codex'"
class="space-y-2"
>
<h4 class="scroll-m-20 font-semibold tracking-tight">
{{ $t('provider.apiKey') }}
</h4>
@@ -56,7 +59,7 @@
<FormControl>
<Input
type="password"
:placeholder="props.provider?.api_key || $t('provider.apiKeyPlaceholder')"
:placeholder="providerWithAuth?.api_key || $t('provider.apiKeyPlaceholder')"
:aria-label="$t('provider.apiKey')"
v-bind="componentField"
/>
@@ -106,6 +109,67 @@
</FormItem>
</FormField>
</section>
<section
v-if="form.values.client_type === 'openai-codex'"
class="rounded-lg border p-4 space-y-3 text-sm"
>
<div class="space-y-1">
<div class="font-medium">
{{ $t('provider.oauth.title') }}
</div>
<div class="text-muted-foreground">
{{ $t('provider.oauth.description') }}
</div>
<div
class="text-xs"
:class="oauthExpired ? 'text-destructive' : 'text-muted-foreground'"
>
<template v-if="oauthStatusLoading">
{{ $t('provider.oauth.status.checking') }}
</template>
<template v-else-if="oauthStatus && !oauthStatus.configured">
{{ $t('provider.oauth.status.notConfigured') }}
</template>
<template v-else-if="oauthExpired">
{{ $t('provider.oauth.status.expired') }}
</template>
<template v-else-if="oauthStatus?.has_token">
{{ $t('provider.oauth.status.authorized') }}
</template>
<template v-else>
{{ $t('provider.oauth.status.missing') }}
</template>
</div>
<div
v-if="oauthStatus?.callback_url"
class="text-xs text-muted-foreground"
>
{{ $t('provider.oauth.callback') }}: {{ oauthStatus.callback_url }}
</div>
</div>
<div class="flex gap-2">
<LoadingButton
type="button"
variant="outline"
:disabled="!canAuthorizeOAuth"
:loading="authorizeLoading"
@click="handleAuthorize"
>
<FontAwesomeIcon :icon="['fas', 'key']" />
{{ $t('provider.oauth.authorize') }}
</LoadingButton>
<LoadingButton
v-if="oauthStatus?.has_token"
type="button"
variant="ghost"
:loading="revokeLoading"
@click="handleRevoke"
>
{{ $t('provider.oauth.revoke') }}
</LoadingButton>
</div>
</section>
</div>
<section class="flex justify-between items-center mt-4">
@@ -206,11 +270,22 @@ import { useForm } from 'vee-validate'
import { postProvidersByIdTest } from '@memohai/sdk'
import type { ProvidersGetResponse, ProvidersTestResponse } from '@memohai/sdk'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
const { t } = useI18n()
type ProviderWithAuth = Partial<ProvidersGetResponse>
type ProviderOAuthStatus = {
configured: boolean
has_token: boolean
expired: boolean
callback_url?: string
expires_at?: string
}
const props = defineProps<{
provider: Partial<ProvidersGetResponse> | undefined
provider: ProviderWithAuth | undefined
editLoading: boolean
deleteLoading: boolean
}>()
@@ -223,6 +298,13 @@ const emit = defineEmits<{
const testLoading = ref(false)
const testResult = ref<ProvidersTestResponse | null>(null)
const testError = ref('')
const oauthStatus = ref<ProviderOAuthStatus | null>(null)
const oauthStatusLoading = ref(false)
const authorizeLoading = ref(false)
const revokeLoading = ref(false)
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
const providerWithAuth = computed(() => props.provider as ProviderWithAuth | undefined)
async function runTest() {
if (!props.provider?.id) return
@@ -265,6 +347,14 @@ const providerSchema = toTypedSchema(z.object({
metadata: z.object({
additionalProp1: z.object({}),
}),
}).superRefine((value, ctx) => {
if (value.client_type !== 'openai-codex' && !value.api_key?.trim() && !providerWithAuth.value?.api_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_key'],
message: 'API key is required',
})
}
}))
const form = useForm({
@@ -283,6 +373,24 @@ watch(() => props.provider, (newVal) => {
}
}, { immediate: true })
watch(() => form.values.client_type, (clientType) => {
if (clientType !== 'openai-codex') {
oauthStatus.value = null
return
}
if (!form.values.base_url) {
form.setFieldValue('base_url', 'https://chatgpt.com/backend-api')
}
})
watch(() => [props.provider?.id, form.values.client_type] as const, async ([id, clientType]) => {
if (!id || clientType !== 'openai-codex') {
oauthStatus.value = null
return
}
await fetchOAuthStatus()
}, { immediate: true })
const hasChanges = computed(() => {
const raw = props.provider
const baseChanged = JSON.stringify({
@@ -316,4 +424,78 @@ const editProvider = form.handleSubmit(async (value) => {
}
emit('submit', payload)
})
const oauthExpired = computed(() => Boolean(oauthStatus.value?.has_token && oauthStatus.value?.expired))
const canAuthorizeOAuth = computed(() =>
Boolean(
props.provider?.id
&& form.values.client_type === 'openai-codex',
) && !oauthStatusLoading.value,
)
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function fetchOAuthStatus() {
if (!props.provider?.id) return
oauthStatusLoading.value = true
try {
const response = await fetch(`${apiBase}/providers/${props.provider.id}/oauth/status`, {
headers: authHeaders(),
})
if (!response.ok) throw new Error(t('provider.oauth.statusFailed'))
oauthStatus.value = await response.json() as ProviderOAuthStatus
} catch (error) {
oauthStatus.value = null
console.error('failed to load provider oauth status', error)
} finally {
oauthStatusLoading.value = false
}
}
async function handleAuthorize() {
if (!props.provider?.id) return
authorizeLoading.value = true
try {
const response = await fetch(`${apiBase}/providers/${props.provider.id}/oauth/authorize`, {
headers: authHeaders(),
})
if (!response.ok) throw new Error(t('provider.oauth.authorizeFailed'))
const data = await response.json() as { auth_url?: string }
if (!data.auth_url) throw new Error(t('provider.oauth.authorizeFailed'))
const popup = window.open(data.auth_url, 'provider-oauth', 'width=600,height=720')
const listener = async (event: MessageEvent) => {
if (event.data?.type !== 'memoh-provider-oauth-success') return
window.removeEventListener('message', listener)
popup?.close()
toast.success(t('provider.oauth.authorizeSuccess'))
await fetchOAuthStatus()
}
window.addEventListener('message', listener)
} catch (error) {
toast.error(error instanceof Error ? error.message : t('provider.oauth.authorizeFailed'))
} finally {
authorizeLoading.value = false
}
}
async function handleRevoke() {
if (!props.provider?.id) return
revokeLoading.value = true
try {
const response = await fetch(`${apiBase}/providers/${props.provider.id}/oauth/token`, {
method: 'DELETE',
headers: authHeaders(),
})
if (!response.ok) throw new Error(t('provider.oauth.revokeFailed'))
toast.success(t('provider.oauth.revokeSuccess'))
await fetchOAuthStatus()
} catch (error) {
toast.error(error instanceof Error ? error.message : t('provider.oauth.revokeFailed'))
} finally {
revokeLoading.value = false
}
}
</script>