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

### English

- Add a per-account drop-system-prompt switch (on by default, WorkBuddy accounts): caller system prompts are stripped before provider-native chat so upstream content screening no longer rejects them
- Treat upstream content-screening rejections as request-level errors: they return 400 without failing over to other accounts or putting the account into cooldown

### 中文

- 新增账号级「丢弃系统提示词」开关(默认开启,WorkBuddy 账号):请求发出前剥离调用方系统提示词,避免被上游内容审核拒绝
- 上游内容审核拒绝改按请求级错误处理:直接返回 400,不再向其他账号无谓切换,也不给账号打冷却

## 0.2.11 - 2026-08-27

### English
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 @@ -64,6 +64,7 @@ export type Overview = {
enabled?: boolean
max_inflight?: number
priority?: number
drop_system_prompt?: boolean
status?: string
cooldown_until?: string | null
url?: string
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ export const messages: Record<Lang, Dict> = {
cooldown: 'Cooldown',
inFlight: 'In-flight',
restarts: 'Restarts',
dropSystemPrompt: 'Drop system prompt',
dropSystemPromptHint: 'Strip caller system prompts before sending to this account; avoids upstream content-screening rejections',
quota: 'Quota',
quotaRemaining: 'Remaining',
quotaAddOn: 'Add-on',
Expand Down Expand Up @@ -631,6 +633,8 @@ export const messages: Record<Lang, Dict> = {
cooldown: '冷却',
inFlight: '进行中',
restarts: '重启次数',
dropSystemPrompt: '丢弃系统提示词',
dropSystemPromptHint: '发送前剥离调用方的系统提示词,避免触发上游内容审核拦截',
quota: '额度',
quotaRemaining: '剩余',
quotaAddOn: '附加包',
Expand Down
39 changes: 37 additions & 2 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,15 @@ export function AccountsPage() {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<AccountFilter>('all')
const [enabledById, setEnabledById] = useState<Record<string, boolean>>({})
const [dropSystemById, setDropSystemById] = useState<Record<string, boolean>>({})
const displayRows = useMemo(() => rows.map((account) => {
const enabled = enabledById[account.id]
return enabled === undefined ? account : { ...account, enabled }
}), [enabledById, rows])
const dropSystem = dropSystemById[account.id]
let next = account
if (enabled !== undefined) next = { ...next, enabled }
if (dropSystem !== undefined) next = { ...next, drop_system_prompt: dropSystem }
return next
}), [enabledById, dropSystemById, rows])

const availableCount = displayRows.filter(isAvailable).length
const attentionCount = displayRows.filter((account) => account.enabled && !isAvailable(account)).length
Expand Down Expand Up @@ -204,6 +209,19 @@ export function AccountsPage() {
})
}

async function onToggleDropSystem(id: string, selected: boolean) {
setDropSystemById((current) => ({ ...current, [id]: selected }))
await run(id, 'toggle', async () => {
await updateAccount(id, { drop_system_prompt: selected })
await refresh()
})
setDropSystemById((current) => {
const next = { ...current }
delete next[id]
return next
})
}

return (
<div className="space-y-5">
<section data-gsap-reveal className="grid gap-4 border-b border-[var(--app-line)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
Expand Down Expand Up @@ -428,6 +446,23 @@ export function AccountsPage() {
</div>
) : null}

{account.provider === 'workbuddy' ? (
<div className="mt-3 flex items-center justify-between gap-3 text-[11px]">
<Tooltip>
<Tooltip.Trigger>
<span className="font-medium">{t('dropSystemPrompt')}</span>
</Tooltip.Trigger>
<Tooltip.Content>{t('dropSystemPromptHint')}</Tooltip.Content>
</Tooltip>
<CompactSwitch
isSelected={Boolean(account.drop_system_prompt)}
isDisabled={thisBusy === 'toggle'}
ariaLabel={t('dropSystemPrompt')}
onChange={(selected) => void onToggleDropSystem(account.id, selected)}
/>
</div>
) : null}

{updatedAt ? <div className="mono mt-3 text-[10px] text-[var(--app-faint)]">{t('updatedAt')} {updatedAt}</div> : null}

{lastError ? (
Expand Down
40 changes: 35 additions & 5 deletions internal/accounts/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import (
)

const (
KindQuota = "quota"
KindRateLimit = "rate_limit"
KindAuth = "auth"
KindNotReady = "not_ready"
KindUnavailable = "unavailable"
KindQuota = "quota"
KindRateLimit = "rate_limit"
KindAuth = "auth"
KindNotReady = "not_ready"
KindUnavailable = "unavailable"
KindInvalidRequest = "invalid_request"
)

const maxRetryAfter = 10 * time.Minute
Expand Down Expand Up @@ -67,6 +68,8 @@ func Classify(status int, body, retryAfter, kindHint, failoverHint string) Class
kind = KindQuota
case notReadyLike(lower):
kind = KindNotReady
case IsInvalidRequestText(lower):
kind = KindInvalidRequest
case authLike(lower) && !quotaLike(lower, code, typ) && !rateLike(lower):
kind = KindAuth
case rateLike(lower) || status == 429:
Expand Down Expand Up @@ -110,6 +113,17 @@ func Classify(status int, body, retryAfter, kindHint, failoverHint string) Class
out.Failover = true
out.Cooldown = ParseRetryAfter(retryAfter, 10*time.Second)
out.Code = firstNonEmpty(code, "not_ready")
case KindInvalidRequest:
// Request content the upstream rejected; retrying on another account
// cannot succeed, and the account itself is healthy.
out.Status = status
if out.Status < 400 {
out.Status = 400
}
out.Failover = false
out.Cooldown = 0
out.Type = firstNonEmpty(typ, "invalid_request_error")
out.Code = firstNonEmpty(code, "invalid_request")
default:
if status >= 500 {
out.Status = status
Expand Down Expand Up @@ -206,6 +220,22 @@ func notReadyLike(lower string) bool {
strings.Contains(lower, "not ready")
}

// IsInvalidRequestText reports whether an error body looks like an upstream
// content-screening rejection: the request itself is the problem, so no
// account should fail over or cool down. Auth and quota shapes are matched
// earlier in Classify and never reach this check.
func IsInvalidRequestText(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "sensitive") ||
strings.Contains(lower, "敏感") ||
strings.Contains(lower, "违规") ||
strings.Contains(lower, "风险") ||
strings.Contains(lower, "拦截") ||
strings.Contains(lower, "moderation") ||
strings.Contains(lower, "content filter") ||
strings.Contains(lower, "content_filter")
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
Expand Down
9 changes: 9 additions & 0 deletions internal/accounts/classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ func TestClassifyFailoverHintOverrides(t *testing.T) {
t.Fatalf("hint 0 should disable failover: %+v", got)
}
}

func TestClassifyContentScreeningStaysRequestLevel(t *testing.T) {
for _, body := range []string{"sensitive content rejected", "内容包含敏感信息"} {
got := Classify(400, body, "", "", "")
if got.Kind != KindInvalidRequest || got.Failover || got.Cooldown != 0 || got.Status != 400 {
t.Fatalf("body=%q got %+v", body, got)
}
}
}
7 changes: 6 additions & 1 deletion internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (m *Manager) startAccount(ctx context.Context, account Account) error {
if descriptor.Runtime == providers.RuntimeInProcess {
m.pool.Upsert(Item{
ID: account.ID, Provider: descriptor.ID, Region: account.ProviderRegion,
Runtime: string(descriptor.Runtime),
Runtime: string(descriptor.Runtime), DropSystemPrompt: account.DropSystemPrompt,
})
return nil
}
Expand Down Expand Up @@ -329,6 +329,11 @@ func (m *Manager) Update(ctx context.Context, id string, input UpdateAccount) er
if err != nil {
return err
}
// Request sanitization applies per request, so sync it into the pool
// without restarting anything.
if before.DropSystemPrompt != after.DropSystemPrompt {
m.pool.SetDropSystemPrompt(id, after.DropSystemPrompt)
}
if before.Enabled && !after.Enabled {
return m.stopAccount(id)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/accounts/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ CREATE TABLE IF NOT EXISTS request_attempts (
usage_source TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS request_attempts_request_id ON request_attempts(request_id);`},
{filename: "005_account_drop_system_prompt.sql", sql: `
ALTER TABLE accounts ADD COLUMN drop_system_prompt INTEGER NOT NULL DEFAULT 1;`},
}

const schemaMigrationsDDL = `
Expand Down
20 changes: 20 additions & 0 deletions internal/accounts/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type Item struct {
InFlight int
Restarts int
Quota *QuotaSnapshot
// DropSystemPrompt mirrors the stored account flag so the executor can
// sanitize requests per account without a store lookup per chat.
DropSystemPrompt bool
}

// RouteQuery selects candidates for one public model request. Empty fields are
Expand Down Expand Up @@ -238,6 +241,23 @@ func (p *Pool) MarkOK(id string) {
observer(*changed)
}
}
// SetDropSystemPrompt updates only the request-sanitization flag on a live
// item. Routing and runtime state stay untouched so the change applies to the
// next request without disturbing cooldowns or health.
func (p *Pool) SetDropSystemPrompt(id string, drop bool) {
if p == nil || id == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.items {
if p.items[i].ID == id {
p.items[i].DropSystemPrompt = drop
return
}
}
}

func (p *Pool) MergeHealth(id string, ready, hot bool, inFlight, restarts int, lastError string) {
if p == nil || id == "" {
return
Expand Down
70 changes: 39 additions & 31 deletions internal/accounts/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ type Account struct {
Enabled bool `json:"enabled"`
MaxInFlight int `json:"max_inflight"`
Priority int `json:"priority"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// DropSystemPrompt drops caller system prompts before provider-native chat.
DropSystemPrompt bool `json:"drop_system_prompt"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type CreateAccount struct {
Expand All @@ -48,10 +50,11 @@ type CreateAccount struct {
}

type UpdateAccount struct {
Name string
Enabled *bool
MaxInFlight *int
Priority *int
Name string
Enabled *bool
MaxInFlight *int
Priority *int
DropSystemPrompt *bool
}

type NativeCredential struct {
Expand Down Expand Up @@ -118,24 +121,25 @@ func (s *Store) Create(ctx context.Context, input CreateAccount) (Account, error
}
now := time.Now().UTC()
account := Account{
ID: newAccountID(),
Name: name,
Provider: descriptor.ID,
ProviderRegion: region.ID,
AuthType: "none",
Enabled: input.Enabled,
MaxInFlight: maxInFlight,
Priority: priority,
Status: "offline",
CreatedAt: now,
UpdatedAt: now,
ID: newAccountID(),
Name: name,
Provider: descriptor.ID,
ProviderRegion: region.ID,
AuthType: "none",
Enabled: input.Enabled,
MaxInFlight: maxInFlight,
Priority: priority,
DropSystemPrompt: true,
Status: "offline",
CreatedAt: now,
UpdatedAt: now,
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO accounts (
id, name, provider, provider_region, auth_type, enabled, max_inflight, priority, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, name, provider, provider_region, auth_type, enabled, max_inflight, priority, drop_system_prompt, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
account.ID, account.Name, account.Provider, account.ProviderRegion, account.AuthType,
account.Enabled, account.MaxInFlight, account.Priority, account.Status,
account.Enabled, account.MaxInFlight, account.Priority, account.DropSystemPrompt, account.Status,
formatTime(account.CreatedAt), formatTime(account.UpdatedAt),
)
if err != nil {
Expand All @@ -146,8 +150,8 @@ INSERT INTO accounts (

func (s *Store) Get(ctx context.Context, id string) (Account, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, provider, provider_region, remote_uid, auth_type, enabled, max_inflight, priority, status,
last_error, last_error_kind, cooldown_until, created_at, updated_at
SELECT id, name, provider, provider_region, remote_uid, auth_type, enabled, max_inflight, priority,
drop_system_prompt, status, last_error, last_error_kind, cooldown_until, created_at, updated_at
FROM accounts WHERE id = ?`, strings.TrimSpace(id))
account, err := scanAccount(row)
if errors.Is(err, sql.ErrNoRows) {
Expand All @@ -168,7 +172,8 @@ func scanAccount(row rowScanner) (Account, error) {
var cooldown, created, updated sql.NullString
err := row.Scan(
&account.ID, &account.Name, &account.Provider, &account.ProviderRegion, &account.RemoteUID,
&account.AuthType, &account.Enabled, &account.MaxInFlight, &account.Priority, &account.Status,
&account.AuthType, &account.Enabled, &account.MaxInFlight, &account.Priority,
&account.DropSystemPrompt, &account.Status,
&account.LastError, &account.LastErrorKind, &cooldown, &created, &updated,
)
if err != nil {
Expand Down Expand Up @@ -208,8 +213,8 @@ func parseTime(value string) time.Time {

func (s *Store) List(ctx context.Context) ([]Account, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, provider, provider_region, remote_uid, auth_type, enabled, max_inflight, priority, status,
last_error, last_error_kind, cooldown_until, created_at, updated_at
SELECT id, name, provider, provider_region, remote_uid, auth_type, enabled, max_inflight, priority,
drop_system_prompt, status, last_error, last_error_kind, cooldown_until, created_at, updated_at
FROM accounts ORDER BY created_at, id`)
if err != nil {
return nil, fmt.Errorf("list accounts: %w", err)
Expand Down Expand Up @@ -243,10 +248,13 @@ func (s *Store) Update(ctx context.Context, id string, input UpdateAccount) erro
if input.Priority != nil && *input.Priority > 0 {
account.Priority = *input.Priority
}
if input.DropSystemPrompt != nil {
account.DropSystemPrompt = *input.DropSystemPrompt
}
account.UpdatedAt = time.Now().UTC()
result, err := s.db.ExecContext(ctx, `
UPDATE accounts SET name = ?, enabled = ?, max_inflight = ?, priority = ?, updated_at = ?
WHERE id = ?`, account.Name, account.Enabled, account.MaxInFlight, account.Priority, formatTime(account.UpdatedAt), account.ID)
UPDATE accounts SET name = ?, enabled = ?, max_inflight = ?, priority = ?, drop_system_prompt = ?, updated_at = ?
WHERE id = ?`, account.Name, account.Enabled, account.MaxInFlight, account.Priority, account.DropSystemPrompt, formatTime(account.UpdatedAt), account.ID)
if err != nil {
return fmt.Errorf("update account: %w", err)
}
Expand Down
Loading
Loading