mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
4 Commits
maint/cher
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b8faab94 | ||
|
|
074dccdc8e | ||
|
|
8cd3fd86b0 | ||
|
|
88e7699074 |
@@ -1,2 +0,0 @@
|
||||
- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing.
|
||||
- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers.
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "../config/codexIdentity.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
|
||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
@@ -223,6 +222,90 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip server-generated item IDs from the input array.
|
||||
*
|
||||
* The Codex /codex/responses endpoint does not persist response items even when
|
||||
* store=true is sent. When proxy clients (e.g. OpenClaw) include response items
|
||||
* from previous turns in the input array, those items carry server-assigned IDs
|
||||
* (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to
|
||||
* validate these IDs against its persistence store and returns 404 when the items
|
||||
* are not found (because store was effectively false).
|
||||
*
|
||||
* This function:
|
||||
* 1. Removes bare string references ("rs_abc123") from the input array
|
||||
* 2. Removes object items with type "item_reference" (explicit stored-item refs)
|
||||
* 3. Strips the "id" field from any object in input whose id matches a
|
||||
* server-generated prefix (rs_, fc_, resp_, msg_) — so the content is
|
||||
* preserved but the backend won't try to look it up
|
||||
*/
|
||||
export function stripStoredItemReferences(body: Record<string, unknown>): void {
|
||||
if (Array.isArray(body.input) && body.input.length === 0) {
|
||||
body.input = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "continue" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.input)) return;
|
||||
|
||||
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
|
||||
let strippedCount = 0;
|
||||
|
||||
body.input = body.input.filter((item) => {
|
||||
// Bare string references: "rs_abc123", "resp_abc123"
|
||||
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Object references: { type: "item_reference", id: "rs_..." }
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
!Array.isArray(item) &&
|
||||
(item as Record<string, unknown>).type === "item_reference"
|
||||
) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reasoning blobs (encrypted_content) are unusable with store=false since
|
||||
// previous_response_id is deleted — strip them to avoid wasting context
|
||||
// tokens (O(n^2) growth across agentic turns).
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
!Array.isArray(item) &&
|
||||
(item as Record<string, unknown>).type === "reasoning"
|
||||
) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Object items with server-generated IDs: strip the id field but keep the item.
|
||||
// e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id
|
||||
// e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id
|
||||
if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) {
|
||||
delete record.id;
|
||||
strippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (strippedCount > 0) {
|
||||
console.debug(
|
||||
`[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): void {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
@@ -1213,7 +1296,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
|
||||
// This MUST run before convertSystemToDeveloperRole.
|
||||
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
|
||||
if (!body.input && Array.isArray(body.messages)) {
|
||||
body.input = body.messages.map((msg: ResponsesMessageInput) => ({
|
||||
type: "message",
|
||||
@@ -1336,6 +1419,11 @@ export class CodexExecutor extends BaseExecutor {
|
||||
preserveCustomTools: nativeCodexPassthrough,
|
||||
});
|
||||
|
||||
// Strip stored response item references (rs_, resp_, msg_ IDs) from input.
|
||||
// The /codex/responses endpoint does not persist responses even with store=true,
|
||||
// so any references to previous response items would cause 404 errors.
|
||||
stripStoredItemReferences(body);
|
||||
|
||||
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
|
||||
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
|
||||
delete body.messages;
|
||||
@@ -1427,11 +1515,6 @@ export class CodexExecutor extends BaseExecutor {
|
||||
delete body.session_id;
|
||||
delete body.conversation_id;
|
||||
|
||||
applyResponsesInputPolicy(
|
||||
body,
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
|
||||
);
|
||||
|
||||
if (nativeCodexPassthrough) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe
|
||||
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
|
||||
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
|
||||
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
|
||||
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
|
||||
import {
|
||||
getHeaderValueCaseInsensitive,
|
||||
isNoMemoryRequested,
|
||||
@@ -208,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,
|
||||
@@ -367,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
|
||||
@@ -387,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,
|
||||
@@ -424,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,
|
||||
@@ -437,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", {
|
||||
@@ -1065,13 +1071,6 @@ export async function handleChatCore({
|
||||
return cacheHit;
|
||||
}
|
||||
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") {
|
||||
applyResponsesInputPolicy(
|
||||
body as Record<string, unknown>,
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
|
||||
);
|
||||
}
|
||||
|
||||
body = sanitizeChatRequestBody(body, sourceFormat, targetFormat);
|
||||
// Per-request opt-out: clients that manage their own context send
|
||||
// `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner
|
||||
@@ -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();
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/;
|
||||
|
||||
/**
|
||||
* Applies the persistence-independent policy for replayed Responses input items.
|
||||
* Stored references can only be resolved by the upstream that created them, so
|
||||
* they are always removed. Self-contained encrypted reasoning is retained only
|
||||
* when the selected connection explicitly opts in.
|
||||
*/
|
||||
export function applyResponsesInputPolicy(
|
||||
body: Record<string, unknown>,
|
||||
preserveEncryptedReasoning = false
|
||||
): void {
|
||||
if (Array.isArray(body.input) && body.input.length === 0) {
|
||||
body.input = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "continue" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.input)) return;
|
||||
|
||||
body.input = body.input.filter((item) => {
|
||||
if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record =
|
||||
item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null;
|
||||
if (!record) return true;
|
||||
|
||||
if (record.type === "item_reference") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
record.type === "reasoning" &&
|
||||
(!preserveEncryptedReasoning ||
|
||||
typeof record.encrypted_content !== "string" ||
|
||||
record.encrypted_content.trim().length === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) {
|
||||
delete record.id;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
|
||||
@@ -57,6 +58,7 @@ import AgentrouterConsoleFields from "./AgentrouterConsoleFields";
|
||||
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
|
||||
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
|
||||
import ProviderRegionField, { getProviderRegionConfig } from "./AlibabaProviderRegionField";
|
||||
|
||||
export interface EditConnectionModalConnection {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -71,6 +73,7 @@ export interface EditConnectionModalConnection {
|
||||
healthCheckInterval?: number;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface EditConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
connection: EditConnectionModalConnection | null;
|
||||
@@ -81,7 +84,9 @@ export interface EditConnectionModalProps {
|
||||
onResyncModels?: (connectionId: string) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const stringField = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export default function EditConnectionModal({
|
||||
isOpen,
|
||||
connection,
|
||||
@@ -122,7 +127,6 @@ export default function EditConnectionModal({
|
||||
codexReasoningEffort: "medium",
|
||||
codexServiceTier: "default" as CodexServiceTier,
|
||||
codexOpenaiStoreEnabled: false,
|
||||
preserveEncryptedReasoning: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
newApiAggregatorBalance: false,
|
||||
@@ -165,6 +169,7 @@ export default function EditConnectionModal({
|
||||
>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const showEmail = useEmailPrivacyStore((state) => state.emailsVisible);
|
||||
|
||||
// #6147 — built-in providers can opt in to an advanced base-URL override.
|
||||
// OAuth connections are excluded: their save path does not persist
|
||||
// providerSpecificData.baseUrl.
|
||||
@@ -188,13 +193,6 @@ export default function EditConnectionModal({
|
||||
const openRouterPreset = useOpenRouterPresetControl(provider, t);
|
||||
const setOpenRouterPreset = openRouterPreset.setValue;
|
||||
const isCodex = provider === "codex";
|
||||
const isResponsesConnection =
|
||||
isCodex ||
|
||||
provider === "openai" ||
|
||||
(isOpenAICompatibleProvider(provider) &&
|
||||
(provider.startsWith("openai-compatible-responses-") ||
|
||||
connectionProviderSpecificData?.apiType === "responses" ||
|
||||
formData.targetFormat === "openai-responses"));
|
||||
const isClaude = provider === "claude";
|
||||
const isAntigravityFamily = provider === "antigravity" || provider === "agy";
|
||||
const localProviderMetadata = getLocalProviderMetadata(provider);
|
||||
@@ -241,6 +239,7 @@ export default function EditConnectionModal({
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && connection) {
|
||||
const effectiveProvider = connection.provider || providerId;
|
||||
@@ -319,8 +318,6 @@ export default function EditConnectionModal({
|
||||
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
|
||||
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
|
||||
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
preserveEncryptedReasoning:
|
||||
connection.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
consoleApiKey: existingConsoleApiKey,
|
||||
newApiUserId: existingNewApiUserId,
|
||||
newApiAggregatorBalance: connection.providerSpecificData?.newApiAggregatorBalance === true,
|
||||
@@ -381,6 +378,7 @@ export default function EditConnectionModal({
|
||||
defaultRegion,
|
||||
setOpenRouterPreset,
|
||||
]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!provider) return;
|
||||
setTesting(true);
|
||||
@@ -409,6 +407,7 @@ export default function EditConnectionModal({
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (
|
||||
!provider ||
|
||||
@@ -441,6 +440,7 @@ export default function EditConnectionModal({
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddParsedExtraKeys = (raw: string) => {
|
||||
const { added, duplicates } = parseExtraApiKeys(raw, extraApiKeys);
|
||||
if (added.length > 0) {
|
||||
@@ -451,6 +451,7 @@ export default function EditConnectionModal({
|
||||
notify.warning(t("bulkPasteDuplicatesIgnored", { count: duplicates }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
@@ -466,12 +467,14 @@ export default function EditConnectionModal({
|
||||
}
|
||||
parsedMaxConcurrent = numericMaxConcurrent;
|
||||
}
|
||||
|
||||
const updates: any = {
|
||||
name: formData.name,
|
||||
priority: formData.priority,
|
||||
maxConcurrent: parsedMaxConcurrent,
|
||||
healthCheckInterval: formData.healthCheckInterval,
|
||||
};
|
||||
|
||||
const overrides: Record<string, number> = {};
|
||||
if (formData.rpm.trim()) overrides.rpm = Number(formData.rpm);
|
||||
if (formData.tpm.trim()) overrides.tpm = Number(formData.tpm);
|
||||
@@ -480,13 +483,16 @@ export default function EditConnectionModal({
|
||||
if (formData.rateLimitMaxConcurrent.trim())
|
||||
overrides.maxConcurrent = Number(formData.rateLimitMaxConcurrent);
|
||||
updates.rateLimitOverrides = Object.keys(overrides).length > 0 ? overrides : null;
|
||||
|
||||
if (isAntigravityFamily) {
|
||||
updates.projectId = trimmedCloudCodeProjectId || null;
|
||||
}
|
||||
|
||||
if (isGooglePse && !formData.cx.trim()) {
|
||||
setSaveError(t("searchEngineIdRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
let validatedBaseUrl = null;
|
||||
if (usesBaseUrl) {
|
||||
// #6147 — an opt-in override left blank clears it (no default to fall
|
||||
@@ -502,6 +508,7 @@ export default function EditConnectionModal({
|
||||
validatedBaseUrl = checked.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOAuth && formData.apiKey) {
|
||||
updates.apiKey = formData.apiKey;
|
||||
let isValid = validationResult === "success";
|
||||
@@ -604,10 +611,6 @@ export default function EditConnectionModal({
|
||||
updates.providerSpecificData.targetFormat = formData.targetFormat || null;
|
||||
}
|
||||
}
|
||||
if (isResponsesConnection && updates.providerSpecificData) {
|
||||
updates.providerSpecificData.preserveEncryptedReasoning =
|
||||
formData.preserveEncryptedReasoning === true;
|
||||
}
|
||||
const freeOnlyChanged =
|
||||
showFreeModelsToggle &&
|
||||
formData.importFreeModelsOnly !==
|
||||
@@ -631,24 +634,15 @@ export default function EditConnectionModal({
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
const testErrorMeta =
|
||||
!testResult?.valid && testResult?.diagnosis?.type
|
||||
? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null
|
||||
: null;
|
||||
const preserveEncryptedReasoningToggle = isResponsesConnection ? (
|
||||
<Toggle
|
||||
checked={formData.preserveEncryptedReasoning}
|
||||
onChange={(checked) => setFormData({ ...formData, preserveEncryptedReasoning: checked })}
|
||||
label={providerText(t, "preserveEncryptedReasoningLabel", "Preserve encrypted reasoning")}
|
||||
description={providerText(
|
||||
t,
|
||||
"preserveEncryptedReasoningDescription",
|
||||
"Forward encrypted Responses reasoning items supplied by the client."
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={t("editConnection")} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -742,7 +736,6 @@ export default function EditConnectionModal({
|
||||
description={t("importFreeModelsOnlyHint")}
|
||||
/>
|
||||
)}
|
||||
{preserveEncryptedReasoningToggle}
|
||||
<Toggle
|
||||
checked={formData.disableCooling}
|
||||
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
|
||||
@@ -1032,6 +1025,7 @@ export default function EditConnectionModal({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* #6147 — opt-in "Advanced → override base URL" for eligible built-ins */}
|
||||
{!usesBaseUrl && isBaseUrlOverrideEligible && (
|
||||
<button
|
||||
@@ -1042,6 +1036,7 @@ export default function EditConnectionModal({
|
||||
{providerText(t, "overrideBaseUrlAdvanced", "Advanced: override base URL")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{usesBaseUrl && (
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
@@ -1060,6 +1055,7 @@ export default function EditConnectionModal({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProtocolSelector && (
|
||||
<Select
|
||||
label={providerText(t, "apiProtocolLabel", "API protocol")}
|
||||
@@ -1079,11 +1075,13 @@ export default function EditConnectionModal({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProviderRegionField
|
||||
provider={provider}
|
||||
value={formData.region}
|
||||
onChange={(region) => setFormData({ ...formData, region })}
|
||||
/>
|
||||
|
||||
{isCloudflare && (
|
||||
<Input
|
||||
label={t("accountIdLabel")}
|
||||
@@ -1093,6 +1091,7 @@ export default function EditConnectionModal({
|
||||
hint={t("accountIdHint")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGlm && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
@@ -1116,6 +1115,7 @@ export default function EditConnectionModal({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOAuth && connection?.apiKey && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">{t("apiKeyHealthLabel")}</label>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { z } from "zod";
|
||||
import { Button, Card, Modal } from "@/shared/components";
|
||||
import { useProxyBatchOperations } from "./useProxyBatchOperations";
|
||||
import { ProxyStatusBadge } from "./ProxyStatusBadge";
|
||||
@@ -16,90 +15,32 @@ import {
|
||||
} from "./parseBulkProxyImport";
|
||||
import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions";
|
||||
import type { ProxyItem } from "./proxyRegistryTypes";
|
||||
import {
|
||||
BULK_IMPORT_PLACEHOLDER,
|
||||
EMPTY_FORM,
|
||||
type HealthInfo,
|
||||
type ProxyRegistryManagerProps,
|
||||
type TestResult,
|
||||
type UsageInfo,
|
||||
} from "./proxyRegistryConstants";
|
||||
import {
|
||||
loadAllProxyUsage,
|
||||
loadProxyHealth,
|
||||
loadProxyUsage,
|
||||
repairRelayResponseSchema,
|
||||
} from "./proxyRegistryData";
|
||||
|
||||
type UsageInfo = {
|
||||
count: number;
|
||||
assignments: Array<{ scope: string; scopeId: string | null }>;
|
||||
};
|
||||
|
||||
type HealthInfo = {
|
||||
proxyId: string;
|
||||
totalRequests: number;
|
||||
successRate: number | null;
|
||||
avgLatencyMs: number | null;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
type TestResult = {
|
||||
success: boolean;
|
||||
publicIp?: string;
|
||||
latencyMs?: number;
|
||||
country?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
type: "http",
|
||||
host: "",
|
||||
port: "8080",
|
||||
username: "",
|
||||
password: "",
|
||||
region: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
family: "auto",
|
||||
};
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# FORMAT 1 — Pipe-delimited (full control):
|
||||
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
#
|
||||
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
|
||||
# ip:port → no auth, type defaults to socks5
|
||||
# ip:port:user:pass → with auth
|
||||
# user:pass@ip:port → with auth (@-style)
|
||||
# user:pass:ip:port → with auth (user-pass-first)
|
||||
# protocol://ip:port → explicit protocol
|
||||
# protocol://user:pass@ip:port → explicit protocol + auth
|
||||
#
|
||||
# FORMAT 3 — Protocol header mode:
|
||||
# Put a bare protocol (http, https, socks5) on its own line to set
|
||||
# the default type for all subsequent shorthand lines that don't
|
||||
# include an explicit protocol:// prefix.
|
||||
#
|
||||
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Pipe-delimited examples:
|
||||
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
|
||||
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
|
||||
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
|
||||
#
|
||||
# Shorthand examples:
|
||||
# 138.99.147.218:50101
|
||||
# 138.99.147.218:50101:myuser:mypass
|
||||
# myuser:mypass@138.99.147.218:50101
|
||||
# myuser:mypass:138.99.147.218:50101
|
||||
# http://10.0.0.50:8080
|
||||
# https://admin:secret123@proxy.example.com:443
|
||||
#
|
||||
# Protocol header mode example:
|
||||
# socks5
|
||||
# 138.99.147.218:50101:myuser:mypass
|
||||
# 200.234.177.62:50101:otheruser:otherpass
|
||||
#`;
|
||||
|
||||
export default function ProxyRegistryManager({
|
||||
export default function ProxyRegistryManager({
|
||||
onRedeployRelay,
|
||||
}: {
|
||||
onRedeployRelay?: (proxy: ProxyItem) => void;
|
||||
} = {}) {
|
||||
showVercelRelay = false,
|
||||
showDenoRelay = false,
|
||||
showCloudflareRelay = false,
|
||||
onOpenVercelRelay,
|
||||
onOpenDenoRelay,
|
||||
onOpenCloudflareRelay,
|
||||
}: ProxyRegistryManagerProps = {}) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const settingsT = useTranslations("settings");
|
||||
const [items, setItems] = useState<ProxyItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -135,7 +76,7 @@ export default function ProxyRegistryManager({
|
||||
const [poolLoaded, setPoolLoaded] = useState(false);
|
||||
const [poolSaving, setPoolSaving] = useState(false);
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState(false);
|
||||
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
|
||||
const [bulkImportText, setBulkImportText] = useState("");
|
||||
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
|
||||
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
|
||||
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
|
||||
@@ -146,53 +87,40 @@ export default function ProxyRegistryManager({
|
||||
updated: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [relayMenuOpen, setRelayMenuOpen] = useState(false);
|
||||
const actionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const relayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsOpen && !relayMenuOpen) return;
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (actionsOpen && actionsRef.current && !actionsRef.current.contains(target)) {
|
||||
setActionsOpen(false);
|
||||
}
|
||||
if (relayMenuOpen && relayRef.current && !relayRef.current.contains(target)) {
|
||||
setRelayMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [actionsOpen, relayMenuOpen]);
|
||||
|
||||
const closeActions = () => {
|
||||
setActionsOpen(false);
|
||||
setRelayMenuOpen(false);
|
||||
};
|
||||
|
||||
const editingId = useMemo(() => form.id || "", [form.id]);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/health?hours=24");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
const entries = Array.isArray(data?.items) ? data.items : [];
|
||||
const mapped = Object.fromEntries(
|
||||
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
|
||||
) as Record<string, HealthInfo>;
|
||||
setHealthById(mapped);
|
||||
} catch {
|
||||
// ignore health loading errors in UI
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAllUsage = useCallback(async (proxyIds: string[]) => {
|
||||
if (!proxyIds.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
proxyIds.map((id) =>
|
||||
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
|
||||
Array.isArray(data?.items) ? data.items : [];
|
||||
// Deduplicate by scope+scopeId — prevents double-counting when both
|
||||
// a provider-scope and account-scope row exist for the same proxy
|
||||
const seen = new Set<string>();
|
||||
const assignments = rawAssignments.filter((a) => {
|
||||
const key = `${a.scope}:${a.scopeId ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
|
||||
})
|
||||
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
|
||||
)
|
||||
);
|
||||
setUsageById(Object.fromEntries(results));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
const loadHealth = useCallback(() => loadProxyHealth(setHealthById), []);
|
||||
const loadAllUsage = useCallback(
|
||||
(proxyIds: string[]) => loadAllProxyUsage(proxyIds, setUsageById),
|
||||
[]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -240,17 +168,9 @@ export default function ProxyRegistryManager({
|
||||
|
||||
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
|
||||
|
||||
const handleBatchDelete = useCallback(() => {
|
||||
hookHandleBatchDelete(setError);
|
||||
}, [hookHandleBatchDelete, setError]);
|
||||
|
||||
const handleBatchActivate = useCallback(() => {
|
||||
hookHandleBatchActivate(setError, "active");
|
||||
}, [hookHandleBatchActivate, setError]);
|
||||
|
||||
const handleAutoTestAll = useCallback(() => {
|
||||
hookHandleAutoTestAll(setError, setTestById);
|
||||
}, [hookHandleAutoTestAll, setError, setTestById]);
|
||||
const handleBatchDelete = () => hookHandleBatchDelete(setError);
|
||||
const handleBatchActivate = () => hookHandleBatchActivate(setError, "active");
|
||||
const handleAutoTestAll = () => hookHandleAutoTestAll(setError, setTestById);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -284,33 +204,7 @@ export default function ProxyRegistryManager({
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const loadUsage = async (proxyId: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
|
||||
data?.items
|
||||
)
|
||||
? data.items
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const assignments = rawAssignments.filter((a) => {
|
||||
const key = `${a.scope}:${a.scopeId ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
setUsageById((prev) => ({
|
||||
...prev,
|
||||
[proxyId]: { count: assignments.length, assignments },
|
||||
}));
|
||||
} catch {
|
||||
// ignore usage loading errors in UI
|
||||
}
|
||||
};
|
||||
const loadUsage = (proxyId: string) => loadProxyUsage(proxyId, setUsageById);
|
||||
|
||||
const handleTestProxy = async (item: ProxyItem) => {
|
||||
if (testingId) return;
|
||||
@@ -345,12 +239,6 @@ export default function ProxyRegistryManager({
|
||||
}
|
||||
};
|
||||
|
||||
const repairRelayResponseSchema = z.object({
|
||||
repaired: z.boolean().optional(),
|
||||
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
|
||||
error: z.object({ message: z.string() }).optional(),
|
||||
});
|
||||
|
||||
const handleRepairRelay = async (item: ProxyItem) => {
|
||||
if (repairingId || !item.relayInfo?.isRelay) return;
|
||||
setRepairingId(item.id);
|
||||
@@ -724,7 +612,7 @@ export default function ProxyRegistryManager({
|
||||
};
|
||||
|
||||
const openBulkImport = () => {
|
||||
setBulkImportText(BULK_IMPORT_TEMPLATE);
|
||||
setBulkImportText("");
|
||||
setBulkImportParsed([]);
|
||||
setBulkImportErrors([]);
|
||||
setBulkImportSkipped(0);
|
||||
@@ -736,49 +624,13 @@ export default function ProxyRegistryManager({
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
<div className="w-full min-w-0">
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upgrade"
|
||||
onClick={handleMigrate}
|
||||
loading={migrating}
|
||||
data-testid="proxy-registry-import-legacy"
|
||||
>
|
||||
{t("importLegacy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={openBulkImport}
|
||||
data-testid="proxy-registry-open-bulk-import"
|
||||
>
|
||||
{t("bulkImport")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="account_tree"
|
||||
onClick={() => setBulkOpen(true)}
|
||||
data-testid="proxy-registry-open-bulk"
|
||||
>
|
||||
{t("bulkAssign")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="hub"
|
||||
onClick={openPool}
|
||||
data-testid="proxy-registry-open-pool"
|
||||
>
|
||||
{t("managePool")}
|
||||
</Button>
|
||||
<div className="w-full border-t border-border" aria-hidden="true" />
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
<ProxyBatchActions
|
||||
selectedCount={selectedIds.size}
|
||||
batchDeleting={batchDeleting}
|
||||
@@ -788,6 +640,152 @@ export default function ProxyRegistryManager({
|
||||
onBatchActivate={handleBatchActivate}
|
||||
onAutoTestAll={handleAutoTestAll}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="hub"
|
||||
onClick={openPool}
|
||||
data-testid="proxy-registry-open-pool"
|
||||
>
|
||||
{t("managePool")}
|
||||
</Button>
|
||||
{showAnyRelay && (
|
||||
<div className="relative inline-flex items-center" ref={relayRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="rocket_launch"
|
||||
iconRight="expand_more"
|
||||
onClick={() => {
|
||||
setRelayMenuOpen((value) => !value);
|
||||
setActionsOpen(false);
|
||||
}}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={relayMenuOpen}
|
||||
data-testid="proxy-registry-deploy-relay"
|
||||
>
|
||||
{settingsT("deployRelayButton")}
|
||||
</Button>
|
||||
{relayMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
|
||||
role="menu"
|
||||
>
|
||||
{showVercelRelay && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="cloud_upload"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onOpenVercelRelay?.();
|
||||
closeActions();
|
||||
}}
|
||||
>
|
||||
{settingsT("vercelRelayButton")}
|
||||
</Button>
|
||||
)}
|
||||
{showDenoRelay && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="terminal"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onOpenDenoRelay?.();
|
||||
closeActions();
|
||||
}}
|
||||
>
|
||||
{settingsT("denoRelayButton")}
|
||||
</Button>
|
||||
)}
|
||||
{showCloudflareRelay && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="cloud"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
onOpenCloudflareRelay?.();
|
||||
closeActions();
|
||||
}}
|
||||
>
|
||||
{settingsT("cloudflareRelayButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative inline-flex items-center" ref={actionsRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setActionsOpen((value) => !value);
|
||||
setRelayMenuOpen(false);
|
||||
}}
|
||||
aria-label="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={actionsOpen}
|
||||
data-testid="proxy-registry-more-actions"
|
||||
>
|
||||
⋯
|
||||
</Button>
|
||||
{actionsOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
|
||||
role="menu"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="upload_file"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
openBulkImport();
|
||||
closeActions();
|
||||
}}
|
||||
data-testid="proxy-registry-open-bulk-import"
|
||||
>
|
||||
{t("bulkImport")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="upload_file"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
handleMigrate();
|
||||
closeActions();
|
||||
}}
|
||||
loading={migrating}
|
||||
data-testid="proxy-registry-import-legacy"
|
||||
>
|
||||
{t("importLegacy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="account_tree"
|
||||
fullWidth
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
setBulkOpen(true);
|
||||
closeActions();
|
||||
}}
|
||||
data-testid="proxy-registry-open-bulk"
|
||||
>
|
||||
{t("bulkAssign")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
@@ -1328,9 +1326,10 @@ export default function ProxyRegistryManager({
|
||||
<div>
|
||||
<textarea
|
||||
data-testid="proxy-registry-bulk-import-textarea"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed placeholder:whitespace-pre-wrap placeholder:text-text-muted/70"
|
||||
rows={14}
|
||||
value={bulkImportText}
|
||||
placeholder={BULK_IMPORT_PLACEHOLDER}
|
||||
onChange={(e) => {
|
||||
setBulkImportText(e.target.value);
|
||||
setBulkImportParsedOnce(false);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProxyRegistryManager from "../ProxyRegistryManager";
|
||||
import VercelRelayModal from "./VercelRelayModal";
|
||||
@@ -13,27 +12,10 @@ export default function ProxyPoolTab() {
|
||||
const [vercelModalOpen, setVercelModalOpen] = useState(false);
|
||||
const [denoModalOpen, setDenoModalOpen] = useState(false);
|
||||
const [cloudflareModalOpen, setCloudflareModalOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false";
|
||||
const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false";
|
||||
const showCloudflareRelay = process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED !== "false";
|
||||
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
|
||||
|
||||
// Close the dropdown on outside click — mirrors the upstream PR-1437
|
||||
// grouped-button UX so adding more relay backends does not blow up the
|
||||
// toolbar horizontally.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
const handleVercelDeployed = (_poolProxyId: string, relayUrl: string) => {
|
||||
alert(`${t("vercelRelaySuccess")}: ${relayUrl}`);
|
||||
@@ -50,79 +32,15 @@ export default function ProxyPoolTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showAnyRelay && (
|
||||
<div className="flex justify-end">
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="rocket_launch"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
{t("deployRelayButton")}
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-xl">
|
||||
{showVercelRelay && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setVercelModalOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
cloud_upload
|
||||
</span>
|
||||
{t("vercelRelayButton")}
|
||||
</button>
|
||||
)}
|
||||
{showDenoRelay && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDenoModalOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
terminal
|
||||
</span>
|
||||
{t("denoRelayButton")}
|
||||
</button>
|
||||
)}
|
||||
{showCloudflareRelay && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCloudflareModalOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
cloud
|
||||
</span>
|
||||
{t("cloudflareRelayButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ProxyRegistryManager onRedeployRelay={handleRedeployRelay} />
|
||||
<ProxyRegistryManager
|
||||
onRedeployRelay={handleRedeployRelay}
|
||||
showVercelRelay={showVercelRelay}
|
||||
showDenoRelay={showDenoRelay}
|
||||
showCloudflareRelay={showCloudflareRelay}
|
||||
onOpenVercelRelay={() => setVercelModalOpen(true)}
|
||||
onOpenDenoRelay={() => setDenoModalOpen(true)}
|
||||
onOpenCloudflareRelay={() => setCloudflareModalOpen(true)}
|
||||
/>
|
||||
<VercelRelayModal
|
||||
isOpen={vercelModalOpen}
|
||||
onClose={() => setVercelModalOpen(false)}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ProxyItem } from "./proxyRegistryTypes";
|
||||
|
||||
export type UsageInfo = {
|
||||
count: number;
|
||||
assignments: Array<{ scope: string; scopeId: string | null }>;
|
||||
};
|
||||
|
||||
export type HealthInfo = {
|
||||
proxyId: string;
|
||||
totalRequests: number;
|
||||
successRate: number | null;
|
||||
avgLatencyMs: number | null;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
export type TestResult = {
|
||||
success: boolean;
|
||||
publicIp?: string;
|
||||
latencyMs?: number;
|
||||
country?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
type: "http",
|
||||
host: "",
|
||||
port: "8080",
|
||||
username: "",
|
||||
password: "",
|
||||
region: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
family: "auto",
|
||||
};
|
||||
|
||||
export const BULK_IMPORT_PLACEHOLDER = `# Proxy Bulk Import
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# FORMAT 1 — Pipe-delimited (full control):
|
||||
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
#
|
||||
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
|
||||
# ip:port → no auth, type defaults to socks5
|
||||
# ip:port:user:pass → with auth
|
||||
# user:pass@ip:port → with auth (@-style)
|
||||
# user:pass:ip:port → with auth (user-pass-first)
|
||||
# protocol://ip:port → explicit protocol
|
||||
# protocol://user:pass@ip:port → explicit protocol + auth
|
||||
#
|
||||
# FORMAT 3 — Protocol header mode:
|
||||
# Put a bare protocol (http, https, socks5) on its own line to set
|
||||
# the default type for all subsequent shorthand lines that don't
|
||||
# include an explicit protocol:// prefix.
|
||||
#
|
||||
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Pipe-delimited examples:
|
||||
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
|
||||
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
|
||||
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
|
||||
#
|
||||
# Shorthand examples:
|
||||
# 138.99.147.218:50101
|
||||
# 138.99.147.218:50101:myuser:mypass
|
||||
# myuser:mypass@138.99.147.218:50101
|
||||
# myuser:mypass:138.99.147.218:50101
|
||||
# http://10.0.0.50:8080
|
||||
# https://admin:secret123@proxy.example.com:443
|
||||
#
|
||||
# Protocol header mode example:
|
||||
# socks5
|
||||
# 138.99.147.218:50101:myuser:mypass
|
||||
# 200.234.177.62:50101:otheruser:otherpass
|
||||
#`;
|
||||
|
||||
export type ProxyRegistryManagerProps = {
|
||||
onRedeployRelay?: (proxy: ProxyItem) => void;
|
||||
showVercelRelay?: boolean;
|
||||
showDenoRelay?: boolean;
|
||||
showCloudflareRelay?: boolean;
|
||||
onOpenVercelRelay?: () => void;
|
||||
onOpenDenoRelay?: () => void;
|
||||
onOpenCloudflareRelay?: () => void;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
import type { HealthInfo, UsageInfo } from "./proxyRegistryConstants";
|
||||
|
||||
type SetState<T> = (value: T | ((previous: T) => T)) => void;
|
||||
type Assignment = { scope: string; scopeId: string | null };
|
||||
|
||||
function uniqueAssignments(assignments: Assignment[]) {
|
||||
const seen = new Set<string>();
|
||||
return assignments.filter((assignment) => {
|
||||
const key = `${assignment.scope}:${assignment.scopeId ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadProxyHealth(setHealthById: SetState<Record<string, HealthInfo>>) {
|
||||
try {
|
||||
const response = await fetch("/api/settings/proxies/health?hours=24");
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) return;
|
||||
const entries = Array.isArray(data?.items) ? data.items : [];
|
||||
setHealthById(Object.fromEntries(entries.map((entry: HealthInfo) => [entry.proxyId, entry])));
|
||||
} catch {
|
||||
// Ignore health-loading errors in the UI.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAllProxyUsage(
|
||||
proxyIds: string[],
|
||||
setUsageById: SetState<Record<string, UsageInfo>>
|
||||
) {
|
||||
if (!proxyIds.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
proxyIds.map((id) =>
|
||||
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
const assignments = uniqueAssignments(
|
||||
Array.isArray(data?.items) ? data.items : []
|
||||
);
|
||||
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
|
||||
})
|
||||
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
|
||||
)
|
||||
);
|
||||
setUsageById(Object.fromEntries(results));
|
||||
} catch {
|
||||
// Ignore usage-loading errors in the UI.
|
||||
}
|
||||
}
|
||||
|
||||
export const repairRelayResponseSchema = z.object({
|
||||
repaired: z.boolean().optional(),
|
||||
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
|
||||
error: z.object({ message: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export async function loadProxyUsage(
|
||||
proxyId: string,
|
||||
setUsageById: SetState<Record<string, UsageInfo>>
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) return;
|
||||
const assignments = uniqueAssignments(Array.isArray(data?.items) ? data.items : []);
|
||||
setUsageById((previous) => ({
|
||||
...previous,
|
||||
[proxyId]: { count: assignments.length, assignments },
|
||||
}));
|
||||
} catch {
|
||||
// Ignore usage-loading errors in the UI.
|
||||
}
|
||||
}
|
||||
@@ -20,29 +20,6 @@ const SENSITIVE_KEYS = new Set([
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const ENCRYPTED_REASONING_KEY = "encrypted_content";
|
||||
|
||||
function encryptedReasoningOmissionMarker(length?: number): string {
|
||||
return length === undefined
|
||||
? "[omitted: encrypted reasoning]"
|
||||
: `[omitted: encrypted reasoning, ${length} chars]`;
|
||||
}
|
||||
|
||||
// Matches a JSON string field in captured SSE text. Alternatives inside the value are disjoint,
|
||||
// keeping the scan linear even for large encrypted blobs.
|
||||
const SERIALIZED_ENCRYPTED_REASONING_RE = /(\"encrypted_content\"\s*:\s*\")((?:\\.|[^\"\\])*)\"/g;
|
||||
const STREAM_CHUNK_TIMESTAMP_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] /;
|
||||
|
||||
export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[] {
|
||||
const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join("");
|
||||
let found = false;
|
||||
const omitted = combined.replace(SERIALIZED_ENCRYPTED_REASONING_RE, (_match, prefix: string) => {
|
||||
found = true;
|
||||
return `${prefix}${encryptedReasoningOmissionMarker()}\"`;
|
||||
});
|
||||
return found ? [omitted] : chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other
|
||||
* typed arrays). `Array.isArray()` returns false for these, so callers that
|
||||
@@ -79,28 +56,6 @@ export function normalizePayloadForLog(payload: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove opaque encrypted reasoning from log copies. The value is replayable by clients but
|
||||
* provides no useful diagnostics, so retaining its size is sufficient for observability.
|
||||
*/
|
||||
export function omitEncryptedReasoningForLog(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
if (isOpaqueBinary(payload)) return describeOpaqueBinary(payload);
|
||||
if (Array.isArray(payload)) return payload.map(omitEncryptedReasoningForLog);
|
||||
|
||||
const omitted: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === ENCRYPTED_REASONING_KEY && typeof value === "string" && value.length > 0) {
|
||||
omitted[key] = encryptedReasoningOmissionMarker(value.length);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
omitted[key] = omitEncryptedReasoningForLog(value);
|
||||
} else {
|
||||
omitted[key] = value;
|
||||
}
|
||||
}
|
||||
return omitted;
|
||||
}
|
||||
|
||||
export function redactPayload(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
if (isOpaqueBinary(payload)) return describeOpaqueBinary(payload);
|
||||
@@ -145,8 +100,7 @@ export function sanitizePayloadPII(payload: unknown): unknown {
|
||||
export function protectPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
|
||||
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
|
||||
const piiSanitized = sanitizePayloadPII(normalized);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,13 +193,6 @@ export function normalizeProviderSpecificData(
|
||||
delete normalized.openaiStoreEnabled;
|
||||
}
|
||||
|
||||
if (
|
||||
"preserveEncryptedReasoning" in normalized &&
|
||||
typeof normalized.preserveEncryptedReasoning !== "boolean"
|
||||
) {
|
||||
delete normalized.preserveEncryptedReasoning;
|
||||
}
|
||||
|
||||
if ("blockExtraUsage" in normalized && typeof normalized.blockExtraUsage !== "boolean") {
|
||||
delete normalized.blockExtraUsage;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
|
||||
import { sanitizePII } from "../../piiSanitizer";
|
||||
import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads";
|
||||
import { protectPayloadForLog } from "../../logPayloads";
|
||||
import type { CallLogDetailState } from "../callLogArtifacts";
|
||||
// #7879: re-export the canonical helper so existing consumers of this module
|
||||
// keep importing `toNumber` from here unchanged.
|
||||
@@ -79,12 +79,9 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
|
||||
if (key === "streamChunks" && value && typeof value === "object") {
|
||||
const chunks = value as Record<string, unknown>;
|
||||
const compacted = Object.fromEntries(
|
||||
Object.entries(chunks)
|
||||
.filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0)
|
||||
.map(([stage, chunkValue]) => [
|
||||
stage,
|
||||
omitEncryptedReasoningFromLogChunks(chunkValue as string[]),
|
||||
])
|
||||
Object.entries(chunks).filter(
|
||||
([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0
|
||||
)
|
||||
);
|
||||
if (Object.keys(compacted).length > 0) {
|
||||
protectedPayloads.streamChunks = protectPayloadForLog(
|
||||
|
||||
@@ -154,15 +154,6 @@ export function validateProviderSpecificData(
|
||||
});
|
||||
}
|
||||
|
||||
const preserveEncryptedReasoning = data.preserveEncryptedReasoning;
|
||||
if (preserveEncryptedReasoning !== undefined && typeof preserveEncryptedReasoning !== "boolean") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.preserveEncryptedReasoning must be a boolean",
|
||||
path: ["preserveEncryptedReasoning"],
|
||||
});
|
||||
}
|
||||
|
||||
const blockExtraUsage = data.blockExtraUsage;
|
||||
if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -4,8 +4,10 @@ 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-chatcore-translation-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
@@ -47,12 +49,14 @@ const { resetPayloadRulesConfigForTests, setPayloadRulesConfig } =
|
||||
await import("../../open-sse/services/payloadRules.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { register, getRequestTranslator } = await import("../../open-sse/translator/registry.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalResponsesToOpenAI = getRequestTranslator(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI);
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalBackgroundConfig = getBackgroundDegradationConfig();
|
||||
const originalCallLogPipelineCaptureStreamChunks =
|
||||
process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS;
|
||||
|
||||
function noopLog() {
|
||||
return {
|
||||
debug() {},
|
||||
@@ -61,6 +65,7 @@ function noopLog() {
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
function restorePipelineCaptureEnv() {
|
||||
if (originalCallLogPipelineCaptureStreamChunks === undefined) {
|
||||
delete process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS;
|
||||
@@ -69,6 +74,7 @@ function restorePipelineCaptureEnv() {
|
||||
originalCallLogPipelineCaptureStreamChunks;
|
||||
}
|
||||
}
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
@@ -76,6 +82,7 @@ function toPlainHeaders(headers) {
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
@@ -90,6 +97,7 @@ function buildOpenAIResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-json",
|
||||
@@ -114,6 +122,7 @@ function buildOpenAIResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
@@ -161,6 +170,7 @@ function buildClaudeResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_json",
|
||||
@@ -379,6 +389,7 @@ test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("chatCore times out upstream execution before provider response headers", async () => {
|
||||
// This test asserts pendingDetail.providerRequest — only attached when the
|
||||
// call-log pipeline capture is enabled. Declare the dependency explicitly
|
||||
@@ -445,6 +456,7 @@ test("chatCore times out upstream execution before provider response headers", a
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("chatCore can disable pipeline stream chunk capture through environment", async () => {
|
||||
process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS = "false";
|
||||
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
|
||||
@@ -467,6 +479,7 @@ test("chatCore can disable pipeline stream chunk capture through environment", a
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads when capture is enabled");
|
||||
assert.equal((detail.pipelinePayloads as any).streamChunks, undefined);
|
||||
});
|
||||
|
||||
test("chatCore keeps Responses-native Codex payloads in native passthrough mode", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "codex",
|
||||
@@ -494,6 +507,7 @@ test("chatCore keeps Responses-native Codex payloads in native passthrough mode"
|
||||
assert.deepEqual(call.body.metadata, { source: "codex-client" });
|
||||
assert.equal("messages" in call.body, false);
|
||||
});
|
||||
|
||||
test("chatCore honors providerSpecificData.apiType for legacy openai-compatible providers", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "openai-compatible-sp-openai",
|
||||
@@ -523,78 +537,7 @@ test("chatCore honors providerSpecificData.apiType for legacy openai-compatible
|
||||
assert.equal("messages" in call.body, false);
|
||||
assert.equal(payload.choices[0].message.content, "ok");
|
||||
});
|
||||
test("chatCore applies Responses input policy to openai-compatible targets", async () => {
|
||||
const reasoningItems = [
|
||||
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
|
||||
{ type: "reasoning", encrypted_content: "" },
|
||||
{ type: "reasoning", summary: [{ text: "not self-contained" }] },
|
||||
{ type: "item_reference", id: "rs_reference" },
|
||||
{ id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" },
|
||||
];
|
||||
|
||||
for (const preserveEncryptedReasoning of [false, true]) {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "openai-compatible-sp-openai",
|
||||
model: "gpt-5.4",
|
||||
endpoint: "/v1/responses",
|
||||
credentials: {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
apiType: "responses",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
prefix: "sp-openai",
|
||||
preserveEncryptedReasoning,
|
||||
},
|
||||
},
|
||||
body: { model: "gpt-5.4", stream: false, input: reasoningItems },
|
||||
responseFormat: "openai-responses",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
const input = call.body.input as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(
|
||||
input.filter((item) => item.type === "reasoning"),
|
||||
preserveEncryptedReasoning ? [{ type: "reasoning", encrypted_content: "encrypted-blob" }] : []
|
||||
);
|
||||
assert.equal(
|
||||
input.some((item) => item.type === "item_reference"),
|
||||
false
|
||||
);
|
||||
assert.equal(input.find((item) => item.type === "function_call")?.id, undefined);
|
||||
}
|
||||
});
|
||||
test("chatCore preserves opted-in encrypted reasoning for Codex", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex",
|
||||
endpoint: "/v1/responses",
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
providerSpecificData: { preserveEncryptedReasoning: true },
|
||||
},
|
||||
body: {
|
||||
model: "gpt-5.1-codex",
|
||||
stream: false,
|
||||
input: [
|
||||
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
|
||||
{ type: "reasoning", encrypted_content: "" },
|
||||
{ type: "item_reference", id: "rs_reference" },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
|
||||
],
|
||||
},
|
||||
responseFormat: "openai-responses",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(
|
||||
call.body.input.filter((item) => item.type === "reasoning"),
|
||||
[{ type: "reasoning", encrypted_content: "encrypted-blob" }]
|
||||
);
|
||||
assert.equal(
|
||||
call.body.input.some((item) => item.type === "item_reference"),
|
||||
false
|
||||
);
|
||||
});
|
||||
test("chatCore helper exports detect responses passthrough paths and token expiry windows", () => {
|
||||
assert.equal(
|
||||
shouldUseNativeCodexPassthrough({
|
||||
@@ -622,6 +565,7 @@ test("chatCore helper exports detect responses passthrough paths and token expir
|
||||
);
|
||||
assert.equal(isTokenExpiringSoon(null), false);
|
||||
});
|
||||
|
||||
test("chatCore helper detects Claude Code semantic passthrough only for direct Claude-Code routes", () => {
|
||||
assert.equal(
|
||||
isClaudeCodeSemanticPassthroughRequest({
|
||||
@@ -661,6 +605,7 @@ test("chatCore helper detects Claude Code semantic passthrough only for direct C
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore applies payload rules after translating Responses input into Chat payloads", async () => {
|
||||
setPayloadRulesConfig({
|
||||
default: [
|
||||
@@ -706,6 +651,7 @@ test("chatCore applies payload rules after translating Responses input into Chat
|
||||
assert.equal(call.body.messages[0].metadata.routeTag, "feature-110");
|
||||
assert.equal(call.body.messages[0].role, "user");
|
||||
});
|
||||
|
||||
test("chatCore builds Claude Code-compatible upstream requests for CC providers", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
@@ -815,6 +761,7 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
|
||||
// user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
|
||||
assert.equal(call.body.messages[2].content[0].type, "tool_result");
|
||||
});
|
||||
|
||||
test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -868,6 +815,7 @@ test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", asyn
|
||||
);
|
||||
assert.equal(call.body.messages[3].content[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
@@ -995,6 +943,7 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
|
||||
// user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
|
||||
assert.equal(call.body.messages[2].content[0].type, "tool_result");
|
||||
});
|
||||
|
||||
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -1041,6 +990,7 @@ test("chatCore preserves cache_control automatically for Claude Code single-mode
|
||||
// base.ts executor explicitly strips cache_control from tools for Claude Code clients
|
||||
assert.equal(call.body.tools[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("chatCore supplements a missing message cache breakpoint for native Claude Code requests", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -1086,6 +1036,7 @@ test("chatCore supplements a missing message cache breakpoint for native Claude
|
||||
assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.equal(call.body.tools[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("chatCore auto cache policy becomes false for nondeterministic combos", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -1119,6 +1070,7 @@ test("chatCore auto cache policy becomes false for nondeterministic combos", asy
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore always-preserve mode keeps cache_control even without Claude Code user-agent", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -1140,6 +1092,7 @@ test("chatCore always-preserve mode keeps cache_control even without Claude Code
|
||||
assert.equal(hasCacheControl(call.body), true);
|
||||
assert.deepEqual(call.body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
});
|
||||
|
||||
test("chatCore disables raw Claude passthrough when cache preservation is off and normalizes through OpenAI", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "never" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -1175,6 +1128,7 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an
|
||||
// Tools disable flag is applied
|
||||
assert.equal("_disableToolPrefix" in call.body, false);
|
||||
});
|
||||
|
||||
test("chatCore default translation converts Claude requests to OpenAI and strips cache markers for non-Claude providers", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -1200,6 +1154,7 @@ test("chatCore default translation converts Claude requests to OpenAI and strips
|
||||
assert.equal(call.body.messages[0].role, "system");
|
||||
assert.equal(JSON.stringify(call.body).includes("cache_control"), false);
|
||||
});
|
||||
|
||||
test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text blocks, and cleans helper flags", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
@@ -1242,6 +1197,7 @@ test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text bl
|
||||
["hello"]
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore restores prefixed Claude passthrough tool names in upstream responses", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
@@ -1293,6 +1249,7 @@ test("chatCore restores prefixed Claude passthrough tool names in upstream respo
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(payload.content[0].name, "Bash");
|
||||
});
|
||||
|
||||
test("chatCore strips unsupported reasoning params and caps provider token fields", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -1314,6 +1271,7 @@ test("chatCore strips unsupported reasoning params and caps provider token field
|
||||
assert.equal(call.body.max_tokens, undefined);
|
||||
assert.equal(call.body.max_completion_tokens, 16384);
|
||||
});
|
||||
|
||||
test("chatCore preserves reasoning_effort for assistant-prefill OpenAI-compatible requests", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "openai-compatible-aio",
|
||||
@@ -1335,6 +1293,7 @@ test("chatCore preserves reasoning_effort for assistant-prefill OpenAI-compatibl
|
||||
assert.equal(call.body.model, "glm-5.1");
|
||||
assert.equal(call.body.reasoning_effort, "xhigh");
|
||||
});
|
||||
|
||||
test("chatCore logs chat completions endpoint as OpenAI protocol", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "openrouter",
|
||||
@@ -1361,6 +1320,7 @@ test("chatCore logs chat completions endpoint as OpenAI protocol", async () => {
|
||||
assert.equal(logEntry.path, "/v1/chat/completions");
|
||||
assert.equal(logEntry.sourceFormat, FORMATS.OPENAI);
|
||||
});
|
||||
|
||||
test("chatCore surfaces translation errors with explicit status codes", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
@@ -1387,6 +1347,7 @@ test("chatCore surfaces translation errors with explicit status codes", async ()
|
||||
assert.equal(result.status, 409);
|
||||
assert.equal(result.error, "responses translator rejected the payload");
|
||||
});
|
||||
|
||||
test("chatCore surfaces typed translation errors with the declared error type", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
@@ -1417,6 +1378,7 @@ test("chatCore surfaces typed translation errors with the declared error type",
|
||||
assert.equal(payload.error.type, "unsupported_feature");
|
||||
assert.equal(payload.error.code, "unsupported_feature");
|
||||
});
|
||||
|
||||
test("chatCore returns 500 when translation throws a generic error", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
@@ -1441,6 +1403,7 @@ test("chatCore returns 500 when translation throws a generic error", async () =>
|
||||
assert.equal(result.status, 500);
|
||||
assert.equal(result.error, "unexpected translator crash");
|
||||
});
|
||||
|
||||
test("chatCore refreshes GitHub credentials after 401 and retries with the refreshed Copilot token", async () => {
|
||||
let refreshedCredentials = null;
|
||||
const { calls, result } = await invokeChatCore({
|
||||
@@ -1506,6 +1469,7 @@ test("chatCore refreshes GitHub credentials after 401 and retries with the refre
|
||||
assert.equal(refreshedCredentials?.providerSpecificData?.copilotToken, "copilot-refreshed-token");
|
||||
assert.equal(payload.choices[0].message.content, "retry succeeded after refresh");
|
||||
});
|
||||
|
||||
test("chatCore uses the native executor when no upstream proxy mode is enabled", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -1519,6 +1483,7 @@ test("chatCore uses the native executor when no upstream proxy mode is enabled",
|
||||
|
||||
assert.match(call.url, /^https:\/\/api\.openai\.com\/v1\/chat\/completions$/);
|
||||
});
|
||||
|
||||
test("chatCore routes providers through CLIProxyAPI in passthrough mode", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "qoder",
|
||||
@@ -1540,6 +1505,7 @@ test("chatCore routes providers through CLIProxyAPI in passthrough mode", async
|
||||
assert.match(call.url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
|
||||
assert.equal(call.headers.Authorization ?? call.headers.authorization, "Bearer qoder-token");
|
||||
});
|
||||
|
||||
test("chatCore fallback proxy mode retries through CLIProxyAPI after retryable native failures", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "github",
|
||||
@@ -1580,6 +1546,7 @@ test("chatCore fallback proxy mode retries through CLIProxyAPI after retryable n
|
||||
assert.match(calls[0].url, /^https:\/\/api\.githubcopilot\.com\/chat\/completions$/);
|
||||
assert.match(calls[1].url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
|
||||
});
|
||||
|
||||
test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after a retryable native status", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "github",
|
||||
@@ -1620,6 +1587,7 @@ test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after a retryable
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.error, "[502]: cliproxy retry failed");
|
||||
});
|
||||
|
||||
test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after native executor throws", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "github",
|
||||
@@ -1657,6 +1625,7 @@ test("chatCore fallback proxy mode surfaces CLIProxyAPI errors after native exec
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.error, "[502]: cliproxy transport exploded");
|
||||
});
|
||||
|
||||
test("chatCore serves a cached idempotent response without hitting the provider twice", async () => {
|
||||
const sharedHeaders = { "idempotency-key": "unit-idempotent-key" };
|
||||
|
||||
@@ -1692,6 +1661,7 @@ test("chatCore serves a cached idempotent response without hitting the provider
|
||||
const payload = (await second.result.response.json()) as any;
|
||||
assert.equal(payload.choices[0].message.content, "ok");
|
||||
});
|
||||
|
||||
test("chatCore returns a semantic cache HIT for repeated deterministic requests", async () => {
|
||||
let upstreamHits = 0;
|
||||
const sharedBody = {
|
||||
@@ -1744,6 +1714,7 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests"
|
||||
assert.equal(semanticLog.path, "/v1/chat/completions");
|
||||
assert.equal(semanticLog.status, 200);
|
||||
});
|
||||
|
||||
test("chatCore skips semantic cache when disabled in settings", async () => {
|
||||
await settingsDb.updateSettings({ semanticCacheEnabled: false });
|
||||
|
||||
@@ -1786,6 +1757,7 @@ test("chatCore skips semantic cache when disabled in settings", async () => {
|
||||
const payload = (await second.result.response.json()) as any;
|
||||
assert.equal(payload.choices[0].message.content, "fresh-2");
|
||||
});
|
||||
|
||||
test("chatCore attaches OmniRoute response metadata headers to non-stream responses", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
@@ -1807,6 +1779,7 @@ test("chatCore attaches OmniRoute response metadata headers to non-stream respon
|
||||
assert.ok(Number(result.response.headers.get("X-OmniRoute-Latency-Ms")) >= 0);
|
||||
assert.match(String(result.response.headers.get("X-OmniRoute-Response-Cost")), /^\d+\.\d{10}$/);
|
||||
});
|
||||
|
||||
test("chatCore does not expose provider request credentials in non-stream response headers", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -1825,6 +1798,7 @@ test("chatCore does not expose provider request credentials in non-stream respon
|
||||
assert.equal(result.response.headers.get("Content-Type"), "application/json");
|
||||
assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS");
|
||||
});
|
||||
|
||||
test("chatCore normalizes tool finish reasons and estimates usage when upstream omits it", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -1876,6 +1850,7 @@ test("chatCore normalizes tool finish reasons and estimates usage when upstream
|
||||
assert.ok(payload.usage.total_tokens > 0);
|
||||
assert.ok(payload.usage.prompt_tokens > 0);
|
||||
});
|
||||
|
||||
test("chatCore bypasses Claude CLI warmup probes before touching the provider", async () => {
|
||||
const { calls, result } = await invokeChatCore({
|
||||
model: "gpt-5",
|
||||
@@ -1892,6 +1867,7 @@ test("chatCore bypasses Claude CLI warmup probes before touching the provider",
|
||||
assert.equal(calls.length, 0);
|
||||
assert.match(payload.choices[0].message.content, /CLI Command Execution/);
|
||||
});
|
||||
|
||||
test("chatCore redirects background utility tasks to a cheaper mapped model", async () => {
|
||||
setBackgroundDegradationConfig({
|
||||
enabled: true,
|
||||
@@ -1918,6 +1894,7 @@ test("chatCore redirects background utility tasks to a cheaper mapped model", as
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(call.body.model, "gpt-5-mini");
|
||||
});
|
||||
|
||||
test("chatCore preserves Codex dual-window scope cooldowns on 429 responses", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
@@ -1971,6 +1948,7 @@ test("chatCore preserves Codex dual-window scope cooldowns on 429 responses", as
|
||||
);
|
||||
assert.equal((updated as any).providerSpecificData.codexExhaustedWindow, "5h");
|
||||
});
|
||||
|
||||
test("chatCore 429 lets account fallback apply the configured resilience cooldown", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
resilienceSettings: {
|
||||
@@ -2031,6 +2009,7 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow
|
||||
assert.equal((afterFallback as any).testStatus, "unavailable");
|
||||
assert.ok(cooldownRemaining > 0 && cooldownRemaining <= 2_000);
|
||||
});
|
||||
|
||||
test("chatCore falls back to the next family model when the requested model is unavailable", async () => {
|
||||
const { calls, result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2057,6 +2036,7 @@ test("chatCore falls back to the next family model when the requested model is u
|
||||
assert.equal(calls[1].body.model, "gpt-5.1-mini");
|
||||
assert.equal(payload.choices[0].message.content, "family fallback ok");
|
||||
});
|
||||
|
||||
test("chatCore falls back to a larger-context sibling when the request overflows context", async () => {
|
||||
saveModelsDevCapabilities({
|
||||
unknown: {
|
||||
@@ -2091,6 +2071,7 @@ test("chatCore falls back to a larger-context sibling when the request overflows
|
||||
assert.equal(calls[1].body.model, "gpt-4o");
|
||||
assert.equal(payload.choices[0].message.content, "larger context fallback");
|
||||
});
|
||||
|
||||
test("chatCore parses upstream SSE payloads for non-streaming requests", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2109,6 +2090,7 @@ test("chatCore parses upstream SSE payloads for non-streaming requests", async (
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(payload.choices[0].message.content, "sse json");
|
||||
});
|
||||
|
||||
test("chatCore rejects malformed non-streaming SSE payloads", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2130,6 +2112,7 @@ test("chatCore rejects malformed non-streaming SSE payloads", async () => {
|
||||
assert.equal(result.status, 502);
|
||||
assert.match(result.error, /Invalid SSE response/);
|
||||
});
|
||||
|
||||
test("chatCore rejects malformed non-streaming JSON payloads", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2151,6 +2134,7 @@ test("chatCore rejects malformed non-streaming JSON payloads", async () => {
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.error, "Invalid JSON response from provider");
|
||||
});
|
||||
|
||||
test("chatCore falls back after an empty-content success response", async () => {
|
||||
const { calls, result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2191,6 +2175,7 @@ test("chatCore falls back after an empty-content success response", async () =>
|
||||
assert.equal(calls[1].body.model, "gpt-5.1-mini");
|
||||
assert.equal(payload.choices[0].message.content, "empty-content fallback ok");
|
||||
});
|
||||
|
||||
test("chatCore returns a gateway error when the empty-content fallback responds with invalid JSON", async () => {
|
||||
const { result, calls } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2235,6 +2220,7 @@ test("chatCore returns a gateway error when the empty-content fallback responds
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1].body.model, "gpt-5.1-mini");
|
||||
});
|
||||
|
||||
test("chatCore records Claude prompt cache and cache usage metadata in call logs", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
@@ -2312,6 +2298,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs
|
||||
cacheCreationTokens: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("chatCore propagates budget errors without an executor-level emergency hop", async () => {
|
||||
// The emergency budget fallback is orchestrated by the routing layer
|
||||
// (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
|
||||
@@ -2351,6 +2338,7 @@ test("chatCore propagates budget errors without an executor-level emergency hop"
|
||||
"emergency fallback model must not be called at executor level"
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore injects progress events into streaming responses when requested", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2372,6 +2360,7 @@ test("chatCore injects progress events into streaming responses when requested",
|
||||
assert.equal(result.response.headers.get("X-OmniRoute-Progress"), "enabled");
|
||||
assert.match(streamText, /event: progress/);
|
||||
});
|
||||
|
||||
test("chatCore emits final SSE metadata comments before [DONE] on streaming responses", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2399,6 +2388,7 @@ test("chatCore emits final SSE metadata comments before [DONE] on streaming resp
|
||||
streamText.indexOf(": x-omniroute-response-cost=") < streamText.indexOf("data: [DONE]")
|
||||
);
|
||||
});
|
||||
|
||||
test("buildStreamingResponseHeaders drops upstream compression and framing headers", () => {
|
||||
const headers = new Headers(
|
||||
buildStreamingResponseHeaders(
|
||||
@@ -2427,6 +2417,7 @@ test("buildStreamingResponseHeaders drops upstream compression and framing heade
|
||||
assert.equal(headers.get("X-Upstream-Trace"), "trace-1");
|
||||
assert.equal(headers.get("X-OmniRoute-Cache"), "MISS");
|
||||
});
|
||||
|
||||
test("chatCore strips upstream compression and length headers from streaming responses", async () => {
|
||||
const upstreamPayload = `data: ${JSON.stringify({
|
||||
id: "chatcmpl-stream-headers",
|
||||
@@ -2461,6 +2452,7 @@ test("chatCore strips upstream compression and length headers from streaming res
|
||||
assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS");
|
||||
await result.response.text();
|
||||
});
|
||||
|
||||
test("chatCore maps upstream aborts to request-aborted errors", async () => {
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
@@ -2481,6 +2473,7 @@ test("chatCore maps upstream aborts to request-aborted errors", async () => {
|
||||
assert.equal(result.status, 499);
|
||||
assert.equal(result.error, "Request aborted");
|
||||
});
|
||||
|
||||
test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () => {
|
||||
// abort(reason) rejects the upstream fetch with the raw reason — often a
|
||||
// bare string with no `name`/`status`. It must map to 499 like a named
|
||||
@@ -2543,6 +2536,7 @@ test("chatCore does not log a synthetic clientResponse body for a client abort",
|
||||
"an aborted request never delivered anything to the client — clientResponse must stay unset"
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore returns streaming responses without waiting for upstream completion", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let closeUpstream: (() => void) | null = null;
|
||||
@@ -2611,6 +2605,7 @@ test("chatCore returns streaming responses without waiting for upstream completi
|
||||
assert.equal(result.success, true);
|
||||
assert.match(streamText, /streamed-without-buffering/);
|
||||
});
|
||||
|
||||
test("chatCore releases account semaphore slots when upstream execution throws", async () => {
|
||||
const connectionId = "sem-exception";
|
||||
const semaphoreKey = buildAccountSemaphoreKey({
|
||||
@@ -2643,6 +2638,7 @@ test("chatCore releases account semaphore slots when upstream execution throws",
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(getAccountSemaphoreStats()[semaphoreKey], undefined);
|
||||
});
|
||||
|
||||
test("chatCore locks per-model quota failures without dropping quota helper references", async () => {
|
||||
const model = "gemini-1.5-pro";
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
@@ -2688,6 +2684,7 @@ test("chatCore locks per-model quota failures without dropping quota helper refe
|
||||
});
|
||||
|
||||
// ── Streaming semantic cache tests ──────────────────────────────────────────
|
||||
|
||||
test("chatCore caches streaming response and serves cache HIT on repeat", async () => {
|
||||
let upstreamHits = 0;
|
||||
const sharedBody = {
|
||||
@@ -2742,6 +2739,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async
|
||||
assert.match(sse, /^data:/m, "cache HIT should be SSE-framed");
|
||||
assert.match(sse, /streamed-once/, "SSE cache HIT should carry the cached content");
|
||||
});
|
||||
|
||||
test("chatCore does not cache streaming response when temperature > 0", async () => {
|
||||
let upstreamHits = 0;
|
||||
const sharedBody = {
|
||||
@@ -2782,6 +2780,7 @@ test("chatCore does not cache streaming response when temperature > 0", async ()
|
||||
assert.equal(upstreamHits, 2, "both requests should hit upstream");
|
||||
assert.equal(second.calls.length, 1, "second request should reach upstream");
|
||||
});
|
||||
|
||||
test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", async () => {
|
||||
let upstreamHits = 0;
|
||||
const sharedBody = {
|
||||
@@ -2828,6 +2827,7 @@ test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", a
|
||||
await second.result.response.text();
|
||||
assert.equal(upstreamHits, 2, "both requests should hit upstream with no-cache");
|
||||
});
|
||||
|
||||
test("chatCore returns cache HIT as SSE when the client requests streaming", async () => {
|
||||
const sharedBody = {
|
||||
model: "gpt-4o-mini",
|
||||
|
||||
@@ -42,36 +42,6 @@ test("provider schemas reject non-boolean openaiStoreEnabled values", () => {
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept boolean preserveEncryptedReasoning in providerSpecificData", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "codex",
|
||||
apiKey: "token",
|
||||
name: "Codex",
|
||||
providerSpecificData: { preserveEncryptedReasoning: true },
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: { preserveEncryptedReasoning: false },
|
||||
});
|
||||
|
||||
assert.equal(created.success, true);
|
||||
assert.equal(updated.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject non-boolean preserveEncryptedReasoning values", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "codex",
|
||||
apiKey: "token",
|
||||
name: "Codex",
|
||||
providerSpecificData: { preserveEncryptedReasoning: "yes" },
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: { preserveEncryptedReasoning: 1 },
|
||||
});
|
||||
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept boolean CC-compatible request defaults", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "anthropic-compatible-cc-demo",
|
||||
|
||||
@@ -15,21 +15,6 @@ test("Codex request defaults accept max but leave ultra to the Codex client", ()
|
||||
assert.equal(normalizeCodexReasoningEffort("ultra"), undefined);
|
||||
});
|
||||
|
||||
test("normalizeProviderSpecificData keeps only boolean preserveEncryptedReasoning", () => {
|
||||
assert.equal(
|
||||
normalizeProviderSpecificData("codex", { preserveEncryptedReasoning: true })
|
||||
?.preserveEncryptedReasoning,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
normalizeProviderSpecificData("codex", {
|
||||
preserveEncryptedReasoning: "yes",
|
||||
tag: "primary",
|
||||
})?.preserveEncryptedReasoning,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => {
|
||||
assert.equal(
|
||||
buildOpenAIStoreSessionId("ext:client session/abc"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { protectPipelinePayloads } from "../../src/lib/usage/callLogs/format.ts";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -35,46 +34,6 @@ test("normalizes JSON strings before log protection and redacts sensitive keys",
|
||||
});
|
||||
});
|
||||
|
||||
test("omits encrypted reasoning values from structured log payloads", () => {
|
||||
const encryptedContent = "encrypted".repeat(128);
|
||||
const payload = {
|
||||
output: [
|
||||
{
|
||||
type: "reasoning",
|
||||
encrypted_content: encryptedContent,
|
||||
reasoning_content: "visible diagnostic reasoning",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const protectedPayload = protectPayloadForLog(payload) as typeof payload;
|
||||
|
||||
assert.equal(
|
||||
protectedPayload.output[0].encrypted_content,
|
||||
`[omitted: encrypted reasoning, ${encryptedContent.length} chars]`
|
||||
);
|
||||
assert.equal(protectedPayload.output[0].reasoning_content, "visible diagnostic reasoning");
|
||||
assert.equal(payload.output[0].encrypted_content, encryptedContent);
|
||||
});
|
||||
|
||||
test("omits encrypted reasoning split across captured SSE chunks", () => {
|
||||
const encryptedContent = "opaque-replay-state".repeat(128);
|
||||
const protectedPipeline = protectPipelinePayloads({
|
||||
streamChunks: {
|
||||
provider: [
|
||||
'[12:00:00.000] data: {"type":"response.completed","response":{"output":[{"type":"reasoning","encrypted_',
|
||||
`[12:00:00.001] content":"${encryptedContent}","summary":[]}]}}\n\n`,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const storedChunks = protectedPipeline?.streamChunks?.provider ?? [];
|
||||
assert.equal(storedChunks.length, 1);
|
||||
assert.equal(storedChunks[0].includes(encryptedContent), false);
|
||||
assert.equal(storedChunks[0].includes("[omitted: encrypted reasoning]"), true);
|
||||
assert.equal(storedChunks[0].includes('"summary":[]'), true);
|
||||
});
|
||||
|
||||
test("wraps raw text payloads in JSON-safe objects", () => {
|
||||
const normalized = normalizePayloadForLog("event: ping\ndata: plain-text\n\n");
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts";
|
||||
import { stripStoredItemReferences } from "../../open-sse/executors/codex.ts";
|
||||
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts";
|
||||
|
||||
// Port of decolua/9router#1599 — strip unusable reasoning blobs from agentic
|
||||
// context to prevent O(n^2) token growth across turns. Encrypted reasoning is
|
||||
// self-contained and may be replayed only through an explicit connection opt-in.
|
||||
// Port of decolua/9router#1599 — strip reasoning blobs from agentic context to
|
||||
// prevent O(n^2) token growth across turns.
|
||||
//
|
||||
// (1) codex.ts stripStoredItemReferences: object items of type "reasoning"
|
||||
// (encrypted_content) are unusable with store=false (previous_response_id is
|
||||
// deleted) and must be dropped from the Responses `input` array.
|
||||
// (2) openaiHelper.ts filterToOpenAIFormat: assistant+tool_calls messages must
|
||||
// have `reasoning_content` stripped instead of being returned as-is.
|
||||
|
||||
test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
|
||||
test("stripStoredItemReferences drops object items with type=reasoning", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
@@ -24,7 +29,7 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
|
||||
],
|
||||
};
|
||||
|
||||
applyResponsesInputPolicy(body);
|
||||
stripStoredItemReferences(body);
|
||||
|
||||
const input = body.input as Array<Record<string, unknown>>;
|
||||
// Both reasoning items must be gone.
|
||||
@@ -40,66 +45,6 @@ test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
|
||||
assert.equal(input[1].id, undefined, "fc_ server id stripped, item kept");
|
||||
});
|
||||
|
||||
test("selected connection policy preserves encrypted reasoning input", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
id: "rs_encrypted123",
|
||||
type: "reasoning",
|
||||
encrypted_content: "encrypted-blob",
|
||||
summary: [{ type: "summary_text", text: "safe summary" }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
|
||||
],
|
||||
};
|
||||
|
||||
applyResponsesInputPolicy(body, true);
|
||||
|
||||
assert.deepEqual(body.input, [
|
||||
{
|
||||
type: "reasoning",
|
||||
encrypted_content: "encrypted-blob",
|
||||
summary: [{ type: "summary_text", text: "safe summary" }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserving encrypted reasoning still removes stored references", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{ id: "rs_encrypted123", type: "reasoning", encrypted_content: "encrypted-blob" },
|
||||
"rs_stored123",
|
||||
{ type: "item_reference", id: "resp_stored123" },
|
||||
{ type: "function_call", id: "fc_stored123", call_id: "call_1" },
|
||||
],
|
||||
};
|
||||
|
||||
applyResponsesInputPolicy(body, true);
|
||||
|
||||
assert.deepEqual(body.input, [
|
||||
{ type: "reasoning", encrypted_content: "encrypted-blob" },
|
||||
{ type: "function_call", call_id: "call_1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("applyResponsesInputPolicy still drops summary-only reasoning when enabled", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{ id: "rs_summary123", type: "reasoning", summary: [{ text: "thinking..." }] },
|
||||
{ type: "reasoning", encrypted_content: "" },
|
||||
{ type: "reasoning", encrypted_content: 42 },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
],
|
||||
};
|
||||
|
||||
applyResponsesInputPolicy(body, true);
|
||||
|
||||
assert.deepEqual(body.input, [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterToOpenAIFormat strips reasoning_content from assistant+tool_calls messages", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
|
||||
@@ -23,5 +23,7 @@ describe("ProxyRegistryManager (TDZ regression #5918)", () => {
|
||||
const html = renderToString(React.createElement(ProxyRegistryManager));
|
||||
// The heading key is rendered via the mocked translator (key echo).
|
||||
expect(html).toContain("title");
|
||||
expect(html).toContain("w-full border-t border-border");
|
||||
expect(html).toContain("flex w-full flex-wrap items-center justify-end gap-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,112 +169,6 @@ describe("EditConnectionModal — import only free models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditConnectionModal — encrypted Responses reasoning", () => {
|
||||
const PRESERVE_TOGGLE = 'button[role="switch"][aria-label="Preserve encrypted reasoning"]';
|
||||
|
||||
it("loads and saves the opt-in for an OpenAI-compatible Responses connection", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc",
|
||||
connection: {
|
||||
id: "conn-responses",
|
||||
provider: "openai-compatible-responses-12345678-1234-1234-1234-123456789abc",
|
||||
authType: "apikey",
|
||||
providerSpecificData: { preserveEncryptedReasoning: true },
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
const toggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults off and persists an opt-in for first-party OpenAI", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "openai",
|
||||
connection: {
|
||||
id: "conn-openai",
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
const toggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
act(() => toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("is absent for a chat-only compatible connection", () => {
|
||||
const el = render({
|
||||
providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
|
||||
connection: {
|
||||
id: "conn-chat",
|
||||
provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
|
||||
authType: "apikey",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
});
|
||||
expect(el.querySelector(PRESERVE_TOGGLE)).toBeNull();
|
||||
});
|
||||
|
||||
it("appears when a compatible connection selects the Responses target format", () => {
|
||||
const el = render({
|
||||
providerId: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
|
||||
connection: {
|
||||
id: "conn-selected-responses",
|
||||
provider: "openai-compatible-chat-12345678-1234-1234-1234-123456789abc",
|
||||
authType: "apikey",
|
||||
providerSpecificData: { targetFormat: "openai-responses" },
|
||||
},
|
||||
});
|
||||
expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps Codex controls and persists the opt-in on its OAuth save path", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "codex",
|
||||
connection: {
|
||||
id: "conn-codex",
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
providerSpecificData: { preserveEncryptedReasoning: true },
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
expect(el.querySelector(PRESERVE_TOGGLE)?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(el.textContent).toContain("defaultThinkingStrengthLabel");
|
||||
expect(
|
||||
el.querySelector('button[role="switch"][aria-label="openaiResponsesStoreLabel"]')
|
||||
).toBeTruthy();
|
||||
const cooldownToggle = el.querySelector<HTMLButtonElement>(
|
||||
'button[role="switch"][aria-label="disableCoolingLabel"]'
|
||||
)!;
|
||||
const reasoningToggle = el.querySelector<HTMLButtonElement>(PRESERVE_TOGGLE)!;
|
||||
expect(reasoningToggle.parentElement?.nextElementSibling).toBe(cooldownToggle.parentElement);
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
expect(onSave.mock.calls[0][0].providerSpecificData?.preserveEncryptedReasoning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditConnectionModal — quota scraping fields", () => {
|
||||
it("saves OpenCode Go workspace and replacement auth cookie", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
Reference in New Issue
Block a user