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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Distinguish hard quota exhaustion from prompt token limits, and cool exhausted accounts until the next local midnight
- Keep explicitly identified conversations on one account with bounded in-memory session affinity, same-provider/region escape, and routing-source request logs
- Add stateless Anthropic Messages and OpenAI Responses adapters for text and function-tool conversations

### 中文

- 区分账号额度耗尽和请求 token 超限,真正额度耗尽的账号冷却到本地次日凌晨
- 增加基于显式会话标识的有界内存会话粘性路由、同提供方同区域逃逸,以及路由来源请求日志
- 新增面向文本与函数工具对话的无状态 Anthropic Messages 与 OpenAI Responses 适配层

## 0.2.41 - 2026-09-03

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

## 功能

- **OpenAI 兼容代理**:`/v1/chat/completions`、`/v1/models`,流式/非流式、工具调用、`reasoning_content`
- **OpenAI / Anthropic 兼容代理**:`/v1/chat/completions`、`/v1/responses`、`/v1/messages`、`/v1/models`;支持流式/非流式、文本、图片与函数工具调用;文件输入会明确拒绝。`messages` / `responses` 当前为无状态适配层,不支持服务端会话或上游专属工具。
- **多渠道账号池**:Qoder 国际版 / 国内版、WorkBuddy 国际版 / 国内版、Trae 国内版 Solo;地域隔离、账号固定、并发限制、冷却与同族故障切换
- **常驻热 worker**:每账号独立 Node 进程与运行目录,鉴权、WASM 编码和云端 SSE 连接保持热;预热后小对话延迟约 1-2 秒,按请求拉起 CLI 的方案通常要 10 秒以上
- **多种登录方式**:浏览器 Device Flow OAuth、PAT、`qoder-native-v1` 凭证导入/导出
Expand Down Expand Up @@ -50,7 +50,7 @@ Base URL: http://127.0.0.1:3010/v1
API Key: <首次启动时生成的 Key>
```

不指定账号时,调度器自动选择可用账号;需要固定账号时加请求头 `X-Qoder-Account: acc_...`。curl / PowerShell 示例见 [部署说明](deploy/README.md)。
不指定账号时,调度器自动选择可用账号;需要固定账号时加请求头 `X-Qoder-Account: acc_...`。除 Chat Completions 外,也可使用 Anthropic `POST /v1/messages` 与 OpenAI `POST /v1/responses`;两者要求请求携带完整对话,不支持 `previous_response_id` / `conversation` 服务端续接。curl / PowerShell 示例见 [部署说明](deploy/README.md)。

## 工作方式

Expand Down Expand Up @@ -82,10 +82,9 @@ CLI2API 是本地网关:不提供账号、额度或官方 API 服务,不做
**进行中**

- Qoder 国内版与 WorkBuddy 的真账号验收(登录、故障切换、混合账号池)
- Phase I:Anthropic `/v1/messages` 入口适配层与统一会话契约

**已支持**

- Anthropic `/v1/messages` 与 OpenAI `/v1/responses` 的无状态文本 / 函数工具适配层
- WorkBuddy 每日签到与 token 保活(账号级开关,默认关闭;控制台可立即签到 / 刷新积分)

**规划中**
Expand Down
7 changes: 3 additions & 4 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Long-lived workers, multi-account scheduling, one-command Docker start.

## Features

- **OpenAI-compatible proxy**: `/v1/chat/completions`, `/v1/models` — streaming/non-streaming, tool calls, `reasoning_content`
- **OpenAI / Anthropic-compatible proxy**: `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models` — streaming/non-streaming text, images, and function tools; 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
- **Long-lived warm workers**: one isolated Node process and runtime directory per account keeps authentication, WASM encoding, and cloud SSE connections warm. Typical small-chat latency is ~1-2s after warmup, versus ~10s+ for spawn-per-request wrappers
- **Multiple login methods**: browser Device Flow OAuth, PAT, and `qoder-native-v1` credential import/export
Expand Down Expand Up @@ -50,7 +50,7 @@ Base URL: http://127.0.0.1:3010/v1
API Key: <the key printed on first startup>
```

Without an account header the scheduler picks a ready account; pin a request with the `X-Qoder-Account: acc_...` header. curl / PowerShell examples in the [deployment guide](deploy/README.md).
Without an account header the scheduler picks a ready account; pin a request with the `X-Qoder-Account: acc_...` header. Anthropic `POST /v1/messages` and OpenAI `POST /v1/responses` are also available; both require the complete conversation in each request and do not support server-side continuation through `previous_response_id` / `conversation`. curl / PowerShell examples in the [deployment guide](deploy/README.md).

## How it works

Expand Down Expand Up @@ -82,10 +82,9 @@ CLI2API is a local gateway: it does not provide accounts, quotas, or an official
**In progress**

- Live-account acceptance for Qoder CN and WorkBuddy (login, failover, mixed account pools)
- Phase I: Anthropic `/v1/messages` ingress adapter and the canonical conversation contract

**Supported**

- Stateless text, image, and function-tool adapters for Anthropic `/v1/messages` and OpenAI `/v1/responses`
- WorkBuddy daily check-in and token keepalive (per-account opt-in, off by default; console can check in now / refresh credits)

**Planned**
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type RequestLog = {
mapped_model?: string
account_id?: string
provider?: string
routing?: string
prompt_tokens?: number | null
completion_tokens?: number | null
cache_read_tokens?: number | null
Expand Down
90 changes: 43 additions & 47 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import gsap from 'gsap'
import { ProviderMark } from '@/components/ProviderMark'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { AccountCardSkeleton, SkeletonBlock } from '@/components/ui/PageSkeletons'
import { AccountCardSkeleton } from '@/components/ui/PageSkeletons'
import { QuotaMeter } from '@/components/account/QuotaMeter'
import { RuntimeMeter } from '@/components/account/RuntimeMeter'
import {
Expand All @@ -35,7 +35,6 @@ type Translate = (key: string, vars?: Record<string, string | number>) => string
type Props = {
account: AccountRow
busyKind: AccountBusyKind | ''
detailsLoading?: boolean
authPanelOpen: boolean
authUrl?: string
note?: string
Expand Down Expand Up @@ -82,7 +81,6 @@ function checkinStatusFor(account: AccountRow, t: Translate) {
export function AccountCard({
account,
busyKind,
detailsLoading = false,
authPanelOpen,
authUrl,
note,
Expand Down Expand Up @@ -133,25 +131,6 @@ export function AccountCard({
const provider = accountProviderLabel(account.provider, account.region, t)
const checkin = checkinStatusFor(account, t)

const detailsSkeleton = (
<>
<div className="rounded-2xl border border-border bg-surface-secondary/45 p-2.5">
<SkeletonBlock className="h-3 w-20" />
<SkeletonBlock className="mt-2.5 h-2 w-full" />
<div className="mt-2.5 grid grid-cols-3 gap-2 border-t border-separator pt-2.5">
<SkeletonBlock className="h-7 w-full" />
<SkeletonBlock className="h-7 w-full" />
<SkeletonBlock className="h-7 w-full" />
</div>
</div>
<div className="min-h-[58px] rounded-2xl border border-border bg-surface-secondary/25 p-2.5">
<SkeletonBlock className="h-3 w-20" />
<SkeletonBlock className="mt-2 h-1.5 w-full rounded-[1px]" />
<SkeletonBlock className="mt-2 h-3 w-36" />
</div>
</>
)

useLayoutEffect(() => {
const chip = chipRef.current
if (!chip) return
Expand Down Expand Up @@ -201,7 +180,7 @@ export function AccountCard({
// status are never left on screen while the account is re-probed.
if (busyKind === 'refresh') {
return (
<div data-gsap-reveal aria-busy="true" aria-label={t('refreshingAccount')}>
<div data-gsap-reveal className="min-h-[280px]" aria-busy="true" aria-label={t('refreshingAccount')}>
<AccountCardSkeleton />
</div>
)
Expand All @@ -211,8 +190,7 @@ export function AccountCard({
<Card
data-gsap-reveal
data-state={state}
aria-busy={detailsLoading}
className="account-card overflow-hidden p-0"
className="account-card min-h-[280px] overflow-hidden p-0"
>
<Card.Header className="flex-row items-start justify-between gap-2.5 px-3 pt-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2.5">
Expand All @@ -230,31 +208,50 @@ export function AccountCard({
</div>
</div>
<div className="flex max-w-[48%] shrink-0 flex-wrap justify-end gap-1.5">
{detailsLoading ? <SkeletonBlock className="h-5 w-16" /> : (
<>
<span ref={chipRef}>
<Chip size="sm" variant="soft" color={stateColor}>{stateCopy}</Chip>
</span>
{modelCooldowns.length ? <Chip size="sm" variant="soft" color="warning">{t('partialCooling')}</Chip> : null}
</>
)}
<span ref={chipRef}>
{modelCooldowns.length && state === 'cooling' ? (
<Tooltip>
<Tooltip.Trigger>
<span className="inline-flex cursor-help"><Chip size="sm" variant="soft" color={stateColor}>{stateCopy}</Chip></span>
</Tooltip.Trigger>
<Tooltip.Content>
<div className="space-y-1.5">
<div className="font-medium">{t('partialCooling')}</div>
{modelCooldowns.map(([model, until]) => (
<div key={model} className="flex items-center justify-between gap-4 text-xs">
<span>{model}</span>
<span className="mono text-foreground/65">{cooldownLabel(until)}</span>
</div>
))}
</div>
</Tooltip.Content>
</Tooltip>
) : (
<Chip size="sm" variant="soft" color={stateColor}>{stateCopy}</Chip>
)}
</span>
{modelCooldowns.length && state !== 'cooling' ? (
<Tooltip>
<Tooltip.Trigger>
<span className="inline-flex cursor-help"><Chip size="sm" variant="soft" color="warning">{t('partialCooling')}</Chip></span>
</Tooltip.Trigger>
<Tooltip.Content>
<div className="space-y-1.5">
<div className="font-medium">{t('partialCooling')}</div>
{modelCooldowns.map(([model, until]) => (
<div key={model} className="flex items-center justify-between gap-4 text-xs">
<span>{model}</span>
<span className="mono text-foreground/65">{cooldownLabel(until)}</span>
</div>
))}
</div>
</Tooltip.Content>
</Tooltip>
) : null}
</div>
</Card.Header>

<Card.Content className="gap-2 px-3 pb-2">
{detailsLoading ? detailsSkeleton : <>
{modelCooldowns.length ? (
<div className="flex items-start gap-2 rounded-2xl border border-warning/25 bg-warning/5 px-2.5 py-2 text-[11px] text-warning-foreground dark:text-warning">
<span className="status-dot mt-0.5 shrink-0" data-state="warn" />
<div className="min-w-0">
<div className="font-medium">{t('partialCooling')}</div>
<div className="mt-0.5 break-words text-warning-foreground/80 dark:text-warning/80">
{modelCooldowns.slice(0, 3).map(([model, until]) => `${model} ${cooldownLabel(until)}`).join(' · ')}
{modelCooldowns.length > 3 ? ` · +${modelCooldowns.length - 3}` : ''}
</div>
</div>
</div>
) : null}
<div className="rounded-2xl border border-border bg-surface-secondary/45 p-2">
<RuntimeMeter state={state} label={t('runtimeState')} stateCopy={stateCopy} />
<div className="mt-2 grid grid-cols-3 gap-2 border-t border-separator pt-2 text-[10px] text-foreground/65">
Expand Down Expand Up @@ -326,7 +323,6 @@ export function AccountCard({
</div>
</div>
) : null}
</>}
</Card.Content>

{authPanelOpen && account.enabled ? (
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ui/PageSkeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export function OverviewPageSkeleton() {

export function AccountCardSkeleton() {
return (
<article className="overflow-hidden rounded-3xl border border-border bg-surface">
<article className="min-h-[280px] overflow-hidden rounded-3xl border border-border bg-surface">
<div className="space-y-2 px-3 pt-2.5 pb-1.5">
<div className="flex items-start justify-between gap-2.5">
<div className="flex min-w-0 items-center gap-2.5">
Expand Down Expand Up @@ -169,7 +169,7 @@ export function AccountCardSkeleton() {
)
}

export function AccountsListSkeleton({ count = 6 }: { count?: number }) {
export function AccountsListSkeleton({ count = 5 }: { count?: number }) {
return (
<section className="grid gap-2.5 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" aria-label="Loading accounts">
{Array.from({ length: count }, (_, index) => <AccountCardSkeleton key={index} />)}
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 @@ -182,6 +182,11 @@ export const messages: Record<Lang, Dict> = {
logsLevelWarn: 'Warn',
logsLevelError: 'Error',
logsAttempts: 'Attempts',
logsRouting: 'Routing',
logsRouting_pool: 'Pool',
logsRouting_pin: 'Pinned',
logsRouting_sticky: 'Session sticky',
logsRouting_sticky_escape: 'Session escape',
logsNoAttempts: 'No attempt details.',
logsDetailTitle: 'Request detail',
logsShownTotal: '{shown} / {total}',
Expand Down Expand Up @@ -709,6 +714,11 @@ export const messages: Record<Lang, Dict> = {
logsLevelWarn: '警告',
logsLevelError: '错误',
logsAttempts: '尝试记录',
logsRouting: '路由方式',
logsRouting_pool: '常规池',
logsRouting_pin: '显式指定',
logsRouting_sticky: '会话粘性',
logsRouting_sticky_escape: '粘性逃逸',
logsNoAttempts: '没有尝试详情。',
logsDetailTitle: '请求详情',
logsShownTotal: '{shown} / {total}',
Expand Down
43 changes: 12 additions & 31 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,8 @@ export function AccountsPage() {
const { t } = useI18n()
const [accounts, setAccounts] = useState<AccountRow[]>([])
const [accountsLoading, setAccountsLoading] = useState(true)
const [accountsRefreshing, setAccountsRefreshing] = useState(false)
const [refreshingById, setRefreshingById] = useState<Record<string, boolean>>({})
const rows = accounts
const hasAccounts = rows.length > 0
const refreshing = accountsRefreshing && hasAccounts
const [addOpen, setAddOpen] = useState(false)
const [busy, setBusy] = useState<AccountBusy | null>(null)
const [patById, setPatById] = useState<Record<string, string>>({})
Expand All @@ -75,43 +72,29 @@ export function AccountsPage() {
const [priorityById, setPriorityById] = useState<Record<string, number>>({})
const [quotaRefreshing, setQuotaRefreshing] = useState(false)
const reloadAccounts = useCallback(async (refresh = false) => {
if (refresh) setAccountsRefreshing(true)
try {
const response = await fetchAccounts(refresh)
const next = response.data || EMPTY_ACCOUNTS
setAccounts(next)
return next
} finally {
setAccountsLoading(false)
if (refresh) setAccountsRefreshing(false)
}
}, [])

const refreshAccountsInBatches = useCallback(async (ids: string[], forceQuota = true) => {
const accountIds = [...new Set(ids)]
if (!accountIds.length) return
setAccountsRefreshing(true)
try {
for (let start = 0; start < accountIds.length; start += ACCOUNT_REFRESH_BATCH_SIZE) {
const batch = accountIds.slice(start, start + ACCOUNT_REFRESH_BATCH_SIZE)
await Promise.all(batch.map(async (id) => {
setRefreshingById((current) => ({ ...current, [id]: true }))
try {
const refreshed = await refreshAccount(id, { quota: forceQuota })
setAccounts((current) => current.map((account) => account.id === id ? refreshed : account))
} catch (error) {
setNoteById((current) => ({ ...current, [id]: error instanceof Error ? error.message : String(error) }))
} finally {
setRefreshingById((current) => {
const next = { ...current }
delete next[id]
return next
})
}
}))
}
} finally {
setAccountsRefreshing(false)
for (let start = 0; start < accountIds.length; start += ACCOUNT_REFRESH_BATCH_SIZE) {
const batch = accountIds.slice(start, start + ACCOUNT_REFRESH_BATCH_SIZE)
await Promise.all(batch.map(async (id) => {
try {
const refreshed = await refreshAccount(id, { quota: forceQuota })
setAccounts((current) => current.map((account) => account.id === id ? refreshed : account))
} catch (error) {
setNoteById((current) => ({ ...current, [id]: error instanceof Error ? error.message : String(error) }))
}
}))
}
}, [])

Expand Down Expand Up @@ -400,7 +383,6 @@ export function AccountsPage() {
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{accountsRefreshing ? <span className="text-xs text-muted" role="status">{t('refreshing')}</span> : null}
<Button size="sm" variant="secondary" isPending={quotaRefreshing} onPress={() => void onRefreshCredits()}>
{t('refreshCredits')}
</Button>
Expand Down Expand Up @@ -472,7 +454,7 @@ export function AccountsPage() {
</section>
) : null}

{!refreshing && !hasAccounts ? (
{!hasAccounts ? (
<EmptyPanel
className="rounded-3xl border border-dashed border-border"
icon={<BrandMark size={28} />}
Expand All @@ -491,13 +473,12 @@ export function AccountsPage() {
/>
) : null}

<section className="grid gap-2.5 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" aria-busy={refreshing}>
<section className="grid auto-rows-[minmax(280px,auto)] gap-2.5 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{pagedRows.map((account) => (
<AccountCard
key={account.id}
account={account}
busyKind={busy?.id === account.id ? busy.kind : ''}
detailsLoading={Boolean(refreshingById[account.id])}
authPanelOpen={authPanelId === account.id}
authUrl={urlById[account.id]}
note={noteById[account.id]}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/pages/LogsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ export function LogsPage() {
return accountProviderLabel(provider, account?.region, t)
}

function routingLabel(routing?: string) {
switch (routing) {
case 'pin': return t('logsRouting_pin')
case 'sticky': return t('logsRouting_sticky')
case 'sticky_escape': return t('logsRouting_sticky_escape')
default: return t('logsRouting_pool')
}
}

const accountOptions = useMemo(
() => (accounts || []).map((account) => ({ id: account.id, label: account.name || account.id })),
[accounts],
Expand Down Expand Up @@ -785,6 +794,7 @@ export function LogsPage() {
[t('logsColLatency'), formatLatency(selected?.latency_ms)],
[t('logsColTTFT'), formatLatency(selected?.ttfb_ms)],
[t('logsColStream'), selected?.stream ? t('logsStreamYes') : t('logsStreamNo')],
[t('logsRouting'), selected ? routingLabel(selected.routing) : '—'],
].map(([label, value]) => (
<div key={String(label)}>
<dt className="text-[11px] text-muted">{label}</dt>
Expand Down
Loading
Loading