mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(provider): add github copilot device flow provider (#364)
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-if="form.values.client_type !== 'openai-codex'"
|
||||
v-if="!['openai-codex', 'github-copilot'].includes(form.values.client_type)"
|
||||
v-slot="{ componentField }"
|
||||
name="api_key"
|
||||
>
|
||||
@@ -69,12 +69,13 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div
|
||||
v-else
|
||||
v-else-if="['openai-codex', 'github-copilot'].includes(form.values.client_type)"
|
||||
class="rounded-lg border p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('provider.oauth.createHint') }}
|
||||
{{ $t(form.values.client_type === 'github-copilot' ? 'provider.oauth.githubCreateHint' : 'provider.oauth.openaiCreateHint') }}
|
||||
</div>
|
||||
<FormField
|
||||
v-if="form.values.client_type !== 'github-copilot'"
|
||||
v-slot="{ componentField }"
|
||||
name="base_url"
|
||||
>
|
||||
@@ -188,12 +189,13 @@ const { mutateAsync: createProviderMutation, isLoading } = useMutation({
|
||||
mutation: async (data: Record<string, unknown>) => {
|
||||
const config: Record<string, unknown> = {}
|
||||
if (data.base_url) config.base_url = data.base_url
|
||||
if (data.api_key) config.api_key = data.api_key
|
||||
if (typeof data.api_key === 'string' && data.api_key.trim() !== '' && data.client_type !== 'github-copilot') {
|
||||
config.api_key = data.api_key.trim()
|
||||
}
|
||||
const payload = {
|
||||
name: data.name,
|
||||
client_type: data.client_type,
|
||||
config,
|
||||
metadata: { additionalProp1: {} },
|
||||
}
|
||||
const { data: result } = await postProviders({ body: payload as ProvidersCreateRequest, throwOnError: true })
|
||||
if (data.auto_import && result?.id) {
|
||||
@@ -221,18 +223,25 @@ const { mutateAsync: createProviderMutation, isLoading } = useMutation({
|
||||
|
||||
const providerSchema = toTypedSchema(z.object({
|
||||
api_key: z.string().optional(),
|
||||
base_url: z.string().min(1),
|
||||
base_url: z.string().optional(),
|
||||
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()) {
|
||||
if (!['openai-codex', 'github-copilot'].includes(value.client_type) && !value.api_key?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_key'],
|
||||
message: 'API key is required',
|
||||
})
|
||||
}
|
||||
if (value.client_type !== 'github-copilot' && !value.base_url?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['base_url'],
|
||||
message: 'Base URL is required',
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
@@ -240,14 +249,16 @@ const form = useForm({
|
||||
initialValues: {
|
||||
auto_import: false,
|
||||
client_type: 'openai-completions',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.values.client_type, (clientType) => {
|
||||
if (clientType !== 'openai-codex') return
|
||||
if (!form.values.base_url) {
|
||||
if (clientType === 'openai-codex' && !form.values.base_url) {
|
||||
form.setFieldValue('base_url', 'https://chatgpt.com/backend-api')
|
||||
}
|
||||
if (clientType === 'github-copilot') {
|
||||
form.setFieldValue('base_url', '')
|
||||
}
|
||||
})
|
||||
|
||||
const createProvider = form.handleSubmit(async (value) => {
|
||||
|
||||
@@ -20,6 +20,11 @@ export const CLIENT_TYPE_META: Record<string, ClientTypeMeta> = {
|
||||
label: 'OpenAI Codex',
|
||||
hint: 'Codex API (OAuth, coding-optimized)',
|
||||
},
|
||||
'github-copilot': {
|
||||
value: 'github-copilot',
|
||||
label: 'GitHub Copilot',
|
||||
hint: 'Device OAuth with GitHub account',
|
||||
},
|
||||
'anthropic-messages': {
|
||||
value: 'anthropic-messages',
|
||||
label: 'Anthropic Messages',
|
||||
|
||||
@@ -292,20 +292,35 @@
|
||||
"enable": "Enable",
|
||||
"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.",
|
||||
"openaiTitle": "OpenAI OAuth",
|
||||
"openaiDescription": "Authorize this provider with your ChatGPT account for Codex-compatible OpenAI access.",
|
||||
"openaiCreateHint": "Save the provider first, then authorize it from the provider details panel.",
|
||||
"githubTitle": "GitHub Copilot OAuth",
|
||||
"githubDescription": "Connect the current Memoh account with GitHub Copilot.",
|
||||
"githubDeviceTitle": "GitHub Copilot Device Authorization",
|
||||
"githubDeviceDescription": "Start device authorization, open GitHub's verification page, and enter the user code shown below.",
|
||||
"githubCreateHint": "Save the provider first, then start device authorization from the provider details panel.",
|
||||
"githubDeviceHint": "Open the verification URL below and enter this user code to authorize the current Memoh account.",
|
||||
"authorize": "Authorize",
|
||||
"deviceAuthorize": "Start Device Authorization",
|
||||
"authorizeFailed": "Failed to start authorization",
|
||||
"authorizeSuccess": "Authorization successful",
|
||||
"revoke": "Revoke",
|
||||
"revokeFailed": "Failed to revoke authorization",
|
||||
"revokeSuccess": "Authorization revoked",
|
||||
"copyFailed": "Failed to copy device code",
|
||||
"connectedAccount": "Connected Account",
|
||||
"callback": "Callback URL",
|
||||
"deviceVerificationUri": "Verification URL",
|
||||
"deviceUserCode": "User Code",
|
||||
"deviceExpiresAt": "Expires At",
|
||||
"statusFailed": "Failed to load OAuth status",
|
||||
"status": {
|
||||
"checking": "Checking authorization status...",
|
||||
"authorized": "Authorized",
|
||||
"authorizedCurrent": "Current account connected",
|
||||
"oauthing": "OAuthing...",
|
||||
"pendingDevice": "Waiting for device authorization to complete...",
|
||||
"expired": "Authorization expired. Re-authorize to continue.",
|
||||
"missing": "Not authorized yet.",
|
||||
"notConfigured": "This provider is not configured for OAuth."
|
||||
|
||||
@@ -288,20 +288,35 @@
|
||||
"enable": "启用",
|
||||
"enableHint": "只有启用的供应商的模型才会出现在可用模型列表中",
|
||||
"oauth": {
|
||||
"title": "OpenAI OAuth",
|
||||
"description": "使用你的 ChatGPT 账号为该提供商授权,以启用 Codex 兼容的 OpenAI 访问。",
|
||||
"createHint": "请先保存提供商,再到详情面板完成授权。",
|
||||
"openaiTitle": "OpenAI OAuth",
|
||||
"openaiDescription": "使用你的 ChatGPT 账号为该提供商授权,以启用 Codex 兼容的 OpenAI 访问。",
|
||||
"openaiCreateHint": "请先保存提供商,再到详情面板完成授权。",
|
||||
"githubTitle": "GitHub Copilot OAuth",
|
||||
"githubDescription": "为当前 Memoh 账号连接 GitHub Copilot。",
|
||||
"githubDeviceTitle": "GitHub Copilot Device Authorization",
|
||||
"githubDeviceDescription": "启动设备授权后,打开 GitHub 验证页面并输入下方显示的用户代码。",
|
||||
"githubCreateHint": "请先保存提供商,再到详情面板启动设备授权。",
|
||||
"githubDeviceHint": "打开下方验证地址,并输入这个用户代码,为当前 Memoh 账号完成授权。",
|
||||
"authorize": "授权",
|
||||
"deviceAuthorize": "启动设备授权",
|
||||
"authorizeFailed": "启动授权失败",
|
||||
"authorizeSuccess": "授权成功",
|
||||
"revoke": "撤销授权",
|
||||
"revokeFailed": "撤销授权失败",
|
||||
"revokeSuccess": "授权已撤销",
|
||||
"copyFailed": "复制设备代码失败",
|
||||
"connectedAccount": "已连接账号",
|
||||
"callback": "回调地址",
|
||||
"deviceVerificationUri": "验证地址",
|
||||
"deviceUserCode": "用户代码",
|
||||
"deviceExpiresAt": "过期时间",
|
||||
"statusFailed": "加载 OAuth 状态失败",
|
||||
"status": {
|
||||
"checking": "正在检查授权状态...",
|
||||
"authorized": "已授权",
|
||||
"authorizedCurrent": "当前账号已连接",
|
||||
"oauthing": "授权中...",
|
||||
"pendingDevice": "正在等待设备授权完成...",
|
||||
"expired": "授权已过期,请重新授权。",
|
||||
"missing": "尚未授权。",
|
||||
"notConfigured": "当前提供商未正确配置 OAuth。"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="form.values.client_type !== 'openai-codex'"
|
||||
v-if="!['openai-codex', 'github-copilot'].includes(form.values.client_type)"
|
||||
class="space-y-2"
|
||||
>
|
||||
<FormField
|
||||
@@ -33,7 +33,7 @@
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
:placeholder="(providerWithAuth?.config as Record<string, unknown> | undefined)?.api_key as string || $t('provider.apiKeyPlaceholder')"
|
||||
:placeholder="getStoredSecret(props.provider?.config as Record<string, unknown> | undefined) || $t('provider.apiKeyPlaceholder')"
|
||||
:aria-label="$t('provider.apiKey')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
@@ -42,7 +42,10 @@
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<section
|
||||
v-if="form.values.client_type !== 'github-copilot'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="base_url"
|
||||
@@ -81,15 +84,15 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="form.values.client_type === 'openai-codex'"
|
||||
v-if="['openai-codex', 'github-copilot'].includes(form.values.client_type)"
|
||||
class="rounded-lg border p-4 space-y-3 text-xs"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ $t('provider.oauth.title') }}
|
||||
{{ $t(form.values.client_type === 'github-copilot' ? 'provider.oauth.githubDeviceTitle' : 'provider.oauth.openaiTitle') }}
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ $t('provider.oauth.description') }}
|
||||
{{ $t(form.values.client_type === 'github-copilot' ? 'provider.oauth.githubDeviceDescription' : 'provider.oauth.openaiDescription') }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs"
|
||||
@@ -105,7 +108,10 @@
|
||||
{{ $t('provider.oauth.status.expired') }}
|
||||
</template>
|
||||
<template v-else-if="oauthStatus?.has_token">
|
||||
{{ $t('provider.oauth.status.authorized') }}
|
||||
{{ $t(form.values.client_type === 'github-copilot' ? 'provider.oauth.status.authorizedCurrent' : 'provider.oauth.status.authorized') }}
|
||||
</template>
|
||||
<template v-else-if="oauthStatus?.device?.pending">
|
||||
{{ $t('provider.oauth.status.pendingDevice') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('provider.oauth.status.missing') }}
|
||||
@@ -118,16 +124,88 @@
|
||||
{{ $t('provider.oauth.callback') }}: {{ oauthStatus.callback_url }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.values.client_type === 'github-copilot'
|
||||
&& oauthStatus?.device?.pending
|
||||
&& !oauthStatus?.has_token
|
||||
&& oauthStatus?.device?.user_code
|
||||
&& oauthStatus?.device?.verification_uri"
|
||||
class="rounded-md bg-muted/40 p-3 space-y-2"
|
||||
>
|
||||
<div class="text-muted-foreground">
|
||||
{{ $t('provider.oauth.githubDeviceHint') }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ $t('provider.oauth.deviceVerificationUri') }}
|
||||
</div>
|
||||
<code class="block break-all rounded bg-background px-2 py-1 select-all">{{ oauthStatus?.device?.verification_uri }}</code>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ $t('provider.oauth.deviceUserCode') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="block flex-1 rounded bg-background px-2 py-1 text-sm tracking-[0.3em] select-all">{{ oauthStatus?.device?.user_code }}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleCopyDeviceCode"
|
||||
>
|
||||
<Copy />
|
||||
{{ $t('common.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="oauthStatus?.device?.expires_at"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ $t('provider.oauth.deviceExpiresAt') }}: {{ oauthStatus.device.expires_at }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-foreground">
|
||||
<Spinner class="size-4" />
|
||||
<span>{{ $t('provider.oauth.status.oauthing') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.values.client_type === 'github-copilot' && oauthStatus?.has_token && !oauthExpired"
|
||||
class="rounded-md bg-muted/40 p-3 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ $t('provider.oauth.connectedAccount') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ oauthStatus?.account?.email || oauthStatus?.account?.label || oauthStatus?.account?.name || oauthStatus?.account?.login || $t('provider.oauth.status.authorizedCurrent') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="[oauthStatus?.account?.login?.trim() ? `@${oauthStatus.account.login.trim()}` : '', oauthStatus?.account?.email?.trim() ?? ''].filter(Boolean).join(' · ')"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ [oauthStatus?.account?.login?.trim() ? `@${oauthStatus.account.login.trim()}` : '', oauthStatus?.account?.email?.trim() ?? ''].filter(Boolean).join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<LoadingButton
|
||||
v-if="props.provider?.id
|
||||
&& ['openai-codex', 'github-copilot'].includes(form.values.client_type)
|
||||
&& !(
|
||||
form.values.client_type === 'github-copilot'
|
||||
&& oauthStatus?.device?.pending
|
||||
&& !oauthStatus?.has_token
|
||||
&& oauthStatus?.device?.user_code
|
||||
&& oauthStatus?.device?.verification_uri
|
||||
)
|
||||
&& (!oauthStatus?.has_token || oauthExpired)"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!canAuthorizeOAuth"
|
||||
:disabled="!props.provider?.id || !['openai-codex', 'github-copilot'].includes(form.values.client_type) || oauthStatusLoading"
|
||||
:loading="authorizeLoading"
|
||||
@click="handleAuthorize"
|
||||
>
|
||||
<KeyRound />
|
||||
{{ $t('provider.oauth.authorize') }}
|
||||
{{ $t(form.values.client_type === 'github-copilot' ? 'provider.oauth.deviceAuthorize' : 'provider.oauth.authorize') }}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
v-if="oauthStatus?.has_token"
|
||||
@@ -225,14 +303,16 @@ import {
|
||||
FormField,
|
||||
FormLabel,
|
||||
FormItem,
|
||||
Spinner,
|
||||
} from '@memohai/ui'
|
||||
import { KeyRound, RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { Copy, KeyRound, RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import StatusDot from '@/components/status-dot/index.vue'
|
||||
import LoadingButton from '@/components/loading-button/index.vue'
|
||||
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
@@ -242,15 +322,44 @@ import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyText } = useClipboard()
|
||||
|
||||
type ProviderWithAuth = Partial<ProvidersGetResponse>
|
||||
|
||||
type ProviderOAuthStatus = {
|
||||
configured: boolean
|
||||
mode?: string
|
||||
has_token: boolean
|
||||
expired: boolean
|
||||
callback_url?: string
|
||||
expires_at?: string
|
||||
account?: {
|
||||
label?: string
|
||||
login?: string
|
||||
name?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
profile_url?: string
|
||||
}
|
||||
device?: {
|
||||
pending: boolean
|
||||
user_code?: string
|
||||
verification_uri?: string
|
||||
expires_at?: string
|
||||
interval_seconds?: number
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderOAuthAuthorizeResponse = {
|
||||
mode?: string
|
||||
auth_url?: string
|
||||
device?: ProviderOAuthStatus['device']
|
||||
}
|
||||
|
||||
function getStoredSecret(config: Record<string, unknown> | undefined) {
|
||||
if (!config) return ''
|
||||
const apiKey = config.api_key
|
||||
return typeof apiKey === 'string' ? apiKey : ''
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -271,10 +380,9 @@ const oauthStatus = ref<ProviderOAuthStatus | null>(null)
|
||||
const oauthStatusLoading = ref(false)
|
||||
const authorizeLoading = ref(false)
|
||||
const revokeLoading = ref(false)
|
||||
const pollTimer = ref<number | null>(null)
|
||||
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
|
||||
testLoading.value = true
|
||||
@@ -310,20 +418,27 @@ const clientTypeOptions = computed(() =>
|
||||
const providerSchema = toTypedSchema(z.object({
|
||||
enable: z.boolean(),
|
||||
name: z.string().min(1),
|
||||
base_url: z.string().min(1),
|
||||
base_url: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
client_type: z.string().min(1),
|
||||
metadata: z.object({
|
||||
additionalProp1: z.object({}),
|
||||
}),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.client_type !== 'openai-codex' && !value.api_key?.trim() && !(providerWithAuth.value?.config as Record<string, unknown> | undefined)?.api_key) {
|
||||
const existingSecret = getStoredSecret(
|
||||
props.provider?.config as Record<string, unknown> | undefined,
|
||||
)
|
||||
if (!['openai-codex', 'github-copilot'].includes(value.client_type) && !value.api_key?.trim() && !existingSecret.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_key'],
|
||||
message: 'API key is required',
|
||||
})
|
||||
}
|
||||
if (value.client_type !== 'github-copilot' && !value.base_url?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['base_url'],
|
||||
message: 'Base URL is required',
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
@@ -344,17 +459,19 @@ watch(() => props.provider, (newVal) => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => form.values.client_type, (clientType) => {
|
||||
if (clientType !== 'openai-codex') {
|
||||
if (!['openai-codex', 'github-copilot'].includes(clientType)) {
|
||||
oauthStatus.value = null
|
||||
return
|
||||
}
|
||||
if (!form.values.base_url) {
|
||||
if (clientType === 'openai-codex' && !form.values.base_url) {
|
||||
form.setFieldValue('base_url', 'https://chatgpt.com/backend-api')
|
||||
}
|
||||
if (clientType === 'github-copilot') {
|
||||
form.setFieldValue('base_url', '')
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => [props.provider?.id, form.values.client_type] as const, async ([id, clientType]) => {
|
||||
if (!id || clientType !== 'openai-codex') {
|
||||
if (!id || (clientType !== 'openai-codex' && clientType !== 'github-copilot')) {
|
||||
oauthStatus.value = null
|
||||
return
|
||||
}
|
||||
@@ -369,13 +486,11 @@ const hasChanges = computed(() => {
|
||||
name: form.values.name,
|
||||
base_url: form.values.base_url,
|
||||
client_type: form.values.client_type,
|
||||
metadata: form.values.metadata,
|
||||
}) !== JSON.stringify({
|
||||
enable: raw?.enable ?? true,
|
||||
name: raw?.name,
|
||||
base_url: (cfg?.base_url as string) ?? '',
|
||||
client_type: raw?.client_type || 'openai-completions',
|
||||
metadata: { additionalProp1: {} },
|
||||
})
|
||||
|
||||
const apiKeyChanged = Boolean(form.values.api_key && form.values.api_key.trim() !== '')
|
||||
@@ -383,33 +498,47 @@ const hasChanges = computed(() => {
|
||||
})
|
||||
|
||||
const editProvider = form.handleSubmit(async (value) => {
|
||||
const config: Record<string, unknown> = { base_url: value.base_url }
|
||||
const config: Record<string, unknown> = {}
|
||||
if (value.base_url && value.base_url.trim() !== '') {
|
||||
config.base_url = value.base_url
|
||||
}
|
||||
if (value.api_key && value.api_key.trim() !== '') {
|
||||
config.api_key = value.api_key
|
||||
if (value.client_type !== 'github-copilot') {
|
||||
config.api_key = value.api_key.trim()
|
||||
}
|
||||
}
|
||||
const metadata = {
|
||||
...((props.provider?.metadata as Record<string, unknown> | undefined) ?? {}),
|
||||
}
|
||||
if (value.client_type === 'github-copilot') {
|
||||
delete metadata.oauth_client_id
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
enable: value.enable,
|
||||
name: value.name,
|
||||
config,
|
||||
client_type: value.client_type,
|
||||
metadata: value.metadata,
|
||||
}
|
||||
if (Object.keys(metadata).length > 0 || value.client_type === 'github-copilot') {
|
||||
payload.metadata = metadata
|
||||
}
|
||||
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}` } : {}
|
||||
}
|
||||
|
||||
function clearPollTimer() {
|
||||
if (pollTimer.value !== null) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOAuthStatus() {
|
||||
if (!props.provider?.id) return
|
||||
oauthStatusLoading.value = true
|
||||
@@ -427,6 +556,44 @@ async function fetchOAuthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOAuthAuthorization(notifyOnSuccess = false) {
|
||||
if (!props.provider?.id || form.values.client_type !== 'github-copilot') return
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/providers/${props.provider.id}/oauth/poll`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (!response.ok) throw new Error(t('provider.oauth.authorizeFailed'))
|
||||
const nextStatus = await response.json() as ProviderOAuthStatus
|
||||
const becameAuthorized = !oauthStatus.value?.has_token && Boolean(nextStatus.has_token)
|
||||
oauthStatus.value = nextStatus
|
||||
if (notifyOnSuccess && becameAuthorized) {
|
||||
toast.success(t('provider.oauth.authorizeSuccess'))
|
||||
}
|
||||
} catch (error) {
|
||||
clearPollTimer()
|
||||
toast.error(error instanceof Error ? error.message : t('provider.oauth.authorizeFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
watch(oauthStatus, (status) => {
|
||||
clearPollTimer()
|
||||
if (form.values.client_type !== 'github-copilot') {
|
||||
return
|
||||
}
|
||||
if (!status?.device?.pending || status.has_token) {
|
||||
return
|
||||
}
|
||||
const intervalSeconds = Math.max(status.device.interval_seconds ?? 5, 1)
|
||||
pollTimer.value = window.setTimeout(() => {
|
||||
void pollOAuthAuthorization(true)
|
||||
}, intervalSeconds * 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimer()
|
||||
})
|
||||
|
||||
async function handleAuthorize() {
|
||||
if (!props.provider?.id) return
|
||||
authorizeLoading.value = true
|
||||
@@ -435,7 +602,18 @@ async function handleAuthorize() {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (!response.ok) throw new Error(t('provider.oauth.authorizeFailed'))
|
||||
const data = await response.json() as { auth_url?: string }
|
||||
const data = await response.json() as ProviderOAuthAuthorizeResponse
|
||||
if (data.mode === 'device') {
|
||||
oauthStatus.value = {
|
||||
configured: true,
|
||||
mode: 'device',
|
||||
has_token: false,
|
||||
expired: false,
|
||||
callback_url: '',
|
||||
device: data.device,
|
||||
}
|
||||
return
|
||||
}
|
||||
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) => {
|
||||
@@ -453,8 +631,34 @@ async function handleAuthorize() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyDeviceCode() {
|
||||
const userCode = oauthStatus.value?.device?.user_code?.trim()
|
||||
const verificationUri = oauthStatus.value?.device?.verification_uri?.trim()
|
||||
if (!userCode || !verificationUri) return
|
||||
|
||||
const popup = window.open('', 'provider-device-oauth', 'width=960,height=720')
|
||||
const copied = await copyText(userCode)
|
||||
|
||||
if (!copied) {
|
||||
popup?.close()
|
||||
toast.error(t('provider.oauth.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(t('common.copied'))
|
||||
|
||||
if (popup) {
|
||||
popup.location.href = verificationUri
|
||||
popup.focus()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(verificationUri, '_blank', 'width=960,height=720')
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!props.provider?.id) return
|
||||
clearPollTimer()
|
||||
revokeLoading.value = true
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/providers/${props.provider.id}/oauth/token`, {
|
||||
|
||||
Reference in New Issue
Block a user