Skip to content

feat(channel): add GitHub Copilot support - #714

Open
jjcc123312 wants to merge 5 commits into
mainfrom
codex/copilot-channel-plan
Open

feat(channel): add GitHub Copilot support#714
jjcc123312 wants to merge 5 commits into
mainfrom
codex/copilot-channel-plan

Conversation

@jjcc123312

@jjcc123312 jjcc123312 commented Aug 13, 2026

Copy link
Copy Markdown

Background

Add a first-class GitHub Copilot channel, separate from the existing Codex/ChatGPT OAuth channel. The implementation stays focused on the verified official Copilot API paths.

Evidence and design

  • Uses ChannelTypeCopilot = 112; moves Dummy to 113.
  • The relay always targets the official https://api.githubcopilot.com host: OpenAI Chat Completions uses /chat/completions; native Anthropic Claude uses /v1/messages.
  • GitHub Device Flow returns a gho_ OAuth App credential, forwarded directly as the upstream Bearer credential. The obsolete /copilot_internal/v2/token exchange and short-token cache were removed.
  • Copilot request headers use User-Agent: opencode/0.4.2, Openai-Intent, X-GitHub-Api-Version, X-Initiator, and an individual X-Request-Id; native Claude calls also use SSE Accept and anthropic-version.
  • The Client ID is an administrator-configured copilot.client_id system setting. No environment-variable fallback and no third-party OAuth App Client ID are embedded.
  • Device Flow requires Redis: sessions are server-side, bound to admin/channel, claimed with Redis SETNX, re-read after claim, consumed before credential write, and persisted only while the target remains a Copilot channel.
  • Copilot supports exactly one OAuth credential. New channels are created empty, then authorized with Device Flow.

Scope

  • Go channel registration, direct OAuth relay adapter, native Claude response handling, Device Flow admin APIs, and credential safeguards.
  • Console system setting for Client ID, empty-credential create UX, Device Flow dialog, and all 8 i18n locales.
  • Focused regression tests and an updated persisted design record.

Validation

  • go test ./setting/system_setting ./service ./controller ./relay/channel/copilot -run "Test.*Copilot|TestConvertClaudeRequestUsesNativePassthrough" -count=1
  • go vet ./service ./controller ./relay/channel/copilot ./setting/system_setting
  • bun test src/features/channels/lib/channel-form.test.ts src/features/channels/constants.test.ts
  • bun run typecheck
  • bun run i18n:sync
  • git diff --check

Risk and rollout

Router deploy is required because this changes /v1 relay routing and upstream authentication. Deploy newapi-console as well for the Client ID setting and authorization controls. Configure an organization-owned or enterprise-approved GitHub OAuth App Client ID and Redis; validate Device Flow, Chat Completions, native Claude /v1/messages, streaming/non-streaming, and concurrent multi-node polling in staging with an entitled test account. newapi-web, Terraform, and Cloudflare are not affected.

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit cbb067c3 · 共 8 条

model/channel.go

  • L649-659: [严重] 这里把 RowsAffected != 1 直接当成 RecordNotFound,会在“key 值与原值相同”时误判失败。部分数据库在更新为相同值时会返回 0 行受影响,但记录实际上存在,这会导致 Copilot 凭据刷新/重试路径无故报错。建议改为仅在 result.Error != nil 时返回错误,或者在需要区分“记录不存在”时先单独做存在性校验。
func UpdateChannelKeyForType(id int, channelType int, key string) error {
	result := DB.Model(&Channel{}).Where("id = ? AND type = ?", id, channelType).Update("key", key)
	if result.Error != nil {
		return result.Error
	}
	publishChannelsChanged()
	return nil
}

service/copilot_token.go

  • L78: [严重] 这里把第一个进入 singleflight 的请求级 ctx 传给实际换 token 的 HTTP 请求,后续同一 cacheKey 的并发请求都会等待并复用这一次结果;如果首个客户端断开、超时或被取消,换 token 会被取消并把错误广播给其它仍然有效的请求,导致并发场景下 Copilot 认证批量失败。建议在 singleflight 内部为换 token 创建独立的受控超时上下文,避免传播首个请求的取消状态。
tokenCtx, cancel := context.WithTimeout(context.Background(), copilotTokenTimeout)
		defer cancel()
		return exchangeCopilotToken(tokenCtx, credential, proxyURL, cacheKey)
  • L156: [严重] 这里按 channelID 的字符串前缀删除缓存,但 DeleteByPrefix 会先补 : 再做匹配,因此传入 1 实际匹配的是 copilot_token:v1:1:* 这一类 key;当前实现依赖底层自动补分隔符,后续如果 cache 命名或前缀格式调整,很容易出现误删/漏删。建议显式传入带分隔符的频道前缀,或直接提供按完整 namespace key 删除的封装,降低失效范围不确定性。
_, err := getCopilotTokenCache().DeleteByPrefix(strconv.Itoa(channelID) + ":")

service/copilot_device.go

  • L216-224: [严重] 这里先删除 Redis 会话再写入渠道凭证,两个操作不具备原子性;如果 UpdateChannelKeyForType 或后续缓存刷新过程中失败,device flow 已被消费,当前拿到的 GitHub access token 不会落库,用户也无法用同一个 flow 重试,可能导致授权成功但渠道仍不可用。建议先持久化凭证,成功后再清理会话;清理失败可记录日志并让后续重复轮询按幂等写入处理,避免凭证丢失。
if token := strings.TrimSpace(payload.AccessToken); token != "" {
		if err := modelUpdateCopilotCredential(channelID, token); err != nil {
			return nil, err
		}
		if err := consumeCopilotDeviceFlow(ctx, flowID); err != nil {
			common.SysError("copilot device authorization session cleanup failed: " + err.Error())
		}
		return &CopilotDevicePoll{Status: "authorized", Message: "authorization completed"}, nil
	}
  • L362-365: [严重] 授权成功的用户请求路径中同步调用 InitChannelCache() 会全量查询 channels 和 abilities 并重建全局缓存;当渠道/能力数据较多或并发授权时,会显著拉长接口耗时并放大数据库与缓存锁竞争。建议改为只更新/失效当前 channel 的缓存,或将全量刷新放到异步任务中,并确保请求成功不依赖全量重建完成。
go model.InitChannelCache()
	if err := InvalidateCopilotTokenCache(channelID); err != nil {
		common.SysError("copilot token cache invalidation failed for channel " + strconv.Itoa(channelID))
	}

web/default/src/features/channels/components/dialogs/copilot-device-flow-dialog.tsx

  • L0: [严重] handleStartawait startCopilotDeviceFlow 返回后没有校验当前弹窗/请求是否仍然有效。若用户在请求未完成时关闭或重新打开弹窗,旧请求完成后仍会把 flowActiveRef 置为 true、打开外部页面并启动轮询,导致已取消的授权流程被重新拉起。建议增加请求序号或 AbortController,并在回调前检查仍是当前这次启动请求。
const startRequestIdRef = useRef(0)

  const handleStart = async () => {
    const requestId = ++startRequestIdRef.current
    setState((current) => ({ ...current, isStarting: true, status: '' }))
    try {
      const res = await startCopilotDeviceFlow(props.channelId)
      if (requestId !== startRequestIdRef.current || !props.open) return
      if (!res.success)
        throw new Error(res.message || t('Authorization failed'))

      const verificationUri = res.data?.verification_uri || ''
      const userCode = res.data?.user_code || ''
  • L122-127: [严重] handlePoll 里只要轮询请求抛错就会立刻 stopPolling() 并终止整个设备码授权。这个流程对超时、5xx、网络抖动等临时故障过于敏感,容易把本来可恢复的授权中断成“必须重来”,影响 Copilot 授权核心功能的可用性。建议把终态错误(expired/denied)和临时错误分开处理,临时错误保留当前 code 并按间隔重试或退避重试。
} catch (error) {
      setState((current) => ({
        ...current,
        status: t('Polling temporarily failed, retrying...'),
      }))
      schedulePoll(intervalSeconds)
    }

web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx

  • L628-633: [严重] 这里只在选择 Copilot 且 base_url 为空时写入默认地址,但用户随后切换到其他渠道类型时不会清理这个默认值,提交时会把 https://api.githubcopilot.com 带到非 Copilot 渠道,可能导致新建的渠道请求被发往错误上游而不可用。建议在类型切换时识别并清理上一个类型的默认值,或统一按渠道类型重新初始化 base_url
const copilotDefaultBaseUrl = 'https://api.githubcopilot.com'
    const currentBaseUrlValue = form.getValues('base_url')
    if (currentType === 112) {
      if (!currentBaseUrlValue) {
        form.setValue('base_url', copilotDefaultBaseUrl)
      }
    } else if (currentBaseUrlValue === copilotDefaultBaseUrl) {
      form.setValue('base_url', '')
    }

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 cbb067c3..b69d0bbe · 共 4 条

web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx

  • L942: [阻塞] 这里在提交回调中使用了 currentType,但 useCallback 的依赖数组没有包含它,用户切换到 GitHub Copilot(type=112) 后回调可能仍持有旧类型,导致创建 Copilot 渠道时仍被 API Key 必填校验拦截,核心新增功能不可用。建议直接使用本次提交数据里的 data.type 做判断,避免闭包状态滞后。
if (!isEditing && data.type !== 112 && !data.key?.trim()) {

controller/channel.go

  • L0: [严重] 这里把 Claude 请求的 Accept 固定设置为 text/event-stream,但下方 DoResponse 同时还保留了非流式的 ClaudeHandler。这样非流式 Claude 请求也会协商成 SSE,服务端返回格式和本地解析路径很可能不一致,导致普通请求失败。建议按 info.IsStream 分支设置:流式用 text/event-stream,非流式保持 application/json
if info.RelayFormat == types.RelayFormatClaude {
		if info.IsStream {
			header.Set("Accept", "text/event-stream")
		} else {
			header.Set("Accept", "application/json")
		}
		header.Set("anthropic-version", "2023-06-01")
	} else {
		header.Set("Accept", "application/json")
	}

service/copilot_device.go

  • L75-81: [严重] 这里直接从全局系统设置读取 ClientID,但没有把它写入 device flow session。这样如果管理员在用户完成授权前修改了 Copilot Client ID,后续 PollCopilotDeviceFlow 会改用新的 client_id 去换 token,导致已经发起的 device code 失效,正常进行中的授权会被打断。建议把发起时使用的 client_id 一并持久化到 session 中,轮询时固定使用该值。
clientID := copilotDeviceClientID()
if clientID == "" {
	return nil, errors.New("Copilot Device Flow is not configured; configure the Copilot Client ID")
}
if !copilotDeviceRedisAvailable() {
	return nil, errors.New("Copilot Device Flow requires Redis")
}
// 将 clientID 写入 session,Poll 时优先使用 session 中的值,避免配置变更影响进行中的授权流程。

relay/channel/copilot/adaptor.go

  • L71-76: [严重] 这里对 Claude 格式无条件设置 Accept: text/event-stream,但 DoResponse 又会在 info.IsStream == false 时走 claude.ClaudeHandler 的非流式解析路径。这样非流式 Claude 请求也可能被上游按 SSE 返回,最终导致本地按普通 JSON 处理失败或卡住。建议仅在流式请求时设置 text/event-stream,非流式场景保持 application/json,并与请求体里的 stream 标记保持一致。
if info.RelayFormat == types.RelayFormatClaude {
		if info.IsStream {
			header.Set("Accept", "text/event-stream")
		} else {
			header.Set("Accept", "application/json")
		}
		header.Set("anthropic-version", "2023-06-01")
	} else {
		header.Set("Accept", "application/json")
	}

@jjcc123312

Copy link
Copy Markdown
Author

Review feedback follow-up (commit 6decf46):

  • Resolved: the Copilot credential write now happens before best-effort Device Flow session cleanup, so a cleanup failure cannot discard a successful authorization.
  • Resolved: the Device Flow session persists the Client ID used to start it; polling is not broken if an admin changes the setting mid-flow.
  • Resolved: native Claude uses text/event-stream only for streaming and application/json otherwise.
  • Resolved: create validation now uses data.type, removing the stale currentType closure.
  • Resolved: the Copilot default base URL is cleared when selecting another type.
  • Resolved: dialog start requests are invalidated on close, and temporary poll failures retry instead of ending the authorization.
  • Resolved: same-value channel-key updates no longer falsely return record-not-found.

The former copilot_token singleflight/cache-prefix comments apply to the earlier exchange-token design; that code was removed in b69d0bb when the channel switched to direct gho_ OAuth credentials, so those items are obsolete.

Validation: go test ./service ./relay/channel/copilot ./model -run "TestCopilot|TestSetupRequestHeaderUses|TestConvertClaudeRequestUsesNativePassthrough" -count=1, go vet ./service ./relay/channel/copilot ./model, targeted Bun tests, and TypeScript typecheck passed.

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 b69d0bbe..6decf461 · 共 4 条

model/channel.go

  • L649-656: [严重] 这里删除了 RowsAffected 校验后,id/type 不匹配或记录已不存在时会被当成成功返回,publishChannelsChanged() 也会继续执行,导致调用方无法感知更新其实没有发生。像 Copilot 授权流程里这会把“写入成功”伪装成成功,最终出现数据库未更新但前端已完成授权的状态不一致。建议恢复受影响行数检查,未命中时返回 gorm.ErrRecordNotFound 或明确的业务错误。
func UpdateChannelKeyForType(id int, channelType int, key string) error {
	result := DB.Model(&Channel{}).Where("id = ? AND type = ?", id, channelType).Update("key", key)
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected != 1 {
		return gorm.ErrRecordNotFound
	}
	publishChannelsChanged()
	return nil
}

service/copilot_device.go

  • L143-145: [严重] 这里把 client_id 为空直接判成无效会把升级前已经创建、但 Redis 里还没带新字段的旧会话全部拦死,用户在部署窗口内发起的 Copilot 授权会无法继续完成。建议兼容老会话:要么在加载时补回当前配置的 client_id 并续用,要么先按过期/失效清理后给出可恢复的引导,避免线上升级导致正在进行中的授权流程中断。
if session.ClientID == "" {
		clientID := copilotDeviceClientID()
		if clientID == "" {
			return nil, errors.New("Copilot Device Flow is not configured; configure the Copilot Client ID")
		}
		session.ClientID = clientID
	}

web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx

  • L944: [严重] 这里把 ClientID 强依赖到 Redis 里的会话结构,但轮询阶段对历史会话没有兼容处理。升级前已经开始的 Copilot 授权流程在新的轮询逻辑下会直接被判定为无效,导致正在进行中的授权被中断。建议在缺失 ClientID 时回退到当前配置值,或在读取会话时做迁移兼容。
if session.ClientID == "" {
		session.ClientID = copilotDeviceClientID()
		if session.ClientID == "" {
			return nil, errors.New("Copilot Device Flow is not configured; configure the Copilot Client ID")
		}
	}

web/default/src/features/channels/components/dialogs/copilot-device-flow-dialog.tsx

  • L134-139: [严重] 这里只在成功分支校验了 requestId,但 catch 里没有做同样的判断。若旧的 start 请求在用户关闭/重新打开对话框后才失败,catch 仍会把新一轮请求的 isStarting 置回 false 并弹出过期错误,导致按钮状态被错误回写,出现并发竞态。建议在 catch 中也先判断 requestId !== startRequestIdRef.current || !props.open 再更新状态。
const handleStart = async () => {
    const requestId = ++startRequestIdRef.current
    setState((current) => ({ ...current, isStarting: true, status: '' }))
    try {
      const res = await startCopilotDeviceFlow(props.channelId)
      if (requestId !== startRequestIdRef.current || !props.open) return

@jjcc123312

Copy link
Copy Markdown
Author

Follow-up for comment 5280546802 (commit 2f79a9f18):

  • Resolved: sessions created before the Client ID snapshot field now recover it from the current configured Client ID during polling. New sessions remain pinned to their starting Client ID.
  • Resolved: stale start-request failures now pass the same request-id/open guard before changing dialog state or displaying a toast.
  • Already addressed in 816c535: zero affected rows now re-check the id + channel type before reporting success, preserving the type/deletion guard while avoiding MySQL no-op false negatives.

Validation: go test ./service -run TestCopilotDeviceFlow -count=1, go vet ./service, and cd web/default && bun run typecheck.

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 6decf461..9621bbd3 · 共 2 条

service/copilot_device.go

  • L33-39: [严重] 这里把 Redis 兜底改成了进程内 memoryStore,但 Start/Poll/Consume 仍然要求同一个实例持有同一份状态。只要服务是多副本部署、重启,或者负载均衡没有粘性会话,设备流就会出现“找不到 session / 已过期 / 已消费”的随机失败,Copilot 授权会间歇性不可用。建议仅在明确的单实例模式下启用该路径,或者继续要求共享存储。
if !copilotDeviceRedisAvailable() {
	return nil, errors.New("Copilot Device Flow requires Redis")
}
  • L386-392: [严重] 内存兜底下 loadCopilotDeviceSession 只有在某个 flowID 被再次访问时才会清理过期 session;如果用户不断发起设备流但从不完成授权,这些过期记录会长期留在 map 里,进程内内存会持续增长。建议增加统一的过期清理机制,或者在写入/读取时批量回收过期项。
copilotDeviceMemoryStore.Lock()
defer copilotDeviceMemoryStore.Unlock()
session, found := copilotDeviceMemoryStore.sessions[flowID]
if found && session.ExpiresAt <= time.Now().Unix() {
	delete(copilotDeviceMemoryStore.sessions, flowID)
	delete(copilotDeviceMemoryStore.claims, flowID)
	found = false
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants