2459 lines
109 KiB
TypeScript
2459 lines
109 KiB
TypeScript
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 AuthMode = "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json";
|
|
|
|
type GatewayConfig = {
|
|
profile_name?: string;
|
|
image_profile_name?: string;
|
|
listen_host?: string;
|
|
listen_port?: number;
|
|
management_access_key?: string;
|
|
management_access_key_configured?: boolean;
|
|
upstream_base_url?: string;
|
|
upstream_auth_mode?: string;
|
|
upstream_auth_env?: string | null;
|
|
upstream_auth_json_key?: string | null;
|
|
image_base_url?: string;
|
|
image_auth_mode?: string;
|
|
image_auth_env?: string | null;
|
|
image_auth_json_key?: string | null;
|
|
request_history_limit?: number;
|
|
model_remap?: string;
|
|
endpoints?: string[];
|
|
reasoning_match_mode?: ReasoningMatchMode;
|
|
reasoning_equals?: number[];
|
|
retryable_status_codes?: number[];
|
|
retryable_error_messages?: string[];
|
|
upstream_fetch_retry_attempts?: number;
|
|
upstream_fetch_retry_backoff_ms?: number;
|
|
non_stream_status_code?: number;
|
|
log_match?: boolean;
|
|
};
|
|
|
|
type Metrics = {
|
|
started_at?: string;
|
|
persistent_since?: string | null;
|
|
total_proxy_request_count?: number;
|
|
inspected_response_count?: number;
|
|
matched_response_count?: number;
|
|
reasoning_516_count?: number;
|
|
reasoning_516_ratio?: number;
|
|
token_totals?: {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
total_tokens?: number;
|
|
reasoning_tokens?: number;
|
|
cached_tokens?: number;
|
|
};
|
|
observed_reasoning_counts?: Record<string, number>;
|
|
observed_reasoning_counts_total_keys?: number;
|
|
observed_reasoning_counts_omitted?: number;
|
|
};
|
|
|
|
type StatusPayload = {
|
|
ok: boolean;
|
|
listen?: string;
|
|
config?: GatewayConfig;
|
|
state?: {
|
|
provider_name?: string;
|
|
codex_current_base_url?: string;
|
|
latest_backup_path?: string;
|
|
};
|
|
paths?: {
|
|
config_path?: string;
|
|
profiles_dir?: string;
|
|
image_profiles_dir?: string;
|
|
log_path?: string;
|
|
thread_rules_path?: string;
|
|
};
|
|
metrics?: Metrics;
|
|
};
|
|
|
|
type Usage = {
|
|
input_tokens?: number | null;
|
|
output_tokens?: number | null;
|
|
total_tokens?: number | null;
|
|
reasoning_tokens?: number | null;
|
|
cached_tokens?: number | null;
|
|
};
|
|
|
|
type RequestEntry = {
|
|
seq: number;
|
|
request_id?: string | null;
|
|
response_id?: string | null;
|
|
thread_id?: string | null;
|
|
lifecycle_state?: string | null;
|
|
started_at?: string;
|
|
first_response_at?: string | null;
|
|
first_response_delay_ms?: number | null;
|
|
finished_at?: string | null;
|
|
last_activity_at?: string | null;
|
|
duration_ms?: number | null;
|
|
profile_name?: string;
|
|
method?: string;
|
|
path?: string;
|
|
request_body_bytes?: number | null;
|
|
response_bytes_received?: number | null;
|
|
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_guard_enabled?: boolean;
|
|
reasoning_guard_thread_override?: string | 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;
|
|
upstream?: {
|
|
origin?: string;
|
|
path?: string;
|
|
route?: "default" | "images" | string;
|
|
auth_mode?: string;
|
|
auth_source?: string;
|
|
authorization_configured?: boolean;
|
|
status?: number;
|
|
content_type?: string;
|
|
} | null;
|
|
reasoning_tokens?: number | null;
|
|
usage?: Usage | null;
|
|
error?: string | null;
|
|
};
|
|
|
|
type RequestsPayload = {
|
|
total_entries?: number;
|
|
latest_seq?: number;
|
|
entries?: RequestEntry[];
|
|
};
|
|
|
|
type ThreadRuleEntry = {
|
|
thread_id: string;
|
|
reasoning_intercept_enabled: boolean;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
type ThreadRulesPayload = {
|
|
ok: boolean;
|
|
thread_rules_path?: string;
|
|
rules?: ThreadRuleEntry[];
|
|
saved_rule?: ThreadRuleEntry | null;
|
|
removed_rule?: ThreadRuleEntry | null;
|
|
message?: string;
|
|
};
|
|
|
|
type ProfileFormModel = {
|
|
listen_host?: string;
|
|
listen_port?: string;
|
|
upstream_base_url?: string;
|
|
auth_mode?: string;
|
|
auth_env?: string;
|
|
auth_file?: string;
|
|
manual_secret_file?: string;
|
|
manual_secret_configured?: boolean;
|
|
auth_json_path?: string;
|
|
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[];
|
|
upstream_fetch_retry_attempts?: string;
|
|
upstream_fetch_retry_backoff_ms?: string;
|
|
endpoints?: string[];
|
|
};
|
|
|
|
type Profile = {
|
|
name: string;
|
|
active: boolean;
|
|
file_path?: string;
|
|
summary?: {
|
|
listen_host?: string;
|
|
listen_port?: string;
|
|
upstream_base_url?: string;
|
|
auth_mode?: string;
|
|
auth_source?: string;
|
|
request_history_limit?: string;
|
|
model_remap?: string;
|
|
reasoning_match_mode?: ReasoningMatchMode;
|
|
reasoning_equals?: string;
|
|
};
|
|
form?: ProfileFormModel;
|
|
};
|
|
|
|
type ImageProfileFormModel = {
|
|
base_url?: string;
|
|
auth_mode?: string;
|
|
auth_env?: string;
|
|
auth_file?: string;
|
|
manual_secret_file?: string;
|
|
manual_secret_configured?: boolean;
|
|
auth_json_path?: string;
|
|
auth_json_key?: string;
|
|
};
|
|
|
|
type ImageProfile = {
|
|
name: string;
|
|
active: boolean;
|
|
file_path?: string;
|
|
summary?: {
|
|
base_url?: string;
|
|
auth_mode?: string;
|
|
auth_source?: string;
|
|
};
|
|
form?: ImageProfileFormModel;
|
|
};
|
|
|
|
type ProfilesPayload = {
|
|
profiles_dir?: string;
|
|
active_profile?: string;
|
|
applied_profile?: {
|
|
profile?: string;
|
|
hot_swapped?: boolean;
|
|
listen?: string;
|
|
upstream_base_url?: string;
|
|
} | null;
|
|
profiles?: Profile[];
|
|
};
|
|
|
|
type ImageProfilesPayload = {
|
|
image_profiles_dir?: string;
|
|
active_image_profile?: string;
|
|
applied_image_profile?: {
|
|
image_profile?: string;
|
|
hot_swapped?: boolean;
|
|
image_base_url?: string;
|
|
} | null;
|
|
image_profiles?: ImageProfile[];
|
|
};
|
|
|
|
type LogEntry = {
|
|
seq: number;
|
|
at?: string;
|
|
message?: string;
|
|
};
|
|
|
|
type LogsPayload = {
|
|
total_entries?: number;
|
|
latest_seq?: number;
|
|
entries?: LogEntry[];
|
|
};
|
|
|
|
type MessageState = {
|
|
text: string;
|
|
tone: Tone;
|
|
};
|
|
|
|
type ProfileFormState = {
|
|
name: string;
|
|
listen_host: string;
|
|
listen_port: string;
|
|
upstream_base_url: string;
|
|
auth_mode: AuthMode;
|
|
auth_env: string;
|
|
auth_file: string;
|
|
manual_secret: string;
|
|
manual_secret_file: string;
|
|
manual_secret_configured: boolean;
|
|
auth_json_path: string;
|
|
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;
|
|
upstream_fetch_retry_attempts: string;
|
|
upstream_fetch_retry_backoff_ms: string;
|
|
endpoints: string;
|
|
};
|
|
|
|
type ImageProfileFormState = {
|
|
name: string;
|
|
base_url: string;
|
|
auth_mode: AuthMode;
|
|
auth_env: string;
|
|
auth_file: string;
|
|
manual_secret: string;
|
|
manual_secret_file: string;
|
|
manual_secret_configured: boolean;
|
|
auth_json_path: string;
|
|
auth_json_key: string;
|
|
};
|
|
|
|
type RuleFormState = {
|
|
reasoning_match_mode: ReasoningMatchMode;
|
|
reasoning_equals: string;
|
|
retryable_status_codes: string;
|
|
retryable_error_messages: string;
|
|
upstream_fetch_retry_attempts: string;
|
|
upstream_fetch_retry_backoff_ms: string;
|
|
endpoints: string;
|
|
non_stream_status_code: string;
|
|
log_match: boolean;
|
|
};
|
|
|
|
const api = {
|
|
status: "/__codex_retry_gateway/api/status",
|
|
config: "/__codex_retry_gateway/api/config",
|
|
logs: "/__codex_retry_gateway/api/logs",
|
|
requests: "/__codex_retry_gateway/api/requests",
|
|
threadRules: "/__codex_retry_gateway/api/thread-rules",
|
|
profiles: "/__codex_retry_gateway/api/profiles",
|
|
profileProbe: "/__codex_retry_gateway/api/profiles/probe",
|
|
profileSwitch: "/__codex_retry_gateway/api/profiles/switch",
|
|
imageProfiles: "/__codex_retry_gateway/api/image-profiles",
|
|
imageProfileProbe: "/__codex_retry_gateway/api/image-profiles/probe",
|
|
imageProfileSwitch: "/__codex_retry_gateway/api/image-profiles/switch",
|
|
restore: "/__codex_retry_gateway/api/restore",
|
|
};
|
|
|
|
const ACCESS_KEY_STORAGE_KEY = "codex_retry_gateway_access_key";
|
|
|
|
const REQUEST_PAGE_SIZE = 20;
|
|
const LOG_PAGE_SIZE = 200;
|
|
|
|
const zhNumberFormatter = new Intl.NumberFormat("zh-CN");
|
|
const zhTimestampFormatter = new Intl.DateTimeFormat("zh-CN", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hourCycle: "h23",
|
|
});
|
|
|
|
type ProfileProbePayload = {
|
|
ok: boolean;
|
|
profile?: string;
|
|
image_profile?: string;
|
|
upstream_base_url?: string;
|
|
image_base_url?: string;
|
|
auth_mode?: string;
|
|
auth_source?: string;
|
|
authorization_configured?: boolean;
|
|
model_remap?: string;
|
|
probes?: Array<{
|
|
kind?: string;
|
|
target?: string;
|
|
status?: number;
|
|
requested_model?: string;
|
|
forwarded_model?: string;
|
|
content_type?: string;
|
|
body_preview?: string;
|
|
}>;
|
|
};
|
|
|
|
const pageCopy: Record<PageKey, { title: string; subtitle: string; index: string }> = {
|
|
overview: {
|
|
title: "概览",
|
|
subtitle: "当前 gateway 运行态,以及基于持久请求记录回灌的累计统计。",
|
|
index: "01",
|
|
},
|
|
requests: {
|
|
title: "请求",
|
|
subtitle: "每次请求的时间戳、状态、token usage 和命中情况。",
|
|
index: "02",
|
|
},
|
|
profiles: {
|
|
title: "Profiles",
|
|
subtitle: "文本和图片上游分别保存、探测与热切换,互不改写对方配置。",
|
|
index: "03",
|
|
},
|
|
rules: {
|
|
title: "规则",
|
|
subtitle: "热更新当前拦截规则;profile 默认规则请在 Profiles 页保存。",
|
|
index: "04",
|
|
},
|
|
logs: {
|
|
title: "日志",
|
|
subtitle: "当前进程的实时日志,不含请求正文或响应正文。",
|
|
index: "05",
|
|
},
|
|
};
|
|
|
|
const defaultProfileForm: ProfileFormState = {
|
|
name: "",
|
|
listen_host: "100.115.235.115",
|
|
listen_port: "4610",
|
|
upstream_base_url: "",
|
|
auth_mode: "passthrough",
|
|
auth_env: "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY",
|
|
auth_file: "",
|
|
manual_secret: "",
|
|
manual_secret_file: "",
|
|
manual_secret_configured: false,
|
|
auth_json_path: "",
|
|
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",
|
|
upstream_fetch_retry_attempts: "5",
|
|
upstream_fetch_retry_backoff_ms: "350",
|
|
endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions",
|
|
};
|
|
|
|
const defaultImageProfileForm: ImageProfileFormState = {
|
|
name: "",
|
|
base_url: "",
|
|
auth_mode: "fixed_bearer",
|
|
auth_env: "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
|
auth_file: "",
|
|
manual_secret: "",
|
|
manual_secret_file: "",
|
|
manual_secret_configured: false,
|
|
auth_json_path: "",
|
|
auth_json_key: "OPENAI_API_KEY",
|
|
};
|
|
|
|
function numberFormat(value: unknown) {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? zhNumberFormatter.format(value)
|
|
: "-";
|
|
}
|
|
|
|
function timestamp(value?: string | null) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? value : zhTimestampFormatter.format(date);
|
|
}
|
|
|
|
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 "-";
|
|
}
|
|
const startedMs = new Date(startedAt).getTime();
|
|
const updatedMs = new Date(updatedAt).getTime();
|
|
if (Number.isNaN(startedMs) || Number.isNaN(updatedMs)) {
|
|
return "-";
|
|
}
|
|
return `${Math.max(0, (updatedMs - startedMs) / 1000).toFixed(1)} s`;
|
|
}
|
|
|
|
function bytesFormat(value?: number | null) {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return "-";
|
|
}
|
|
if (value < 1024) {
|
|
return `${value} B`;
|
|
}
|
|
if (value < 1024 * 1024) {
|
|
return `${(value / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
}
|
|
|
|
function lifecycleTone(value?: string | null) {
|
|
if (value === "finish") {
|
|
return "";
|
|
}
|
|
if (value === "receive_first") {
|
|
return "warn";
|
|
}
|
|
return "pending";
|
|
}
|
|
|
|
function percent(value?: number) {
|
|
return typeof value === "number" && Number.isFinite(value) ? `${(value * 100).toFixed(2)}%` : "0.00%";
|
|
}
|
|
|
|
function effectiveInputTokens(inputTokens?: number | null, cachedTokens?: number | null) {
|
|
if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens)) {
|
|
return null;
|
|
}
|
|
const cached = typeof cachedTokens === "number" && Number.isFinite(cachedTokens) ? cachedTokens : 0;
|
|
return Math.max(0, inputTokens - cached);
|
|
}
|
|
|
|
function cachedRatio(inputTokens?: number | null, cachedTokens?: number | null) {
|
|
if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens <= 0) {
|
|
return null;
|
|
}
|
|
const cached = typeof cachedTokens === "number" && Number.isFinite(cachedTokens) ? cachedTokens : 0;
|
|
return Math.max(0, Math.min(1, cached / inputTokens));
|
|
}
|
|
|
|
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",
|
|
thread_guard_disabled: "thread 已关闭拦截",
|
|
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_guard_disabled") {
|
|
return "thread 已关闭拦截";
|
|
}
|
|
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 formatThreadGuardOverride(value?: string | null) {
|
|
const override = `${value || ""}`.trim();
|
|
if (override === "disabled") {
|
|
return "已关闭";
|
|
}
|
|
if (override === "enabled") {
|
|
return "强制开启";
|
|
}
|
|
return "默认开启";
|
|
}
|
|
|
|
function threadGuardBadgeTone(value?: string | null) {
|
|
const override = `${value || ""}`.trim();
|
|
if (override === "disabled") {
|
|
return "warn";
|
|
}
|
|
if (override === "enabled") {
|
|
return "success";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function splitList(value: string) {
|
|
return value
|
|
.split(/[\s,]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function splitLines(value: string) {
|
|
return value
|
|
.split(/\r?\n/)
|
|
.map((item) => item.trim())
|
|
.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();
|
|
if (accessKey && !headers.has("x-codex-retry-gateway-key")) {
|
|
headers.set("x-codex-retry-gateway-key", accessKey);
|
|
}
|
|
const response = await fetch(url, { cache: "no-store", ...options, headers });
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || "请求失败");
|
|
}
|
|
return payload as T;
|
|
}
|
|
|
|
function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
|
const config = status?.config || {};
|
|
return {
|
|
...defaultProfileForm,
|
|
listen_host: config.listen_host || defaultProfileForm.listen_host,
|
|
listen_port: String(config.listen_port || defaultProfileForm.listen_port),
|
|
upstream_base_url: config.upstream_base_url || "",
|
|
auth_mode: (config.upstream_auth_mode as ProfileFormState["auth_mode"]) || "passthrough",
|
|
auth_env: config.upstream_auth_env || defaultProfileForm.auth_env,
|
|
manual_secret: "",
|
|
manual_secret_file: "",
|
|
manual_secret_configured: false,
|
|
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,
|
|
retryable_status_codes: Array.isArray(config.retryable_status_codes)
|
|
? config.retryable_status_codes.join(",")
|
|
: defaultProfileForm.retryable_status_codes,
|
|
retryable_error_messages: Array.isArray(config.retryable_error_messages)
|
|
? config.retryable_error_messages.join("\n")
|
|
: defaultProfileForm.retryable_error_messages,
|
|
upstream_fetch_retry_attempts: String(
|
|
config.upstream_fetch_retry_attempts ?? defaultProfileForm.upstream_fetch_retry_attempts,
|
|
),
|
|
upstream_fetch_retry_backoff_ms: String(
|
|
config.upstream_fetch_retry_backoff_ms ?? defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
|
),
|
|
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints,
|
|
};
|
|
}
|
|
|
|
function profileFormFromProfile(profile: Profile): ProfileFormState {
|
|
const form = profile.form || {};
|
|
return {
|
|
...defaultProfileForm,
|
|
name: profile.name,
|
|
listen_host: form.listen_host || "",
|
|
listen_port: form.listen_port || "",
|
|
upstream_base_url: form.upstream_base_url || "",
|
|
auth_mode: (form.auth_mode as ProfileFormState["auth_mode"]) || "passthrough",
|
|
auth_env: form.auth_env || "",
|
|
auth_file: form.auth_file || "",
|
|
manual_secret: "",
|
|
manual_secret_file: form.manual_secret_file || "",
|
|
manual_secret_configured: Boolean(form.manual_secret_configured),
|
|
auth_json_path: form.auth_json_path || "",
|
|
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_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")
|
|
: defaultProfileForm.retryable_error_messages,
|
|
upstream_fetch_retry_attempts:
|
|
form.upstream_fetch_retry_attempts || defaultProfileForm.upstream_fetch_retry_attempts,
|
|
upstream_fetch_retry_backoff_ms:
|
|
form.upstream_fetch_retry_backoff_ms || defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
|
endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "",
|
|
};
|
|
}
|
|
|
|
function imageProfileFormFromStatus(status: StatusPayload | null): ImageProfileFormState {
|
|
const config = status?.config || {};
|
|
return {
|
|
...defaultImageProfileForm,
|
|
name: config.image_profile_name || "",
|
|
base_url: config.image_base_url || "",
|
|
auth_mode: (config.image_auth_mode as AuthMode) || defaultImageProfileForm.auth_mode,
|
|
auth_env: config.image_auth_env || defaultImageProfileForm.auth_env,
|
|
auth_json_key: config.image_auth_json_key || defaultImageProfileForm.auth_json_key,
|
|
};
|
|
}
|
|
|
|
function imageProfileFormFromProfile(profile: ImageProfile): ImageProfileFormState {
|
|
const form = profile.form || {};
|
|
return {
|
|
...defaultImageProfileForm,
|
|
name: profile.name,
|
|
base_url: form.base_url || "",
|
|
auth_mode: (form.auth_mode as AuthMode) || defaultImageProfileForm.auth_mode,
|
|
auth_env: form.auth_env || defaultImageProfileForm.auth_env,
|
|
auth_file: form.auth_file || "",
|
|
manual_secret: "",
|
|
manual_secret_file: form.manual_secret_file || "",
|
|
manual_secret_configured: Boolean(form.manual_secret_configured),
|
|
auth_json_path: form.auth_json_path || "",
|
|
auth_json_key: form.auth_json_key || defaultImageProfileForm.auth_json_key,
|
|
};
|
|
}
|
|
|
|
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(", ")
|
|
: defaultProfileForm.retryable_status_codes,
|
|
retryable_error_messages: Array.isArray(config.retryable_error_messages)
|
|
? config.retryable_error_messages.join("\n")
|
|
: defaultProfileForm.retryable_error_messages,
|
|
upstream_fetch_retry_attempts: String(
|
|
config.upstream_fetch_retry_attempts ?? defaultProfileForm.upstream_fetch_retry_attempts,
|
|
),
|
|
upstream_fetch_retry_backoff_ms: String(
|
|
config.upstream_fetch_retry_backoff_ms ?? defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
|
),
|
|
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "",
|
|
non_stream_status_code: String(config.non_stream_status_code || 502),
|
|
log_match: Boolean(config.log_match),
|
|
};
|
|
}
|
|
|
|
export default function App() {
|
|
useEffect(() => {
|
|
const url = new URL(window.location.href);
|
|
const accessKey = url.searchParams.get("key")?.trim();
|
|
if (!accessKey) {
|
|
return;
|
|
}
|
|
window.localStorage.setItem(ACCESS_KEY_STORAGE_KEY, accessKey);
|
|
url.searchParams.delete("key");
|
|
window.history.replaceState({}, "", url.toString());
|
|
}, []);
|
|
|
|
const [page, setPage] = useState<PageKey>(() => {
|
|
const hash = window.location.hash.replace("#", "") as PageKey;
|
|
return pageCopy[hash] ? hash : "overview";
|
|
});
|
|
const [status, setStatus] = useState<StatusPayload | null>(null);
|
|
const [requests, setRequests] = useState<RequestEntry[]>([]);
|
|
const [requestsTotal, setRequestsTotal] = useState(0);
|
|
const [requestsMeta, setRequestsMeta] = useState("正在读取请求记录...");
|
|
const [threadRules, setThreadRules] = useState<ThreadRuleEntry[]>([]);
|
|
const [threadRulesMeta, setThreadRulesMeta] = useState("正在读取 thread 规则...");
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [profilesMeta, setProfilesMeta] = useState("正在读取 profiles...");
|
|
const [imageProfiles, setImageProfiles] = useState<ImageProfile[]>([]);
|
|
const [imageProfilesMeta, setImageProfilesMeta] = useState("正在读取图片 profiles...");
|
|
const [logs, setLogs] = useState<string>("正在读取日志...");
|
|
const [logsMeta, setLogsMeta] = useState("正在读取日志...");
|
|
const [latestLogSeq, setLatestLogSeq] = useState(0);
|
|
const [requestQuery, setRequestQuery] = useState("");
|
|
const [requestFilter, setRequestFilter] = useState("all");
|
|
const [requestLimit, setRequestLimit] = useState(REQUEST_PAGE_SIZE);
|
|
const [ruleForm, setRuleForm] = useState<RuleFormState>(ruleFormFromStatus(null));
|
|
const [profileForm, setProfileForm] = useState<ProfileFormState>(defaultProfileForm);
|
|
const [imageProfileForm, setImageProfileForm] = useState<ImageProfileFormState>(defaultImageProfileForm);
|
|
const [ruleMessage, setRuleMessage] = useState<MessageState>({ text: "", tone: "" });
|
|
const [threadRuleMessage, setThreadRuleMessage] = useState<MessageState>({ text: "", tone: "" });
|
|
const [profileMessage, setProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
|
const [profileProbeResult, setProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
|
const [imageProfileMessage, setImageProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
|
const [imageProfileProbeResult, setImageProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
|
const [probingProfile, setProbingProfile] = useState("");
|
|
const [deletingProfile, setDeletingProfile] = useState("");
|
|
const [switchingTo, setSwitchingTo] = useState("");
|
|
const [probingImageProfile, setProbingImageProfile] = useState("");
|
|
const [deletingImageProfile, setDeletingImageProfile] = useState("");
|
|
const [switchingImageTo, setSwitchingImageTo] = useState("");
|
|
const [restoreRequested, setRestoreRequested] = useState(false);
|
|
const [shouldSyncProfileFormToActive, setShouldSyncProfileFormToActive] = useState(true);
|
|
const [shouldSyncImageProfileFormToActive, setShouldSyncImageProfileFormToActive] = useState(true);
|
|
const [threadRuleBusyThreadId, setThreadRuleBusyThreadId] = useState("");
|
|
|
|
const metrics = status?.metrics || {};
|
|
const tokens = metrics.token_totals || {};
|
|
const currentPage = pageCopy[page];
|
|
const effectiveInputTotal = effectiveInputTokens(tokens.input_tokens, tokens.cached_tokens);
|
|
const cachedTotalRatio = cachedRatio(tokens.input_tokens, tokens.cached_tokens);
|
|
|
|
const reasoningChips = Object.entries(metrics.observed_reasoning_counts || {}).sort(
|
|
(left, right) => Number(right[1]) - Number(left[1]),
|
|
);
|
|
|
|
async function loadStatus(refreshRuleForm = false) {
|
|
const payload = await fetchJson<StatusPayload>(api.status);
|
|
startTransition(() => {
|
|
setStatus(payload);
|
|
if (refreshRuleForm) {
|
|
setRuleForm(ruleFormFromStatus(payload));
|
|
setProfileForm(profileFormFromStatus(payload));
|
|
setImageProfileForm(imageProfileFormFromStatus(payload));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadRequests(limitOverride = requestLimit) {
|
|
const url = new URL(api.requests, window.location.origin);
|
|
url.searchParams.set("limit", String(limitOverride));
|
|
if (requestQuery.trim()) {
|
|
url.searchParams.set("query", requestQuery.trim());
|
|
}
|
|
if (requestFilter !== "all") {
|
|
url.searchParams.set("filter", requestFilter);
|
|
}
|
|
const payload = await fetchJson<RequestsPayload>(url.toString());
|
|
const entries = payload.entries || [];
|
|
startTransition(() => {
|
|
setRequests(entries);
|
|
setRequestsTotal(payload.total_entries ?? entries.length);
|
|
setRequestsMeta(
|
|
`查询命中 ${payload.total_entries ?? entries.length} 条,当前展示最近 ${entries.length} 条,最新序号 ${payload.latest_seq ?? 0}。`,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function loadThreadRules() {
|
|
const payload = await fetchJson<ThreadRulesPayload>(api.threadRules);
|
|
const rules = payload.rules || [];
|
|
startTransition(() => {
|
|
setThreadRules(rules);
|
|
setThreadRulesMeta(`当前覆盖 ${rules.length} 个 thread;文件:${payload.thread_rules_path || "-"}`);
|
|
});
|
|
}
|
|
|
|
async function loadProfiles() {
|
|
const [payload, imagePayload] = await Promise.all([
|
|
fetchJson<ProfilesPayload>(api.profiles),
|
|
fetchJson<ImageProfilesPayload>(api.imageProfiles),
|
|
]);
|
|
const items = payload.profiles || [];
|
|
const imageItems = imagePayload.image_profiles || [];
|
|
startTransition(() => {
|
|
setProfiles(items);
|
|
setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`);
|
|
setImageProfiles(imageItems);
|
|
setImageProfilesMeta(`目录:${imagePayload.image_profiles_dir || "-"};当前运行:${imagePayload.active_image_profile || "未配置"}。`);
|
|
});
|
|
}
|
|
|
|
async function loadLogs(incremental = false) {
|
|
const url = new URL(api.logs, window.location.origin);
|
|
if (incremental && latestLogSeq > 0) {
|
|
url.searchParams.set("since_seq", String(latestLogSeq));
|
|
} else {
|
|
url.searchParams.set("limit", String(LOG_PAGE_SIZE));
|
|
}
|
|
const payload = await fetchJson<LogsPayload>(url.toString());
|
|
const rendered = (payload.entries || [])
|
|
.map((entry) => `${entry.at || "-"} ${entry.message || ""}`)
|
|
.join("\n");
|
|
startTransition(() => {
|
|
setLogs((current) => {
|
|
if (!incremental || latestLogSeq === 0) {
|
|
return rendered || "当前还没有日志。";
|
|
}
|
|
return rendered ? `${current.trim()}\n${rendered}` : current;
|
|
});
|
|
setLogsMeta(`已载入 ${payload.total_entries ?? payload.entries?.length ?? 0} 条日志,最新序号 ${payload.latest_seq ?? latestLogSeq}。`);
|
|
if (typeof payload.latest_seq === "number") {
|
|
setLatestLogSeq(payload.latest_seq);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadPageData(targetPage: PageKey, { incrementalLogs = true } = {}) {
|
|
if (targetPage === "requests") {
|
|
await loadRequests();
|
|
return;
|
|
}
|
|
if (targetPage === "rules") {
|
|
await loadThreadRules();
|
|
return;
|
|
}
|
|
if (targetPage === "profiles") {
|
|
await loadProfiles();
|
|
return;
|
|
}
|
|
if (targetPage === "logs") {
|
|
await loadLogs(incrementalLogs && latestLogSeq > 0);
|
|
}
|
|
}
|
|
|
|
async function refreshLiveData(targetPage: PageKey = page) {
|
|
if (restoreRequested || switchingTo || switchingImageTo) {
|
|
return;
|
|
}
|
|
const tasks: Array<Promise<unknown>> = [loadStatus(false)];
|
|
if (targetPage !== "overview") {
|
|
tasks.push(loadPageData(targetPage, { incrementalLogs: true }));
|
|
}
|
|
await Promise.all(tasks);
|
|
}
|
|
|
|
useEffect(() => {
|
|
window.location.hash = page;
|
|
}, [page]);
|
|
|
|
useEffect(() => {
|
|
if (page === "profiles") {
|
|
setShouldSyncProfileFormToActive(true);
|
|
setShouldSyncImageProfileFormToActive(true);
|
|
}
|
|
}, [page]);
|
|
|
|
useEffect(() => {
|
|
const onHashChange = () => {
|
|
const next = window.location.hash.replace("#", "") as PageKey;
|
|
if (pageCopy[next]) {
|
|
setPage(next);
|
|
}
|
|
};
|
|
window.addEventListener("hashchange", onHashChange);
|
|
return () => window.removeEventListener("hashchange", onHashChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadStatus(true).catch((error) => {
|
|
setRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
loadStatus(false).catch((error) => {
|
|
setRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
});
|
|
}, 10000);
|
|
return () => window.clearInterval(timer);
|
|
}, [restoreRequested, switchingTo, switchingImageTo]);
|
|
|
|
useEffect(() => {
|
|
if (page !== "requests" && page !== "logs" && page !== "rules") {
|
|
return;
|
|
}
|
|
const timer = window.setInterval(() => {
|
|
loadPageData(page, { incrementalLogs: true }).catch((error) => {
|
|
if (page === "rules") {
|
|
setThreadRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
return;
|
|
}
|
|
setRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
});
|
|
}, 2500);
|
|
return () => window.clearInterval(timer);
|
|
}, [page, requestQuery, requestFilter, latestLogSeq, requestLimit]);
|
|
|
|
useEffect(() => {
|
|
if (page === "overview" || page === "requests") {
|
|
return;
|
|
}
|
|
loadPageData(page, { incrementalLogs: false }).catch((error) => {
|
|
if (page === "rules") {
|
|
setThreadRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
return;
|
|
}
|
|
setRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
});
|
|
}, [page]);
|
|
|
|
useEffect(() => {
|
|
if (page !== "profiles" || !shouldSyncProfileFormToActive) {
|
|
return;
|
|
}
|
|
const activeProfile = profiles.find((profile) => profile.active);
|
|
if (!activeProfile) {
|
|
return;
|
|
}
|
|
setProfileForm(profileFormFromProfile(activeProfile));
|
|
setProfileMessage({ text: "", tone: "" });
|
|
setShouldSyncProfileFormToActive(false);
|
|
}, [page, profiles, shouldSyncProfileFormToActive]);
|
|
|
|
useEffect(() => {
|
|
if (page !== "profiles" || !shouldSyncImageProfileFormToActive) {
|
|
return;
|
|
}
|
|
const activeProfile = imageProfiles.find((profile) => profile.active);
|
|
if (!activeProfile) {
|
|
return;
|
|
}
|
|
setImageProfileForm(imageProfileFormFromProfile(activeProfile));
|
|
setImageProfileMessage({ text: "", tone: "" });
|
|
setShouldSyncImageProfileFormToActive(false);
|
|
}, [page, imageProfiles, shouldSyncImageProfileFormToActive]);
|
|
|
|
async function saveRules(event: FormEvent) {
|
|
event.preventDefault();
|
|
setRuleMessage({ text: "正在保存配置...", tone: "" });
|
|
try {
|
|
const payload = await fetchJson<StatusPayload>(api.config, {
|
|
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)),
|
|
retryable_status_codes: splitList(ruleForm.retryable_status_codes)
|
|
.map((value) => Number.parseInt(value, 10))
|
|
.filter((value) => Number.isInteger(value)),
|
|
retryable_error_messages: splitLines(ruleForm.retryable_error_messages),
|
|
upstream_fetch_retry_attempts: Number.parseInt(ruleForm.upstream_fetch_retry_attempts, 10),
|
|
upstream_fetch_retry_backoff_ms: Number.parseInt(ruleForm.upstream_fetch_retry_backoff_ms, 10),
|
|
endpoints: splitLines(ruleForm.endpoints),
|
|
non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10),
|
|
log_match: ruleForm.log_match,
|
|
}),
|
|
});
|
|
setStatus(payload);
|
|
setRuleForm(ruleFormFromStatus(payload));
|
|
setRuleMessage({ text: "配置已保存,并已对当前 gateway 立即生效。", tone: "success" });
|
|
} catch (error) {
|
|
setRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function updateThreadRule(threadId: string, reasoningInterceptEnabled: boolean) {
|
|
if (!threadId.trim()) {
|
|
return;
|
|
}
|
|
setThreadRuleBusyThreadId(threadId);
|
|
setThreadRuleMessage({
|
|
text: reasoningInterceptEnabled ? `正在为 ${threadId} 开启拦截...` : `正在为 ${threadId} 关闭拦截...`,
|
|
tone: "",
|
|
});
|
|
try {
|
|
const payload = await fetchJson<ThreadRulesPayload>(api.threadRules, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
thread_id: threadId,
|
|
reasoning_intercept_enabled: reasoningInterceptEnabled,
|
|
}),
|
|
});
|
|
setThreadRules(payload.rules || []);
|
|
setThreadRulesMeta(`当前覆盖 ${(payload.rules || []).length} 个 thread;文件:${payload.thread_rules_path || "-"}`);
|
|
setThreadRuleMessage({
|
|
text: payload.message || (reasoningInterceptEnabled ? "thread 已开启拦截" : "thread 已关闭拦截"),
|
|
tone: "success",
|
|
});
|
|
await Promise.all([loadStatus(false), loadRequests(requestLimit), loadThreadRules()]);
|
|
} catch (error) {
|
|
setThreadRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setThreadRuleBusyThreadId("");
|
|
}
|
|
}
|
|
|
|
async function restoreThreadRule(threadId: string) {
|
|
if (!threadId.trim()) {
|
|
return;
|
|
}
|
|
setThreadRuleBusyThreadId(threadId);
|
|
setThreadRuleMessage({ text: `正在恢复 ${threadId} 的默认策略...`, tone: "" });
|
|
try {
|
|
const payload = await fetchJson<ThreadRulesPayload>(`${api.threadRules}/${encodeURIComponent(threadId)}`, {
|
|
method: "DELETE",
|
|
});
|
|
setThreadRules(payload.rules || []);
|
|
setThreadRulesMeta(`当前覆盖 ${(payload.rules || []).length} 个 thread;文件:${payload.thread_rules_path || "-"}`);
|
|
setThreadRuleMessage({ text: payload.message || "thread 已恢复默认拦截策略", tone: "success" });
|
|
await Promise.all([loadStatus(false), loadRequests(requestLimit), loadThreadRules()]);
|
|
} catch (error) {
|
|
setThreadRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setThreadRuleBusyThreadId("");
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setRequestLimit(REQUEST_PAGE_SIZE);
|
|
}, [requestQuery, requestFilter]);
|
|
|
|
useEffect(() => {
|
|
if (page !== "requests") {
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
loadRequests(requestLimit).catch((error) => {
|
|
setRuleMessage({ text: error?.message || String(error), tone: "error" });
|
|
});
|
|
}, 180);
|
|
return () => window.clearTimeout(timer);
|
|
}, [page, requestQuery, requestFilter, requestLimit]);
|
|
|
|
async function saveProfile(event: FormEvent) {
|
|
event.preventDefault();
|
|
setProfileMessage({ text: "正在保存 profile...", tone: "" });
|
|
try {
|
|
const payload = await fetchJson<ProfilesPayload>(api.profiles, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: profileForm.name,
|
|
listen_host: profileForm.listen_host,
|
|
listen_port: Number.parseInt(profileForm.listen_port, 10),
|
|
upstream_base_url: profileForm.upstream_base_url,
|
|
auth_mode: profileForm.auth_mode,
|
|
auth_env: profileForm.auth_env,
|
|
auth_file: profileForm.auth_file,
|
|
manual_secret: profileForm.manual_secret,
|
|
manual_secret_file: profileForm.manual_secret_file,
|
|
manual_secret_configured: profileForm.manual_secret_configured,
|
|
auth_json_path: profileForm.auth_json_path,
|
|
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),
|
|
upstream_fetch_retry_attempts: Number.parseInt(profileForm.upstream_fetch_retry_attempts, 10),
|
|
upstream_fetch_retry_backoff_ms: Number.parseInt(profileForm.upstream_fetch_retry_backoff_ms, 10),
|
|
endpoints: splitLines(profileForm.endpoints),
|
|
}),
|
|
});
|
|
setProfiles(payload.profiles || []);
|
|
setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`);
|
|
setProfileForm((current) => ({
|
|
...current,
|
|
manual_secret: "",
|
|
manual_secret_configured: current.auth_mode === "manual_bearer",
|
|
}));
|
|
setProfileMessage({
|
|
text: payload.applied_profile ? "profile 已保存,并已对当前运行实例后台热应用。" : "profile 已保存。需要运行它时,点击左侧卡片里的“切换”。",
|
|
tone: "success",
|
|
});
|
|
} catch (error) {
|
|
setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
function editProfile(profile: Profile) {
|
|
setShouldSyncProfileFormToActive(false);
|
|
setProfileForm(profileFormFromProfile(profile));
|
|
setProfileMessage({ text: "编辑后保存 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" });
|
|
}
|
|
|
|
async function switchProfile(profile: Profile) {
|
|
if (profile.active) {
|
|
return;
|
|
}
|
|
if (!window.confirm(`切换到 profile "${profile.name}" 会尝试后台热切换,不重启当前 gateway。确定继续吗?`)) {
|
|
return;
|
|
}
|
|
setSwitchingTo(profile.name);
|
|
try {
|
|
await fetchJson(api.profileSwitch, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ profile: profile.name }),
|
|
});
|
|
waitForProfile(profile.name);
|
|
} catch (error) {
|
|
setSwitchingTo("");
|
|
setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function probeProfile(profile: Profile) {
|
|
setProbingProfile(profile.name);
|
|
setProfileMessage({ text: `正在探测 profile ${profile.name}...`, tone: "" });
|
|
try {
|
|
const payload = await fetchJson<ProfileProbePayload>(api.profileProbe, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
profile: profile.name,
|
|
model: "gpt-5.5-fast",
|
|
input: "ping",
|
|
}),
|
|
});
|
|
setProfileProbeResult(payload);
|
|
setProfileMessage({ text: `profile ${profile.name} 探针已完成,不影响当前运行实例。`, tone: "success" });
|
|
} catch (error) {
|
|
setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setProbingProfile("");
|
|
}
|
|
}
|
|
|
|
async function removeProfile(profile: Profile) {
|
|
if (profile.active) {
|
|
setProfileMessage({ text: "当前运行的 profile 不能直接删除;请先切换到其他 profile。", tone: "error" });
|
|
return;
|
|
}
|
|
if (!window.confirm(`删除 profile "${profile.name}" 会移除对应 env 文件。确定继续吗?`)) {
|
|
return;
|
|
}
|
|
setDeletingProfile(profile.name);
|
|
try {
|
|
const payload = await fetchJson<ProfilesPayload>(`${api.profiles}/${encodeURIComponent(profile.name)}`, {
|
|
method: "DELETE",
|
|
});
|
|
setProfiles(payload.profiles || []);
|
|
setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`);
|
|
if (profileForm.name === profile.name) {
|
|
setProfileForm(defaultProfileForm);
|
|
}
|
|
if (profileProbeResult?.profile === profile.name) {
|
|
setProfileProbeResult(null);
|
|
}
|
|
setProfileMessage({ text: `profile ${profile.name} 已删除。`, tone: "success" });
|
|
} catch (error) {
|
|
setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setDeletingProfile("");
|
|
}
|
|
}
|
|
|
|
async function saveImageProfile(event: FormEvent) {
|
|
event.preventDefault();
|
|
setImageProfileMessage({ text: "正在保存图片 profile...", tone: "" });
|
|
try {
|
|
const payload = await fetchJson<ImageProfilesPayload>(api.imageProfiles, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: imageProfileForm.name,
|
|
base_url: imageProfileForm.base_url,
|
|
auth_mode: imageProfileForm.auth_mode,
|
|
auth_env: imageProfileForm.auth_env,
|
|
auth_file: imageProfileForm.auth_file,
|
|
manual_secret: imageProfileForm.manual_secret,
|
|
manual_secret_file: imageProfileForm.manual_secret_file,
|
|
manual_secret_configured: imageProfileForm.manual_secret_configured,
|
|
auth_json_path: imageProfileForm.auth_json_path,
|
|
auth_json_key: imageProfileForm.auth_json_key,
|
|
}),
|
|
});
|
|
setImageProfiles(payload.image_profiles || []);
|
|
setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`);
|
|
setImageProfileForm((current) => ({
|
|
...current,
|
|
manual_secret: "",
|
|
manual_secret_configured: current.auth_mode === "manual_bearer",
|
|
}));
|
|
setImageProfileMessage({
|
|
text: payload.applied_image_profile
|
|
? "图片 profile 已保存,并已对当前运行实例后台热应用。"
|
|
: "图片 profile 已保存。需要运行它时,点击左侧卡片里的“切换”。",
|
|
tone: "success",
|
|
});
|
|
} catch (error) {
|
|
setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
function editImageProfile(profile: ImageProfile) {
|
|
setShouldSyncImageProfileFormToActive(false);
|
|
setImageProfileForm(imageProfileFormFromProfile(profile));
|
|
setImageProfileMessage({ text: "编辑后保存图片 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" });
|
|
}
|
|
|
|
async function switchImageProfile(profile: ImageProfile) {
|
|
if (profile.active) {
|
|
return;
|
|
}
|
|
if (!window.confirm(`切换到图片 profile "${profile.name}" 会立即改写 /images/* 的上游,不重启 gateway。确定继续吗?`)) {
|
|
return;
|
|
}
|
|
setSwitchingImageTo(profile.name);
|
|
try {
|
|
await fetchJson(api.imageProfileSwitch, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ profile: profile.name }),
|
|
});
|
|
waitForImageProfile(profile.name);
|
|
} catch (error) {
|
|
setSwitchingImageTo("");
|
|
setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
async function probeImageProfile(profile: ImageProfile) {
|
|
setProbingImageProfile(profile.name);
|
|
setImageProfileMessage({ text: `正在探测图片 profile ${profile.name}...`, tone: "" });
|
|
try {
|
|
const payload = await fetchJson<ProfileProbePayload>(api.imageProfileProbe, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ profile: profile.name }),
|
|
});
|
|
setImageProfileProbeResult(payload);
|
|
setImageProfileMessage({ text: `图片 profile ${profile.name} 探针已完成,不影响当前运行实例。`, tone: "success" });
|
|
} catch (error) {
|
|
setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setProbingImageProfile("");
|
|
}
|
|
}
|
|
|
|
async function removeImageProfile(profile: ImageProfile) {
|
|
if (profile.active) {
|
|
setImageProfileMessage({ text: "当前运行的图片 profile 不能直接删除;请先切换到其他图片 profile。", tone: "error" });
|
|
return;
|
|
}
|
|
if (!window.confirm(`删除图片 profile "${profile.name}" 会移除对应 env 文件。确定继续吗?`)) {
|
|
return;
|
|
}
|
|
setDeletingImageProfile(profile.name);
|
|
try {
|
|
const payload = await fetchJson<ImageProfilesPayload>(`${api.imageProfiles}/${encodeURIComponent(profile.name)}`, {
|
|
method: "DELETE",
|
|
});
|
|
setImageProfiles(payload.image_profiles || []);
|
|
setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`);
|
|
if (imageProfileForm.name === profile.name) {
|
|
setImageProfileForm(defaultImageProfileForm);
|
|
}
|
|
if (imageProfileProbeResult?.image_profile === profile.name) {
|
|
setImageProfileProbeResult(null);
|
|
}
|
|
setImageProfileMessage({ text: `图片 profile ${profile.name} 已删除。`, tone: "success" });
|
|
} catch (error) {
|
|
setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
} finally {
|
|
setDeletingImageProfile("");
|
|
}
|
|
}
|
|
|
|
function waitForProfile(profileName: string) {
|
|
const deadline = Date.now() + 12000;
|
|
const tick = async () => {
|
|
if (Date.now() > deadline) {
|
|
setSwitchingTo("");
|
|
setProfileMessage({ text: "热切换已提交,但暂时没有看到当前实例切到目标 profile。", tone: "error" });
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await fetchJson<StatusPayload>(api.status);
|
|
if (payload.config?.profile_name === profileName) {
|
|
setSwitchingTo("");
|
|
setShouldSyncProfileFormToActive(true);
|
|
Promise.all([loadStatus(true), loadProfiles()]).catch(() => {});
|
|
return;
|
|
}
|
|
} catch {
|
|
// keep polling
|
|
}
|
|
window.setTimeout(tick, 500);
|
|
};
|
|
window.setTimeout(tick, 300);
|
|
}
|
|
|
|
function waitForImageProfile(profileName: string) {
|
|
const deadline = Date.now() + 12000;
|
|
const tick = async () => {
|
|
if (Date.now() > deadline) {
|
|
setSwitchingImageTo("");
|
|
setImageProfileMessage({ text: "图片热切换已提交,但暂时没有看到当前实例切到目标 profile。", tone: "error" });
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await fetchJson<StatusPayload>(api.status);
|
|
if (payload.config?.image_profile_name === profileName) {
|
|
setSwitchingImageTo("");
|
|
setShouldSyncImageProfileFormToActive(true);
|
|
Promise.all([loadStatus(true), loadProfiles()]).catch(() => {});
|
|
return;
|
|
}
|
|
} catch {
|
|
// keep polling
|
|
}
|
|
window.setTimeout(tick, 500);
|
|
};
|
|
window.setTimeout(tick, 300);
|
|
}
|
|
|
|
async function restoreConfig() {
|
|
if (!window.confirm("恢复后会关闭当前 gateway,并把 Codex 配置切回原上游。确定继续吗?")) {
|
|
return;
|
|
}
|
|
setRestoreRequested(true);
|
|
setRuleMessage({ text: "正在触发恢复,页面很快会失联...", tone: "" });
|
|
try {
|
|
await fetchJson(api.restore, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
setRuleMessage({ text: "恢复脚本已启动,等待 gateway 关闭。", tone: "success" });
|
|
} catch (error) {
|
|
setRestoreRequested(false);
|
|
setRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="app-shell">
|
|
<aside className="sidebar">
|
|
<div className="brand">
|
|
<span className="eyebrow">Gateway Console</span>
|
|
<h1>Codex Retry Gateway</h1>
|
|
<p>分页面管理 profile、请求 token、拦截规则和运行日志。页面只记录元数据,不保存请求正文或响应正文。</p>
|
|
</div>
|
|
|
|
<nav className="nav" aria-label="Gateway pages">
|
|
{(Object.keys(pageCopy) as PageKey[]).map((key) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
data-active={page === key}
|
|
onClick={() => setPage(key)}
|
|
>
|
|
{pageCopy[key].title} <span>{pageCopy[key].index}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<SideCard label="当前 Profile" value={status?.config?.profile_name || "-"} strong />
|
|
<SideCard label="当前图片 Profile" value={status?.config?.image_profile_name || "未配置"} />
|
|
<SideCard label="监听地址" value={status?.listen || "-"} />
|
|
<SideCard label="Auth 模式" value={status?.config?.upstream_auth_mode || "-"} />
|
|
</aside>
|
|
|
|
<main className="content">
|
|
<header className="topbar">
|
|
<div>
|
|
<h2>{currentPage.title}</h2>
|
|
<p>{currentPage.subtitle}</p>
|
|
</div>
|
|
<div className="toolbar">
|
|
<button className="ghost" type="button" onClick={() => refreshLiveData()}>
|
|
刷新
|
|
</button>
|
|
<a className="badge" href={api.status}>
|
|
status api
|
|
</a>
|
|
</div>
|
|
</header>
|
|
|
|
{page === "overview" && (
|
|
<section className="page" data-active="true">
|
|
<div className="grid">
|
|
<Card
|
|
title="运行态"
|
|
hint="配置、provider、Codex 当前 base_url 和 profile 状态。"
|
|
badge={status?.ok ? "online" : "读取中"}
|
|
>
|
|
<div className="stat-grid">
|
|
<Stat label="Profile" value={status?.config?.profile_name || "-"} />
|
|
<Stat label="代理请求" value={numberFormat(metrics.total_proxy_request_count || 0)} />
|
|
<Stat label="检查响应" value={numberFormat(metrics.inspected_response_count || 0)} />
|
|
<Stat label="规则命中" value={numberFormat(metrics.matched_response_count || 0)} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Token Totals" hint="按持久请求记录累计;重启后会从 requests history 回灌。">
|
|
<div className="token-strip">
|
|
<TokenCard label="Input" value={numberFormat(effectiveInputTotal ?? 0)} />
|
|
<TokenCard label="Output" value={numberFormat(tokens.output_tokens || 0)} />
|
|
<TokenCard label="Total" value={numberFormat(tokens.total_tokens || 0)} />
|
|
<TokenCard label="Reasoning" value={numberFormat(tokens.reasoning_tokens || 0)} />
|
|
<TokenCard
|
|
label="Cached"
|
|
value={`${numberFormat(tokens.cached_tokens || 0)}${cachedTotalRatio !== null ? ` (${percent(cachedTotalRatio)})` : ""}`}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="grid two">
|
|
<Card title="连接信息">
|
|
<InfoList
|
|
rows={[
|
|
["监听地址", status?.listen || "-"],
|
|
["文本上游", status?.config?.upstream_base_url || "-"],
|
|
["文本认证", status?.config?.upstream_auth_mode || "passthrough"],
|
|
["图片 Profile", status?.config?.image_profile_name || "未配置"],
|
|
["图片上游", status?.config?.image_base_url || "未配置"],
|
|
["图片认证", status?.config?.image_base_url ? status?.config?.image_auth_mode || "fixed_bearer" : "未启用"],
|
|
["Provider", status?.state?.provider_name || "未检测到安装状态"],
|
|
["Codex Base URL", status?.state?.codex_current_base_url || "-"],
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<Card title="规则观察">
|
|
<InfoList
|
|
rows={[
|
|
["516 命中", numberFormat(metrics.reasoning_516_count || 0)],
|
|
["516 占比", percent(metrics.reasoning_516_ratio || 0)],
|
|
["累计起点", timestamp(metrics.persistent_since || metrics.started_at)],
|
|
["本次启动", timestamp(metrics.started_at)],
|
|
["Config", status?.paths?.config_path || "-"],
|
|
["备份", status?.state?.latest_backup_path || "-"],
|
|
]}
|
|
/>
|
|
<div className="chips">
|
|
{reasoningChips.length === 0 ? (
|
|
<span className="chip">还没有 reasoning 观测</span>
|
|
) : (
|
|
<>
|
|
{reasoningChips.map(([reasoning, count]) => (
|
|
<span className="chip" key={reasoning}>
|
|
reasoning {reasoning}: {count}
|
|
</span>
|
|
))}
|
|
{(metrics.observed_reasoning_counts_omitted || 0) > 0 ? (
|
|
<span className="chip">
|
|
其余 {metrics.observed_reasoning_counts_omitted} 项未展开
|
|
</span>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{page === "requests" && (
|
|
<section className="page" data-active="true">
|
|
<Card
|
|
title="请求记录"
|
|
hint={requestsMeta}
|
|
actions={
|
|
<div className="toolbar compact-toolbar">
|
|
<input
|
|
type="search"
|
|
value={requestQuery}
|
|
placeholder="搜索模型 / 路径 / 状态"
|
|
onChange={(event) => setRequestQuery(event.target.value)}
|
|
/>
|
|
<select value={requestFilter} onChange={(event) => setRequestFilter(event.target.value)}>
|
|
<option value="all">全部请求</option>
|
|
<option value="matched">规则命中</option>
|
|
<option value="stream">流式响应</option>
|
|
<option value="error">有错误</option>
|
|
</select>
|
|
<button className="secondary" type="button" onClick={() => loadRequests()}>
|
|
刷新请求
|
|
</button>
|
|
<button
|
|
className="ghost"
|
|
type="button"
|
|
disabled={requests.length >= requestsTotal && requestsTotal > 0}
|
|
onClick={() => setRequestLimit((current) => current + REQUEST_PAGE_SIZE)}
|
|
>
|
|
更多
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="request-list">
|
|
{requests.length === 0 ? (
|
|
<div className="empty-state">没有匹配的请求记录。</div>
|
|
) : (
|
|
requests.map((entry) => {
|
|
const usage = entry.usage || {};
|
|
const upstream = entry.upstream || {};
|
|
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
|
|
: [];
|
|
const threadRuleBusy = Boolean(entry.thread_id) && threadRuleBusyThreadId === entry.thread_id;
|
|
return (
|
|
<article className="request-card" key={entry.seq}>
|
|
<div className="request-head">
|
|
<div>
|
|
<div className="request-title">
|
|
<span className={`badge ${statusTone}`}>{entry.status_code ?? "-"}</span>
|
|
<strong>{entry.profile_name || status?.config?.profile_name || "-"}</strong>
|
|
<span className={`badge ${lifecycleTone(entry.lifecycle_state)}`}>{entry.lifecycle_state || "sent"}</span>
|
|
<span className="meta-pill meta-sent">
|
|
<span className="meta-key">发</span>
|
|
{timestamp(entry.started_at)}
|
|
</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)}
|
|
</span>
|
|
<span className="meta-pill meta-progress">
|
|
<span className="meta-key">更</span>
|
|
{timestamp(entry.last_activity_at || entry.usage_last_updated_at)}
|
|
</span>
|
|
<span className="meta-pill meta-body">
|
|
<span className="meta-key">体</span>
|
|
{bytesFormat(entry.request_body_bytes)}
|
|
</span>
|
|
<span className="meta-pill meta-stream">
|
|
<span className="meta-key">流</span>
|
|
{entry.response_stream
|
|
? `${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)}
|
|
</span>
|
|
</div>
|
|
<div className="request-subtitle">
|
|
<code>{`${entry.method || "-"} ${entry.path || "-"}`}</code>
|
|
<span className="hint">{entry.response_stream ? "stream" : "non-stream"}</span>
|
|
<span className="hint">id {requestPrimaryId(entry)}</span>
|
|
<span className="hint">thread {entry.thread_id || "-"}</span>
|
|
</div>
|
|
</div>
|
|
<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}
|
|
<span className={`badge ${threadGuardBadgeTone(entry.reasoning_guard_thread_override)}`}>
|
|
guard {formatThreadGuardOverride(entry.reasoning_guard_thread_override)}
|
|
</span>
|
|
{showReasoningRetry ? (
|
|
<span className={`badge ${entry.reasoning_retry_stop_reason === "success" ? "success" : "warn"}`}>
|
|
retry {formatRetryStopReason(entry.reasoning_retry_stop_reason)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="request-grid">
|
|
<div className="request-block">
|
|
<label>模型</label>
|
|
<code>{entry.requested_model || entry.model || "-"}</code>
|
|
<span className="hint">
|
|
{entry.forwarded_model && entry.forwarded_model !== entry.requested_model
|
|
? `转发为 ${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">
|
|
<label>{upstream.route === "images" ? "图片上游" : "文本上游"}</label>
|
|
<code>{`${upstream.origin || "-"}${upstream.path || ""}`}</code>
|
|
<span className="hint">
|
|
{upstream.auth_mode || "-"} / {upstream.auth_source || "-"}
|
|
{upstream.authorization_configured === false ? " / no auth" : ""}
|
|
</span>
|
|
<span className="hint">
|
|
重试 {numberFormat(entry.upstream_attempt_count)} 次
|
|
{entry.upstream_status_code ? ` / upstream ${entry.upstream_status_code}` : ""}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="request-block">
|
|
<label>Token</label>
|
|
<div className="request-token-row">
|
|
<span className="token-pill token-in">in {numberFormat(effectiveInput)}</span>
|
|
<span className="token-pill token-out">out {numberFormat(usage.output_tokens)}</span>
|
|
<span className="token-pill token-cached">cached {numberFormat(usage.cached_tokens)}{cachedHitRatio !== null ? ` (${percent(cachedHitRatio)})` : ""}</span>
|
|
<span className="token-pill token-reasoning">reasoning {numberFormat(entry.reasoning_tokens ?? usage.reasoning_tokens)}</span>
|
|
<span className="token-pill token-total">total {numberFormat(usage.total_tokens)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="request-block">
|
|
<label>IDs</label>
|
|
<code>{`response ${entry.response_id || "-"}`}</code>
|
|
<span className="hint">{`request ${entry.request_id || "-"}`}</span>
|
|
<span className="hint">{`thread ${entry.thread_id || "-"}`}</span>
|
|
{entry.thread_id ? (
|
|
<div className="thread-rule-actions">
|
|
<button
|
|
className="ghost"
|
|
type="button"
|
|
disabled={threadRuleBusy || entry.reasoning_guard_thread_override === "enabled"}
|
|
onClick={() => updateThreadRule(entry.thread_id || "", true)}
|
|
>
|
|
{threadRuleBusy && entry.reasoning_guard_thread_override !== "enabled" ? "处理中" : "开启拦截"}
|
|
</button>
|
|
<button
|
|
className="ghost"
|
|
type="button"
|
|
disabled={threadRuleBusy || entry.reasoning_guard_thread_override === "disabled"}
|
|
onClick={() => updateThreadRule(entry.thread_id || "", false)}
|
|
>
|
|
{threadRuleBusy && entry.reasoning_guard_thread_override !== "disabled" ? "处理中" : "关闭拦截"}
|
|
</button>
|
|
{entry.reasoning_guard_thread_override && entry.reasoning_guard_thread_override !== "default" ? (
|
|
<button
|
|
className="secondary"
|
|
type="button"
|
|
disabled={threadRuleBusy}
|
|
onClick={() => restoreThreadRule(entry.thread_id || "")}
|
|
>
|
|
{threadRuleBusy ? "处理中" : "恢复默认"}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{showReasoningRetry ? (
|
|
<div className="request-block reasoning-retry-block">
|
|
<label>Reasoning Retry</label>
|
|
<code>{formatRetryThreadMode(entry.reasoning_retry_thread_mode)}</code>
|
|
<span className="hint">
|
|
guard {formatThreadGuardOverride(entry.reasoning_guard_thread_override)}
|
|
</span>
|
|
<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>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
)}
|
|
|
|
{page === "profiles" && (
|
|
<section className="page" data-active="true">
|
|
<div className="grid profile-layout">
|
|
<Card
|
|
title="文本 Profile 列表"
|
|
hint={profilesMeta}
|
|
actions={
|
|
<button
|
|
className="secondary"
|
|
type="button"
|
|
onClick={() => {
|
|
setShouldSyncProfileFormToActive(false);
|
|
setProfileForm(profileFormFromStatus(status));
|
|
setProfileMessage({ text: "", tone: "" });
|
|
}}
|
|
>
|
|
新建文本 Profile
|
|
</button>
|
|
}
|
|
>
|
|
<div className="grid profile-list-grid">
|
|
{profiles.length === 0 ? (
|
|
<div className="profile-card">当前还没有 profile env 文件。</div>
|
|
) : (
|
|
profiles.map((profile) => (
|
|
<article className="profile-card compact-profile-card" data-active-profile={profile.active} key={profile.name}>
|
|
<div className="profile-head">
|
|
<div className="profile-meta">
|
|
<h3>{profile.name}</h3>
|
|
<div className="profile-path hint">{profile.file_path || ""}</div>
|
|
</div>
|
|
<div className="profile-actions">
|
|
{profile.active ? <span className="badge">当前运行</span> : <span className="badge warn">可切换</span>}
|
|
<button className="ghost" type="button" onClick={() => editProfile(profile)}>
|
|
编辑
|
|
</button>
|
|
<button className="ghost" type="button" disabled={probingProfile === profile.name} onClick={() => probeProfile(profile)}>
|
|
{probingProfile === profile.name ? "探测中" : "探针"}
|
|
</button>
|
|
<button className="danger" type="button" disabled={deletingProfile === profile.name} onClick={() => removeProfile(profile)}>
|
|
{deletingProfile === profile.name ? "删除中" : "删除"}
|
|
</button>
|
|
<button className="primary" type="button" disabled={profile.active} onClick={() => switchProfile(profile)}>
|
|
切换
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mini-stats">
|
|
<MiniStat label="监听" value={`${profile.summary?.listen_host || "-"}:${profile.summary?.listen_port || "-"}`} />
|
|
<MiniStat label="文本上游" value={profile.summary?.upstream_base_url || "-"} />
|
|
<MiniStat label="文本认证" value={`${profile.summary?.auth_mode || "passthrough"} / ${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={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>
|
|
))
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={profileForm.name ? `编辑文本 ${profileForm.name}` : "编辑文本 Profile"} hint="手动 token/password 只能写入系统 secret 文件,保存后不会从后端读回或展示。">
|
|
<form onSubmit={saveProfile}>
|
|
<Field label="Profile 名称">
|
|
<input value={profileForm.name} placeholder="default 或 sub2api-auth-json" onChange={(event) => setProfileForm({ ...profileForm, name: event.target.value })} />
|
|
</Field>
|
|
<div className="field-row">
|
|
<Field label="监听 Host">
|
|
<input value={profileForm.listen_host} placeholder="100.115.235.115" onChange={(event) => setProfileForm({ ...profileForm, listen_host: event.target.value })} />
|
|
</Field>
|
|
<Field label="监听 Port">
|
|
<input type="number" min={1} max={65535} value={profileForm.listen_port} onChange={(event) => setProfileForm({ ...profileForm, listen_port: event.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<Field label="文本上游 Base URL">
|
|
<input value={profileForm.upstream_base_url} placeholder="https://example.com/v1" onChange={(event) => setProfileForm({ ...profileForm, upstream_base_url: event.target.value })} />
|
|
</Field>
|
|
<Field label="Model Remap" hint="每行一个 from=to,也支持用分号分隔;例如 gpt-5.5-fast=gpt-5.5;gpt-5.4-fast=gpt-5.4。切换 profile 后会自动把请求模型改写为目标 provider 可用模型。">
|
|
<textarea value={profileForm.model_remap} onChange={(event) => setProfileForm({ ...profileForm, model_remap: event.target.value })} />
|
|
</Field>
|
|
<Field label="文本 Auth 模式">
|
|
<select value={profileForm.auth_mode} onChange={(event) => setProfileForm({ ...profileForm, auth_mode: event.target.value as ProfileFormState["auth_mode"] })}>
|
|
<option value="passthrough">passthrough:透传 Codex Authorization</option>
|
|
<option value="manual_bearer">manual_bearer:手动写入系统 secret 文件</option>
|
|
<option value="fixed_bearer">fixed_bearer:从 env/file 读取 Bearer</option>
|
|
<option value="auth_json">auth_json:从 ~/.codex/auth.json 读取字段</option>
|
|
</select>
|
|
</Field>
|
|
{profileForm.auth_mode === "manual_bearer" && (
|
|
<Field
|
|
label="文本 Token / Password"
|
|
hint={profileForm.manual_secret_configured ? "系统中已有 secret;留空表示保留现有值,填入新值会覆盖写入。" : "只写入系统 secret 文件,保存后不会显示或返回。"}
|
|
>
|
|
<input
|
|
type="password"
|
|
value={profileForm.manual_secret}
|
|
autoComplete="new-password"
|
|
placeholder={profileForm.manual_secret_configured ? "已配置;留空保留" : "输入一次 token/password"}
|
|
onChange={(event) => setProfileForm({ ...profileForm, manual_secret: event.target.value })}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{profileForm.auth_mode === "fixed_bearer" && (
|
|
<>
|
|
<Field label="文本 Token 环境变量名" hint="只填变量名,不填 token 值。">
|
|
<input value={profileForm.auth_env} placeholder="CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY" onChange={(event) => setProfileForm({ ...profileForm, auth_env: event.target.value })} />
|
|
</Field>
|
|
<Field label="文本 Token 文件路径">
|
|
<input value={profileForm.auth_file} placeholder="/run/secrets/provider-token" onChange={(event) => setProfileForm({ ...profileForm, auth_file: event.target.value })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
{profileForm.auth_mode === "auth_json" && (
|
|
<>
|
|
<Field label="文本 auth.json 路径">
|
|
<input value={profileForm.auth_json_path} placeholder="留空表示 ~/.codex/auth.json" onChange={(event) => setProfileForm({ ...profileForm, auth_json_path: event.target.value })} />
|
|
</Field>
|
|
<Field label="文本 auth.json 字段名" hint="这里填 JSON key,例如 OPENAI_API_KEY,不粘贴 sk- 开头的密钥值。">
|
|
<input value={profileForm.auth_json_key} placeholder="OPENAI_API_KEY" onChange={(event) => setProfileForm({ ...profileForm, auth_json_key: event.target.value })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
<Field label="History Limit" hint="0 表示不裁剪;正整数表示后台持久化记录的保留上限。">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={profileForm.request_history_limit}
|
|
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>
|
|
<Field label="retryable_status_codes">
|
|
<input value={profileForm.retryable_status_codes} placeholder="429,503" onChange={(event) => setProfileForm({ ...profileForm, retryable_status_codes: event.target.value })} />
|
|
</Field>
|
|
<Field label="retryable_error_messages" hint="每行一条错误文案;上游 JSON 错误包含任一行时会转成本地重试状态码。">
|
|
<textarea value={profileForm.retryable_error_messages} onChange={(event) => setProfileForm({ ...profileForm, retryable_error_messages: event.target.value })} />
|
|
</Field>
|
|
<div className="field-row">
|
|
<Field label="upstream_fetch_retry_attempts">
|
|
<input type="number" min={1} value={profileForm.upstream_fetch_retry_attempts} onChange={(event) => setProfileForm({ ...profileForm, upstream_fetch_retry_attempts: event.target.value })} />
|
|
</Field>
|
|
<Field label="upstream_fetch_retry_backoff_ms">
|
|
<input type="number" min={0} step={50} value={profileForm.upstream_fetch_retry_backoff_ms} onChange={(event) => setProfileForm({ ...profileForm, upstream_fetch_retry_backoff_ms: event.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<Field label="endpoints">
|
|
<textarea value={profileForm.endpoints} onChange={(event) => setProfileForm({ ...profileForm, endpoints: event.target.value })} />
|
|
</Field>
|
|
<div className="toolbar">
|
|
<button className="primary" type="submit">
|
|
保存 Profile
|
|
</button>
|
|
<button className="ghost" type="button" onClick={() => setProfileForm(profileFormFromStatus(status))}>
|
|
清空表单
|
|
</button>
|
|
</div>
|
|
<Message message={profileMessage} />
|
|
</form>
|
|
</Card>
|
|
|
|
<Card title="文本 Profile 探针" hint="不切换当前 gateway,只对选定文本 profile 做上游连通性与最小请求探测。默认测试模型是 gpt-5.5-fast,若配置了 remap,会显示实际转发模型。">
|
|
{profileProbeResult ? (
|
|
<div className="grid">
|
|
<InfoList
|
|
rows={[
|
|
["Profile", profileProbeResult.profile || "-"],
|
|
["上游", profileProbeResult.upstream_base_url || "-"],
|
|
["Auth", `${profileProbeResult.auth_mode || "-"} / ${profileProbeResult.auth_source || "-"}`],
|
|
["Model Remap", profileProbeResult.model_remap || "-"],
|
|
]}
|
|
/>
|
|
{(profileProbeResult.probes || []).map((probe) => (
|
|
<div className="profile-card" key={`${probe.kind}-${probe.target}`}>
|
|
<div className="profile-head">
|
|
<div>
|
|
<h3>{probe.kind || "probe"}</h3>
|
|
<div className="hint">{probe.target || "-"}</div>
|
|
</div>
|
|
<span className="badge">{probe.status ?? "-"}</span>
|
|
</div>
|
|
<div className="mini-stats">
|
|
<MiniStat label="请求模型" value={probe.requested_model || "-"} />
|
|
<MiniStat label="转发模型" value={probe.forwarded_model || "-"} />
|
|
<MiniStat label="Content-Type" value={probe.content_type || "-"} />
|
|
</div>
|
|
<pre className="log-output">{probe.body_preview || "无返回摘要"}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="hint">先在左侧 profile 卡片点击“探针”,这里会显示不切换情况下的探测结果。</div>
|
|
)}
|
|
</Card>
|
|
|
|
<Card
|
|
title="图片 Profile 列表"
|
|
hint={imageProfilesMeta}
|
|
actions={
|
|
<button
|
|
className="secondary"
|
|
type="button"
|
|
onClick={() => {
|
|
setShouldSyncImageProfileFormToActive(false);
|
|
setImageProfileForm(defaultImageProfileForm);
|
|
setImageProfileMessage({ text: "", tone: "" });
|
|
}}
|
|
>
|
|
新建图片 Profile
|
|
</button>
|
|
}
|
|
>
|
|
<div className="grid profile-list-grid">
|
|
{imageProfiles.length === 0 ? (
|
|
<div className="profile-card">当前还没有图片 profile env 文件。</div>
|
|
) : (
|
|
imageProfiles.map((profile) => (
|
|
<article className="profile-card compact-profile-card" data-active-profile={profile.active} key={profile.name}>
|
|
<div className="profile-head">
|
|
<div className="profile-meta">
|
|
<h3>{profile.name}</h3>
|
|
<div className="profile-path hint">{profile.file_path || ""}</div>
|
|
</div>
|
|
<div className="profile-actions">
|
|
{profile.active ? <span className="badge">当前运行</span> : <span className="badge warn">可切换</span>}
|
|
<button className="ghost" type="button" onClick={() => editImageProfile(profile)}>
|
|
编辑
|
|
</button>
|
|
<button className="ghost" type="button" disabled={probingImageProfile === profile.name} onClick={() => probeImageProfile(profile)}>
|
|
{probingImageProfile === profile.name ? "探测中" : "探针"}
|
|
</button>
|
|
<button className="danger" type="button" disabled={deletingImageProfile === profile.name} onClick={() => removeImageProfile(profile)}>
|
|
{deletingImageProfile === profile.name ? "删除中" : "删除"}
|
|
</button>
|
|
<button className="primary" type="button" disabled={profile.active} onClick={() => switchImageProfile(profile)}>
|
|
切换
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mini-stats">
|
|
<MiniStat label="图片上游" value={profile.summary?.base_url || "回退文本上游"} />
|
|
<MiniStat label="图片认证" value={`${profile.summary?.auth_mode || "fixed_bearer"} / ${profile.summary?.auth_source || "-"}`} />
|
|
</div>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title={imageProfileForm.name ? `编辑图片 ${imageProfileForm.name}` : "编辑图片 Profile"} hint="图片 API key 只写入系统 secret 文件,保存后不会从后端读回或展示。">
|
|
<form onSubmit={saveImageProfile}>
|
|
<Field label="图片 Profile 名称">
|
|
<input value={imageProfileForm.name} placeholder="image-primary" onChange={(event) => setImageProfileForm({ ...imageProfileForm, name: event.target.value })} />
|
|
</Field>
|
|
<Field label="图片上游 Base URL" hint="留空时 /images/* 和 /v1/images/* 回退到当前文本上游。">
|
|
<input value={imageProfileForm.base_url} placeholder="https://image-provider.example/v1" onChange={(event) => setImageProfileForm({ ...imageProfileForm, base_url: event.target.value })} />
|
|
</Field>
|
|
<Field label="图片 Auth 模式">
|
|
<select value={imageProfileForm.auth_mode} onChange={(event) => setImageProfileForm({ ...imageProfileForm, auth_mode: event.target.value as AuthMode })}>
|
|
<option value="passthrough">passthrough:透传 Codex Authorization</option>
|
|
<option value="manual_bearer">manual_bearer:手动写入系统 secret 文件</option>
|
|
<option value="fixed_bearer">fixed_bearer:从 env/file 读取 Bearer</option>
|
|
<option value="auth_json">auth_json:从 ~/.codex/auth.json 读取字段</option>
|
|
</select>
|
|
</Field>
|
|
{imageProfileForm.auth_mode === "manual_bearer" && (
|
|
<Field
|
|
label="图片 API Key"
|
|
hint={imageProfileForm.manual_secret_configured ? "系统中已有图片 key;留空表示保留,填入新值会覆盖写入。" : "只写入系统 secret 文件,保存后不会显示或返回。"}
|
|
>
|
|
<input
|
|
type="password"
|
|
value={imageProfileForm.manual_secret}
|
|
autoComplete="new-password"
|
|
placeholder={imageProfileForm.manual_secret_configured ? "已配置;留空保留" : "输入一次图片 API key"}
|
|
onChange={(event) => setImageProfileForm({ ...imageProfileForm, manual_secret: event.target.value })}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{imageProfileForm.auth_mode === "fixed_bearer" && (
|
|
<>
|
|
<Field label="图片 Key 环境变量名" hint="只填变量名,不填 key 值。">
|
|
<input value={imageProfileForm.auth_env} placeholder="CODEX_RETRY_GATEWAY_IMAGE_API_KEY" onChange={(event) => setImageProfileForm({ ...imageProfileForm, auth_env: event.target.value })} />
|
|
</Field>
|
|
<Field label="图片 Key 文件路径">
|
|
<input value={imageProfileForm.auth_file} placeholder="/run/secrets/image-provider-token" onChange={(event) => setImageProfileForm({ ...imageProfileForm, auth_file: event.target.value })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
{imageProfileForm.auth_mode === "auth_json" && (
|
|
<>
|
|
<Field label="图片 auth.json 路径">
|
|
<input value={imageProfileForm.auth_json_path} placeholder="留空表示 ~/.codex/auth.json" onChange={(event) => setImageProfileForm({ ...imageProfileForm, auth_json_path: event.target.value })} />
|
|
</Field>
|
|
<Field label="图片 auth.json 字段名">
|
|
<input value={imageProfileForm.auth_json_key} placeholder="OPENAI_API_KEY" onChange={(event) => setImageProfileForm({ ...imageProfileForm, auth_json_key: event.target.value })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
<div className="toolbar">
|
|
<button className="primary" type="submit">
|
|
保存图片 Profile
|
|
</button>
|
|
<button className="ghost" type="button" onClick={() => setImageProfileForm(defaultImageProfileForm)}>
|
|
清空表单
|
|
</button>
|
|
</div>
|
|
<Message message={imageProfileMessage} />
|
|
</form>
|
|
</Card>
|
|
|
|
<Card title="图片 Profile 探针" hint="不切换当前 gateway,只对选定图片 profile 请求 /v1/models 检查连通性和认证。">
|
|
{imageProfileProbeResult ? (
|
|
<div className="grid">
|
|
<InfoList
|
|
rows={[
|
|
["图片 Profile", imageProfileProbeResult.image_profile || "-"],
|
|
["上游", imageProfileProbeResult.image_base_url || "-"],
|
|
["Auth", `${imageProfileProbeResult.auth_mode || "-"} / ${imageProfileProbeResult.auth_source || "-"}`],
|
|
]}
|
|
/>
|
|
{(imageProfileProbeResult.probes || []).map((probe) => (
|
|
<div className="profile-card" key={`${probe.kind}-${probe.target}`}>
|
|
<div className="profile-head">
|
|
<div>
|
|
<h3>{probe.kind || "probe"}</h3>
|
|
<div className="hint">{probe.target || "-"}</div>
|
|
</div>
|
|
<span className="badge">{probe.status ?? "-"}</span>
|
|
</div>
|
|
<div className="mini-stats">
|
|
<MiniStat label="Content-Type" value={probe.content_type || "-"} />
|
|
</div>
|
|
<pre className="log-output">{probe.body_preview || "无返回摘要"}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="hint">先在图片 profile 卡片点击“探针”,这里会显示不切换情况下的探测结果。</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{page === "rules" && (
|
|
<section className="page" data-active="true">
|
|
<div className="grid rules-layout">
|
|
<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>
|
|
<Field label="retryable_status_codes">
|
|
<input value={ruleForm.retryable_status_codes} placeholder="例如:429, 503" onChange={(event) => setRuleForm({ ...ruleForm, retryable_status_codes: event.target.value })} />
|
|
</Field>
|
|
<Field label="retryable_error_messages" hint="每行一条错误文案;命中后会转成本地 non_stream_status_code。">
|
|
<textarea value={ruleForm.retryable_error_messages} onChange={(event) => setRuleForm({ ...ruleForm, retryable_error_messages: event.target.value })} />
|
|
</Field>
|
|
<div className="field-row">
|
|
<Field label="upstream_fetch_retry_attempts">
|
|
<input type="number" min={1} value={ruleForm.upstream_fetch_retry_attempts} onChange={(event) => setRuleForm({ ...ruleForm, upstream_fetch_retry_attempts: event.target.value })} />
|
|
</Field>
|
|
<Field label="upstream_fetch_retry_backoff_ms">
|
|
<input type="number" min={0} step={50} value={ruleForm.upstream_fetch_retry_backoff_ms} onChange={(event) => setRuleForm({ ...ruleForm, upstream_fetch_retry_backoff_ms: event.target.value })} />
|
|
</Field>
|
|
</div>
|
|
<Field label="endpoints">
|
|
<textarea value={ruleForm.endpoints} onChange={(event) => setRuleForm({ ...ruleForm, endpoints: event.target.value })} />
|
|
</Field>
|
|
<div className="field-row">
|
|
<Field label="non_stream_status_code">
|
|
<input type="number" min={100} max={599} value={ruleForm.non_stream_status_code} onChange={(event) => setRuleForm({ ...ruleForm, non_stream_status_code: event.target.value })} />
|
|
</Field>
|
|
<label className="inline-toggle">
|
|
<input type="checkbox" checked={ruleForm.log_match} onChange={(event) => setRuleForm({ ...ruleForm, log_match: event.target.checked })} />
|
|
命中时写日志
|
|
</label>
|
|
</div>
|
|
<div className="toolbar">
|
|
<button className="primary" type="submit">
|
|
保存并立即生效
|
|
</button>
|
|
<button className="danger" type="button" onClick={restoreConfig}>
|
|
恢复 Codex 原设置并关闭网关
|
|
</button>
|
|
</div>
|
|
<Message message={ruleMessage} />
|
|
</form>
|
|
</Card>
|
|
|
|
<Card title="Thread 覆盖" hint={threadRulesMeta}>
|
|
<div className="thread-rule-list">
|
|
{threadRules.length === 0 ? (
|
|
<div className="empty-state">当前没有按 thread_id 的拦截覆盖,全部走默认规则。</div>
|
|
) : (
|
|
threadRules.map((rule) => {
|
|
const busy = threadRuleBusyThreadId === rule.thread_id;
|
|
return (
|
|
<article className="thread-rule-card" key={rule.thread_id}>
|
|
<div className="thread-rule-head">
|
|
<div>
|
|
<strong>{rule.thread_id}</strong>
|
|
<div className="hint">更新于 {timestamp(rule.updated_at)}</div>
|
|
</div>
|
|
<span className={`badge ${threadGuardBadgeTone(rule.reasoning_intercept_enabled ? "enabled" : "disabled")}`}>
|
|
{rule.reasoning_intercept_enabled ? "强制开启" : "已关闭"}
|
|
</span>
|
|
</div>
|
|
<div className="thread-rule-actions">
|
|
<button className="ghost" type="button" disabled={busy || rule.reasoning_intercept_enabled} onClick={() => updateThreadRule(rule.thread_id, true)}>
|
|
{busy && !rule.reasoning_intercept_enabled ? "处理中" : "开启拦截"}
|
|
</button>
|
|
<button className="ghost" type="button" disabled={busy || !rule.reasoning_intercept_enabled} onClick={() => updateThreadRule(rule.thread_id, false)}>
|
|
{busy && rule.reasoning_intercept_enabled ? "处理中" : "关闭拦截"}
|
|
</button>
|
|
<button className="secondary" type="button" disabled={busy} onClick={() => restoreThreadRule(rule.thread_id)}>
|
|
{busy ? "处理中" : "恢复默认"}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<Message message={threadRuleMessage} />
|
|
</Card>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{page === "logs" && (
|
|
<section className="page" data-active="true">
|
|
<Card
|
|
title="实时日志"
|
|
hint={logsMeta}
|
|
actions={
|
|
<button className="secondary" type="button" onClick={() => loadLogs(false)}>
|
|
刷新日志
|
|
</button>
|
|
}
|
|
>
|
|
<pre className="log-output">{logs}</pre>
|
|
</Card>
|
|
</section>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
<div className="switch-overlay" data-active={Boolean(switchingTo)}>
|
|
<div className="switch-dialog">
|
|
<span className="eyebrow">Profile Switch</span>
|
|
<h2>正在切换到 {switchingTo}</h2>
|
|
<p className="muted">gateway 正在后台热切换当前运行配置,页面会自动等待目标 profile 生效。</p>
|
|
<div className="progress-line">
|
|
<span />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Card({
|
|
title,
|
|
hint,
|
|
badge,
|
|
actions,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
hint?: string;
|
|
badge?: string;
|
|
actions?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="card">
|
|
<div className="card-inner">
|
|
<div className="card-head">
|
|
<div>
|
|
<h3>{title}</h3>
|
|
{hint ? <div className="hint">{hint}</div> : null}
|
|
</div>
|
|
{badge ? <span className="badge">{badge}</span> : actions}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<label className="field">
|
|
<span>{label}</span>
|
|
{children}
|
|
{hint ? <span className="hint">{hint}</span> : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="stat">
|
|
<label>{label}</label>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TokenCard({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="token-card">
|
|
<label>{label}</label>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoList({ rows }: { rows: Array<[string, string]> }) {
|
|
return (
|
|
<div className="info-list">
|
|
{rows.map(([label, value]) => (
|
|
<div className="info-row" key={label}>
|
|
<label>{label}</label>
|
|
<span>{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MiniStat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="mini-stat">
|
|
<label>{label}</label>
|
|
<span>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SideCard({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) {
|
|
return (
|
|
<div className="side-card">
|
|
<label>{label}</label>
|
|
{strong ? <strong>{value}</strong> : <span>{value}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Message({ message }: { message: MessageState }) {
|
|
return (
|
|
<div className="message" data-tone={message.tone}>
|
|
{message.text}
|
|
</div>
|
|
);
|
|
}
|