Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14fae29618 | |||
| 31e673f777 |
@@ -10,7 +10,7 @@
|
|||||||
- Secret 边界:本地用户配置
|
- Secret 边界:本地用户配置
|
||||||
- 数据来源:本地 Codex provider 配置、gateway profile state 与上游 OpenAI-compatible API 响应。
|
- 数据来源:本地 Codex provider 配置、gateway profile state 与上游 OpenAI-compatible API 响应。
|
||||||
- 输出边界:仅返回代理后的 Codex API 响应和本地健康/状态信息。
|
- 输出边界:仅返回代理后的 Codex API 响应和本地健康/状态信息。
|
||||||
- 写入边界:只写本地用户态 gateway 状态目录。
|
- 写入边界:只写本地用户态 gateway 状态目录,以及当前 Codex provider 的 `base_url` 与其可恢复备份。
|
||||||
- 本地状态路径:`~/.codex-retry-gateway/`
|
- 本地状态路径:`~/.codex-retry-gateway/`
|
||||||
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
|
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`、`stream disconnected before completion: Concurrency limit exceeded for account, please retry later`,以及 Responses 错误码 `server_is_overloaded` / `slow_down`),也会自动重试;重试耗尽后转成本地 `502`
|
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`、`stream disconnected before completion: Concurrency limit exceeded for account, please retry later`,以及 Responses 错误码 `server_is_overloaded` / `slow_down`),也会自动重试;重试耗尽后转成本地 `502`
|
||||||
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
||||||
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
||||||
|
- 可选 `passthrough` 直通模式会对 `stream: true` 请求跳过 reasoning retry;若上游实际返回 `text/event-stream`,则把原始 SSE chunk 直接转发给客户端,不解析、缓存、规范化或拦截
|
||||||
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件
|
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件
|
||||||
- 默认同时拦截 root 路径和 `/v1` 路径:
|
- 默认同时拦截 root 路径和 `/v1` 路径:
|
||||||
- `/responses`
|
- `/responses`
|
||||||
@@ -52,6 +53,8 @@ macOS / Linux:
|
|||||||
|
|
||||||
- 这是一个可独立发布、独立运行的仓库
|
- 这是一个可独立发布、独立运行的仓库
|
||||||
- 默认监听地址是 `http://127.0.0.1:4610`
|
- 默认监听地址是 `http://127.0.0.1:4610`
|
||||||
|
- Profile 可以把服务绑定到 `0.0.0.0`;这只影响服务监听。Codex `config.toml`、同机 health 探测和本地 UI 链接仍会使用 `http://127.0.0.1:<listen-port>`,不会把不可作为客户端目标的 `0.0.0.0` 写入 provider `base_url`。
|
||||||
|
- 从管理页保存当前正在运行的文本 Profile 时,会只同步当前 `model_provider` 的 `base_url`;首次需要改写时保留/创建 `config.toml` 备份,并保留已知的原始上游地址。
|
||||||
- 默认示例上游见 `config.example.json`
|
- 默认示例上游见 `config.example.json`
|
||||||
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录
|
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录
|
||||||
|
|
||||||
@@ -388,6 +391,7 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
|
|||||||
- 默认 `strict_502`
|
- 默认 `strict_502`
|
||||||
- `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
|
- `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
|
||||||
- `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
|
- `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
|
||||||
|
- `passthrough`:对 `stream: true` 请求关闭 reasoning retry;只有上游实际返回 `text/event-stream` 时原样转发 SSE chunk,非 SSE 响应继续按单次既有规则处理
|
||||||
- `log_match`
|
- `log_match`
|
||||||
- 是否记录命中日志
|
- 是否记录命中日志
|
||||||
|
|
||||||
|
|||||||
+278
-17
@@ -11,6 +11,13 @@ import zlib from "node:zlib";
|
|||||||
import { TextDecoder } from "node:util";
|
import { TextDecoder } from "node:util";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCodexProviderContext,
|
||||||
|
getGatewayBaseUrl,
|
||||||
|
getGatewayListenerBaseUrl,
|
||||||
|
setCodexProviderBaseUrl,
|
||||||
|
} from "./scripts/admin-lib.mjs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const ADMIN_BASE_PATH = "/__codex_retry_gateway";
|
const ADMIN_BASE_PATH = "/__codex_retry_gateway";
|
||||||
@@ -80,6 +87,7 @@ const DEFAULT_CONFIG = {
|
|||||||
log_match: true,
|
log_match: true,
|
||||||
health_path: "/__codex_retry_gateway/health",
|
health_path: "/__codex_retry_gateway/health",
|
||||||
};
|
};
|
||||||
|
const STREAM_ACTIONS = ["strict_502", "disconnect", "passthrough"];
|
||||||
|
|
||||||
const REASONING_POINTERS = [
|
const REASONING_POINTERS = [
|
||||||
"/usage/output_tokens_details/reasoning_tokens",
|
"/usage/output_tokens_details/reasoning_tokens",
|
||||||
@@ -935,6 +943,15 @@ function normalizeReasoningMatchMode(value) {
|
|||||||
return DEFAULT_CONFIG.reasoning_match_mode;
|
return DEFAULT_CONFIG.reasoning_match_mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStreamAction(value) {
|
||||||
|
const action = `${value || DEFAULT_CONFIG.stream_action}`.trim().toLowerCase();
|
||||||
|
return STREAM_ACTIONS.includes(action) ? action : DEFAULT_CONFIG.stream_action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamPassthroughEnabled(config) {
|
||||||
|
return normalizeStreamAction(config?.stream_action) === "passthrough";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReasoningEquals(values, fallback = DEFAULT_CONFIG.reasoning_equals) {
|
function normalizeReasoningEquals(values, fallback = DEFAULT_CONFIG.reasoning_equals) {
|
||||||
const normalized = normalizeIntegerList(values, fallback);
|
const normalized = normalizeIntegerList(values, fallback);
|
||||||
return normalized.length > 0 ? normalized : [...fallback];
|
return normalized.length > 0 ? normalized : [...fallback];
|
||||||
@@ -1646,6 +1663,7 @@ function buildProfileFormModel(env) {
|
|||||||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS || `${DEFAULT_CONFIG.upstream_fetch_retry_attempts}`,
|
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS || `${DEFAULT_CONFIG.upstream_fetch_retry_attempts}`,
|
||||||
upstream_fetch_retry_backoff_ms:
|
upstream_fetch_retry_backoff_ms:
|
||||||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS || `${DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms}`,
|
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS || `${DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms}`,
|
||||||
|
stream_action: normalizeStreamAction(env.CODEX_RETRY_GATEWAY_STREAM_ACTION),
|
||||||
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints),
|
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1758,7 +1776,7 @@ function buildConfigFromProfileEnv(profileName, env, imageConfig = {}) {
|
|||||||
non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
|
non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
|
||||||
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
|
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
|
||||||
: DEFAULT_CONFIG.non_stream_status_code,
|
: DEFAULT_CONFIG.non_stream_status_code,
|
||||||
stream_action: env.CODEX_RETRY_GATEWAY_STREAM_ACTION || DEFAULT_CONFIG.stream_action,
|
stream_action: normalizeStreamAction(env.CODEX_RETRY_GATEWAY_STREAM_ACTION),
|
||||||
log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined
|
log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined
|
||||||
? DEFAULT_CONFIG.log_match
|
? DEFAULT_CONFIG.log_match
|
||||||
: ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()),
|
: ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()),
|
||||||
@@ -1895,6 +1913,7 @@ async function buildProfileEnvText(payload) {
|
|||||||
if (upstreamFetchRetryBackoffMs < 0) {
|
if (upstreamFetchRetryBackoffMs < 0) {
|
||||||
throw new Error("upstream_fetch_retry_backoff_ms 不能为负数");
|
throw new Error("upstream_fetch_retry_backoff_ms 不能为负数");
|
||||||
}
|
}
|
||||||
|
const streamAction = normalizeStreamAction(payload.stream_action);
|
||||||
|
|
||||||
const envPairs = [
|
const envPairs = [
|
||||||
["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()],
|
["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()],
|
||||||
@@ -1907,6 +1926,7 @@ async function buildProfileEnvText(payload) {
|
|||||||
["CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES", retryableErrorMessages.join("\n")],
|
["CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES", retryableErrorMessages.join("\n")],
|
||||||
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS", `${upstreamFetchRetryAttempts}`],
|
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS", `${upstreamFetchRetryAttempts}`],
|
||||||
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS", `${upstreamFetchRetryBackoffMs}`],
|
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS", `${upstreamFetchRetryBackoffMs}`],
|
||||||
|
["CODEX_RETRY_GATEWAY_STREAM_ACTION", streamAction],
|
||||||
["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`],
|
["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`],
|
||||||
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
|
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
|
||||||
];
|
];
|
||||||
@@ -2132,6 +2152,7 @@ async function buildTextProfileExportBundle(runtime, payload) {
|
|||||||
retryable_error_messages: config.retryable_error_messages,
|
retryable_error_messages: config.retryable_error_messages,
|
||||||
upstream_fetch_retry_attempts: config.upstream_fetch_retry_attempts,
|
upstream_fetch_retry_attempts: config.upstream_fetch_retry_attempts,
|
||||||
upstream_fetch_retry_backoff_ms: config.upstream_fetch_retry_backoff_ms,
|
upstream_fetch_retry_backoff_ms: config.upstream_fetch_retry_backoff_ms,
|
||||||
|
stream_action: config.stream_action,
|
||||||
endpoints: config.endpoints,
|
endpoints: config.endpoints,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2242,6 +2263,7 @@ async function importTextProfileBundle(runtime, payload) {
|
|||||||
retryable_error_messages: imported.profile.retryable_error_messages,
|
retryable_error_messages: imported.profile.retryable_error_messages,
|
||||||
upstream_fetch_retry_attempts: imported.profile.upstream_fetch_retry_attempts,
|
upstream_fetch_retry_attempts: imported.profile.upstream_fetch_retry_attempts,
|
||||||
upstream_fetch_retry_backoff_ms: imported.profile.upstream_fetch_retry_backoff_ms,
|
upstream_fetch_retry_backoff_ms: imported.profile.upstream_fetch_retry_backoff_ms,
|
||||||
|
stream_action: imported.profile.stream_action,
|
||||||
endpoints: imported.profile.endpoints,
|
endpoints: imported.profile.endpoints,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -2544,6 +2566,7 @@ async function loadConfig(configPath) {
|
|||||||
config.retryable_error_messages,
|
config.retryable_error_messages,
|
||||||
DEFAULT_CONFIG.retryable_error_messages,
|
DEFAULT_CONFIG.retryable_error_messages,
|
||||||
);
|
);
|
||||||
|
config.stream_action = normalizeStreamAction(config.stream_action);
|
||||||
config.management_access_key = normalizeManagementAccessKey(config.management_access_key);
|
config.management_access_key = normalizeManagementAccessKey(config.management_access_key);
|
||||||
config.upstream_fetch_retry_attempts = normalizePositiveInteger(
|
config.upstream_fetch_retry_attempts = normalizePositiveInteger(
|
||||||
config.upstream_fetch_retry_attempts,
|
config.upstream_fetch_retry_attempts,
|
||||||
@@ -2573,6 +2596,7 @@ function buildRuntimePaths(configPath, logPath) {
|
|||||||
const homeDir = process.env.HOME || "";
|
const homeDir = process.env.HOME || "";
|
||||||
return {
|
return {
|
||||||
stateRoot,
|
stateRoot,
|
||||||
|
backupDir: path.join(stateRoot, "backups"),
|
||||||
statePath: path.join(stateRoot, "state.json"),
|
statePath: path.join(stateRoot, "state.json"),
|
||||||
pidPath: path.join(stateRoot, "gateway.pid"),
|
pidPath: path.join(stateRoot, "gateway.pid"),
|
||||||
profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"),
|
profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"),
|
||||||
@@ -2896,6 +2920,80 @@ async function updateRuntimeState(runtime, updates) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOriginalCodexBaseUrl(state, currentBaseUrl, listenHost, listenPort) {
|
||||||
|
const knownGatewayUrls = new Set([
|
||||||
|
getGatewayBaseUrl(listenHost, listenPort),
|
||||||
|
getGatewayListenerBaseUrl(listenHost, listenPort),
|
||||||
|
`${state?.gateway_base_url || ""}`.trim(),
|
||||||
|
].filter(Boolean));
|
||||||
|
const stateOriginalBaseUrl = `${state?.original_base_url || ""}`.trim();
|
||||||
|
if (stateOriginalBaseUrl && !knownGatewayUrls.has(stateOriginalBaseUrl)) {
|
||||||
|
return stateOriginalBaseUrl;
|
||||||
|
}
|
||||||
|
return knownGatewayUrls.has(currentBaseUrl) ? null : currentBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCurrentCodexProviderToGateway(runtime, gatewayBaseUrl) {
|
||||||
|
const state = await readOptionalJson(runtime.paths.statePath);
|
||||||
|
const codexConfigPath = `${state?.codex_config_path || ""}`.trim();
|
||||||
|
if (!codexConfigPath) {
|
||||||
|
return {
|
||||||
|
attempted: false,
|
||||||
|
changed: false,
|
||||||
|
providerName: "",
|
||||||
|
originalBaseUrl: null,
|
||||||
|
backupPath: null,
|
||||||
|
rollback: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerContext = await getCodexProviderContext(codexConfigPath);
|
||||||
|
const currentBaseUrl = providerContext.currentBaseUrl;
|
||||||
|
const originalBaseUrl = resolveOriginalCodexBaseUrl(
|
||||||
|
state,
|
||||||
|
currentBaseUrl,
|
||||||
|
runtime.config.listen_host,
|
||||||
|
runtime.config.listen_port,
|
||||||
|
);
|
||||||
|
|
||||||
|
let backupPath = `${state?.latest_backup_path || ""}`.trim();
|
||||||
|
if (currentBaseUrl !== gatewayBaseUrl && (!backupPath || !fs.existsSync(backupPath))) {
|
||||||
|
await mkdir(runtime.paths.backupDir, { recursive: true });
|
||||||
|
backupPath = path.join(
|
||||||
|
runtime.paths.backupDir,
|
||||||
|
`config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`,
|
||||||
|
);
|
||||||
|
await copyFile(codexConfigPath, backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = currentBaseUrl !== gatewayBaseUrl;
|
||||||
|
if (changed) {
|
||||||
|
await setCodexProviderBaseUrl({
|
||||||
|
codexConfigPath,
|
||||||
|
providerName: providerContext.providerName,
|
||||||
|
newBaseUrl: gatewayBaseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
changed,
|
||||||
|
providerName: providerContext.providerName,
|
||||||
|
originalBaseUrl,
|
||||||
|
backupPath: backupPath || null,
|
||||||
|
rollback: async () => {
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setCodexProviderBaseUrl({
|
||||||
|
codexConfigPath,
|
||||||
|
providerName: providerContext.providerName,
|
||||||
|
newBaseUrl: currentBaseUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function listProfiles(runtime) {
|
async function listProfiles(runtime) {
|
||||||
let files = [];
|
let files = [];
|
||||||
try {
|
try {
|
||||||
@@ -2941,6 +3039,7 @@ async function listProfiles(runtime) {
|
|||||||
auth_source: summarizeProfileAuthSource(env),
|
auth_source: summarizeProfileAuthSource(env),
|
||||||
reasoning_match_mode: form.reasoning_match_mode,
|
reasoning_match_mode: form.reasoning_match_mode,
|
||||||
reasoning_equals: form.reasoning_equals,
|
reasoning_equals: form.reasoning_equals,
|
||||||
|
stream_action: form.stream_action,
|
||||||
},
|
},
|
||||||
form,
|
form,
|
||||||
});
|
});
|
||||||
@@ -3120,20 +3219,31 @@ async function applyProfileConfig(runtime, profileName) {
|
|||||||
throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换");
|
throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换");
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.config = {
|
const gatewayBaseUrl = getGatewayBaseUrl(config.listen_host, config.listen_port);
|
||||||
...config,
|
const codexProviderSync = await syncCurrentCodexProviderToGateway(runtime, gatewayBaseUrl);
|
||||||
model_remap_map: parseModelRemapMap(config.model_remap),
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeConfig(runtime.configPath, runtime.config);
|
try {
|
||||||
await updateRuntimeState(runtime, {
|
runtime.config = {
|
||||||
profile_name: runtime.config.profile_name || "default",
|
...config,
|
||||||
profile_env_path: profilePath,
|
model_remap_map: parseModelRemapMap(config.model_remap),
|
||||||
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
};
|
||||||
last_started_at: new Date().toISOString(),
|
|
||||||
});
|
await writeConfig(runtime.configPath, runtime.config);
|
||||||
|
await updateRuntimeState(runtime, {
|
||||||
|
profile_name: runtime.config.profile_name || "default",
|
||||||
|
profile_env_path: profilePath,
|
||||||
|
gateway_base_url: gatewayBaseUrl,
|
||||||
|
...(codexProviderSync.providerName ? { provider_name: codexProviderSync.providerName } : {}),
|
||||||
|
...(codexProviderSync.originalBaseUrl ? { original_base_url: codexProviderSync.originalBaseUrl } : {}),
|
||||||
|
...(codexProviderSync.backupPath ? { latest_backup_path: codexProviderSync.backupPath } : {}),
|
||||||
|
last_started_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await codexProviderSync.rollback().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
runtime.logger(
|
runtime.logger(
|
||||||
`[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url}`,
|
`[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url} codex_config_sync=${codexProviderSync.changed ? "updated" : codexProviderSync.attempted ? "current" : "unavailable"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -3142,6 +3252,12 @@ async function applyProfileConfig(runtime, profileName) {
|
|||||||
hot_swapped: true,
|
hot_swapped: true,
|
||||||
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
||||||
upstream_base_url: runtime.config.upstream_base_url,
|
upstream_base_url: runtime.config.upstream_base_url,
|
||||||
|
codex_provider_sync: codexProviderSync.attempted
|
||||||
|
? {
|
||||||
|
updated: codexProviderSync.changed,
|
||||||
|
gateway_base_url: gatewayBaseUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3703,6 +3819,9 @@ function buildEditableConfig(currentConfig, payload) {
|
|||||||
payload.non_stream_status_code === undefined
|
payload.non_stream_status_code === undefined
|
||||||
? currentConfig.non_stream_status_code
|
? currentConfig.non_stream_status_code
|
||||||
: Number.parseInt(`${payload.non_stream_status_code}`, 10);
|
: Number.parseInt(`${payload.non_stream_status_code}`, 10);
|
||||||
|
const nextStreamAction = normalizeStreamAction(
|
||||||
|
payload.stream_action === undefined ? currentConfig.stream_action : payload.stream_action,
|
||||||
|
);
|
||||||
|
|
||||||
if (nextRetryableStatusCodes.length === 0) {
|
if (nextRetryableStatusCodes.length === 0) {
|
||||||
throw new Error("retryable_status_codes 不能为空");
|
throw new Error("retryable_status_codes 不能为空");
|
||||||
@@ -3737,6 +3856,7 @@ function buildEditableConfig(currentConfig, payload) {
|
|||||||
upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts,
|
upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts,
|
||||||
upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs,
|
upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs,
|
||||||
non_stream_status_code: nextStatusCode,
|
non_stream_status_code: nextStatusCode,
|
||||||
|
stream_action: nextStreamAction,
|
||||||
log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match),
|
log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4322,7 +4442,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
|
|||||||
await writeConfig(runtime.configPath, nextConfig);
|
await writeConfig(runtime.configPath, nextConfig);
|
||||||
runtime.config = nextConfig;
|
runtime.config = nextConfig;
|
||||||
runtime.logger(
|
runtime.logger(
|
||||||
`[config] updated reasoning_match_mode=${nextConfig.reasoning_match_mode} reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`,
|
`[config] updated reasoning_match_mode=${nextConfig.reasoning_match_mode} reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} stream_action=${nextConfig.stream_action} endpoints=${nextConfig.endpoints.join(",")}`,
|
||||||
);
|
);
|
||||||
const state = await readRuntimeState(runtime);
|
const state = await readRuntimeState(runtime);
|
||||||
jsonResponse(req, res, 200, {
|
jsonResponse(req, res, 200, {
|
||||||
@@ -4538,6 +4658,34 @@ async function writeBufferedStreamChunks(res, chunks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeResponseChunk(res, chunk) {
|
||||||
|
if (!chunk || chunk.length === 0 || res.destroyed || res.writableEnded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (res.write(chunk)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
res.off("drain", onDrain);
|
||||||
|
res.off("close", onClose);
|
||||||
|
res.off("error", onClose);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onDrain = () => finish();
|
||||||
|
const onClose = () => finish();
|
||||||
|
res.once("drain", onDrain);
|
||||||
|
res.once("close", onClose);
|
||||||
|
res.once("error", onClose);
|
||||||
|
});
|
||||||
|
return !res.destroyed && !res.writableEnded;
|
||||||
|
}
|
||||||
|
|
||||||
async function writeCapturedResponse(res, delivery) {
|
async function writeCapturedResponse(res, delivery) {
|
||||||
if (!delivery) {
|
if (!delivery) {
|
||||||
throw new Error("missing captured response delivery");
|
throw new Error("missing captured response delivery");
|
||||||
@@ -5281,7 +5429,7 @@ async function handleStreaming({
|
|||||||
captureOnly = false,
|
captureOnly = false,
|
||||||
persistEntry = true,
|
persistEntry = true,
|
||||||
}) {
|
}) {
|
||||||
const strict502Mode = captureOnly || config.stream_action !== "disconnect";
|
const strict502Mode = captureOnly || normalizeStreamAction(config.stream_action) !== "disconnect";
|
||||||
const reader = upstreamResponse.body.getReader();
|
const reader = upstreamResponse.body.getReader();
|
||||||
const sseState = {
|
const sseState = {
|
||||||
decoder: new TextDecoder("utf8"),
|
decoder: new TextDecoder("utf8"),
|
||||||
@@ -5659,6 +5807,97 @@ async function handleStreaming({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePassthroughStreaming({
|
||||||
|
runtime,
|
||||||
|
upstreamResponse,
|
||||||
|
res,
|
||||||
|
requestEntry,
|
||||||
|
requestAbortSignal = null,
|
||||||
|
persistEntry = true,
|
||||||
|
}) {
|
||||||
|
const reader = upstreamResponse.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("upstream SSE response is missing a readable body");
|
||||||
|
}
|
||||||
|
|
||||||
|
requestEntry.inspected = false;
|
||||||
|
copyHeadersToClient(upstreamResponse.headers, res);
|
||||||
|
res.writeHead(upstreamResponse.status);
|
||||||
|
res.socket?.setNoDelay(true);
|
||||||
|
res.flushHeaders?.();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let readResult;
|
||||||
|
try {
|
||||||
|
readResult = await reader.read();
|
||||||
|
} catch (error) {
|
||||||
|
if (requestAbortSignal?.aborted) {
|
||||||
|
throw requestAbortSignal.reason || error;
|
||||||
|
}
|
||||||
|
if (persistEntry) {
|
||||||
|
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
|
||||||
|
}
|
||||||
|
if (!res.destroyed && !res.writableEnded) {
|
||||||
|
res.destroy(error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
inspected: false,
|
||||||
|
matched: false,
|
||||||
|
status_code: upstreamResponse.status,
|
||||||
|
upstream_status_code: upstreamResponse.status,
|
||||||
|
response_id: requestEntry.response_id,
|
||||||
|
thread_id: requestEntry.thread_id,
|
||||||
|
response_bytes_received: requestEntry.response_bytes_received,
|
||||||
|
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||||
|
error: `upstream SSE stream terminated: ${error?.message || error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readResult.done) {
|
||||||
|
if (persistEntry) {
|
||||||
|
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
|
||||||
|
}
|
||||||
|
if (!res.destroyed && !res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
inspected: false,
|
||||||
|
matched: false,
|
||||||
|
status_code: upstreamResponse.status,
|
||||||
|
upstream_status_code: upstreamResponse.status,
|
||||||
|
response_id: requestEntry.response_id,
|
||||||
|
thread_id: requestEntry.thread_id,
|
||||||
|
response_bytes_received: requestEntry.response_bytes_received,
|
||||||
|
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = Buffer.from(readResult.value);
|
||||||
|
const now = new Date();
|
||||||
|
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry, at: now });
|
||||||
|
updateStreamingProgress(requestEntry, { chunkBytes: chunk.length, at: now });
|
||||||
|
if (persistEntry) {
|
||||||
|
persistStreamingProgress(
|
||||||
|
runtime,
|
||||||
|
requestEntry,
|
||||||
|
{ force: requestEntry.stream_chunk_count === 1 },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeResponseChunk(res, chunk);
|
||||||
|
if (res.destroyed || res.writableEnded) {
|
||||||
|
return {
|
||||||
|
cancelled: true,
|
||||||
|
cancel_reason: REASONING_RETRY_ABORT_CLIENT,
|
||||||
|
response_id: requestEntry.response_id,
|
||||||
|
thread_id: requestEntry.thread_id,
|
||||||
|
response_bytes_received: requestEntry.response_bytes_received,
|
||||||
|
stream_chunk_count: requestEntry.stream_chunk_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildAttemptRequestEntry(baseEntry) {
|
function buildAttemptRequestEntry(baseEntry) {
|
||||||
return {
|
return {
|
||||||
...baseEntry,
|
...baseEntry,
|
||||||
@@ -5778,6 +6017,22 @@ async function executeGatewayQuery({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSseContentType(responseContentType) && !captureOnly && streamPassthroughEnabled(config)) {
|
||||||
|
const result = await handlePassthroughStreaming({
|
||||||
|
runtime,
|
||||||
|
upstreamResponse,
|
||||||
|
res,
|
||||||
|
requestEntry,
|
||||||
|
requestAbortSignal: queryAbortLink.controller.signal,
|
||||||
|
persistEntry,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response_stream: true,
|
||||||
|
...result,
|
||||||
|
total_upstream_attempts: totalUpstreamAttempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldInspect) {
|
if (!shouldInspect) {
|
||||||
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry });
|
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry });
|
||||||
const body = Buffer.from(await upstreamResponse.arrayBuffer());
|
const body = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
@@ -6303,6 +6558,12 @@ async function proxyRequest(runtime, req, res) {
|
|||||||
requestEntry.reasoning_summary = extractRequestReasoningSummary(requestJson);
|
requestEntry.reasoning_summary = extractRequestReasoningSummary(requestJson);
|
||||||
requestEntry.request_stream = requestIsStream;
|
requestEntry.request_stream = requestIsStream;
|
||||||
applyThreadReasoningState(runtime, requestEntry, pathname);
|
applyThreadReasoningState(runtime, requestEntry, pathname);
|
||||||
|
const streamPassthroughRequest = streamPassthroughEnabled(config) && requestIsStream;
|
||||||
|
if (streamPassthroughRequest && isResponsesReasoningRetryPath(pathname)) {
|
||||||
|
requestEntry.reasoning_retry_enabled = false;
|
||||||
|
requestEntry.reasoning_retry_thread_mode = "stream_passthrough";
|
||||||
|
requestEntry.reasoning_retry_stop_reason = "stream_passthrough";
|
||||||
|
}
|
||||||
upsertRequestEntry(runtime, requestEntry);
|
upsertRequestEntry(runtime, requestEntry);
|
||||||
|
|
||||||
const upstreamRoute = selectUpstreamRoute(config, pathname);
|
const upstreamRoute = selectUpstreamRoute(config, pathname);
|
||||||
@@ -6323,7 +6584,7 @@ async function proxyRequest(runtime, req, res) {
|
|||||||
}
|
}
|
||||||
const clientAbortContext = createClientAbortContext(req, res);
|
const clientAbortContext = createClientAbortContext(req, res);
|
||||||
try {
|
try {
|
||||||
const result = isResponsesReasoningRetryEligible(pathname, requestEntry)
|
const result = !streamPassthroughRequest && isResponsesReasoningRetryEligible(pathname, requestEntry)
|
||||||
? await runResponsesReasoningRetry({
|
? await runResponsesReasoningRetry({
|
||||||
runtime,
|
runtime,
|
||||||
config,
|
config,
|
||||||
@@ -6477,7 +6738,7 @@ async function main() {
|
|||||||
last_started_at: new Date().toISOString(),
|
last_started_at: new Date().toISOString(),
|
||||||
profile_name: runtime.config.profile_name || "default",
|
profile_name: runtime.config.profile_name || "default",
|
||||||
image_profile_name: runtime.config.image_profile_name || "",
|
image_profile_name: runtime.config.image_profile_name || "",
|
||||||
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
gateway_base_url: getGatewayBaseUrl(runtime.config.listen_host, runtime.config.listen_port),
|
||||||
}).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`));
|
}).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`));
|
||||||
logger(
|
logger(
|
||||||
`[start] codex retry gateway profile=${runtime.config.profile_name || "default"} image_profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} reasoning_match_mode=${normalizeReasoningMatchMode(runtime.config.reasoning_match_mode)} listening on http://${runtime.config.listen_host}:${runtime.config.listen_port} -> ${runtime.config.upstream_base_url}`,
|
`[start] codex retry gateway profile=${runtime.config.profile_name || "default"} image_profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} reasoning_match_mode=${normalizeReasoningMatchMode(runtime.config.reasoning_match_mode)} listening on http://${runtime.config.listen_host}:${runtime.config.listen_port} -> ${runtime.config.upstream_base_url}`,
|
||||||
|
|||||||
+31
-14
@@ -73,9 +73,23 @@ export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGatewayBaseUrl(listenHost, listenPort) {
|
export function getGatewayListenerBaseUrl(listenHost, listenPort) {
|
||||||
return `http://${listenHost}:${listenPort}`;
|
return `http://${listenHost}:${listenPort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGatewayBaseUrl(listenHost, listenPort) {
|
||||||
|
// 0.0.0.0 is a valid bind address, but not a client destination. Keep the
|
||||||
|
// listener wildcard intact and use loopback for Codex, health probes, and UI
|
||||||
|
// links that originate on the same machine.
|
||||||
|
const clientHost = `${listenHost ?? ""}`.trim() === "0.0.0.0" ? "127.0.0.1" : listenHost;
|
||||||
|
return getGatewayListenerBaseUrl(clientHost, listenPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGatewayBaseUrlForListener(baseUrl, listenHost, listenPort) {
|
||||||
|
const candidate = `${baseUrl ?? ""}`.trim();
|
||||||
|
return candidate === getGatewayBaseUrl(listenHost, listenPort)
|
||||||
|
|| candidate === getGatewayListenerBaseUrl(listenHost, listenPort);
|
||||||
|
}
|
||||||
|
|
||||||
export function getGatewayBaseUrlFromConfig(gatewayConfig) {
|
export function getGatewayBaseUrlFromConfig(gatewayConfig) {
|
||||||
if (!gatewayConfig) {
|
if (!gatewayConfig) {
|
||||||
@@ -408,16 +422,16 @@ export async function installForCurrentProvider({
|
|||||||
const existingState = await readJsonFile(paths.statePath);
|
const existingState = await readJsonFile(paths.statePath);
|
||||||
|
|
||||||
let originalBaseUrl = providerContext.currentBaseUrl;
|
let originalBaseUrl = providerContext.currentBaseUrl;
|
||||||
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
|
if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
|
||||||
if (!existingState?.original_base_url) {
|
if (!existingState?.original_base_url) {
|
||||||
throw new Error("Provider already points to the local gateway, but original_base_url is missing from state.");
|
throw new Error("Provider already points to the local gateway, but original_base_url is missing from state.");
|
||||||
}
|
}
|
||||||
originalBaseUrl = `${existingState.original_base_url}`;
|
originalBaseUrl = `${existingState.original_base_url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalBaseUrl === localGatewayBaseUrl) {
|
if (isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
|
||||||
throw new Error("A real upstream_base_url could not be determined.");
|
throw new Error("A real upstream_base_url could not be determined.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupPath = path.join(paths.backupDir, `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`);
|
const backupPath = path.join(paths.backupDir, `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`);
|
||||||
await copyFile(codexConfigPath, backupPath);
|
await copyFile(codexConfigPath, backupPath);
|
||||||
@@ -557,7 +571,10 @@ export async function launchUi({
|
|||||||
const existingGatewayConfig = await readJsonFile(paths.configPath);
|
const existingGatewayConfig = await readJsonFile(paths.configPath);
|
||||||
const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null;
|
const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null;
|
||||||
const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig);
|
const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig);
|
||||||
const managedGatewayBaseUrls = [requestedGatewayBaseUrl];
|
const managedGatewayBaseUrls = [
|
||||||
|
requestedGatewayBaseUrl,
|
||||||
|
getGatewayListenerBaseUrl(listenHost, listenPort),
|
||||||
|
];
|
||||||
for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) {
|
for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) {
|
||||||
if (candidate && !managedGatewayBaseUrls.includes(candidate)) {
|
if (candidate && !managedGatewayBaseUrls.includes(candidate)) {
|
||||||
managedGatewayBaseUrls.push(candidate);
|
managedGatewayBaseUrls.push(candidate);
|
||||||
|
|||||||
+26
-5
@@ -19,6 +19,7 @@ import {
|
|||||||
getCodexProviderContext,
|
getCodexProviderContext,
|
||||||
getGatewayBaseUrl,
|
getGatewayBaseUrl,
|
||||||
getGatewayStatePaths,
|
getGatewayStatePaths,
|
||||||
|
isGatewayBaseUrlForListener,
|
||||||
normalizeIntArray,
|
normalizeIntArray,
|
||||||
normalizePhraseArray,
|
normalizePhraseArray,
|
||||||
normalizeReasoningMatchMode,
|
normalizeReasoningMatchMode,
|
||||||
@@ -345,12 +346,14 @@ function buildProfileConfig({
|
|||||||
imageProfileEnv,
|
imageProfileEnv,
|
||||||
existingGatewayConfig,
|
existingGatewayConfig,
|
||||||
providerContext,
|
providerContext,
|
||||||
|
listenHost,
|
||||||
|
listenPort,
|
||||||
localGatewayBaseUrl,
|
localGatewayBaseUrl,
|
||||||
}) {
|
}) {
|
||||||
const upstreamBaseUrl =
|
const upstreamBaseUrl =
|
||||||
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
|
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
|
||||||
existingGatewayConfig?.upstream_base_url ||
|
existingGatewayConfig?.upstream_base_url ||
|
||||||
(providerContext.currentBaseUrl === localGatewayBaseUrl
|
(isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
|
||||||
? null
|
? null
|
||||||
: providerContext.currentBaseUrl);
|
: providerContext.currentBaseUrl);
|
||||||
|
|
||||||
@@ -425,15 +428,22 @@ function buildProfileConfig({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerContext, localGatewayBaseUrl }) {
|
async function ensureCodexPointsToGateway({
|
||||||
|
paths,
|
||||||
|
codexConfigPath,
|
||||||
|
providerContext,
|
||||||
|
listenHost,
|
||||||
|
listenPort,
|
||||||
|
localGatewayBaseUrl,
|
||||||
|
}) {
|
||||||
await ensureDirectory(paths.backupDir);
|
await ensureDirectory(paths.backupDir);
|
||||||
const existingState = await readJsonFile(paths.statePath);
|
const existingState = await readJsonFile(paths.statePath);
|
||||||
|
|
||||||
let originalBaseUrl = providerContext.currentBaseUrl;
|
let originalBaseUrl = providerContext.currentBaseUrl;
|
||||||
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
|
if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
|
||||||
originalBaseUrl = existingState?.original_base_url || null;
|
originalBaseUrl = existingState?.original_base_url || null;
|
||||||
}
|
}
|
||||||
if (!originalBaseUrl || originalBaseUrl === localGatewayBaseUrl) {
|
if (!originalBaseUrl || isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
|
||||||
throw new Error("A restorable original Codex base_url could not be determined.");
|
throw new Error("A restorable original Codex base_url could not be determined.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,19 +546,30 @@ async function main() {
|
|||||||
imageProfileEnv,
|
imageProfileEnv,
|
||||||
existingGatewayConfig,
|
existingGatewayConfig,
|
||||||
providerContext,
|
providerContext,
|
||||||
|
listenHost,
|
||||||
|
listenPort,
|
||||||
localGatewayBaseUrl,
|
localGatewayBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const installState = options.noCodexConfigUpdate
|
const installState = options.noCodexConfigUpdate
|
||||||
? {
|
? {
|
||||||
existingState,
|
existingState,
|
||||||
originalBaseUrl: providerContext.currentBaseUrl,
|
originalBaseUrl: isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
|
||||||
|
? (
|
||||||
|
existingState?.original_base_url
|
||||||
|
&& !isGatewayBaseUrlForListener(existingState.original_base_url, listenHost, listenPort)
|
||||||
|
? `${existingState.original_base_url}`
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
: providerContext.currentBaseUrl,
|
||||||
backupPath: null,
|
backupPath: null,
|
||||||
}
|
}
|
||||||
: await ensureCodexPointsToGateway({
|
: await ensureCodexPointsToGateway({
|
||||||
paths,
|
paths,
|
||||||
codexConfigPath,
|
codexConfigPath,
|
||||||
providerContext,
|
providerContext,
|
||||||
|
listenHost,
|
||||||
|
listenPort,
|
||||||
localGatewayBaseUrl,
|
localGatewayBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -638,14 +638,18 @@ async function run() {
|
|||||||
const gatewayPort = await getFreePort();
|
const gatewayPort = await getFreePort();
|
||||||
const configPath = path.join(tempRoot, "config.json");
|
const configPath = path.join(tempRoot, "config.json");
|
||||||
const logPath = path.join(tempRoot, "gateway.log");
|
const logPath = path.join(tempRoot, "gateway.log");
|
||||||
|
const codexConfigPath = path.join(tempRoot, ".codex", "config.toml");
|
||||||
const profilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "profiles");
|
const profilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "profiles");
|
||||||
const imageProfilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "image-profiles");
|
const imageProfilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "image-profiles");
|
||||||
|
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||||
|
const legacyWildcardGatewayBaseUrl = `http://0.0.0.0:${gatewayPort}`;
|
||||||
|
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
profile_name: "legacy-text",
|
profile_name: "legacy-text",
|
||||||
listen_host: "127.0.0.1",
|
listen_host: "0.0.0.0",
|
||||||
listen_port: gatewayPort,
|
listen_port: gatewayPort,
|
||||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
upstream_base_url: upstreamBaseUrl,
|
||||||
image_base_url: `http://127.0.0.1:${imageUpstreamPort}`,
|
image_base_url: `http://127.0.0.1:${imageUpstreamPort}`,
|
||||||
image_auth_mode: "fixed_bearer",
|
image_auth_mode: "fixed_bearer",
|
||||||
image_auth_env: "TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
image_auth_env: "TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
||||||
@@ -672,12 +676,39 @@ async function run() {
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(profilesDir, "legacy-text.env"),
|
path.join(profilesDir, "legacy-text.env"),
|
||||||
[
|
[
|
||||||
`CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL=http://127.0.0.1:${upstreamPort}`,
|
"CODEX_RETRY_GATEWAY_LISTEN_HOST=0.0.0.0",
|
||||||
|
`CODEX_RETRY_GATEWAY_LISTEN_PORT=${gatewayPort}`,
|
||||||
|
`CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL=${upstreamBaseUrl}`,
|
||||||
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE=passthrough",
|
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE=passthrough",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
await mkdir(path.dirname(codexConfigPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
codexConfigPath,
|
||||||
|
[
|
||||||
|
'model_provider = "custom"',
|
||||||
|
"",
|
||||||
|
"[model_providers.custom]",
|
||||||
|
'name = "Gateway E2E Test"',
|
||||||
|
`base_url = "${legacyWildcardGatewayBaseUrl}"`,
|
||||||
|
'wire_api = "responses"',
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(tempRoot, "state.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
codex_config_path: codexConfigPath,
|
||||||
|
provider_name: "custom",
|
||||||
|
original_base_url: upstreamBaseUrl,
|
||||||
|
gateway_base_url: legacyWildcardGatewayBaseUrl,
|
||||||
|
profile_name: "legacy-text",
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||||
|
|
||||||
const upstream = await startFakeUpstream(upstreamPort, { label: "default" });
|
const upstream = await startFakeUpstream(upstreamPort, { label: "default" });
|
||||||
@@ -822,6 +853,7 @@ async function run() {
|
|||||||
retryable_error_messages: ["capacity test"],
|
retryable_error_messages: ["capacity test"],
|
||||||
upstream_fetch_retry_attempts: 3,
|
upstream_fetch_retry_attempts: 3,
|
||||||
upstream_fetch_retry_backoff_ms: 50,
|
upstream_fetch_retry_backoff_ms: 50,
|
||||||
|
stream_action: "passthrough",
|
||||||
endpoints: ["/responses", "/v1/responses"],
|
endpoints: ["/responses", "/v1/responses"],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -829,6 +861,15 @@ async function run() {
|
|||||||
assert(exportableTextProfileResponse.status === 200, `可导出文本 profile 保存失败: ${exportableTextProfileResponse.status}`);
|
assert(exportableTextProfileResponse.status === 200, `可导出文本 profile 保存失败: ${exportableTextProfileResponse.status}`);
|
||||||
const exportableTextProfilePayload = await exportableTextProfileResponse.json();
|
const exportableTextProfilePayload = await exportableTextProfileResponse.json();
|
||||||
assert(!JSON.stringify(exportableTextProfilePayload).includes("text-export-secret"), "普通 profile API 不应返回导出 key");
|
assert(!JSON.stringify(exportableTextProfilePayload).includes("text-export-secret"), "普通 profile API 不应返回导出 key");
|
||||||
|
const exportableTextProfile = (exportableTextProfilePayload.profiles || []).find(
|
||||||
|
(profile) => profile?.name === "export-source",
|
||||||
|
);
|
||||||
|
assert(exportableTextProfile?.form?.stream_action === "passthrough", "文本 profile 未保留 SSE 直通模式");
|
||||||
|
assert(
|
||||||
|
(await readFile(path.join(profilesDir, "export-source.env"), "utf8"))
|
||||||
|
.includes("CODEX_RETRY_GATEWAY_STREAM_ACTION=passthrough"),
|
||||||
|
"文本 profile env 未写入 SSE 直通模式",
|
||||||
|
);
|
||||||
|
|
||||||
const passthroughExportResponse = await fetch(
|
const passthroughExportResponse = await fetch(
|
||||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`,
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`,
|
||||||
@@ -1026,7 +1067,7 @@ async function run() {
|
|||||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: "legacy-text",
|
name: "legacy-text",
|
||||||
listen_host: "127.0.0.1",
|
listen_host: "0.0.0.0",
|
||||||
listen_port: gatewayPort,
|
listen_port: gatewayPort,
|
||||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
||||||
auth_mode: "passthrough",
|
auth_mode: "passthrough",
|
||||||
@@ -1034,6 +1075,29 @@ async function run() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert(saveActiveTextProfileResponse.status === 200, `当前文本 profile 保存失败: ${saveActiveTextProfileResponse.status}`);
|
assert(saveActiveTextProfileResponse.status === 200, `当前文本 profile 保存失败: ${saveActiveTextProfileResponse.status}`);
|
||||||
|
const saveActiveTextProfilePayload = await saveActiveTextProfileResponse.json();
|
||||||
|
assert(
|
||||||
|
saveActiveTextProfilePayload?.applied_profile?.codex_provider_sync?.updated === true,
|
||||||
|
"保存活跃 wildcard listener profile 时未同步 Codex provider",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
saveActiveTextProfilePayload?.applied_profile?.codex_provider_sync?.gateway_base_url === gatewayBaseUrl,
|
||||||
|
"保存活跃 wildcard listener profile 时未使用回环 gateway URL",
|
||||||
|
);
|
||||||
|
const savedCodexConfig = await readFile(codexConfigPath, "utf8");
|
||||||
|
assert(
|
||||||
|
savedCodexConfig.includes(`base_url = "${gatewayBaseUrl}"`),
|
||||||
|
"保存 wildcard listener profile 后 Codex config 未指向 127.0.0.1",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!savedCodexConfig.includes(`base_url = "${legacyWildcardGatewayBaseUrl}"`),
|
||||||
|
"保存 wildcard listener profile 后 Codex config 仍指向 0.0.0.0",
|
||||||
|
);
|
||||||
|
const savedState = JSON.parse(await readFile(path.join(tempRoot, "state.json"), "utf8"));
|
||||||
|
assert(savedState.gateway_base_url === gatewayBaseUrl, "runtime state 未规范化 wildcard gateway URL");
|
||||||
|
assert(savedState.original_base_url === upstreamBaseUrl, "同步 Codex config 时覆盖了原始上游 URL");
|
||||||
|
assert(savedState.latest_backup_path, "同步 Codex config 时未创建可恢复备份");
|
||||||
|
assert((await stat(savedState.latest_backup_path)).isFile(), "Codex config 备份文件不存在");
|
||||||
const afterTextSaveStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
const afterTextSaveStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
||||||
const afterTextSaveStatusPayload = await afterTextSaveStatusResponse.json();
|
const afterTextSaveStatusPayload = await afterTextSaveStatusResponse.json();
|
||||||
assert(afterTextSaveStatusPayload?.config?.image_profile_name === "image-primary", "保存文本 profile 不应重置图片 profile");
|
assert(afterTextSaveStatusPayload?.config?.image_profile_name === "image-primary", "保存文本 profile 不应重置图片 profile");
|
||||||
@@ -1916,6 +1980,59 @@ async function run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enablePassthroughResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ stream_action: "passthrough" }),
|
||||||
|
});
|
||||||
|
const enablePassthroughPayload = await enablePassthroughResponse.json();
|
||||||
|
assert(enablePassthroughResponse.status === 200, `启用 SSE 直通模式失败: ${enablePassthroughResponse.status}`);
|
||||||
|
assert(enablePassthroughPayload?.config?.stream_action === "passthrough", "当前配置未切换到 SSE 直通模式");
|
||||||
|
|
||||||
|
const passthroughThreadId = "thread_sse_passthrough";
|
||||||
|
const passthroughRetryKey = reasoningRetryKeyForRequest("/responses", {
|
||||||
|
stream: true,
|
||||||
|
thread_id: passthroughThreadId,
|
||||||
|
test_reasoning_retry_key: "sse-passthrough",
|
||||||
|
});
|
||||||
|
const passthroughStream = await readSseUntilClose(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/responses`,
|
||||||
|
{
|
||||||
|
stream: true,
|
||||||
|
thread_id: passthroughThreadId,
|
||||||
|
test_reasoning_before_success_times: 1,
|
||||||
|
test_reasoning_retry_key: "sse-passthrough",
|
||||||
|
test_stream_include_lifecycle: true,
|
||||||
|
test_stream_lifecycle_marker: "passthrough-raw-marker",
|
||||||
|
test_stream_delta_chunks: 2,
|
||||||
|
test_stream_chunk_delay_ms: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert(passthroughStream.status === 200, `SSE 直通状态异常: ${passthroughStream.status}`);
|
||||||
|
assert(passthroughStream.headers.get("x-upstream-reasoning-attempt") === "1", "SSE 直通不应发起 reasoning 重试");
|
||||||
|
assert(passthroughStream.text.includes("passthrough-raw-marker"), "SSE 直通不应改写上游生命周期 payload");
|
||||||
|
assert(passthroughStream.text.includes('"reasoning_tokens":516'), "SSE 直通不应拦截匹配的 reasoning 响应");
|
||||||
|
const passthroughRetryStats = upstream.getReasoningRetryStat(passthroughRetryKey);
|
||||||
|
assert(passthroughRetryStats.totalRequests === 1, "SSE 直通不应启动多轮 reasoning 请求");
|
||||||
|
const passthroughRequestsResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(passthroughThreadId)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
|
);
|
||||||
|
const passthroughRequestsPayload = await passthroughRequestsResponse.json();
|
||||||
|
const passthroughEntry = (passthroughRequestsPayload?.entries || []).find(
|
||||||
|
(entry) => entry.thread_id === passthroughThreadId,
|
||||||
|
);
|
||||||
|
assert(passthroughEntry?.inspected === false, "SSE 直通请求不应标记为已检查");
|
||||||
|
assert(passthroughEntry?.reasoning_retry_stop_reason === "stream_passthrough", "SSE 直通请求未记录跳过重试原因");
|
||||||
|
assert((passthroughEntry?.stream_chunk_count || 0) >= 1, "SSE 直通请求未累计流式 chunk");
|
||||||
|
|
||||||
|
const restoreStrictStreamResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ stream_action: "strict_502" }),
|
||||||
|
});
|
||||||
|
assert(restoreStrictStreamResponse.status === 200, "测试后恢复严格 SSE 模式失败");
|
||||||
|
|
||||||
const capturedLifecycleStream = await readSseUntilClose(
|
const capturedLifecycleStream = await readSseUntilClose(
|
||||||
`http://127.0.0.1:${gatewayPort}/responses`,
|
`http://127.0.0.1:${gatewayPort}/responses`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { getGatewayBaseUrl } from "./admin-lib.mjs";
|
||||||
|
|
||||||
const scriptsRoot = import.meta.dirname;
|
const scriptsRoot = import.meta.dirname;
|
||||||
const launchScript = path.join(scriptsRoot, "launch-ui.sh");
|
const launchScript = path.join(scriptsRoot, "launch-ui.sh");
|
||||||
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
|
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
|
||||||
@@ -122,6 +124,10 @@ async function run() {
|
|||||||
const gatewayPort = await getFreePort();
|
const gatewayPort = await getFreePort();
|
||||||
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
|
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||||
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
assert(
|
||||||
|
getGatewayBaseUrl("0.0.0.0", gatewayPort) === gatewayBaseUrl,
|
||||||
|
"Wildcard listener must normalize to loopback for local gateway URLs",
|
||||||
|
);
|
||||||
|
|
||||||
await mkdir(codexDir, { recursive: true });
|
await mkdir(codexDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
|||||||
type Tone = "" | "success" | "error";
|
type Tone = "" | "success" | "error";
|
||||||
type ReasoningMatchMode = "formula_518n_minus_2" | "manual";
|
type ReasoningMatchMode = "formula_518n_minus_2" | "manual";
|
||||||
type AuthMode = "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json";
|
type AuthMode = "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json";
|
||||||
|
type StreamAction = "strict_502" | "disconnect" | "passthrough";
|
||||||
|
|
||||||
type GatewayConfig = {
|
type GatewayConfig = {
|
||||||
profile_name?: string;
|
profile_name?: string;
|
||||||
@@ -30,6 +31,7 @@ type GatewayConfig = {
|
|||||||
upstream_fetch_retry_attempts?: number;
|
upstream_fetch_retry_attempts?: number;
|
||||||
upstream_fetch_retry_backoff_ms?: number;
|
upstream_fetch_retry_backoff_ms?: number;
|
||||||
non_stream_status_code?: number;
|
non_stream_status_code?: number;
|
||||||
|
stream_action?: StreamAction;
|
||||||
log_match?: boolean;
|
log_match?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,6 +192,7 @@ type ProfileFormModel = {
|
|||||||
retryable_error_messages?: string[];
|
retryable_error_messages?: string[];
|
||||||
upstream_fetch_retry_attempts?: string;
|
upstream_fetch_retry_attempts?: string;
|
||||||
upstream_fetch_retry_backoff_ms?: string;
|
upstream_fetch_retry_backoff_ms?: string;
|
||||||
|
stream_action?: StreamAction;
|
||||||
endpoints?: string[];
|
endpoints?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,6 +210,7 @@ type Profile = {
|
|||||||
model_remap?: string;
|
model_remap?: string;
|
||||||
reasoning_match_mode?: ReasoningMatchMode;
|
reasoning_match_mode?: ReasoningMatchMode;
|
||||||
reasoning_equals?: string;
|
reasoning_equals?: string;
|
||||||
|
stream_action?: StreamAction;
|
||||||
};
|
};
|
||||||
form?: ProfileFormModel;
|
form?: ProfileFormModel;
|
||||||
};
|
};
|
||||||
@@ -321,6 +325,7 @@ type ProfileFormState = {
|
|||||||
retryable_error_messages: string;
|
retryable_error_messages: string;
|
||||||
upstream_fetch_retry_attempts: string;
|
upstream_fetch_retry_attempts: string;
|
||||||
upstream_fetch_retry_backoff_ms: string;
|
upstream_fetch_retry_backoff_ms: string;
|
||||||
|
stream_action: StreamAction;
|
||||||
endpoints: string;
|
endpoints: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,6 +351,7 @@ type RuleFormState = {
|
|||||||
upstream_fetch_retry_backoff_ms: string;
|
upstream_fetch_retry_backoff_ms: string;
|
||||||
endpoints: string;
|
endpoints: string;
|
||||||
non_stream_status_code: string;
|
non_stream_status_code: string;
|
||||||
|
stream_action: StreamAction;
|
||||||
log_match: boolean;
|
log_match: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -454,6 +460,7 @@ const defaultProfileForm: ProfileFormState = {
|
|||||||
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",
|
||||||
upstream_fetch_retry_attempts: "5",
|
upstream_fetch_retry_attempts: "5",
|
||||||
upstream_fetch_retry_backoff_ms: "350",
|
upstream_fetch_retry_backoff_ms: "350",
|
||||||
|
stream_action: "strict_502",
|
||||||
endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions",
|
endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -694,6 +701,24 @@ function normalizeReasoningMode(value: unknown): ReasoningMatchMode {
|
|||||||
return value === "manual" ? "manual" : "formula_518n_minus_2";
|
return value === "manual" ? "manual" : "formula_518n_minus_2";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStreamAction(value: unknown): StreamAction {
|
||||||
|
if (value === "disconnect" || value === "passthrough") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "strict_502";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStreamAction(value: unknown) {
|
||||||
|
const action = normalizeStreamAction(value);
|
||||||
|
if (action === "passthrough") {
|
||||||
|
return "直通";
|
||||||
|
}
|
||||||
|
if (action === "disconnect") {
|
||||||
|
return "透传后断开";
|
||||||
|
}
|
||||||
|
return "严格检查";
|
||||||
|
}
|
||||||
|
|
||||||
function formatReasoningMode(mode: ReasoningMatchMode) {
|
function formatReasoningMode(mode: ReasoningMatchMode) {
|
||||||
return mode === "manual" ? "manual" : "518n-2";
|
return mode === "manual" ? "manual" : "518n-2";
|
||||||
}
|
}
|
||||||
@@ -782,6 +807,7 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
|||||||
upstream_fetch_retry_backoff_ms: String(
|
upstream_fetch_retry_backoff_ms: String(
|
||||||
config.upstream_fetch_retry_backoff_ms ?? defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
config.upstream_fetch_retry_backoff_ms ?? defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
||||||
),
|
),
|
||||||
|
stream_action: normalizeStreamAction(config.stream_action),
|
||||||
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints,
|
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -814,6 +840,7 @@ function profileFormFromProfile(profile: Profile): ProfileFormState {
|
|||||||
form.upstream_fetch_retry_attempts || defaultProfileForm.upstream_fetch_retry_attempts,
|
form.upstream_fetch_retry_attempts || defaultProfileForm.upstream_fetch_retry_attempts,
|
||||||
upstream_fetch_retry_backoff_ms:
|
upstream_fetch_retry_backoff_ms:
|
||||||
form.upstream_fetch_retry_backoff_ms || defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
form.upstream_fetch_retry_backoff_ms || defaultProfileForm.upstream_fetch_retry_backoff_ms,
|
||||||
|
stream_action: normalizeStreamAction(form.stream_action),
|
||||||
endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "",
|
endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -866,6 +893,7 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
|||||||
),
|
),
|
||||||
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "",
|
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "",
|
||||||
non_stream_status_code: String(config.non_stream_status_code || 502),
|
non_stream_status_code: String(config.non_stream_status_code || 502),
|
||||||
|
stream_action: normalizeStreamAction(config.stream_action),
|
||||||
log_match: Boolean(config.log_match),
|
log_match: Boolean(config.log_match),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1168,6 +1196,7 @@ export default function App() {
|
|||||||
upstream_fetch_retry_backoff_ms: Number.parseInt(ruleForm.upstream_fetch_retry_backoff_ms, 10),
|
upstream_fetch_retry_backoff_ms: Number.parseInt(ruleForm.upstream_fetch_retry_backoff_ms, 10),
|
||||||
endpoints: splitLines(ruleForm.endpoints),
|
endpoints: splitLines(ruleForm.endpoints),
|
||||||
non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10),
|
non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10),
|
||||||
|
stream_action: ruleForm.stream_action,
|
||||||
log_match: ruleForm.log_match,
|
log_match: ruleForm.log_match,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1276,6 +1305,7 @@ export default function App() {
|
|||||||
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
|
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
|
||||||
upstream_fetch_retry_attempts: Number.parseInt(profileForm.upstream_fetch_retry_attempts, 10),
|
upstream_fetch_retry_attempts: Number.parseInt(profileForm.upstream_fetch_retry_attempts, 10),
|
||||||
upstream_fetch_retry_backoff_ms: Number.parseInt(profileForm.upstream_fetch_retry_backoff_ms, 10),
|
upstream_fetch_retry_backoff_ms: Number.parseInt(profileForm.upstream_fetch_retry_backoff_ms, 10),
|
||||||
|
stream_action: profileForm.stream_action,
|
||||||
endpoints: splitLines(profileForm.endpoints),
|
endpoints: splitLines(profileForm.endpoints),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -2202,6 +2232,7 @@ export default function App() {
|
|||||||
label="Rule Mode"
|
label="Rule Mode"
|
||||||
value={formatReasoningMode(normalizeReasoningMode(profile.summary?.reasoning_match_mode))}
|
value={formatReasoningMode(normalizeReasoningMode(profile.summary?.reasoning_match_mode))}
|
||||||
/>
|
/>
|
||||||
|
<MiniStat label="SSE" value={formatStreamAction(profile.summary?.stream_action)} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
@@ -2388,6 +2419,16 @@ export default function App() {
|
|||||||
<input type="number" min={0} step={50} value={profileForm.upstream_fetch_retry_backoff_ms} onChange={(event) => setProfileForm({ ...profileForm, upstream_fetch_retry_backoff_ms: event.target.value })} />
|
<input type="number" min={0} step={50} value={profileForm.upstream_fetch_retry_backoff_ms} onChange={(event) => setProfileForm({ ...profileForm, upstream_fetch_retry_backoff_ms: event.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="stream_action" hint="passthrough 对 stream:true 请求关闭 reasoning retry;若上游返回 text/event-stream,会把原始 chunk 直接转发,不解析、缓存或规范化。">
|
||||||
|
<select
|
||||||
|
value={profileForm.stream_action}
|
||||||
|
onChange={(event) => setProfileForm({ ...profileForm, stream_action: normalizeStreamAction(event.target.value) })}
|
||||||
|
>
|
||||||
|
<option value="strict_502">strict_502:完整检查后回放</option>
|
||||||
|
<option value="disconnect">disconnect:边转发边检查,命中后断开</option>
|
||||||
|
<option value="passthrough">passthrough:SSE 原样直通</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="endpoints">
|
<Field label="endpoints">
|
||||||
<textarea value={profileForm.endpoints} onChange={(event) => setProfileForm({ ...profileForm, endpoints: event.target.value })} />
|
<textarea value={profileForm.endpoints} onChange={(event) => setProfileForm({ ...profileForm, endpoints: event.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -2717,6 +2758,16 @@ export default function App() {
|
|||||||
命中时写日志
|
命中时写日志
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="stream_action" hint="passthrough 会立即使当前实例的 SSE 原样直通;若要让重启或切换 profile 后仍保持,请在 Profiles 页保存该字段。">
|
||||||
|
<select
|
||||||
|
value={ruleForm.stream_action}
|
||||||
|
onChange={(event) => setRuleForm({ ...ruleForm, stream_action: normalizeStreamAction(event.target.value) })}
|
||||||
|
>
|
||||||
|
<option value="strict_502">strict_502:完整检查后回放</option>
|
||||||
|
<option value="disconnect">disconnect:边转发边检查,命中后断开</option>
|
||||||
|
<option value="passthrough">passthrough:SSE 原样直通</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<button className="primary" type="submit">
|
<button className="primary" type="submit">
|
||||||
保存并立即生效
|
保存并立即生效
|
||||||
|
|||||||
Reference in New Issue
Block a user