History

Merge branch 'main' into feature/kiro-integration

Luis Pater committed 11 months ago

Commit f77c22e2 parents df83ba8 92f033d20 files · +1522 −198Browse files

Unified · Split

20 files
  1. .github/workflows/docker-image.yml +1 −1
  2. .github/workflows/release.yaml +2 −1
  3. .goreleaser.yml +4 −4
  4. Dockerfile +3 −3
  5. README.md +9 −89
  6. README_CN.md +9 −95
  7. cmd/server/main.go +5 −0
  8. docker-compose.yml +2 −2
  9. internal/api/handlers/management/config_basic.go +2 −2
  10. internal/auth/copilot/copilot_auth.go +225 −0
  11. internal/auth/copilot/errors.go +187 −0
  12. internal/auth/copilot/oauth.go +255 −0
  13. internal/auth/copilot/token.go +93 −0
  14. internal/cmd/auth_manager.go +2 −1
  15. internal/cmd/github_copilot_login.go +44 −0
  16. internal/registry/model_definitions.go +184 −0
  17. internal/runtime/executor/github_copilot_executor.go +361 −0
  18. sdk/auth/github_copilot.go +129 −0
  19. sdk/auth/refresh_registry.go +1 −0
  20. sdk/cliproxy/service.go +4 −0
.github/workflows/docker-image.yml+1−1
@@ -7,7 +7,7 @@ on: · 6 unmodified lines
77
88 env:
99 APP_NAME: CLIProxyAPI
10− DOCKERHUB_REPO: eceasy/cli-proxy-api
10+ DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
1111
1212 jobs:
1313 docker:
.github/workflows/release.yaml+2−1
@@ -23,7 +23,8 @@ jobs: · 22 unmodified lines
2323 cache: true
2424 - name: Generate Build Metadata
2525 run: |
26− echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
26+ VERSION=$(git describe --tags --always --dirty)
27+ echo "VERSION=${VERSION}" >> $GITHUB_ENV
2728 echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
2829 echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
2930 - uses: goreleaser/goreleaser-action@v4
.goreleaser.yml+4−4
@@ -1,5 +1,5 @@
11 builds:
2− - id: "cli-proxy-api"
2+ - id: "cli-proxy-api-plus"
33 env:
44 - CGO_ENABLED=0
55 goos:
@@ -10,11 +10,11 @@ builds: · 4 unmodified lines
1010 - amd64
1111 - arm64
1212 main: ./cmd/server/
13− binary: cli-proxy-api
13+ binary: cli-proxy-api-plus
1414 ldflags:
15− - -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
15+ - -s -w -X 'main.Version={{.Version}}-plus' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
1616 archives:
17− - id: "cli-proxy-api"
17+ - id: "cli-proxy-api-plus"
1818 format: tar.gz
1919 format_overrides:
2020 - goos: windows
Dockerfile+3−3
@@ -12,7 +12,7 @@ ARG VERSION=dev · 11 unmodified lines
1212 ARG COMMIT=none
1313 ARG BUILD_DATE=unknown
1414
15−RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
15+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}-plus' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPIPlus ./cmd/server/
1616
1717 FROM alpine:3.22.0
1818
@@ -20,7 +20,7 @@ RUN apk add --no-cache tzdata · 1 unmodified line
2020
2121 RUN mkdir /CLIProxyAPI
2222
23−COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
23+COPY --from=builder ./app/CLIProxyAPIPlus /CLIProxyAPI/CLIProxyAPIPlus
2424
2525 COPY config.example.yaml /CLIProxyAPI/config.example.yaml
2626
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai · 5 unmodified lines
3232
3333 RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
3434
35−CMD ["./CLIProxyAPI"]
35+CMD ["./CLIProxyAPIPlus"]
README.md+9−89
@@ -1,102 +1,22 @@
1−# CLI Proxy API
1+# CLIProxyAPI Plus
22
3−English | [中文](README_CN.md)
3+English | [Chinese](README_CN.md)
44
5−A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
5+This is the Plus version of [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI), adding support for third-party providers on top of the mainline project.
66
7−It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
7+All third-party provider support is maintained by community contributors; CLIProxyAPI does not provide technical support. Please contact the corresponding community maintainer if you need assistance.
88
9−So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
9+The Plus release stays in lockstep with the mainline features.
1010
11−## Sponsor
11+## Differences from the Mainline
1212
13−[![z.ai](https://assets.router-for.me/english.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)
14−
15−This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
16−
17−GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
18−
19−Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
20−
21−## Overview
22−
23−- OpenAI/Gemini/Claude compatible API endpoints for CLI models
24−- OpenAI Codex support (GPT models) via OAuth login
25−- Claude Code support via OAuth login
26−- Qwen Code support via OAuth login
27−- iFlow support via OAuth login
28−- Amp CLI and IDE extensions support with provider routing
29−- Streaming and non-streaming responses
30−- Function calling/tools support
31−- Multimodal input support (text and images)
32−- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
33−- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
34−- Generative Language API Key support
35−- AI Studio Build multi-account load balancing
36−- Gemini CLI multi-account load balancing
37−- Claude Code multi-account load balancing
38−- Qwen Code multi-account load balancing
39−- iFlow multi-account load balancing
40−- OpenAI Codex multi-account load balancing
41−- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
42−- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
43−
44−## Getting Started
45−
46−CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
47−
48−## Management API
49−
50−see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
51−
52−## Amp CLI Support
53−
54−CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
55−
56−- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
57−- Management proxy for OAuth authentication and account features
58−- Smart model fallback with automatic routing
59−- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
60−- Security-first design with localhost-only management endpoints
61−
62−**→ [Complete Amp CLI Integration Guide](docs/amp-cli-integration.md)**
63−
64−## SDK Docs
65−
66−- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
67−- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
68−- Access: [docs/sdk-access.md](docs/sdk-access.md)
69−- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
70−- Custom Provider Example: `examples/custom-provider`
13+- Added GitHub Copilot support (OAuth login), provided by [em4gp](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)
7114
7215 ## Contributing
7316
74−Contributions are welcome! Please feel free to submit a Pull Request.
75−
76−1. Fork the repository
77−2. Create your feature branch (`git checkout -b feature/amazing-feature`)
78−3. Commit your changes (`git commit -m 'Add some amazing feature'`)
79−4. Push to the branch (`git push origin feature/amazing-feature`)
80−5. Open a Pull Request
81−
82−## Who is with us?
83−
84−Those projects are based on CLIProxyAPI:
85−
86−### [vibeproxy](https://github.com/automazeio/vibeproxy)
87−
88−Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
89−
90−### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
91−
92−Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
93−
94−### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
95−
96−CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
17+This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
9718
98−> [!NOTE]
99−> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
19+If you need to submit any non-third-party provider changes, please open them against the mainline repository.
10020
10121 ## License
10222
README_CN.md+9−95
@@ -1,109 +1,23 @@
1−# CLI 代理 API
1+# CLIProxyAPI Plus
22
33 [English](README.md) | 中文
44
5−一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
5+这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。
66
7−现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
7+所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
88
9−您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
9+该 Plus 版本的主线功能与主线功能强制同步。
1010
11−## 赞助商
11+## 与主线版本版本差异
1212
13−[![bigmodel.cn](https://assets.router-for.me/chinese.png)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
14−
15−本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
16−
17−GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.6,为开发者提供顶尖的编码体验。
18−
19−智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
20−
21−## 功能特性
22−
23−- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
24−- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
25−- 新增 Claude Code 支持(OAuth 登录)
26−- 新增 Qwen Code 支持(OAuth 登录)
27−- 新增 iFlow 支持(OAuth 登录)
28−- 支持流式与非流式响应
29−- 函数调用/工具支持
30−- 多模态输入(文本、图片)
31−- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
32−- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
33−- 支持 Gemini AIStudio API 密钥
34−- 支持 AI Studio Build 多账户轮询
35−- 支持 Gemini CLI 多账户轮询
36−- 支持 Claude Code 多账户轮询
37−- 支持 Qwen Code 多账户轮询
38−- 支持 iFlow 多账户轮询
39−- 支持 OpenAI Codex 多账户轮询
40−- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
41−- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
42−
43−## 新手入门
44−
45−CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
46−
47−## 管理 API 文档
48−
49−请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
50−
51−## Amp CLI 支持
52−
53−CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
54−
55−- 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
56−- 管理代理,处理 OAuth 认证和账号功能
57−- 智能模型回退与自动路由
58−- 以安全为先的设计,管理端点仅限 localhost
59−
60−**→ [Amp CLI 完整集成指南](docs/amp-cli-integration_CN.md)**
61−
62−## SDK 文档
63−
64−- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
65−- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
66−- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
67−- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
68−- 自定义 Provider 示例:`examples/custom-provider`
13+- 新增 GitHub Copilot 支持(OAuth 登录),由[em4gp](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
6914
7015 ## 贡献
7116
72−欢迎贡献!请随时提交 Pull Request。
73−
74−1. Fork 仓库
75−2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
76−3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
77−4. 推送到分支(`git push origin feature/amazing-feature`)
78−5. 打开 Pull Request
79−
80−## 谁与我们在一起?
81−
82−这些项目基于 CLIProxyAPI:
83−
84−### [vibeproxy](https://github.com/automazeio/vibeproxy)
85−
86−一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
87−
88−### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
17+该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
8918
90−一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
91−
92−### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
93−
94−CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
95−
96−> [!NOTE]
97−> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
19+如果需要提交任何非第三方供应商支持的 Pull Request,请提交到主线版本。
9820
9921 ## 许可证
10022
101−此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
102−
103−## 写给所有中国网友的
104−
105−QQ 群:188637136
106−
107−或
108−
109−Telegram 群:https://t.me/CLIProxyAPI
23+此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
cmd/server/main.go+5−0
@@ -79,6 +79,7 @@ func main() { · 78 unmodified lines
7979 var kiroGoogleLogin bool
8080 var kiroAWSLogin bool
8181 var kiroImport bool
82+ var githubCopilotLogin bool
8283 var projectID string
8384 var vertexImport string
8485 var configPath string
@@ -101,6 +102,7 @@ func main() { · 16 unmodified lines
101102 flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
102103 flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
103104 flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)")
105+ flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
104106 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
105107 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
106108 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -461,6 +463,9 @@ func main() { · 354 unmodified lines
461463 } else if antigravityLogin {
462464 // Handle Antigravity login
463465 cmd.DoAntigravityLogin(cfg, options)
466+ } else if githubCopilotLogin {
467+ // Handle GitHub Copilot login
468+ cmd.DoGitHubCopilotLogin(cfg, options)
464469 } else if codexLogin {
465470 // Handle Codex login
466471 cmd.DoCodexLogin(cfg, options)
docker-compose.yml+2−2
@@ -1,6 +1,6 @@
11 services:
22 cli-proxy-api:
3− image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
3+ image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api-plus:latest}
44 pull_policy: always
55 build:
66 context: .
@@ -9,7 +9,7 @@ services: · 2 unmodified lines
99 VERSION: ${VERSION:-dev}
1010 COMMIT: ${COMMIT:-none}
1111 BUILD_DATE: ${BUILD_DATE:-unknown}
12− container_name: cli-proxy-api
12+ container_name: cli-proxy-api-plus
1313 # env_file:
1414 # - .env
1515 environment:
internal/api/handlers/management/config_basic.go+2−2
@@ -19,8 +19,8 @@ import ( · 18 unmodified lines
1919 )
2020
2121 const (
22− latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
23− latestReleaseUserAgent = "CLIProxyAPI"
22+ latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest"
23+ latestReleaseUserAgent = "CLIProxyAPIPlus"
2424 )
2525
2626 func (h *Handler) GetConfig(c *gin.Context) {
internal/auth/copilot/copilot_auth.go+225−0
@@ -0,0 +1,225 @@
1+// Package copilot provides authentication and token management for GitHub Copilot API.
2+// It handles the OAuth2 device flow for secure authentication with the Copilot API.
3+package copilot
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "time"
12+
13+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15+ log "github.com/sirupsen/logrus"
16+)
17+
18+const (
19+ // copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token.
20+ copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token"
21+ // copilotAPIEndpoint is the base URL for making API requests.
22+ copilotAPIEndpoint = "https://api.githubcopilot.com"
23+
24+ // Common HTTP header values for Copilot API requests.
25+ copilotUserAgent = "GithubCopilot/1.0"
26+ copilotEditorVersion = "vscode/1.100.0"
27+ copilotPluginVersion = "copilot/1.300.0"
28+ copilotIntegrationID = "vscode-chat"
29+ copilotOpenAIIntent = "conversation-panel"
30+)
31+
32+// CopilotAPIToken represents the Copilot API token response.
33+type CopilotAPIToken struct {
34+ // Token is the JWT token for authenticating with the Copilot API.
35+ Token string `json:"token"`
36+ // ExpiresAt is the Unix timestamp when the token expires.
37+ ExpiresAt int64 `json:"expires_at"`
38+ // Endpoints contains the available API endpoints.
39+ Endpoints struct {
40+ API string `json:"api"`
41+ Proxy string `json:"proxy"`
42+ OriginTracker string `json:"origin-tracker"`
43+ Telemetry string `json:"telemetry"`
44+ } `json:"endpoints,omitempty"`
45+ // ErrorDetails contains error information if the request failed.
46+ ErrorDetails *struct {
47+ URL string `json:"url"`
48+ Message string `json:"message"`
49+ DocumentationURL string `json:"documentation_url"`
50+ } `json:"error_details,omitempty"`
51+}
52+
53+// CopilotAuth handles GitHub Copilot authentication flow.
54+// It provides methods for device flow authentication and token management.
55+type CopilotAuth struct {
56+ httpClient *http.Client
57+ deviceClient *DeviceFlowClient
58+ cfg *config.Config
59+}
60+
61+// NewCopilotAuth creates a new CopilotAuth service instance.
62+// It initializes an HTTP client with proxy settings from the provided configuration.
63+func NewCopilotAuth(cfg *config.Config) *CopilotAuth {
64+ return &CopilotAuth{
65+ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}),
66+ deviceClient: NewDeviceFlowClient(cfg),
67+ cfg: cfg,
68+ }
69+}
70+
71+// StartDeviceFlow initiates the device flow authentication.
72+// Returns the device code response containing the user code and verification URI.
73+func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
74+ return c.deviceClient.RequestDeviceCode(ctx)
75+}
76+
77+// WaitForAuthorization polls for user authorization and returns the auth bundle.
78+func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) {
79+ tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode)
80+ if err != nil {
81+ return nil, err
82+ }
83+
84+ // Fetch the GitHub username
85+ username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken)
86+ if err != nil {
87+ log.Warnf("copilot: failed to fetch user info: %v", err)
88+ username = "unknown"
89+ }
90+
91+ return &CopilotAuthBundle{
92+ TokenData: tokenData,
93+ Username: username,
94+ }, nil
95+}
96+
97+// GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token.
98+// This token is used to make authenticated requests to the Copilot API.
99+func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) {
100+ if githubAccessToken == "" {
101+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty"))
102+ }
103+
104+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil)
105+ if err != nil {
106+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
107+ }
108+
109+ req.Header.Set("Authorization", "token "+githubAccessToken)
110+ req.Header.Set("Accept", "application/json")
111+ req.Header.Set("User-Agent", copilotUserAgent)
112+ req.Header.Set("Editor-Version", copilotEditorVersion)
113+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
114+
115+ resp, err := c.httpClient.Do(req)
116+ if err != nil {
117+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
118+ }
119+ defer func() {
120+ if errClose := resp.Body.Close(); errClose != nil {
121+ log.Errorf("copilot api token: close body error: %v", errClose)
122+ }
123+ }()
124+
125+ bodyBytes, err := io.ReadAll(resp.Body)
126+ if err != nil {
127+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
128+ }
129+
130+ if !isHTTPSuccess(resp.StatusCode) {
131+ return nil, NewAuthenticationError(ErrTokenExchangeFailed,
132+ fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
133+ }
134+
135+ var apiToken CopilotAPIToken
136+ if err = json.Unmarshal(bodyBytes, &apiToken); err != nil {
137+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
138+ }
139+
140+ if apiToken.Token == "" {
141+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token"))
142+ }
143+
144+ return &apiToken, nil
145+}
146+
147+// ValidateToken checks if a GitHub access token is valid by attempting to fetch user info.
148+func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) {
149+ if accessToken == "" {
150+ return false, "", nil
151+ }
152+
153+ username, err := c.deviceClient.FetchUserInfo(ctx, accessToken)
154+ if err != nil {
155+ return false, "", err
156+ }
157+
158+ return true, username, nil
159+}
160+
161+// CreateTokenStorage creates a new CopilotTokenStorage from auth bundle.
162+func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage {
163+ return &CopilotTokenStorage{
164+ AccessToken: bundle.TokenData.AccessToken,
165+ TokenType: bundle.TokenData.TokenType,
166+ Scope: bundle.TokenData.Scope,
167+ Username: bundle.Username,
168+ Type: "github-copilot",
169+ }
170+}
171+
172+// LoadAndValidateToken loads a token from storage and validates it.
173+// Returns the storage if valid, or an error if the token is invalid or expired.
174+func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) {
175+ if storage == nil || storage.AccessToken == "" {
176+ return false, fmt.Errorf("no token available")
177+ }
178+
179+ // Check if we can still use the GitHub token to get a Copilot API token
180+ apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken)
181+ if err != nil {
182+ return false, err
183+ }
184+
185+ // Check if the API token is expired
186+ if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt {
187+ return false, fmt.Errorf("copilot api token expired")
188+ }
189+
190+ return true, nil
191+}
192+
193+// GetAPIEndpoint returns the Copilot API endpoint URL.
194+func (c *CopilotAuth) GetAPIEndpoint() string {
195+ return copilotAPIEndpoint
196+}
197+
198+// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
199+func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) {
200+ req, err := http.NewRequestWithContext(ctx, method, url, body)
201+ if err != nil {
202+ return nil, fmt.Errorf("failed to create request: %w", err)
203+ }
204+
205+ req.Header.Set("Authorization", "Bearer "+apiToken.Token)
206+ req.Header.Set("Content-Type", "application/json")
207+ req.Header.Set("Accept", "application/json")
208+ req.Header.Set("User-Agent", copilotUserAgent)
209+ req.Header.Set("Editor-Version", copilotEditorVersion)
210+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
211+ req.Header.Set("Openai-Intent", copilotOpenAIIntent)
212+ req.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
213+
214+ return req, nil
215+}
216+
217+// buildChatCompletionURL builds the URL for chat completions API.
218+func buildChatCompletionURL() string {
219+ return copilotAPIEndpoint + "/chat/completions"
220+}
221+
222+// isHTTPSuccess checks if the status code indicates success (2xx).
223+func isHTTPSuccess(statusCode int) bool {
224+ return statusCode >= 200 && statusCode < 300
225+}
internal/auth/copilot/errors.go+187−0
@@ -0,0 +1,187 @@
1+package copilot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+)
8+
9+// OAuthError represents an OAuth-specific error.
10+type OAuthError struct {
11+ // Code is the OAuth error code.
12+ Code string `json:"error"`
13+ // Description is a human-readable description of the error.
14+ Description string `json:"error_description,omitempty"`
15+ // URI is a URI identifying a human-readable web page with information about the error.
16+ URI string `json:"error_uri,omitempty"`
17+ // StatusCode is the HTTP status code associated with the error.
18+ StatusCode int `json:"-"`
19+}
20+
21+// Error returns a string representation of the OAuth error.
22+func (e *OAuthError) Error() string {
23+ if e.Description != "" {
24+ return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
25+ }
26+ return fmt.Sprintf("OAuth error: %s", e.Code)
27+}
28+
29+// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
30+func NewOAuthError(code, description string, statusCode int) *OAuthError {
31+ return &OAuthError{
32+ Code: code,
33+ Description: description,
34+ StatusCode: statusCode,
35+ }
36+}
37+
38+// AuthenticationError represents authentication-related errors.
39+type AuthenticationError struct {
40+ // Type is the type of authentication error.
41+ Type string `json:"type"`
42+ // Message is a human-readable message describing the error.
43+ Message string `json:"message"`
44+ // Code is the HTTP status code associated with the error.
45+ Code int `json:"code"`
46+ // Cause is the underlying error that caused this authentication error.
47+ Cause error `json:"-"`
48+}
49+
50+// Error returns a string representation of the authentication error.
51+func (e *AuthenticationError) Error() string {
52+ if e.Cause != nil {
53+ return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
54+ }
55+ return fmt.Sprintf("%s: %s", e.Type, e.Message)
56+}
57+
58+// Unwrap returns the underlying cause of the error.
59+func (e *AuthenticationError) Unwrap() error {
60+ return e.Cause
61+}
62+
63+// Common authentication error types for GitHub Copilot device flow.
64+var (
65+ // ErrDeviceCodeFailed represents an error when requesting the device code fails.
66+ ErrDeviceCodeFailed = &AuthenticationError{
67+ Type: "device_code_failed",
68+ Message: "Failed to request device code from GitHub",
69+ Code: http.StatusBadRequest,
70+ }
71+
72+ // ErrDeviceCodeExpired represents an error when the device code has expired.
73+ ErrDeviceCodeExpired = &AuthenticationError{
74+ Type: "device_code_expired",
75+ Message: "Device code has expired. Please try again.",
76+ Code: http.StatusGone,
77+ }
78+
79+ // ErrAuthorizationPending represents a pending authorization state (not an error, used for polling).
80+ ErrAuthorizationPending = &AuthenticationError{
81+ Type: "authorization_pending",
82+ Message: "Authorization is pending. Waiting for user to authorize.",
83+ Code: http.StatusAccepted,
84+ }
85+
86+ // ErrSlowDown represents a request to slow down polling.
87+ ErrSlowDown = &AuthenticationError{
88+ Type: "slow_down",
89+ Message: "Polling too frequently. Slowing down.",
90+ Code: http.StatusTooManyRequests,
91+ }
92+
93+ // ErrAccessDenied represents an error when the user denies authorization.
94+ ErrAccessDenied = &AuthenticationError{
95+ Type: "access_denied",
96+ Message: "User denied authorization",
97+ Code: http.StatusForbidden,
98+ }
99+
100+ // ErrTokenExchangeFailed represents an error when token exchange fails.
101+ ErrTokenExchangeFailed = &AuthenticationError{
102+ Type: "token_exchange_failed",
103+ Message: "Failed to exchange device code for access token",
104+ Code: http.StatusBadRequest,
105+ }
106+
107+ // ErrPollingTimeout represents an error when polling times out.
108+ ErrPollingTimeout = &AuthenticationError{
109+ Type: "polling_timeout",
110+ Message: "Timeout waiting for user authorization",
111+ Code: http.StatusRequestTimeout,
112+ }
113+
114+ // ErrUserInfoFailed represents an error when fetching user info fails.
115+ ErrUserInfoFailed = &AuthenticationError{
116+ Type: "user_info_failed",
117+ Message: "Failed to fetch GitHub user information",
118+ Code: http.StatusBadRequest,
119+ }
120+)
121+
122+// NewAuthenticationError creates a new authentication error with a cause based on a base error.
123+func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
124+ return &AuthenticationError{
125+ Type: baseErr.Type,
126+ Message: baseErr.Message,
127+ Code: baseErr.Code,
128+ Cause: cause,
129+ }
130+}
131+
132+// IsAuthenticationError checks if an error is an authentication error.
133+func IsAuthenticationError(err error) bool {
134+ var authenticationError *AuthenticationError
135+ ok := errors.As(err, &authenticationError)
136+ return ok
137+}
138+
139+// IsOAuthError checks if an error is an OAuth error.
140+func IsOAuthError(err error) bool {
141+ var oAuthError *OAuthError
142+ ok := errors.As(err, &oAuthError)
143+ return ok
144+}
145+
146+// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
147+func GetUserFriendlyMessage(err error) string {
148+ var authErr *AuthenticationError
149+ if errors.As(err, &authErr) {
150+ switch authErr.Type {
151+ case "device_code_failed":
152+ return "Failed to start GitHub authentication. Please check your network connection and try again."
153+ case "device_code_expired":
154+ return "The authentication code has expired. Please try again."
155+ case "authorization_pending":
156+ return "Waiting for you to authorize the application on GitHub."
157+ case "slow_down":
158+ return "Please wait a moment before trying again."
159+ case "access_denied":
160+ return "Authentication was cancelled or denied."
161+ case "token_exchange_failed":
162+ return "Failed to complete authentication. Please try again."
163+ case "polling_timeout":
164+ return "Authentication timed out. Please try again."
165+ case "user_info_failed":
166+ return "Failed to get your GitHub account information. Please try again."
167+ default:
168+ return "Authentication failed. Please try again."
169+ }
170+ }
171+
172+ var oauthErr *OAuthError
173+ if errors.As(err, &oauthErr) {
174+ switch oauthErr.Code {
175+ case "access_denied":
176+ return "Authentication was cancelled or denied."
177+ case "invalid_request":
178+ return "Invalid authentication request. Please try again."
179+ case "server_error":
180+ return "GitHub server error. Please try again later."
181+ default:
182+ return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
183+ }
184+ }
185+
186+ return "An unexpected error occurred. Please try again."
187+}
internal/auth/copilot/oauth.go+255−0
@@ -0,0 +1,255 @@
1+package copilot
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+
14+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
16+ log "github.com/sirupsen/logrus"
17+)
18+
19+const (
20+ // copilotClientID is GitHub's Copilot CLI OAuth client ID.
21+ copilotClientID = "Iv1.b507a08c87ecfe98"
22+ // copilotDeviceCodeURL is the endpoint for requesting device codes.
23+ copilotDeviceCodeURL = "https://github.com/login/device/code"
24+ // copilotTokenURL is the endpoint for exchanging device codes for tokens.
25+ copilotTokenURL = "https://github.com/login/oauth/access_token"
26+ // copilotUserInfoURL is the endpoint for fetching GitHub user information.
27+ copilotUserInfoURL = "https://api.github.com/user"
28+ // defaultPollInterval is the default interval for polling token endpoint.
29+ defaultPollInterval = 5 * time.Second
30+ // maxPollDuration is the maximum time to wait for user authorization.
31+ maxPollDuration = 15 * time.Minute
32+)
33+
34+// DeviceFlowClient handles the OAuth2 device flow for GitHub Copilot.
35+type DeviceFlowClient struct {
36+ httpClient *http.Client
37+ cfg *config.Config
38+}
39+
40+// NewDeviceFlowClient creates a new device flow client.
41+func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
42+ client := &http.Client{Timeout: 30 * time.Second}
43+ if cfg != nil {
44+ client = util.SetProxy(&cfg.SDKConfig, client)
45+ }
46+ return &DeviceFlowClient{
47+ httpClient: client,
48+ cfg: cfg,
49+ }
50+}
51+
52+// RequestDeviceCode initiates the device flow by requesting a device code from GitHub.
53+func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
54+ data := url.Values{}
55+ data.Set("client_id", copilotClientID)
56+ data.Set("scope", "user:email")
57+
58+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotDeviceCodeURL, strings.NewReader(data.Encode()))
59+ if err != nil {
60+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
61+ }
62+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
63+ req.Header.Set("Accept", "application/json")
64+
65+ resp, err := c.httpClient.Do(req)
66+ if err != nil {
67+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
68+ }
69+ defer func() {
70+ if errClose := resp.Body.Close(); errClose != nil {
71+ log.Errorf("copilot device code: close body error: %v", errClose)
72+ }
73+ }()
74+
75+ if !isHTTPSuccess(resp.StatusCode) {
76+ bodyBytes, _ := io.ReadAll(resp.Body)
77+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
78+ }
79+
80+ var deviceCode DeviceCodeResponse
81+ if err = json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
82+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
83+ }
84+
85+ return &deviceCode, nil
86+}
87+
88+// PollForToken polls the token endpoint until the user authorizes or the device code expires.
89+func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotTokenData, error) {
90+ if deviceCode == nil {
91+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("device code is nil"))
92+ }
93+
94+ interval := time.Duration(deviceCode.Interval) * time.Second
95+ if interval < defaultPollInterval {
96+ interval = defaultPollInterval
97+ }
98+
99+ deadline := time.Now().Add(maxPollDuration)
100+ if deviceCode.ExpiresIn > 0 {
101+ codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
102+ if codeDeadline.Before(deadline) {
103+ deadline = codeDeadline
104+ }
105+ }
106+
107+ ticker := time.NewTicker(interval)
108+ defer ticker.Stop()
109+
110+ for {
111+ select {
112+ case <-ctx.Done():
113+ return nil, NewAuthenticationError(ErrPollingTimeout, ctx.Err())
114+ case <-ticker.C:
115+ if time.Now().After(deadline) {
116+ return nil, ErrPollingTimeout
117+ }
118+
119+ token, err := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
120+ if err != nil {
121+ var authErr *AuthenticationError
122+ if errors.As(err, &authErr) {
123+ switch authErr.Type {
124+ case ErrAuthorizationPending.Type:
125+ // Continue polling
126+ continue
127+ case ErrSlowDown.Type:
128+ // Increase interval and continue
129+ interval += 5 * time.Second
130+ ticker.Reset(interval)
131+ continue
132+ case ErrDeviceCodeExpired.Type:
133+ return nil, err
134+ case ErrAccessDenied.Type:
135+ return nil, err
136+ }
137+ }
138+ return nil, err
139+ }
140+ return token, nil
141+ }
142+ }
143+}
144+
145+// exchangeDeviceCode attempts to exchange the device code for an access token.
146+func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*CopilotTokenData, error) {
147+ data := url.Values{}
148+ data.Set("client_id", copilotClientID)
149+ data.Set("device_code", deviceCode)
150+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
151+
152+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotTokenURL, strings.NewReader(data.Encode()))
153+ if err != nil {
154+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
155+ }
156+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
157+ req.Header.Set("Accept", "application/json")
158+
159+ resp, err := c.httpClient.Do(req)
160+ if err != nil {
161+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
162+ }
163+ defer func() {
164+ if errClose := resp.Body.Close(); errClose != nil {
165+ log.Errorf("copilot token exchange: close body error: %v", errClose)
166+ }
167+ }()
168+
169+ bodyBytes, err := io.ReadAll(resp.Body)
170+ if err != nil {
171+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
172+ }
173+
174+ // GitHub returns 200 for both success and error cases in device flow
175+ // Check for OAuth error response first
176+ var oauthResp struct {
177+ Error string `json:"error"`
178+ ErrorDescription string `json:"error_description"`
179+ AccessToken string `json:"access_token"`
180+ TokenType string `json:"token_type"`
181+ Scope string `json:"scope"`
182+ }
183+
184+ if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
185+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
186+ }
187+
188+ if oauthResp.Error != "" {
189+ switch oauthResp.Error {
190+ case "authorization_pending":
191+ return nil, ErrAuthorizationPending
192+ case "slow_down":
193+ return nil, ErrSlowDown
194+ case "expired_token":
195+ return nil, ErrDeviceCodeExpired
196+ case "access_denied":
197+ return nil, ErrAccessDenied
198+ default:
199+ return nil, NewOAuthError(oauthResp.Error, oauthResp.ErrorDescription, resp.StatusCode)
200+ }
201+ }
202+
203+ if oauthResp.AccessToken == "" {
204+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty access token"))
205+ }
206+
207+ return &CopilotTokenData{
208+ AccessToken: oauthResp.AccessToken,
209+ TokenType: oauthResp.TokenType,
210+ Scope: oauthResp.Scope,
211+ }, nil
212+}
213+
214+// FetchUserInfo retrieves the GitHub username for the authenticated user.
215+func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (string, error) {
216+ if accessToken == "" {
217+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty"))
218+ }
219+
220+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUserInfoURL, nil)
221+ if err != nil {
222+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
223+ }
224+ req.Header.Set("Authorization", "Bearer "+accessToken)
225+ req.Header.Set("Accept", "application/json")
226+ req.Header.Set("User-Agent", "CLIProxyAPI")
227+
228+ resp, err := c.httpClient.Do(req)
229+ if err != nil {
230+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
231+ }
232+ defer func() {
233+ if errClose := resp.Body.Close(); errClose != nil {
234+ log.Errorf("copilot user info: close body error: %v", errClose)
235+ }
236+ }()
237+
238+ if !isHTTPSuccess(resp.StatusCode) {
239+ bodyBytes, _ := io.ReadAll(resp.Body)
240+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
241+ }
242+
243+ var userInfo struct {
244+ Login string `json:"login"`
245+ }
246+ if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
247+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
248+ }
249+
250+ if userInfo.Login == "" {
251+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username"))
252+ }
253+
254+ return userInfo.Login, nil
255+}
internal/auth/copilot/token.go+93−0
@@ -0,0 +1,93 @@
1+// Package copilot provides authentication and token management functionality
2+// for GitHub Copilot AI services. It handles OAuth2 device flow token storage,
3+// serialization, and retrieval for maintaining authenticated sessions with the Copilot API.
4+package copilot
5+
6+import (
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+
12+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
13+)
14+
15+// CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication.
16+// It maintains compatibility with the existing auth system while adding Copilot-specific fields
17+// for managing access tokens and user account information.
18+type CopilotTokenStorage struct {
19+ // AccessToken is the OAuth2 access token used for authenticating API requests.
20+ AccessToken string `json:"access_token"`
21+ // TokenType is the type of token, typically "bearer".
22+ TokenType string `json:"token_type"`
23+ // Scope is the OAuth2 scope granted to the token.
24+ Scope string `json:"scope"`
25+ // ExpiresAt is the timestamp when the access token expires (if provided).
26+ ExpiresAt string `json:"expires_at,omitempty"`
27+ // Username is the GitHub username associated with this token.
28+ Username string `json:"username"`
29+ // Type indicates the authentication provider type, always "github-copilot" for this storage.
30+ Type string `json:"type"`
31+}
32+
33+// CopilotTokenData holds the raw OAuth token response from GitHub.
34+type CopilotTokenData struct {
35+ // AccessToken is the OAuth2 access token.
36+ AccessToken string `json:"access_token"`
37+ // TokenType is the type of token, typically "bearer".
38+ TokenType string `json:"token_type"`
39+ // Scope is the OAuth2 scope granted to the token.
40+ Scope string `json:"scope"`
41+}
42+
43+// CopilotAuthBundle bundles authentication data for storage.
44+type CopilotAuthBundle struct {
45+ // TokenData contains the OAuth token information.
46+ TokenData *CopilotTokenData
47+ // Username is the GitHub username.
48+ Username string
49+}
50+
51+// DeviceCodeResponse represents GitHub's device code response.
52+type DeviceCodeResponse struct {
53+ // DeviceCode is the device verification code.
54+ DeviceCode string `json:"device_code"`
55+ // UserCode is the code the user must enter at the verification URI.
56+ UserCode string `json:"user_code"`
57+ // VerificationURI is the URL where the user should enter the code.
58+ VerificationURI string `json:"verification_uri"`
59+ // ExpiresIn is the number of seconds until the device code expires.
60+ ExpiresIn int `json:"expires_in"`
61+ // Interval is the minimum number of seconds to wait between polling requests.
62+ Interval int `json:"interval"`
63+}
64+
65+// SaveTokenToFile serializes the Copilot token storage to a JSON file.
66+// This method creates the necessary directory structure and writes the token
67+// data in JSON format to the specified file path for persistent storage.
68+//
69+// Parameters:
70+// - authFilePath: The full path where the token file should be saved
71+//
72+// Returns:
73+// - error: An error if the operation fails, nil otherwise
74+func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error {
75+ misc.LogSavingCredentials(authFilePath)
76+ ts.Type = "github-copilot"
77+ if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
78+ return fmt.Errorf("failed to create directory: %v", err)
79+ }
80+
81+ f, err := os.Create(authFilePath)
82+ if err != nil {
83+ return fmt.Errorf("failed to create token file: %w", err)
84+ }
85+ defer func() {
86+ _ = f.Close()
87+ }()
88+
89+ if err = json.NewEncoder(f).Encode(ts); err != nil {
90+ return fmt.Errorf("failed to write token to file: %w", err)
91+ }
92+ return nil
93+}
internal/cmd/auth_manager.go+2−1
@@ -6,7 +6,7 @@ import ( · 5 unmodified lines
66
77 // newAuthManager creates a new authentication manager instance with all supported
88 // authenticators and a file-based token store. It initializes authenticators for
9−// Gemini, Codex, Claude, and Qwen providers.
9+// Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers.
1010 //
1111 // Returns:
1212 // - *sdkAuth.Manager: A configured authentication manager instance
@@ -20,6 +20,7 @@ func newAuthManager() *sdkAuth.Manager { · 7 unmodified lines
2020 sdkAuth.NewIFlowAuthenticator(),
2121 sdkAuth.NewAntigravityAuthenticator(),
2222 sdkAuth.NewKiroAuthenticator(),
23+ sdkAuth.NewGitHubCopilotAuthenticator(),
2324 )
2425 return manager
2526 }
internal/cmd/github_copilot_login.go+44−0
@@ -0,0 +1,44 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
8+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
9+ log "github.com/sirupsen/logrus"
10+)
11+
12+// DoGitHubCopilotLogin triggers the OAuth device flow for GitHub Copilot and saves tokens.
13+// It initiates the device flow authentication, displays the user code for the user to enter
14+// at GitHub's verification URL, and waits for authorization before saving the tokens.
15+//
16+// Parameters:
17+// - cfg: The application configuration containing proxy and auth directory settings
18+// - options: Login options including browser behavior settings
19+func DoGitHubCopilotLogin(cfg *config.Config, options *LoginOptions) {
20+ if options == nil {
21+ options = &LoginOptions{}
22+ }
23+
24+ manager := newAuthManager()
25+ authOpts := &sdkAuth.LoginOptions{
26+ NoBrowser: options.NoBrowser,
27+ Metadata: map[string]string{},
28+ Prompt: options.Prompt,
29+ }
30+
31+ record, savedPath, err := manager.Login(context.Background(), "github-copilot", cfg, authOpts)
32+ if err != nil {
33+ log.Errorf("GitHub Copilot authentication failed: %v", err)
34+ return
35+ }
36+
37+ if savedPath != "" {
38+ fmt.Printf("Authentication saved to %s\n", savedPath)
39+ }
40+ if record != nil && record.Label != "" {
41+ fmt.Printf("Authenticated as %s\n", record.Label)
42+ }
43+ fmt.Println("GitHub Copilot authentication successful!")
44+}
internal/registry/model_definitions.go+184−0
@@ -987,6 +987,190 @@ func GetIFlowModels() []*ModelInfo { · 986 unmodified lines
987987 return models
988988 }
989989
990+// GetGitHubCopilotModels returns the available models for GitHub Copilot.
991+// These models are available through the GitHub Copilot API at api.githubcopilot.com.
992+func GetGitHubCopilotModels() []*ModelInfo {
993+ now := int64(1732752000) // 2024-11-27
994+ return []*ModelInfo{
995+ {
996+ ID: "gpt-4.1",
997+ Object: "model",
998+ Created: now,
999+ OwnedBy: "github-copilot",
1000+ Type: "github-copilot",
1001+ DisplayName: "GPT-4.1",
1002+ Description: "OpenAI GPT-4.1 via GitHub Copilot",
1003+ ContextLength: 128000,
1004+ MaxCompletionTokens: 16384,
1005+ },
1006+ {
1007+ ID: "gpt-5",
1008+ Object: "model",
1009+ Created: now,
1010+ OwnedBy: "github-copilot",
1011+ Type: "github-copilot",
1012+ DisplayName: "GPT-5",
1013+ Description: "OpenAI GPT-5 via GitHub Copilot",
1014+ ContextLength: 200000,
1015+ MaxCompletionTokens: 32768,
1016+ },
1017+ {
1018+ ID: "gpt-5-mini",
1019+ Object: "model",
1020+ Created: now,
1021+ OwnedBy: "github-copilot",
1022+ Type: "github-copilot",
1023+ DisplayName: "GPT-5 Mini",
1024+ Description: "OpenAI GPT-5 Mini via GitHub Copilot",
1025+ ContextLength: 128000,
1026+ MaxCompletionTokens: 16384,
1027+ },
1028+ {
1029+ ID: "gpt-5-codex",
1030+ Object: "model",
1031+ Created: now,
1032+ OwnedBy: "github-copilot",
1033+ Type: "github-copilot",
1034+ DisplayName: "GPT-5 Codex",
1035+ Description: "OpenAI GPT-5 Codex via GitHub Copilot",
1036+ ContextLength: 200000,
1037+ MaxCompletionTokens: 32768,
1038+ },
1039+ {
1040+ ID: "gpt-5.1",
1041+ Object: "model",
1042+ Created: now,
1043+ OwnedBy: "github-copilot",
1044+ Type: "github-copilot",
1045+ DisplayName: "GPT-5.1",
1046+ Description: "OpenAI GPT-5.1 via GitHub Copilot",
1047+ ContextLength: 200000,
1048+ MaxCompletionTokens: 32768,
1049+ },
1050+ {
1051+ ID: "gpt-5.1-codex",
1052+ Object: "model",
1053+ Created: now,
1054+ OwnedBy: "github-copilot",
1055+ Type: "github-copilot",
1056+ DisplayName: "GPT-5.1 Codex",
1057+ Description: "OpenAI GPT-5.1 Codex via GitHub Copilot",
1058+ ContextLength: 200000,
1059+ MaxCompletionTokens: 32768,
1060+ },
1061+ {
1062+ ID: "gpt-5.1-codex-mini",
1063+ Object: "model",
1064+ Created: now,
1065+ OwnedBy: "github-copilot",
1066+ Type: "github-copilot",
1067+ DisplayName: "GPT-5.1 Codex Mini",
1068+ Description: "OpenAI GPT-5.1 Codex Mini via GitHub Copilot",
1069+ ContextLength: 128000,
1070+ MaxCompletionTokens: 16384,
1071+ },
1072+ {
1073+ ID: "claude-haiku-4.5",
1074+ Object: "model",
1075+ Created: now,
1076+ OwnedBy: "github-copilot",
1077+ Type: "github-copilot",
1078+ DisplayName: "Claude Haiku 4.5",
1079+ Description: "Anthropic Claude Haiku 4.5 via GitHub Copilot",
1080+ ContextLength: 200000,
1081+ MaxCompletionTokens: 64000,
1082+ },
1083+ {
1084+ ID: "claude-opus-4.1",
1085+ Object: "model",
1086+ Created: now,
1087+ OwnedBy: "github-copilot",
1088+ Type: "github-copilot",
1089+ DisplayName: "Claude Opus 4.1",
1090+ Description: "Anthropic Claude Opus 4.1 via GitHub Copilot",
1091+ ContextLength: 200000,
1092+ MaxCompletionTokens: 32000,
1093+ },
1094+ {
1095+ ID: "claude-opus-4.5",
1096+ Object: "model",
1097+ Created: now,
1098+ OwnedBy: "github-copilot",
1099+ Type: "github-copilot",
1100+ DisplayName: "Claude Opus 4.5",
1101+ Description: "Anthropic Claude Opus 4.5 via GitHub Copilot",
1102+ ContextLength: 200000,
1103+ MaxCompletionTokens: 64000,
1104+ },
1105+ {
1106+ ID: "claude-sonnet-4",
1107+ Object: "model",
1108+ Created: now,
1109+ OwnedBy: "github-copilot",
1110+ Type: "github-copilot",
1111+ DisplayName: "Claude Sonnet 4",
1112+ Description: "Anthropic Claude Sonnet 4 via GitHub Copilot",
1113+ ContextLength: 200000,
1114+ MaxCompletionTokens: 64000,
1115+ },
1116+ {
1117+ ID: "claude-sonnet-4.5",
1118+ Object: "model",
1119+ Created: now,
1120+ OwnedBy: "github-copilot",
1121+ Type: "github-copilot",
1122+ DisplayName: "Claude Sonnet 4.5",
1123+ Description: "Anthropic Claude Sonnet 4.5 via GitHub Copilot",
1124+ ContextLength: 200000,
1125+ MaxCompletionTokens: 64000,
1126+ },
1127+ {
1128+ ID: "gemini-2.5-pro",
1129+ Object: "model",
1130+ Created: now,
1131+ OwnedBy: "github-copilot",
1132+ Type: "github-copilot",
1133+ DisplayName: "Gemini 2.5 Pro",
1134+ Description: "Google Gemini 2.5 Pro via GitHub Copilot",
1135+ ContextLength: 1048576,
1136+ MaxCompletionTokens: 65536,
1137+ },
1138+ {
1139+ ID: "gemini-3-pro",
1140+ Object: "model",
1141+ Created: now,
1142+ OwnedBy: "github-copilot",
1143+ Type: "github-copilot",
1144+ DisplayName: "Gemini 3 Pro",
1145+ Description: "Google Gemini 3 Pro via GitHub Copilot",
1146+ ContextLength: 1048576,
1147+ MaxCompletionTokens: 65536,
1148+ },
1149+ {
1150+ ID: "grok-code-fast-1",
1151+ Object: "model",
1152+ Created: now,
1153+ OwnedBy: "github-copilot",
1154+ Type: "github-copilot",
1155+ DisplayName: "Grok Code Fast 1",
1156+ Description: "xAI Grok Code Fast 1 via GitHub Copilot",
1157+ ContextLength: 128000,
1158+ MaxCompletionTokens: 16384,
1159+ },
1160+ {
1161+ ID: "raptor-mini",
1162+ Object: "model",
1163+ Created: now,
1164+ OwnedBy: "github-copilot",
1165+ Type: "github-copilot",
1166+ DisplayName: "Raptor Mini",
1167+ Description: "Raptor Mini via GitHub Copilot",
1168+ ContextLength: 128000,
1169+ MaxCompletionTokens: 16384,
1170+ },
1171+ }
1172+}
1173+
9901174 // GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
9911175 func GetKiroModels() []*ModelInfo {
9921176 return []*ModelInfo{
internal/runtime/executor/github_copilot_executor.go+361−0
@@ -0,0 +1,361 @@
1+package executor
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "sync"
11+ "time"
12+
13+ "github.com/google/uuid"
14+ copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
15+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
16+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
18+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
19+ log "github.com/sirupsen/logrus"
20+ "github.com/tidwall/sjson"
21+)
22+
23+const (
24+ githubCopilotBaseURL = "https://api.githubcopilot.com"
25+ githubCopilotChatPath = "/chat/completions"
26+ githubCopilotAuthType = "github-copilot"
27+ githubCopilotTokenCacheTTL = 25 * time.Minute
28+ // tokenExpiryBuffer is the time before expiry when we should refresh the token.
29+ tokenExpiryBuffer = 5 * time.Minute
30+ // maxScannerBufferSize is the maximum buffer size for SSE scanning (20MB).
31+ maxScannerBufferSize = 20_971_520
32+
33+ // Copilot API header values.
34+ copilotUserAgent = "GithubCopilot/1.0"
35+ copilotEditorVersion = "vscode/1.100.0"
36+ copilotPluginVersion = "copilot/1.300.0"
37+ copilotIntegrationID = "vscode-chat"
38+ copilotOpenAIIntent = "conversation-panel"
39+)
40+
41+// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
42+type GitHubCopilotExecutor struct {
43+ cfg *config.Config
44+ mu sync.RWMutex
45+ cache map[string]*cachedAPIToken
46+}
47+
48+// cachedAPIToken stores a cached Copilot API token with its expiry.
49+type cachedAPIToken struct {
50+ token string
51+ expiresAt time.Time
52+}
53+
54+// NewGitHubCopilotExecutor constructs a new executor instance.
55+func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
56+ return &GitHubCopilotExecutor{
57+ cfg: cfg,
58+ cache: make(map[string]*cachedAPIToken),
59+ }
60+}
61+
62+// Identifier implements ProviderExecutor.
63+func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
64+
65+// PrepareRequest implements ProviderExecutor.
66+func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
67+ return nil
68+}
69+
70+// Execute handles non-streaming requests to GitHub Copilot.
71+func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
72+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
73+ if errToken != nil {
74+ return resp, errToken
75+ }
76+
77+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
78+ defer reporter.trackFailure(ctx, &err)
79+
80+ from := opts.SourceFormat
81+ to := sdktranslator.FromString("openai")
82+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
83+ body = e.normalizeModel(req.Model, body)
84+ body = applyPayloadConfig(e.cfg, req.Model, body)
85+ body, _ = sjson.SetBytes(body, "stream", false)
86+
87+ url := githubCopilotBaseURL + githubCopilotChatPath
88+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
89+ if err != nil {
90+ return resp, err
91+ }
92+ e.applyHeaders(httpReq, apiToken)
93+
94+ var authID, authLabel, authType, authValue string
95+ if auth != nil {
96+ authID = auth.ID
97+ authLabel = auth.Label
98+ authType, authValue = auth.AccountInfo()
99+ }
100+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
101+ URL: url,
102+ Method: http.MethodPost,
103+ Headers: httpReq.Header.Clone(),
104+ Body: body,
105+ Provider: e.Identifier(),
106+ AuthID: authID,
107+ AuthLabel: authLabel,
108+ AuthType: authType,
109+ AuthValue: authValue,
110+ })
111+
112+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
113+ httpResp, err := httpClient.Do(httpReq)
114+ if err != nil {
115+ recordAPIResponseError(ctx, e.cfg, err)
116+ return resp, err
117+ }
118+ defer func() {
119+ if errClose := httpResp.Body.Close(); errClose != nil {
120+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
121+ }
122+ }()
123+
124+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
125+
126+ if !isHTTPSuccess(httpResp.StatusCode) {
127+ data, _ := io.ReadAll(httpResp.Body)
128+ appendAPIResponseChunk(ctx, e.cfg, data)
129+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
130+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
131+ return resp, err
132+ }
133+
134+ data, err := io.ReadAll(httpResp.Body)
135+ if err != nil {
136+ recordAPIResponseError(ctx, e.cfg, err)
137+ return resp, err
138+ }
139+ appendAPIResponseChunk(ctx, e.cfg, data)
140+
141+ detail := parseOpenAIUsage(data)
142+ if detail.TotalTokens > 0 {
143+ reporter.publish(ctx, detail)
144+ }
145+
146+ var param any
147+ converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
148+ resp = cliproxyexecutor.Response{Payload: []byte(converted)}
149+ reporter.ensurePublished(ctx)
150+ return resp, nil
151+}
152+
153+// ExecuteStream handles streaming requests to GitHub Copilot.
154+func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
155+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
156+ if errToken != nil {
157+ return nil, errToken
158+ }
159+
160+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
161+ defer reporter.trackFailure(ctx, &err)
162+
163+ from := opts.SourceFormat
164+ to := sdktranslator.FromString("openai")
165+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
166+ body = e.normalizeModel(req.Model, body)
167+ body = applyPayloadConfig(e.cfg, req.Model, body)
168+ body, _ = sjson.SetBytes(body, "stream", true)
169+ // Enable stream options for usage stats in stream
170+ body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
171+
172+ url := githubCopilotBaseURL + githubCopilotChatPath
173+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
174+ if err != nil {
175+ return nil, err
176+ }
177+ e.applyHeaders(httpReq, apiToken)
178+
179+ var authID, authLabel, authType, authValue string
180+ if auth != nil {
181+ authID = auth.ID
182+ authLabel = auth.Label
183+ authType, authValue = auth.AccountInfo()
184+ }
185+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
186+ URL: url,
187+ Method: http.MethodPost,
188+ Headers: httpReq.Header.Clone(),
189+ Body: body,
190+ Provider: e.Identifier(),
191+ AuthID: authID,
192+ AuthLabel: authLabel,
193+ AuthType: authType,
194+ AuthValue: authValue,
195+ })
196+
197+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
198+ httpResp, err := httpClient.Do(httpReq)
199+ if err != nil {
200+ recordAPIResponseError(ctx, e.cfg, err)
201+ return nil, err
202+ }
203+
204+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
205+
206+ if !isHTTPSuccess(httpResp.StatusCode) {
207+ data, readErr := io.ReadAll(httpResp.Body)
208+ if errClose := httpResp.Body.Close(); errClose != nil {
209+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
210+ }
211+ if readErr != nil {
212+ recordAPIResponseError(ctx, e.cfg, readErr)
213+ return nil, readErr
214+ }
215+ appendAPIResponseChunk(ctx, e.cfg, data)
216+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
217+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
218+ return nil, err
219+ }
220+
221+ out := make(chan cliproxyexecutor.StreamChunk)
222+ stream = out
223+
224+ go func() {
225+ defer close(out)
226+ defer func() {
227+ if errClose := httpResp.Body.Close(); errClose != nil {
228+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
229+ }
230+ }()
231+
232+ scanner := bufio.NewScanner(httpResp.Body)
233+ scanner.Buffer(nil, maxScannerBufferSize)
234+ var param any
235+
236+ for scanner.Scan() {
237+ line := scanner.Bytes()
238+ appendAPIResponseChunk(ctx, e.cfg, line)
239+
240+ // Parse SSE data
241+ if bytes.HasPrefix(line, dataTag) {
242+ data := bytes.TrimSpace(line[5:])
243+ if bytes.Equal(data, []byte("[DONE]")) {
244+ continue
245+ }
246+ if detail, ok := parseOpenAIStreamUsage(line); ok {
247+ reporter.publish(ctx, detail)
248+ }
249+ }
250+
251+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
252+ for i := range chunks {
253+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
254+ }
255+ }
256+
257+ if errScan := scanner.Err(); errScan != nil {
258+ recordAPIResponseError(ctx, e.cfg, errScan)
259+ reporter.publishFailure(ctx)
260+ out <- cliproxyexecutor.StreamChunk{Err: errScan}
261+ } else {
262+ reporter.ensurePublished(ctx)
263+ }
264+ }()
265+
266+ return stream, nil
267+}
268+
269+// CountTokens is not supported for GitHub Copilot.
270+func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
271+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"}
272+}
273+
274+// Refresh validates the GitHub token is still working.
275+// GitHub OAuth tokens don't expire traditionally, so we just validate.
276+func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
277+ if auth == nil {
278+ return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
279+ }
280+
281+ // Get the GitHub access token
282+ accessToken := metaStringValue(auth.Metadata, "access_token")
283+ if accessToken == "" {
284+ return auth, nil
285+ }
286+
287+ // Validate the token can still get a Copilot API token
288+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
289+ _, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
290+ if err != nil {
291+ return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)}
292+ }
293+
294+ return auth, nil
295+}
296+
297+// ensureAPIToken gets or refreshes the Copilot API token.
298+func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
299+ if auth == nil {
300+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
301+ }
302+
303+ // Get the GitHub access token
304+ accessToken := metaStringValue(auth.Metadata, "access_token")
305+ if accessToken == "" {
306+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}
307+ }
308+
309+ // Check for cached API token using thread-safe access
310+ e.mu.RLock()
311+ if cached, ok := e.cache[accessToken]; ok && cached.expiresAt.After(time.Now().Add(tokenExpiryBuffer)) {
312+ e.mu.RUnlock()
313+ return cached.token, nil
314+ }
315+ e.mu.RUnlock()
316+
317+ // Get a new Copilot API token
318+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
319+ apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
320+ if err != nil {
321+ return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)}
322+ }
323+
324+ // Cache the token with thread-safe access
325+ expiresAt := time.Now().Add(githubCopilotTokenCacheTTL)
326+ if apiToken.ExpiresAt > 0 {
327+ expiresAt = time.Unix(apiToken.ExpiresAt, 0)
328+ }
329+ e.mu.Lock()
330+ e.cache[accessToken] = &cachedAPIToken{
331+ token: apiToken.Token,
332+ expiresAt: expiresAt,
333+ }
334+ e.mu.Unlock()
335+
336+ return apiToken.Token, nil
337+}
338+
339+// applyHeaders sets the required headers for GitHub Copilot API requests.
340+func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
341+ r.Header.Set("Content-Type", "application/json")
342+ r.Header.Set("Authorization", "Bearer "+apiToken)
343+ r.Header.Set("Accept", "application/json")
344+ r.Header.Set("User-Agent", copilotUserAgent)
345+ r.Header.Set("Editor-Version", copilotEditorVersion)
346+ r.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
347+ r.Header.Set("Openai-Intent", copilotOpenAIIntent)
348+ r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
349+ r.Header.Set("X-Request-Id", uuid.NewString())
350+}
351+
352+// normalizeModel is a no-op as GitHub Copilot accepts model names directly.
353+// Model mapping should be done at the registry level if needed.
354+func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte {
355+ return body
356+}
357+
358+// isHTTPSuccess checks if the status code indicates success (2xx).
359+func isHTTPSuccess(statusCode int) bool {
360+ return statusCode >= 200 && statusCode < 300
361+}
sdk/auth/github_copilot.go+129−0
@@ -0,0 +1,129 @@
1+package auth
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
9+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
10+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
12+ log "github.com/sirupsen/logrus"
13+)
14+
15+// GitHubCopilotAuthenticator implements the OAuth device flow login for GitHub Copilot.
16+type GitHubCopilotAuthenticator struct{}
17+
18+// NewGitHubCopilotAuthenticator constructs a new GitHub Copilot authenticator.
19+func NewGitHubCopilotAuthenticator() Authenticator {
20+ return &GitHubCopilotAuthenticator{}
21+}
22+
23+// Provider returns the provider key for github-copilot.
24+func (GitHubCopilotAuthenticator) Provider() string {
25+ return "github-copilot"
26+}
27+
28+// RefreshLead returns nil since GitHub OAuth tokens don't expire in the traditional sense.
29+// The token remains valid until the user revokes it or the Copilot subscription expires.
30+func (GitHubCopilotAuthenticator) RefreshLead() *time.Duration {
31+ return nil
32+}
33+
34+// Login initiates the GitHub device flow authentication for Copilot access.
35+func (a GitHubCopilotAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
36+ if cfg == nil {
37+ return nil, fmt.Errorf("cliproxy auth: configuration is required")
38+ }
39+ if opts == nil {
40+ opts = &LoginOptions{}
41+ }
42+
43+ authSvc := copilot.NewCopilotAuth(cfg)
44+
45+ // Start the device flow
46+ fmt.Println("Starting GitHub Copilot authentication...")
47+ deviceCode, err := authSvc.StartDeviceFlow(ctx)
48+ if err != nil {
49+ return nil, fmt.Errorf("github-copilot: failed to start device flow: %w", err)
50+ }
51+
52+ // Display the user code and verification URL
53+ fmt.Printf("\nTo authenticate, please visit: %s\n", deviceCode.VerificationURI)
54+ fmt.Printf("And enter the code: %s\n\n", deviceCode.UserCode)
55+
56+ // Try to open the browser automatically
57+ if !opts.NoBrowser {
58+ if browser.IsAvailable() {
59+ if errOpen := browser.OpenURL(deviceCode.VerificationURI); errOpen != nil {
60+ log.Warnf("Failed to open browser automatically: %v", errOpen)
61+ }
62+ }
63+ }
64+
65+ fmt.Println("Waiting for GitHub authorization...")
66+ fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
67+
68+ // Wait for user authorization
69+ authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)
70+ if err != nil {
71+ errMsg := copilot.GetUserFriendlyMessage(err)
72+ return nil, fmt.Errorf("github-copilot: %s", errMsg)
73+ }
74+
75+ // Verify the token can get a Copilot API token
76+ fmt.Println("Verifying Copilot access...")
77+ apiToken, err := authSvc.GetCopilotAPIToken(ctx, authBundle.TokenData.AccessToken)
78+ if err != nil {
79+ return nil, fmt.Errorf("github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: %w", err)
80+ }
81+
82+ // Create the token storage
83+ tokenStorage := authSvc.CreateTokenStorage(authBundle)
84+
85+ // Build metadata with token information for the executor
86+ metadata := map[string]any{
87+ "type": "github-copilot",
88+ "username": authBundle.Username,
89+ "access_token": authBundle.TokenData.AccessToken,
90+ "token_type": authBundle.TokenData.TokenType,
91+ "scope": authBundle.TokenData.Scope,
92+ "timestamp": time.Now().UnixMilli(),
93+ }
94+
95+ if apiToken.ExpiresAt > 0 {
96+ metadata["api_token_expires_at"] = apiToken.ExpiresAt
97+ }
98+
99+ fileName := fmt.Sprintf("github-copilot-%s.json", authBundle.Username)
100+
101+ fmt.Printf("\nGitHub Copilot authentication successful for user: %s\n", authBundle.Username)
102+
103+ return &coreauth.Auth{
104+ ID: fileName,
105+ Provider: a.Provider(),
106+ FileName: fileName,
107+ Label: authBundle.Username,
108+ Storage: tokenStorage,
109+ Metadata: metadata,
110+ }, nil
111+}
112+
113+// RefreshGitHubCopilotToken validates and returns the current token status.
114+// GitHub OAuth tokens don't need traditional refresh - we just validate they still work.
115+func RefreshGitHubCopilotToken(ctx context.Context, cfg *config.Config, storage *copilot.CopilotTokenStorage) error {
116+ if storage == nil || storage.AccessToken == "" {
117+ return fmt.Errorf("no token available")
118+ }
119+
120+ authSvc := copilot.NewCopilotAuth(cfg)
121+
122+ // Validate the token can still get a Copilot API token
123+ _, err := authSvc.GetCopilotAPIToken(ctx, storage.AccessToken)
124+ if err != nil {
125+ return fmt.Errorf("token validation failed: %w", err)
126+ }
127+
128+ return nil
129+}
sdk/auth/refresh_registry.go+1−0
@@ -15,6 +15,7 @@ func init() { · 14 unmodified lines
1515 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
1616 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
1717 registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
18+ registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
1819 }
1920
2021 func registerRefreshLead(provider string, factory func() Authenticator) {
sdk/cliproxy/service.go+4−0
@@ -381,6 +381,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 380 unmodified lines
381381 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
382382 case "kiro":
383383 s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
384+ case "github-copilot":
385+ s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
384386 default:
385387 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
386388 if providerKey == "" {
@@ -722,6 +724,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 335 unmodified lines
722724 models = applyExcludedModels(models, excluded)
723725 case "iflow":
724726 models = registry.GetIFlowModels()
727+ case "github-copilot":
728+ models = registry.GetGitHubCopilotModels()
725729 models = applyExcludedModels(models, excluded)
726730 case "kiro":
727731 models = registry.GetKiroModels()

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t