Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

- **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 的运行时负责
- **按 provider 支持多种登录方式**:浏览器 Device Flow OAuth、PAT,以及适用 provider 的凭证导入/导出
- **Web 控制台**:账号、模型、接入、请求历史与运行时日志,明暗主题
Expand Down
1 change: 1 addition & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Long-lived account runtimes, multi-account scheduling. Deploy with Docker; that

- **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
- **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
Expand Down
5 changes: 5 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ RUN apt-get update \
WORKDIR /app
COPY --from=build /out/qoder-api-proxy /app/qoder-api-proxy
COPY --from=build /out/cli2api-updater /app/cli2api-updater
COPY worker/package.json worker/package-lock.json /app/worker/
RUN cd /app/worker && npm ci --omit=dev
COPY worker/src /app/worker
COPY worker/last-plain.sample.json /app/worker/last-plain.sample.json
RUN test -f /app/worker/daemon.mjs \
&& test -f /app/worker/compat.mjs \
&& test -d /app/worker/node_modules/undici
RUN mkdir -p /data && chmod 700 /data
ENV HOST=0.0.0.0 \
PORT=3010 \
Expand Down
1 change: 1 addition & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ the same conversation prefer the same account from the first user message
| `QODER_MAX_RETRY_ACCOUNTS` | `4` | Maximum accounts attempted for one request (1-64) |
| `QODER_SSE_DIAGNOSTIC_MODELS` | empty | Comma-separated Qoder model IDs for redacted SSE diagnostics in Runtime Logs; `*` enables all |
| `QODER_WORKER_BASE_PORT` | `32100` | Internal child-runtime port range |
| `QODER_PROXY_URL` | empty | Global outbound proxy; supports `http(s)://`, `direct`, or `none`. Global proxies must be HTTP(S); SOCKS5 is only accepted for WorkBuddy / Trae account-level proxies |
| `QODERCLI_JS` | image default | Pinned Qoder Global CLI bundle |
| `QODERCNCLI_JS` | image default | Pinned Qoder CN CLI bundle |
| `UPDATE_GITHUB_TOKEN` | empty | Optional GitHub token for release checks |
Expand Down
1 change: 1 addition & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ services:
QODER_RUNTIME_DIR: "/run/cli2api"
QODER_SSE_DIAGNOSTIC_MODELS: "${QODER_SSE_DIAGNOSTIC_MODELS:-}"
QODER_WORKER_BASE_PORT: "${QODER_WORKER_BASE_PORT:-32100}"
QODER_PROXY_URL: "${QODER_PROXY_URL:-}"
UPDATE_SOCKET_PATH: "/run/cli2api-updater/updater.sock"
UPDATE_AGENT_URL: "${UPDATE_AGENT_URL:-}"
UPDATE_AGENT_TOKEN: "${UPDATE_AGENT_TOKEN:-}"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function createAccount(
drop_system_prompt?: boolean
workbuddy_auto_checkin?: boolean
workbuddy_checkin_time?: string
proxy_url?: string
},
) {
return api('/api/accounts', {
Expand All @@ -205,6 +206,7 @@ export function createAccount(
drop_system_prompt: options?.drop_system_prompt,
workbuddy_auto_checkin: options?.workbuddy_auto_checkin,
workbuddy_checkin_time: options?.workbuddy_checkin_time,
proxy_url: options?.proxy_url,
}),
})
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type SystemUpdateInfo = {

export type SystemSettings = {
cross_provider_model_pool: boolean
proxy_url: string
routing_strategy: 'round-robin' | 'weighted-round-robin' | 'fill-first'
session_affinity?: {
ttl_seconds?: number
Expand Down Expand Up @@ -94,7 +95,7 @@ export function fetchSystemSettings() {
return api<SystemSettings>('/api/system/settings')
}

export function updateSystemSettings(input: { cross_provider_model_pool?: boolean; routing_strategy?: SystemSettings['routing_strategy'] }) {
export function updateSystemSettings(input: { cross_provider_model_pool?: boolean; routing_strategy?: SystemSettings['routing_strategy']; proxy_url?: string }) {
return api<SystemSettings>('/api/system/settings', {
method: 'PATCH',
body: JSON.stringify(input),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export type Overview = {
drop_system_prompt?: boolean
workbuddy_auto_checkin?: boolean
workbuddy_checkin_time?: string
proxy_url?: string
status?: string
cooldown_until?: string | null
url?: string
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const [dropSystemPrompt, setDropSystemPrompt] = useState(true)
const [autoCheckin, setAutoCheckin] = useState(false)
const [autoCheckinTime, setAutoCheckinTime] = useState('09:00')
const [proxyUrl, setProxyUrl] = useState('')
const [pat, setPat] = useState('')
const [json, setJson] = useState('')
const [phase, setPhase] = useState<Phase>('idle')
Expand Down Expand Up @@ -210,6 +211,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
drop_system_prompt: showDropSystem ? dropSystemPrompt : true,
workbuddy_auto_checkin: showAutoCheckin ? autoCheckin : false,
workbuddy_checkin_time: showAutoCheckin ? autoCheckinTime : undefined,
proxy_url: proxyUrl.trim(),
}
}

Expand All @@ -226,6 +228,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setDropSystemPrompt(true)
setAutoCheckin(false)
setAutoCheckinTime('09:00')
setProxyUrl('')
setPat('')
setJson('')
setAdvancedOpen(false)
Expand Down Expand Up @@ -553,6 +556,17 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
<p className="text-[11px] leading-4 text-muted">{t('priorityHint')}</p>
</label>
</div>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted">{t('proxyUrl')}</span>
<Input
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
placeholder={t('proxyUrlPlaceholder')}
aria-label={t('proxyUrl')}
disabled={settingsLocked}
/>
<p className="text-[11px] leading-4 text-muted">{t('proxyUrlHint')}</p>
</label>
{showDropSystem ? (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/account/EditAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ type Props = {
busy: boolean
t: Translate
onClose: () => void
onSave: (input: { name: string; max_inflight: number; priority: number }) => Promise<void>
onSave: (input: { name: string; max_inflight: number; priority: number; proxy_url: string }) => Promise<void>
}

export function EditAccountModal({ account, busy, t, onClose, onSave }: Props) {
const [name, setName] = useState(account?.name || '')
const [maxInFlight, setMaxInFlight] = useState<number>(account?.max_inflight ?? 4)
const [priority, setPriority] = useState<number>(account?.priority ?? 50)
const [proxyUrl, setProxyUrl] = useState(account?.proxy_url || '')
const [error, setError] = useState('')
const title = t('editAccountTitle', { name: account?.name || account?.id || '' })
const provider = account ? accountProviderLabel(account.provider, account.region, t) : ''
Expand All @@ -40,7 +41,7 @@ export function EditAccountModal({ account, busy, t, onClose, onSave }: Props) {
}
setError('')
try {
await onSave({ name: trimmed, max_inflight: maxInFlight, priority })
await onSave({ name: trimmed, max_inflight: maxInFlight, priority, proxy_url: proxyUrl.trim() })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
Expand Down Expand Up @@ -127,6 +128,17 @@ export function EditAccountModal({ account, busy, t, onClose, onSave }: Props) {
<Description className="text-xs leading-5 text-muted">{t('priorityHint')}</Description>
</NumberField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium text-muted">{t('proxyUrl')}</Label>
<Input
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
placeholder={t('proxyUrlPlaceholder')}
aria-label={t('proxyUrl')}
disabled={busy}
/>
<Description className="text-xs leading-5 text-muted">{t('proxyUrlHint')}</Description>
</div>
</Form>
</Modal.Body>
<Modal.Footer className="justify-end gap-2 px-6 pb-6">
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,11 @@ export const messages: Record<Lang, Dict> = {
wizardNamePh: 'Account name',
maxInflightHint: 'How many chats this account may run at once. Changing it later restarts the worker.',
priorityHint: 'Higher numbers are preferred when routing later uses this field. Default 50.',
proxyUrl: 'Proxy URL',
proxyUrlPlaceholder: 'http://127.0.0.1:7890, direct',
proxyUrlHint: 'Blank inherits the global proxy. Use direct or none to bypass proxies for this account. Qoder supports HTTP(S) only; WorkBuddy/Trae also support SOCKS5.',
proxySettingsTitle: 'Outbound proxy',
proxySettingsHint: 'Set the default proxy for upstream requests. Global proxies support HTTP(S) only. SOCKS5 is available for WorkBuddy/Trae account-level proxies. Account-level settings override it.',
dropSystemPromptCreateHint: 'WorkBuddy only. Strip caller system prompts, then send an empty leading system slot so Global still accepts the request.',
wizardBrowserLead: 'Opens the authorization page. Finish in the browser and this wizard continues automatically.',
wizardStartBrowser: 'Start browser login',
Expand Down Expand Up @@ -1159,6 +1164,11 @@ export const messages: Record<Lang, Dict> = {
wizardNamePh: '账号名称',
maxInflightHint: '这个账号同时能跑多少条对话。之后改并发会重启该账号的 worker。',
priorityHint: '数字越大,之后调度会越优先选这个账号。默认 50。',
proxyUrl: '代理地址',
proxyUrlPlaceholder: 'http://127.0.0.1:7890、direct',
proxyUrlHint: '留空继承全局代理;填写 direct 或 none 可让此账号直连。Qoder 仅支持 HTTP(S),WorkBuddy/Trae 额外支持 SOCKS5。',
proxySettingsTitle: '统一代理出口',
proxySettingsHint: '设置所有上游请求的默认代理;全局代理支持 HTTP(S),SOCKS5 仅支持 WorkBuddy/Trae 的账号级代理;账号级代理优先于全局代理。',
dropSystemPromptCreateHint: '仅 WorkBuddy。剥离调用方系统提示词后仍补一条空的 system,避免国际版拒绝请求。',
wizardBrowserLead: '会打开授权页。在浏览器完成授权后,向导会自动继续。',
wizardStartBrowser: '开始浏览器登录',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export function AccountsPage() {
}
}

async function onSaveSettings(id: string, input: { name: string; max_inflight: number; priority: number }) {
async function onSaveSettings(id: string, input: { name: string; max_inflight: number; priority: number; proxy_url: string }) {
if (!id) throw new Error(t('accountNameRequired'))
setNameById((current) => ({ ...current, [id]: input.name }))
setInflightById((current) => ({ ...current, [id]: input.max_inflight }))
Expand Down
54 changes: 52 additions & 2 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Chip, Description, Label, ListBox, Modal, Select } from '@heroui/react'
import { Button, Card, Chip, Description, Input, Label, ListBox, Modal, Select } from '@heroui/react'
import {
ArrowClockwise,
ArrowCircleUp,
Expand Down Expand Up @@ -33,6 +33,7 @@ export function SystemPage() {
const [info, setInfo] = useState<SystemUpdateInfo | null>(null)
const [consoleKey, setConsoleKey] = useState<ConsoleKeyView | null>(null)
const [settings, setSettings] = useState<SystemSettings | null>(null)
const [proxyDraft, setProxyDraft] = useState('')
const [settingsBusy, setSettingsBusy] = useState(false)
const [consoleBusy, setConsoleBusy] = useState(false)
const [rotateOpen, setRotateOpen] = useState(false)
Expand Down Expand Up @@ -72,7 +73,10 @@ export function SystemPage() {
const timer = window.setTimeout(() => {
void load(false)
void fetchConsoleKey().then(setConsoleKey).catch(() => undefined)
void fetchSystemSettings().then(setSettings).catch((err) => setError(err instanceof Error ? err.message : String(err)))
void fetchSystemSettings().then((result) => {
setSettings(result)
setProxyDraft(result.proxy_url || '')
}).catch((err) => setError(err instanceof Error ? err.message : String(err)))
}, 0)
return () => window.clearTimeout(timer)
}, [load])
Expand Down Expand Up @@ -225,6 +229,31 @@ export function SystemPage() {
}
}

async function updateProxyURL(value: string) {
const saved = settings?.proxy_url || ''
// Nothing changed in the field: keep the draft as-is and skip the PATCH so
// a no-op blur never reloads workers.
if (value === saved) {
setProxyDraft(saved)
return
}
const previousDraft = proxyDraft
setProxyDraft(value)
setSettingsBusy(true)
setError('')
try {
const updated = await updateSystemSettings({ proxy_url: value })
setSettings(updated)
setProxyDraft(updated.proxy_url || '')
} catch (err) {
setProxyDraft(previousDraft)
setSettings((current) => current ? { ...current, proxy_url: saved } : current)
setError(err instanceof Error ? err.message : String(err))
} finally {
setSettingsBusy(false)
}
}

async function updateRoutingStrategy(strategy: SystemSettings['routing_strategy']) {
const previous = settings?.routing_strategy || 'round-robin'
setSettings((current) => current ? { ...current, routing_strategy: strategy } : current)
Expand Down Expand Up @@ -339,6 +368,27 @@ export function SystemPage() {
</div>
</Card>

<Card data-gsap-reveal>
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-secondary text-foreground"><SlidersHorizontal size={15} /></div>
<div>
<h3 className="font-semibold">{t('proxySettingsTitle')}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t('proxySettingsHint')}</p>
</div>
</div>
<div className="mt-4 space-y-1.5">
<Label className="text-sm font-medium text-muted">{t('proxyUrl')}</Label>
<Input
value={proxyDraft}
onChange={(event) => setProxyDraft(event.target.value)}
onBlur={(event) => void updateProxyURL(event.target.value.trim())}
placeholder={t('proxyUrlPlaceholder')}
disabled={settingsBusy || !settings}
/>
<Description className="text-xs leading-5 text-muted">{t('proxyUrlHint')}</Description>
</div>
</Card>

<Card data-gsap-reveal>
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-secondary text-foreground"><SlidersHorizontal size={15} /></div>
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ go 1.25.6

require github.com/joho/godotenv v1.5.1

require (
golang.org/x/net v0.56.0
modernc.org/sqlite v1.57.0
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand All @@ -14,5 +19,4 @@ require (
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.57.0 // indirect
)
Loading