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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
## 功能

- **兼容接口**:Chat Completions、Responses、Anthropic Messages 和模型列表,支持流式回复与函数工具调用。
- **多账号调度**:自动选号、会话粘性、并发限制、冷却与故障切换。
- **多账号调度**:轮询、加权轮询、顺序填满,以及可选的同供应商最早到期优先(FEFO);保留自动选号、会话粘性、并发限制、冷却与故障切换。
- **统一控制台**:管理账号、模型、客户端密钥、代理设置,查看额度和请求日志。
- **Docker 运维**:持久化账号数据;可选安装宿主机更新器,在控制台下载更新、确认升级和回滚。

Expand Down Expand Up @@ -71,6 +71,15 @@ Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 N

在「账号」卡片直接操作 WorkBuddy、QwenWork 国内版/国际版与 Qoder 国内版、国际版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。活动关闭时只记录跳过;真实账号签到验收仍待完成。QwenWork 的签到接口在客户端里存在,但生产网关尚未开放(404):国内版按钱包日额度核对当日免费积分,国际版只能核对总额度并记录跳过原因。

## 最早到期优先(FEFO)

在「系统 → 账号调度策略」选择「最早到期优先(FEFO)」,或向 `PATCH /api/system/settings` 提交 `{"routing_strategy":"fefo"}`。默认仍是轮询,原有轮询、加权轮询和顺序填满行为不变。

- **只在同一供应商内排序**:先按供应商分组,再比较本组账号最近的积分包到期日;Qoder、WorkBuddy 等互不影响,跨供应商仍按原有轮询选择。
- **只认实际可消费的积分包到期**:跳过余额为零、已过期或到期未知的包;同一 UTC+8 自然日到期的账号公平轮询。未知到期不猜测,作为该供应商的兜底候选,只有已知到期账号都不可用时才参与。
- **额度与故障切换照旧**:额度耗尽、限流冷却、并发占满或被显式排除的账号先退出候选,恢复后自动回到排序。响应或流结束后异步刷新该账号额度并重新排序;缓存中的包自然过期也会触发额度探测。不会把订阅重置、令牌有效期或签到奖励当成积分包到期。
- **数据缺口**:目前只有 WorkBuddy 个人账号的 `get-user-resource` 返回逐包 `CycleEndTime`。Qoder 的 `quota/usage` 只有订阅月度边界(`expiresAt`),Trae、Devin、Command Code、Codex 也只有重置窗口或总额度,因此这些供应商的账号在 FEFO 下按未知日期处理。WorkBuddy 企业/共享身份,以及结果达到 100 条的未确认列表,也不会获得 FEFO 优先级。升级后尚未刷新额度的旧缓存同样按未知日期处理,启动探测或手动刷新后才会带上包元数据。

## 使用限制

- 只连接你自己的账号,不提供账号、额度或官方 API 服务。
Expand Down
11 changes: 10 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Deploy with Docker, manage accounts in the web console, and connect clients thro
## Features

- **Compatible APIs**: Chat Completions, Responses, Anthropic Messages, and model listing, with streaming and function tools.
- **Multi-account routing**: automatic selection, session affinity, concurrency limits, cooldowns, and failover.
- **Multi-account routing**: round-robin, weighted round-robin, fill-first, and opt-in same-provider earliest-expiry-first (FEFO), with automatic selection, session affinity, concurrency limits, cooldowns, and failover.
- **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.

Expand Down Expand Up @@ -71,6 +71,15 @@ Accounts, models, access, and logs all live in one web console: readiness and qu

Account cards provide WorkBuddy, QwenWork CN/Global, Qoder CN, and Qoder Global check-in actions and automatic check-in switches (off by default), with history under More → Check-in records; no separate check-in page is needed. Set each provider's default time under System → Automatic check-in, then choose inheritance or an override in Edit account. Default changes apply to inheriting accounts immediately. Existing WorkBuddy accounts retain their previous times until switched to inheritance. Inactive campaigns are skipped. Live-account check-in acceptance is still pending. QwenWork ships a check-in API in its desktop client, but production gateways do not route it (404): CN confirms the daily free credits from the wallet balance, while Global can only report the total quota and the skip reason.

## Earliest-expiry-first (FEFO)

Select **Earliest expiry first (FEFO)** under **System → Account routing strategy**, or send `{"routing_strategy":"fefo"}` to `PATCH /api/system/settings`. Round-robin remains the default; the existing round-robin, weighted round-robin, and fill-first behavior is unchanged.

- **Rank only inside one provider.** Accounts are grouped by provider family before their nearest credit-package expiry is compared. Qoder, WorkBuddy, and other providers never compete with each other, and cross-provider selection keeps its existing rotation.
- **Use only spendable package dates.** Depleted, expired, and undated packages are skipped. Accounts expiring on the same UTC+8 calendar day rotate fairly. Missing dates are never guessed: they form that provider's fallback cohort and participate only after every dated account is unavailable.
- **Keep quota and failover semantics.** Exhausted, cooling, concurrency-saturated, or explicitly excluded accounts leave the candidate set and return to the ranking once they recover. Finishing a response or stream refreshes that account's quota asynchronously; a cached package expiring naturally also triggers a probe. Subscription resets, token lifetimes, and check-in rewards are never treated as package expiry.
- **Data gaps.** Only WorkBuddy personal accounts currently return per-package `CycleEndTime` from `get-user-resource`. Qoder's `quota/usage` exposes only the subscription cycle boundary (`expiresAt`), while Trae, Devin, Command Code, and Codex expose reset windows or totals only, so their accounts remain in the unknown-date cohort under FEFO. WorkBuddy enterprise/shared identities and unconfirmed result sets capped at 100 also receive no FEFO priority. Cached quota snapshots written before this version remain undated until the boot probe or a manual refresh repopulates the package metadata.

## Limitations

- Bring your own accounts. CLI2API does not supply accounts, quotas, or an official API service.
Expand Down
13 changes: 13 additions & 0 deletions changelog/unreleased/fefo-credit-expiry-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### English

- Add opt-in earliest-expiry-first (FEFO) account routing. It ranks accounts only inside the same provider family by their nearest spendable credit-package expiry, rotates accounts expiring on the same UTC+8 day, and falls back to round-robin when a provider has no package dates. Existing round-robin, weighted round-robin, fill-first, affinity, pinning, region/provider gates, cooldowns, and failover are unchanged.
- Refresh a FEFO account's quota after each completed response or stream (coalesced per account) and re-probe accounts whose trusted packages all expired, so newly granted or spent credits change the next selection without waiting for a manual refresh.
- Expose WorkBuddy package expiry as FEFO-eligible only for personal identities with a complete, paginated result. Enterprise/shared identities, full result pages, undated packages, and other providers' reset windows or token lifetimes never invent a priority.
- Add the FEFO option to System → Account routing strategy and document that Qoder, Trae, Devin, Command Code, and Codex do not currently provide per-package credit expiry, so their accounts stay in the unknown-date cohort.

### 中文

- 新增可选的同供应商最早到期优先(FEFO)调度:只在同一供应商内按最近可消费积分包到期日排序,同一 UTC+8 自然日到期的账号公平轮询;供应商没有积分包日期时回退轮询。原有轮询、加权轮询、顺序填满、会话粘性、固定账号、地域/供应商限制、冷却与故障切换保持不变。
- 每次响应或流结束后异步刷新该账号额度(按账号合并请求),并重新探测积分包已全部过期的账号,让新增或已消耗的积分立即影响下一次选择,无需手动刷新。
- 仅 WorkBuddy 个人身份且分页结果完整的积分包参与 FEFO 优先级;企业/共享身份、满 100 条的未确认列表、无日期包,以及其他供应商的重置窗口或令牌有效期都不会被伪造成到期优先级。
- 在「系统 → 账号调度策略」增加 FEFO 选项,并说明 Qoder、Trae、Devin、Command Code、Codex 当前不提供逐包积分到期信息,其账号保持未知日期分组。
2 changes: 1 addition & 1 deletion frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export type SystemSettings = {
cross_provider_model_pool: boolean
checkin_disabled_accounts: boolean
proxy_url: string
routing_strategy: 'round-robin' | 'weighted-round-robin' | 'fill-first'
routing_strategy: 'round-robin' | 'weighted-round-robin' | 'fill-first' | 'fefo'
workbuddy_checkin_time: string
checkin_times: Record<string, string>
timezone: string
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ export const messages: Record<Lang, Dict> = {
workbuddyCheckinSettingsHint: 'Default first daily check-in time for new WorkBuddy accounts. Existing accounts keep their own time until you edit them.',
workbuddyCheckinSettingsFieldHint: 'Uses server local time. New WorkBuddy accounts inherit this unless you set a different time on the account.',
routingStrategyTitle: 'Account routing strategy',
routingStrategyHint: 'Choose how ready accounts share traffic. Fill-first keeps traffic on the highest-priority account until it is unavailable.',
routingStrategyHint: 'Choose how ready accounts share traffic. Fill-first keeps traffic on the highest-priority account until it is unavailable. Earliest-expiry-first only ranks accounts inside the same provider by their nearest credit-package expiry; providers without package expiry keep the existing rotation.',
routingStrategy: 'Strategy',
routingRoundRobin: 'Round robin',
routingWeightedRoundRobin: 'Weighted round robin',
routingFillFirst: 'Fill first',
routingFEFO: 'Earliest expiry first (FEFO)',
routingWeight: 'Routing weight',
sessionAffinityTTL: 'Session TTL',
sessionAffinityHits: 'Sticky hits',
Expand Down Expand Up @@ -1053,11 +1054,12 @@ export const messages: Record<Lang, Dict> = {
workbuddyCheckinSettingsHint: '新建 WorkBuddy 账号时使用的默认首次签到时间。已有账号会保留各自的时间,直到你单独修改。',
workbuddyCheckinSettingsFieldHint: '使用服务器本地时间。新建 WorkBuddy 账号会继承这里的时间,账号编辑页仍可单独覆盖。',
routingStrategyTitle: '账号调度策略',
routingStrategyHint: '选择就绪账号如何分担流量。填满优先会持续使用最高优先级账号,直到它不可用。',
routingStrategyHint: '选择就绪账号如何分担流量。填满优先会持续使用最高优先级账号,直到它不可用。最早到期优先只在同一供应商内部按最近的积分包到期日排序;没有积分包到期信息的供应商保持原有轮询。',
routingStrategy: '策略',
routingRoundRobin: '轮询',
routingWeightedRoundRobin: '加权轮询',
routingFillFirst: '填满优先',
routingFEFO: '最早到期优先(FEFO)',
routingWeight: '调度权重',
sessionAffinityTTL: '会话 TTL',
sessionAffinityHits: '粘性命中',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export function SystemPage() {
<ListBox.Item id="round-robin" textValue="round-robin"><Label>{t('routingRoundRobin')}</Label><ListBox.ItemIndicator /></ListBox.Item>
<ListBox.Item id="weighted-round-robin" textValue="weighted-round-robin"><Label>{t('routingWeightedRoundRobin')}</Label><ListBox.ItemIndicator /></ListBox.Item>
<ListBox.Item id="fill-first" textValue="fill-first"><Label>{t('routingFillFirst')}</Label><ListBox.ItemIndicator /></ListBox.Item>
<ListBox.Item id="fefo" textValue="fefo"><Label>{t('routingFEFO')}</Label><ListBox.ItemIndicator /></ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
Expand Down
6 changes: 6 additions & 0 deletions internal/accounts/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const (
RoutingStrategyRoundRobin = "round-robin"
RoutingStrategyWeightedRoundRobin = "weighted-round-robin"
RoutingStrategyFillFirst = "fill-first"
// RoutingStrategyFEFO is the opt-in earliest-expiry-first strategy. It
// ranks accounts inside one provider family by the nearest credit-package
// expiry and leaves cross-provider rotation untouched.
RoutingStrategyFEFO = "fefo"
)

func NormalizeRoutingStrategy(strategy string) string {
Expand All @@ -17,6 +21,8 @@ func NormalizeRoutingStrategy(strategy string) string {
return RoutingStrategyFillFirst
case RoutingStrategyWeightedRoundRobin:
return RoutingStrategyWeightedRoundRobin
case RoutingStrategyFEFO:
return RoutingStrategyFEFO
default:
return RoutingStrategyRoundRobin
}
Expand Down
9 changes: 8 additions & 1 deletion internal/accounts/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ type QuotaWindow struct {
Exceeded bool `json:"exceeded,omitempty"`
}

// QuotaPackageScopeAccountFEFO marks credit packages that the provider has
// confirmed are spendable by this account and carry a real package expiry. An
// empty or unknown scope never participates in FEFO ranking.
const QuotaPackageScopeAccountFEFO = "account-fefo"

// QuotaPackage is one upstream credit/resource pack that carries its own
// expiry. EndsAt is a Unix second; EndTime is the provider-native wall-clock
// string for display.
Expand All @@ -23,12 +28,14 @@ type QuotaPackage struct {
Used float64 `json:"used"`
Size float64 `json:"size"`
Unit string `json:"unit,omitempty"`
Scope string `json:"scope,omitempty"`
EndsAt int64 `json:"ends_at,omitempty"`
EndTime string `json:"end_time,omitempty"`
}

// QuotaSnapshot is the quota state for one account.
// A zero value means "unknown"; only Exceeded influences routing.
// A zero value means "unknown". Exceeded gates quota routing; explicitly
// scoped package expiry may additionally guide opt-in FEFO scheduling.
type QuotaSnapshot struct {
Used float64 `json:"used"`
Total float64 `json:"total"`
Expand Down
23 changes: 23 additions & 0 deletions internal/api/system_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ func TestSystemSettingsRoutePersistsAndAppliesRoutingStrategy(t *testing.T) {
}
}

func TestSystemSettingsRoutePersistsFEFORoutingStrategy(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
QoderHome: t.TempDir(), DataDir: t.TempDir(),
})
defer srv.Close()

request := httptest.NewRequest(http.MethodPatch, "/api/system/settings", bytes.NewBufferString(`{"routing_strategy":"fefo"}`))
request.Header.Set("Authorization", "Bearer secret")
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte(`"routing_strategy":"fefo"`)) {
t.Fatalf("updated settings: %d %s", response.Code, response.Body.String())
}
if got := srv.Pool.RoutingStrategy(); got != accounts.RoutingStrategyFEFO {
t.Fatalf("runtime strategy = %q", got)
}
value, ok, err := srv.Manager.Store().GetSecret(context.Background(), routingStrategySecret)
if err != nil || !ok || value != accounts.RoutingStrategyFEFO {
t.Fatalf("persisted strategy=%q ok=%v err=%v", value, ok, err)
}
}

func TestEnsureWorkBuddyCheckinTimeDefaultsToNine(t *testing.T) {
store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db"))
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/app/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func (a *App) newGateway() *apigateway.Handler {
}
if a.Manager != nil {
h.Catalogs = a.Manager
h.AfterAccountUse = a.Manager.RefreshFEFOQuotaAfterUse
}
if a.Recorder != nil {
h.Logs = a.Recorder
Expand Down
4 changes: 2 additions & 2 deletions internal/control/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ func (h *System) Patch(ctx context.Context, input SystemSettingsPatch) error {
var strategy string
if input.RoutingStrategy != nil {
rawStrategy := strings.ToLower(strings.TrimSpace(*input.RoutingStrategy))
if rawStrategy != accounts.RoutingStrategyRoundRobin && rawStrategy != accounts.RoutingStrategyWeightedRoundRobin && rawStrategy != accounts.RoutingStrategyFillFirst {
return operationError("invalid_routing_strategy", "routing_strategy must be round-robin, weighted-round-robin, or fill-first")
if rawStrategy != accounts.RoutingStrategyRoundRobin && rawStrategy != accounts.RoutingStrategyWeightedRoundRobin && rawStrategy != accounts.RoutingStrategyFillFirst && rawStrategy != accounts.RoutingStrategyFEFO {
return operationError("invalid_routing_strategy", "routing_strategy must be round-robin, weighted-round-robin, fill-first, or fefo")
}
strategy = accounts.NormalizeRoutingStrategy(rawStrategy)
}
Expand Down
Loading
Loading