feat: add retry wave visibility controls
This commit is contained in:
+275
-6
@@ -2,6 +2,7 @@ import { FormEvent, startTransition, useEffect, useState } from "react";
|
||||
|
||||
type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
||||
type Tone = "" | "success" | "error";
|
||||
type ReasoningMatchMode = "formula_518n_minus_2" | "manual";
|
||||
|
||||
type GatewayConfig = {
|
||||
profile_name?: string;
|
||||
@@ -16,6 +17,7 @@ type GatewayConfig = {
|
||||
request_history_limit?: number;
|
||||
model_remap?: string;
|
||||
endpoints?: string[];
|
||||
reasoning_match_mode?: ReasoningMatchMode;
|
||||
reasoning_equals?: number[];
|
||||
retryable_status_codes?: number[];
|
||||
retryable_error_messages?: string[];
|
||||
@@ -90,10 +92,36 @@ type RequestEntry = {
|
||||
model?: string | null;
|
||||
requested_model?: string | null;
|
||||
forwarded_model?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
reasoning_summary?: string | null;
|
||||
response_stream?: boolean;
|
||||
stream_chunk_count?: number | null;
|
||||
usage_last_updated_at?: string | null;
|
||||
upstream_attempt_count?: number | null;
|
||||
reasoning_retry_enabled?: boolean;
|
||||
reasoning_retry_query_count?: number | null;
|
||||
reasoning_retry_round_count?: number | null;
|
||||
reasoning_retry_current_round?: number | null;
|
||||
reasoning_retry_current_width?: number | null;
|
||||
reasoning_retry_current_firsts?: Array<{
|
||||
round?: number | null;
|
||||
slot?: number | null;
|
||||
first_response_at?: string | null;
|
||||
first_response_delay_ms?: number | null;
|
||||
outcome?: string | null;
|
||||
status_code?: number | null;
|
||||
upstream_status_code?: number | null;
|
||||
reasoning_tokens?: number | null;
|
||||
matched?: boolean | null;
|
||||
}> | null;
|
||||
reasoning_retry_winner_round?: number | null;
|
||||
reasoning_retry_winner_slot?: number | null;
|
||||
reasoning_retry_stop_reason?: string | null;
|
||||
reasoning_retry_thread_mode?: string | null;
|
||||
reasoning_retry_extra_inspected_count?: number | null;
|
||||
reasoning_retry_extra_matched_count?: number | null;
|
||||
reasoning_retry_extra_usage?: Usage | null;
|
||||
reasoning_retry_extra_reasoning_counts?: Record<string, number> | null;
|
||||
matched?: boolean;
|
||||
status_code?: number | null;
|
||||
upstream_status_code?: number | null;
|
||||
@@ -130,6 +158,7 @@ type ProfileFormModel = {
|
||||
auth_json_key?: string;
|
||||
request_history_limit?: string;
|
||||
model_remap?: string;
|
||||
reasoning_match_mode?: ReasoningMatchMode;
|
||||
reasoning_equals?: string;
|
||||
retryable_status_codes?: string;
|
||||
retryable_error_messages?: string[];
|
||||
@@ -150,6 +179,7 @@ type Profile = {
|
||||
auth_source?: string;
|
||||
request_history_limit?: string;
|
||||
model_remap?: string;
|
||||
reasoning_match_mode?: ReasoningMatchMode;
|
||||
reasoning_equals?: string;
|
||||
};
|
||||
form?: ProfileFormModel;
|
||||
@@ -199,6 +229,7 @@ type ProfileFormState = {
|
||||
auth_json_key: string;
|
||||
request_history_limit: string;
|
||||
model_remap: string;
|
||||
reasoning_match_mode: ReasoningMatchMode;
|
||||
reasoning_equals: string;
|
||||
retryable_status_codes: string;
|
||||
retryable_error_messages: string;
|
||||
@@ -208,6 +239,7 @@ type ProfileFormState = {
|
||||
};
|
||||
|
||||
type RuleFormState = {
|
||||
reasoning_match_mode: ReasoningMatchMode;
|
||||
reasoning_equals: string;
|
||||
retryable_status_codes: string;
|
||||
retryable_error_messages: string;
|
||||
@@ -307,6 +339,7 @@ const defaultProfileForm: ProfileFormState = {
|
||||
auth_json_key: "OPENAI_API_KEY",
|
||||
request_history_limit: "0",
|
||||
model_remap: "",
|
||||
reasoning_match_mode: "formula_518n_minus_2",
|
||||
reasoning_equals: "516,1034,1552",
|
||||
retryable_status_codes: "429,503",
|
||||
retryable_error_messages: "Selected model is at capacity. Please try a different model.\nstream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||
@@ -333,6 +366,17 @@ function durationSeconds(value?: number | null) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${(value / 1000).toFixed(2)} s` : "-";
|
||||
}
|
||||
|
||||
function compactDurationSeconds(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return "?";
|
||||
}
|
||||
const seconds = value / 1000;
|
||||
if (seconds >= 100) {
|
||||
return `${seconds.toFixed(0)}s`;
|
||||
}
|
||||
return `${seconds.toFixed(1).replace(/\\.0$/, "")}s`;
|
||||
}
|
||||
|
||||
function secondsSince(startedAt: string | null | undefined, updatedAt: string | null | undefined) {
|
||||
if (!startedAt || !updatedAt) {
|
||||
return "-";
|
||||
@@ -392,6 +436,98 @@ function requestPrimaryId(entry: RequestEntry) {
|
||||
return entry.response_id || entry.request_id || "-";
|
||||
}
|
||||
|
||||
function hasReasoningRetryInfo(entry: RequestEntry) {
|
||||
return Boolean(
|
||||
entry.reasoning_retry_enabled ||
|
||||
entry.reasoning_retry_thread_mode ||
|
||||
entry.reasoning_retry_query_count ||
|
||||
entry.reasoning_retry_round_count ||
|
||||
entry.reasoning_retry_stop_reason,
|
||||
);
|
||||
}
|
||||
|
||||
function retryRoundWidthText(entry: RequestEntry) {
|
||||
const round = entry.reasoning_retry_current_round || entry.reasoning_retry_round_count || 0;
|
||||
const width = entry.reasoning_retry_current_width || 0;
|
||||
if (!round) {
|
||||
return "-";
|
||||
}
|
||||
return width ? `${numberFormat(round)}(${numberFormat(width)})` : numberFormat(round);
|
||||
}
|
||||
|
||||
function retryFirstLabel(first: NonNullable<RequestEntry["reasoning_retry_current_firsts"]>[number], fallbackRound?: number | null) {
|
||||
const round = first.round ?? fallbackRound ?? null;
|
||||
const slot = first.slot ?? null;
|
||||
if (typeof round === "number" && Number.isInteger(round)) {
|
||||
if (typeof slot === "number" && Number.isInteger(slot) && slot > 1) {
|
||||
return `${numberFormat(round)}-${numberFormat(slot)}`;
|
||||
}
|
||||
return numberFormat(round);
|
||||
}
|
||||
if (typeof slot === "number" && Number.isInteger(slot)) {
|
||||
return `#${numberFormat(slot)}`;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
function retryFirstText(
|
||||
first: NonNullable<RequestEntry["reasoning_retry_current_firsts"]>[number],
|
||||
fallbackRound?: number | null,
|
||||
) {
|
||||
const delay = durationSeconds(first.first_response_delay_ms);
|
||||
const outcome = first.outcome || "pending";
|
||||
const reasoning = typeof first.reasoning_tokens === "number" ? ` r${numberFormat(first.reasoning_tokens)}` : "";
|
||||
const status = typeof first.status_code === "number" ? ` ${first.status_code}` : "";
|
||||
return `${retryFirstLabel(first, fallbackRound)} ${delay} ${outcome}${status}${reasoning}`;
|
||||
}
|
||||
|
||||
function retryFirstCompactText(
|
||||
first: NonNullable<RequestEntry["reasoning_retry_current_firsts"]>[number],
|
||||
) {
|
||||
return compactDurationSeconds(first.first_response_delay_ms);
|
||||
}
|
||||
|
||||
function formatRetryStopReason(value?: string | null) {
|
||||
const reason = `${value || ""}`.trim();
|
||||
if (!reason) {
|
||||
return "-";
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
success: "成功",
|
||||
missing_thread_id: "缺少 thread_id",
|
||||
completed_without_retry: "未触发调度",
|
||||
reasoning_guard: "reasoning 命中",
|
||||
retryable_upstream_error: "上游可重试错误",
|
||||
fatal: "致命错误",
|
||||
exhausted_without_winner: "无赢家",
|
||||
};
|
||||
return labels[reason] || reason;
|
||||
}
|
||||
|
||||
function formatRetryThreadMode(value?: string | null) {
|
||||
const mode = `${value || ""}`.trim();
|
||||
if (!mode || mode === "disabled") {
|
||||
return "未启用";
|
||||
}
|
||||
if (mode === "thread_id") {
|
||||
return "按 thread_id";
|
||||
}
|
||||
if (mode === "missing_thread_id") {
|
||||
return "缺少 thread_id";
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function sortedReasoningCounts(value?: Record<string, number> | null) {
|
||||
return Object.entries(value || {}).sort((left, right) => {
|
||||
const countDelta = Number(right[1] || 0) - Number(left[1] || 0);
|
||||
if (countDelta !== 0) {
|
||||
return countDelta;
|
||||
}
|
||||
return Number(left[0] || 0) - Number(right[0] || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function splitList(value: string) {
|
||||
return value
|
||||
.split(/[\s,]+/)
|
||||
@@ -406,6 +542,21 @@ function splitLines(value: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeReasoningMode(value: unknown): ReasoningMatchMode {
|
||||
return value === "manual" ? "manual" : "formula_518n_minus_2";
|
||||
}
|
||||
|
||||
function formatReasoningMode(mode: ReasoningMatchMode) {
|
||||
return mode === "manual" ? "manual" : "518n-2";
|
||||
}
|
||||
|
||||
function formatReasoningRule(mode: ReasoningMatchMode, reasoningEquals?: string) {
|
||||
if (mode === "manual") {
|
||||
return reasoningEquals || "-";
|
||||
}
|
||||
return "516, 1034, 1552, ...";
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers || {});
|
||||
const accessKey = window.localStorage.getItem(ACCESS_KEY_STORAGE_KEY)?.trim();
|
||||
@@ -435,6 +586,7 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
||||
auth_json_key: config.upstream_auth_json_key || defaultProfileForm.auth_json_key,
|
||||
request_history_limit: String(config.request_history_limit ?? defaultProfileForm.request_history_limit),
|
||||
model_remap: config.model_remap || "",
|
||||
reasoning_match_mode: normalizeReasoningMode(config.reasoning_match_mode),
|
||||
reasoning_equals: Array.isArray(config.reasoning_equals)
|
||||
? config.reasoning_equals.join(",")
|
||||
: defaultProfileForm.reasoning_equals,
|
||||
@@ -472,7 +624,8 @@ function profileFormFromProfile(profile: Profile): ProfileFormState {
|
||||
auth_json_key: form.auth_json_key || "OPENAI_API_KEY",
|
||||
request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit,
|
||||
model_remap: form.model_remap || "",
|
||||
reasoning_equals: form.reasoning_equals || "",
|
||||
reasoning_match_mode: normalizeReasoningMode(form.reasoning_match_mode),
|
||||
reasoning_equals: form.reasoning_equals || defaultProfileForm.reasoning_equals,
|
||||
retryable_status_codes: form.retryable_status_codes || defaultProfileForm.retryable_status_codes,
|
||||
retryable_error_messages: Array.isArray(form.retryable_error_messages)
|
||||
? form.retryable_error_messages.join("\n")
|
||||
@@ -488,6 +641,7 @@ function profileFormFromProfile(profile: Profile): ProfileFormState {
|
||||
function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
||||
const config = status?.config || {};
|
||||
return {
|
||||
reasoning_match_mode: normalizeReasoningMode(config.reasoning_match_mode),
|
||||
reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "",
|
||||
retryable_status_codes: Array.isArray(config.retryable_status_codes)
|
||||
? config.retryable_status_codes.join(", ")
|
||||
@@ -724,6 +878,7 @@ export default function App() {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
reasoning_match_mode: ruleForm.reasoning_match_mode,
|
||||
reasoning_equals: splitList(ruleForm.reasoning_equals)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value)),
|
||||
@@ -784,6 +939,7 @@ export default function App() {
|
||||
auth_json_key: profileForm.auth_json_key,
|
||||
request_history_limit: Number.parseInt(profileForm.request_history_limit, 10),
|
||||
model_remap: profileForm.model_remap,
|
||||
reasoning_match_mode: profileForm.reasoning_match_mode,
|
||||
reasoning_equals: splitList(profileForm.reasoning_equals),
|
||||
retryable_status_codes: splitList(profileForm.retryable_status_codes),
|
||||
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
|
||||
@@ -1089,6 +1245,11 @@ export default function App() {
|
||||
const statusTone = entry.error ? "error" : entry.matched ? "warn" : "";
|
||||
const effectiveInput = effectiveInputTokens(usage.input_tokens, usage.cached_tokens);
|
||||
const cachedHitRatio = cachedRatio(usage.input_tokens, usage.cached_tokens);
|
||||
const showReasoningRetry = hasReasoningRetryInfo(entry);
|
||||
const retryReasoningCounts = sortedReasoningCounts(entry.reasoning_retry_extra_reasoning_counts);
|
||||
const retryCurrentFirsts = Array.isArray(entry.reasoning_retry_current_firsts)
|
||||
? entry.reasoning_retry_current_firsts
|
||||
: [];
|
||||
return (
|
||||
<article className="request-card" key={entry.seq}>
|
||||
<div className="request-head">
|
||||
@@ -1101,10 +1262,21 @@ export default function App() {
|
||||
<span className="meta-key">发</span>
|
||||
{timestamp(entry.started_at)}
|
||||
</span>
|
||||
<span className="meta-pill meta-first">
|
||||
<span className="meta-key">首</span>
|
||||
{durationSeconds(entry.first_response_delay_ms)}
|
||||
</span>
|
||||
{retryCurrentFirsts.length > 0 ? (
|
||||
<span className="meta-pill meta-first meta-first-list-pill">
|
||||
<span className="meta-key">首</span>
|
||||
{retryCurrentFirsts.map((first, index) => (
|
||||
<span className="meta-first-mini" key={`${first.round || entry.reasoning_retry_current_round || "r"}-${first.slot || index}`}>
|
||||
{retryFirstCompactText(first)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="meta-pill meta-first">
|
||||
<span className="meta-key">首</span>
|
||||
{durationSeconds(entry.first_response_delay_ms)}
|
||||
</span>
|
||||
)}
|
||||
<span className="meta-pill meta-total">
|
||||
<span className="meta-key">总</span>
|
||||
{durationSeconds(entry.duration_ms)}
|
||||
@@ -1123,6 +1295,12 @@ export default function App() {
|
||||
? `${numberFormat(entry.stream_chunk_count)} chunk / ${bytesFormat(entry.response_bytes_received)} / ${secondsSince(entry.started_at, entry.last_activity_at || entry.usage_last_updated_at || entry.finished_at)}`
|
||||
: "-"}
|
||||
</span>
|
||||
{showReasoningRetry ? (
|
||||
<span className="meta-pill meta-retry">
|
||||
<span className="meta-key">重打</span>
|
||||
{`${retryRoundWidthText(entry)} / ${numberFormat(entry.reasoning_retry_query_count || 0)}q`}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="meta-pill meta-received">
|
||||
<span className="meta-key">收</span>
|
||||
{timestamp(entry.finished_at)}
|
||||
@@ -1138,6 +1316,11 @@ export default function App() {
|
||||
<div className="request-badges">
|
||||
{entry.matched ? <span className="badge warn">matched</span> : <span className="badge">pass</span>}
|
||||
{entry.error ? <span className="badge error">error</span> : null}
|
||||
{showReasoningRetry ? (
|
||||
<span className={`badge ${entry.reasoning_retry_stop_reason === "success" ? "success" : "warn"}`}>
|
||||
retry {formatRetryStopReason(entry.reasoning_retry_stop_reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1150,6 +1333,12 @@ export default function App() {
|
||||
? `转发为 ${entry.forwarded_model}`
|
||||
: entry.forwarded_model || "-"}
|
||||
</span>
|
||||
<span className="hint">
|
||||
{entry.reasoning_effort
|
||||
? `强度 ${entry.reasoning_effort}`
|
||||
: "强度 -"}
|
||||
{entry.reasoning_summary ? ` / summary ${entry.reasoning_summary}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="request-block">
|
||||
@@ -1182,6 +1371,54 @@ export default function App() {
|
||||
<span className="hint">{`request ${entry.request_id || "-"}`}</span>
|
||||
<span className="hint">{`thread ${entry.thread_id || "-"}`}</span>
|
||||
</div>
|
||||
|
||||
{showReasoningRetry ? (
|
||||
<div className="request-block reasoning-retry-block">
|
||||
<label>Reasoning Retry</label>
|
||||
<code>{formatRetryThreadMode(entry.reasoning_retry_thread_mode)}</code>
|
||||
<span className="hint">
|
||||
schedule 1,1,2,2,4,4... / query {numberFormat(entry.reasoning_retry_query_count || 0)}
|
||||
{" / "}
|
||||
round {retryRoundWidthText(entry)}
|
||||
</span>
|
||||
<span className="hint">
|
||||
winner {entry.reasoning_retry_winner_round && entry.reasoning_retry_winner_slot
|
||||
? `round ${entry.reasoning_retry_winner_round} slot ${entry.reasoning_retry_winner_slot}`
|
||||
: "-"}
|
||||
{" / "}
|
||||
stop {formatRetryStopReason(entry.reasoning_retry_stop_reason)}
|
||||
</span>
|
||||
<span className="hint">
|
||||
extra matched {numberFormat(entry.reasoning_retry_extra_matched_count || 0)}
|
||||
{" / "}
|
||||
inspected {numberFormat(entry.reasoning_retry_extra_inspected_count || 0)}
|
||||
{" / "}
|
||||
extra reasoning {numberFormat(entry.reasoning_retry_extra_usage?.reasoning_tokens)}
|
||||
</span>
|
||||
{retryReasoningCounts.length > 0 ? (
|
||||
<div className="retry-chip-row">
|
||||
{retryReasoningCounts.slice(0, 4).map(([reasoning, count]) => (
|
||||
<span className="chip compact-chip" key={reasoning}>
|
||||
{reasoning}: {numberFormat(count)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{retryCurrentFirsts.length > 0 ? (
|
||||
<div className="retry-first-list" aria-label="current retry wave first responses">
|
||||
{retryCurrentFirsts.map((first, index) => (
|
||||
<span
|
||||
className={`retry-first-chip ${first.first_response_delay_ms == null ? "pending" : ""}`}
|
||||
key={`${first.round || entry.reasoning_retry_current_round || "r"}-${first.slot || index}`}
|
||||
title={`first ${timestamp(first.first_response_at)} / upstream ${first.upstream_status_code ?? "-"}`}
|
||||
>
|
||||
{retryFirstText(first, entry.reasoning_retry_current_round)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -1246,7 +1483,17 @@ export default function App() {
|
||||
<MiniStat label="Auth Source" value={profile.summary?.auth_source || "-"} />
|
||||
<MiniStat label="History" value={profile.summary?.request_history_limit || "0"} />
|
||||
<MiniStat label="Model Remap" value={profile.summary?.model_remap || "-"} />
|
||||
<MiniStat label="Reasoning" value={profile.summary?.reasoning_equals || "-"} />
|
||||
<MiniStat
|
||||
label="Reasoning"
|
||||
value={formatReasoningRule(
|
||||
normalizeReasoningMode(profile.summary?.reasoning_match_mode),
|
||||
profile.summary?.reasoning_equals,
|
||||
)}
|
||||
/>
|
||||
<MiniStat
|
||||
label="Rule Mode"
|
||||
value={formatReasoningMode(normalizeReasoningMode(profile.summary?.reasoning_match_mode))}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
@@ -1323,6 +1570,17 @@ export default function App() {
|
||||
onChange={(event) => setProfileForm({ ...profileForm, request_history_limit: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="reasoning_match_mode" hint="`518n-2` 会命中 516、1034、1552 等;`manual` 则只按下面的列表拦截。">
|
||||
<select
|
||||
value={profileForm.reasoning_match_mode}
|
||||
onChange={(event) =>
|
||||
setProfileForm({ ...profileForm, reasoning_match_mode: normalizeReasoningMode(event.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="formula_518n_minus_2">formula_518n_minus_2</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="reasoning_equals">
|
||||
<input value={profileForm.reasoning_equals} placeholder="516,1034,1552" onChange={(event) => setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} />
|
||||
</Field>
|
||||
@@ -1396,6 +1654,17 @@ export default function App() {
|
||||
<section className="page" data-active="true">
|
||||
<Card title="当前运行规则" hint="保存后会热生效,只影响当前正在运行的 gateway config;长期 profile 默认值请去 Profiles 页保存。">
|
||||
<form onSubmit={saveRules}>
|
||||
<Field label="reasoning_match_mode" hint="`518n-2` 会命中 516、1034、1552 等;切到 `manual` 时才只按手写列表判断。">
|
||||
<select
|
||||
value={ruleForm.reasoning_match_mode}
|
||||
onChange={(event) =>
|
||||
setRuleForm({ ...ruleForm, reasoning_match_mode: normalizeReasoningMode(event.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="formula_518n_minus_2">formula_518n_minus_2</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="reasoning_equals">
|
||||
<input value={ruleForm.reasoning_equals} placeholder="例如:516, 1034, 1552" onChange={(event) => setRuleForm({ ...ruleForm, reasoning_equals: event.target.value })} />
|
||||
</Field>
|
||||
|
||||
Reference in New Issue
Block a user