Unified · Split
| @@ -7,7 +7,7 @@ on: · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | env: |
| 9 | 9 | APP_NAME: CLIProxyAPI |
| 10 | − DOCKERHUB_REPO: eceasy/cli-proxy-api | |
| 10 | + DOCKERHUB_REPO: eceasy/cli-proxy-api-plus | |
| 11 | 11 | |
| 12 | 12 | jobs: |
| 13 | 13 | docker: |
| @@ -23,7 +23,8 @@ jobs: · 22 unmodified lines | ||
| 23 | 23 | cache: true |
| 24 | 24 | - name: Generate Build Metadata |
| 25 | 25 | run: | |
| 26 | − echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV | |
| 26 | + VERSION=$(git describe --tags --always --dirty) | |
| 27 | + echo "VERSION=${VERSION}" >> $GITHUB_ENV | |
| 27 | 28 | echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV |
| 28 | 29 | echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV |
| 29 | 30 | - uses: goreleaser/goreleaser-action@v4 |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | builds: |
| 2 | − - id: "cli-proxy-api" | |
| 2 | + - id: "cli-proxy-api-plus" | |
| 3 | 3 | env: |
| 4 | 4 | - CGO_ENABLED=0 |
| 5 | 5 | goos: |
| @@ -10,11 +10,11 @@ builds: · 4 unmodified lines | ||
| 10 | 10 | - amd64 |
| 11 | 11 | - arm64 |
| 12 | 12 | main: ./cmd/server/ |
| 13 | − binary: cli-proxy-api | |
| 13 | + binary: cli-proxy-api-plus | |
| 14 | 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}}' | |
| 16 | 16 | archives: |
| 17 | − - id: "cli-proxy-api" | |
| 17 | + - id: "cli-proxy-api-plus" | |
| 18 | 18 | format: tar.gz |
| 19 | 19 | format_overrides: |
| 20 | 20 | - goos: windows |
| @@ -12,7 +12,7 @@ ARG VERSION=dev · 11 unmodified lines | ||
| 12 | 12 | ARG COMMIT=none |
| 13 | 13 | ARG BUILD_DATE=unknown |
| 14 | 14 | |
| 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/ | |
| 16 | 16 | |
| 17 | 17 | FROM alpine:3.22.0 |
| 18 | 18 | |
| @@ -20,7 +20,7 @@ RUN apk add --no-cache tzdata · 1 unmodified line | ||
| 20 | 20 | |
| 21 | 21 | RUN mkdir /CLIProxyAPI |
| 22 | 22 | |
| 23 | −COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI | |
| 23 | +COPY --from=builder ./app/CLIProxyAPIPlus /CLIProxyAPI/CLIProxyAPIPlus | |
| 24 | 24 | |
| 25 | 25 | COPY config.example.yaml /CLIProxyAPI/config.example.yaml |
| 26 | 26 | |
| @@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai · 5 unmodified lines | ||
| 32 | 32 | |
| 33 | 33 | RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone |
| 34 | 34 | |
| 35 | −CMD ["./CLIProxyAPI"] | |
| 35 | +CMD ["./CLIProxyAPIPlus"] | |
| @@ -1,102 +1,22 @@ | ||
| 1 | −# CLI Proxy API | |
| 1 | +# CLIProxyAPI Plus | |
| 2 | 2 | |
| 3 | −English | [中文](README_CN.md) | |
| 3 | +English | [Chinese](README_CN.md) | |
| 4 | 4 | |
| 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. | |
| 6 | 6 | |
| 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. | |
| 8 | 8 | |
| 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. | |
| 10 | 10 | |
| 11 | −## Sponsor | |
| 11 | +## Differences from the Mainline | |
| 12 | 12 | |
| 13 | −[](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) | |
| 71 | 14 | |
| 72 | 15 | ## Contributing |
| 73 | 16 | |
| 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. | |
| 97 | 18 | |
| 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. | |
| 100 | 20 | |
| 101 | 21 | ## License |
| 102 | 22 | |
| @@ -1,109 +1,23 @@ | ||
| 1 | −# CLI 代理 API | |
| 1 | +# CLIProxyAPI Plus | |
| 2 | 2 | |
| 3 | 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 | −[](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)提供 | |
| 69 | 14 | |
| 70 | 15 | ## 贡献 |
| 71 | 16 | |
| 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 都将被拒绝。 | |
| 89 | 18 | |
| 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,请提交到主线版本。 | |
| 98 | 20 | |
| 99 | 21 | ## 许可证 |
| 100 | 22 | |
| 101 | −此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 | |
| 102 | − | |
| 103 | −## 写给所有中国网友的 | |
| 104 | − | |
| 105 | −QQ 群:188637136 | |
| 106 | − | |
| 107 | −或 | |
| 108 | − | |
| 109 | −Telegram 群:https://t.me/CLIProxyAPI | |
| 23 | +此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 | |
| @@ -79,6 +79,7 @@ func main() { · 78 unmodified lines | ||
| 79 | 79 | var kiroGoogleLogin bool |
| 80 | 80 | var kiroAWSLogin bool |
| 81 | 81 | var kiroImport bool |
| 82 | + var githubCopilotLogin bool | |
| 82 | 83 | var projectID string |
| 83 | 84 | var vertexImport string |
| 84 | 85 | var configPath string |
| @@ -101,6 +102,7 @@ func main() { · 16 unmodified lines | ||
| 101 | 102 | flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)") |
| 102 | 103 | flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)") |
| 103 | 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 | 106 | flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") |
| 105 | 107 | flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") |
| 106 | 108 | flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") |
| @@ -461,6 +463,9 @@ func main() { · 354 unmodified lines | ||
| 461 | 463 | } else if antigravityLogin { |
| 462 | 464 | // Handle Antigravity login |
| 463 | 465 | cmd.DoAntigravityLogin(cfg, options) |
| 466 | + } else if githubCopilotLogin { | |
| 467 | + // Handle GitHub Copilot login | |
| 468 | + cmd.DoGitHubCopilotLogin(cfg, options) | |
| 464 | 469 | } else if codexLogin { |
| 465 | 470 | // Handle Codex login |
| 466 | 471 | cmd.DoCodexLogin(cfg, options) |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | services: |
| 2 | 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 | 4 | pull_policy: always |
| 5 | 5 | build: |
| 6 | 6 | context: . |
| @@ -9,7 +9,7 @@ services: · 2 unmodified lines | ||
| 9 | 9 | VERSION: ${VERSION:-dev} |
| 10 | 10 | COMMIT: ${COMMIT:-none} |
| 11 | 11 | BUILD_DATE: ${BUILD_DATE:-unknown} |
| 12 | − container_name: cli-proxy-api | |
| 12 | + container_name: cli-proxy-api-plus | |
| 13 | 13 | # env_file: |
| 14 | 14 | # - .env |
| 15 | 15 | environment: |
| @@ -19,8 +19,8 @@ import ( · 18 unmodified lines | ||
| 19 | 19 | ) |
| 20 | 20 | |
| 21 | 21 | 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" | |
| 24 | 24 | ) |
| 25 | 25 | |
| 26 | 26 | func (h *Handler) GetConfig(c *gin.Context) { |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -6,7 +6,7 @@ import ( · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | // newAuthManager creates a new authentication manager instance with all supported |
| 8 | 8 | // 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 | 11 | // Returns: |
| 12 | 12 | // - *sdkAuth.Manager: A configured authentication manager instance |
| @@ -20,6 +20,7 @@ func newAuthManager() *sdkAuth.Manager { · 7 unmodified lines | ||
| 20 | 20 | sdkAuth.NewIFlowAuthenticator(), |
| 21 | 21 | sdkAuth.NewAntigravityAuthenticator(), |
| 22 | 22 | sdkAuth.NewKiroAuthenticator(), |
| 23 | + sdkAuth.NewGitHubCopilotAuthenticator(), | |
| 23 | 24 | ) |
| 24 | 25 | return manager |
| 25 | 26 | } |
| @@ -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 | +} | |
| @@ -987,6 +987,190 @@ func GetIFlowModels() []*ModelInfo { · 986 unmodified lines | ||
| 987 | 987 | 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. | |
| 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 | + | |
| 990 | 1174 | // GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions |
| 991 | 1175 | func GetKiroModels() []*ModelInfo { |
| 992 | 1176 | return []*ModelInfo{ |
| @@ -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, ¶m) | |
| 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), ¶m) | |
| 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 | +} | |
| @@ -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 | +} | |
| @@ -15,6 +15,7 @@ func init() { · 14 unmodified lines | ||
| 15 | 15 | registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) |
| 16 | 16 | registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() }) |
| 17 | 17 | registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() }) |
| 18 | + registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) | |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | func registerRefreshLead(provider string, factory func() Authenticator) { |
| @@ -381,6 +381,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { · 380 unmodified lines | ||
| 381 | 381 | s.coreManager.RegisterExecutor(executor.NewIFlowExecutor(s.cfg)) |
| 382 | 382 | case "kiro": |
| 383 | 383 | s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) |
| 384 | + case "github-copilot": | |
| 385 | + s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) | |
| 384 | 386 | default: |
| 385 | 387 | providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) |
| 386 | 388 | if providerKey == "" { |
| @@ -722,6 +724,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 335 unmodified lines | ||
| 722 | 724 | models = applyExcludedModels(models, excluded) |
| 723 | 725 | case "iflow": |
| 724 | 726 | models = registry.GetIFlowModels() |
| 727 | + case "github-copilot": | |
| 728 | + models = registry.GetGitHubCopilotModels() | |
| 725 | 729 | models = applyExcludedModels(models, excluded) |
| 726 | 730 | case "kiro": |
| 727 | 731 | models = registry.GetKiroModels() |