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
7 changes: 7 additions & 0 deletions changelog/unreleased/account-model-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Each account can map a client-facing model name to its own real model, and can limit which catalog models it serves.

### 中文

- 每个账号可以把自己的真实模型映射成对外名字,也可以限制自己只接选中的目录模型。
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-checkin-clears-cooldown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Clear a WorkBuddy account's cooldown immediately after a successful daily check-in so newly restored credits can be routed.

### 中文

- WorkBuddy 每日签到成功后立即清除对应账号的冷却,让恢复的积分可以马上参与调度。
3 changes: 3 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export type Overview = {
max_inflight?: number
priority?: number
drop_system_prompt?: boolean
model_aliases?: Record<string, string>
enabled_models?: string[]
models_restricted?: boolean
workbuddy_auto_checkin?: boolean
workbuddy_checkin_time?: string
auto_checkin?: boolean
Expand Down
68 changes: 65 additions & 3 deletions frontend/src/components/account/EditAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { Alert, Button, Chip, Form, Input, Modal, NumberField } from '@heroui/react'
import { useEffect, useState } from 'react'
import { Alert, Button, Checkbox, Chip, Form, Input, Modal, NumberField } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import { ProviderMark } from '@/components/ProviderMark'
import { FormRow } from '@/components/ui/FormRow'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { fetchModels } from '@/api/overview'
import type { ModelInfo } from '@/api/types'
import type { AccountRow } from '@/lib/account'
import { accountProviderLabel } from '@/lib/provider'

Expand All @@ -16,7 +18,7 @@ type Props = {
busy: boolean
t: Translate
onClose: () => void
onSave: (input: { name: string; max_inflight: number; priority: number; proxy_url: string; checkin_time?: string; drop_system_prompt?: boolean }) => Promise<void>
onSave: (input: { name: string; max_inflight: number; priority: number; proxy_url: string; checkin_time?: string; drop_system_prompt?: boolean; model_aliases: Record<string, string>; enabled_models: string[]; set_model_routing: boolean }) => Promise<void>
}

export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone, busy, t, onClose, onSave }: Props) {
Expand All @@ -25,9 +27,27 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
const [priority, setPriority] = useState<number>(account?.priority ?? 50)
const [proxyUrl, setProxyUrl] = useState(account?.proxy_url || '')
const [dropSystemPrompt, setDropSystemPrompt] = useState(Boolean(account?.drop_system_prompt))
const [aliases, setAliases] = useState<Array<{ alias: string; model: string }>>(
Object.entries(account?.model_aliases || {}).map(([alias, model]) => ({ alias, model })),
)
const [aliasDraft, setAliasDraft] = useState({ alias: '', model: '' })
const [restrictModels, setRestrictModels] = useState(Boolean(account?.models_restricted))
const [enabledModels, setEnabledModels] = useState<string[]>(account?.enabled_models || [])
const [catalog, setCatalog] = useState<ModelInfo[]>([])
const [inheritCheckinTime, setInheritCheckinTime] = useState(!account?.checkin_time)
const [checkinTime, setCheckinTime] = useState<string | null>(account?.checkin_time || null)
const [error, setError] = useState('')
useEffect(() => {
if (!account?.id) return
let cancelled = false
void fetchModels(account.id).then((data) => {
if (!cancelled) setCatalog(data.data || [])
}).catch(() => {
if (!cancelled) setCatalog([])
})
return () => { cancelled = true }
}, [account?.id])

const title = t('editAccountTitle', { name: account?.name || account?.id || '' })
const provider = account ? accountProviderLabel(account.provider, account.region, t) : ''

Expand Down Expand Up @@ -59,6 +79,9 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
proxy_url: proxyUrl.trim(),
checkin_time: checkinDefaultTime !== undefined ? (inheritCheckinTime ? '' : checkinTime ?? checkinDefaultTime) : undefined,
drop_system_prompt: account?.provider === 'workbuddy' ? dropSystemPrompt : undefined,
model_aliases: Object.fromEntries(aliases.filter((item) => item.alias.trim() && item.model.trim()).map((item) => [item.alias.trim(), item.model.trim()])),
enabled_models: restrictModels ? enabledModels : [],
set_model_routing: true,
})
onClose()
} catch (err) {
Expand Down Expand Up @@ -162,6 +185,45 @@ export function EditAccountModal({ account, checkinDefaultTime, checkinTimezone,
/>
</FormRow>
) : null}
<FormRow label={t('accountModelAliases')} hint={t('accountModelAliasesHint')}>
<div className="space-y-2">
{aliases.map((item) => (
<div key={item.alias} className="flex items-center justify-between gap-2">
<span className="mono min-w-0 truncate text-sm">{item.alias} → {item.model}</span>
<Button size="sm" variant="ghost" isDisabled={busy} onPress={() => setAliases((current) => current.filter((row) => row.alias !== item.alias))}>{t('modelAliasRemove')}</Button>
</div>
))}
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<Input aria-label={t('modelAliasName')} value={aliasDraft.alias} onChange={(event) => setAliasDraft((current) => ({ ...current, alias: event.target.value }))} placeholder={t('modelAliasPlaceholder')} disabled={busy} />
<Input aria-label={t('modelAliasTarget')} value={aliasDraft.model} onChange={(event) => setAliasDraft((current) => ({ ...current, model: event.target.value }))} placeholder={t('accountModelAliasTargetPlaceholder')} disabled={busy} />
<Button size="sm" variant="secondary" isDisabled={busy || !aliasDraft.alias.trim() || !aliasDraft.model.trim()} onPress={() => {
setAliases((current) => [...current.filter((row) => row.alias !== aliasDraft.alias.trim()), { alias: aliasDraft.alias.trim(), model: aliasDraft.model.trim() }])
setAliasDraft({ alias: '', model: '' })
}}>{t('modelAliasAdd')}</Button>
</div>
</div>
</FormRow>
<FormRow label={t('enabledModels')} hint={t('enabledModelsHint')}>
<div className="space-y-2">
<CompactSwitch isSelected={restrictModels} isDisabled={busy} ariaLabel={t('enabledModels')} label={t('restrictModels')} onChange={setRestrictModels} />
{restrictModels ? (
<div className="max-h-40 space-y-1 overflow-auto">
{catalog.map((model) => (
<Checkbox
key={model.id}
isSelected={enabledModels.includes(model.id)}
isDisabled={busy}
onChange={(selected) => setEnabledModels((current) => selected ? [...current, model.id] : current.filter((id) => id !== model.id))}
>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
<Checkbox.Content><span className="mono text-sm">{model.id}</span></Checkbox.Content>
</Checkbox>
))}
{catalog.length === 0 ? <p className="text-xs text-muted">{t('noAccountModels')}</p> : null}
</div>
) : null}
</div>
</FormRow>
{checkinDefaultTime !== undefined ? (
<FormRow label={t('autoCheckinTime')} hint={t('checkinScheduleHint')}>
<div className="space-y-3">
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,18 @@ export const messages: Record<Lang, Dict> = {
wizardSettingsHint: 'Set these before login. You can still change them later from Edit on the account card.',
editAccount: 'Edit',
editAccountTitle: 'Edit {name}',
editAccountHint: 'Change the display name, concurrency, routing priority, outbound proxy, and check-in time when supported.',
editAccountHint: 'Change the display name, concurrency, routing priority, outbound proxy, model aliases, and check-in time when supported.',
accountModelAliases: 'Aliases',
accountModelAliasesHint: 'A name this account answers as. The request is rewritten to this account\'s real model after the account is picked.',
accountModelAliasTargetPlaceholder: 'real model on this account',
modelAliasName: 'Alias',
modelAliasTarget: 'Real model',
modelAliasAdd: 'Add',
modelAliasRemove: 'Remove',
modelAliasPlaceholder: 'kimi-k3',
enabledModels: 'Models',
enabledModelsHint: 'Leave unrestricted to serve the whole catalog. Restrict to choose which models this account may serve.',
restrictModels: 'Only selected models',
maxInflightInvalid: 'Concurrency must be an integer from 1 to 32.',
priorityInvalid: 'Priority must be an integer from 1 to 100.',
wizardNamePh: 'Account name',
Expand Down Expand Up @@ -1274,7 +1285,18 @@ export const messages: Record<Lang, Dict> = {
wizardSettingsHint: '登录前先设好这些选项,之后仍可在账号卡片上点编辑修改。',
editAccount: '编辑',
editAccountTitle: '编辑 {name}',
editAccountHint: '修改显示名称、并发、调度优先级和代理出口;支持签到的账号还可设置签到时间。',
editAccountHint: '修改显示名称、并发、调度优先级、代理出口和模型别名;支持签到的账号还可设置签到时间。',
accountModelAliases: '别名',
accountModelAliasesHint: '这个账号对外应答的名字。选中这个账号后,请求会换成它自己的真实模型。',
accountModelAliasTargetPlaceholder: '这个账号上的真实模型',
modelAliasName: '外号',
modelAliasTarget: '真实模型',
modelAliasAdd: '添加',
modelAliasRemove: '移除',
modelAliasPlaceholder: 'kimi-k3',
enabledModels: '可用模型',
enabledModelsHint: '不限制时使用整个目录。限制后,这个账号只接选中的模型。',
restrictModels: '只使用选中的模型',
maxInflightInvalid: '并发必须是 1 到 32 之间的整数。',
priorityInvalid: '优先级必须是 1 到 100 之间的整数。',
wizardNamePh: '账号名称',
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 @@ -320,7 +320,7 @@ export function AccountsPage() {
})
}

async function onSaveSettings(id: string, input: { name: string; max_inflight: number; priority: number; proxy_url?: string; checkin_time?: string; drop_system_prompt?: boolean }) {
async function onSaveSettings(id: string, input: { name: string; max_inflight: number; priority: number; proxy_url?: string; checkin_time?: string; drop_system_prompt?: boolean; model_aliases?: Record<string, string>; enabled_models?: string[]; set_model_routing?: boolean }) {
if (!id) throw new Error(t('accountNameRequired'))
setNameById((current) => ({ ...current, [id]: input.name }))
setInflightById((current) => ({ ...current, [id]: input.max_inflight }))
Expand Down
44 changes: 43 additions & 1 deletion internal/accounts/ids.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package accounts

import "strings"
import (
"fmt"
"strings"
)

const (
RoutingStrategyRoundRobin = "round-robin"
Expand Down Expand Up @@ -49,6 +52,45 @@ func CanonicalModelID(model string) string {
return key
}

// NormalizeAccountRouting canonicalizes one account's alias map and enabled
// model list. An alias points at exactly one real model on that account.
// The enabled list is the catalog models the account may serve; empty means
// the operator saved a list with nothing selected.
func NormalizeAccountRouting(aliases map[string]string, enabled []string) (map[string]string, []string, error) {
normalizedAliases := map[string]string{}
for alias, modelID := range aliases {
key := CanonicalModelID(alias)
target := CanonicalModelID(modelID)
if key == "" || key == "auto" || target == "" || target == "auto" {
return nil, nil, fmt.Errorf("alias and model are required")
}
if key == target {
return nil, nil, fmt.Errorf("alias must differ from the real model")
}
if previous, exists := normalizedAliases[key]; exists && previous != target {
return nil, nil, fmt.Errorf("%s maps to more than one model", key)
}
normalizedAliases[key] = target
}
seen := map[string]struct{}{}
normalizedEnabled := make([]string, 0, len(enabled))
for _, modelID := range enabled {
key := CanonicalModelID(modelID)
if key == "" || key == "auto" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
normalizedEnabled = append(normalizedEnabled, key)
}
if len(normalizedAliases) == 0 {
normalizedAliases = nil
}
return normalizedAliases, normalizedEnabled, nil
}

// NormalizeModelName converts display-name formats sent by external clients
// into the canonical model ID used for routing. It currently strips a leading
// "Provider: " segment (single-word provider, no spaces) before lowercasing
Expand Down
18 changes: 18 additions & 0 deletions internal/accounts/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ type Account struct {
Priority int `json:"priority"`
// DropSystemPrompt drops caller system prompts before provider-native chat.
DropSystemPrompt bool `json:"drop_system_prompt"`
// ModelAliases maps a client-facing name to this account's real catalog
// model. Routing matches the alias; the request is rewritten to the real
// model only after this account is picked.
ModelAliases map[string]string `json:"model_aliases,omitempty"`
// EnabledModels limits which catalog models this account may serve.
// ModelsRestricted reports whether the operator saved a list; an empty
// list then serves nothing, while an unset list serves the catalog.
EnabledModels []string `json:"enabled_models,omitempty"`
ModelsRestricted bool `json:"models_restricted,omitempty"`
// WorkBuddyAutoCheckin opts into scheduled daily check-in (default off).
WorkBuddyAutoCheckin bool `json:"workbuddy_auto_checkin"`
// WorkBuddyCheckinTime is the process-local daily check-in time.
Expand Down Expand Up @@ -61,6 +70,9 @@ type CreateAccount struct {
WorkBuddyAutoCheckin *bool
WorkBuddyCheckinTime string
ProxyURL string
ModelAliases map[string]string
EnabledModels []string
SetModelRouting bool
}

type UpdateAccount struct {
Expand All @@ -74,6 +86,12 @@ type UpdateAccount struct {
WorkBuddyAutoCheckin *bool
WorkBuddyCheckinTime *string
ProxyURL *string
// ModelAliases and EnabledModels replace the stored routing when
// SetModelRouting is true. Nil aliases clear the map; nil enabled models
// mean the account can serve its whole catalog.
ModelAliases map[string]string
EnabledModels []string
SetModelRouting bool
}

type NativeCredential struct {
Expand Down
24 changes: 14 additions & 10 deletions internal/console/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,19 @@ func (h *Handler) HandleAccountByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, account)
case http.MethodPatch:
var input struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
MaxInFlight *int `json:"max_inflight"`
Priority *int `json:"priority"`
DropSystemPrompt *bool `json:"drop_system_prompt"`
WorkBuddyAutoCheckin *bool `json:"workbuddy_auto_checkin"`
WorkBuddyCheckinTime *string `json:"workbuddy_checkin_time"`
AutoCheckin *bool `json:"auto_checkin"`
CheckinTime *string `json:"checkin_time"`
ProxyURL *string `json:"proxy_url"`
Name string `json:"name"`
Enabled *bool `json:"enabled"`
MaxInFlight *int `json:"max_inflight"`
Priority *int `json:"priority"`
DropSystemPrompt *bool `json:"drop_system_prompt"`
WorkBuddyAutoCheckin *bool `json:"workbuddy_auto_checkin"`
WorkBuddyCheckinTime *string `json:"workbuddy_checkin_time"`
AutoCheckin *bool `json:"auto_checkin"`
CheckinTime *string `json:"checkin_time"`
ProxyURL *string `json:"proxy_url"`
ModelAliases map[string]string `json:"model_aliases"`
EnabledModels []string `json:"enabled_models"`
SetModelRouting bool `json:"set_model_routing"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxConsoleBodyBytes)).Decode(&input); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_request", err.Error())
Expand All @@ -131,6 +134,7 @@ func (h *Handler) HandleAccountByID(w http.ResponseWriter, r *http.Request) {
Name: input.Name, Enabled: input.Enabled, MaxInFlight: input.MaxInFlight, Priority: input.Priority,
DropSystemPrompt: input.DropSystemPrompt, WorkBuddyAutoCheckin: input.WorkBuddyAutoCheckin,
WorkBuddyCheckinTime: input.WorkBuddyCheckinTime, ProxyURL: input.ProxyURL,
ModelAliases: input.ModelAliases, EnabledModels: input.EnabledModels, SetModelRouting: input.SetModelRouting,
})
if err != nil {
writeErr(w, http.StatusBadRequest, "account_update_failed", err.Error())
Expand Down
20 changes: 17 additions & 3 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ func (e ChatExecutor) markClassified(id string, c Classified, model string) {
return
}
if c.Model == "" && c.Kind == accounts.KindRateLimit {
c.Model = model
c.Model = e.cooldownModel(id, model)
}
e.Pool.MarkClassified(id, c)
}
Expand All @@ -596,7 +596,20 @@ func (e ChatExecutor) markOK(id, model string) {
if e.Pool == nil {
return
}
e.Pool.MarkOK(id, model)
e.Pool.MarkOK(id, e.cooldownModel(id, model))
}

// cooldownModel records the account's real catalog model when the client
// used that account's alias, so the alias and the real name share one cooldown.
func (e ChatExecutor) cooldownModel(id, model string) string {
if e.Pool == nil || id == "" {
return model
}
item, ok := e.Pool.ByID(id)
if !ok {
return model
}
return AccountModelID(item, model)
}

func (e ChatExecutor) recordAttempt(ctx context.Context, attempt accounts.RequestAttempt) {
Expand Down Expand Up @@ -910,6 +923,7 @@ func (e ChatExecutor) ChatNonStream(ctx context.Context, req translate.ChatReque
// WorkBuddy still needs a leading system slot after the strip (code 11128);
// the adapter inserts an empty placeholder, this helper only drops caller text.
func sanitizeForItem(item Item, req translate.ChatRequest) translate.ChatRequest {
req.Model = AccountModelID(item, req.Model)
if native := NativeModelID(item, req.Model); native != "" {
req.Model = native
}
Expand Down Expand Up @@ -993,7 +1007,7 @@ func (e ChatExecutor) chatInProcessStreamAttempt(ctx context.Context, item Item,
nativeResponses := false
if preferNativeResponses && native != nil && adapter.NativeResponses != nil {
options := providers.RequestOptions{
Model: NativeModelID(item, req.Model),
Model: NativeModelID(item, AccountModelID(item, req.Model)),
DropSystemPrompt: item.DropSystemPrompt && itemProvider(item) != "qoder",
}
resp, resolved, err = adapter.NativeResponses.ResponsesStream(ctx, item.ID, native, options)
Expand Down
Loading
Loading