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
7 7
8env:8env:
9 APP_NAME: CLIProxyAPI9 APP_NAME: CLIProxyAPI
10 DOCKERHUB_REPO: eceasy/cli-proxy-api10 DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
11 11
12jobs:12jobs:
13 docker:13 docker:
.github/workflows/release.yaml+2−1
@@ -23,7 +23,8 @@ jobs: · 22 unmodified lines
23 cache: true23 cache: true
24 - name: Generate Build Metadata24 - name: Generate Build Metadata
25 run: |25 run: |
26 echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV26 VERSION=$(git describe --tags --always --dirty)
27 echo "VERSION=${VERSION}" >> $GITHUB_ENV
27 echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV28 echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
28 echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV29 echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
29 - uses: goreleaser/goreleaser-action@v430 - uses: goreleaser/goreleaser-action@v4
.goreleaser.yml+4−4
@@ -1,5 +1,5 @@
1builds:1builds:
2 - id: "cli-proxy-api"2 - id: "cli-proxy-api-plus"
3 env:3 env:
4 - CGO_ENABLED=04 - CGO_ENABLED=0
5 goos:5 goos:
@@ -10,11 +10,11 @@ builds: · 4 unmodified lines
10 - amd6410 - amd64
11 - arm6411 - arm64
12 main: ./cmd/server/12 main: ./cmd/server/
13 binary: cli-proxy-api13 binary: cli-proxy-api-plus
14 ldflags:14 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}}'
16archives:16archives:
17 - id: "cli-proxy-api"17 - id: "cli-proxy-api-plus"
18 format: tar.gz18 format: tar.gz
19 format_overrides:19 format_overrides:
20 - goos: windows20 - goos: windows
Dockerfile+3−3
@@ -12,7 +12,7 @@ ARG VERSION=dev · 11 unmodified lines
12ARG COMMIT=none12ARG COMMIT=none
13ARG BUILD_DATE=unknown13ARG BUILD_DATE=unknown
14 14
15RUN 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/15RUN 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/
16 16
17FROM alpine:3.22.017FROM alpine:3.22.0
18 18
@@ -20,7 +20,7 @@ RUN apk add --no-cache tzdata · 1 unmodified line
20 20
21RUN mkdir /CLIProxyAPI21RUN mkdir /CLIProxyAPI
22 22
23COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI23COPY --from=builder ./app/CLIProxyAPIPlus /CLIProxyAPI/CLIProxyAPIPlus
24 24
25COPY config.example.yaml /CLIProxyAPI/config.example.yaml25COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26 26
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai · 5 unmodified lines
32 32
33RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone33RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
34 34
35CMD ["./CLIProxyAPI"]35CMD ["./CLIProxyAPIPlus"]
README.md+9−89
@@ -1,102 +1,22 @@
1# CLI Proxy API1# CLIProxyAPI Plus
2 2
3English | [中文](README_CN.md)3English | [Chinese](README_CN.md)
4 4
5A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.5This 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.
6 6
7It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.7All 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.
8 8
9So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.9The Plus release stays in lockstep with the mainline features.
10 10
11## Sponsor11## Differences from the Mainline
12 12
13[![z.ai](https://assets.router-for.me/english.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)13- Added GitHub Copilot support (OAuth login), provided by [em4gp](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)
14
15This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
16
17GLM 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
19Get 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
46CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
47
48## Management API
49
50see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
51
52## Amp CLI Support
53
54CLIProxyAPI 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`
71 14
72## Contributing15## Contributing
73 16
74Contributions are welcome! Please feel free to submit a Pull Request.17This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
75
761. Fork the repository
772. Create your feature branch (`git checkout -b feature/amazing-feature`)
783. Commit your changes (`git commit -m 'Add some amazing feature'`)
794. Push to the branch (`git push origin feature/amazing-feature`)
805. Open a Pull Request
81
82## Who is with us?
83
84Those projects are based on CLIProxyAPI:
85
86### [vibeproxy](https://github.com/automazeio/vibeproxy)
87
88Native 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
92Browser-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
96CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
97 18
98> [!NOTE] 19If you need to submit any non-third-party provider changes, please open them against the mainline repository.
99> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
100 20
101## License21## License
102 22
README_CN.md+9−95
@@ -1,109 +1,23 @@
1# CLI 代理 API1# CLIProxyAPI Plus
2 2
3[English](README.md) | 中文3[English](README.md) | 中文
4 4
5一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。5这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 的 Plus 版本,在原有基础上增加了第三方供应商的支持。
6 6
7现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。7所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
8 8
9您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。9该 Plus 版本的主线功能与主线功能强制同步。
10 10
11## 赞助商11## 与主线版本版本差异
12 12
13[![bigmodel.cn](https://assets.router-for.me/chinese.png)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)13- 新增 GitHub Copilot 支持(OAuth 登录),由[em4gp](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
14
15本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
16
17GLM 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
45CLIProxyAPI 用户手册: [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
53CLIProxyAPI 已内置对 [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`
69 14
70## 贡献15## 贡献
71 16
72欢迎贡献!请随时提交 Pull Request。17该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
73
741. Fork 仓库
752. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
763. 提交您的更改(`git commit -m 'Add some amazing feature'`)
774. 推送到分支(`git push origin feature/amazing-feature`)
785. 打开 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)
89 18
90一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。19如果需要提交任何非第三方供应商支持的 Pull Request,请提交到主线版本。
91
92### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
93
94CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
95
96> [!NOTE]
97> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
98 20
99## 许可证21## 许可证
100 22
101此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。23此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
102
103## 写给所有中国网友的
104
105QQ 群:188637136
106
107或
108
109Telegram 群:https://t.me/CLIProxyAPI
cmd/server/main.go+5−0
@@ -79,6 +79,7 @@ func main() { · 78 unmodified lines
79 var kiroGoogleLogin bool79 var kiroGoogleLogin bool
80 var kiroAWSLogin bool80 var kiroAWSLogin bool
81 var kiroImport bool81 var kiroImport bool
82 var githubCopilotLogin bool
82 var projectID string83 var projectID string
83 var vertexImport string84 var vertexImport string
84 var configPath string85 var configPath string
@@ -101,6 +102,7 @@ func main() { · 16 unmodified lines
101 flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")102 flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
102 flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")103 flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
103 flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)")104 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")
104 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")106 flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
105 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")107 flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
106 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")108 flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -461,6 +463,9 @@ func main() { · 354 unmodified lines
461 } else if antigravityLogin {463 } else if antigravityLogin {
462 // Handle Antigravity login464 // Handle Antigravity login
463 cmd.DoAntigravityLogin(cfg, options)465 cmd.DoAntigravityLogin(cfg, options)
466 } else if githubCopilotLogin {
467 // Handle GitHub Copilot login
468 cmd.DoGitHubCopilotLogin(cfg, options)
464 } else if codexLogin {469 } else if codexLogin {
465 // Handle Codex login470 // Handle Codex login
466 cmd.DoCodexLogin(cfg, options)471 cmd.DoCodexLogin(cfg, options)
docker-compose.yml+2−2
@@ -1,6 +1,6 @@
1services:1services:
2 cli-proxy-api:2 cli-proxy-api:
3 image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}3 image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api-plus:latest}
4 pull_policy: always4 pull_policy: always
5 build:5 build:
6 context: .6 context: .
@@ -9,7 +9,7 @@ services: · 2 unmodified lines
9 VERSION: ${VERSION:-dev}9 VERSION: ${VERSION:-dev}
10 COMMIT: ${COMMIT:-none}10 COMMIT: ${COMMIT:-none}
11 BUILD_DATE: ${BUILD_DATE:-unknown}11 BUILD_DATE: ${BUILD_DATE:-unknown}
12 container_name: cli-proxy-api12 container_name: cli-proxy-api-plus
13 # env_file:13 # env_file:
14 # - .env14 # - .env
15 environment:15 environment:
internal/api/handlers/management/config_basic.go+2−2
@@ -19,8 +19,8 @@ import ( · 18 unmodified lines
19)19)
20 20
21const (21const (
22 latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"22 latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest"
23 latestReleaseUserAgent = "CLIProxyAPI"23 latestReleaseUserAgent = "CLIProxyAPIPlus"
24)24)
25 25
26func (h *Handler) GetConfig(c *gin.Context) {26func (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.
3package copilot
4
5import (
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
18const (
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.
33type 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.
55type 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.
63func 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.
73func (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.
78func (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.
99func (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.
148func (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.
162func (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.
174func (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.
194func (c *CopilotAuth) GetAPIEndpoint() string {
195 return copilotAPIEndpoint
196}
197
198// MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
199func (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.
218func buildChatCompletionURL() string {
219 return copilotAPIEndpoint + "/chat/completions"
220}
221
222// isHTTPSuccess checks if the status code indicates success (2xx).
223func isHTTPSuccess(statusCode int) bool {
224 return statusCode >= 200 && statusCode < 300
225}
internal/auth/copilot/errors.go+187−0
@@ -0,0 +1,187 @@
1package copilot
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7)
8
9// OAuthError represents an OAuth-specific error.
10type 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.
22func (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.
30func 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.
39type 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.
51func (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.
59func (e *AuthenticationError) Unwrap() error {
60 return e.Cause
61}
62
63// Common authentication error types for GitHub Copilot device flow.
64var (
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.
123func 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.
133func 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.
140func 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.
147func 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 @@
1package copilot
2
3import (
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
19const (
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.
35type DeviceFlowClient struct {
36 httpClient *http.Client
37 cfg *config.Config
38}
39
40// NewDeviceFlowClient creates a new device flow client.
41func 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.
53func (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.
89func (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.
146func (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.
215func (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.
4package copilot
5
6import (
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.
18type 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.
34type 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.
44type 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.
52type 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
74func (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
6 6
7// newAuthManager creates a new authentication manager instance with all supported7// newAuthManager creates a new authentication manager instance with all supported
8// authenticators and a file-based token store. It initializes authenticators for8// 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.
10//10//
11// Returns:11// Returns:
12// - *sdkAuth.Manager: A configured authentication manager instance12// - *sdkAuth.Manager: A configured authentication manager instance
@@ -20,6 +20,7 @@ func newAuthManager() *sdkAuth.Manager { · 7 unmodified lines
20 sdkAuth.NewIFlowAuthenticator(),20 sdkAuth.NewIFlowAuthenticator(),
21 sdkAuth.NewAntigravityAuthenticator(),21 sdkAuth.NewAntigravityAuthenticator(),
22 sdkAuth.NewKiroAuthenticator(),22 sdkAuth.NewKiroAuthenticator(),
23 sdkAuth.NewGitHubCopilotAuthenticator(),
23 )24 )
24 return manager25 return manager
25}26}
internal/cmd/github_copilot_login.go+44−0
@@ -0,0 +1,44 @@
1package cmd
2
3import (
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
19func 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
987 return models987 return models
988}988}
989 989
990// GetGitHubCopilotModels returns the available models for GitHub Copilot.
991// These models are available through the GitHub Copilot API at api.githubcopilot.com.
992func 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
990// GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions1174// GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
991func GetKiroModels() []*ModelInfo {1175func GetKiroModels() []*ModelInfo {
992 return []*ModelInfo{1176 return []*ModelInfo{
internal/runtime/executor/github_copilot_executor.go+361−0
@@ -0,0 +1,361 @@
1package executor
2
3import (
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
23const (
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.
42type 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.
49type cachedAPIToken struct {
50 token string
51 expiresAt time.Time
52}
53
54// NewGitHubCopilotExecutor constructs a new executor instance.
55func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
56 return &GitHubCopilotExecutor{
57 cfg: cfg,
58 cache: make(map[string]*cachedAPIToken),
59 }
60}
61
62// Identifier implements ProviderExecutor.
63func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
64
65// PrepareRequest implements ProviderExecutor.
66func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
67 return nil
68}
69
70// Execute handles non-streaming requests to GitHub Copilot.
71func (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.
154func (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.
270func (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.
276func (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.
298func (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.
340func (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.
354func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte {
355 return body
356}
357
358// isHTTPSuccess checks if the status code indicates success (2xx).
359func isHTTPSuccess(statusCode int) bool {
360 return statusCode >= 200 && statusCode < 300
361}
sdk/auth/github_copilot.go+129−0
@@ -0,0 +1,129 @@
1package auth
2
3import (
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.
16type GitHubCopilotAuthenticator struct{}
17
18// NewGitHubCopilotAuthenticator constructs a new GitHub Copilot authenticator.
19func NewGitHubCopilotAuthenticator() Authenticator {
20 return &GitHubCopilotAuthenticator{}
21}
22
23// Provider returns the provider key for github-copilot.
24func (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.
30func (GitHubCopilotAuthenticator) RefreshLead() *time.Duration {
31 return nil
32}
33
34// Login initiates the GitHub device flow authentication for Copilot access.
35func (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.
115func 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
15 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })15 registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
16 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })16 registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
17 registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })17 registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() })
18 registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() })
18}19}
19 20
20func registerRefreshLead(provider string, factory func() Authenticator) {21func 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
381 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))381 s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg))
382 case "kiro":382 case "kiro":
383 s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))383 s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg))
384 case "github-copilot":
385 s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg))
384 default:386 default:
385 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))387 providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
386 if providerKey == "" {388 if providerKey == "" {
@@ -722,6 +724,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 335 unmodified lines
722 models = applyExcludedModels(models, excluded)724 models = applyExcludedModels(models, excluded)
723 case "iflow":725 case "iflow":
724 models = registry.GetIFlowModels()726 models = registry.GetIFlowModels()
727 case "github-copilot":
728 models = registry.GetGitHubCopilotModels()
725 models = applyExcludedModels(models, excluded)729 models = applyExcludedModels(models, excluded)
726 case "kiro":730 case "kiro":
727 models = registry.GetKiroModels()731 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