mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
Compare commits
4 Commits
maint/cher
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcbaa436ba | ||
|
|
abcf6f8128 | ||
|
|
154f773a28 | ||
|
|
be583b5392 |
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.",
|
||||
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
|
||||
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
|
||||
"_rebaseline_2026_08_08_9183_reasoning_cache_index_sync": "Extracted fix(responses-api): sync reasoning-cache write index with the fixed read side (from the originally-authored #9183) — chatCore.ts's write side cached every response under a hardcoded messageIndex:0, and translator/index.ts's plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site (a second, previously-undiscovered instance of the same hardcoding bug, found while re-verifying this fix against the current upstream tip — the two never agreed once a conversation went past its first assistant turn, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache). Own growth: open-sse/handlers/chatCore.ts 5034->5042 (+8, computing messageIndex from the incoming request's message count at both the streaming and non-streaming cache-write call sites) — irreducible call-site wiring. Covered by tests/unit/reasoning-cache.test.ts (new end-to-end write/read regression test, rebaselined below) and tests/unit/translator-helper-branches.test.ts fixture updates. Other #9183 sub-fixes (output_index collision prevention, reasoning-content-alias generalization) were originally assumed already superseded by upstream's own independent fix — a live incident 2026-08-08 disproved that for the message-vs-tool-call collision case specifically (fixed separately in #9822); not re-extracted here since this PR's own scope is the narrower messageIndex sync only.",
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -166,7 +162,6 @@
|
||||
"cap": 1000,
|
||||
"testCap": 1000,
|
||||
"testFrozen": {
|
||||
"tests/unit/reasoning-cache.test.ts": 1035,
|
||||
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
|
||||
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",
|
||||
"_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).",
|
||||
@@ -355,7 +350,7 @@
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 5042,
|
||||
"open-sse/handlers/chatCore.ts": 5034,
|
||||
"open-sse/handlers/imageGeneration.ts": 3101,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1128,
|
||||
"open-sse/handlers/search.ts": 1536,
|
||||
|
||||
@@ -207,6 +207,7 @@ import { stageTrace } from "./chatCore/stageTrace.ts";
|
||||
import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts";
|
||||
import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts";
|
||||
import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts";
|
||||
|
||||
import {
|
||||
getCallLogPipelineCaptureStreamChunks,
|
||||
getCallLogPipelineMaxSizeBytes,
|
||||
@@ -366,7 +367,9 @@ import {
|
||||
isTpmExhausted,
|
||||
isRpmExhausted,
|
||||
} from "../services/geminiRateLimitTracker.ts";
|
||||
|
||||
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
* Returns { success, response, status, error } for caller to handle fallback
|
||||
@@ -386,8 +389,10 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
* @param {boolean} options.isCombo - Whether this request is from a combo
|
||||
* @param {string} options.connectionId - Connection ID for settings lookup
|
||||
*/
|
||||
|
||||
// extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so
|
||||
// existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here.
|
||||
|
||||
export async function handleChatCore({
|
||||
body,
|
||||
modelInfo,
|
||||
@@ -423,6 +428,7 @@ export async function handleChatCore({
|
||||
/* fail open */
|
||||
}
|
||||
}
|
||||
|
||||
// Per-request model-routing metadata (first extracted slice of the request-setup phase).
|
||||
const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup(
|
||||
modelInfo,
|
||||
@@ -436,6 +442,7 @@ export async function handleChatCore({
|
||||
// (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id
|
||||
// is a log-correlation token, not a security secret.
|
||||
const traceId = globalThis.crypto.randomUUID().slice(0, 6);
|
||||
|
||||
// Emit request.started event for real-time dashboard
|
||||
setImmediate(() => {
|
||||
emit("request.started", {
|
||||
@@ -4326,14 +4333,9 @@ export async function handleChatCore({
|
||||
try {
|
||||
const firstChoice = translatedResponse?.choices?.[0];
|
||||
const msg = firstChoice?.message;
|
||||
// The response being cached now will be replayed as history on the *next*
|
||||
// turn, where the read side (translator/index.ts) keys the lookup by the
|
||||
// message's real position in that future `messages` array — i.e. right
|
||||
// after everything the client sent this turn.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
messageIndex: 0,
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the response
|
||||
@@ -4758,15 +4760,12 @@ export async function handleChatCore({
|
||||
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
|
||||
if (normalizedStreamStatus === 200 && streamResponseBody) {
|
||||
try {
|
||||
const streamBody = streamResponseBody as Record<string, unknown>;
|
||||
const choices = streamBody.choices as { message?: Record<string, unknown> }[] | undefined;
|
||||
const body = streamResponseBody as Record<string, unknown>;
|
||||
const choices = body.choices as { message?: Record<string, unknown> }[] | undefined;
|
||||
const msg = choices?.[0]?.message;
|
||||
// See the non-streaming capture above: messageIndex must match the
|
||||
// position this message will occupy in the *next* turn's history.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
messageIndex: 0,
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the stream
|
||||
@@ -5026,6 +5025,7 @@ export async function handleChatCore({
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
if (!expiresAt) return false;
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
|
||||
@@ -570,7 +570,7 @@ export function translateRequest(
|
||||
|
||||
const cacheKey = hasToolCalls
|
||||
? msg.tool_calls[0]?.id
|
||||
: getAssistantMessageCacheKey(result, messageIndex);
|
||||
: getAssistantMessageCacheKey(result, 0);
|
||||
if (cacheKey) {
|
||||
const cached = lookupReasoning(cacheKey);
|
||||
if (cached) {
|
||||
|
||||
@@ -83,7 +83,6 @@ import { getResource404Bypass } from "./requestResourceHealth";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
import { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
interface RecoverableConnectionState {
|
||||
connectionId: string;
|
||||
@@ -94,7 +93,6 @@ interface RecoverableConnectionState {
|
||||
lastErrorType?: string | null;
|
||||
lastErrorSource?: string | null;
|
||||
}
|
||||
|
||||
interface CredentialSelectionOptions {
|
||||
allowSuppressedConnections?: boolean;
|
||||
allowRateLimitedConnections?: boolean;
|
||||
@@ -104,14 +102,12 @@ interface CredentialSelectionOptions {
|
||||
sessionKey?: string | null;
|
||||
sessionAffinityTtlMs?: number | null;
|
||||
}
|
||||
|
||||
interface CooldownInspectionState {
|
||||
connection: ProviderConnectionView;
|
||||
connectionCooldownMs: number | null;
|
||||
codexScopeCooldownMs: number | null;
|
||||
retryableModelCooldownMs: number | null;
|
||||
}
|
||||
|
||||
const MIN_QUOTA_THRESHOLD_PERCENT = 1;
|
||||
const MAX_QUOTA_THRESHOLD_PERCENT = 100;
|
||||
const NON_RETRYABLE_MODEL_LOCKOUT_REASONS = new Set(["not_found", "not_found_local"]);
|
||||
@@ -119,25 +115,20 @@ const NON_RETRYABLE_MODEL_LOCKOUT_REASONS = new Set(["not_found", "not_found_loc
|
||||
// this base. Real upstream Retry-After hints still win — they flow through
|
||||
// `exactCooldownMs` (usedUpstreamRetryHint), not this base. (#5222)
|
||||
const ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS = 30_000;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const parsed = toNumber(value, Number.NaN);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeSessionKey(value: unknown, prefix: string): string | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -146,7 +137,6 @@ function normalizeSessionKey(value: unknown, prefix: string): string | null {
|
||||
}
|
||||
return `${prefix}:sha256:${createHash("sha256").update(trimmed).digest("hex")}`;
|
||||
}
|
||||
|
||||
function extractTextForSessionHash(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
@@ -164,7 +154,6 @@ function extractTextForSessionHash(value: unknown): string | null {
|
||||
if (value && typeof value === "object") return JSON.stringify(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFirstInputText(body: unknown): string | null {
|
||||
const record = asRecord(body);
|
||||
if (record.input !== undefined) {
|
||||
@@ -189,7 +178,6 @@ function getFirstInputText(body: unknown): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSessionAffinityKey(
|
||||
body: unknown,
|
||||
headers?: Headers | { get?: (name: string) => string | null } | null
|
||||
@@ -216,7 +204,6 @@ export function extractSessionAffinityKey(
|
||||
if (!inputText || inputText.trim().length === 0) return null;
|
||||
return `input:sha256:${createHash("sha256").update(inputText.slice(0, 4096)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
|
||||
use5h: boolean;
|
||||
useWeekly: boolean;
|
||||
@@ -227,13 +214,11 @@ function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
|
||||
useWeekly: toBooleanOrDefault(policy.useWeekly, true),
|
||||
};
|
||||
}
|
||||
|
||||
interface QuotaLimitPolicy {
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
windows: string[];
|
||||
}
|
||||
|
||||
interface QuotaCacheView {
|
||||
quotas?: Record<
|
||||
string,
|
||||
@@ -243,7 +228,6 @@ interface QuotaCacheView {
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
function normalizeQuotaThreshold(
|
||||
value: unknown,
|
||||
fallback = DEFAULT_QUOTA_THRESHOLD_PERCENT
|
||||
@@ -251,17 +235,14 @@ function normalizeQuotaThreshold(
|
||||
const parsed = toNumber(value, fallback);
|
||||
return Math.min(MAX_QUOTA_THRESHOLD_PERCENT, Math.max(MIN_QUOTA_THRESHOLD_PERCENT, parsed));
|
||||
}
|
||||
|
||||
function normalizeWindowName(windowName: unknown): string | null {
|
||||
if (typeof windowName !== "string") return null;
|
||||
const normalized = windowName.trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function uniqueWindows(windows: string[]): string[] {
|
||||
return [...new Set(windows)];
|
||||
}
|
||||
|
||||
function normalizeCodexWindowName(windowName: unknown): string | null {
|
||||
if (typeof windowName !== "string") return null;
|
||||
const normalized = windowName.trim().toLowerCase();
|
||||
@@ -273,7 +254,6 @@ function normalizeCodexWindowName(windowName: unknown): string | null {
|
||||
}
|
||||
return toCodexBaseQuotaWindowName(normalized);
|
||||
}
|
||||
|
||||
function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: JsonRecord): string[] {
|
||||
const codexPolicy = getCodexLimitPolicy(providerSpecificData);
|
||||
const normalizedRaw = rawWindows.map(normalizeCodexWindowName).filter(Boolean) as string[];
|
||||
@@ -291,7 +271,6 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json
|
||||
|
||||
return uniqueWindows(windows);
|
||||
}
|
||||
|
||||
function getCodexScopeRateLimitedUntil(
|
||||
providerSpecificData: JsonRecord,
|
||||
model: string | null
|
||||
@@ -302,7 +281,6 @@ function getCodexScopeRateLimitedUntil(
|
||||
const value = scopeMap[scope];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isCodexScopeUnavailable(
|
||||
connection: ProviderConnectionView,
|
||||
model: string | null
|
||||
@@ -311,7 +289,6 @@ function isCodexScopeUnavailable(
|
||||
if (!until) return false;
|
||||
return new Date(until).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function getEarliestCodexScopeRateLimitedUntil(
|
||||
connections: ProviderConnectionView[],
|
||||
model: string | null
|
||||
@@ -332,11 +309,9 @@ function getEarliestCodexScopeRateLimitedUntil(
|
||||
|
||||
return earliest;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | null): string {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean {
|
||||
const status = normalizeStatus(connection.testStatus);
|
||||
return status === "credits_exhausted" || status === "banned" || status === "expired";
|
||||
@@ -354,7 +329,6 @@ function isRecoverableCookieAuth401(
|
||||
resolveProviderId(provider) in WEB_COOKIE_PROVIDERS
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTerminalConnectionStatus(
|
||||
status: number,
|
||||
result: { permanent?: boolean; creditsExhausted?: boolean },
|
||||
@@ -381,7 +355,6 @@ function resolveTerminalConnectionStatus(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveQuotaLimitPolicy(
|
||||
provider: string,
|
||||
providerSpecificData: JsonRecord
|
||||
@@ -407,7 +380,6 @@ export function resolveQuotaLimitPolicy(
|
||||
windows,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateQuotaLimitPolicy(
|
||||
provider: string,
|
||||
connection: ProviderConnectionView,
|
||||
@@ -440,7 +412,6 @@ export function evaluateQuotaLimitPolicy(
|
||||
resetAt: getEarliestFutureDate(resetCandidates),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFutureDateMs(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
// Tolerate numeric-epoch strings (e.g. "1781696905131.0") as well as ISO
|
||||
@@ -449,7 +420,6 @@ function parseFutureDateMs(value: string | null): number | null {
|
||||
if (!Number.isFinite(ms) || ms <= Date.now()) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
function getEarliestFutureDate(candidates: Array<string | null>): string | null {
|
||||
return (
|
||||
candidates
|
||||
@@ -461,31 +431,26 @@ function getEarliestFutureDate(candidates: Array<string | null>): string | null
|
||||
.sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedQuotaResetAt(connectionId: string): string | null {
|
||||
const entry = getQuotaCache(connectionId);
|
||||
if (!entry?.quotas) return null;
|
||||
return getEarliestFutureDate(Object.values(entry.quotas).map((quota) => quota.resetAt));
|
||||
}
|
||||
|
||||
function isRetryableModelLockoutReason(reason: unknown): boolean {
|
||||
return typeof reason === "string" && reason.length > 0
|
||||
? !NON_RETRYABLE_MODEL_LOCKOUT_REASONS.has(reason)
|
||||
: false;
|
||||
}
|
||||
|
||||
function pushClampedPercentage(percentages: number[], value: number): void {
|
||||
if (Number.isFinite(value)) {
|
||||
percentages.push(Math.max(0, Math.min(100, value)));
|
||||
}
|
||||
}
|
||||
|
||||
function isResetAtInPast(resetAt: string | null): boolean {
|
||||
if (!resetAt) return false;
|
||||
const resetMs = new Date(resetAt).getTime();
|
||||
return Number.isFinite(resetMs) && resetMs <= Date.now();
|
||||
}
|
||||
|
||||
function collectPolicyQuotaHeadroomPercentages(
|
||||
provider: string,
|
||||
connection: ProviderConnectionView,
|
||||
@@ -508,7 +473,6 @@ function collectPolicyQuotaHeadroomPercentages(
|
||||
|
||||
return percentages;
|
||||
}
|
||||
|
||||
function collectCachedQuotaHeadroomPercentages(
|
||||
provider: string,
|
||||
connection: ProviderConnectionView,
|
||||
@@ -528,7 +492,6 @@ function collectCachedQuotaHeadroomPercentages(
|
||||
|
||||
return percentages;
|
||||
}
|
||||
|
||||
function getConnectionQuotaHeadroomPercent(
|
||||
provider: string,
|
||||
connection: ProviderConnectionView,
|
||||
@@ -548,7 +511,6 @@ function getConnectionQuotaHeadroomPercent(
|
||||
|
||||
return percentages.length > 0 ? Math.min(...percentages) : null;
|
||||
}
|
||||
|
||||
function getConnectionErrorPenalty(connection: ProviderConnectionView): number {
|
||||
const errorType = normalizeStatus(connection.lastErrorType);
|
||||
const errorSource = normalizeStatus(connection.lastErrorSource);
|
||||
@@ -572,7 +534,6 @@ function getConnectionErrorPenalty(connection: ProviderConnectionView): number {
|
||||
|
||||
return penalty;
|
||||
}
|
||||
|
||||
function getConnectionRecencyPenalty(connection: ProviderConnectionView): number {
|
||||
if (!connection.lastUsedAt) return 0;
|
||||
const ageMs = Date.now() - new Date(connection.lastUsedAt).getTime();
|
||||
@@ -582,7 +543,6 @@ function getConnectionRecencyPenalty(connection: ProviderConnectionView): number
|
||||
if (ageMs < 5 * 60_000) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getP2CConnectionScore(
|
||||
provider: string,
|
||||
connection: ProviderConnectionView,
|
||||
@@ -628,7 +588,6 @@ function getP2CConnectionScore(
|
||||
|
||||
return { score, quotaHeadroomPercent };
|
||||
}
|
||||
|
||||
function compareP2CConnections(
|
||||
provider: string,
|
||||
a: ProviderConnectionView,
|
||||
@@ -662,12 +621,10 @@ function compareP2CConnections(
|
||||
* exclude it (#3061), otherwise it gets re-selected forever.
|
||||
*/
|
||||
const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth";
|
||||
|
||||
type AnonymousFallbackProviderDefinition = {
|
||||
anonymousFallback?: boolean;
|
||||
noAuth?: boolean;
|
||||
};
|
||||
|
||||
function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}): {
|
||||
apiKey: null;
|
||||
accessToken: null;
|
||||
@@ -756,7 +713,6 @@ async function loadNoAuthProviderSpecificData(providerId: string): Promise<JsonR
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
const providerDef = getProviderById(providerId) as
|
||||
AnonymousFallbackProviderDefinition | undefined;
|
||||
@@ -772,7 +728,6 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
webCookieProviderDef?.noAuth === true
|
||||
);
|
||||
}
|
||||
|
||||
async function maybeSyntheticNoAuthFallback(
|
||||
providerId: string,
|
||||
excludedConnectionIds: Set<string>,
|
||||
@@ -790,7 +745,6 @@ async function maybeSyntheticNoAuthFallback(
|
||||
const providerSpecificData = await loadNoAuthProviderSpecificData(providerId);
|
||||
return buildSyntheticNoAuthCredentials(providerSpecificData);
|
||||
}
|
||||
|
||||
function normalizeExcludedConnectionIds(
|
||||
excludeConnectionId: string | null,
|
||||
extraExcludedConnectionIds: string[] | null | undefined
|
||||
@@ -811,7 +765,6 @@ function normalizeExcludedConnectionIds(
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatConnectionPrefixesForLog(ids: Iterable<string>, max = 6): string {
|
||||
const prefixes = Array.from(ids)
|
||||
.filter((id) => typeof id === "string" && id.length > 0)
|
||||
@@ -819,7 +772,6 @@ function formatConnectionPrefixesForLog(ids: Iterable<string>, max = 6): string
|
||||
.map((id) => `${id.slice(0, 8)}...`);
|
||||
return prefixes.length > 0 ? prefixes.join(",") : "none";
|
||||
}
|
||||
|
||||
function buildQuotaPreflightRateLimitedResult(
|
||||
provider: string,
|
||||
blockedByPreflight: Array<{
|
||||
@@ -850,12 +802,10 @@ function buildQuotaPreflightRateLimitedResult(
|
||||
lastErrorCode: 429,
|
||||
};
|
||||
}
|
||||
|
||||
function quotaPreflightUnavailableUntil(resetAt?: string | null): string {
|
||||
const resetMs = parseFutureDateMs(resetAt ?? null);
|
||||
return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function markQuotaPreflightAccountUnavailable(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
@@ -884,14 +834,12 @@ async function markQuotaPreflightAccountUnavailable(
|
||||
// Provider-scoped mutexes prevent race conditions during account selection without
|
||||
// serializing unrelated providers behind a single global lock.
|
||||
const selectionMutexes = new Map<string, Promise<void>>();
|
||||
|
||||
function getSelectionMutexKey(provider: string, options: CredentialSelectionOptions): string {
|
||||
return [
|
||||
resolveProviderId(provider) || provider,
|
||||
options.forcedConnectionId ? `forced:${options.forcedConnectionId}` : "pool",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function createSelectionLock(key: string) {
|
||||
const currentMutex = selectionMutexes.get(key) ?? Promise.resolve();
|
||||
let resolveMutex: (() => void) | undefined;
|
||||
@@ -923,7 +871,6 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
|
||||
// Re-export readHeaderValue and AuthRequestHeaders from headerReader.ts for
|
||||
// backwards compat with existing imports (e.g. googApiKeyAuth.ts).
|
||||
export { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts";
|
||||
|
||||
const PROVIDER_SEARCH_PAIRS: string[][] = [
|
||||
["nvidia", "nvidia_nim"],
|
||||
["kimi-coding", "kimi-coding-apikey"],
|
||||
@@ -1703,7 +1650,6 @@ export async function getProviderCredentials(
|
||||
selectionLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProviderCredentialsWithQuotaPreflight(
|
||||
provider: string,
|
||||
excludeConnectionId: string | null = null,
|
||||
@@ -2005,16 +1951,17 @@ export async function markAccountUnavailable(
|
||||
const disableCooling = connProviderSpecificData.disableCooling === true;
|
||||
|
||||
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
|
||||
const isNvidiaModelGone = provider === "nvidia" && status === 410;
|
||||
const modelLockoutOptions = { maxCooldownMs: effectiveProviderProfile?.maxCooldownMs };
|
||||
if (
|
||||
isPerModelQuotaProvider &&
|
||||
provider &&
|
||||
provider !== "codex" &&
|
||||
model &&
|
||||
(status === 404 || status === 429 || status >= 500)
|
||||
(status === 404 || isNvidiaModelGone || status === 429 || status >= 500)
|
||||
) {
|
||||
const reason =
|
||||
status === 404
|
||||
status === 404 || isNvidiaModelGone
|
||||
? "not_found"
|
||||
: status === 429 && fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED
|
||||
? "quota_exhausted"
|
||||
@@ -2046,7 +1993,10 @@ export async function markAccountUnavailable(
|
||||
? "model"
|
||||
: getQuotaScopeLabelForProvider(provider, model);
|
||||
const antigravityFamilyInferredBaseCooldownMs =
|
||||
!usesExactAntigravityLock && provider === "antigravity" && quotaScope === "family" && status === 429
|
||||
!usesExactAntigravityLock &&
|
||||
provider === "antigravity" &&
|
||||
quotaScope === "family" &&
|
||||
status === 429
|
||||
? ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS
|
||||
: null;
|
||||
const lockout = recordModelLockoutFailure(
|
||||
@@ -2055,7 +2005,7 @@ export async function markAccountUnavailable(
|
||||
model,
|
||||
reason,
|
||||
status,
|
||||
status === 404
|
||||
status === 404 || isNvidiaModelGone
|
||||
? (effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.notFoundLocal)
|
||||
: (antigravityFamilyInferredBaseCooldownMs ??
|
||||
fallbackResult.baseCooldownMs ??
|
||||
@@ -2352,7 +2302,6 @@ export interface RecoveredStateExpectation {
|
||||
lastErrorAt: string | null;
|
||||
rateLimitedUntil: string | null;
|
||||
}
|
||||
|
||||
export async function clearRecoveredProviderState(
|
||||
credentials: Partial<RecoverableConnectionState> | null,
|
||||
expectedState?: RecoveredStateExpectation
|
||||
@@ -2373,12 +2322,10 @@ export async function clearRecoveredProviderState(
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
return { applied: true };
|
||||
}
|
||||
|
||||
type AuthRequestLike = {
|
||||
headers?: AuthRequestHeaders | null;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
function readNonEmptyUrlToken(request: AuthRequestLike): string | null {
|
||||
if (typeof request?.url !== "string" || request.url.trim().length === 0) return null;
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"tests/unit/no-memory-header.test.ts",
|
||||
"tests/unit/noauth-autocombo-lockout-7623.test.ts",
|
||||
"tests/unit/non-streaming-sse-terminal-typescan-4459.test.ts",
|
||||
"tests/unit/nvidia-410-model-scope.test.ts",
|
||||
"tests/unit/nvidia-passthrough-models-6773.test.ts",
|
||||
"tests/unit/nvidia-quota-phase1.test.ts",
|
||||
"tests/unit/oauth-providers-config.test.ts",
|
||||
|
||||
196
tests/unit/nvidia-410-model-scope.test.ts
Normal file
196
tests/unit/nvidia-410-model-scope.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-nvidia-410-model-scope-"));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "nvidia-410-model-scope-test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
const fallback = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
const DEAD_MODEL = "deepseek-ai/deepseek-v4-pro";
|
||||
const HEALTHY_MODEL = "z-ai/glm-5.2";
|
||||
|
||||
const GONE_BODY = JSON.stringify({
|
||||
type: "about:blank",
|
||||
title: "Gone",
|
||||
status: 410,
|
||||
detail:
|
||||
"The model 'deepseek-ai/deepseek-v4-pro' has reached its end of life " +
|
||||
"and is no longer available.",
|
||||
});
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function seedNvidiaConnection() {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "nvidia",
|
||||
authType: "apikey",
|
||||
name: "nvidia-410-model-scope",
|
||||
apiKey: "sk-nvidia-410-model-scope",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("NVIDIA 410 Gone stays model-scoped and leaves the connection usable", async () => {
|
||||
const connection = await seedNvidiaConnection();
|
||||
|
||||
assert.equal(
|
||||
fallback.hasPerModelQuota("nvidia", DEAD_MODEL),
|
||||
true,
|
||||
"NVIDIA must use per-model failure scoping"
|
||||
);
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connection.id,
|
||||
410,
|
||||
GONE_BODY,
|
||||
"nvidia",
|
||||
DEAD_MODEL
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(
|
||||
after?.rateLimitedUntil ?? null,
|
||||
null,
|
||||
"410 for one retired NVIDIA model must not apply a connection-wide cooldown"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
after?.testStatus,
|
||||
"active",
|
||||
"410 for one retired NVIDIA model must leave the NVIDIA connection active"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fallback.isModelLocked("nvidia", connection.id, DEAD_MODEL),
|
||||
true,
|
||||
"the retired model itself should be locked"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fallback.isModelLocked("nvidia", connection.id, HEALTHY_MODEL),
|
||||
false,
|
||||
"a healthy sibling NVIDIA model must remain unlocked"
|
||||
);
|
||||
|
||||
const healthyCredentials = await auth.getProviderCredentials("nvidia", null, null, HEALTHY_MODEL);
|
||||
|
||||
assert.equal(
|
||||
healthyCredentials?.connectionId,
|
||||
connection.id,
|
||||
"the same NVIDIA connection must remain selectable for healthy sibling models"
|
||||
);
|
||||
});
|
||||
|
||||
test("non-per-model provider keeps 410 connection-scoped", async () => {
|
||||
assert.equal(
|
||||
fallback.hasPerModelQuota("openai", DEAD_MODEL),
|
||||
false,
|
||||
"plain OpenAI API-key connections are not per-model quota providers"
|
||||
);
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "openai-410-connection-scope",
|
||||
apiKey: "sk-openai-410-connection-scope",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connection.id,
|
||||
410,
|
||||
"Gone",
|
||||
"openai",
|
||||
DEAD_MODEL
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.ok(
|
||||
after?.rateLimitedUntil,
|
||||
"non-per-model providers should retain the existing connection-level 410 behavior"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
after?.testStatus,
|
||||
"unavailable",
|
||||
"410 model scoping must not be applied globally to every provider"
|
||||
);
|
||||
});
|
||||
|
||||
test("other per-model providers retain existing 410 connection scope", async () => {
|
||||
assert.equal(
|
||||
fallback.hasPerModelQuota("gemini", DEAD_MODEL),
|
||||
true,
|
||||
"Gemini provides a non-NVIDIA per-model control case"
|
||||
);
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "gemini",
|
||||
authType: "apikey",
|
||||
name: "gemini-410-control",
|
||||
apiKey: "sk-gemini-410-control",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connection.id,
|
||||
410,
|
||||
"Gone",
|
||||
"gemini",
|
||||
DEAD_MODEL
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.ok(
|
||||
after?.rateLimitedUntil,
|
||||
"410 must remain connection-scoped for per-model providers without an explicit 410 contract"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
after?.testStatus,
|
||||
"unavailable",
|
||||
"the NVIDIA-specific 410 fix must not change other provider semantics"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fallback.isModelLocked("gemini", connection.id, DEAD_MODEL),
|
||||
false,
|
||||
"a generic per-model provider must not inherit NVIDIA's 410 model lock"
|
||||
);
|
||||
});
|
||||
@@ -865,12 +865,11 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
// The non-tool-call cache key is built as `getAssistantMessageCacheKey(result, messageIndex)`
|
||||
// where messageIndex is the assistant message's real position in the `messages`
|
||||
// array (index 1 here: user, assistant, user) — matching what the write side
|
||||
// (chatCore.ts) now caches under once the response is generated.
|
||||
// NOTE: the non-tool-call cache key is built as `getAssistantMessageCacheKey(result, 0)`
|
||||
// — the message index is hardcoded to 0 in the translator, so the key is always
|
||||
// `request:<id>:message:0` regardless of the assistant message's actual position.
|
||||
cacheReasoning(
|
||||
"request:req-plain-1:message:1",
|
||||
"request:req-plain-1:message:0",
|
||||
"deepseek",
|
||||
"deepseek-v4-pro",
|
||||
"Real cached plain-turn reasoning"
|
||||
@@ -900,60 +899,6 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
|
||||
);
|
||||
assert.equal(getReasoningCacheServiceStats().replays, 1);
|
||||
});
|
||||
|
||||
it("write side (chatCore's messageIndex) and read side (translateRequest) agree on the same key end-to-end", () => {
|
||||
// Regression for a mismatch where chatCore.ts always cached under
|
||||
// `messageIndex: 0` (the position of the response within *its own* choices
|
||||
// array) while translateRequest's read side looked up the message's real
|
||||
// position in the *next* turn's full history — the two never agreed once a
|
||||
// conversation went past its first assistant turn, so replay silently
|
||||
// fell back to the placeholder in real multi-turn usage.
|
||||
clearReasoningCacheAll();
|
||||
clearModelsDevCapabilities();
|
||||
saveModelsDevCapabilities({
|
||||
deepseek: {
|
||||
"deepseek-v4-pro": buildCapability({
|
||||
interleaved_field: "reasoning_content",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Turn 1: the incoming request has a single user message (length 1), so
|
||||
// the assistant response chatCore is about to cache will occupy index 1
|
||||
// once it's appended to history for turn 2 — mirroring
|
||||
// `messageIndex: bodyMessages.length` in chatCore.ts.
|
||||
const turn1RequestBody = { messages: [{ role: "user", content: "hi" }] };
|
||||
cacheReasoningFromAssistantMessage(
|
||||
{ role: "assistant", content: "Hello! How can I help?", reasoning_content: "real reasoning" },
|
||||
"deepseek",
|
||||
"deepseek-v4-pro",
|
||||
{ requestId: "req-e2e-1", messageIndex: turn1RequestBody.messages.length }
|
||||
);
|
||||
|
||||
// Turn 2: client replays the full history including the cached assistant
|
||||
// turn, now genuinely at index 1.
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI,
|
||||
"deepseek-v4-pro",
|
||||
{
|
||||
request_id: "req-e2e-1",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Hello! How can I help?" },
|
||||
{ role: "user", content: "tell me more" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
assert.equal(translated.messages[1].reasoning_content, "real reasoning");
|
||||
assert.equal(getReasoningCacheServiceStats().replays, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reasoning Replay Cache — API Route", () => {
|
||||
|
||||
@@ -632,7 +632,7 @@ test("translateRequest replays cached reasoning-only messages when interleaved f
|
||||
},
|
||||
});
|
||||
cacheReasoningByKey(
|
||||
"request:req_reasoning_only:message:1",
|
||||
"request:req_reasoning_only:message:0",
|
||||
"deepseek",
|
||||
"deepseek-v4-flash",
|
||||
"cached reasoning only"
|
||||
@@ -690,131 +690,138 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
|
||||
test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => {
|
||||
clearReasoningCacheAll();
|
||||
cacheReasoningByKey(
|
||||
"toolu_kimi_claude",
|
||||
"kimi-coding",
|
||||
"kimi-for-coding",
|
||||
"cached thinking for Kimi tool call"
|
||||
);
|
||||
test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => {
|
||||
clearReasoningCacheAll();
|
||||
cacheReasoningByKey(
|
||||
"toolu_kimi_claude",
|
||||
"kimi-coding",
|
||||
"kimi-for-coding",
|
||||
"cached thinking for Kimi tool call"
|
||||
);
|
||||
|
||||
// Claude-format request: assistant has tool_use in content[] but NO thinking block
|
||||
// This simulates the scenario that causes infinite loops
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
reasoning_effort: "high",
|
||||
messages: [
|
||||
{ role: "user", content: "read the file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_kimi_claude",
|
||||
name: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
// Claude-format request: assistant has tool_use in content[] but NO thinking block
|
||||
// This simulates the scenario that causes infinite loops
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
reasoning_effort: "high",
|
||||
messages: [
|
||||
{ role: "user", content: "read the file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_kimi_claude",
|
||||
name: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistantMsg, "assistant message should exist");
|
||||
assert.ok(Array.isArray(assistantMsg.content), "content should be array");
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistantMsg, "assistant message should exist");
|
||||
assert.ok(Array.isArray(assistantMsg.content), "content should be array");
|
||||
|
||||
// Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use.
|
||||
const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking");
|
||||
assert.ok(thinkingBlock, "thinking block should be injected");
|
||||
assert.equal(thinkingBlock.thinking, "");
|
||||
// Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use.
|
||||
const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking");
|
||||
assert.ok(thinkingBlock, "thinking block should be injected");
|
||||
assert.equal(thinkingBlock.thinking, "");
|
||||
|
||||
// Thinking block should appear before tool_use
|
||||
const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock);
|
||||
const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use");
|
||||
assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use");
|
||||
// Thinking block should appear before tool_use
|
||||
const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock);
|
||||
const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use");
|
||||
assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use");
|
||||
|
||||
assert.equal(getReasoningCacheServiceStats().replays, 0);
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
assert.equal(getReasoningCacheServiceStats().replays, 0);
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
|
||||
test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => {
|
||||
clearReasoningCacheAll();
|
||||
test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => {
|
||||
clearReasoningCacheAll();
|
||||
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
reasoning_effort: "high",
|
||||
messages: [
|
||||
{ role: "user", content: "do it" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_miss", content: "output" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
reasoning_effort: "high",
|
||||
messages: [
|
||||
{ role: "user", content: "do it" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_miss", content: "output" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistantMsg, "assistant message should exist");
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistantMsg, "assistant message should exist");
|
||||
|
||||
const thinkingBlock =
|
||||
Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking");
|
||||
assert.ok(thinkingBlock, "thinking block should be injected on cache miss");
|
||||
assert.equal(thinkingBlock.thinking, "");
|
||||
const thinkingBlock =
|
||||
Array.isArray(assistantMsg.content) &&
|
||||
assistantMsg.content.find((b) => b?.type === "thinking");
|
||||
assert.ok(thinkingBlock, "thinking block should be injected on cache miss");
|
||||
assert.equal(thinkingBlock.thinking, "");
|
||||
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
|
||||
test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => {
|
||||
clearReasoningCacheAll();
|
||||
test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => {
|
||||
clearReasoningCacheAll();
|
||||
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "I already have this" },
|
||||
{ type: "tool_use", id: "toolu_existing", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_existing", content: "data" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"kimi-for-coding",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "I already have this" },
|
||||
{ type: "tool_use", id: "toolu_existing", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "toolu_existing", content: "data" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"kimi-coding"
|
||||
);
|
||||
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
const thinkingBlocks =
|
||||
Array.isArray(assistantMsg.content) &&
|
||||
assistantMsg.content.filter((b) => b?.type === "thinking");
|
||||
assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)");
|
||||
assert.equal(
|
||||
thinkingBlocks[0].thinking,
|
||||
"I already have this",
|
||||
"original thinking should be preserved"
|
||||
);
|
||||
const assistantMsg = result.messages.find((m) => m.role === "assistant");
|
||||
const thinkingBlocks =
|
||||
Array.isArray(assistantMsg.content) &&
|
||||
assistantMsg.content.filter((b) => b?.type === "thinking");
|
||||
assert.equal(
|
||||
thinkingBlocks?.length,
|
||||
1,
|
||||
"should have exactly one thinking block (no duplicate)"
|
||||
);
|
||||
assert.equal(
|
||||
thinkingBlocks[0].thinking,
|
||||
"I already have this",
|
||||
"original thinking should be preserved"
|
||||
);
|
||||
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
clearReasoningCacheAll();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user