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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ web/

deploy/docker-compose.override.yml
.zcode/
.learnings/
11 changes: 11 additions & 0 deletions changelog/unreleased/qwenwork-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- Add the QwenWork provider (`qwenwork`), an in-process adapter for the QwenWork desktop product. Two regions under one provider: `global` (qwenwork.ai, default) and `cn` (千问办公, qwenwork.cn). Browser login uses the single-use device authorization link; poll and token refresh run in-process, with refresh-token rotation stored automatically. Chat goes through the COSY-signed `agent_chat_generation` endpoint and the wrapped upstream SSE is relayed as OpenAI chunks, so `/v1/chat/completions`, `/v1/messages`, and `/v1/responses` all work, streaming and non-streaming, with multi-turn history and tool calls. The model catalog (`qwork-advanced`, `qwork-auto`, …) is fetched live from the upstream `qwork` list, and quota comes from the account-context API. Model-level errors ("Model is not available for this user") cool only that model; quota code 14018 maps to account quota. Credentials can also be imported from a qwenwork2api account export (`accessToken`/`refreshToken`). The CN region follows the desktop bundle configuration but has not yet been verified with a real CN account; international accounts are verified end-to-end.
- Console: new QwenWork International / 千问办公 国内版 account types in the add-account wizard. The authorization link is single-use: open it directly in a browser already signed in to the target account, and do not send it through chat tools. Models are addressable with the `qwenwork/` prefix (for example `qwenwork/qwork-advanced`).
- Protocol behavior was cross-checked against nostalgia296/qwenwork2api-makers (no license declared); the Go implementation is independent and copies no code.

### 中文

- 新增 QwenWork 供应商(`qwenwork`):QwenWork(千问办公)桌面产品的进程内适配器。同一供应商下两个 region:`global`(qwenwork.ai,默认)与 `cn`(qwenwork.cn)。浏览器登录使用一次性的设备授权链接;轮询与令牌刷新都在进程内完成,refresh_token 轮换后自动保存。对话走 COSY 签名的 `agent_chat_generation` 端点,上游包装 SSE 转换为 OpenAI 分块转发,因此 `/v1/chat/completions`、`/v1/messages`、`/v1/responses` 流式与非流式均可用,支持多轮历史与工具调用。模型目录(`qwork-advanced`、`qwork-auto` 等)实时取自上游 `qwork` 列表,额度来自 account-context 接口。模型级错误("Model is not available for this user")只冷却该模型;额度错误码 14018 映射为账号额度。也支持从 qwenwork2api 的账号导出(`accessToken`/`refreshToken`)导入凭证。国内版按桌面端配置实现,但尚未用真实国内版账号验收;国际版已端到端验证。
- 控制台:添加账号向导新增 "QwenWork 国际版" 与 "千问办公 国内版" 两种类型。授权链接只能使用一次:请在已登录目标账号的浏览器窗口里直接打开,不要经聊天工具发送。模型可用 `qwenwork/` 前缀指定(例如 `qwenwork/qwork-advanced`)。
- 协议行为参考并核对了 nostalgia296/qwenwork2api-makers(未声明许可证);Go 实现为独立编写,未复制其代码。
5 changes: 5 additions & 0 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const labelKeys: Record<string, string> = {
'command-global': 'accountTypeCommandGlobal',
'codex-global': 'accountTypeCodexGlobal',
'zhipu-cn': 'accountTypeZhipuCN',
'qwenwork-global': 'accountTypeQwenworkGlobal',
'qwenwork-cn': 'accountTypeQwenworkCN',
}

const hintKeys: Record<string, string> = {
Expand All @@ -68,6 +70,8 @@ const hintKeys: Record<string, string> = {
'command-global': 'accountTypeCommandGlobalHint',
'codex-global': 'accountTypeCodexGlobalHint',
'zhipu-cn': 'accountTypeZhipuCNHint',
'qwenwork-global': 'accountTypeQwenworkGlobalHint',
'qwenwork-cn': 'accountTypeQwenworkCNHint',
}

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
Expand Down Expand Up @@ -439,6 +443,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
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 if (activeOption.descriptor.id === 'qwenwork') bundle.format = 'qwenwork-oauth-v1'
else bundle.format = 'qoder-native-v1'
}
try {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/ProviderMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CommandMark } from '@/components/CommandMark'
import { CodexMark } from '@/components/CodexMark'
import { DevinMark } from '@/components/DevinMark'
import { QoderMark } from '@/components/QoderMark'
import { QwenworkMark } from '@/components/QwenworkMark'
import { TraeMark } from '@/components/TraeMark'
import { WorkBuddyMark } from '@/components/WorkBuddyMark'
import { ZhipuMark } from '@/components/ZhipuMark'
Expand Down Expand Up @@ -33,5 +34,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) {
if (id === 'zhipu') {
return <ZhipuMark size={size} className={className} />
}
if (id === 'qwenwork') {
return <QwenworkMark size={size} className={className} />
}
return <QoderMark size={size} className={className} />
}
29 changes: 29 additions & 0 deletions frontend/src/components/QwenworkMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type Props = {
size?: number
className?: string
}

// QwenWork mark: an ink tile, a Q glyph, and a Signal Cyan terminal dot —
// a simple geometric placeholder, deliberately not the official trademark.
export function QwenworkMark({ size = 16, className = '' }: Props) {
const dot = Math.max(2, Math.round(size * 0.14))
return (
<span
className={`inline-flex shrink-0 items-baseline justify-center rounded-[22%] bg-[#18181B] text-white ${className}`.trim()}
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.5)), fontWeight: 650, lineHeight: 1 }}
aria-hidden="true"
>
<span style={{ alignSelf: 'center' }}>Q</span>
<span
style={{
alignSelf: 'center',
width: dot,
height: dot,
marginLeft: Math.max(0, Math.round(size * 0.03)),
borderRadius: '50%',
background: '#22D3EE',
}}
/>
</span>
)
}
12 changes: 10 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/, trae/, devin/, command/, or zhipu/ 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/, command/, zhipu/, or qwenwork/ 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 @@ -582,6 +582,10 @@ export const messages: Record<Lang, Dict> = {
accountTypeCodexGlobalHint: 'OpenAI Codex via Codex CLI OAuth, Sign in with ChatGPT, or an imported credential',
accountTypeZhipuCN: 'Zhipu GLM',
accountTypeZhipuCNHint: 'Zhipu GLM with an API key. Choose pay-as-you-go or Coding Plan, the same way the upstream account is billed.',
accountTypeQwenworkGlobal: 'QwenWork International',
accountTypeQwenworkGlobalHint: 'QwenWork international (qwenwork.ai) via browser OAuth or imported credential. The authorization link is single-use: open it directly in a browser already signed in to the target account, and do not send it through chat tools.',
accountTypeQwenworkCN: 'QwenWork CN',
accountTypeQwenworkCNHint: '千问办公 China (qwenwork.cn) via browser OAuth or imported credential. The authorization link is single-use: open it directly in a browser already signed in to the target account, and do not send it through chat tools. The CN region has not yet been verified with a real account.',
addAccountDesc: 'Create an isolated account session. Each account keeps its own runtime home.',
tabBrowser: 'Browser login',
tabPat: 'PAT',
Expand Down Expand Up @@ -712,7 +716,7 @@ export const messages: Record<Lang, Dict> = {
routesHint: 'OpenAI / Anthropic 兼容端点',
clientConfig: '客户端配置',
clientConfigHint: '使用与控制台相同的密钥作为 Bearer Token',
providerPrefixHint: '用 qoder/、workbuddy/、trae/、devin/、command/ 或 zhipu/ 前缀指定供应商。客户端 API 密钥也可以限制可见和可调度的供应商。不带前缀时是否跨供应商,由系统设置里的模型池决定。',
providerPrefixHint: '用 qoder/、workbuddy/、trae/、devin/、command/、zhipu/ 或 qwenwork/ 前缀指定供应商。客户端 API 密钥也可以限制可见和可调度的供应商。不带前缀时是否跨供应商,由系统设置里的模型池决定。',
authentication: '认证方式',
curlExample: 'curl 示例',
quickTestHint: '通过所选账号发送一条真实请求',
Expand Down Expand Up @@ -1281,6 +1285,10 @@ export const messages: Record<Lang, Dict> = {
accountTypeCodexGlobalHint: 'OpenAI Codex:Codex CLI OAuth、Sign in with ChatGPT,或导入凭证',
accountTypeZhipuCN: '智谱 GLM',
accountTypeZhipuCNHint: '填写智谱 API Key。按量付费或 Coding Plan 与上游账号的计费方式一致。',
accountTypeQwenworkGlobal: 'QwenWork 国际版',
accountTypeQwenworkGlobalHint: 'QwenWork 国际版(qwenwork.ai),浏览器授权或导入凭证。授权链接只能使用一次:请在已登录目标账号的浏览器窗口里直接打开,不要经聊天工具发送。',
accountTypeQwenworkCN: '千问办公 国内版',
accountTypeQwenworkCNHint: '千问办公国内版(qwenwork.cn),浏览器授权或导入凭证。授权链接只能使用一次:请在已登录目标账号的浏览器窗口里直接打开,不要经聊天工具发送。国内版尚未经真实账号验收。',
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 @@ -23,6 +23,10 @@ export function isZhipuProvider(provider?: string) {
return String(provider || '').toLowerCase() === 'zhipu'
}

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

export function accountProviderFamilyLabel(
provider: string | undefined,
t: (key: string) => string,
Expand All @@ -33,6 +37,7 @@ export function accountProviderFamilyLabel(
if (isDevinProvider(providerID)) return 'Devin'
if (isCommandProvider(providerID)) return 'Command Code'
if (isZhipuProvider(providerID)) return 'Zhipu GLM'
if (isQwenworkProvider(providerID)) return 'QwenWork'
if (isQoderProvider(providerID)) return 'Qoder'
return provider || t('account')
}
Expand All @@ -59,6 +64,9 @@ export function accountProviderLabel(
if (isZhipuProvider(providerID)) {
return t('accountTypeZhipuCN')
}
if (isQwenworkProvider(providerID)) {
return regionID === 'cn' ? t('accountTypeQwenworkCN') : t('accountTypeQwenworkGlobal')
}
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 @@ -26,6 +26,7 @@ import (
"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/qwenwork"
"github.com/caigee-cmd/cli2api/internal/providers/trae"
"github.com/caigee-cmd/cli2api/internal/providers/workbuddy"
"github.com/caigee-cmd/cli2api/internal/providers/zhipu"
Expand Down Expand Up @@ -118,6 +119,7 @@ func New(cfg config.Config) *App {
providerReg.Register(command.NewClient(store).Adapter())
providerReg.Register(codex.NewClient(store).Adapter())
providerReg.Register(zhipu.NewClient(store).Adapter())
providerReg.Register(qwenwork.NewClient(store).Adapter())
qoderClient := qoder.NewClient()
qoderClient.Bind(manager.AccountURL, manager.ProxyAPIKey)
qoderClient.SetDirect(qoder.NewDirect(store, func(ctx context.Context, accountID string) (string, error) {
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 @@ -173,7 +173,7 @@ func isConcreteProvider(imp string) bool {
rest := strings.TrimPrefix(imp, prefix)
root, _, _ := strings.Cut(rest, "/")
switch root {
case "qoder", "workbuddy", "trae", "devin", "command", "codex":
case "qoder", "workbuddy", "trae", "devin", "command", "codex", "zhipu", "qwenwork":
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/", "devin/", "command/", "codex/", "zhipu/"} {
for _, prefix := range []string{"trae/", "workbuddy/", "qoder/", "devin/", "command/", "codex/", "zhipu/", "qwenwork/"} {
if strings.HasPrefix(strings.ToLower(raw), prefix) {
provider = strings.TrimSuffix(prefix, "/")
raw = raw[len(prefix):]
Expand Down
2 changes: 1 addition & 1 deletion internal/executor/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) {
// "<provider>/" model id. Command Code's own model ids are org-namespaced
// ("deepseek/…", "moonshotai/…"), so its provider prefix sits in front of an
// already-namespaced id ("command/deepseek/…").
var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/"}
var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/", "qwenwork/"}

// regionDashSuffixes are the region qualifiers that may be appended to a
// provider prefix with a dash: "workbuddy-cn/", "workbuddy-gl/",
Expand Down
175 changes: 175 additions & 0 deletions internal/providers/qwenwork/classify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package qwenwork

import (
"encoding/json"
"net/http"
"strconv"
"strings"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
)

// quotaCode is the account-level quota error the QwenWork gateway embeds in
// error frames.
const quotaCode = "14018"

// Classify maps a QwenWork HTTP status and error body onto the internal
// taxonomy. Text checks run before plain status checks because the gateway
// reports model and quota failures inside 400-class frames: "Model is not
// available for this user" and quota code 14018 must not be folded into
// invalid_request. Model-level misses stay model_not_available so the
// executor cools the model, never the whole account.
func Classify(status int, body string) providers.ClassifiedError {
text := strings.TrimSpace(body)
message, code := errorTextAndCode(text)
kind := classifyKind(status, message, code)
if kind == "" {
return providers.ClassifiedError{}
}
out := providers.ClassifiedError{Kind: kind, Status: status, Message: message}
switch kind {
case accounts.KindModelNotAvailable:
out.Status = firstStatus(status, http.StatusBadRequest)
case accounts.KindQuota:
out.Status = http.StatusPaymentRequired
case accounts.KindAuth:
if out.Status != http.StatusUnauthorized && out.Status != http.StatusForbidden {
out.Status = http.StatusUnauthorized
}
case accounts.KindRateLimit:
out.Status = http.StatusTooManyRequests
case accounts.KindInvalidRequest, accounts.KindUnavailable:
out.Status = firstStatus(status, http.StatusBadGateway)
}
if out.Message == "" {
out.Message = "qwenwork upstream status " + strconv.Itoa(out.Status)
}
return out
}

// classifyKind picks the taxonomy kind. The known error texts and body-level
// codes win over the frame status they ride in: "Model is not available for
// this user" arrives in a 400-class frame and must not fold into
// invalid_request.
func classifyKind(status int, message, code string) string {
lower := strings.ToLower(message + " " + code)
if strings.Contains(lower, "model is not available") {
return accounts.KindModelNotAvailable
}
// Body-level 4xx codes: the desktop error convention sends
// {"code":"401","message":"…"} even inside 200-class frames.
switch code {
case "401", "403":
return accounts.KindAuth
case "402":
return accounts.KindQuota
case "429":
return accounts.KindRateLimit
}
switch {
case status == http.StatusPaymentRequired || code == quotaCode || quotaLike(lower):
return accounts.KindQuota
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return accounts.KindAuth
case status == http.StatusTooManyRequests:
return accounts.KindRateLimit
case status == http.StatusBadRequest || status == http.StatusRequestEntityTooLarge || status == http.StatusUnprocessableEntity:
return accounts.KindInvalidRequest
case status >= 500 || status == http.StatusNotFound:
return accounts.KindUnavailable
}
return ""
}

func quotaLike(lower string) bool {
return strings.Contains(lower, "quota") ||
strings.Contains(lower, "insufficient credit") ||
strings.Contains(lower, "积分不足") ||
strings.Contains(lower, "额度")
}

// errorTextAndCode extracts the human message and machine code from a
// QwenWork error body. Both the flat gateway shape ({"code":14018,"message":
// "…"}) and the OpenAI error shape ({"error":{"message":"…"}}) are accepted;
// anything else keeps the trimmed body as the message.
func errorTextAndCode(body string) (message, code string) {
var flat struct {
Code any `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
if json.Unmarshal([]byte(body), &flat) == nil {
code = scalarCode(flat.Code)
message = strings.TrimSpace(flat.Message)
if message == "" {
message = strings.TrimSpace(flat.Msg)
}
}
if message == "" {
var openAI struct {
Error struct {
Message string `json:"message"`
Code any `json:"code"`
} `json:"error"`
}
if json.Unmarshal([]byte(body), &openAI) == nil && openAI.Error.Message != "" {
message = strings.TrimSpace(openAI.Error.Message)
if code == "" {
code = scalarCode(openAI.Error.Code)
}
}
}
if message == "" {
message = body
}
return message, code
}

// scalarCode renders a JSON code value (number or string) as text.
func scalarCode(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
}
return ""
}

func firstStatus(status, fallback int) int {
if status >= 400 {
return status
}
return fallback
}

// classifiedError wraps Classify for the HTTP error path. Bodies that carry
// no known marker still fail over as unavailable instead of passing a raw
// upstream page to the client.
func classifiedError(status int, body []byte) error {
classified := Classify(status, string(body))
if classified.Kind == "" {
classified = providers.ClassifiedError{
Kind: accounts.KindUnavailable,
Status: firstStatus(status, http.StatusBadGateway),
Message: truncateBody(body),
}
}
return &providers.Error{Kind: classified.Kind, Status: classified.Status, Message: classified.Message}
}

// truncateBody keeps error bodies bounded and never empty.
func truncateBody(body []byte) string {
text := strings.TrimSpace(string(body))
if text == "" {
return ""
}
if len(text) > 240 {
return text[:240]
}
return text
}
Loading
Loading