mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user