@lcv-ideas-software/cross-review 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2568 -0
- package/LICENSE +201 -0
- package/NOTICE +26 -0
- package/README.md +208 -0
- package/SECURITY.md +52 -0
- package/dist/scripts/api-streaming-smoke.d.ts +1 -0
- package/dist/scripts/api-streaming-smoke.js +78 -0
- package/dist/scripts/api-streaming-smoke.js.map +1 -0
- package/dist/scripts/runtime-default-smoke.d.ts +1 -0
- package/dist/scripts/runtime-default-smoke.js +88 -0
- package/dist/scripts/runtime-default-smoke.js.map +1 -0
- package/dist/scripts/runtime-smoke.d.ts +1 -0
- package/dist/scripts/runtime-smoke.js +148 -0
- package/dist/scripts/runtime-smoke.js.map +1 -0
- package/dist/scripts/smoke.d.ts +1 -0
- package/dist/scripts/smoke.js +6156 -0
- package/dist/scripts/smoke.js.map +1 -0
- package/dist/src/core/cache-manifest.d.ts +22 -0
- package/dist/src/core/cache-manifest.js +133 -0
- package/dist/src/core/cache-manifest.js.map +1 -0
- package/dist/src/core/caller-tokens.d.ts +32 -0
- package/dist/src/core/caller-tokens.js +240 -0
- package/dist/src/core/caller-tokens.js.map +1 -0
- package/dist/src/core/config.d.ts +9 -0
- package/dist/src/core/config.js +643 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/convergence.d.ts +5 -0
- package/dist/src/core/convergence.js +186 -0
- package/dist/src/core/convergence.js.map +1 -0
- package/dist/src/core/cost.d.ts +59 -0
- package/dist/src/core/cost.js +359 -0
- package/dist/src/core/cost.js.map +1 -0
- package/dist/src/core/file-config.d.ts +316 -0
- package/dist/src/core/file-config.js +490 -0
- package/dist/src/core/file-config.js.map +1 -0
- package/dist/src/core/orchestrator.d.ts +199 -0
- package/dist/src/core/orchestrator.js +3430 -0
- package/dist/src/core/orchestrator.js.map +1 -0
- package/dist/src/core/prompt-parts.d.ts +58 -0
- package/dist/src/core/prompt-parts.js +122 -0
- package/dist/src/core/prompt-parts.js.map +1 -0
- package/dist/src/core/relator-lottery.d.ts +23 -0
- package/dist/src/core/relator-lottery.js +112 -0
- package/dist/src/core/relator-lottery.js.map +1 -0
- package/dist/src/core/reports.d.ts +2 -0
- package/dist/src/core/reports.js +82 -0
- package/dist/src/core/reports.js.map +1 -0
- package/dist/src/core/session-store.d.ts +149 -0
- package/dist/src/core/session-store.js +1923 -0
- package/dist/src/core/session-store.js.map +1 -0
- package/dist/src/core/status.d.ts +61 -0
- package/dist/src/core/status.js +249 -0
- package/dist/src/core/status.js.map +1 -0
- package/dist/src/core/timeouts.d.ts +2 -0
- package/dist/src/core/timeouts.js +3 -0
- package/dist/src/core/timeouts.js.map +1 -0
- package/dist/src/core/types.d.ts +604 -0
- package/dist/src/core/types.js +36 -0
- package/dist/src/core/types.js.map +1 -0
- package/dist/src/dashboard/server.d.ts +2 -0
- package/dist/src/dashboard/server.js +339 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/mcp/server.d.ts +54 -0
- package/dist/src/mcp/server.js +1584 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/observability/logger.d.ts +9 -0
- package/dist/src/observability/logger.js +24 -0
- package/dist/src/observability/logger.js.map +1 -0
- package/dist/src/peers/anthropic.d.ts +14 -0
- package/dist/src/peers/anthropic.js +290 -0
- package/dist/src/peers/anthropic.js.map +1 -0
- package/dist/src/peers/base.d.ts +72 -0
- package/dist/src/peers/base.js +416 -0
- package/dist/src/peers/base.js.map +1 -0
- package/dist/src/peers/deepseek.d.ts +12 -0
- package/dist/src/peers/deepseek.js +246 -0
- package/dist/src/peers/deepseek.js.map +1 -0
- package/dist/src/peers/errors.d.ts +2 -0
- package/dist/src/peers/errors.js +185 -0
- package/dist/src/peers/errors.js.map +1 -0
- package/dist/src/peers/gemini.d.ts +13 -0
- package/dist/src/peers/gemini.js +215 -0
- package/dist/src/peers/gemini.js.map +1 -0
- package/dist/src/peers/grok.d.ts +17 -0
- package/dist/src/peers/grok.js +346 -0
- package/dist/src/peers/grok.js.map +1 -0
- package/dist/src/peers/model-selection.d.ts +4 -0
- package/dist/src/peers/model-selection.js +260 -0
- package/dist/src/peers/model-selection.js.map +1 -0
- package/dist/src/peers/openai.d.ts +14 -0
- package/dist/src/peers/openai.js +299 -0
- package/dist/src/peers/openai.js.map +1 -0
- package/dist/src/peers/perplexity.d.ts +18 -0
- package/dist/src/peers/perplexity.js +375 -0
- package/dist/src/peers/perplexity.js.map +1 -0
- package/dist/src/peers/registry.d.ts +3 -0
- package/dist/src/peers/registry.js +77 -0
- package/dist/src/peers/registry.js.map +1 -0
- package/dist/src/peers/retry.d.ts +2 -0
- package/dist/src/peers/retry.js +36 -0
- package/dist/src/peers/retry.js.map +1 -0
- package/dist/src/peers/stub.d.ts +13 -0
- package/dist/src/peers/stub.js +344 -0
- package/dist/src/peers/stub.js.map +1 -0
- package/dist/src/peers/text.d.ts +18 -0
- package/dist/src/peers/text.js +39 -0
- package/dist/src/peers/text.js.map +1 -0
- package/dist/src/security/redact.d.ts +2 -0
- package/dist/src/security/redact.js +128 -0
- package/dist/src/security/redact.js.map +1 -0
- package/docs/api-keys.md +34 -0
- package/docs/architecture.md +118 -0
- package/docs/caching.md +135 -0
- package/docs/costs.md +40 -0
- package/docs/evidence-preflight.md +88 -0
- package/docs/github-security-baseline.md +32 -0
- package/docs/model-selection.md +105 -0
- package/docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md +354 -0
- package/docs/reports/cross-review-v2-format-recovery-findings-2026-04-28.md +223 -0
- package/docs/reports/cross-review-v2-official-provider-docs-refresh-2026-05-05.md +60 -0
- package/docs/reports/cross-review-v2-token-streaming-smoke-2026-04-30.md +119 -0
- package/package.json +88 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { applyFileConfigToEnv } from "./file-config.js";
|
|
6
|
+
// v2.4.0 / audit closure (P3.12): tilde expansion for env-provided paths.
|
|
7
|
+
// `path.resolve` does NOT expand `~` to the user's home directory on any
|
|
8
|
+
// platform — operators routinely write `~/sessions` in env files and end
|
|
9
|
+
// up with a literal `~` directory. We honor `~`, `~/...`, and `~\...`
|
|
10
|
+
// (Windows) before resolving. The shell's `~user` syntax is intentionally
|
|
11
|
+
// NOT supported because it would require a passwd lookup.
|
|
12
|
+
function expandHome(rawPath) {
|
|
13
|
+
if (rawPath === "~")
|
|
14
|
+
return os.homedir();
|
|
15
|
+
if (rawPath.startsWith("~/") || rawPath.startsWith("~\\")) {
|
|
16
|
+
return path.join(os.homedir(), rawPath.slice(2));
|
|
17
|
+
}
|
|
18
|
+
return rawPath;
|
|
19
|
+
}
|
|
20
|
+
export const VERSION = "4.0.0";
|
|
21
|
+
export const RELEASE_DATE = "2026-05-15";
|
|
22
|
+
export const DEFAULT_MAX_OUTPUT_TOKENS = 20_000;
|
|
23
|
+
const COST_RATE_ENV_PREFIX = {
|
|
24
|
+
codex: "CROSS_REVIEW_OPENAI",
|
|
25
|
+
claude: "CROSS_REVIEW_ANTHROPIC",
|
|
26
|
+
gemini: "CROSS_REVIEW_GEMINI",
|
|
27
|
+
deepseek: "CROSS_REVIEW_DEEPSEEK",
|
|
28
|
+
// v2.14.0: Grok pricing via env (no hardcoded defaults; operator
|
|
29
|
+
// populates `CROSS_REVIEW_GROK_INPUT_USD_PER_MILLION` +
|
|
30
|
+
// `CROSS_REVIEW_GROK_OUTPUT_USD_PER_MILLION`).
|
|
31
|
+
grok: "CROSS_REVIEW_GROK",
|
|
32
|
+
// v3.0.0: Perplexity pricing via env. Sonar API bills both per-token
|
|
33
|
+
// (INPUT/OUTPUT) AND per-1000-requests where the fee scales with
|
|
34
|
+
// search_context_size (REQUEST_FEE_LOW/MEDIUM/HIGH). Sonar Deep
|
|
35
|
+
// Research model additionally bills citation_tokens, reasoning_tokens
|
|
36
|
+
// and search_queries — those fields are optional and left undefined
|
|
37
|
+
// for the other Perplexity models.
|
|
38
|
+
perplexity: "CROSS_REVIEW_PERPLEXITY",
|
|
39
|
+
};
|
|
40
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
41
|
+
const __dirname = path.dirname(__filename);
|
|
42
|
+
const RUNTIME_ROOT = path.resolve(__dirname, "..", "..");
|
|
43
|
+
const PROJECT_ROOT = path.basename(RUNTIME_ROOT).toLowerCase() === "dist"
|
|
44
|
+
? path.resolve(RUNTIME_ROOT, "..")
|
|
45
|
+
: RUNTIME_ROOT;
|
|
46
|
+
// v2.28.0 (cold-start hardening Part 3): single bulk read of the Windows
|
|
47
|
+
// registry environment scopes at first miss, then pure Map lookups. The
|
|
48
|
+
// previous per-var `reg query <root> /v NAME` design fired 2 subprocesses
|
|
49
|
+
// per missing env var (HKCU + HKLM, ~30 ms each). With ~140 config env
|
|
50
|
+
// vars consulted per `loadConfig()` and only some present in
|
|
51
|
+
// `process.env`, the missing-var fallback alone consumed 3-7 seconds of
|
|
52
|
+
// boot time on Windows — the dominant cost outside MCP SDK module load.
|
|
53
|
+
// Caching makes the cost O(1 + 2 registry reads) instead of O(N missing
|
|
54
|
+
// × 2 spawns). The cache is populated on the first call that would have
|
|
55
|
+
// gone to the registry; if `process.env` already has every var, it is
|
|
56
|
+
// never populated. Cross-process cache isolation is preserved (each Node
|
|
57
|
+
// process has its own).
|
|
58
|
+
let _winRegistryEnvCache = null;
|
|
59
|
+
// v3.7.0 (AUDIT-6, Codex super-audit 2026-05-14): cross-review's
|
|
60
|
+
// "API-only" claim means it does NOT execute caller-supplied shell or
|
|
61
|
+
// repo commands — it is not a CLI runner and never shells out on behalf
|
|
62
|
+
// of a caller. It DOES make a small number of fixed, internal process
|
|
63
|
+
// calls with constant arguments (this `reg query` for the Windows
|
|
64
|
+
// env-var fallback; `tasklist` for process-tree introspection in
|
|
65
|
+
// caller-tokens). Those args are constants or PID-derived, never
|
|
66
|
+
// caller-influenced. The precise statement is "no caller-supplied
|
|
67
|
+
// shell/repo execution", not "no child processes at all".
|
|
68
|
+
function loadWindowsRegistryEnvCache() {
|
|
69
|
+
if (_winRegistryEnvCache)
|
|
70
|
+
return _winRegistryEnvCache;
|
|
71
|
+
const cache = new Map();
|
|
72
|
+
const roots = [
|
|
73
|
+
"HKCU\\Environment",
|
|
74
|
+
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
|
75
|
+
];
|
|
76
|
+
// HKCU wins over HKLM on collision (per Windows env-resolution order),
|
|
77
|
+
// so populate HKLM first and overwrite with HKCU last.
|
|
78
|
+
for (const root of [roots[1], roots[0]]) {
|
|
79
|
+
try {
|
|
80
|
+
const output = execFileSync("reg", ["query", root], {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
// `reg query <root>` emits one line per value:
|
|
86
|
+
// <whitespace><Name><whitespace>REG_<TYPE><whitespace><Value>
|
|
87
|
+
// Header lines (the root path itself) and blank lines are
|
|
88
|
+
// filtered by the REG_<TYPE> token requirement.
|
|
89
|
+
for (const line of output.split(/\r?\n/)) {
|
|
90
|
+
const match = line.match(/^\s*([^\s]+)\s+REG_(?:SZ|EXPAND_SZ|MULTI_SZ|DWORD|QWORD|BINARY)\s+(.*)$/);
|
|
91
|
+
if (!match)
|
|
92
|
+
continue;
|
|
93
|
+
const [, name, rawValue] = match;
|
|
94
|
+
if (!name || rawValue == null)
|
|
95
|
+
continue;
|
|
96
|
+
cache.set(name, rawValue.trim());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Missing root is unusual but not fatal — env-var lookups simply
|
|
101
|
+
// fall back to `undefined` after the cache miss.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
_winRegistryEnvCache = cache;
|
|
105
|
+
return cache;
|
|
106
|
+
}
|
|
107
|
+
function readWindowsRegistryEnv(name) {
|
|
108
|
+
if (process.platform !== "win32")
|
|
109
|
+
return undefined;
|
|
110
|
+
return loadWindowsRegistryEnvCache().get(name);
|
|
111
|
+
}
|
|
112
|
+
function envValue(name) {
|
|
113
|
+
const processValue = process.env[name];
|
|
114
|
+
if (processValue)
|
|
115
|
+
return processValue;
|
|
116
|
+
const registryValue = readWindowsRegistryEnv(name);
|
|
117
|
+
if (registryValue) {
|
|
118
|
+
process.env[name] = registryValue;
|
|
119
|
+
return registryValue;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function boolEnv(name, fallback = false) {
|
|
124
|
+
const value = envValue(name);
|
|
125
|
+
if (value == null || value === "")
|
|
126
|
+
return fallback;
|
|
127
|
+
return /^(1|true|yes|on)$/i.test(value);
|
|
128
|
+
}
|
|
129
|
+
function intEnv(name, fallback) {
|
|
130
|
+
const parsed = Number.parseInt(envValue(name) ?? "", 10);
|
|
131
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
132
|
+
}
|
|
133
|
+
function numberEnv(name) {
|
|
134
|
+
const parsed = Number.parseFloat(envValue(name) ?? "");
|
|
135
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
136
|
+
}
|
|
137
|
+
function listEnv(name) {
|
|
138
|
+
return (envValue(name) ?? "")
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((value) => value.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
function keyForPeer(peer) {
|
|
144
|
+
switch (peer) {
|
|
145
|
+
case "codex":
|
|
146
|
+
return envValue("OPENAI_API_KEY");
|
|
147
|
+
case "claude":
|
|
148
|
+
return envValue("ANTHROPIC_API_KEY");
|
|
149
|
+
case "gemini":
|
|
150
|
+
return envValue("GEMINI_API_KEY");
|
|
151
|
+
case "deepseek":
|
|
152
|
+
return envValue("DEEPSEEK_API_KEY");
|
|
153
|
+
// v2.14.0: Grok auth via GROK_API_KEY (canonical, operator-corrected
|
|
154
|
+
// 2026-05-04 — peer name is "grok" not "xai", env var follows).
|
|
155
|
+
case "grok":
|
|
156
|
+
return envValue("GROK_API_KEY");
|
|
157
|
+
// v3.0.0: Perplexity auth via PERPLEXITY_API_KEY (canonical per
|
|
158
|
+
// docs.perplexity.ai). The Sonar API accepts the key as a Bearer
|
|
159
|
+
// token in the Authorization header.
|
|
160
|
+
case "perplexity":
|
|
161
|
+
return envValue("PERPLEXITY_API_KEY");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function reasoningEffort(name, fallback) {
|
|
165
|
+
const value = envValue(name);
|
|
166
|
+
if (!value)
|
|
167
|
+
return fallback;
|
|
168
|
+
if (/^(none|minimal|low|medium|high|xhigh|max)$/i.test(value)) {
|
|
169
|
+
return value.toLowerCase();
|
|
170
|
+
}
|
|
171
|
+
return fallback;
|
|
172
|
+
}
|
|
173
|
+
// v3.1.0: telemetry of the file-config load attempt; readers can call
|
|
174
|
+
// `getLastFileConfigResult()` to surface a boot notice or expose it via
|
|
175
|
+
// server_info. Not part of AppConfig (intentionally) so existing
|
|
176
|
+
// snapshots remain backward-compatible.
|
|
177
|
+
let LAST_FILE_CONFIG_RESULT;
|
|
178
|
+
export function getLastFileConfigResult() {
|
|
179
|
+
return LAST_FILE_CONFIG_RESULT;
|
|
180
|
+
}
|
|
181
|
+
export function loadConfig() {
|
|
182
|
+
const configuredDataDir = envValue("CROSS_REVIEW_DATA_DIR");
|
|
183
|
+
const dataDir = configuredDataDir
|
|
184
|
+
? path.resolve(expandHome(configuredDataDir))
|
|
185
|
+
: path.join(PROJECT_ROOT, "data");
|
|
186
|
+
// v3.1.0 central config file: hydrate `process.env` with values from
|
|
187
|
+
// `${dataDir}/config.json` (or path overridden via
|
|
188
|
+
// CROSS_REVIEW_CONFIG_FILE) BEFORE any of the per-field readers
|
|
189
|
+
// below consult envValue(). The file's contribution is a default
|
|
190
|
+
// layer: env (process.env + Windows registry) wins, file second,
|
|
191
|
+
// hardcoded defaults last. See src/core/file-config.ts for the
|
|
192
|
+
// mapping table from structured JSON fields to flat env-var names.
|
|
193
|
+
LAST_FILE_CONFIG_RESULT = applyFileConfigToEnv(dataDir, envValue);
|
|
194
|
+
return {
|
|
195
|
+
version: VERSION,
|
|
196
|
+
data_dir: dataDir,
|
|
197
|
+
log_level: envValue("CROSS_REVIEW_LOG_LEVEL") || "info",
|
|
198
|
+
stub: boolEnv("CROSS_REVIEW_STUB", false),
|
|
199
|
+
dashboard_port: intEnv("CROSS_REVIEW_DASHBOARD_PORT", 4588),
|
|
200
|
+
retry: {
|
|
201
|
+
max_attempts: intEnv("CROSS_REVIEW_RETRY_ATTEMPTS", 3),
|
|
202
|
+
base_delay_ms: intEnv("CROSS_REVIEW_RETRY_BASE_MS", 1000),
|
|
203
|
+
max_delay_ms: intEnv("CROSS_REVIEW_RETRY_MAX_MS", 30000),
|
|
204
|
+
timeout_ms: intEnv("CROSS_REVIEW_TIMEOUT_MS", 30 * 60 * 1000),
|
|
205
|
+
},
|
|
206
|
+
budget: {
|
|
207
|
+
max_session_cost_usd: numberEnv("CROSS_REVIEW_MAX_SESSION_COST_USD"),
|
|
208
|
+
until_stopped_max_cost_usd: numberEnv("CROSS_REVIEW_UNTIL_STOPPED_MAX_COST_USD"),
|
|
209
|
+
preflight_max_round_cost_usd: numberEnv("CROSS_REVIEW_PREFLIGHT_MAX_ROUND_COST_USD"),
|
|
210
|
+
require_rates_for_budget: true,
|
|
211
|
+
// v2.5.0: configurable fallback for run_until_unanimous when the
|
|
212
|
+
// caller does not pass `max_rounds` and `until_stopped` is false.
|
|
213
|
+
// The MCP zod schema caps caller-supplied `max_rounds` at 1000
|
|
214
|
+
// (v3.7.0 / AUDIT-5: corrected stale "32" in this comment); this
|
|
215
|
+
// controls the SERVER-side default (previously hardcoded to 8 in
|
|
216
|
+
// orchestrator.ts). Values <=0 fall back to 8.
|
|
217
|
+
default_max_rounds: intEnv("CROSS_REVIEW_DEFAULT_MAX_ROUNDS", 8),
|
|
218
|
+
// v2.25.0 (circular mode): maximum number of full rotations
|
|
219
|
+
// permitted in a `mode: "circular"` session before the runtime
|
|
220
|
+
// aborts with `circular_max_rotations_exceeded`. A "rotation" is
|
|
221
|
+
// `rotation_order.length` rounds (one turn per non-caller peer).
|
|
222
|
+
// Default 3 maps to 12 rounds for a 4-peer panel (caller=peer)
|
|
223
|
+
// or 15 rounds for a 5-peer panel (caller=operator) — large
|
|
224
|
+
// enough that a well-behaved artifact converges, small enough
|
|
225
|
+
// that runaway revisions abort within reasonable budget.
|
|
226
|
+
// Empirical anchor: maestro-app circular sessions historically
|
|
227
|
+
// converged within 2 rotations; 3 gives one safety margin.
|
|
228
|
+
circular_max_rotations: intEnv("CROSS_REVIEW_CIRCULAR_MAX_ROTATIONS", 3),
|
|
229
|
+
},
|
|
230
|
+
prompt: {
|
|
231
|
+
max_task_chars: intEnv("CROSS_REVIEW_MAX_TASK_CHARS", 8_000),
|
|
232
|
+
max_review_focus_chars: intEnv("CROSS_REVIEW_MAX_REVIEW_FOCUS_CHARS", 2_000),
|
|
233
|
+
max_history_chars: intEnv("CROSS_REVIEW_MAX_HISTORY_CHARS", 20_000),
|
|
234
|
+
max_draft_chars: intEnv("CROSS_REVIEW_MAX_DRAFT_CHARS", 40_000),
|
|
235
|
+
max_prior_rounds: intEnv("CROSS_REVIEW_MAX_PRIOR_ROUNDS", 5),
|
|
236
|
+
max_peer_requests: intEnv("CROSS_REVIEW_MAX_PEER_REQUESTS", 8),
|
|
237
|
+
// v2.14.0 (path-A structural fix): see AppConfig type docs.
|
|
238
|
+
// v2.26.1 (2026-05-12): default raised 80_000 → 200_000 after the
|
|
239
|
+
// stepsecurity v0.2.0 ship empirically demonstrated that 80K is
|
|
240
|
+
// too low for multi-file evidence sets. session-store.ts:1507
|
|
241
|
+
// computes `perFileCap = max(2_000, floor(totalCap * 0.6))`, then
|
|
242
|
+
// each attachment consumes `min(perFileCap, totalCap - used)`. With
|
|
243
|
+
// 5 attachments totaling ~95KB, the 4th+ attachments got truncated
|
|
244
|
+
// because the budget was already exhausted (peers reported
|
|
245
|
+
// `truncated to 33273 of 38412 bytes` while the file content
|
|
246
|
+
// had legitimate 38KB). 200_000 default accommodates ~5 attachments
|
|
247
|
+
// averaging ~30KB each before any per-file truncation. Operator
|
|
248
|
+
// can still tune via CROSS_REVIEW_MAX_ATTACHED_EVIDENCE_CHARS.
|
|
249
|
+
max_attached_evidence_chars: intEnv("CROSS_REVIEW_MAX_ATTACHED_EVIDENCE_CHARS", 200_000),
|
|
250
|
+
},
|
|
251
|
+
max_output_tokens: intEnv("CROSS_REVIEW_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS),
|
|
252
|
+
// v3.5.0 (CRV2-4): evidence preflight gate. Default ON — the check
|
|
253
|
+
// is conservative (only trips on a completed-work claim with zero
|
|
254
|
+
// evidence markers) and saves a full multi-round paid cross-review
|
|
255
|
+
// on under-evidenced submissions. Operators set
|
|
256
|
+
// CROSS_REVIEW_EVIDENCE_PREFLIGHT=off to disable.
|
|
257
|
+
evidence_preflight_enabled: boolEnv("CROSS_REVIEW_EVIDENCE_PREFLIGHT", true),
|
|
258
|
+
streaming: {
|
|
259
|
+
events: boolEnv("CROSS_REVIEW_STREAM_EVENTS", true),
|
|
260
|
+
tokens: boolEnv("CROSS_REVIEW_STREAM_TOKENS", true),
|
|
261
|
+
include_text: boolEnv("CROSS_REVIEW_STREAM_TEXT", false),
|
|
262
|
+
},
|
|
263
|
+
models: {
|
|
264
|
+
codex: envValue("CROSS_REVIEW_OPENAI_MODEL") || "gpt-5.5",
|
|
265
|
+
claude: envValue("CROSS_REVIEW_ANTHROPIC_MODEL") || "claude-opus-4-7",
|
|
266
|
+
gemini: envValue("CROSS_REVIEW_GEMINI_MODEL") || "gemini-2.5-pro",
|
|
267
|
+
deepseek: envValue("CROSS_REVIEW_DEEPSEEK_MODEL") || "deepseek-v4-pro",
|
|
268
|
+
// v3.7.2 (AUDIT-3 + operator directive 2026-05-14): grok default
|
|
269
|
+
// pinned to `grok-4-latest` — the operator's chosen "most advanced
|
|
270
|
+
// pro with reasoning" model for cross-review, superseding the
|
|
271
|
+
// prior `grok-4.20-multi-agent` default. The operator may still
|
|
272
|
+
// env-override via CROSS_REVIEW_GROK_MODEL to any xAI model
|
|
273
|
+
// (`grok-4.20-multi-agent` for explicit `reasoning.effort` control,
|
|
274
|
+
// `grok-4.3` / `grok-4.20-reasoning` etc.); the adapter detects the
|
|
275
|
+
// chosen model before deciding whether to send the reasoning field.
|
|
276
|
+
grok: envValue("CROSS_REVIEW_GROK_MODEL") || "grok-4-latest",
|
|
277
|
+
// v3.0.0 (operator directive 2026-05-12): Perplexity default
|
|
278
|
+
// `sonar-reasoning-pro` — reasoning + grounding + chain-of-thought,
|
|
279
|
+
// best fit for cross-review where the peer must reason about the
|
|
280
|
+
// attached draft (not just fact-lookup). Operator can override via
|
|
281
|
+
// CROSS_REVIEW_PERPLEXITY_MODEL to switch to `sonar`, `sonar-pro`,
|
|
282
|
+
// or `sonar-deep-research`. The adapter validates the chosen model
|
|
283
|
+
// against the documented allowlist at call time.
|
|
284
|
+
perplexity: envValue("CROSS_REVIEW_PERPLEXITY_MODEL") || "sonar-reasoning-pro",
|
|
285
|
+
},
|
|
286
|
+
fallback_models: {
|
|
287
|
+
codex: listEnv("CROSS_REVIEW_OPENAI_FALLBACK_MODELS"),
|
|
288
|
+
claude: listEnv("CROSS_REVIEW_ANTHROPIC_FALLBACK_MODELS"),
|
|
289
|
+
gemini: listEnv("CROSS_REVIEW_GEMINI_FALLBACK_MODELS"),
|
|
290
|
+
deepseek: listEnv("CROSS_REVIEW_DEEPSEEK_FALLBACK_MODELS"),
|
|
291
|
+
grok: listEnv("CROSS_REVIEW_GROK_FALLBACK_MODELS"),
|
|
292
|
+
perplexity: listEnv("CROSS_REVIEW_PERPLEXITY_FALLBACK_MODELS"),
|
|
293
|
+
},
|
|
294
|
+
reasoning_effort: {
|
|
295
|
+
codex: reasoningEffort("CROSS_REVIEW_OPENAI_REASONING_EFFORT", "xhigh"),
|
|
296
|
+
claude: reasoningEffort("CROSS_REVIEW_ANTHROPIC_REASONING_EFFORT", "xhigh"),
|
|
297
|
+
deepseek: reasoningEffort("CROSS_REVIEW_DEEPSEEK_REASONING_EFFORT", "max"),
|
|
298
|
+
grok: reasoningEffort("CROSS_REVIEW_GROK_REASONING_EFFORT", "xhigh"),
|
|
299
|
+
// v3.0.0: Perplexity Sonar API only accepts `minimal|low|medium|high`
|
|
300
|
+
// for sonar-reasoning-pro / sonar-deep-research (other models
|
|
301
|
+
// ignore the field entirely). Default `high` matches the
|
|
302
|
+
// canonical "max reasoning per peer" stance the other peers take
|
|
303
|
+
// (xhigh/max for OpenAI/Anthropic/Grok/DeepSeek). The adapter
|
|
304
|
+
// clamps internal scale (`xhigh`/`max`) to `high` for Perplexity.
|
|
305
|
+
perplexity: reasoningEffort("CROSS_REVIEW_PERPLEXITY_REASONING_EFFORT", "high"),
|
|
306
|
+
},
|
|
307
|
+
model_selection: {},
|
|
308
|
+
api_keys: {
|
|
309
|
+
codex: keyForPeer("codex"),
|
|
310
|
+
claude: keyForPeer("claude"),
|
|
311
|
+
gemini: keyForPeer("gemini"),
|
|
312
|
+
deepseek: keyForPeer("deepseek"),
|
|
313
|
+
grok: keyForPeer("grok"),
|
|
314
|
+
perplexity: keyForPeer("perplexity"),
|
|
315
|
+
},
|
|
316
|
+
cost_rates: {
|
|
317
|
+
codex: costRate(COST_RATE_ENV_PREFIX.codex),
|
|
318
|
+
claude: costRate(COST_RATE_ENV_PREFIX.claude),
|
|
319
|
+
gemini: costRate(COST_RATE_ENV_PREFIX.gemini),
|
|
320
|
+
deepseek: costRate(COST_RATE_ENV_PREFIX.deepseek),
|
|
321
|
+
grok: costRate(COST_RATE_ENV_PREFIX.grok),
|
|
322
|
+
perplexity: costRate(COST_RATE_ENV_PREFIX.perplexity),
|
|
323
|
+
},
|
|
324
|
+
evidence_judge_autowire: loadEvidenceJudgeAutowireConfig(),
|
|
325
|
+
peer_enabled: loadPeerEnabledConfig(),
|
|
326
|
+
cache: loadCacheConfig(),
|
|
327
|
+
perplexity: loadPerplexityConfig(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// v3.0.0 (Perplexity 6th peer): per-call Perplexity-specific knobs.
|
|
331
|
+
// `search_context_size` controls the breadth of the web search and
|
|
332
|
+
// drives both quality AND per-1000-request fee (low=$5-6 / medium=$8-10
|
|
333
|
+
// / high=$12-14 depending on model). Default `low` minimizes noise
|
|
334
|
+
// and cost for cross-review use (peer reasons about attached draft;
|
|
335
|
+
// search is a fact-check overlay). `disable_search` turns off the
|
|
336
|
+
// web-search component entirely (peer becomes a pure LLM; pricing
|
|
337
|
+
// reduces to token-based only). Default `false` per operator directive
|
|
338
|
+
// 2026-05-12 — search-active is the differentiator versus the other 5
|
|
339
|
+
// peers.
|
|
340
|
+
function loadPerplexityConfig() {
|
|
341
|
+
const sizeRaw = (envValue("CROSS_REVIEW_PERPLEXITY_SEARCH_CONTEXT_SIZE") ?? "")
|
|
342
|
+
.trim()
|
|
343
|
+
.toLowerCase();
|
|
344
|
+
let searchContextSize = "low";
|
|
345
|
+
if (sizeRaw === "medium" || sizeRaw === "high") {
|
|
346
|
+
searchContextSize = sizeRaw;
|
|
347
|
+
}
|
|
348
|
+
else if (sizeRaw !== "" && sizeRaw !== "low") {
|
|
349
|
+
console.error(`[cross-review] notice: CROSS_REVIEW_PERPLEXITY_SEARCH_CONTEXT_SIZE="${sizeRaw}" not recognized; defaulting to "low". Recognized values: low, medium, high.`);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
search_context_size: searchContextSize,
|
|
353
|
+
disable_search: boolEnv("CROSS_REVIEW_PERPLEXITY_DISABLE_SEARCH", false),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// v2.21.0 (caching): config loader. Default ON; switch off via
|
|
357
|
+
// CROSS_REVIEW_DISABLE_CACHE=true (operator panic button when a
|
|
358
|
+
// provider misbehaves or the operator wants strictly-fresh runs for
|
|
359
|
+
// audit reproducibility). TTL options gated to the documented values
|
|
360
|
+
// to prevent typos silently sending nonsense to providers — Anthropic
|
|
361
|
+
// API rejects unknown ttl values with 400. OpenAI does NOT publish
|
|
362
|
+
// per-call retention values; we still parse the env so future
|
|
363
|
+
// migrations can flip the default without touching adapter code.
|
|
364
|
+
function loadCacheConfig() {
|
|
365
|
+
const enabled = !boolEnv("CROSS_REVIEW_DISABLE_CACHE", false);
|
|
366
|
+
const schemaVersion = (envValue("CROSS_REVIEW_CACHE_SCHEMA_VERSION") ?? "v1").trim() || "v1";
|
|
367
|
+
const anthropicTtl = parseTtlEnv("CROSS_REVIEW_CACHE_TTL_ANTHROPIC", "1h");
|
|
368
|
+
const openaiTtl = parseTtlEnv("CROSS_REVIEW_CACHE_TTL_OPENAI", "1h");
|
|
369
|
+
// v3.7.5 (A3, logs+sessions study 2026-05-15): per-provider cache
|
|
370
|
+
// disable. Default for Anthropic (claude) is `true` (cache off) based
|
|
371
|
+
// on empirical $1.18 wasted to save $0.0035 over 244 sessions
|
|
372
|
+
// (0.3% hit-rate). All other providers default `false` (cache on,
|
|
373
|
+
// preserving v2.21.0 behavior). Operators may flip any per-provider
|
|
374
|
+
// flag via `CROSS_REVIEW_DISABLE_CACHE_<PROVIDER>` (`true|false`).
|
|
375
|
+
// Recognized truthy values match the parser used by peer_enabled:
|
|
376
|
+
// on/true/1/yes/enabled (case-insensitive). Anything else is "off".
|
|
377
|
+
// v3.7.5 (A3): env vars use PROVIDER names (ANTHROPIC/OPENAI/...) matching
|
|
378
|
+
// the v2.21.0 TTL convention (`CROSS_REVIEW_CACHE_TTL_ANTHROPIC` +
|
|
379
|
+
// `CROSS_REVIEW_CACHE_TTL_OPENAI`). Internal `disable_per_peer` is
|
|
380
|
+
// keyed by PeerId (claude/codex/...). Mapping below is the only place
|
|
381
|
+
// provider names cross with peer ids.
|
|
382
|
+
const disablePerPeer = {
|
|
383
|
+
codex: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_OPENAI", false),
|
|
384
|
+
claude: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_ANTHROPIC", true),
|
|
385
|
+
gemini: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_GEMINI", false),
|
|
386
|
+
deepseek: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_DEEPSEEK", false),
|
|
387
|
+
grok: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_GROK", false),
|
|
388
|
+
perplexity: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_PERPLEXITY", false),
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
schema_version: schemaVersion,
|
|
392
|
+
enabled,
|
|
393
|
+
ttl: {
|
|
394
|
+
anthropic: anthropicTtl,
|
|
395
|
+
openai: openaiTtl,
|
|
396
|
+
},
|
|
397
|
+
disable_per_peer: disablePerPeer,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
// v3.7.5 (A3): per-provider cache-disable env var parser. Same shape as
|
|
401
|
+
// the peer_enabled parser but with a per-call default since Anthropic
|
|
402
|
+
// defaults true and others default false.
|
|
403
|
+
function parseDisableCacheEnv(name, fallback) {
|
|
404
|
+
const raw = (envValue(name) ?? "").trim().toLowerCase();
|
|
405
|
+
if (raw === "")
|
|
406
|
+
return fallback;
|
|
407
|
+
if (/^(on|true|1|yes|enabled)$/i.test(raw))
|
|
408
|
+
return true;
|
|
409
|
+
if (/^(off|false|0|no|disabled)$/i.test(raw))
|
|
410
|
+
return false;
|
|
411
|
+
console.error(`[cross-review] notice: ${name}="${raw}" is not recognized; defaulting to "${fallback ? "on" : "off"}". Recognized values: on/true/1/yes/enabled vs off/false/0/no/disabled.`);
|
|
412
|
+
return fallback;
|
|
413
|
+
}
|
|
414
|
+
function parseTtlEnv(name, fallback) {
|
|
415
|
+
const raw = (envValue(name) ?? "").trim().toLowerCase();
|
|
416
|
+
if (raw === "5m" || raw === "1h")
|
|
417
|
+
return raw;
|
|
418
|
+
if (raw !== "") {
|
|
419
|
+
console.error(`[cross-review] notice: ${name}="${raw}" not recognized; defaulting to "${fallback}". Recognized values: 5m, 1h.`);
|
|
420
|
+
}
|
|
421
|
+
return fallback;
|
|
422
|
+
}
|
|
423
|
+
// v2.14.0 (operator directive 2026-05-04): per-peer enable/disable
|
|
424
|
+
// parser. Default `on` for every peer. Recognized truthy values:
|
|
425
|
+
// "on", "true", "1", "yes", "enabled". Recognized falsy: "off",
|
|
426
|
+
// "false", "0", "no", "disabled". Unrecognized values fall back to
|
|
427
|
+
// `on` with a stderr warning so a typo never silently disables a peer.
|
|
428
|
+
// Boot-time minimum-2-enabled validation lives at the boundary
|
|
429
|
+
// (orchestrator construction) — keeping the parser pure makes it easy
|
|
430
|
+
// to test in isolation.
|
|
431
|
+
function loadPeerEnabledConfig() {
|
|
432
|
+
const peers = ["codex", "claude", "gemini", "deepseek", "grok", "perplexity"];
|
|
433
|
+
const result = {};
|
|
434
|
+
for (const peer of peers) {
|
|
435
|
+
const envName = `CROSS_REVIEW_PEER_${peer.toUpperCase()}`;
|
|
436
|
+
const raw = (envValue(envName) ?? "").trim().toLowerCase();
|
|
437
|
+
if (raw === "") {
|
|
438
|
+
result[peer] = true;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (/^(on|true|1|yes|enabled)$/i.test(raw)) {
|
|
442
|
+
result[peer] = true;
|
|
443
|
+
}
|
|
444
|
+
else if (/^(off|false|0|no|disabled)$/i.test(raw)) {
|
|
445
|
+
result[peer] = false;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
console.error(`[cross-review] notice: ${envName}="${raw}" is not recognized; defaulting to "on". Recognized values: on/true/1/yes/enabled vs off/false/0/no/disabled.`);
|
|
449
|
+
result[peer] = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
// v2.12.0: parse the judge auto-wire env vars into a typed struct that
|
|
455
|
+
// server_info, the boot notice and the orchestrator share. Invalid
|
|
456
|
+
// values do NOT throw — `mode` keeps the literal string for the boot
|
|
457
|
+
// notice, `peer` is undefined when not in PEERS, `active` is true iff
|
|
458
|
+
// the runtime will actually emit shadow_decision events.
|
|
459
|
+
function loadEvidenceJudgeAutowireConfig() {
|
|
460
|
+
const rawMode = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_MODE ?? "")
|
|
461
|
+
.trim()
|
|
462
|
+
.toLowerCase();
|
|
463
|
+
const rawPeer = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_PEER ?? "").trim();
|
|
464
|
+
const rawConsensusPeers = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_CONSENSUS_PEERS ?? "").trim();
|
|
465
|
+
const peerKnown = ["codex", "claude", "gemini", "deepseek", "grok", "perplexity"];
|
|
466
|
+
const peer = peerKnown.includes(rawPeer) ? rawPeer : undefined;
|
|
467
|
+
// v2.15.0 (item 1): parse consensus peers list. Comma-separated; only
|
|
468
|
+
// peers that are members of PEERS are kept. Need >=2 valid entries
|
|
469
|
+
// for consensus to apply (orchestrator guard); below 2 falls back to
|
|
470
|
+
// single-peer autowire.
|
|
471
|
+
const consensusPeers = rawConsensusPeers
|
|
472
|
+
? rawConsensusPeers
|
|
473
|
+
.split(",")
|
|
474
|
+
.map((entry) => entry.trim())
|
|
475
|
+
.filter((entry) => peerKnown.includes(entry))
|
|
476
|
+
.map((entry) => entry)
|
|
477
|
+
: [];
|
|
478
|
+
const mode = rawMode === "" ? "off" : rawMode;
|
|
479
|
+
// v2.14.0 (item 2): "active" promoted to first-class autowire mode.
|
|
480
|
+
// v2.15.0 (item 1): consensus path also activates `active` when
|
|
481
|
+
// consensus_peers >= 2 (single-peer field becomes optional in that case).
|
|
482
|
+
const active = (mode === "shadow" || mode === "active") && (peer !== undefined || consensusPeers.length >= 2);
|
|
483
|
+
// v2.12.0: preserve EXACT pre-v2.12 semantics. The legacy inline read
|
|
484
|
+
// was `Number.parseInt(env ?? "8", 10) || 8` — this lets negative
|
|
485
|
+
// values flow through (because `-5 || 8 === -5` in JS) so the
|
|
486
|
+
// orchestrator's `Math.max(1, Math.min(100, cap))` clamps -5 to 1, NOT 8.
|
|
487
|
+
// We don't use `intEnv` here because that helper has a `parsed > 0`
|
|
488
|
+
// filter, which would change the consumer's clamp result for negatives.
|
|
489
|
+
// codex R1 ship-review of v2.12.0 caught the divergence.
|
|
490
|
+
// v2.18.4 / Codex audit 2026-05-07 P1.4: defensive cap reduction
|
|
491
|
+
// 8 → 4. Math: with default consensus_peers=4 (codex+gemini+
|
|
492
|
+
// deepseek+grok), worst-case round fires `consensus_peers ×
|
|
493
|
+
// max_items_per_pass = 4 × 8 = 32` paid judge calls per round.
|
|
494
|
+
// Lowering the default to 4 puts the worst case at `4 × 4 = 16`
|
|
495
|
+
// paid calls, halving the budget exposure without a code change.
|
|
496
|
+
// Operators wanting the prior 8 (or higher) set the env-var
|
|
497
|
+
// explicitly. Single-peer mode goes from 1×8=8 to 1×4=4 — a coverage
|
|
498
|
+
// reduction, but the operator can always raise via env-var. This
|
|
499
|
+
// is a *default* change, not a hard cap.
|
|
500
|
+
const rawCap = Number.parseInt(process.env.CROSS_REVIEW_EVIDENCE_JUDGE_MAX_ITEMS_PER_PASS ?? "4", 10);
|
|
501
|
+
const maxItemsPerPass = Number.isFinite(rawCap) && rawCap !== 0 ? rawCap : 4;
|
|
502
|
+
return {
|
|
503
|
+
mode,
|
|
504
|
+
peer,
|
|
505
|
+
active,
|
|
506
|
+
max_items_per_pass: maxItemsPerPass,
|
|
507
|
+
configured_mode_raw: rawMode,
|
|
508
|
+
configured_peer_raw: rawPeer,
|
|
509
|
+
consensus_peers: consensusPeers,
|
|
510
|
+
configured_consensus_peers_raw: rawConsensusPeers,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
export function missingFinancialControlVars(config, peers, options = {}) {
|
|
514
|
+
const missing = new Set();
|
|
515
|
+
if (config.budget.max_session_cost_usd == null) {
|
|
516
|
+
missing.add("CROSS_REVIEW_MAX_SESSION_COST_USD");
|
|
517
|
+
}
|
|
518
|
+
if (config.budget.preflight_max_round_cost_usd == null) {
|
|
519
|
+
missing.add("CROSS_REVIEW_PREFLIGHT_MAX_ROUND_COST_USD");
|
|
520
|
+
}
|
|
521
|
+
if (options.untilStopped && config.budget.until_stopped_max_cost_usd == null) {
|
|
522
|
+
missing.add("CROSS_REVIEW_UNTIL_STOPPED_MAX_COST_USD");
|
|
523
|
+
}
|
|
524
|
+
for (const peer of peers) {
|
|
525
|
+
if (config.cost_rates[peer])
|
|
526
|
+
continue;
|
|
527
|
+
const prefix = COST_RATE_ENV_PREFIX[peer];
|
|
528
|
+
missing.add(`${prefix}_INPUT_USD_PER_MILLION`);
|
|
529
|
+
missing.add(`${prefix}_OUTPUT_USD_PER_MILLION`);
|
|
530
|
+
}
|
|
531
|
+
// v3.0.0 (Perplexity 6th peer): when the perplexity peer is in scope
|
|
532
|
+
// AND search is enabled (default), the request fee for the configured
|
|
533
|
+
// search_context_size MUST be set — Perplexity bills BOTH per-token
|
|
534
|
+
// AND per-1000-requests, and the request fee is the only way the
|
|
535
|
+
// cost layer can account for the search cost dimension. When
|
|
536
|
+
// disable_search is true, the request fee is irrelevant (peer
|
|
537
|
+
// becomes pure-LLM and the missing fee is harmless). This preserves
|
|
538
|
+
// the v2.26.0 "no-hardcoded-financials" contract: every dimension of
|
|
539
|
+
// pricing that applies to the current call must be operator-
|
|
540
|
+
// configured before paid traffic is allowed.
|
|
541
|
+
if (peers.includes("perplexity") && !config.perplexity.disable_search) {
|
|
542
|
+
const rate = config.cost_rates.perplexity;
|
|
543
|
+
const size = config.perplexity.search_context_size;
|
|
544
|
+
const requestFeeField = size === "high"
|
|
545
|
+
? "request_fee_high_per_1000"
|
|
546
|
+
: size === "medium"
|
|
547
|
+
? "request_fee_medium_per_1000"
|
|
548
|
+
: "request_fee_low_per_1000";
|
|
549
|
+
const requestFeeEnvSuffix = size === "high"
|
|
550
|
+
? "REQUEST_FEE_HIGH_USD_PER_1000_REQUESTS"
|
|
551
|
+
: size === "medium"
|
|
552
|
+
? "REQUEST_FEE_MEDIUM_USD_PER_1000_REQUESTS"
|
|
553
|
+
: "REQUEST_FEE_LOW_USD_PER_1000_REQUESTS";
|
|
554
|
+
if (!rate || rate[requestFeeField] == null) {
|
|
555
|
+
missing.add(`${COST_RATE_ENV_PREFIX.perplexity}_${requestFeeEnvSuffix}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return [...missing].sort();
|
|
559
|
+
}
|
|
560
|
+
// v2.26.0: cost rate parser expanded to a complete pricing model.
|
|
561
|
+
// Required: _INPUT_USD_PER_MILLION + _OUTPUT_USD_PER_MILLION (backward
|
|
562
|
+
// compat). Optional extensions:
|
|
563
|
+
// - Extended tier (≤ vs > threshold): _INPUT_EXTENDED, _OUTPUT_EXTENDED
|
|
564
|
+
// - Cache pricing: _CACHE_READ, _CACHE_WRITE, _CACHE_READ_EXTENDED,
|
|
565
|
+
// _CACHE_WRITE_EXTENDED
|
|
566
|
+
// - Promo pricing (limited-time discount): _PROMO_INPUT, _PROMO_OUTPUT,
|
|
567
|
+
// _PROMO_INPUT_EXTENDED, _PROMO_OUTPUT_EXTENDED, _PROMO_CACHE_READ,
|
|
568
|
+
// _PROMO_CACHE_WRITE, _PROMO_CACHE_READ_EXTENDED,
|
|
569
|
+
// _PROMO_CACHE_WRITE_EXTENDED, _PROMO_EXPIRES_AT_UTC (ISO 8601)
|
|
570
|
+
// - Tier threshold: _THRESHOLD_TOKENS (e.g., 200000 for Gemini)
|
|
571
|
+
// Selection logic (in cost.ts selectRate()): if today < promo_expires_at
|
|
572
|
+
// AND a corresponding promo field is set, use promo. Else if total input
|
|
573
|
+
// tokens > threshold AND extended field is set, use extended. Else use
|
|
574
|
+
// base. Each category (input/output/cache_read/cache_write) selects
|
|
575
|
+
// independently.
|
|
576
|
+
function costRate(prefix) {
|
|
577
|
+
const input = numberEnv(`${prefix}_INPUT_USD_PER_MILLION`);
|
|
578
|
+
const output = numberEnv(`${prefix}_OUTPUT_USD_PER_MILLION`);
|
|
579
|
+
if (input == null || output == null)
|
|
580
|
+
return undefined;
|
|
581
|
+
const opt = (suffix) => numberEnv(`${prefix}_${suffix}`) ?? undefined;
|
|
582
|
+
const promoExpiresRaw = envValue(`${prefix}_PROMO_EXPIRES_AT_UTC`);
|
|
583
|
+
const thresholdTokensRaw = numberEnv(`${prefix}_THRESHOLD_TOKENS`);
|
|
584
|
+
const rate = {
|
|
585
|
+
input_per_million: input,
|
|
586
|
+
output_per_million: output,
|
|
587
|
+
};
|
|
588
|
+
const fields = [
|
|
589
|
+
["input_extended_per_million", "INPUT_EXTENDED_USD_PER_MILLION"],
|
|
590
|
+
["output_extended_per_million", "OUTPUT_EXTENDED_USD_PER_MILLION"],
|
|
591
|
+
["cache_read_per_million", "CACHE_READ_USD_PER_MILLION"],
|
|
592
|
+
["cache_write_per_million", "CACHE_WRITE_USD_PER_MILLION"],
|
|
593
|
+
["cache_read_extended_per_million", "CACHE_READ_EXTENDED_USD_PER_MILLION"],
|
|
594
|
+
["cache_write_extended_per_million", "CACHE_WRITE_EXTENDED_USD_PER_MILLION"],
|
|
595
|
+
["promo_input_per_million", "PROMO_INPUT_USD_PER_MILLION"],
|
|
596
|
+
["promo_output_per_million", "PROMO_OUTPUT_USD_PER_MILLION"],
|
|
597
|
+
["promo_input_extended_per_million", "PROMO_INPUT_EXTENDED_USD_PER_MILLION"],
|
|
598
|
+
["promo_output_extended_per_million", "PROMO_OUTPUT_EXTENDED_USD_PER_MILLION"],
|
|
599
|
+
["promo_cache_read_per_million", "PROMO_CACHE_READ_USD_PER_MILLION"],
|
|
600
|
+
["promo_cache_write_per_million", "PROMO_CACHE_WRITE_USD_PER_MILLION"],
|
|
601
|
+
["promo_cache_read_extended_per_million", "PROMO_CACHE_READ_EXTENDED_USD_PER_MILLION"],
|
|
602
|
+
["promo_cache_write_extended_per_million", "PROMO_CACHE_WRITE_EXTENDED_USD_PER_MILLION"],
|
|
603
|
+
// v3.0.0 (Perplexity 6th peer): Perplexity bills both per-token AND
|
|
604
|
+
// per-1000-requests where the request fee scales with
|
|
605
|
+
// `search_context_size`. Other peers' costRate calls leave these
|
|
606
|
+
// suffixes undefined; perplexity costRate (env prefix
|
|
607
|
+
// CROSS_REVIEW_PERPLEXITY) sets them when operator configures them.
|
|
608
|
+
["request_fee_low_per_1000", "REQUEST_FEE_LOW_USD_PER_1000_REQUESTS"],
|
|
609
|
+
["request_fee_medium_per_1000", "REQUEST_FEE_MEDIUM_USD_PER_1000_REQUESTS"],
|
|
610
|
+
["request_fee_high_per_1000", "REQUEST_FEE_HIGH_USD_PER_1000_REQUESTS"],
|
|
611
|
+
// v3.0.0 (Perplexity Sonar Deep Research): three distinct line
|
|
612
|
+
// items billed separately from input/output. Other Sonar models
|
|
613
|
+
// (sonar / sonar-pro / sonar-reasoning-pro) leave these undefined.
|
|
614
|
+
["citation_tokens_per_million", "CITATION_TOKENS_USD_PER_MILLION"],
|
|
615
|
+
[
|
|
616
|
+
"deep_research_reasoning_tokens_per_million",
|
|
617
|
+
"DEEP_RESEARCH_REASONING_TOKENS_USD_PER_MILLION",
|
|
618
|
+
],
|
|
619
|
+
["search_queries_per_1000", "SEARCH_QUERIES_USD_PER_1000_REQUESTS"],
|
|
620
|
+
];
|
|
621
|
+
for (const [key, suffix] of fields) {
|
|
622
|
+
const value = opt(suffix);
|
|
623
|
+
if (value != null) {
|
|
624
|
+
rate[key] = value;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (thresholdTokensRaw != null && thresholdTokensRaw > 0) {
|
|
628
|
+
rate.threshold_tokens = Math.floor(thresholdTokensRaw);
|
|
629
|
+
}
|
|
630
|
+
if (promoExpiresRaw && promoExpiresRaw.trim() !== "") {
|
|
631
|
+
const trimmed = promoExpiresRaw.trim();
|
|
632
|
+
// Validate ISO 8601 parseability so a typo doesn't silently disable promo.
|
|
633
|
+
const parsed = Date.parse(trimmed);
|
|
634
|
+
if (Number.isNaN(parsed)) {
|
|
635
|
+
console.error(`[cross-review] notice: ${prefix}_PROMO_EXPIRES_AT_UTC="${trimmed}" is not a valid ISO 8601 timestamp; promo rates will be ignored.`);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
rate.promo_expires_at = trimmed;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return rate;
|
|
642
|
+
}
|
|
643
|
+
//# sourceMappingURL=config.js.map
|