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/responses-incomplete-terminal-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Map upstream `finish_reason: length` results to the Responses API `incomplete` terminal state, including streaming events, request logs, statistics, and UI filters.

### 中文

- 将上游 `finish_reason: length` 结果映射为 Responses API 的 `incomplete` 终止状态,并同步支持流式事件、请求日志、统计与界面筛选。
1 change: 1 addition & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export type RequestStatsWindow = {
export type RequestStatsTotals = {
requests: number
ok: number
incomplete: number
error: number
canceled: number
streaming: number
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const messages: Record<Lang, Dict> = {
logsStreamNo: 'sync',
logsFilterAll: 'All',
logsFilterOk: 'OK',
logsFilterIncomplete: 'Incomplete',
logsFilterError: 'Error',
logsFilterCanceled: 'Canceled',
logsFilterAccountAll: 'All accounts',
Expand Down Expand Up @@ -842,6 +843,7 @@ export const messages: Record<Lang, Dict> = {
logsStreamNo: '同步',
logsFilterAll: '全部',
logsFilterOk: '成功',
logsFilterIncomplete: '未完成',
logsFilterError: '失败',
logsFilterCanceled: '取消',
logsFilterAccountAll: '全部账号',
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/pages/LogsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { useI18n } from '@/hooks/useI18n'
import { accountProviderLabel } from '@/lib/provider'

type PageTab = 'requests' | 'runtime'
type RequestFilter = 'all' | 'ok' | 'error' | 'canceled'
type RequestFilter = 'all' | 'ok' | 'incomplete' | 'error' | 'canceled'
type RuntimeFilter = 'all' | 'info' | 'warn' | 'error'
type StreamFilter = 'all' | 'stream' | 'sync'
type TimeRange = 'all' | '1h' | '24h' | '7d' | 'custom'
Expand All @@ -56,7 +56,7 @@ type DateRangeValue = { start: DateValue; end: DateValue }

function statusColor(status?: string): 'success' | 'warning' | 'danger' | 'default' {
if (status === 'ok') return 'success'
if (status === 'streaming' || status === 'started') return 'warning'
if (status === 'streaming' || status === 'started' || status === 'incomplete') return 'warning'
if (status === 'error' || status === 'canceled') return 'danger'
return 'default'
}
Expand Down Expand Up @@ -511,6 +511,7 @@ export function LogsPage() {
options={[
{ id: 'all', label: t('logsFilterAll') },
{ id: 'ok', label: t('logsFilterOk') },
{ id: 'incomplete', label: t('logsFilterIncomplete') },
{ id: 'error', label: t('logsFilterError') },
{ id: 'canceled', label: t('logsFilterCanceled') },
]}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type StatsWindow = 1 | 24 | 168

const EMPTY_STATS: RequestStats = {
window: { from: '', to: '', hours: 24 },
totals: { requests: 0, ok: 0, error: 0, canceled: 0, streaming: 0, success_rate: 0 },
totals: { requests: 0, ok: 0, incomplete: 0, error: 0, canceled: 0, streaming: 0, success_rate: 0 },
latency: {},
tokens: { prompt: 0, completion: 0, cache_read: 0, total: 0 },
status: [],
Expand Down Expand Up @@ -121,7 +121,7 @@ export function OverviewPage() {

const metrics = [
{ label: t('metricRequests'), value: traffic.totals.requests as number | null, kind: 'compact' as const, detail: t('statsWindowHint', { window: t(hours === 1 ? 'statsWindow1h' : hours === 168 ? 'statsWindow7d' : 'statsWindow24h') }), ok: traffic.totals.requests > 0 },
{ label: t('metricSuccess'), value: traffic.totals.success_rate, kind: 'percent' as const, detail: `${traffic.totals.ok} ${t('logsFilterOk')} · ${traffic.totals.error} ${t('logsFilterError')}`, ok: traffic.totals.requests === 0 || traffic.totals.success_rate >= 0.9 },
{ label: t('metricSuccess'), value: traffic.totals.success_rate, kind: 'percent' as const, detail: `${traffic.totals.ok} ${t('logsFilterOk')} · ${traffic.totals.incomplete} ${t('logsFilterIncomplete')} · ${traffic.totals.error} ${t('logsFilterError')}`, ok: traffic.totals.requests === 0 || traffic.totals.success_rate >= 0.9 },
{ label: t('metricLatency'), value: traffic.latency.p95_ms ?? traffic.latency.avg_ms, kind: 'ms' as const, detail: `p50 ${formatLatency(traffic.latency.p50_ms)} · avg ${formatLatency(traffic.latency.avg_ms)}`, ok: traffic.latency.p95_ms == null || traffic.latency.p95_ms < 8000 },
{ label: t('metricTokens'), value: traffic.tokens.total, kind: 'compact' as const, detail: `${formatCompact(traffic.tokens.prompt)} / ${formatCompact(traffic.tokens.completion)}`, ok: true },
]
Expand Down
12 changes: 7 additions & 5 deletions internal/accounts/request_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import (
)

const (
RequestStatusStarted = "started"
RequestStatusStreaming = "streaming"
RequestStatusOK = "ok"
RequestStatusError = "error"
RequestStatusCanceled = "canceled"
RequestStatusStarted = "started"
RequestStatusStreaming = "streaming"
RequestStatusOK = "ok"
RequestStatusIncomplete = "incomplete"
RequestStatusError = "error"
RequestStatusCanceled = "canceled"

AttemptStatusStarted = "started"
AttemptStatusOK = "ok"
Expand Down Expand Up @@ -140,6 +141,7 @@ type RequestStatsWindow struct {
type RequestStatsTotals struct {
Requests int `json:"requests"`
OK int `json:"ok"`
Incomplete int `json:"incomplete"`
Error int `json:"error"`
Canceled int `json:"canceled"`
Streaming int `json:"streaming"`
Expand Down
6 changes: 4 additions & 2 deletions internal/gateway/compat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ func RelayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
if err != nil {
return stats, err
}
stats.FinishReason = output.finishReason
if err := closeReasoning(); err != nil {
return stats, err
}
Expand Down Expand Up @@ -608,8 +609,9 @@ func RelayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
return stats, err
}
}
completed := responsesResponse(requestID, model, content, output.reasoning.String(), calls, derefInt(stats.PromptTokens), derefInt(stats.CompletionTokens))
if err := eventWriter.write("response.completed", map[string]any{"type": "response.completed", "response": completed}); err != nil {
terminal := responsesTerminalForFinishReason(output.finishReason)
response := responsesResponse(requestID, model, content, output.reasoning.String(), calls, derefInt(stats.PromptTokens), derefInt(stats.CompletionTokens), output.finishReason)
if err := eventWriter.write(terminal.event, map[string]any{"type": terminal.event, "response": response}); err != nil {
return stats, err
}
return stats, nil
Expand Down
1 change: 1 addition & 0 deletions internal/gateway/openai_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type StreamRelayStats struct {
Credits *float64
ConsumedCredits *float64
Model string
FinishReason string
FirstTokenAt *time.Time
SSEEventCount int
BytesRead int64
Expand Down
47 changes: 41 additions & 6 deletions internal/gateway/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ func (h *Handler) HandleResponses(w http.ResponseWriter, r *http.Request) {
writeCompatibilityOpenAIError(w, err)
return
}
h.finishCompatibility(execution, result.AccountID, result.Provider, result.Routing, accounts.RequestStatusOK, 0, &StreamRelayStats{
requestStatus := responsesRequestStatus(result.FinishReason)
h.finishCompatibility(execution, result.AccountID, result.Provider, result.Routing, requestStatus, 0, &StreamRelayStats{
PromptTokens: ptrInt(result.PromptTokens), CompletionTokens: ptrInt(result.CompletionTokens),
CacheReadTokens: result.CacheReadTokens, CacheWriteTokens: result.CacheWriteTokens,
CachedTokens: result.CachedTokens, UsageSource: result.UsageSource, Credits: result.Credits,
ConsumedCredits: result.ConsumedCredits, Model: result.Model,
ConsumedCredits: result.ConsumedCredits, Model: result.Model, FinishReason: result.FinishReason,
}, nil, result.AttemptCount, result.ReasoningLevel)
response := responsesResponse(execution.RequestID, firstNonEmpty(result.Model, execution.PublicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens)
response := responsesResponse(execution.RequestID, firstNonEmpty(result.Model, execution.PublicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens, result.FinishReason)
translate.RestoreResponseToolNames(response, execution.Request.ResponseToolNames)
writeJSON(w, http.StatusOK, response)
}
Expand All @@ -71,6 +72,9 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
writer := compatibilityStreamWriter(w)
stats, relayErr := RelayResponsesStream(writer, upstream.Response.Body, execution.RequestID, firstNonEmpty(execution.PublicModel, execution.Request.Model), execution.Request.ResponseToolNames)
status := streamRequestStatus(relayErr)
if relayErr == nil {
status = responsesRequestStatus(stats.FinishReason)
}
if r.Context().Err() != nil || errors.Is(relayErr, context.Canceled) || errors.Is(relayErr, context.DeadlineExceeded) {
status = accounts.RequestStatusCanceled
}
Expand Down Expand Up @@ -100,12 +104,43 @@ func writeCompatibilityOpenAIError(w http.ResponseWriter, err error) {
WriteClassifiedErr(w, err)
}

func responsesResponse(requestID, model, content, reasoning string, toolCalls []proxyToolCall, promptTokens, completionTokens int) map[string]any {
return map[string]any{
"id": "resp_" + requestID, "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": model,
type responsesTerminal struct {
status string
event string
incompleteDetails map[string]any
}

func responsesTerminalForFinishReason(finishReason string) responsesTerminal {
if finishReason == "length" {
return responsesTerminal{
status: "incomplete",
event: "response.incomplete",
incompleteDetails: map[string]any{
"reason": "max_output_tokens",
},
}
}
return responsesTerminal{status: "completed", event: "response.completed"}
}

func responsesRequestStatus(finishReason string) string {
if responsesTerminalForFinishReason(finishReason).status == "incomplete" {
return accounts.RequestStatusIncomplete
}
return accounts.RequestStatusOK
}

func responsesResponse(requestID, model, content, reasoning string, toolCalls []proxyToolCall, promptTokens, completionTokens int, finishReason string) map[string]any {
terminal := responsesTerminalForFinishReason(finishReason)
response := map[string]any{
"id": "resp_" + requestID, "object": "response", "created_at": time.Now().Unix(), "status": terminal.status, "model": model,
"output": responsesOutputItems(requestID, content, reasoning, toolCalls),
"usage": responsesUsage(promptTokens, completionTokens),
}
if terminal.incompleteDetails != nil {
response["incomplete_details"] = terminal.incompleteDetails
}
return response
}

func responsesOutputItems(requestID, content, reasoning string, toolCalls []proxyToolCall) []any {
Expand Down
56 changes: 56 additions & 0 deletions internal/gateway/responses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package gateway

import (
"encoding/json"
"strings"
"testing"
)

func TestResponsesResponseMapsLengthToIncomplete(t *testing.T) {
response := responsesResponse("req", "model", "", "reasoning", nil, 10, 32, "length")
if response["status"] != "incomplete" {
t.Fatalf("status=%v", response["status"])
}
details := response["incomplete_details"].(map[string]any)
if details["reason"] != "max_output_tokens" {
t.Fatalf("details=%#v", details)
}
if responsesRequestStatus("length") != "incomplete" || responsesRequestStatus("stop") != "ok" {
t.Fatal("request status mapping changed")
}
}

func TestRelayResponsesStreamEmitsIncomplete(t *testing.T) {
upstream := strings.NewReader("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"thinking\"}}]}\n\n" +
"data: {\"choices\":[{\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":32}}\n\n" +
"data: [DONE]\n\n")
var output strings.Builder
stats, err := RelayResponsesStream(&output, upstream, "req", "model", nil)
if err != nil {
t.Fatal(err)
}
if stats.FinishReason != "length" {
t.Fatalf("finish reason=%q", stats.FinishReason)
}
body := output.String()
if !strings.Contains(body, "event: response.incomplete") || strings.Contains(body, "event: response.completed") {
t.Fatalf("wrong terminal event:\n%s", body)
}
var terminal map[string]any
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var event map[string]any
if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event) == nil && event["type"] == "response.incomplete" {
terminal = event
}
}
if terminal == nil {
t.Fatal("missing response.incomplete payload")
}
response := terminal["response"].(map[string]any)
if response["status"] != "incomplete" || response["incomplete_details"].(map[string]any)["reason"] != "max_output_tokens" {
t.Fatalf("response=%#v", response)
}
}
14 changes: 13 additions & 1 deletion internal/runtime/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,19 @@ func TestManagerEscalatesConsecutiveRestartBackoffSeparately(t *testing.T) {
if third == second {
t.Fatal("manager reused exited process")
}
item, ok := manager.Pool().ByID(account.ID)
// fakeStarter announces the process before Start returns. Wait until the
// manager has registered that process and copied the restart count into the
// pool instead of racing the remainder of startAccount.
deadline := time.Now().Add(time.Second)
var item executor.Item
var ok bool
for time.Now().Before(deadline) {
item, ok = manager.Pool().ByID(account.ID)
if ok && item.Restarts == 2 && item.RestartBackoffLevel == 2 {
break
}
time.Sleep(time.Millisecond)
}
if !ok {
t.Fatal("account disappeared during consecutive recovery")
}
Expand Down
9 changes: 5 additions & 4 deletions internal/store/request_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,10 @@ func (s *Store) SummarizeRequestLogs(ctx context.Context, from, to time.Time) (a

row := s.db.QueryRowContext(ctx, `
SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0),
COUNT(*),
COALESCE(SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'incomplete' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN stream = 1 THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN latency_ms IS NOT NULL THEN latency_ms END),
Expand All @@ -194,7 +195,7 @@ SELECT
FROM request_logs`+where, args...)
var avgLatency, avgTTFB sql.NullFloat64
if err := row.Scan(
&stats.Totals.Requests, &stats.Totals.OK, &stats.Totals.Error, &stats.Totals.Canceled, &stats.Totals.Streaming,
&stats.Totals.Requests, &stats.Totals.OK, &stats.Totals.Incomplete, &stats.Totals.Error, &stats.Totals.Canceled, &stats.Totals.Streaming,
&avgLatency, &avgTTFB, &stats.Tokens.Prompt, &stats.Tokens.Completion, &stats.Tokens.CacheRead,
); err != nil {
return accounts.RequestStats{}, fmt.Errorf("summarize request logs: %w", err)
Expand Down
13 changes: 7 additions & 6 deletions internal/store/request_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ func TestSummarizeRequestLogs(t *testing.T) {
insert(base.Add(20*time.Minute), accounts.RequestStatusOK, "glm-5.3", "acc_a", "qoder", "", 200, 10, 20, true)
insert(base.Add(90*time.Minute), accounts.RequestStatusError, "qwen3.7-plus", "acc_b", "workbuddy", accounts.KindRateLimit, 400, 8, 0, false)
insert(base.Add(2*time.Hour), accounts.RequestStatusCanceled, "glm-5.3", "acc_a", "qoder", "", 0, 0, 0, false)
insert(base.Add(2*time.Hour+15*time.Minute), accounts.RequestStatusIncomplete, "glm-5.3", "acc_a", "qoder", "", 0, 0, 0, false)
insert(base.Add(-30*time.Hour), accounts.RequestStatusOK, "old", "acc_a", "qoder", "", 50, 1, 1, false)

from := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
Expand All @@ -355,10 +356,10 @@ func TestSummarizeRequestLogs(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stats.Totals.Requests != 4 || stats.Totals.OK != 2 || stats.Totals.Error != 1 || stats.Totals.Canceled != 1 || stats.Totals.Streaming != 1 {
if stats.Totals.Requests != 5 || stats.Totals.OK != 2 || stats.Totals.Incomplete != 1 || stats.Totals.Error != 1 || stats.Totals.Canceled != 1 || stats.Totals.Streaming != 1 {
t.Fatalf("totals = %+v", stats.Totals)
}
if stats.Totals.SuccessRate != 0.5 {
if stats.Totals.SuccessRate != 0.4 {
t.Fatalf("success rate = %v", stats.Totals.SuccessRate)
}
if stats.Tokens.Prompt != 30 || stats.Tokens.Completion != 54 || stats.Tokens.Total != 84 {
Expand All @@ -376,19 +377,19 @@ func TestSummarizeRequestLogs(t *testing.T) {
if len(stats.Errors) != 1 || stats.Errors[0].Key != accounts.KindRateLimit || stats.Errors[0].Count != 1 {
t.Fatalf("errors = %+v", stats.Errors)
}
if len(stats.Models) == 0 || stats.Models[0].Key != "glm-5.3" || stats.Models[0].Count != 3 {
if len(stats.Models) == 0 || stats.Models[0].Key != "glm-5.3" || stats.Models[0].Count != 4 {
t.Fatalf("models = %+v", stats.Models)
}
if len(stats.Accounts) == 0 || stats.Accounts[0].Key != "acc_a" || stats.Accounts[0].Count != 3 {
if len(stats.Accounts) == 0 || stats.Accounts[0].Key != "acc_a" || stats.Accounts[0].Count != 4 {
t.Fatalf("accounts = %+v", stats.Accounts)
}
if len(stats.Providers) == 0 || stats.Providers[0].Key != "qoder" || stats.Providers[0].Count != 3 {
if len(stats.Providers) == 0 || stats.Providers[0].Key != "qoder" || stats.Providers[0].Count != 4 {
t.Fatalf("providers = %+v", stats.Providers)
}
if len(stats.Series) != 3 {
t.Fatalf("series len = %d %+v", len(stats.Series), stats.Series)
}
if stats.Series[0].Requests != 2 || stats.Series[1].Requests != 1 || stats.Series[2].Requests != 1 {
if stats.Series[0].Requests != 2 || stats.Series[1].Requests != 1 || stats.Series[2].Requests != 2 {
t.Fatalf("series = %+v", stats.Series)
}

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-DPnzAZji.js"></script>
<script type="module" crossorigin src="/assets/index-cijSDZXp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WwQDPEz6.css">
</head>
<body>
Expand Down
Loading