feat(provider): add github copilot device flow provider (#364)

This commit is contained in:
LiBr
2026-04-13 19:38:33 +08:00
committed by GitHub
parent a40207ab6d
commit df8fbd8859
36 changed files with 2659 additions and 246 deletions
+21 -10
View File
@@ -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) => {
+5
View File
@@ -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',
+18 -3
View File
@@ -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."
+18 -3
View File
@@ -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`, {