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

### English

- Normalize empty WorkBuddy message content, preserve tool-call messages, and record non-sensitive message-shape diagnostics for request troubleshooting

### 中文

- 规范化 WorkBuddy 的空消息内容,保留工具调用消息,并记录不含正文的消息结构诊断,方便排查请求问题

## 0.4.0 - 2026-09-09

### English
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export type RequestLog = {
error_code?: string
error_message?: string
attempt_count: number
message_count?: number
empty_message_indexes?: number[]
message_roles?: string[]
attempts?: RequestAttempt[]
stream_diagnostic?: RequestStreamDiagnostic
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export const messages: Record<Lang, Dict> = {
logsRouting_sticky_escape: 'Session escape',
logsNoAttempts: 'No attempt details.',
logsStreamDiagnostics: 'Stream diagnostics',
logsMessageShape: 'Message shape',
logsMessageCount: 'Messages',
logsEmptyMessageIndexes: 'Empty indexes',
logsMessageRoles: 'Roles',
logsCancellationSource: 'Cancellation source',
logsUpstreamStatus: 'Upstream status',
logsSSEEvents: 'SSE events',
Expand Down Expand Up @@ -821,6 +825,10 @@ export const messages: Record<Lang, Dict> = {
logsRouting_sticky_escape: '粘性逃逸',
logsNoAttempts: '没有尝试详情。',
logsStreamDiagnostics: '流诊断',
logsMessageShape: '消息结构',
logsMessageCount: '消息数',
logsEmptyMessageIndexes: '空内容下标',
logsMessageRoles: '角色顺序',
logsCancellationSource: '取消来源',
logsUpstreamStatus: '上游状态',
logsSSEEvents: 'SSE 事件数',
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 @@ -820,6 +820,16 @@ export function LogsPage() {
</dd>
</div>
</dl>
{selected && selected.message_count ? (
<div className="rounded-lg border border-separator bg-surface-secondary px-3 py-3 text-xs">
<div className="font-medium text-muted">{t('logsMessageShape')}</div>
<dl className="mt-2 grid gap-2 sm:grid-cols-2">
<div><dt className="text-[10px] text-muted">{t('logsMessageCount')}</dt><dd className="mono mt-0.5">{selected.message_count}</dd></div>
<div><dt className="text-[10px] text-muted">{t('logsEmptyMessageIndexes')}</dt><dd className="mono mt-0.5 break-all">{selected.empty_message_indexes?.length ? selected.empty_message_indexes.join(', ') : '—'}</dd></div>
<div className="sm:col-span-2"><dt className="text-[10px] text-muted">{t('logsMessageRoles')}</dt><dd className="mono mt-0.5 break-all">{selected.message_roles?.join(' → ') || '—'}</dd></div>
</dl>
</div>
) : null}
{selected?.error_message ? (
<div className="rounded-lg border border-separator bg-surface-secondary px-3 py-2 text-xs leading-5 text-muted">
{selected.error_kind ? <span className="mono mr-2 text-muted">{selected.error_kind}</span> : null}
Expand Down
4 changes: 4 additions & 0 deletions internal/accounts/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ CREATE TABLE IF NOT EXISTS request_stream_diagnostics (
saw_done INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS request_stream_diagnostics_created_at ON request_stream_diagnostics(created_at DESC);`},
{filename: "017_request_message_shape.sql", sql: `
ALTER TABLE request_logs ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE request_logs ADD COLUMN empty_message_indexes TEXT NOT NULL DEFAULT '';
ALTER TABLE request_logs ADD COLUMN message_roles TEXT NOT NULL DEFAULT '';`},
}

const schemaMigrationsDDL = `
Expand Down
125 changes: 85 additions & 40 deletions internal/accounts/request_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
Expand All @@ -27,30 +28,33 @@ const (
var ErrRequestLogNotFound = errors.New("request log not found")

type RequestLog struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Stream bool `json:"stream"`
Status string `json:"status"`
RequestedModel string `json:"requested_model"`
MappedModel string `json:"mapped_model,omitempty"`
AccountID string `json:"account_id,omitempty"`
Provider string `json:"provider,omitempty"`
Routing string `json:"routing,omitempty"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
CacheReadTokens *int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens *int `json:"cache_write_tokens,omitempty"`
UsageSource string `json:"usage_source,omitempty"`
Credits *float64 `json:"credits,omitempty"`
LatencyMs *int `json:"latency_ms,omitempty"`
TTFBMs *int `json:"ttfb_ms,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
AttemptCount int `json:"attempt_count"`
Attempts []RequestAttempt `json:"attempts,omitempty"`
StreamDiagnostic *RequestStreamDiagnostic `json:"stream_diagnostic,omitempty"`
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Stream bool `json:"stream"`
Status string `json:"status"`
RequestedModel string `json:"requested_model"`
MappedModel string `json:"mapped_model,omitempty"`
AccountID string `json:"account_id,omitempty"`
Provider string `json:"provider,omitempty"`
Routing string `json:"routing,omitempty"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
CacheReadTokens *int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens *int `json:"cache_write_tokens,omitempty"`
UsageSource string `json:"usage_source,omitempty"`
Credits *float64 `json:"credits,omitempty"`
LatencyMs *int `json:"latency_ms,omitempty"`
TTFBMs *int `json:"ttfb_ms,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
AttemptCount int `json:"attempt_count"`
MessageCount int `json:"message_count,omitempty"`
EmptyMessageIndexes []int `json:"empty_message_indexes,omitempty"`
MessageRoles []string `json:"message_roles,omitempty"`
Attempts []RequestAttempt `json:"attempts,omitempty"`
StreamDiagnostic *RequestStreamDiagnostic `json:"stream_diagnostic,omitempty"`
}

type RequestStreamDiagnostic struct {
Expand Down Expand Up @@ -203,14 +207,14 @@ func (s *Store) InsertRequestLog(ctx context.Context, log RequestLog) error {
INSERT INTO request_logs (
id, created_at, finished_at, stream, status, requested_model, mapped_model, account_id, provider, routing,
prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, usage_source, credits,
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count, message_count, empty_message_indexes, message_roles
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
log.ID, formatTime(log.CreatedAt), finished, boolToInt(log.Stream), log.Status,
log.RequestedModel, log.MappedModel, nullIfEmpty(log.AccountID), strings.TrimSpace(log.Provider), strings.TrimSpace(log.Routing),
nullableInt(log.PromptTokens), nullableInt(log.CompletionTokens),
nullableInt(log.CacheReadTokens), nullableInt(log.CacheWriteTokens),
log.UsageSource, nullableFloat(log.Credits), nullableInt(log.LatencyMs), nullableInt(log.TTFBMs),
log.ErrorKind, log.ErrorCode, log.ErrorMessage, log.AttemptCount,
log.ErrorKind, log.ErrorCode, log.ErrorMessage, log.AttemptCount, log.MessageCount, encodeIntSlice(log.EmptyMessageIndexes), encodeStringSlice(log.MessageRoles),
)
if err != nil {
return fmt.Errorf("insert request log: %w", err)
Expand All @@ -231,13 +235,13 @@ func (s *Store) UpdateRequestLog(ctx context.Context, log RequestLog) error {
finished_at = ?, status = ?, requested_model = ?, mapped_model = ?, account_id = ?, provider = ?, routing = ?,
prompt_tokens = ?, completion_tokens = ?, cache_read_tokens = ?, cache_write_tokens = ?,
usage_source = ?, credits = ?, latency_ms = ?, ttfb_ms = ?,
error_kind = ?, error_code = ?, error_message = ?, attempt_count = ?
error_kind = ?, error_code = ?, error_message = ?, attempt_count = ?, message_count = ?, empty_message_indexes = ?, message_roles = ?
WHERE id = ?`,
finished, log.Status, log.RequestedModel, log.MappedModel, nullIfEmpty(log.AccountID), strings.TrimSpace(log.Provider), strings.TrimSpace(log.Routing),
nullableInt(log.PromptTokens), nullableInt(log.CompletionTokens),
nullableInt(log.CacheReadTokens), nullableInt(log.CacheWriteTokens),
log.UsageSource, nullableFloat(log.Credits), nullableInt(log.LatencyMs), nullableInt(log.TTFBMs),
log.ErrorKind, log.ErrorCode, log.ErrorMessage, log.AttemptCount, log.ID,
log.ErrorKind, log.ErrorCode, log.ErrorMessage, log.AttemptCount, log.MessageCount, encodeIntSlice(log.EmptyMessageIndexes), encodeStringSlice(log.MessageRoles), log.ID,
)
if err != nil {
return fmt.Errorf("update request log: %w", err)
Expand Down Expand Up @@ -300,7 +304,7 @@ func (s *Store) ListRequestLogs(ctx context.Context, filter RequestLogFilter) (R
SELECT id, created_at, finished_at, stream, status, requested_model, mapped_model, account_id,
COALESCE(NULLIF(provider, ''), (SELECT provider FROM accounts WHERE accounts.id = request_logs.account_id), ''), routing,
prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, usage_source, credits,
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count, message_count, empty_message_indexes, message_roles
FROM request_logs` + where + ` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
Expand Down Expand Up @@ -637,7 +641,7 @@ func (s *Store) GetRequestLog(ctx context.Context, id string) (RequestLog, error
SELECT id, created_at, finished_at, stream, status, requested_model, mapped_model, account_id,
COALESCE(NULLIF(provider, ''), (SELECT provider FROM accounts WHERE accounts.id = request_logs.account_id), ''), routing,
prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, usage_source, credits,
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count
latency_ms, ttfb_ms, error_kind, error_code, error_message, attempt_count, message_count, empty_message_indexes, message_roles
FROM request_logs WHERE id = ?`, strings.TrimSpace(id))
log, err := scanRequestLog(row)
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -835,21 +839,60 @@ func buildRequestLogWhere(filter RequestLogFilter) (string, []any) {
return " WHERE " + strings.Join(clauses, " AND "), args
}

func encodeIntSlice(values []int) string {
if len(values) == 0 {
return ""
}
raw, _ := json.Marshal(values)
return string(raw)
}

func encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
}
raw, _ := json.Marshal(values)
return string(raw)
}

func decodeIntSlice(raw string) []int {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []int
if json.Unmarshal([]byte(raw), &values) != nil {
return nil
}
return values
}

func decodeStringSlice(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if json.Unmarshal([]byte(raw), &values) != nil {
return nil
}
return values
}

func scanRequestLog(row rowScanner) (RequestLog, error) {
var (
log RequestLog
finished, accountID sql.NullString
stream int
prompt, completion sql.NullInt64
cacheRead, cacheWrite sql.NullInt64
credits sql.NullFloat64
latency, ttfb sql.NullInt64
created string
log RequestLog
finished, accountID sql.NullString
stream int
prompt, completion sql.NullInt64
cacheRead, cacheWrite sql.NullInt64
credits sql.NullFloat64
latency, ttfb sql.NullInt64
emptyIndexes, messageRoles sql.NullString
created string
)
err := row.Scan(
&log.ID, &created, &finished, &stream, &log.Status, &log.RequestedModel, &log.MappedModel, &accountID, &log.Provider, &log.Routing,
&prompt, &completion, &cacheRead, &cacheWrite, &log.UsageSource, &credits,
&latency, &ttfb, &log.ErrorKind, &log.ErrorCode, &log.ErrorMessage, &log.AttemptCount,
&latency, &ttfb, &log.ErrorKind, &log.ErrorCode, &log.ErrorMessage, &log.AttemptCount, &log.MessageCount, &emptyIndexes, &messageRoles,
)
if err != nil {
return RequestLog{}, err
Expand All @@ -863,6 +906,8 @@ func scanRequestLog(row rowScanner) (RequestLog, error) {
if accountID.Valid {
log.AccountID = accountID.String
}
log.EmptyMessageIndexes = decodeIntSlice(emptyIndexes.String)
log.MessageRoles = decodeStringSlice(messageRoles.String)
log.PromptTokens = nullIntPtr(prompt)
log.CompletionTokens = nullIntPtr(completion)
log.CacheReadTokens = nullIntPtr(cacheRead)
Expand Down
2 changes: 2 additions & 0 deletions internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,8 @@ func (s *Server) prepareChatExecution(r *http.Request, request translate.ChatReq
s.startRequestLog(accounts.RequestLog{
ID: requestID, CreatedAt: started, Stream: request.Stream, Status: accounts.RequestStatusStarted,
RequestedModel: firstNonEmpty(publicModel, request.Model),
MessageCount: len(request.Messages), EmptyMessageIndexes: translate.EmptyMessageIndexes(request.Messages),
MessageRoles: translate.MessageRoles(request.Messages),
})
ctx := executor.WithAllowedProviders(executor.WithRequestID(r.Context(), requestID), identity.AllowedProviders)
if sessionKey := requestSessionKey(r, identity, request); sessionKey != "" {
Expand Down
30 changes: 28 additions & 2 deletions internal/providers/workbuddy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ func TestPrepareBodyForcesStreamAndStringToolChoice(t *testing.T) {
}
}

func TestPrepareBodyInsertsEmptyLeadingSystem(t *testing.T) {
func TestPrepareBodyInsertsNonEmptyLeadingSystem(t *testing.T) {
out := PrepareBody([]byte(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`))
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
Expand All @@ -594,7 +594,7 @@ func TestPrepareBodyInsertsEmptyLeadingSystem(t *testing.T) {
}
first, _ := messages[0].(map[string]any)
second, _ := messages[1].(map[string]any)
if first["role"] != "system" || first["content"] != "" || second["role"] != "user" {
if first["role"] != "system" || first["content"] != "You are a helpful assistant." || second["role"] != "user" {
t.Fatalf("messages=%v", body["messages"])
}

Expand All @@ -613,6 +613,32 @@ func TestPrepareBodyInsertsEmptyLeadingSystem(t *testing.T) {
}
}

func TestPrepareBodyNormalizesEmptyMessageContent(t *testing.T) {
out := PrepareBody([]byte(`{"model":"m","messages":[{"role":"user","content":""},{"role":"assistant","content":null},{"role":"user","content":[]}]}`))
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
t.Fatal(err)
}
messages, _ := body["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("messages=%v", messages)
}
message, _ := messages[0].(map[string]any)
if message["role"] != "system" || message["content"] != "You are a helpful assistant." {
t.Fatalf("messages=%v", messages)
}

withToolCall := PrepareBody([]byte(`{"model":"m","messages":[{"role":"assistant","content":"","tool_calls":[{"id":"call_1"}]}]}`))
var toolBody map[string]any
if err := json.Unmarshal(withToolCall, &toolBody); err != nil {
t.Fatal(err)
}
toolMessages, _ := toolBody["messages"].([]any)
if len(toolMessages) != 2 {
t.Fatalf("tool message was dropped: %v", toolMessages)
}
}

func TestPrepareBodyDropsNullAndEmptyTools(t *testing.T) {
nullOut := PrepareBody([]byte(`{"model":"m","tools":null}`))
var nullBody map[string]any
Expand Down
Loading
Loading