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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS

Go + Node gateway for personal Qoder, WorkBuddy, Trae CN Work, and experimental Devin accounts, with OpenAI / Anthropic-compatible endpoints.
Go + Node gateway for personal Qoder, WorkBuddy, Trae CN Work, and experimental Devin and Command Code accounts, with OpenAI / Anthropic-compatible endpoints.

## Docs

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Contributing

CLI2API keeps the Qoder execution path stable while supporting provider-specific
adapters for Qoder, WorkBuddy, Trae, and experimental Devin. Keep new provider work behind the shared
adapters for Qoder, WorkBuddy, Trae, and experimental Devin and Command Code. Keep new provider work behind the shared
account, routing, and protocol contracts. Read [AGENTS.md](AGENTS.md) for hard rules, [docs/ARCHITECTURE_SUMMARY.md](docs/ARCHITECTURE_SUMMARY.md) for the public backend map, and [docs/REFACTORING.md](docs/REFACTORING.md) for accepted package boundaries. Preserve existing uncommitted work;
before feature work, fetch and merge the latest `origin/main` into your branch.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

接入 **Qoder(国际版 / 国内版)**、**WorkBuddy(国际版 / 国内版)**、**Trae 国内版 Work**,以及实验性的 **Devin**。
接入 **Qoder(国际版 / 国内版)**、**WorkBuddy(国际版 / 国内版)**、**Trae 国内版 Work**,以及实验性的 **Devin** 和 **Command Code**。

用 Docker 部署,在 Web 控制台管理账号,再通过兼容 API 接入客户端。

Expand All @@ -24,7 +24,7 @@
- **统一控制台**:管理账号、模型、客户端密钥、代理设置,查看额度和请求日志。
- **Docker 运维**:持久化账号数据;可选安装宿主机更新器,在控制台下载更新、确认升级和回滚。

Qoder 国内版、WorkBuddy、Trae 的适配代码已实现,真账号验收仍未完成;Devin 为实验性接入,不承诺生产可用。托管更新的真实升级 / 回滚验收也尚未完成。
Qoder 国内版、WorkBuddy、Trae 的适配代码已实现,真账号验收仍未完成;Devin 与 Command Code 为实验性接入,不承诺生产可用。托管更新的真实升级 / 回滚验收也尚未完成。

## 快速开始

Expand Down Expand Up @@ -59,7 +59,7 @@ API Key: <在 API keys 页面创建的客户端密钥>
<img src="./docs/assets/readme/architecture-zh.svg" width="100%" alt="CLI2API 架构:OpenAI 客户端经 Go 控制面路由到每账号独立运行时,再连接各 provider 上游">
</p>

Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 Node 进程与 HOME;WorkBuddy、Trae、Devin 使用 Go 进程内适配器,不为每次请求启动完整 CLI。
Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 Node 进程与 HOME;WorkBuddy、Trae、Devin、Command Code 使用 Go 进程内适配器,不为每次请求启动完整 CLI。

## 控制台

Expand Down
6 changes: 3 additions & 3 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**

Connect **Qoder (Global / CN)**, **WorkBuddy (Global / CN)**, **Trae CN Work**, and experimental **Devin** accounts.
Connect **Qoder (Global / CN)**, **WorkBuddy (Global / CN)**, **Trae CN Work**, and experimental **Devin** and **Command Code** accounts.

Deploy with Docker, manage accounts in the web console, and connect clients through compatible APIs.

Expand All @@ -24,7 +24,7 @@ Deploy with Docker, manage accounts in the web console, and connect clients thro
- **Web console**: manage accounts, models, client keys, and proxies; view quotas and request logs.
- **Docker operations**: persistent account data; an optional host updater lets you download, confirm, and roll back updates from the console.

Qoder CN, WorkBuddy, and Trae adapters are implemented, but live-account acceptance is still pending. Devin is experimental, not production-ready. Live upgrade and rollback acceptance is also pending.
Qoder CN, WorkBuddy, and Trae adapters are implemented, but live-account acceptance is still pending. Devin and Command Code are experimental, not production-ready. Live upgrade and rollback acceptance is also pending.

## Quick start

Expand Down Expand Up @@ -59,7 +59,7 @@ Get a model ID from **Access** or `/v1/models`. Routing is automatic and prefers
<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>

The Go gateway handles authentication, routing, and request logging. Each Qoder account has its own Node process and HOME; WorkBuddy, Trae, and Devin use Go in-process adapters. No full CLI is started per request.
The Go gateway handles authentication, routing, and request logging. Each Qoder account has its own Node process and HOME; WorkBuddy, Trae, Devin, and Command Code use Go in-process adapters. No full CLI is started per request.

## Console

Expand Down
7 changes: 7 additions & 0 deletions changelog/unreleased/command-code-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Add an experimental **Command Code** (`commandcode.ai`) provider that speaks the CLI's own `/alpha/generate` protocol. That endpoint is not plan-gated, so **every plan works — including the $1 Go plan**, whose Pro-gated `/provider/v1/*` generation endpoints return `403 upgrade_required`. The adapter serves the whole model catalog. Auth is a single `user_…` key, pasted through the existing PAT tab; the model list is read from the anonymous `/provider/v1/models`, and usage is shown from `/alpha/billing/credits` (monthly plan window plus the rolling 5-hour and weekly windows). Streaming is rewritten from the upstream newline-delimited JSON into OpenAI SSE. Experimental: `/alpha/generate` is unpublished and version-coupled, so the pinned CLI version fails loudly rather than degrading silently, and the provider is not production-ready.

### 中文

- 新增实验性 **Command Code**(`commandcode.ai`)渠道,走 CLI 自身的 `/alpha/generate` 协议。该端点不受档位限制,**所有套餐均可用 —— 包括 $1 Go 套餐**(其 Pro 专属的 `/provider/v1/*` 生成端点会返回 `403 upgrade_required`)。适配层覆盖整个模型目录。认证只需一把 `user_…` 密钥,经现有 PAT 页签粘贴;模型列表读取匿名的 `/provider/v1/models`;用量从 `/alpha/billing/credits` 展示(月套餐窗口,以及滚动的 5 小时 / 周窗口)。流式响应由上游的换行分隔 JSON 改写为 OpenAI SSE。实验性:`/alpha/generate` 未公开且与 CLI 版本耦合,故钉版不匹配时明确报错而非静默降级,不承诺生产可用。
24 changes: 22 additions & 2 deletions 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-global': 'accountTypeWorkBuddyGlobal',
'trae-cn': 'accountTypeTraeCN',
'devin-global': 'accountTypeDevinGlobal',
'command-global': 'accountTypeCommandGlobal',
}

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

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
Expand Down Expand Up @@ -180,6 +182,9 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const typesReady = Boolean(activeOption) && !typesLoading
const showPatTab = activeOption?.descriptor.capabilities?.pat_login !== false
const showImportTab = activeOption?.descriptor.capabilities?.import_export !== false
// Command Code has no browser login (a pasted user_… key is the only auth),
// so the wizard opens on the PAT tab instead of the browser tab.
const hasBrowserLogin = activeOption?.descriptor.capabilities?.browser_login !== false
const showDropSystem = activeOption?.provider === 'workbuddy'
const showCallbackPaste = activeOption?.provider === 'trae' || activeOption?.provider === 'devin'
const busy = phase === 'busy' || phase === 'polling'
Expand All @@ -192,6 +197,14 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
if (tab === 'import' && !showImportTab) setTab('browser')
}, [showImportTab, showPatTab, tab])

useEffect(() => {
// Providers without a browser login (e.g. Command Code) must not land on an
// empty browser tab; send the operator to the paste-key tab.
if (hasBrowserLogin) return
if (showPatTab) setTab('pat')
else if (showImportTab) setTab('import')
}, [hasBrowserLogin, showImportTab, showPatTab])

function parsedMaxInFlight() {
if (!Number.isInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 32) return 4
return maxInFlight
Expand Down Expand Up @@ -357,6 +370,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
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 if (activeOption.descriptor.id === 'command') bundle.format = 'command-key-v1'
else bundle.format = 'qoder-native-v1'
}
try {
Expand Down Expand Up @@ -406,15 +420,21 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}))

const methodOptions = [
{ value: 'browser' as const, label: t('tabBrowser'), icon: <ShieldCheck size={16} /> },
activeOption?.descriptor.capabilities?.browser_login !== false
? { value: 'browser' as const, label: t('tabBrowser'), icon: <ShieldCheck size={16} /> }
: null,
showPatTab ? { value: 'pat' as const, label: t('tabPat'), icon: <Key size={16} /> } : null,
showImportTab ? { value: 'import' as const, label: t('tabImport'), icon: <FileCode size={16} /> } : null,
].filter(Boolean) as Array<{ value: TabKey; label: string; icon: React.ReactNode }>

const tabLead = tab === 'browser'
? t('wizardBrowserLead')
: tab === 'pat'
? t(activeOption?.region === 'cn' && activeOption?.provider === 'qoder' ? 'wizardPatLeadCN' : 'wizardPatLead')
? t(activeOption?.provider === 'command'
? 'wizardPatLeadCommand'
: activeOption?.region === 'cn' && activeOption?.provider === 'qoder'
? 'wizardPatLeadCN'
: 'wizardPatLead')
: t('wizardImportLead')

return (
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/CommandMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
type Props = {
size?: number
className?: string
}

export function CommandMark({ size = 16, className = '' }: Props) {
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-[22%] bg-[#0a84ff] 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"
>
C
</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 { CommandMark } from '@/components/CommandMark'
import { DevinMark } from '@/components/DevinMark'
import { QoderMark } from '@/components/QoderMark'
import { TraeMark } from '@/components/TraeMark'
Expand All @@ -21,5 +22,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) {
if (id === 'devin') {
return <DevinMark size={size} className={className} />
}
if (id === 'command') {
return <CommandMark size={size} className={className} />
}
return <QoderMark size={size} className={className} />
}
16 changes: 14 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const messages: Record<Lang, Dict> = {
routesHint: 'OpenAI / Anthropic-compatible routes',
clientConfig: 'Client configuration',
clientConfigHint: 'Use the same console key as Bearer token',
providerPrefixHint: 'Pin a provider with a qoder/, workbuddy/, or trae/ model prefix. A named API key can also limit which providers are listed and routed. Bare IDs follow the cross-provider pool setting on System.',
providerPrefixHint: 'Pin a provider with a qoder/, workbuddy/, trae/, devin/, or command/ model prefix. A named API key can also limit which providers are listed and routed. Bare IDs follow the cross-provider pool setting on System.',
authentication: 'Authentication',
curlExample: 'curl example',
quickTestHint: 'Send a real request through the selected account',
Expand Down Expand Up @@ -521,6 +521,9 @@ export const messages: Record<Lang, Dict> = {
quotaDaily: 'Daily quota',
quotaWeekly: 'Weekly quota',
quotaMonthly: 'Monthly quota',
quotaFiveHour: '5-Hour Limit',
quotaWeeklyLimit: 'Weekly Limit',
quotaMonthlyLimit: 'Monthly Limit',
quotaUsedPercent: '{n}% used',
quotaResetsSoon: 'Resets soon',
quotaResetsInMinutes: 'Resets in {n} minutes',
Expand Down Expand Up @@ -559,6 +562,8 @@ export const messages: Record<Lang, Dict> = {
accountTypeTraeCNHint: 'Trae China Solo via browser login or imported credential',
accountTypeDevinGlobal: 'Devin (experimental)',
accountTypeDevinGlobalHint: 'Experimental Devin Global via browser OAuth or session token import',
accountTypeCommandGlobal: 'Command Code',
accountTypeCommandGlobalHint: 'Command Code via a pasted user_… key. Speaks the CLI /alpha/generate protocol, so every plan works — including the $1 Go plan.',
addAccountDesc: 'Create an isolated account session. Each account keeps its own runtime home.',
tabBrowser: 'Browser login',
tabPat: 'PAT',
Expand Down Expand Up @@ -596,6 +601,7 @@ export const messages: Record<Lang, Dict> = {
wizardLoginTimeout: 'Timed out waiting for browser login. You can retry from the account card.',
wizardPatLead: 'Use a personal access token when the browser flow is inconvenient.',
wizardPatLeadCN: 'Create a PAT at qoder.com.cn/account/integrations, then paste it here.',
wizardPatLeadCommand: 'Paste your Command Code key (it looks like user_…, from commandcode.ai/settings).',
wizardPatPh: 'Paste personal access token',
wizardCreateAndLogin: 'Create and sign in',
wizardImportLead: 'Paste a credential bundle previously exported from this console.',
Expand Down Expand Up @@ -657,7 +663,7 @@ export const messages: Record<Lang, Dict> = {
routesHint: 'OpenAI / Anthropic 兼容端点',
clientConfig: '客户端配置',
clientConfigHint: '使用与控制台相同的密钥作为 Bearer Token',
providerPrefixHint: '用 qoder/、workbuddy/、trae/ 前缀指定供应商。客户端 API 密钥也可以限制可见和可调度的供应商。不带前缀时是否跨供应商,由系统设置里的模型池决定。',
providerPrefixHint: '用 qoder/、workbuddy/、trae/、devin/ 或 command/ 前缀指定供应商。客户端 API 密钥也可以限制可见和可调度的供应商。不带前缀时是否跨供应商,由系统设置里的模型池决定。',
authentication: '认证方式',
curlExample: 'curl 示例',
quickTestHint: '通过所选账号发送一条真实请求',
Expand Down Expand Up @@ -1165,6 +1171,9 @@ export const messages: Record<Lang, Dict> = {
quotaDaily: '日额度',
quotaWeekly: '周额度',
quotaMonthly: '月额度',
quotaFiveHour: '5 小时额度',
quotaWeeklyLimit: '周额度',
quotaMonthlyLimit: '月额度',
quotaUsedPercent: '{n}%',
quotaResetsSoon: '即将重置',
quotaResetsInMinutes: '{n} 分钟后重置',
Expand Down Expand Up @@ -1203,6 +1212,8 @@ export const messages: Record<Lang, Dict> = {
accountTypeTraeCNHint: 'Trae 国内 Solo:浏览器登录或导入凭证',
accountTypeDevinGlobal: 'Devin(实验性)',
accountTypeDevinGlobalHint: '实验性 Devin 国际版:浏览器 OAuth 或导入 session token',
accountTypeCommandGlobal: 'Command Code',
accountTypeCommandGlobalHint: '粘贴 user_… 密钥接入。走 CLI 的 /alpha/generate 协议,所有档位均可用(含 $1 Go 套餐)。',
addAccountDesc: '创建一个隔离会话并登录。每个账号使用独立的运行时目录。',
tabBrowser: '浏览器登录',
tabPat: 'PAT',
Expand Down Expand Up @@ -1240,6 +1251,7 @@ export const messages: Record<Lang, Dict> = {
wizardLoginTimeout: '等待浏览器登录超时,可在账号卡片重试。',
wizardPatLead: '浏览器流程不方便时,可用个人访问令牌登录。',
wizardPatLeadCN: '在 qoder.com.cn/account/integrations 创建 PAT,然后粘贴到这里。',
wizardPatLeadCommand: '粘贴你的 Command Code 密钥(形如 user_…,来自 commandcode.ai/settings)。',
wizardPatPh: '粘贴个人访问令牌',
wizardCreateAndLogin: '创建并登录',
wizardImportLead: '粘贴此前从本控制台导出的凭证包。',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export function quotaWindowLabel(window: AccountQuotaWindow, t: (key: string) =>
if (window.id === 'daily') return t('quotaDaily')
if (window.id === 'weekly') return t('quotaWeekly')
if (window.id === 'monthly') return t('quotaMonthly')
if (window.id === 'fiveHour') return t('quotaFiveHour')
if (window.id === 'weeklyLimit') return t('quotaWeeklyLimit')
if (window.id === 'monthlyLimit') return t('quotaMonthlyLimit')
return window.label || t('quota')
}

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 @@ -15,6 +15,10 @@ export function isDevinProvider(provider?: string) {
return String(provider || '').toLowerCase() === 'devin'
}

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

export function accountProviderFamilyLabel(
provider: string | undefined,
t: (key: string) => string,
Expand All @@ -23,6 +27,7 @@ export function accountProviderFamilyLabel(
if (isWorkBuddyProvider(providerID)) return 'WorkBuddy'
if (isTraeProvider(providerID)) return 'Trae'
if (isDevinProvider(providerID)) return 'Devin'
if (isCommandProvider(providerID)) return 'Command Code'
if (isQoderProvider(providerID)) return 'Qoder'
return provider || t('account')
}
Expand All @@ -43,6 +48,9 @@ export function accountProviderLabel(
if (isDevinProvider(providerID)) {
return t('accountTypeDevinGlobal')
}
if (isCommandProvider(providerID)) {
return t('accountTypeCommandGlobal')
}
if (isQoderProvider(providerID)) {
return regionID === 'cn' ? t('accountTypeQoderCN') : t('accountTypeQoderGlobal')
}
Expand Down
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
apigateway "github.com/caigee-cmd/cli2api/internal/gateway"
applogs "github.com/caigee-cmd/cli2api/internal/logs"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/providers/command"
"github.com/caigee-cmd/cli2api/internal/providers/devin"
"github.com/caigee-cmd/cli2api/internal/providers/qoder"
"github.com/caigee-cmd/cli2api/internal/providers/trae"
Expand Down Expand Up @@ -109,6 +110,7 @@ func New(cfg config.Config) *App {
providerReg.Register(workbuddyClient.Adapter())
providerReg.Register(trae.NewClient(store).Adapter())
providerReg.Register(devin.NewClient(store).Adapter())
providerReg.Register(command.NewClient(store).Adapter())
qoderClient := qoder.NewClient()
qoderClient.Bind(manager.AccountURL, manager.ProxyAPIKey)
providerReg.Register(qoderClient.Adapter())
Expand Down
2 changes: 1 addition & 1 deletion internal/app/architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func isConcreteProvider(imp string) bool {
rest := strings.TrimPrefix(imp, prefix)
root, _, _ := strings.Cut(rest, "/")
switch root {
case "qoder", "workbuddy", "trae", "devin":
case "qoder", "workbuddy", "trae", "devin", "command":
return true
default:
return false
Expand Down
2 changes: 1 addition & 1 deletion internal/console/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func splitModelSettingPath(raw, queryProvider string) (provider, modelKey string) {
raw = strings.TrimPrefix(raw, "/api/models/")
provider = strings.ToLower(strings.TrimSpace(queryProvider))
for _, prefix := range []string{"trae/", "workbuddy/", "qoder/"} {
for _, prefix := range []string{"trae/", "workbuddy/", "qoder/", "devin/", "command/"} {
if strings.HasPrefix(strings.ToLower(raw), prefix) {
provider = strings.TrimSuffix(prefix, "/")
raw = raw[len(prefix):]
Expand Down
Loading
Loading