Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26070fb7d5 | ||
|
|
f9a71663f6 | ||
|
|
5d48ef5880 | ||
|
|
a62f2cf1b9 | ||
|
|
446a0e99a8 | ||
|
|
c8cc3b153b | ||
|
|
d90a1cd94f | ||
|
|
41357f5e7a | ||
|
|
5e237993dd |
@@ -1,6 +1,18 @@
|
|||||||
# Codex Retry Gateway
|
# Codex Retry Gateway
|
||||||
|
|
||||||
tg群:https://t.me/AI_INPUT_IM
|
- 文档层级:app 项目真源
|
||||||
|
- 应用性质:自有 infra 工具
|
||||||
|
- 源码来源:git submodule
|
||||||
|
- 运行关系:多实例服务
|
||||||
|
- 部署模型:managed_systemd_apps
|
||||||
|
- 对应服务:`services/pc/systemd-user/codex-retry-gateway/current/README.md`、`services/a100/systemd-user/codex-retry-gateway/current/README.md`
|
||||||
|
- 对应 stack:无
|
||||||
|
- Secret 边界:本地用户配置
|
||||||
|
- 数据来源:本地 Codex provider 配置、gateway profile state 与上游 OpenAI-compatible API 响应。
|
||||||
|
- 输出边界:仅返回代理后的 Codex API 响应和本地健康/状态信息。
|
||||||
|
- 写入边界:只写本地用户态 gateway 状态目录。
|
||||||
|
- 本地状态路径:`~/.codex-retry-gateway/`
|
||||||
|
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
|
||||||
|
|
||||||
一个不依赖 `cc-switch` 路由模式的独立本地网关。
|
一个不依赖 `cc-switch` 路由模式的独立本地网关。
|
||||||
|
|
||||||
@@ -12,6 +24,7 @@ tg群:https://t.me/AI_INPUT_IM
|
|||||||
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`,以及 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`),也会自动重试;重试耗尽后转成本地 `502`
|
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`,以及 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`),也会自动重试;重试耗尽后转成本地 `502`
|
||||||
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
||||||
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
||||||
|
- 流式成功响应在严格检查模式下仍会先缓存完成,但会按分块 SSE 回放给 Codex,而不是单次大包 `res.end(...)`
|
||||||
- 默认同时拦截 root 路径和 `/v1` 路径:
|
- 默认同时拦截 root 路径和 `/v1` 路径:
|
||||||
- `/responses`
|
- `/responses`
|
||||||
- `/chat/completions`
|
- `/chat/completions`
|
||||||
|
|||||||
+4
-2
@@ -8,15 +8,17 @@
|
|||||||
"upstream_auth_file": "",
|
"upstream_auth_file": "",
|
||||||
"upstream_auth_json_path": "",
|
"upstream_auth_json_path": "",
|
||||||
"upstream_auth_json_key": "OPENAI_API_KEY",
|
"upstream_auth_json_key": "OPENAI_API_KEY",
|
||||||
"request_body_limit_bytes": 10485760,
|
"request_body_limit_bytes": 1073741824,
|
||||||
"request_history_limit": 200,
|
"request_history_limit": 200,
|
||||||
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||||
"reasoning_equals": [516],
|
"reasoning_match_mode": "formula_518n_minus_2",
|
||||||
|
"reasoning_equals": [516, 1034, 1552],
|
||||||
"retryable_status_codes": [429, 503],
|
"retryable_status_codes": [429, 503],
|
||||||
"retryable_error_messages": [
|
"retryable_error_messages": [
|
||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
|
||||||
],
|
],
|
||||||
|
"management_access_key": "",
|
||||||
"non_stream_status_code": 502,
|
"non_stream_status_code": 502,
|
||||||
"stream_action": "strict_502",
|
"stream_action": "strict_502",
|
||||||
"log_match": true,
|
"log_match": true,
|
||||||
|
|||||||
+1366
-164
File diff suppressed because it is too large
Load Diff
+29
-5
@@ -7,10 +7,21 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway");
|
export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway");
|
||||||
|
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
|
||||||
|
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||||
export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
||||||
export const DEFAULT_LISTEN_HOST = "127.0.0.1";
|
export const DEFAULT_LISTEN_HOST = "127.0.0.1";
|
||||||
export const DEFAULT_LISTEN_PORT = 4610;
|
export const DEFAULT_LISTEN_PORT = 4610;
|
||||||
export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health";
|
export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health";
|
||||||
|
export const DEFAULT_REASONING_MATCH_MODE = "formula_518n_minus_2";
|
||||||
|
export const DEFAULT_REASONING_EQUALS = [516, 1034, 1552];
|
||||||
|
|
||||||
|
export function expandEscapedLineBreaks(value) {
|
||||||
|
return `${value ?? ""}`
|
||||||
|
.replace(/\\r\\n/g, "\n")
|
||||||
|
.replace(/\\n/g, "\n")
|
||||||
|
.replace(/\\r/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(value) {
|
function escapeRegExp(value) {
|
||||||
return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
@@ -165,7 +176,7 @@ export async function setCodexProviderBaseUrl({ codexConfigPath, providerName, n
|
|||||||
await writeUtf8File(codexConfigPath, updatedContent);
|
await writeUtf8File(codexConfigPath, updatedContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeIntArray(values, fallback = [516]) {
|
export function normalizeIntArray(values, fallback = DEFAULT_REASONING_EQUALS) {
|
||||||
const source = values === undefined || values === null ? fallback : values;
|
const source = values === undefined || values === null ? fallback : values;
|
||||||
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
|
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
|
||||||
const normalized = queue
|
const normalized = queue
|
||||||
@@ -188,11 +199,19 @@ export function normalizeStringArray(values, fallback = []) {
|
|||||||
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
|
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeReasoningMatchMode(value) {
|
||||||
|
const mode = `${value || DEFAULT_REASONING_MATCH_MODE}`.trim().toLowerCase();
|
||||||
|
if (["formula_518n_minus_2", "manual"].includes(mode)) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return DEFAULT_REASONING_MATCH_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePhraseArray(values, fallback = []) {
|
export function normalizePhraseArray(values, fallback = []) {
|
||||||
const source = values === undefined || values === null ? fallback : values;
|
const source = values === undefined || values === null ? fallback : values;
|
||||||
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
|
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
|
||||||
const normalized = queue
|
const normalized = queue
|
||||||
.flatMap((value) => (typeof value === "string" ? value.split(/\r?\n/) : [value]))
|
.flatMap((value) => (typeof value === "string" ? expandEscapedLineBreaks(value).split(/\r?\n/) : [value]))
|
||||||
.map((value) => `${value ?? ""}`.trim())
|
.map((value) => `${value ?? ""}`.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@@ -421,10 +440,15 @@ export async function installForCurrentProvider({
|
|||||||
upstream_base_url: originalBaseUrl,
|
upstream_base_url: originalBaseUrl,
|
||||||
request_body_limit_bytes:
|
request_body_limit_bytes:
|
||||||
existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null
|
existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null
|
||||||
? 10485760
|
? DEFAULT_REQUEST_BODY_LIMIT_BYTES
|
||||||
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10),
|
: (
|
||||||
|
Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10) === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES
|
||||||
|
? DEFAULT_REQUEST_BODY_LIMIT_BYTES
|
||||||
|
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10)
|
||||||
|
),
|
||||||
endpoints: mergedEndpoints,
|
endpoints: mergedEndpoints,
|
||||||
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]),
|
reasoning_match_mode: normalizeReasoningMatchMode(existingGatewayConfig?.reasoning_match_mode),
|
||||||
|
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, DEFAULT_REASONING_EQUALS),
|
||||||
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
|
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
|
||||||
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [
|
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [
|
||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
|
|||||||
+1
-1
@@ -221,7 +221,7 @@ function Wait-GatewayHealth {
|
|||||||
function Normalize-IntArray {
|
function Normalize-IntArray {
|
||||||
param(
|
param(
|
||||||
$Values,
|
$Values,
|
||||||
[int[]]$Default = @(516)
|
[int[]]$Default = @(516, 1034, 1552)
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($null -eq $Values) {
|
if ($null -eq $Values) {
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ $gatewayConfig = [ordered]@{
|
|||||||
listen_host = $ListenHost
|
listen_host = $ListenHost
|
||||||
listen_port = $ListenPort
|
listen_port = $ListenPort
|
||||||
upstream_base_url = $originalBaseUrl
|
upstream_base_url = $originalBaseUrl
|
||||||
request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) { [int]$existingGatewayConfig.request_body_limit_bytes } else { 10485760 }
|
request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) {
|
||||||
|
if ([int]$existingGatewayConfig.request_body_limit_bytes -eq 10485760) { 1073741824 } else { [int]$existingGatewayConfig.request_body_limit_bytes }
|
||||||
|
} else { 1073741824 }
|
||||||
endpoints = @($mergedEndpoints)
|
endpoints = @($mergedEndpoints)
|
||||||
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516)
|
reasoning_match_mode = if (
|
||||||
|
$existingGatewayConfig -and
|
||||||
|
$existingGatewayConfig.reasoning_match_mode -and
|
||||||
|
@("formula_518n_minus_2", "manual") -contains ([string]$existingGatewayConfig.reasoning_match_mode)
|
||||||
|
) { [string]$existingGatewayConfig.reasoning_match_mode } else { "formula_518n_minus_2" }
|
||||||
|
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516, 1034, 1552)
|
||||||
non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 }
|
non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 }
|
||||||
stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "strict_502" }
|
stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "strict_502" }
|
||||||
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
|
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
|
||||||
|
|||||||
+38
-8
@@ -11,13 +11,17 @@ import {
|
|||||||
DEFAULT_HEALTH_PATH,
|
DEFAULT_HEALTH_PATH,
|
||||||
DEFAULT_LISTEN_HOST,
|
DEFAULT_LISTEN_HOST,
|
||||||
DEFAULT_LISTEN_PORT,
|
DEFAULT_LISTEN_PORT,
|
||||||
|
DEFAULT_REASONING_EQUALS,
|
||||||
|
DEFAULT_REASONING_MATCH_MODE,
|
||||||
DEFAULT_STATE_ROOT,
|
DEFAULT_STATE_ROOT,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
|
expandEscapedLineBreaks,
|
||||||
getCodexProviderContext,
|
getCodexProviderContext,
|
||||||
getGatewayBaseUrl,
|
getGatewayBaseUrl,
|
||||||
getGatewayStatePaths,
|
getGatewayStatePaths,
|
||||||
normalizeIntArray,
|
normalizeIntArray,
|
||||||
normalizePhraseArray,
|
normalizePhraseArray,
|
||||||
|
normalizeReasoningMatchMode,
|
||||||
normalizeStringArray,
|
normalizeStringArray,
|
||||||
parseOptions,
|
parseOptions,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
@@ -28,6 +32,8 @@ import {
|
|||||||
} from "./admin-lib.mjs";
|
} from "./admin-lib.mjs";
|
||||||
|
|
||||||
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
|
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
|
||||||
|
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
|
||||||
|
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
function parseEnvFile(content) {
|
function parseEnvFile(content) {
|
||||||
const parsed = {};
|
const parsed = {};
|
||||||
@@ -42,13 +48,16 @@ function parseEnvFile(content) {
|
|||||||
}
|
}
|
||||||
const key = line.slice(0, separatorIndex).trim();
|
const key = line.slice(0, separatorIndex).trim();
|
||||||
let value = line.slice(separatorIndex + 1).trim();
|
let value = line.slice(separatorIndex + 1).trim();
|
||||||
if (
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
try {
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
value = JSON.parse(value);
|
||||||
) {
|
} catch {
|
||||||
value = value.slice(1, -1);
|
value = value.slice(1, -1);
|
||||||
}
|
}
|
||||||
parsed[key] = value;
|
} else if (value.startsWith("'") && value.endsWith("'")) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
parsed[key] = typeof value === "string" ? expandEscapedLineBreaks(value) : value;
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,17 @@ function boolFromEnv(value, fallback = false) {
|
|||||||
return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase());
|
return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequestBodyLimitBytes(value) {
|
||||||
|
const parsed = Number.parseInt(`${value ?? ""}`, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
|
||||||
|
}
|
||||||
|
if (parsed === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES) {
|
||||||
|
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuthMode(value) {
|
function normalizeAuthMode(value) {
|
||||||
const mode = `${value || "passthrough"}`.trim().toLowerCase();
|
const mode = `${value || "passthrough"}`.trim().toLowerCase();
|
||||||
if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) {
|
if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) {
|
||||||
@@ -170,6 +190,11 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
|
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
|
||||||
|
const reasoningMatchMode = normalizeReasoningMatchMode(
|
||||||
|
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
|
||||||
|
existingGatewayConfig?.reasoning_match_mode ||
|
||||||
|
DEFAULT_REASONING_MATCH_MODE,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile_name: profileName,
|
profile_name: profileName,
|
||||||
@@ -180,8 +205,8 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
upstream_base_url: upstreamBaseUrl,
|
upstream_base_url: upstreamBaseUrl,
|
||||||
...profileAuthConfig,
|
...profileAuthConfig,
|
||||||
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
|
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
|
||||||
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10)
|
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
|
||||||
: Number.parseInt(`${existingGatewayConfig?.request_body_limit_bytes || 10485760}`, 10),
|
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
|
||||||
request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT
|
request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT
|
||||||
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10)
|
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10)
|
||||||
: Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10),
|
: Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10),
|
||||||
@@ -190,9 +215,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints,
|
profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints,
|
||||||
["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||||
),
|
),
|
||||||
|
reasoning_match_mode: reasoningMatchMode,
|
||||||
reasoning_equals: normalizeIntArray(
|
reasoning_equals: normalizeIntArray(
|
||||||
profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals,
|
profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals,
|
||||||
[516],
|
DEFAULT_REASONING_EQUALS,
|
||||||
),
|
),
|
||||||
retryable_status_codes: normalizeIntArray(
|
retryable_status_codes: normalizeIntArray(
|
||||||
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes,
|
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes,
|
||||||
@@ -205,6 +231,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
management_access_key:
|
||||||
|
profileEnv.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY ||
|
||||||
|
existingGatewayConfig?.management_access_key ||
|
||||||
|
"",
|
||||||
upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
|
upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
|
||||||
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
|
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
|
||||||
: Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_attempts || 5}`, 10),
|
: Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_attempts || 5}`, 10),
|
||||||
|
|||||||
+391
-22
@@ -8,6 +8,8 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { normalizePhraseArray } from "./admin-lib.mjs";
|
||||||
|
|
||||||
const gatewayRoot = path.resolve(import.meta.dirname, "..");
|
const gatewayRoot = path.resolve(import.meta.dirname, "..");
|
||||||
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
|
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ function assert(condition, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminHeaders = {
|
||||||
|
"x-codex-retry-gateway-key": "test-admin-key",
|
||||||
|
};
|
||||||
|
|
||||||
async function getFreePort() {
|
async function getFreePort() {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.listen(0, "127.0.0.1");
|
server.listen(0, "127.0.0.1");
|
||||||
@@ -39,12 +45,13 @@ function createJsonResponse(res, statusCode, body, extraHeaders = {}) {
|
|||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSseResponse(res, chunks, intervalMs = 20) {
|
function createSseResponse(res, chunks, intervalMs = 20, options = {}) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
connection: "keep-alive",
|
connection: "keep-alive",
|
||||||
"x-upstream-test": "sse",
|
"x-upstream-test": "sse",
|
||||||
|
...(options.headers || {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -63,12 +70,13 @@ function createSseResponse(res, chunks, intervalMs = 20) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTerminatedSseResponse(res, chunks, destroyDelayMs = 20) {
|
function createTerminatedSseResponse(res, chunks, destroyDelayMs = 20, options = {}) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
connection: "keep-alive",
|
connection: "keep-alive",
|
||||||
"x-upstream-test": "sse-terminated",
|
"x-upstream-test": "sse-terminated",
|
||||||
|
...(options.headers || {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
@@ -116,12 +124,57 @@ function createCapacityErrorSseResponse(
|
|||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
],
|
],
|
||||||
intervalMs,
|
intervalMs,
|
||||||
|
{ headers: options.headers || {} },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasoningRetryKeyForRequest(url, parsed) {
|
||||||
|
return [
|
||||||
|
url,
|
||||||
|
parsed.stream ? "stream" : "non-stream",
|
||||||
|
parsed.test_reasoning_retry_key || parsed.thread_id || "missing-thread",
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginReasoningRetryTrackedRequest(statsMap, key, res) {
|
||||||
|
const stats = statsMap.get(key) || {
|
||||||
|
totalRequests: 0,
|
||||||
|
activeRequests: 0,
|
||||||
|
maxConcurrent: 0,
|
||||||
|
cancelledRequests: 0,
|
||||||
|
};
|
||||||
|
stats.totalRequests += 1;
|
||||||
|
stats.activeRequests += 1;
|
||||||
|
stats.maxConcurrent = Math.max(stats.maxConcurrent, stats.activeRequests);
|
||||||
|
statsMap.set(key, stats);
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
const finish = (cancelled = false) => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
stats.activeRequests = Math.max(0, stats.activeRequests - 1);
|
||||||
|
if (cancelled) {
|
||||||
|
stats.cancelledRequests += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on("close", () => {
|
||||||
|
finish(!res.writableEnded);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
finish,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function startFakeUpstream(port) {
|
function startFakeUpstream(port) {
|
||||||
const failBeforeResponseCounts = new Map();
|
const failBeforeResponseCounts = new Map();
|
||||||
const capacityBeforeSuccessCounts = new Map();
|
const capacityBeforeSuccessCounts = new Map();
|
||||||
|
const reasoningBeforeSuccessCounts = new Map();
|
||||||
|
const reasoningRetryStats = new Map();
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const responsePaths = new Set(["/responses", "/v1/responses"]);
|
const responsePaths = new Set(["/responses", "/v1/responses"]);
|
||||||
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
|
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
|
||||||
@@ -147,7 +200,22 @@ function startFakeUpstream(port) {
|
|||||||
});
|
});
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
const parsed = JSON.parse(body || "{}");
|
const parsed = JSON.parse(body || "{}");
|
||||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
let reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||||
|
let reasoningAttempt = null;
|
||||||
|
const reasoningRetryKey = Number.isInteger(parsed.test_reasoning_before_success_times)
|
||||||
|
? reasoningRetryKeyForRequest(req.url, parsed)
|
||||||
|
: null;
|
||||||
|
const reasoningRetryTracker = reasoningRetryKey
|
||||||
|
? beginReasoningRetryTrackedRequest(reasoningRetryStats, reasoningRetryKey, res)
|
||||||
|
: null;
|
||||||
|
if (Number.isInteger(parsed.test_reasoning_before_success_times) && reasoningRetryKey) {
|
||||||
|
const currentCount = (reasoningBeforeSuccessCounts.get(reasoningRetryKey) || 0) + 1;
|
||||||
|
reasoningBeforeSuccessCounts.set(reasoningRetryKey, currentCount);
|
||||||
|
reasoningAttempt = currentCount;
|
||||||
|
reasoning = currentCount <= parsed.test_reasoning_before_success_times
|
||||||
|
? 516
|
||||||
|
: (parsed.test_reasoning_success_tokens ?? 128);
|
||||||
|
}
|
||||||
if (parsed.test_fail_before_response_once) {
|
if (parsed.test_fail_before_response_once) {
|
||||||
const failKey = `${req.url}:fail-before-response-once`;
|
const failKey = `${req.url}:fail-before-response-once`;
|
||||||
const failCount = (failBeforeResponseCounts.get(failKey) || 0) + 1;
|
const failCount = (failBeforeResponseCounts.get(failKey) || 0) + 1;
|
||||||
@@ -231,11 +299,28 @@ function startFakeUpstream(port) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.stream) {
|
if (parsed.stream) {
|
||||||
createSseResponse(res, [
|
const deltaChunkCount = Number.isInteger(parsed.test_stream_delta_chunks) && parsed.test_stream_delta_chunks > 0
|
||||||
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "hello", response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream" })}\n\n`,
|
? parsed.test_stream_delta_chunks
|
||||||
|
: 1;
|
||||||
|
const deltaTextBase = parsed.test_stream_delta_text || "hello";
|
||||||
|
const streamChunks = Array.from({ length: deltaChunkCount }, (_, index) => {
|
||||||
|
const deltaText = deltaChunkCount === 1 ? deltaTextBase : `${deltaTextBase}-${index + 1}`;
|
||||||
|
return `data: ${JSON.stringify({ type: "response.output_text.delta", delta: deltaText, response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream", retry_attempt: reasoningAttempt })}\n\n`;
|
||||||
|
});
|
||||||
|
streamChunks.push(
|
||||||
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
|
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
|
||||||
"data: [DONE]\n\n",
|
"data: [DONE]\n\n",
|
||||||
], parsed.test_stream_chunk_delay_ms ?? 20);
|
);
|
||||||
|
createSseResponse(res, streamChunks, parsed.test_reasoning_response_delay_ms ?? parsed.test_stream_chunk_delay_ms ?? 20, {
|
||||||
|
headers: reasoningAttempt
|
||||||
|
? { "x-upstream-reasoning-attempt": `${reasoningAttempt}` }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sendJsonResponse = () => {
|
||||||
|
if (res.writableEnded || res.destroyed) {
|
||||||
|
reasoningRetryTracker?.finish(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createJsonResponse(
|
createJsonResponse(
|
||||||
@@ -246,15 +331,24 @@ function startFakeUpstream(port) {
|
|||||||
thread_id: parsed.thread_id || "thread_test",
|
thread_id: parsed.thread_id || "thread_test",
|
||||||
retry_attempt: parsed.test_fail_before_response_once
|
retry_attempt: parsed.test_fail_before_response_once
|
||||||
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
|
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
|
||||||
: 0,
|
: reasoningAttempt || 0,
|
||||||
usage: {
|
usage: {
|
||||||
output_tokens_details: {
|
output_tokens_details: {
|
||||||
reasoning_tokens: reasoning,
|
reasoning_tokens: reasoning,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ "x-upstream-test": `responses-${reasoning}` },
|
{
|
||||||
|
"x-upstream-test": `responses-${reasoning}`,
|
||||||
|
...(reasoningAttempt ? { "x-upstream-reasoning-attempt": `${reasoningAttempt}` } : {}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
if (parsed.test_reasoning_response_delay_ms) {
|
||||||
|
setTimeout(sendJsonResponse, parsed.test_reasoning_response_delay_ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJsonResponse();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -291,7 +385,17 @@ function startFakeUpstream(port) {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
server.once("error", reject);
|
server.once("error", reject);
|
||||||
server.listen(port, "127.0.0.1", () => resolve(server));
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
server.getReasoningRetryStat = (key) => {
|
||||||
|
return reasoningRetryStats.get(key) || {
|
||||||
|
totalRequests: 0,
|
||||||
|
activeRequests: 0,
|
||||||
|
maxConcurrent: 0,
|
||||||
|
cancelledRequests: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +439,7 @@ function startGateway(configPath, logPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readSseUntilClose(url, requestBody) {
|
async function readSseUntilClose(url, requestBody) {
|
||||||
|
const startedAt = Date.now();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -345,6 +450,8 @@ async function readSseUntilClose(url, requestBody) {
|
|||||||
const decoder = new TextDecoder("utf8");
|
const decoder = new TextDecoder("utf8");
|
||||||
let text = "";
|
let text = "";
|
||||||
let closedByError = false;
|
let closedByError = false;
|
||||||
|
let readCount = 0;
|
||||||
|
let firstChunkDelayMs = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -352,6 +459,10 @@ async function readSseUntilClose(url, requestBody) {
|
|||||||
if (done) {
|
if (done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
readCount += 1;
|
||||||
|
if (firstChunkDelayMs === null) {
|
||||||
|
firstChunkDelayMs = Date.now() - startedAt;
|
||||||
|
}
|
||||||
text += decoder.decode(value, { stream: true });
|
text += decoder.decode(value, { stream: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closedByError = true;
|
closedByError = true;
|
||||||
@@ -366,10 +477,17 @@ async function readSseUntilClose(url, requestBody) {
|
|||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
text,
|
text,
|
||||||
closedByError,
|
closedByError,
|
||||||
|
readCount,
|
||||||
|
firstChunkDelayMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
assert(
|
||||||
|
JSON.stringify(normalizePhraseArray("a\\nb", [])) === JSON.stringify(["a", "b"]),
|
||||||
|
"normalizePhraseArray 未拆分字面量换行",
|
||||||
|
);
|
||||||
|
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
|
||||||
const upstreamPort = await getFreePort();
|
const upstreamPort = await getFreePort();
|
||||||
const gatewayPort = await getFreePort();
|
const gatewayPort = await getFreePort();
|
||||||
@@ -380,14 +498,16 @@ async function run() {
|
|||||||
listen_host: "127.0.0.1",
|
listen_host: "127.0.0.1",
|
||||||
listen_port: gatewayPort,
|
listen_port: gatewayPort,
|
||||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
||||||
request_body_limit_bytes: 10 * 1024 * 1024,
|
request_body_limit_bytes: 1024 * 1024 * 1024,
|
||||||
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||||
reasoning_equals: [516],
|
reasoning_match_mode: "formula_518n_minus_2",
|
||||||
|
reasoning_equals: [516, 1034, 1552],
|
||||||
retryable_status_codes: [429, 503],
|
retryable_status_codes: [429, 503],
|
||||||
retryable_error_messages: [
|
retryable_error_messages: [
|
||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||||
],
|
],
|
||||||
|
management_access_key: "test-admin-key",
|
||||||
upstream_fetch_retry_attempts: 5,
|
upstream_fetch_retry_attempts: 5,
|
||||||
upstream_fetch_retry_backoff_ms: 25,
|
upstream_fetch_retry_backoff_ms: 25,
|
||||||
non_stream_status_code: 502,
|
non_stream_status_code: 502,
|
||||||
@@ -411,6 +531,33 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`);
|
||||||
|
assert(lockedUiResponse.status === 401, `未带 key 的 UI 不应可访问: ${lockedUiResponse.status}`);
|
||||||
|
const lockedUiText = await lockedUiResponse.text();
|
||||||
|
assert(lockedUiText.includes("Access key"), "未带 key 的 UI 未返回 access key 页面");
|
||||||
|
|
||||||
|
const lockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
||||||
|
assert(lockedStatusResponse.status === 401, `未带 key 的 status API 不应可访问: ${lockedStatusResponse.status}`);
|
||||||
|
|
||||||
|
const unlockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui?key=test-admin-key`, {
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
assert(unlockedUiResponse.status === 200, `带 key 的 UI 访问失败: ${unlockedUiResponse.status}`);
|
||||||
|
assert(
|
||||||
|
(unlockedUiResponse.headers.get("set-cookie") || "").includes("codex_retry_gateway_access=test-admin-key"),
|
||||||
|
"带 key 的 UI 未设置 access cookie",
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, {
|
||||||
|
headers: { "x-codex-retry-gateway-key": "test-admin-key" },
|
||||||
|
});
|
||||||
|
assert(unlockedStatusResponse.status === 200, `带 key 的 status API 访问失败: ${unlockedStatusResponse.status}`);
|
||||||
|
const unlockedStatusPayload = await unlockedStatusResponse.json();
|
||||||
|
assert(
|
||||||
|
unlockedStatusPayload?.config?.management_access_key_configured === true,
|
||||||
|
"status API 未暴露 management_access_key_configured",
|
||||||
|
);
|
||||||
|
|
||||||
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||||
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
||||||
assert(
|
assert(
|
||||||
@@ -445,6 +592,141 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blockedFormulaResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
|
||||||
|
});
|
||||||
|
const blockedFormulaBody = await blockedFormulaResponse.json();
|
||||||
|
assert(blockedFormulaResponse.status === 502, `/responses 2070 未按 518n-2 返回 502: ${blockedFormulaResponse.status}`);
|
||||||
|
assert(
|
||||||
|
blockedFormulaBody?.error?.code === "reasoning_guard_triggered",
|
||||||
|
"/responses 2070 返回体不正确",
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingThreadRetryKey = reasoningRetryKeyForRequest("/responses", {
|
||||||
|
stream: false,
|
||||||
|
test_reasoning_retry_key: "missing-thread-fallback",
|
||||||
|
});
|
||||||
|
const missingThreadRetryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
test_reasoning_before_success_times: 1,
|
||||||
|
test_reasoning_retry_key: "missing-thread-fallback",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const missingThreadRetryBody = await missingThreadRetryResponse.json();
|
||||||
|
assert(missingThreadRetryResponse.status === 502, `无 thread_id 的 responses 请求不应自动重打: ${missingThreadRetryResponse.status}`);
|
||||||
|
assert(
|
||||||
|
missingThreadRetryBody?.error?.code === "reasoning_guard_triggered",
|
||||||
|
"无 thread_id 的 responses 请求返回体异常",
|
||||||
|
);
|
||||||
|
const missingThreadRetryStats = upstream.getReasoningRetryStat(missingThreadRetryKey);
|
||||||
|
assert(missingThreadRetryStats.totalRequests === 1, "无 thread_id 的 responses 请求不应启动多轮重打");
|
||||||
|
|
||||||
|
const retryRound2ThreadId = "thread_retry_round2";
|
||||||
|
const retryRound2Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
thread_id: retryRound2ThreadId,
|
||||||
|
test_reasoning_before_success_times: 1,
|
||||||
|
test_reasoning_retry_key: "round2",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const retryRound2Body = await retryRound2Response.json();
|
||||||
|
assert(retryRound2Response.status === 200, `1,1 重打未恢复: ${retryRound2Response.status}`);
|
||||||
|
assert(retryRound2Body?.usage?.output_tokens_details?.reasoning_tokens === 128, "1,1 重打恢复后的 reasoning_tokens 异常");
|
||||||
|
assert(retryRound2Response.headers.get("x-upstream-reasoning-attempt") === "2", "1,1 重打未命中第二次上游请求");
|
||||||
|
const retryRound2EntryResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(retryRound2ThreadId)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
|
);
|
||||||
|
const retryRound2EntryPayload = await retryRound2EntryResponse.json();
|
||||||
|
const retryRound2Entry = (retryRound2EntryPayload?.entries || []).find((entry) => entry.thread_id === retryRound2ThreadId);
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_query_count === 2, "1,1 重打未记录两次 query");
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_round_count === 2, "1,1 重打未记录两轮");
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_current_round === 2, "1,1 重打未记录当前轮次");
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_current_width === 1, "1,1 重打未记录当前轮并行数");
|
||||||
|
assert(
|
||||||
|
Array.isArray(retryRound2Entry?.reasoning_retry_current_firsts) &&
|
||||||
|
retryRound2Entry.reasoning_retry_current_firsts.length === 1 &&
|
||||||
|
Number.isInteger(retryRound2Entry.reasoning_retry_current_firsts[0]?.first_response_delay_ms) &&
|
||||||
|
retryRound2Entry.reasoning_retry_current_firsts.every((first) => first?.outcome !== "pending"),
|
||||||
|
"1,1 重打未记录当前轮 first 列表",
|
||||||
|
);
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_winner_round === 2, "1,1 重打赢家轮次异常");
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_winner_slot === 1, "1,1 重打赢家槽位异常");
|
||||||
|
assert(retryRound2Entry?.reasoning_retry_stop_reason === "success", "1,1 重打 stop reason 异常");
|
||||||
|
|
||||||
|
const retryWave2ThreadId = "thread_retry_wave2";
|
||||||
|
const retryWave2Key = reasoningRetryKeyForRequest("/responses", {
|
||||||
|
stream: false,
|
||||||
|
thread_id: retryWave2ThreadId,
|
||||||
|
test_reasoning_retry_key: "wave2",
|
||||||
|
});
|
||||||
|
const retryWave2Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
thread_id: retryWave2ThreadId,
|
||||||
|
test_reasoning_before_success_times: 3,
|
||||||
|
test_reasoning_retry_key: "wave2",
|
||||||
|
test_reasoning_response_delay_ms: 80,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const retryWave2Body = await retryWave2Response.json();
|
||||||
|
assert(retryWave2Response.status === 200, `1,1,2 重打未恢复: ${retryWave2Response.status}`);
|
||||||
|
assert(retryWave2Body?.usage?.output_tokens_details?.reasoning_tokens === 128, "1,1,2 重打恢复后的 reasoning_tokens 异常");
|
||||||
|
assert(retryWave2Response.headers.get("x-upstream-reasoning-attempt") === "4", "1,1,2 重打未命中第四次上游请求");
|
||||||
|
const retryWave2Stats = upstream.getReasoningRetryStat(retryWave2Key);
|
||||||
|
assert(retryWave2Stats.totalRequests === 4, "1,1,2 重打总请求数异常");
|
||||||
|
assert(retryWave2Stats.maxConcurrent >= 2, "1,1,2 重打未出现第二轮并行");
|
||||||
|
const retryWave2EntryResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(retryWave2ThreadId)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
|
);
|
||||||
|
const retryWave2EntryPayload = await retryWave2EntryResponse.json();
|
||||||
|
const retryWave2Entry = (retryWave2EntryPayload?.entries || []).find((entry) => entry.thread_id === retryWave2ThreadId);
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_query_count === 4, "1,1,2 重打未记录四次 query");
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_round_count === 3, "1,1,2 重打未记录三轮");
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_current_round === 3, "1,1,2 重打未记录当前轮次");
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_current_width === 2, "1,1,2 重打未记录当前轮并行数");
|
||||||
|
assert(
|
||||||
|
Array.isArray(retryWave2Entry?.reasoning_retry_current_firsts) &&
|
||||||
|
retryWave2Entry.reasoning_retry_current_firsts.length === 2 &&
|
||||||
|
retryWave2Entry.reasoning_retry_current_firsts.some((first) => Number.isInteger(first?.first_response_delay_ms)) &&
|
||||||
|
retryWave2Entry.reasoning_retry_current_firsts.every((first) => first?.outcome !== "pending"),
|
||||||
|
"1,1,2 重打未记录当前两请求 first 列表",
|
||||||
|
);
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_winner_round === 3, "1,1,2 重打赢家轮次异常");
|
||||||
|
assert([1, 2].includes(retryWave2Entry?.reasoning_retry_winner_slot), "1,1,2 重打赢家槽位异常");
|
||||||
|
assert(retryWave2Entry?.reasoning_retry_stop_reason === "success", "1,1,2 重打 stop reason 异常");
|
||||||
|
|
||||||
|
const streamRetryThreadId = "thread_stream_retry_v1";
|
||||||
|
const streamRetryResponse = await readSseUntilClose(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/v1/responses`,
|
||||||
|
{
|
||||||
|
stream: true,
|
||||||
|
thread_id: streamRetryThreadId,
|
||||||
|
test_reasoning_before_success_times: 1,
|
||||||
|
test_reasoning_retry_key: "stream-v1-round2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert(streamRetryResponse.status === 200, `/v1/responses 流式 1,1 重打未恢复: ${streamRetryResponse.status}`);
|
||||||
|
assert(streamRetryResponse.text.includes("hello"), "/v1/responses 流式 1,1 重打未拿到正常 SSE 内容");
|
||||||
|
assert(streamRetryResponse.text.includes("[DONE]"), "/v1/responses 流式 1,1 重打未完整结束");
|
||||||
|
assert(streamRetryResponse.headers.get("x-upstream-reasoning-attempt") === "2", "/v1/responses 流式 1,1 重打未命中第二次上游请求");
|
||||||
|
const streamRetryEntryResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(streamRetryThreadId)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
|
);
|
||||||
|
const streamRetryEntryPayload = await streamRetryEntryResponse.json();
|
||||||
|
const streamRetryEntry = (streamRetryEntryPayload?.entries || []).find((entry) => entry.thread_id === streamRetryThreadId);
|
||||||
|
assert(streamRetryEntry?.reasoning_retry_query_count === 2, "/v1/responses 流式 1,1 重打未记录两次 query");
|
||||||
|
assert(streamRetryEntry?.response_stream === true, "/v1/responses 流式 1,1 重打未保留流式标记");
|
||||||
|
|
||||||
const recoveredPayload = JSON.stringify({ test_fail_before_response_once: true });
|
const recoveredPayload = JSON.stringify({ test_fail_before_response_once: true });
|
||||||
const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -455,7 +737,7 @@ async function run() {
|
|||||||
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
|
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
|
||||||
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
|
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
|
||||||
|
|
||||||
const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsPayload = await requestsResponse.json();
|
const requestsPayload = await requestsResponse.json();
|
||||||
const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200);
|
const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200);
|
||||||
assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`);
|
assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`);
|
||||||
@@ -475,12 +757,36 @@ async function run() {
|
|||||||
assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id");
|
assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id");
|
||||||
assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id");
|
assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id");
|
||||||
|
|
||||||
const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`);
|
const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`, { headers: adminHeaders });
|
||||||
const threadRequestsPayload = await threadRequestsResponse.json();
|
const threadRequestsPayload = await threadRequestsResponse.json();
|
||||||
const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream");
|
const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream");
|
||||||
assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id");
|
assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id");
|
||||||
assert(threadEntry?.thread_id === "thread_nonstream", "non-stream 请求记录未保留 thread_id");
|
assert(threadEntry?.thread_id === "thread_nonstream", "non-stream 请求记录未保留 thread_id");
|
||||||
|
|
||||||
|
const effortTrackedResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
test_reasoning_tokens: 128,
|
||||||
|
reasoning: {
|
||||||
|
effort: "xhigh",
|
||||||
|
summary: "auto",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert(effortTrackedResponse.status === 200, `reasoning.effort 请求失败: ${effortTrackedResponse.status}`);
|
||||||
|
await effortTrackedResponse.json();
|
||||||
|
|
||||||
|
const effortRequestsResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("xhigh")}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
|
);
|
||||||
|
const effortRequestsPayload = await effortRequestsResponse.json();
|
||||||
|
const effortEntry = (effortRequestsPayload?.entries || []).find((entry) => entry.reasoning_effort === "xhigh");
|
||||||
|
assert(effortRequestsResponse.status === 200, `reasoning.effort 搜索失败: ${effortRequestsResponse.status}`);
|
||||||
|
assert(effortEntry?.reasoning_effort === "xhigh", "请求记录未保留 reasoning.effort");
|
||||||
|
assert(effortEntry?.reasoning_summary === "auto", "请求记录未保留 reasoning.summary");
|
||||||
|
|
||||||
const sameRequestPayload = JSON.stringify({ test_reasoning_tokens: 128, test_request_id_marker: "same" });
|
const sameRequestPayload = JSON.stringify({ test_reasoning_tokens: 128, test_request_id_marker: "same" });
|
||||||
const sameRequestFirstResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
const sameRequestFirstResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -515,7 +821,7 @@ async function run() {
|
|||||||
assert(differentPathResponse.status === 200, `不同路径发送失败: ${differentPathResponse.status}`);
|
assert(differentPathResponse.status === 200, `不同路径发送失败: ${differentPathResponse.status}`);
|
||||||
await differentPathResponse.json();
|
await differentPathResponse.json();
|
||||||
|
|
||||||
const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`);
|
const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`, { headers: adminHeaders });
|
||||||
const requestIdRequestsPayload = await requestIdRequestsResponse.json();
|
const requestIdRequestsPayload = await requestIdRequestsResponse.json();
|
||||||
const sameRequestEntries = (requestIdRequestsPayload?.entries || []).filter(
|
const sameRequestEntries = (requestIdRequestsPayload?.entries || []).filter(
|
||||||
(entry) => entry.path === "/responses" && entry.request_body_bytes === Buffer.byteLength(sameRequestPayload),
|
(entry) => entry.path === "/responses" && entry.request_body_bytes === Buffer.byteLength(sameRequestPayload),
|
||||||
@@ -543,6 +849,7 @@ async function run() {
|
|||||||
|
|
||||||
const requestIdQueryResponse = await fetch(
|
const requestIdQueryResponse = await fetch(
|
||||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(sameRequestEntryA.request_id)}`,
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(sameRequestEntryA.request_id)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
);
|
);
|
||||||
const requestIdQueryPayload = await requestIdQueryResponse.json();
|
const requestIdQueryPayload = await requestIdQueryResponse.json();
|
||||||
assert(requestIdQueryResponse.status === 200, `request_id 搜索失败: ${requestIdQueryResponse.status}`);
|
assert(requestIdQueryResponse.status === 200, `request_id 搜索失败: ${requestIdQueryResponse.status}`);
|
||||||
@@ -595,7 +902,7 @@ async function run() {
|
|||||||
"capacity 抖动恢复后的返回体异常",
|
"capacity 抖动恢复后的返回体异常",
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsAfterCapacityRecovery = await requestsAfterCapacityRecoveryResponse.json();
|
const requestsAfterCapacityRecovery = await requestsAfterCapacityRecoveryResponse.json();
|
||||||
const capacityRecoveredEntry = requestsAfterCapacityRecovery?.entries?.find(
|
const capacityRecoveredEntry = requestsAfterCapacityRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
||||||
@@ -615,7 +922,7 @@ async function run() {
|
|||||||
"200+capacity 抖动恢复后的返回体异常",
|
"200+capacity 抖动恢复后的返回体异常",
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`);
|
const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders });
|
||||||
const requestsAfterCapacityStatus200Recovery = await requestsAfterCapacityStatus200RecoveryResponse.json();
|
const requestsAfterCapacityStatus200Recovery = await requestsAfterCapacityStatus200RecoveryResponse.json();
|
||||||
const capacityStatus200RecoveredEntry = requestsAfterCapacityStatus200Recovery?.entries?.find(
|
const capacityStatus200RecoveredEntry = requestsAfterCapacityStatus200Recovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
||||||
@@ -660,7 +967,7 @@ async function run() {
|
|||||||
assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`);
|
assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`);
|
||||||
assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容");
|
assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容");
|
||||||
|
|
||||||
const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json();
|
const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json();
|
||||||
const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find(
|
const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
||||||
@@ -682,7 +989,7 @@ async function run() {
|
|||||||
assert(streamCapacityResponseFailedRecoveredResponse.status === 200, `stream response.failed capacity 抖动后未自动恢复: ${streamCapacityResponseFailedRecoveredResponse.status}`);
|
assert(streamCapacityResponseFailedRecoveredResponse.status === 200, `stream response.failed capacity 抖动后未自动恢复: ${streamCapacityResponseFailedRecoveredResponse.status}`);
|
||||||
assert(streamCapacityResponseFailedRecoveredText.includes("hello"), "stream response.failed capacity 恢复后未拿到正常 SSE 内容");
|
assert(streamCapacityResponseFailedRecoveredText.includes("hello"), "stream response.failed capacity 恢复后未拿到正常 SSE 内容");
|
||||||
|
|
||||||
const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`);
|
const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders });
|
||||||
const requestsAfterStreamCapacityResponseFailedRecovery = await requestsAfterStreamCapacityResponseFailedRecoveryResponse.json();
|
const requestsAfterStreamCapacityResponseFailedRecovery = await requestsAfterStreamCapacityResponseFailedRecoveryResponse.json();
|
||||||
const streamCapacityResponseFailedRecoveredEntry = requestsAfterStreamCapacityResponseFailedRecovery?.entries?.find(
|
const streamCapacityResponseFailedRecoveredEntry = requestsAfterStreamCapacityResponseFailedRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
||||||
@@ -694,12 +1001,34 @@ async function run() {
|
|||||||
{ stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" },
|
{ stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" },
|
||||||
);
|
);
|
||||||
assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`);
|
assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`);
|
||||||
const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`);
|
const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`, { headers: adminHeaders });
|
||||||
const streamThreadRequestsPayload = await streamThreadRequestsResponse.json();
|
const streamThreadRequestsPayload = await streamThreadRequestsResponse.json();
|
||||||
const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok");
|
const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok");
|
||||||
assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id");
|
assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id");
|
||||||
assert(streamThreadEntry?.thread_id === "thread_stream_ok", "stream 请求记录未保留 thread_id");
|
assert(streamThreadEntry?.thread_id === "thread_stream_ok", "stream 请求记录未保留 thread_id");
|
||||||
|
|
||||||
|
const metadataThreadResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"thread-id": "thread_header_fallback",
|
||||||
|
"x-client-request-id": "thread_header_request_id",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
test_reasoning_tokens: 128,
|
||||||
|
client_metadata: {
|
||||||
|
thread_id: "thread_client_metadata",
|
||||||
|
"x-codex-thread-id": "thread_client_metadata_alias",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert(metadataThreadResponse.status === 200, `metadata thread 请求失败: ${metadataThreadResponse.status}`);
|
||||||
|
await metadataThreadResponse.json();
|
||||||
|
const metadataThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_client_metadata")}`, { headers: adminHeaders });
|
||||||
|
const metadataThreadRequestsPayload = await metadataThreadRequestsResponse.json();
|
||||||
|
const metadataThreadEntry = (metadataThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_client_metadata");
|
||||||
|
assert(metadataThreadEntry?.thread_id === "thread_client_metadata", "client_metadata.thread_id 未写入请求记录");
|
||||||
|
|
||||||
const streamDisconnectedRetryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
const streamDisconnectedRetryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -740,6 +1069,22 @@ async function run() {
|
|||||||
assert(okStream.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`);
|
assert(okStream.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`);
|
||||||
assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`);
|
assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`);
|
||||||
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
|
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
|
||||||
|
|
||||||
|
if (streamPath === "/responses" || streamPath === "/v1/responses") {
|
||||||
|
const replayedStream = await readSseUntilClose(
|
||||||
|
`http://127.0.0.1:${gatewayPort}${streamPath}`,
|
||||||
|
{
|
||||||
|
stream: true,
|
||||||
|
test_reasoning_tokens: 128,
|
||||||
|
test_stream_delta_chunks: 48,
|
||||||
|
test_stream_delta_text: "chunk",
|
||||||
|
test_stream_chunk_delay_ms: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert(replayedStream.status === 200, `${streamPath} 回放流首状态异常: ${replayedStream.status}`);
|
||||||
|
assert(replayedStream.text.includes("chunk-48"), `${streamPath} 回放流未保留尾部 delta`);
|
||||||
|
assert(replayedStream.readCount > 1, `${streamPath} 成功流不应退化为单块回放`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamProgressPromise = fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
const streamProgressPromise = fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
@@ -748,7 +1093,7 @@ async function run() {
|
|||||||
body: JSON.stringify({ stream: true, test_reasoning_tokens: 128, test_stream_chunk_delay_ms: 180 }),
|
body: JSON.stringify({ stream: true, test_reasoning_tokens: 128, test_stream_chunk_delay_ms: 180 }),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||||
const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const midRequestsPayload = await midRequestsResponse.json();
|
const midRequestsPayload = await midRequestsResponse.json();
|
||||||
const inFlightStreamEntry = midRequestsPayload?.entries?.find(
|
const inFlightStreamEntry = midRequestsPayload?.entries?.find(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
@@ -778,7 +1123,7 @@ async function run() {
|
|||||||
);
|
);
|
||||||
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
|
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
|
||||||
|
|
||||||
const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
||||||
const metricsBeforeRestart = await metricsBeforeRestartResponse.json();
|
const metricsBeforeRestart = await metricsBeforeRestartResponse.json();
|
||||||
assert(metricsBeforeRestartResponse.status === 200, `status API 状态异常: ${metricsBeforeRestartResponse.status}`);
|
assert(metricsBeforeRestartResponse.status === 200, `status API 状态异常: ${metricsBeforeRestartResponse.status}`);
|
||||||
assert(metricsBeforeRestart?.metrics?.reasoning_516_count >= 1, "重启前 reasoning_516_count 未累计");
|
assert(metricsBeforeRestart?.metrics?.reasoning_516_count >= 1, "重启前 reasoning_516_count 未累计");
|
||||||
@@ -790,7 +1135,7 @@ async function run() {
|
|||||||
gateway = startGateway(configPath, logPath);
|
gateway = startGateway(configPath, logPath);
|
||||||
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||||
|
|
||||||
const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
||||||
const metricsAfterRestart = await metricsAfterRestartResponse.json();
|
const metricsAfterRestart = await metricsAfterRestartResponse.json();
|
||||||
assert(metricsAfterRestartResponse.status === 200, `重启后 status API 状态异常: ${metricsAfterRestartResponse.status}`);
|
assert(metricsAfterRestartResponse.status === 200, `重启后 status API 状态异常: ${metricsAfterRestartResponse.status}`);
|
||||||
assert(metricsAfterRestart?.metrics?.reasoning_516_count >= metricsBeforeRestart?.metrics?.reasoning_516_count, "重启后 reasoning_516_count 未保留");
|
assert(metricsAfterRestart?.metrics?.reasoning_516_count >= metricsBeforeRestart?.metrics?.reasoning_516_count, "重启后 reasoning_516_count 未保留");
|
||||||
@@ -798,6 +1143,30 @@ async function run() {
|
|||||||
assert(metricsAfterRestart?.metrics?.total_proxy_request_count >= metricsBeforeRestart?.metrics?.total_proxy_request_count, "重启后 total_proxy_request_count 未保留");
|
assert(metricsAfterRestart?.metrics?.total_proxy_request_count >= metricsBeforeRestart?.metrics?.total_proxy_request_count, "重启后 total_proxy_request_count 未保留");
|
||||||
assert(metricsAfterRestart?.metrics?.persistent_since, "重启后未返回 persistent_since");
|
assert(metricsAfterRestart?.metrics?.persistent_since, "重启后未返回 persistent_since");
|
||||||
|
|
||||||
|
config.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",
|
||||||
|
];
|
||||||
|
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||||
|
gateway.child.kill();
|
||||||
|
await once(gateway.child, "exit");
|
||||||
|
gateway = startGateway(configPath, logPath);
|
||||||
|
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||||
|
|
||||||
|
const escapedNewlineCapacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ test_capacity_before_success_times: 2, test_capacity_status: 200, test_reasoning_tokens: 128 }),
|
||||||
|
});
|
||||||
|
const escapedNewlineCapacityBody = await escapedNewlineCapacityResponse.json();
|
||||||
|
assert(
|
||||||
|
escapedNewlineCapacityResponse.status === 200,
|
||||||
|
`字面量换行 retryable_error_messages 下 200+capacity 未自动恢复: ${escapedNewlineCapacityResponse.status}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
escapedNewlineCapacityBody?.usage?.output_tokens_details?.reasoning_tokens === 128,
|
||||||
|
"字面量换行 retryable_error_messages 恢复后的返回体异常",
|
||||||
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
const logText = await readFile(logPath, "utf8");
|
const logText = await readFile(logPath, "utf8");
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ async function run() {
|
|||||||
gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`,
|
gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`,
|
||||||
"Gateway config did not preserve original upstream_base_url",
|
"Gateway config did not preserve original upstream_base_url",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
gatewayConfig.reasoning_match_mode === "formula_518n_minus_2",
|
||||||
|
"Gateway config did not default reasoning_match_mode to formula_518n_minus_2",
|
||||||
|
);
|
||||||
assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array");
|
assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array");
|
||||||
assert(
|
assert(
|
||||||
gatewayConfig.endpoints.includes("/responses") &&
|
gatewayConfig.endpoints.includes("/responses") &&
|
||||||
@@ -195,6 +199,13 @@ async function run() {
|
|||||||
});
|
});
|
||||||
assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`);
|
assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`);
|
||||||
|
|
||||||
|
const blocked2070Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
|
||||||
|
});
|
||||||
|
assert(blocked2070Response.status === 502, `Default 2070 formula block did not trigger: ${blocked2070Response.status}`);
|
||||||
|
|
||||||
const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
||||||
const metricsStatusPayload = await metricsStatusResponse.json();
|
const metricsStatusPayload = await metricsStatusResponse.json();
|
||||||
assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`);
|
assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`);
|
||||||
@@ -220,6 +231,7 @@ async function run() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
reasoning_match_mode: "manual",
|
||||||
reasoning_equals: [1024],
|
reasoning_equals: [1024],
|
||||||
retryable_status_codes: [429, 503, 529],
|
retryable_status_codes: [429, 503, 529],
|
||||||
retryable_error_messages: ["Selected model is at capacity. Please try a different model."],
|
retryable_error_messages: ["Selected model is at capacity. Please try a different model."],
|
||||||
@@ -231,10 +243,15 @@ async function run() {
|
|||||||
const saveConfigPayload = await saveConfigResponse.json();
|
const saveConfigPayload = await saveConfigResponse.json();
|
||||||
assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`);
|
assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`);
|
||||||
assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config");
|
assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config");
|
||||||
|
assert(saveConfigPayload.config?.reasoning_match_mode === "manual", "Save config API did not return updated reasoning_match_mode");
|
||||||
|
|
||||||
const updatedGatewayConfig = JSON.parse(
|
const updatedGatewayConfig = JSON.parse(
|
||||||
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
|
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
updatedGatewayConfig.reasoning_match_mode === "manual",
|
||||||
|
"Saved config file did not persist reasoning_match_mode",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
|
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
|
||||||
"Saved config file did not persist reasoning_equals",
|
"Saved config file did not persist reasoning_equals",
|
||||||
@@ -261,6 +278,13 @@ async function run() {
|
|||||||
});
|
});
|
||||||
assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`);
|
assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`);
|
||||||
|
|
||||||
|
const manualModePassthrough = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
|
||||||
|
});
|
||||||
|
assert(manualModePassthrough.status === 200, `manual 模式下 2070 不应继续被拦截: ${manualModePassthrough.status}`);
|
||||||
|
|
||||||
const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, {
|
const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ async function run() {
|
|||||||
statusPayload.state?.original_base_url === upstreamBaseUrl,
|
statusPayload.state?.original_base_url === upstreamBaseUrl,
|
||||||
"First launch did not persist the original upstream base URL",
|
"First launch did not persist the original upstream base URL",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
statusPayload.config?.reasoning_match_mode === "formula_518n_minus_2",
|
||||||
|
"First launch did not default reasoning_match_mode to formula_518n_minus_2",
|
||||||
|
);
|
||||||
|
|
||||||
const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
|
const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
|
||||||
const firstState = JSON.parse(firstStateRaw);
|
const firstState = JSON.parse(firstStateRaw);
|
||||||
@@ -194,6 +198,13 @@ async function run() {
|
|||||||
});
|
});
|
||||||
assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`);
|
assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`);
|
||||||
|
|
||||||
|
const blockedFormulaResponse = await fetch(`${gatewayBaseUrl}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
|
||||||
|
});
|
||||||
|
assert(blockedFormulaResponse.status === 502, `Default 2070 formula interception was not active: ${blockedFormulaResponse.status}`);
|
||||||
|
|
||||||
process.stdout.write("PASS launch-ui flow\n");
|
process.stdout.write("PASS launch-ui flow\n");
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+292
-3
@@ -2,11 +2,14 @@ import { FormEvent, startTransition, useEffect, useState } from "react";
|
|||||||
|
|
||||||
type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
||||||
type Tone = "" | "success" | "error";
|
type Tone = "" | "success" | "error";
|
||||||
|
type ReasoningMatchMode = "formula_518n_minus_2" | "manual";
|
||||||
|
|
||||||
type GatewayConfig = {
|
type GatewayConfig = {
|
||||||
profile_name?: string;
|
profile_name?: string;
|
||||||
listen_host?: string;
|
listen_host?: string;
|
||||||
listen_port?: number;
|
listen_port?: number;
|
||||||
|
management_access_key?: string;
|
||||||
|
management_access_key_configured?: boolean;
|
||||||
upstream_base_url?: string;
|
upstream_base_url?: string;
|
||||||
upstream_auth_mode?: string;
|
upstream_auth_mode?: string;
|
||||||
upstream_auth_env?: string | null;
|
upstream_auth_env?: string | null;
|
||||||
@@ -14,6 +17,7 @@ type GatewayConfig = {
|
|||||||
request_history_limit?: number;
|
request_history_limit?: number;
|
||||||
model_remap?: string;
|
model_remap?: string;
|
||||||
endpoints?: string[];
|
endpoints?: string[];
|
||||||
|
reasoning_match_mode?: ReasoningMatchMode;
|
||||||
reasoning_equals?: number[];
|
reasoning_equals?: number[];
|
||||||
retryable_status_codes?: number[];
|
retryable_status_codes?: number[];
|
||||||
retryable_error_messages?: string[];
|
retryable_error_messages?: string[];
|
||||||
@@ -88,10 +92,36 @@ type RequestEntry = {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
requested_model?: string | null;
|
requested_model?: string | null;
|
||||||
forwarded_model?: string | null;
|
forwarded_model?: string | null;
|
||||||
|
reasoning_effort?: string | null;
|
||||||
|
reasoning_summary?: string | null;
|
||||||
response_stream?: boolean;
|
response_stream?: boolean;
|
||||||
stream_chunk_count?: number | null;
|
stream_chunk_count?: number | null;
|
||||||
usage_last_updated_at?: string | null;
|
usage_last_updated_at?: string | null;
|
||||||
upstream_attempt_count?: number | 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;
|
matched?: boolean;
|
||||||
status_code?: number | null;
|
status_code?: number | null;
|
||||||
upstream_status_code?: number | null;
|
upstream_status_code?: number | null;
|
||||||
@@ -128,6 +158,7 @@ type ProfileFormModel = {
|
|||||||
auth_json_key?: string;
|
auth_json_key?: string;
|
||||||
request_history_limit?: string;
|
request_history_limit?: string;
|
||||||
model_remap?: string;
|
model_remap?: string;
|
||||||
|
reasoning_match_mode?: ReasoningMatchMode;
|
||||||
reasoning_equals?: string;
|
reasoning_equals?: string;
|
||||||
retryable_status_codes?: string;
|
retryable_status_codes?: string;
|
||||||
retryable_error_messages?: string[];
|
retryable_error_messages?: string[];
|
||||||
@@ -148,6 +179,7 @@ type Profile = {
|
|||||||
auth_source?: string;
|
auth_source?: string;
|
||||||
request_history_limit?: string;
|
request_history_limit?: string;
|
||||||
model_remap?: string;
|
model_remap?: string;
|
||||||
|
reasoning_match_mode?: ReasoningMatchMode;
|
||||||
reasoning_equals?: string;
|
reasoning_equals?: string;
|
||||||
};
|
};
|
||||||
form?: ProfileFormModel;
|
form?: ProfileFormModel;
|
||||||
@@ -197,6 +229,7 @@ type ProfileFormState = {
|
|||||||
auth_json_key: string;
|
auth_json_key: string;
|
||||||
request_history_limit: string;
|
request_history_limit: string;
|
||||||
model_remap: string;
|
model_remap: string;
|
||||||
|
reasoning_match_mode: ReasoningMatchMode;
|
||||||
reasoning_equals: string;
|
reasoning_equals: string;
|
||||||
retryable_status_codes: string;
|
retryable_status_codes: string;
|
||||||
retryable_error_messages: string;
|
retryable_error_messages: string;
|
||||||
@@ -206,6 +239,7 @@ type ProfileFormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type RuleFormState = {
|
type RuleFormState = {
|
||||||
|
reasoning_match_mode: ReasoningMatchMode;
|
||||||
reasoning_equals: string;
|
reasoning_equals: string;
|
||||||
retryable_status_codes: string;
|
retryable_status_codes: string;
|
||||||
retryable_error_messages: string;
|
retryable_error_messages: string;
|
||||||
@@ -227,6 +261,8 @@ const api = {
|
|||||||
restore: "/__codex_retry_gateway/api/restore",
|
restore: "/__codex_retry_gateway/api/restore",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACCESS_KEY_STORAGE_KEY = "codex_retry_gateway_access_key";
|
||||||
|
|
||||||
const REQUEST_PAGE_SIZE = 20;
|
const REQUEST_PAGE_SIZE = 20;
|
||||||
const LOG_PAGE_SIZE = 200;
|
const LOG_PAGE_SIZE = 200;
|
||||||
|
|
||||||
@@ -303,6 +339,7 @@ const defaultProfileForm: ProfileFormState = {
|
|||||||
auth_json_key: "OPENAI_API_KEY",
|
auth_json_key: "OPENAI_API_KEY",
|
||||||
request_history_limit: "0",
|
request_history_limit: "0",
|
||||||
model_remap: "",
|
model_remap: "",
|
||||||
|
reasoning_match_mode: "formula_518n_minus_2",
|
||||||
reasoning_equals: "516,1034,1552",
|
reasoning_equals: "516,1034,1552",
|
||||||
retryable_status_codes: "429,503",
|
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",
|
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",
|
||||||
@@ -329,6 +366,17 @@ function durationSeconds(value?: number | null) {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? `${(value / 1000).toFixed(2)} s` : "-";
|
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) {
|
function secondsSince(startedAt: string | null | undefined, updatedAt: string | null | undefined) {
|
||||||
if (!startedAt || !updatedAt) {
|
if (!startedAt || !updatedAt) {
|
||||||
return "-";
|
return "-";
|
||||||
@@ -388,6 +436,98 @@ function requestPrimaryId(entry: RequestEntry) {
|
|||||||
return entry.response_id || entry.request_id || "-";
|
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) {
|
function splitList(value: string) {
|
||||||
return value
|
return value
|
||||||
.split(/[\s,]+/)
|
.split(/[\s,]+/)
|
||||||
@@ -402,8 +542,28 @@ function splitLines(value: string) {
|
|||||||
.filter(Boolean);
|
.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> {
|
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, { cache: "no-store", ...options });
|
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();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload?.error?.message || "请求失败");
|
throw new Error(payload?.error?.message || "请求失败");
|
||||||
@@ -426,6 +586,7 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
|||||||
auth_json_key: config.upstream_auth_json_key || defaultProfileForm.auth_json_key,
|
auth_json_key: config.upstream_auth_json_key || defaultProfileForm.auth_json_key,
|
||||||
request_history_limit: String(config.request_history_limit ?? defaultProfileForm.request_history_limit),
|
request_history_limit: String(config.request_history_limit ?? defaultProfileForm.request_history_limit),
|
||||||
model_remap: config.model_remap || "",
|
model_remap: config.model_remap || "",
|
||||||
|
reasoning_match_mode: normalizeReasoningMode(config.reasoning_match_mode),
|
||||||
reasoning_equals: Array.isArray(config.reasoning_equals)
|
reasoning_equals: Array.isArray(config.reasoning_equals)
|
||||||
? config.reasoning_equals.join(",")
|
? config.reasoning_equals.join(",")
|
||||||
: defaultProfileForm.reasoning_equals,
|
: defaultProfileForm.reasoning_equals,
|
||||||
@@ -463,7 +624,8 @@ function profileFormFromProfile(profile: Profile): ProfileFormState {
|
|||||||
auth_json_key: form.auth_json_key || "OPENAI_API_KEY",
|
auth_json_key: form.auth_json_key || "OPENAI_API_KEY",
|
||||||
request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit,
|
request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit,
|
||||||
model_remap: form.model_remap || "",
|
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_status_codes: form.retryable_status_codes || defaultProfileForm.retryable_status_codes,
|
||||||
retryable_error_messages: Array.isArray(form.retryable_error_messages)
|
retryable_error_messages: Array.isArray(form.retryable_error_messages)
|
||||||
? form.retryable_error_messages.join("\n")
|
? form.retryable_error_messages.join("\n")
|
||||||
@@ -479,6 +641,7 @@ function profileFormFromProfile(profile: Profile): ProfileFormState {
|
|||||||
function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
||||||
const config = status?.config || {};
|
const config = status?.config || {};
|
||||||
return {
|
return {
|
||||||
|
reasoning_match_mode: normalizeReasoningMode(config.reasoning_match_mode),
|
||||||
reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "",
|
reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "",
|
||||||
retryable_status_codes: Array.isArray(config.retryable_status_codes)
|
retryable_status_codes: Array.isArray(config.retryable_status_codes)
|
||||||
? config.retryable_status_codes.join(", ")
|
? config.retryable_status_codes.join(", ")
|
||||||
@@ -499,6 +662,17 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
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 [page, setPage] = useState<PageKey>(() => {
|
||||||
const hash = window.location.hash.replace("#", "") as PageKey;
|
const hash = window.location.hash.replace("#", "") as PageKey;
|
||||||
return pageCopy[hash] ? hash : "overview";
|
return pageCopy[hash] ? hash : "overview";
|
||||||
@@ -704,6 +878,7 @@ export default function App() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
reasoning_match_mode: ruleForm.reasoning_match_mode,
|
||||||
reasoning_equals: splitList(ruleForm.reasoning_equals)
|
reasoning_equals: splitList(ruleForm.reasoning_equals)
|
||||||
.map((value) => Number.parseInt(value, 10))
|
.map((value) => Number.parseInt(value, 10))
|
||||||
.filter((value) => Number.isInteger(value)),
|
.filter((value) => Number.isInteger(value)),
|
||||||
@@ -764,6 +939,7 @@ export default function App() {
|
|||||||
auth_json_key: profileForm.auth_json_key,
|
auth_json_key: profileForm.auth_json_key,
|
||||||
request_history_limit: Number.parseInt(profileForm.request_history_limit, 10),
|
request_history_limit: Number.parseInt(profileForm.request_history_limit, 10),
|
||||||
model_remap: profileForm.model_remap,
|
model_remap: profileForm.model_remap,
|
||||||
|
reasoning_match_mode: profileForm.reasoning_match_mode,
|
||||||
reasoning_equals: splitList(profileForm.reasoning_equals),
|
reasoning_equals: splitList(profileForm.reasoning_equals),
|
||||||
retryable_status_codes: splitList(profileForm.retryable_status_codes),
|
retryable_status_codes: splitList(profileForm.retryable_status_codes),
|
||||||
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
|
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
|
||||||
@@ -1069,6 +1245,11 @@ export default function App() {
|
|||||||
const statusTone = entry.error ? "error" : entry.matched ? "warn" : "";
|
const statusTone = entry.error ? "error" : entry.matched ? "warn" : "";
|
||||||
const effectiveInput = effectiveInputTokens(usage.input_tokens, usage.cached_tokens);
|
const effectiveInput = effectiveInputTokens(usage.input_tokens, usage.cached_tokens);
|
||||||
const cachedHitRatio = cachedRatio(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 (
|
return (
|
||||||
<article className="request-card" key={entry.seq}>
|
<article className="request-card" key={entry.seq}>
|
||||||
<div className="request-head">
|
<div className="request-head">
|
||||||
@@ -1081,10 +1262,21 @@ export default function App() {
|
|||||||
<span className="meta-key">发</span>
|
<span className="meta-key">发</span>
|
||||||
{timestamp(entry.started_at)}
|
{timestamp(entry.started_at)}
|
||||||
</span>
|
</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-pill meta-first">
|
||||||
<span className="meta-key">首</span>
|
<span className="meta-key">首</span>
|
||||||
{durationSeconds(entry.first_response_delay_ms)}
|
{durationSeconds(entry.first_response_delay_ms)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="meta-pill meta-total">
|
<span className="meta-pill meta-total">
|
||||||
<span className="meta-key">总</span>
|
<span className="meta-key">总</span>
|
||||||
{durationSeconds(entry.duration_ms)}
|
{durationSeconds(entry.duration_ms)}
|
||||||
@@ -1103,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)}`
|
? `${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>
|
</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-pill meta-received">
|
||||||
<span className="meta-key">收</span>
|
<span className="meta-key">收</span>
|
||||||
{timestamp(entry.finished_at)}
|
{timestamp(entry.finished_at)}
|
||||||
@@ -1118,6 +1316,11 @@ export default function App() {
|
|||||||
<div className="request-badges">
|
<div className="request-badges">
|
||||||
{entry.matched ? <span className="badge warn">matched</span> : <span className="badge">pass</span>}
|
{entry.matched ? <span className="badge warn">matched</span> : <span className="badge">pass</span>}
|
||||||
{entry.error ? <span className="badge error">error</span> : null}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1130,6 +1333,12 @@ export default function App() {
|
|||||||
? `转发为 ${entry.forwarded_model}`
|
? `转发为 ${entry.forwarded_model}`
|
||||||
: entry.forwarded_model || "-"}
|
: entry.forwarded_model || "-"}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="hint">
|
||||||
|
{entry.reasoning_effort
|
||||||
|
? `强度 ${entry.reasoning_effort}`
|
||||||
|
: "强度 -"}
|
||||||
|
{entry.reasoning_summary ? ` / summary ${entry.reasoning_summary}` : ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="request-block">
|
<div className="request-block">
|
||||||
@@ -1162,6 +1371,54 @@ export default function App() {
|
|||||||
<span className="hint">{`request ${entry.request_id || "-"}`}</span>
|
<span className="hint">{`request ${entry.request_id || "-"}`}</span>
|
||||||
<span className="hint">{`thread ${entry.thread_id || "-"}`}</span>
|
<span className="hint">{`thread ${entry.thread_id || "-"}`}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -1226,7 +1483,17 @@ export default function App() {
|
|||||||
<MiniStat label="Auth Source" value={profile.summary?.auth_source || "-"} />
|
<MiniStat label="Auth Source" value={profile.summary?.auth_source || "-"} />
|
||||||
<MiniStat label="History" value={profile.summary?.request_history_limit || "0"} />
|
<MiniStat label="History" value={profile.summary?.request_history_limit || "0"} />
|
||||||
<MiniStat label="Model Remap" value={profile.summary?.model_remap || "-"} />
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
@@ -1303,6 +1570,17 @@ export default function App() {
|
|||||||
onChange={(event) => setProfileForm({ ...profileForm, request_history_limit: event.target.value })}
|
onChange={(event) => setProfileForm({ ...profileForm, request_history_limit: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<Field label="reasoning_equals">
|
||||||
<input value={profileForm.reasoning_equals} placeholder="516,1034,1552" onChange={(event) => setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} />
|
<input value={profileForm.reasoning_equals} placeholder="516,1034,1552" onChange={(event) => setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -1376,6 +1654,17 @@ export default function App() {
|
|||||||
<section className="page" data-active="true">
|
<section className="page" data-active="true">
|
||||||
<Card title="当前运行规则" hint="保存后会热生效,只影响当前正在运行的 gateway config;长期 profile 默认值请去 Profiles 页保存。">
|
<Card title="当前运行规则" hint="保存后会热生效,只影响当前正在运行的 gateway config;长期 profile 默认值请去 Profiles 页保存。">
|
||||||
<form onSubmit={saveRules}>
|
<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">
|
<Field label="reasoning_equals">
|
||||||
<input value={ruleForm.reasoning_equals} placeholder="例如:516, 1034, 1552" onChange={(event) => setRuleForm({ ...ruleForm, reasoning_equals: event.target.value })} />
|
<input value={ruleForm.reasoning_equals} placeholder="例如:516, 1034, 1552" onChange={(event) => setRuleForm({ ...ruleForm, reasoning_equals: event.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
+69
-2
@@ -450,6 +450,11 @@ a {
|
|||||||
background: #fff0d9;
|
background: #fff0d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.success {
|
||||||
|
color: var(--accent);
|
||||||
|
background: #d7eee6;
|
||||||
|
}
|
||||||
|
|
||||||
.badge.error {
|
.badge.error {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
background: #ffece7;
|
background: #ffece7;
|
||||||
@@ -622,9 +627,29 @@ a {
|
|||||||
background: #e5eaef;
|
background: #e5eaef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-retry {
|
||||||
|
color: #0f5045;
|
||||||
|
background: #d2eee4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-first-list-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-first-mini {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(198, 84, 37, 0.09);
|
||||||
|
color: #8d421e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.request-grid {
|
.request-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.05fr 1.35fr 1.6fr;
|
grid-template-columns: minmax(150px, 1fr) minmax(220px, 1.25fr) minmax(260px, 1.5fr) minmax(180px, 1.05fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +678,46 @@ a {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reasoning-retry-block {
|
||||||
|
grid-column: span 2;
|
||||||
|
border-color: rgba(22, 107, 92, 0.16);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 0% 0%, rgba(22, 107, 92, 0.1), transparent 42%),
|
||||||
|
rgba(255, 250, 240, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-first-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-first-chip {
|
||||||
|
border: 1px solid rgba(22, 107, 92, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #12463c;
|
||||||
|
background: rgba(22, 107, 92, 0.1);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-first-chip.pending {
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(30, 33, 29, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-chip {
|
||||||
|
padding: 3px 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 960px;
|
min-width: 960px;
|
||||||
@@ -946,9 +1011,11 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.request-head,
|
.request-head,
|
||||||
.request-grid {
|
.request-grid,
|
||||||
|
.reasoning-retry-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
|
|||||||
Reference in New Issue
Block a user