Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**把你自己的登录态,变成一个本机 OpenAI 兼容 API**

支持 **Qoder 国际版**、**Qoder 国内版**、**WorkBuddy 国际版**、**WorkBuddy 国内版****Trae 国内版 Solo**。
支持 **Qoder 国际版**、**Qoder 国内版**、**WorkBuddy 国际版**、**WorkBuddy 国内版****Trae 国内版 Solo**,以及实验性的 **Devin**(`provider=devin`,浏览器 OAuth / session token 导入;尚未宣称生产可用)

常驻运行时、多账号调度。请用 Docker 部署,这是官方支持的安装与更新路径。

Expand All @@ -20,9 +20,9 @@
## 功能

- **OpenAI / Anthropic 兼容代理**:`/v1/chat/completions`、`/v1/responses`、`/v1/messages`、`/v1/models`;支持流式/非流式、文本与函数工具调用;图片能力取决于 provider(当前 Qoder 支持,WorkBuddy / Trae 不支持);文件输入会明确拒绝。`messages` / `responses` 当前为无状态适配层,不支持服务端会话或上游专属工具。
- **多渠道账号池**:Qoder 国际版 / 国内版、WorkBuddy 国际版 / 国内版、Trae 国内版 Solo;地域隔离、账号固定、并发限制、冷却与同族故障切换
- **代理出口**:支持统一 HTTP(S) 代理,也支持账号级覆盖;账号可用 `direct` / `none` 显式直连。SOCKS5 仅支持 WorkBuddy / Trae 的账号级代理,Qoder 账号级代理只支持 HTTP(S)
- **账号级常驻运行时**:Qoder 账号使用独立 Node 进程、HOME 与 WASM 上下文;WorkBuddy / Trae 使用进程内 HTTP/SSE 适配器。登录态、云端连接和账号隔离由各 provider 的运行时负责
- **多渠道账号池**:Qoder 国际版 / 国内版、WorkBuddy 国际版 / 国内版、Trae 国内版 Solo,以及实验性 Devin;地域隔离、账号固定、并发限制、冷却与同族故障切换
- **代理出口**:支持统一 HTTP(S) 代理,也支持账号级覆盖;账号可用 `direct` / `none` 显式直连。SOCKS5 仅支持 WorkBuddy / Trae / Devin 的账号级代理,Qoder 账号级代理只支持 HTTP(S)
- **账号级常驻运行时**:Qoder 账号使用独立 Node 进程、HOME 与 WASM 上下文;WorkBuddy / Trae / Devin 使用进程内 HTTP/SSE(或 Connect)适配器。登录态、云端连接和账号隔离由各 provider 的运行时负责
- **按 provider 支持多种登录方式**:浏览器 Device Flow OAuth、PAT,以及适用 provider 的凭证导入/导出
- **Web 控制台**:账号、模型、接入、请求历史与运行时日志,明暗主题
- **部署与运维**:Docker Compose 单容器、安全托管更新(升级前快照、失败自动回滚、直接最新稳定版、可回滚最近三个稳定版)、默认只监听 `127.0.0.1`
Expand All @@ -32,7 +32,7 @@

**强烈建议用 Docker 部署。** 发布镜像、控制台托管更新(升级前快照、失败回滚、直接最新稳定版)都按单容器 Compose 安装来设计;从源码直接跑 Go / Node 不在这条更新路径上。

依赖:Docker(macOS / Windows 用 Docker Desktop,Linux 用 Docker Engine + Compose),以及一个你自己控制的 Qoder、WorkBuddy 或 Trae 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。
依赖:Docker(macOS / Windows 用 Docker Desktop,Linux 用 Docker Engine + Compose),以及一个你自己控制的 Qoder、WorkBuddy、Trae 或实验性 Devin 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。

```bash
git clone https://github.com/caigee-cmd/cli2api.git
Expand All @@ -59,7 +59,7 @@ API Key: <首次启动时生成的 Key>
<img src="./docs/assets/readme/architecture-zh.svg" width="100%" alt="CLI2API 架构:OpenAI 客户端经 Go 控制面路由到每账号独立运行时,再连接各 provider 上游">
</p>

每个启用账号拥有独立运行时:Qoder 使用独立 Node 进程、HOME 和 WASM 上下文,WorkBuddy / Trae 使用进程内适配器。Go 负责账号持久化、调度、并发限制、冷却、失败切换,并管理需要子进程的 provider 生命周期。
每个启用账号拥有独立运行时:Qoder 使用独立 Node 进程、HOME 和 WASM 上下文,WorkBuddy / Trae / Devin 使用进程内适配器。Go 负责账号持久化、调度、并发限制、冷却、失败切换,并管理需要子进程的 provider 生命周期。

## 控制台

Expand All @@ -71,7 +71,7 @@ API Key: <首次启动时生成的 Key>

## 适合什么场景

- 想在本机或私有服务器上统一接入 Qoder / WorkBuddy / Trae
- 想在本机或私有服务器上统一接入 Qoder / WorkBuddy / Trae(以及实验性 Devin)
- 已经在使用 OpenAI API 格式的客户端或脚本
- 需要在多个账号之间自动路由和故障切换
- 想保留登录能力,同时避免每个请求启动完整 CLI Agent
Expand Down
14 changes: 7 additions & 7 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Turn your own logins into a local OpenAI-compatible API**

Supports **Qoder Global**, **Qoder CN**, **WorkBuddy Global**, **WorkBuddy CN**, and **Trae CN Solo**.
Supports **Qoder Global**, **Qoder CN**, **WorkBuddy Global**, **WorkBuddy CN**, **Trae CN Solo**, and experimental **Devin** (`provider=devin`, browser OAuth / session token import; not claimed production-ready).

Long-lived account runtimes, multi-account scheduling. Deploy with Docker; that is the supported install and update path.

Expand All @@ -20,9 +20,9 @@ Long-lived account runtimes, multi-account scheduling. Deploy with Docker; that
## Features

- **OpenAI / Anthropic-compatible proxy**: `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models` — streaming/non-streaming text and function tools; image support depends on the provider (currently supported by Qoder, not WorkBuddy / Trae); file inputs are rejected explicitly. `messages` / `responses` are stateless adapters today and do not support server-side conversations or upstream-specific tools.
- **Multi-channel account pool**: Qoder Global / Qoder CN, WorkBuddy Global / WorkBuddy CN, Trae CN Solo — region isolation, account pinning, concurrency limits, cooldowns, and same-family failover
- **Outbound proxies**: set one global HTTP(S) proxy or override it per account; use `direct` / `none` for explicit direct access. SOCKS5 is available for WorkBuddy / Trae account-level proxies only; Qoder account-level proxies are HTTP(S) only
- **Account-level runtimes**: Qoder accounts use an isolated Node process, HOME, and WASM context; WorkBuddy / Trae use in-process HTTP/SSE adapters. Each provider owns its login and upstream runtime boundary
- **Multi-channel account pool**: Qoder Global / Qoder CN, WorkBuddy Global / WorkBuddy CN, Trae CN Solo, plus experimental Devin — region isolation, account pinning, concurrency limits, cooldowns, and same-family failover
- **Outbound proxies**: set one global HTTP(S) proxy or override it per account; use `direct` / `none` for explicit direct access. SOCKS5 is available for WorkBuddy / Trae / Devin account-level proxies only; Qoder account-level proxies are HTTP(S) only
- **Account-level runtimes**: Qoder accounts use an isolated Node process, HOME, and WASM context; WorkBuddy / Trae / Devin use in-process HTTP/SSE (or Connect) adapters. Each provider owns its login and upstream runtime boundary
- **Provider-specific login methods**: browser Device Flow OAuth, PAT, and credential import/export where supported
- **Web console**: accounts, models, access, request history, and runtime logs, with light and dark themes
- **Deployment and ops**: single Docker Compose container, safe managed updates (pre-update snapshot, automatic rollback on failure, jump to the latest stable release, roll back to one of the three previous stables), binds `127.0.0.1` by default
Expand All @@ -32,7 +32,7 @@ Long-lived account runtimes, multi-account scheduling. Deploy with Docker; that

**Deploy with Docker.** Published images and console managed updates (pre-update snapshot, automatic rollback, jump to the latest stable release) are built around the single Compose container. Running the Go / Node sources directly is not on that update path.

Requirements: Docker (Docker Desktop on macOS/Windows, Docker Engine + Compose on Linux) and a Qoder, WorkBuddy, or Trae account you control. On Windows, Docker Desktop must use Linux containers.
Requirements: Docker (Docker Desktop on macOS/Windows, Docker Engine + Compose on Linux) and a Qoder, WorkBuddy, Trae, or experimental Devin account you control. On Windows, Docker Desktop must use Linux containers.

```bash
git clone https://github.com/caigee-cmd/cli2api.git
Expand All @@ -59,7 +59,7 @@ Without an account header the scheduler picks a ready account; pin a request wit
<img src="./docs/assets/readme/architecture-en.svg" width="100%" alt="CLI2API architecture: OpenAI clients are routed by the Go control plane to one isolated runtime per account, then to the provider upstream">
</p>

Each enabled account gets an isolated runtime: Qoder uses its own Node process, HOME, and WASM context, while WorkBuddy / Trae use in-process adapters. Go owns persistence, scheduling, concurrency limits, cooldowns, failover, and the lifecycle of providers that need child processes.
Each enabled account gets an isolated runtime: Qoder uses its own Node process, HOME, and WASM context, while WorkBuddy / Trae / Devin use in-process adapters. Go owns persistence, scheduling, concurrency limits, cooldowns, failover, and the lifecycle of providers that need child processes.

## Console

Expand All @@ -71,7 +71,7 @@ Accounts, models, access, and logs all live in one web console. Each account sig

## Use cases

- Connect Qoder / WorkBuddy / Trae to local or private-server tooling
- Connect Qoder / WorkBuddy / Trae (and experimental Devin) to local or private-server tooling
- Reuse OpenAI-compatible clients and scripts
- Route requests across multiple accounts with failover
- Keep login state available without starting a full CLI Agent per request
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const labelKeys: Record<string, string> = {
'workbuddy-cn': 'accountTypeWorkBuddyCN',
'workbuddy-global': 'accountTypeWorkBuddyGlobal',
'trae-cn': 'accountTypeTraeCN',
'devin-global': 'accountTypeDevinGlobal',
}

const hintKeys: Record<string, string> = {
Expand All @@ -59,6 +60,7 @@ const hintKeys: Record<string, string> = {
'workbuddy-cn': 'accountTypeWorkBuddyCNHint',
'workbuddy-global': 'accountTypeWorkBuddyGlobalHint',
'trae-cn': 'accountTypeTraeCNHint',
'devin-global': 'accountTypeDevinGlobalHint',
}

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
Expand Down Expand Up @@ -190,7 +192,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const showImportTab = activeOption?.descriptor.capabilities?.import_export !== false
const showDropSystem = activeOption?.provider === 'workbuddy'
const showAutoCheckin = activeOption?.provider === 'workbuddy'
const showCallbackPaste = activeOption?.provider === 'trae'
const showCallbackPaste = activeOption?.provider === 'trae' || activeOption?.provider === 'devin'
const busy = phase === 'busy' || phase === 'polling'
const settingsLocked = Boolean(createdId.current) || busy
const isDone = phase === 'done'
Expand Down Expand Up @@ -369,6 +371,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
if (!bundle.format) {
if (activeOption.descriptor.id === 'workbuddy') bundle.format = 'workbuddy-oauth-v1'
else if (activeOption.descriptor.id === 'trae') bundle.format = 'trae-oauth-v1'
else if (activeOption.descriptor.id === 'devin') bundle.format = 'devin-session-v1'
else bundle.format = 'qoder-native-v1'
}
try {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/DevinMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
type Props = {
size?: number
className?: string
}

export function DevinMark({ size = 16, className = '' }: Props) {
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-[22%] bg-[#1a1a1a] text-white ${className}`.trim()}
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.55)), fontWeight: 650, lineHeight: 1 }}
aria-hidden="true"
>
D
</span>
)
}
4 changes: 4 additions & 0 deletions frontend/src/components/ProviderMark.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DevinMark } from '@/components/DevinMark'
import { QoderMark } from '@/components/QoderMark'
import { TraeMark } from '@/components/TraeMark'
import { WorkBuddyMark } from '@/components/WorkBuddyMark'
Expand All @@ -17,5 +18,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) {
if (id === 'trae') {
return <TraeMark size={size} className={className} />
}
if (id === 'devin') {
return <DevinMark size={size} className={className} />
}
return <QoderMark size={size} className={className} />
}
4 changes: 4 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,8 @@ export const messages: Record<Lang, Dict> = {
accountTypeWorkBuddyGlobalHint: 'WorkBuddy Global via browser login or imported credential',
accountTypeTraeCN: 'Trae CN Solo',
accountTypeTraeCNHint: 'Trae China Solo via browser login or imported credential',
accountTypeDevinGlobal: 'Devin (experimental)',
accountTypeDevinGlobalHint: 'Experimental Devin Global via browser OAuth or session token import',
addAccountDesc: 'Create an isolated account session. Each account keeps its own runtime home.',
tabBrowser: 'Browser login',
tabPat: 'PAT',
Expand Down Expand Up @@ -1155,6 +1157,8 @@ export const messages: Record<Lang, Dict> = {
accountTypeWorkBuddyGlobalHint: 'WorkBuddy 国际版:浏览器登录或导入凭证',
accountTypeTraeCN: 'Trae 国内版(Solo)',
accountTypeTraeCNHint: 'Trae 国内 Solo:浏览器登录或导入凭证',
accountTypeDevinGlobal: 'Devin(实验性)',
accountTypeDevinGlobalHint: '实验性 Devin 国际版:浏览器 OAuth 或导入 session token',
addAccountDesc: '创建一个隔离会话并登录。每个账号使用独立的运行时目录。',
tabBrowser: '浏览器登录',
tabPat: 'PAT',
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ export function isTraeProvider(provider?: string) {
return String(provider || '').toLowerCase() === 'trae'
}

export function isDevinProvider(provider?: string) {
return String(provider || '').toLowerCase() === 'devin'
}

export function accountProviderFamilyLabel(
provider: string | undefined,
t: (key: string) => string,
) {
const providerID = String(provider || '').toLowerCase()
if (isWorkBuddyProvider(providerID)) return 'WorkBuddy'
if (isTraeProvider(providerID)) return 'Trae'
if (isDevinProvider(providerID)) return 'Devin'
if (isQoderProvider(providerID)) return 'Qoder'
return provider || t('account')
}
Expand All @@ -35,6 +40,9 @@ export function accountProviderLabel(
if (isTraeProvider(providerID)) {
return t('accountTypeTraeCN')
}
if (isDevinProvider(providerID)) {
return t('accountTypeDevinGlobal')
}
if (isQoderProvider(providerID)) {
return regionID === 'cn' ? t('accountTypeQoderCN') : t('accountTypeQoderGlobal')
}
Expand Down
47 changes: 46 additions & 1 deletion internal/api/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/providers/devin"
"github.com/caigee-cmd/cli2api/internal/providers/trae"
"github.com/caigee-cmd/cli2api/internal/providers/workbuddy"
)
Expand Down Expand Up @@ -199,8 +200,52 @@ func (s *Server) handleAccountImport(w http.ResponseWriter, r *http.Request) {
}
imported, _ := s.manager.Store().Get(r.Context(), account.ID)
writeJSON(w, http.StatusCreated, imported)
case devin.CredentialFormat:
payload := input.Credential
if len(payload) == 0 {
payload = json.RawMessage(raw)
}
if err := devin.ValidateCredential(payload); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
credential, err := devin.DecodeCredential(payload)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
credential = devin.EnsureDeviceSeed(credential)
encoded, err := credential.Encode()
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
account, err := s.manager.Create(r.Context(), accounts.CreateAccount{
Name: input.Name, Provider: "devin", Region: input.Region, Enabled: false,
MaxInFlight: input.MaxInFlight, Priority: input.Priority, DropSystemPrompt: input.DropSystemPrompt,
WorkBuddyAutoCheckin: input.WorkBuddyAutoCheckin,
WorkBuddyCheckinTime: input.WorkBuddyCheckinTime, ProxyURL: input.ProxyURL,
})
if err != nil {
writeErr(w, http.StatusBadRequest, "account_import_failed", err.Error())
return
}
if err := s.manager.Store().SaveCredentialPayload(r.Context(), account.ID, devin.CredentialFormat, encoded); err != nil {
_ = s.manager.Delete(r.Context(), account.ID)
writeErr(w, http.StatusBadRequest, "account_import_failed", err.Error())
return
}
if credential.UserID != "" && input.Enabled {
enabled := true
if err := s.manager.Update(r.Context(), account.ID, accounts.UpdateAccount{Enabled: &enabled}); err != nil {
writeErr(w, http.StatusBadRequest, "account_import_failed", err.Error())
return
}
}
imported, _ := s.manager.Store().Get(r.Context(), account.ID)
writeJSON(w, http.StatusCreated, imported)
default:
writeErr(w, http.StatusBadRequest, "unsupported_credential_format", "format must be qoder-native-v1, workbuddy-oauth-v1, or trae-oauth-v1")
writeErr(w, http.StatusBadRequest, "unsupported_credential_format", "format must be qoder-native-v1, workbuddy-oauth-v1, trae-oauth-v1, or devin-session-v1")
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {

func providerPrefix(model string) string {
model = strings.TrimSpace(model)
for _, prefix := range []string{"qoder/", "workbuddy/", "trae/"} {
for _, prefix := range []string{"qoder/", "workbuddy/", "trae/", "devin/"} {
if strings.HasPrefix(model, prefix) {
return strings.TrimSuffix(prefix, "/")
}
Expand Down
7 changes: 5 additions & 2 deletions internal/api/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ func TestProvidersEndpointExposesDescriptors(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Data) < 3 {
t.Fatalf("expected qoder + workbuddy + trae descriptors, got %+v", parsed.Data)
if len(parsed.Data) < 4 {
t.Fatalf("expected qoder + workbuddy + trae + devin descriptors, got %+v", parsed.Data)
}
seen := map[string]string{}
for _, item := range parsed.Data {
Expand All @@ -152,4 +152,7 @@ func TestProvidersEndpointExposesDescriptors(t *testing.T) {
if seen["trae"] != "in_process" {
t.Fatalf("trae runtime = %q in %+v", seen["trae"], parsed.Data)
}
if seen["devin"] != "in_process" {
t.Fatalf("devin runtime = %q in %+v", seen["devin"], parsed.Data)
}
}
2 changes: 2 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/caigee-cmd/cli2api/internal/executor"
applogs "github.com/caigee-cmd/cli2api/internal/logs"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/providers/devin"
"github.com/caigee-cmd/cli2api/internal/providers/trae"
"github.com/caigee-cmd/cli2api/internal/providers/workbuddy"
control "github.com/caigee-cmd/cli2api/internal/update"
Expand Down Expand Up @@ -111,6 +112,7 @@ func New(cfg config.Config) *Server {
workbuddyClient := workbuddy.NewClient(store)
providerReg.Register(workbuddyClient.Adapter())
providerReg.Register(trae.NewClient(store).Adapter())
providerReg.Register(devin.NewClient(store).Adapter())
manager.SetProviders(providerReg)
manager.SetWorkBuddy(workbuddyClient)
go manager.RefreshAll(context.Background(), false)
Expand Down
15 changes: 15 additions & 0 deletions internal/providers/devin/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Portions of this package are derived from router-for-me/CLIProxyAPI
(commit 8335eac731946bd4eff18f500653f93736df53d6), licensed under the MIT License.

Copyright (c) 2025-2005.9 Luis Pater
Copyright (c) 2025.9-present Router-For.ME

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the Software), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Loading
Loading