mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
Compare commits
7 Commits
maint/cher
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b828d46011 | ||
|
|
df3b33bf1a | ||
|
|
12f29bf310 | ||
|
|
203fab30c9 | ||
|
|
0eb6608fc2 | ||
|
|
c50082af9b | ||
|
|
06c105b3d4 |
2
changelog.d/features/9000-encrypted-reasoning-replay.md
Normal file
2
changelog.d/features/9000-encrypted-reasoning-replay.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- 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,6 +32,7 @@ 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";
|
||||
@@ -222,90 +223,6 @@ 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;
|
||||
@@ -1296,7 +1213,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 and stripStoredItemReferences.
|
||||
// This MUST run before convertSystemToDeveloperRole.
|
||||
if (!body.input && Array.isArray(body.messages)) {
|
||||
body.input = body.messages.map((msg: ResponsesMessageInput) => ({
|
||||
type: "message",
|
||||
@@ -1419,11 +1336,6 @@ 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;
|
||||
@@ -1515,6 +1427,11 @@ export class CodexExecutor extends BaseExecutor {
|
||||
delete body.session_id;
|
||||
delete body.conversation_id;
|
||||
|
||||
applyResponsesInputPolicy(
|
||||
body,
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
|
||||
);
|
||||
|
||||
if (nativeCodexPassthrough) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -207,7 +208,6 @@ 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,9 +367,7 @@ 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
|
||||
@@ -389,10 +387,8 @@ 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,
|
||||
@@ -428,7 +424,6 @@ 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,
|
||||
@@ -442,7 +437,6 @@ 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", {
|
||||
@@ -1071,6 +1065,13 @@ 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
|
||||
@@ -5025,7 +5026,6 @@ export async function handleChatCore({
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
if (!expiresAt) return false;
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
|
||||
55
open-sse/services/responsesInputPolicy.ts
Normal file
55
open-sse/services/responsesInputPolicy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
|
||||
@@ -58,7 +57,6 @@ 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;
|
||||
@@ -73,7 +71,6 @@ export interface EditConnectionModalConnection {
|
||||
healthCheckInterval?: number;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface EditConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
connection: EditConnectionModalConnection | null;
|
||||
@@ -84,9 +81,7 @@ 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,
|
||||
@@ -127,6 +122,7 @@ export default function EditConnectionModal({
|
||||
codexReasoningEffort: "medium",
|
||||
codexServiceTier: "default" as CodexServiceTier,
|
||||
codexOpenaiStoreEnabled: false,
|
||||
preserveEncryptedReasoning: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
newApiAggregatorBalance: false,
|
||||
@@ -169,7 +165,6 @@ 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.
|
||||
@@ -193,6 +188,13 @@ 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);
|
||||
@@ -239,7 +241,6 @@ export default function EditConnectionModal({
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && connection) {
|
||||
const effectiveProvider = connection.provider || providerId;
|
||||
@@ -318,6 +319,8 @@ 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,
|
||||
@@ -378,7 +381,6 @@ export default function EditConnectionModal({
|
||||
defaultRegion,
|
||||
setOpenRouterPreset,
|
||||
]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!provider) return;
|
||||
setTesting(true);
|
||||
@@ -407,7 +409,6 @@ export default function EditConnectionModal({
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (
|
||||
!provider ||
|
||||
@@ -440,7 +441,6 @@ export default function EditConnectionModal({
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddParsedExtraKeys = (raw: string) => {
|
||||
const { added, duplicates } = parseExtraApiKeys(raw, extraApiKeys);
|
||||
if (added.length > 0) {
|
||||
@@ -451,7 +451,6 @@ export default function EditConnectionModal({
|
||||
notify.warning(t("bulkPasteDuplicatesIgnored", { count: duplicates }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
@@ -467,14 +466,12 @@ 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);
|
||||
@@ -483,16 +480,13 @@ 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
|
||||
@@ -508,7 +502,6 @@ export default function EditConnectionModal({
|
||||
validatedBaseUrl = checked.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOAuth && formData.apiKey) {
|
||||
updates.apiKey = formData.apiKey;
|
||||
let isValid = validationResult === "success";
|
||||
@@ -611,6 +604,10 @@ 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 !==
|
||||
@@ -634,15 +631,24 @@ 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">
|
||||
@@ -736,6 +742,7 @@ export default function EditConnectionModal({
|
||||
description={t("importFreeModelsOnlyHint")}
|
||||
/>
|
||||
)}
|
||||
{preserveEncryptedReasoningToggle}
|
||||
<Toggle
|
||||
checked={formData.disableCooling}
|
||||
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
|
||||
@@ -1025,7 +1032,6 @@ export default function EditConnectionModal({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* #6147 — opt-in "Advanced → override base URL" for eligible built-ins */}
|
||||
{!usesBaseUrl && isBaseUrlOverrideEligible && (
|
||||
<button
|
||||
@@ -1036,7 +1042,6 @@ export default function EditConnectionModal({
|
||||
{providerText(t, "overrideBaseUrlAdvanced", "Advanced: override base URL")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{usesBaseUrl && (
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
@@ -1055,7 +1060,6 @@ export default function EditConnectionModal({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProtocolSelector && (
|
||||
<Select
|
||||
label={providerText(t, "apiProtocolLabel", "API protocol")}
|
||||
@@ -1075,13 +1079,11 @@ export default function EditConnectionModal({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProviderRegionField
|
||||
provider={provider}
|
||||
value={formData.region}
|
||||
onChange={(region) => setFormData({ ...formData, region })}
|
||||
/>
|
||||
|
||||
{isCloudflare && (
|
||||
<Input
|
||||
label={t("accountIdLabel")}
|
||||
@@ -1091,7 +1093,6 @@ export default function EditConnectionModal({
|
||||
hint={t("accountIdHint")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGlm && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
@@ -1115,7 +1116,6 @@ 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>
|
||||
|
||||
@@ -4,21 +4,28 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
import {
|
||||
toModelOverrideTargets,
|
||||
type PricingCatalogProvider,
|
||||
} from "@/lib/modelCapabilityOverrideTargets";
|
||||
|
||||
type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens";
|
||||
type StatusTone = "success" | "error" | "info";
|
||||
|
||||
type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget;
|
||||
type ModelOverrideTarget = {
|
||||
target: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
interface PricingCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PricingCatalogProvider {
|
||||
id: string;
|
||||
alias: string;
|
||||
models: PricingCatalogModel[];
|
||||
}
|
||||
|
||||
interface ModelCapabilityOverride {
|
||||
target: string;
|
||||
key: ModelOverrideKey;
|
||||
@@ -113,11 +120,22 @@ function useModelCapabilityOverridesData() {
|
||||
return { catalog, overrides, loading, statusMessage, saveOverride, removeOverride };
|
||||
}
|
||||
|
||||
function toTargets(catalog: Record<string, PricingCatalogProvider>): ModelOverrideTarget[] {
|
||||
return Object.values(catalog).flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
target: `${provider.id}/${model.id}`,
|
||||
provider: provider.id,
|
||||
modelId: model.id,
|
||||
label: `${provider.id}/${model.id}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModelCapabilityOverridesTab() {
|
||||
const t = useTranslations("settings");
|
||||
const { catalog, overrides, loading, statusMessage, saveOverride, removeOverride } =
|
||||
useModelCapabilityOverridesData();
|
||||
const targets = useMemo(() => toModelOverrideTargets(catalog), [catalog]);
|
||||
const targets = useMemo(() => toTargets(catalog), [catalog]);
|
||||
|
||||
if (loading) return <div className="text-sm text-text-muted animate-pulse">{t("loading")}</div>;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { FilterSelect, HeroStat, SyncMini } from "./PricingTabHelpers";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
@@ -50,8 +48,6 @@ interface PricingCatalogProvider {
|
||||
format: string;
|
||||
modelCount: number;
|
||||
models: PricingCatalogModel[];
|
||||
/** Original pricing namespace (e.g. public prefix) when it differs from `alias`. */
|
||||
pricingKey?: string;
|
||||
}
|
||||
|
||||
function getSourceTone(source: PricingSource): string {
|
||||
@@ -137,15 +133,11 @@ export default function PricingTab() {
|
||||
|
||||
const allProviders = useMemo(() => {
|
||||
return Object.entries(catalog)
|
||||
.map(([alias, info]) => {
|
||||
const pricingKey = info.pricingKey || alias;
|
||||
return {
|
||||
...info,
|
||||
alias,
|
||||
pricingKey,
|
||||
pricedModels: pricingData[pricingKey] ? Object.keys(pricingData[pricingKey]).length : 0,
|
||||
};
|
||||
})
|
||||
.map(([alias, info]) => ({
|
||||
...info,
|
||||
alias,
|
||||
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
|
||||
}))
|
||||
.sort((left, right) => right.modelCount - left.modelCount);
|
||||
}, [catalog, pricingData]);
|
||||
|
||||
@@ -320,14 +312,13 @@ export default function PricingTab() {
|
||||
);
|
||||
|
||||
const saveProvider = useCallback(
|
||||
async (providerAlias: string, pricingKey?: string) => {
|
||||
async (providerAlias: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const writeKey = pricingKey || providerAlias;
|
||||
const response = await fetch("/api/pricing", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [writeKey]: pricingData[writeKey] || {} }),
|
||||
body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -337,7 +328,7 @@ export default function PricingTab() {
|
||||
|
||||
setEditedProviders((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(writeKey);
|
||||
next.delete(providerAlias);
|
||||
return next;
|
||||
});
|
||||
await loadData();
|
||||
@@ -357,13 +348,11 @@ export default function PricingTab() {
|
||||
);
|
||||
|
||||
const resetProvider = useCallback(
|
||||
async (providerAlias: string, pricingKey?: string) => {
|
||||
async (providerAlias: string) => {
|
||||
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
|
||||
|
||||
try {
|
||||
const writeKey = pricingKey || providerAlias;
|
||||
const params = new URLSearchParams({ provider: writeKey });
|
||||
const response = await fetch(`/api/pricing?${params.toString()}`, {
|
||||
const response = await fetch(`/api/pricing?provider=${providerAlias}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -374,7 +363,7 @@ export default function PricingTab() {
|
||||
|
||||
setEditedProviders((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(writeKey);
|
||||
next.delete(providerAlias);
|
||||
return next;
|
||||
});
|
||||
await loadData();
|
||||
@@ -691,16 +680,16 @@ export default function PricingTab() {
|
||||
<ProviderSection
|
||||
key={provider.alias}
|
||||
provider={provider}
|
||||
pricingData={pricingData[provider.pricingKey || provider.alias] || {}}
|
||||
sourceMap={pricingSources[provider.pricingKey || provider.alias] || {}}
|
||||
pricingData={pricingData[provider.alias] || {}}
|
||||
sourceMap={pricingSources[provider.alias] || {}}
|
||||
isExpanded={expandedProviders.has(provider.alias)}
|
||||
isEdited={editedProviders.has(provider.pricingKey || provider.alias)}
|
||||
isEdited={editedProviders.has(provider.alias)}
|
||||
onToggle={() => toggleProvider(provider.alias)}
|
||||
onPricingChange={(model, field, value) =>
|
||||
handlePricingChange(provider.pricingKey || provider.alias, model, field, value)
|
||||
handlePricingChange(provider.alias, model, field, value)
|
||||
}
|
||||
onSave={() => void saveProvider(provider.alias, provider.pricingKey)}
|
||||
onReset={() => void resetProvider(provider.alias, provider.pricingKey)}
|
||||
onSave={() => void saveProvider(provider.alias)}
|
||||
onReset={() => void resetProvider(provider.alias)}
|
||||
saving={saving}
|
||||
getSourceLabel={getSourceLabel}
|
||||
/>
|
||||
@@ -728,6 +717,63 @@ export default function PricingTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncMini({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
|
||||
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">{label}:</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSection({
|
||||
provider,
|
||||
pricingData,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
export function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold tabular-nums leading-tight ${accent || "text-text-main"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncMini({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/30 bg-bg-base/40 px-2 py-1.5">
|
||||
<p className="text-[9px] uppercase tracking-wide text-text-muted font-semibold truncate">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-text-main mt-0.5 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">{label}:</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer focus:outline-none focus:border-primary"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -14,39 +14,12 @@ import {
|
||||
removeModelContextOverride,
|
||||
setModelContextOverride,
|
||||
} from "@/lib/db/modelContextOverrides";
|
||||
import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes";
|
||||
|
||||
const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]);
|
||||
type PublicOverrideKey = z.infer<typeof overrideKeySchema>;
|
||||
type PublicOverride = Omit<ModelCapabilityOverride, "key"> & { key: PublicOverrideKey };
|
||||
|
||||
/**
|
||||
* One-time per-request snapshot of the provider-node prefix index. Loaded once
|
||||
* per handler (never N times per row) straight from the DB — no module-global
|
||||
* mutable caches, no route-to-route imports.
|
||||
*/
|
||||
async function loadPrefixMaps(): Promise<{
|
||||
entries: Map<string, ProviderPrefixEntry>;
|
||||
nodeToPrefix: Map<string, string>;
|
||||
prefixToNode: Map<string, string>;
|
||||
eligibleNodeIds: Set<string>;
|
||||
compatibleNodeIds: Set<string>;
|
||||
}> {
|
||||
const index = await getProviderPrefixIndex();
|
||||
return {
|
||||
entries: index.entries,
|
||||
nodeToPrefix: index.nodeToPrefix,
|
||||
prefixToNode: index.prefixToNode,
|
||||
eligibleNodeIds: index.eligibleNodeIds,
|
||||
compatibleNodeIds: index.compatibleNodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function listPublicOverrides(
|
||||
nodeToPrefix: Map<string, string>,
|
||||
eligibleNodeIds: Set<string>,
|
||||
compatibleNodeIds: Set<string>
|
||||
): Promise<PublicOverride[]> {
|
||||
function listPublicOverrides(): PublicOverride[] {
|
||||
const capabilityOverrides = listModelCapabilityOverrides() as PublicOverride[];
|
||||
const contextOverrides = listModelContextOverrides().map((override): PublicOverride => ({
|
||||
provider: override.provider,
|
||||
@@ -56,27 +29,9 @@ async function listPublicOverrides(
|
||||
value: override.realContext,
|
||||
refreshedAt: override.refreshedAt,
|
||||
}));
|
||||
const merged = [...capabilityOverrides, ...contextOverrides];
|
||||
return merged
|
||||
.filter((override) => {
|
||||
// A compatible node that is NOT the unique non-reserved prefix winner is
|
||||
// ineligible for Model Overrides: never surface it under a generated node
|
||||
// UUID. Eligible winners are those in `eligibleNodeIds` (routable via
|
||||
// their public prefix); built-in providers (not in `compatibleNodeIds`)
|
||||
// are always eligible.
|
||||
return !compatibleNodeIds.has(override.provider) || eligibleNodeIds.has(override.provider);
|
||||
})
|
||||
.map((override) => {
|
||||
const displayProvider = nodeToPrefix.get(override.provider) || override.provider;
|
||||
return {
|
||||
...override,
|
||||
// Both `provider` and `target` are exposed under the public prefix so no
|
||||
// generated node UUID ever leaks into the JSON for a prefixed node.
|
||||
provider: displayProvider,
|
||||
target: `${displayProvider}/${override.modelId}`,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt));
|
||||
return [...capabilityOverrides, ...contextOverrides].sort((left, right) =>
|
||||
right.refreshedAt.localeCompare(left.refreshedAt)
|
||||
);
|
||||
}
|
||||
|
||||
const upsertOverrideSchema = z.object({
|
||||
@@ -85,65 +40,23 @@ const upsertOverrideSchema = z.object({
|
||||
value: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Canonicalize a public `<prefix>/<model>` target to `<internalNodeId>/<model>`
|
||||
* so the override is stored where runtime lookup reads it. Mirrors runtime
|
||||
* prefix routing exactly:
|
||||
*
|
||||
* - `unique` configured prefix → canonicalize to the single runtime-routable
|
||||
* winner node (first openai-compatible then anthropic-compatible, by id).
|
||||
* - `reserved` configured prefix (collides with a built-in registry id/alias,
|
||||
* e.g. a node with `prefix="cx"`) → route via `resolveProviderAlias` to the
|
||||
* built-in canonical provider (runtime routes `cx/` to codex), never 400.
|
||||
* - `ambiguous` (no runtime winner selectable) → fail closed (400).
|
||||
* - A bare built-in alias/id that is NOT a configured node prefix (e.g.
|
||||
* `openai` typed directly) resolves via `resolveProviderAlias` (unchanged).
|
||||
*
|
||||
* Returns the canonical `provider/model` on success, or `{ ok: false }`.
|
||||
*/
|
||||
function canonicalizeTarget(
|
||||
target: string,
|
||||
entries: Map<string, ProviderPrefixEntry>,
|
||||
prefixToNode: Map<string, string>,
|
||||
compatibleNodeIds: Set<string>,
|
||||
eligibleNodeIds: Set<string>
|
||||
): { ok: true; target: string } | { ok: false } {
|
||||
function canonicalizeTarget(target: string): string | null {
|
||||
const raw = target.trim();
|
||||
const slashIndex = raw.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === raw.length - 1) return { ok: false };
|
||||
if (slashIndex <= 0 || slashIndex === raw.length - 1) return null;
|
||||
|
||||
const provider = raw.slice(0, slashIndex).trim();
|
||||
const modelId = raw.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelId) return { ok: false };
|
||||
if (!provider || !modelId) return null;
|
||||
|
||||
// Raw internal compatible-node IDs are never a public Model Overrides target.
|
||||
// Only the public prefix of an eligible runtime winner may select a node.
|
||||
// Reject ineligible raw IDs as well as eligible raw IDs so stale or direct API
|
||||
// callers cannot create UUID-keyed overrides that the UI cannot manage.
|
||||
if (compatibleNodeIds.has(provider)) return { ok: false };
|
||||
|
||||
const configured = entries.get(provider);
|
||||
// A reserved configured prefix is routable to the built-in canonical provider
|
||||
// (runtime never routes it to the compatible node). `resolveProviderAlias`
|
||||
// maps e.g. `cx` → `codex`. Only an ambiguous prefix has no routable target.
|
||||
if (configured && configured.status === "ambiguous") {
|
||||
return { ok: false };
|
||||
}
|
||||
const resolvedNodeId = prefixToNode.get(provider);
|
||||
if (resolvedNodeId && !eligibleNodeIds.has(resolvedNodeId)) return { ok: false };
|
||||
const canonicalProvider = resolvedNodeId || resolveProviderAlias(provider) || provider;
|
||||
|
||||
return { ok: true, target: `${canonicalProvider}/${modelId}` };
|
||||
return `${resolveProviderAlias(provider) || provider}/${modelId}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { nodeToPrefix, eligibleNodeIds, compatibleNodeIds } = await loadPrefixMaps();
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
@@ -162,28 +75,17 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await loadPrefixMaps();
|
||||
const canonical = canonicalizeTarget(
|
||||
parsed.data.target,
|
||||
entries,
|
||||
prefixToNode,
|
||||
compatibleNodeIds,
|
||||
eligibleNodeIds
|
||||
);
|
||||
if (!canonical.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or ambiguous model capability override target" },
|
||||
{ status: 400 }
|
||||
);
|
||||
const target = canonicalizeTarget(parsed.data.target);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParts = canonical.target.split(/\/(.*)/s);
|
||||
const targetParts = target.split(/\/(.*)/s);
|
||||
const written =
|
||||
parsed.data.key === "context_length"
|
||||
? setModelContextOverride(targetParts[0], targetParts[1], parsed.data.value, "manual")
|
||||
: setModelCapabilityOverride(
|
||||
canonical.target,
|
||||
target,
|
||||
parsed.data.key as ModelCapabilityOverrideKey,
|
||||
parsed.data.value
|
||||
);
|
||||
@@ -191,9 +93,7 @@ export async function PATCH(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
@@ -201,33 +101,19 @@ export async function DELETE(request: Request) {
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const target = canonicalizeTarget(searchParams.get("target") || "");
|
||||
const key = searchParams.get("key") || "";
|
||||
const parsedKey = overrideKeySchema.safeParse(key);
|
||||
|
||||
const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await loadPrefixMaps();
|
||||
const canonical = canonicalizeTarget(
|
||||
searchParams.get("target") || "",
|
||||
entries,
|
||||
prefixToNode,
|
||||
compatibleNodeIds,
|
||||
eligibleNodeIds
|
||||
);
|
||||
|
||||
if (!canonical.ok || !parsedKey.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "target and key are required; target must be a valid model override target" },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!target || !parsedKey.success) {
|
||||
return NextResponse.json({ error: "target and key are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsedKey.data === "context_length") {
|
||||
const targetParts = canonical.target.split(/\/(.*)/s);
|
||||
const targetParts = target.split(/\/(.*)/s);
|
||||
removeModelContextOverride(targetParts[0], targetParts[1]);
|
||||
} else {
|
||||
removeModelCapabilityOverride(canonical.target, parsedKey.data as ModelCapabilityOverrideKey);
|
||||
removeModelCapabilityOverride(target, parsedKey.data as ModelCapabilityOverrideKey);
|
||||
}
|
||||
return NextResponse.json({
|
||||
overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds),
|
||||
});
|
||||
return NextResponse.json({ overrides: listPublicOverrides() });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb";
|
||||
import { getProviderPrefixIndex } from "@/lib/providerNodePrefixes";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -29,12 +28,6 @@ export async function GET() {
|
||||
try {
|
||||
const catalog: Record<string, any> = {};
|
||||
|
||||
// Pre-load compatible-provider node public prefixes once (shared across the
|
||||
// whole catalog build — never N lookups per model). Only uniquely-routable
|
||||
// prefixes are exposed as public targets (reserved/ambiguous are not).
|
||||
const { nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } =
|
||||
await getProviderPrefixIndex();
|
||||
|
||||
// ── 1. Registry models (hardcoded) ──────────────────────────────
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
const alias = entry.alias || entry.id;
|
||||
@@ -61,13 +54,6 @@ export async function GET() {
|
||||
return providerId;
|
||||
};
|
||||
|
||||
// A compatible provider node should surface under its configured public
|
||||
// prefix, never its generated `openai-compatible-chat-<uuid>` node id
|
||||
// (#9557). The internal `id` (node id) is preserved for PricingTab and
|
||||
// runtime capability lookup. Only a uniquely-routable non-reserved winner
|
||||
// is Model-Overrides eligible (marked explicitly); a compatible node that
|
||||
// is reserved/losing/no-prefix is marked ineligible and skipped by the
|
||||
// Model-Overrides helper.
|
||||
const ensureCatalogProvider = (providerId: string, alias: string) => {
|
||||
if (!catalog[alias]) {
|
||||
catalog[alias] = {
|
||||
@@ -78,11 +64,6 @@ export async function GET() {
|
||||
format: "openai",
|
||||
models: [],
|
||||
};
|
||||
const prefix = nodeToPrefix.get(providerId);
|
||||
if (prefix) catalog[alias].displayPrefix = prefix;
|
||||
if (compatibleNodeIds.has(providerId)) {
|
||||
catalog[alias].modelOverrideEligible = eligibleNodeIds.has(providerId);
|
||||
}
|
||||
}
|
||||
return catalog[alias];
|
||||
};
|
||||
@@ -130,13 +111,6 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// ── 4. Pricing-only models (DB) ─────────────────────────────────
|
||||
// Pricing may be keyed by the node's public prefix (what the operator typed)
|
||||
// or by the internal node id. When keyed by a uniquely-routable public
|
||||
// prefix, reconcile it to that node so the model list merges into the
|
||||
// canonical compatible-provider entry instead of duplicating it, and
|
||||
// preserve the original pricing namespace as `pricingKey` so PricingTab can
|
||||
// read/save/reset against it. Reserved / ambiguous prefixes have no single
|
||||
// routable node and stay as-is.
|
||||
let pricingData: Record<string, any> = {};
|
||||
try {
|
||||
pricingData = await getPricing();
|
||||
@@ -144,10 +118,7 @@ export async function GET() {
|
||||
/* DB may not be ready */
|
||||
}
|
||||
|
||||
for (const [rawProviderAlias, models] of Object.entries(pricingData)) {
|
||||
// `rawProviderAlias` is the original pricing namespace the operator used.
|
||||
const pricingKey = rawProviderAlias;
|
||||
const providerAlias = prefixToNode.get(rawProviderAlias) || rawProviderAlias;
|
||||
for (const [providerAlias, models] of Object.entries(pricingData)) {
|
||||
if (!catalog[providerAlias]) {
|
||||
catalog[providerAlias] = {
|
||||
id: providerAlias,
|
||||
@@ -157,16 +128,6 @@ export async function GET() {
|
||||
format: "openai",
|
||||
models: [],
|
||||
};
|
||||
const prefix = nodeToPrefix.get(providerAlias);
|
||||
if (prefix) catalog[providerAlias].displayPrefix = prefix;
|
||||
if (compatibleNodeIds.has(providerAlias)) {
|
||||
catalog[providerAlias].modelOverrideEligible = eligibleNodeIds.has(providerAlias);
|
||||
}
|
||||
}
|
||||
// When the entry is keyed internally by the node id but priced under a
|
||||
// public prefix, remember the original pricing namespace for PricingTab.
|
||||
if (pricingKey !== providerAlias && !catalog[providerAlias].pricingKey) {
|
||||
catalog[providerAlias].pricingKey = pricingKey;
|
||||
}
|
||||
|
||||
const existingIds = new Set(catalog[providerAlias].models.map((m) => m.id));
|
||||
|
||||
@@ -20,6 +20,29 @@ 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
|
||||
@@ -56,6 +79,28 @@ 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);
|
||||
@@ -100,7 +145,8 @@ export function sanitizePayloadPII(payload: unknown): unknown {
|
||||
export function protectPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
const piiSanitized = sanitizePayloadPII(normalized);
|
||||
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
|
||||
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Pure catalog → Model Override target conversion.
|
||||
*
|
||||
* The operator-facing Model Overrides surface must present a compatible
|
||||
* provider node under its configured public `prefix` (e.g. `vibeproxy/gpt-4o`)
|
||||
* — never the generated `openai-compatible-chat-<uuid>` node id (#9557).
|
||||
*
|
||||
* The pricing catalog keeps the internal `id` (the DB node id, which PricingTab
|
||||
* uses to key pricing data) and, when the node has a configured prefix, also
|
||||
* carries `displayPrefix`. This helper prefers `displayPrefix` for the public
|
||||
* label/target while leaving the raw id untouched for storage/runtime lookup.
|
||||
*
|
||||
* Model-Overrides eligibility seam: a compatible provider node is eligible only
|
||||
* when it is the unique, non-reserved runtime-routable winner of its configured
|
||||
* prefix. The catalog marks such winners with `modelOverrideEligible === true`
|
||||
* (and a `displayPrefix`); reserved/losing/no-public-prefix compatible nodes are
|
||||
* marked `modelOverrideEligible === false` and are SKIPPED — never surfaced
|
||||
* under a generated node UUID. Built-in / no-compatible catalog entries carry no
|
||||
* flag and remain targetable.
|
||||
*/
|
||||
|
||||
export interface PricingCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PricingCatalogProvider {
|
||||
id: string;
|
||||
alias: string;
|
||||
displayPrefix?: string;
|
||||
/** Explicit Model-Overrides eligibility; undefined ⇒ eligible (built-in/no-compatible). */
|
||||
modelOverrideEligible?: boolean;
|
||||
models: PricingCatalogModel[];
|
||||
}
|
||||
|
||||
export interface ModelOverrideTarget {
|
||||
target: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog provider is targetable in Model Overrides. Only compatible
|
||||
* nodes marked ineligible (reserved/losing/no-public-prefix) are skipped; all
|
||||
* built-in and no-compatible entries are eligible.
|
||||
*/
|
||||
export function isModelOverrideEligible(provider: PricingCatalogProvider): boolean {
|
||||
return provider.modelOverrideEligible !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public display prefix for a compatible provider node, falling back to its
|
||||
* internal id when no operator-configured prefix is set.
|
||||
*/
|
||||
export function modelOverrideProviderPrefix(provider: PricingCatalogProvider): string {
|
||||
return provider.displayPrefix?.trim() || provider.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the /api/pricing/models catalog into Model Override targets. Each
|
||||
* target uses the node's public prefix (when configured) so the selector,
|
||||
* search, selected model, and the target sent to the override API never expose
|
||||
* a generated node UUID. Ineligible compatible nodes are skipped entirely.
|
||||
*/
|
||||
export function toModelOverrideTargets(
|
||||
catalog: Record<string, PricingCatalogProvider>
|
||||
): ModelOverrideTarget[] {
|
||||
return Object.values(catalog).flatMap((provider) => {
|
||||
if (!isModelOverrideEligible(provider)) return [];
|
||||
const prefix = modelOverrideProviderPrefix(provider);
|
||||
return provider.models.map((model) => ({
|
||||
target: `${prefix}/${model.id}`,
|
||||
provider: prefix,
|
||||
modelId: model.id,
|
||||
label: `${prefix}/${model.id}`,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Shared provider-node public-prefix index (#9557).
|
||||
*
|
||||
* A compatible provider node (openai/anthropic-compatible) can carry an
|
||||
* operator-configured public `prefix` (e.g. `vibeproxy`) that the Model
|
||||
* Overrides surface must expose instead of the generated
|
||||
* `openai-compatible-chat-<uuid>` node id.
|
||||
*
|
||||
* This module is the single narrow home for that index so both the pricing
|
||||
* catalog route and the override route resolve node → prefix / prefix → node
|
||||
* consistently. It does one `getProviderNodes()` DB read per call and derives
|
||||
* every map from it — no module-global mutable caches, no route-to-route
|
||||
* imports.
|
||||
*
|
||||
* Classification of each configured prefix (mirrors runtime semantics):
|
||||
* - `reserved`: the prefix collides with a built-in registry id/alias
|
||||
* (e.g. `cx` → codex). Such a node must NOT be advertised as a compatible
|
||||
* public target and the prefix must never be canonicalized to that node —
|
||||
* runtime routes reserved prefixes to the built-in provider, so the
|
||||
* override route must too.
|
||||
* - `unique`: a single runtime-routable node owns the prefix. When two or
|
||||
* more nodes share a prefix, the runtime winner is deterministic (first
|
||||
* openai-compatible node by id order, else first anthropic-compatible
|
||||
* node — see `getModelInfo`), and the prefix index selects that same
|
||||
* winner. Only the winner is targetable/displayed under the prefix;
|
||||
* losing nodes are ineligible and never fall back to a node UUID target.
|
||||
* - `ambiguous`: multiple nodes share the prefix but no runtime winner is
|
||||
* selectable (no compatible node matches) — practically unreachable since
|
||||
* only compatible nodes carry prefixes, kept for safety.
|
||||
*
|
||||
* Model-Overrides eligibility: a compatible node is eligible only when it is
|
||||
* the unique, non-reserved winner of its configured prefix (i.e. it is in
|
||||
* `eligibleNodeIds`). Reserved/losing/no-public-prefix compatible nodes are
|
||||
* ineligible and must be skipped — never surfaced under a generated node UUID.
|
||||
* Built-in/no-compatible catalog entries are always eligible.
|
||||
*/
|
||||
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getProviderNodes } from "@/lib/db/providers/nodes";
|
||||
|
||||
export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved";
|
||||
|
||||
export interface ProviderPrefixEntry {
|
||||
prefix: string;
|
||||
status: ProviderPrefixStatus;
|
||||
/** Present only when `status === "unique"`. */
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderPrefixIndex {
|
||||
/** prefix → classification entry (every configured prefix). */
|
||||
entries: Map<string, ProviderPrefixEntry>;
|
||||
/** nodeId → public prefix, only for uniquely-routable non-reserved winners. */
|
||||
nodeToPrefix: Map<string, string>;
|
||||
/** public prefix → nodeId, only for uniquely-routable non-reserved winners. */
|
||||
prefixToNode: Map<string, string>;
|
||||
/** Every compatible provider node id present in the node table. */
|
||||
compatibleNodeIds: Set<string>;
|
||||
/** Compatible node ids eligible for Model Overrides (unique non-reserved winners). */
|
||||
eligibleNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in reserved prefixes — registry ids + aliases, the same semantics the
|
||||
* runtime `getReservedProviderPrefixes()` uses so user-defined compatible-node
|
||||
* prefixes can never shadow a built-in provider.
|
||||
*/
|
||||
export function buildReservedPrefixes(): Set<string> {
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
export interface CompatibleNodeLike {
|
||||
id?: string;
|
||||
type?: string;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure prefix→node winner selection replicating the runtime `getModelInfo`
|
||||
* rule exactly: the first openai-compatible node (by DB/id order) whose
|
||||
* `prefix` matches wins; otherwise the first anthropic-compatible node.
|
||||
* `nodes` must already be in the runtime's id-ascending order (as
|
||||
* `getProviderNodes` returns).
|
||||
*/
|
||||
export function selectCompatibleNodeForPrefix(
|
||||
nodes: CompatibleNodeLike[],
|
||||
prefix: string
|
||||
): CompatibleNodeLike | null {
|
||||
const openaiMatch = nodes.find((n) => n.type === "openai-compatible" && n.prefix === prefix);
|
||||
if (openaiMatch) return openaiMatch;
|
||||
return nodes.find((n) => n.type === "anthropic-compatible" && n.prefix === prefix) ?? null;
|
||||
}
|
||||
|
||||
export async function getProviderPrefixIndex(): Promise<ProviderPrefixIndex> {
|
||||
const reserved = buildReservedPrefixes();
|
||||
const nodes = (await getProviderNodes()) as CompatibleNodeLike[];
|
||||
const compatible = nodes.filter(
|
||||
(n) => n.type === "openai-compatible" || n.type === "anthropic-compatible"
|
||||
);
|
||||
|
||||
const compatibleNodeIds = new Set<string>();
|
||||
for (const node of compatible) {
|
||||
if (node.id) compatibleNodeIds.add(node.id);
|
||||
}
|
||||
|
||||
const byPrefix = new Map<string, CompatibleNodeLike[]>();
|
||||
for (const node of compatible) {
|
||||
const prefix = node.prefix?.trim();
|
||||
if (!node.id || !prefix) continue;
|
||||
const list = byPrefix.get(prefix) ?? [];
|
||||
list.push(node);
|
||||
byPrefix.set(prefix, list);
|
||||
}
|
||||
|
||||
const entries = new Map<string, ProviderPrefixEntry>();
|
||||
const nodeToPrefix = new Map<string, string>();
|
||||
const prefixToNode = new Map<string, string>();
|
||||
const eligibleNodeIds = new Set<string>();
|
||||
|
||||
for (const [prefix, prefixNodes] of byPrefix) {
|
||||
if (reserved.has(prefix)) {
|
||||
// Built-in registry id/alias — never a compatible public target.
|
||||
entries.set(prefix, { prefix, status: "reserved" });
|
||||
continue;
|
||||
}
|
||||
const winner = selectCompatibleNodeForPrefix(prefixNodes, prefix);
|
||||
if (!winner?.id) {
|
||||
entries.set(prefix, { prefix, status: "ambiguous" });
|
||||
continue;
|
||||
}
|
||||
// The runtime-routable winner alone owns the prefix.
|
||||
entries.set(prefix, { prefix, status: "unique", nodeId: winner.id });
|
||||
nodeToPrefix.set(winner.id, prefix);
|
||||
prefixToNode.set(prefix, winner.id);
|
||||
eligibleNodeIds.add(winner.id);
|
||||
}
|
||||
|
||||
return { entries, nodeToPrefix, prefixToNode, compatibleNodeIds, eligibleNodeIds };
|
||||
}
|
||||
@@ -193,6 +193,13 @@ 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 { protectPayloadForLog } from "../../logPayloads";
|
||||
import { omitEncryptedReasoningFromLogChunks, 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,9 +79,12 @@ 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
|
||||
)
|
||||
Object.entries(chunks)
|
||||
.filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0)
|
||||
.map(([stage, chunkValue]) => [
|
||||
stage,
|
||||
omitEncryptedReasoningFromLogChunks(chunkValue as string[]),
|
||||
])
|
||||
);
|
||||
if (Object.keys(compacted).length > 0) {
|
||||
protectedPayloads.streamChunks = protectPayloadForLog(
|
||||
|
||||
@@ -154,6 +154,15 @@ 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,10 +4,8 @@ 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");
|
||||
@@ -49,14 +47,12 @@ 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() {},
|
||||
@@ -65,7 +61,6 @@ function noopLog() {
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
function restorePipelineCaptureEnv() {
|
||||
if (originalCallLogPipelineCaptureStreamChunks === undefined) {
|
||||
delete process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS;
|
||||
@@ -74,7 +69,6 @@ function restorePipelineCaptureEnv() {
|
||||
originalCallLogPipelineCaptureStreamChunks;
|
||||
}
|
||||
}
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
@@ -82,7 +76,6 @@ function toPlainHeaders(headers) {
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
@@ -97,7 +90,6 @@ function buildOpenAIResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-json",
|
||||
@@ -122,7 +114,6 @@ function buildOpenAIResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
@@ -170,7 +161,6 @@ function buildClaudeResponse(stream, text = "ok") {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_json",
|
||||
@@ -389,7 +379,6 @@ 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
|
||||
@@ -456,7 +445,6 @@ 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 });
|
||||
@@ -479,7 +467,6 @@ 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",
|
||||
@@ -507,7 +494,6 @@ 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",
|
||||
@@ -537,7 +523,78 @@ 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({
|
||||
@@ -565,7 +622,6 @@ 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({
|
||||
@@ -605,7 +661,6 @@ 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: [
|
||||
@@ -651,7 +706,6 @@ 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",
|
||||
@@ -761,7 +815,6 @@ 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();
|
||||
@@ -815,7 +868,6 @@ 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",
|
||||
@@ -943,7 +995,6 @@ 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();
|
||||
@@ -990,7 +1041,6 @@ 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();
|
||||
@@ -1036,7 +1086,6 @@ 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();
|
||||
@@ -1070,7 +1119,6 @@ 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();
|
||||
@@ -1092,7 +1140,6 @@ 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();
|
||||
@@ -1128,7 +1175,6 @@ 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",
|
||||
@@ -1154,7 +1200,6 @@ 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",
|
||||
@@ -1197,7 +1242,6 @@ 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",
|
||||
@@ -1249,7 +1293,6 @@ 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",
|
||||
@@ -1271,7 +1314,6 @@ 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",
|
||||
@@ -1293,7 +1335,6 @@ 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",
|
||||
@@ -1320,7 +1361,6 @@ 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,
|
||||
@@ -1347,7 +1387,6 @@ 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,
|
||||
@@ -1378,7 +1417,6 @@ 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,
|
||||
@@ -1403,7 +1441,6 @@ 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({
|
||||
@@ -1469,7 +1506,6 @@ 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",
|
||||
@@ -1483,7 +1519,6 @@ 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",
|
||||
@@ -1505,7 +1540,6 @@ 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",
|
||||
@@ -1546,7 +1580,6 @@ 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",
|
||||
@@ -1587,7 +1620,6 @@ 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",
|
||||
@@ -1625,7 +1657,6 @@ 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" };
|
||||
|
||||
@@ -1661,7 +1692,6 @@ 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 = {
|
||||
@@ -1714,7 +1744,6 @@ 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 });
|
||||
|
||||
@@ -1757,7 +1786,6 @@ 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",
|
||||
@@ -1779,7 +1807,6 @@ 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",
|
||||
@@ -1798,7 +1825,6 @@ 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",
|
||||
@@ -1850,7 +1876,6 @@ 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",
|
||||
@@ -1867,7 +1892,6 @@ 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,
|
||||
@@ -1894,7 +1918,6 @@ 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",
|
||||
@@ -1948,7 +1971,6 @@ 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: {
|
||||
@@ -2009,7 +2031,6 @@ 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",
|
||||
@@ -2036,7 +2057,6 @@ 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: {
|
||||
@@ -2071,7 +2091,6 @@ 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",
|
||||
@@ -2090,7 +2109,6 @@ 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",
|
||||
@@ -2112,7 +2130,6 @@ 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",
|
||||
@@ -2134,7 +2151,6 @@ 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",
|
||||
@@ -2175,7 +2191,6 @@ 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",
|
||||
@@ -2220,7 +2235,6 @@ 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();
|
||||
@@ -2298,7 +2312,6 @@ 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
|
||||
@@ -2338,7 +2351,6 @@ 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",
|
||||
@@ -2360,7 +2372,6 @@ 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",
|
||||
@@ -2388,7 +2399,6 @@ 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(
|
||||
@@ -2417,7 +2427,6 @@ 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",
|
||||
@@ -2452,7 +2461,6 @@ 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",
|
||||
@@ -2473,7 +2481,6 @@ 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
|
||||
@@ -2536,7 +2543,6 @@ 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;
|
||||
@@ -2605,7 +2611,6 @@ 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({
|
||||
@@ -2638,7 +2643,6 @@ 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({
|
||||
@@ -2684,7 +2688,6 @@ 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 = {
|
||||
@@ -2739,7 +2742,6 @@ 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 = {
|
||||
@@ -2780,7 +2782,6 @@ 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 = {
|
||||
@@ -2827,7 +2828,6 @@ 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",
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-model-overrides-prefix-"));
|
||||
process.env.DATA_DIR = moduleDataDir;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const nodes = await import("../../src/lib/db/providers/nodes.ts");
|
||||
const models = await import("../../src/lib/db/models.ts");
|
||||
const overrides = await import("../../src/lib/db/modelCapabilityOverrides.ts");
|
||||
const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts");
|
||||
const pricingRoute = await import("../../src/app/api/pricing/models/route.ts");
|
||||
const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts");
|
||||
const targets = await import("../../src/lib/modelCapabilityOverrideTargets.ts");
|
||||
const caps = await import("../../src/lib/modelCapabilities.ts");
|
||||
const prefixIndex = await import("../../src/lib/providerNodePrefixes.ts");
|
||||
// Runtime prefix→node resolution (same path the request pipeline uses).
|
||||
const sseModel = await import("../../src/sse/services/model.ts");
|
||||
|
||||
beforeEach(() => {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(moduleDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(moduleDataDir, { recursive: true });
|
||||
coreDb.getDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(moduleDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
|
||||
const NODE_PREFIX = "vibeproxy";
|
||||
const NODE_TYPE = "openai-compatible";
|
||||
|
||||
async function seedNodeWithSyncedModel(modelId = "gpt-4o", opts: { prefix?: string | null } = {}) {
|
||||
await nodes.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: NODE_TYPE,
|
||||
prefix: opts.prefix === undefined ? NODE_PREFIX : opts.prefix,
|
||||
name: "VibeProxy",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(NODE_ID, NODE_ID, [
|
||||
{ id: modelId, name: modelId },
|
||||
]);
|
||||
}
|
||||
|
||||
describe("issue #9557: model overrides expose configured provider prefix, not node UUID", () => {
|
||||
it("pricing/models returns public displayPrefix while retaining internal node id", async () => {
|
||||
await seedNodeWithSyncedModel();
|
||||
const response = await pricingRoute.GET();
|
||||
assert.equal(response.status, 200);
|
||||
const catalog = (await response.json()) as Record<
|
||||
string,
|
||||
{ id: string; alias: string; displayPrefix?: string; models: Array<{ id: string }> }
|
||||
>;
|
||||
|
||||
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
|
||||
assert.ok(entry, "compatible node must appear in the pricing catalog");
|
||||
assert.equal(entry.id, NODE_ID, "internal node id must be preserved for PricingTab");
|
||||
assert.equal(entry.displayPrefix, NODE_PREFIX, "public display prefix must be exposed");
|
||||
assert.ok(
|
||||
entry.models.some((model) => model.id === "gpt-4o"),
|
||||
"synced model must be listed under the compatible node"
|
||||
);
|
||||
});
|
||||
|
||||
it("toModelOverrideTargets labels/selects with the public prefix, never the node UUID", () => {
|
||||
const catalog = {
|
||||
[NODE_ID]: {
|
||||
id: NODE_ID,
|
||||
alias: NODE_ID,
|
||||
displayPrefix: NODE_PREFIX,
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
openai: {
|
||||
id: "openai",
|
||||
alias: "openai",
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
// A losing/reserved compatible node explicitly marked ineligible must be
|
||||
// skipped entirely — never surfaced under a node UUID.
|
||||
"openai-compatible-chat-loser": {
|
||||
id: "openai-compatible-chat-loser",
|
||||
alias: "openai-compatible-chat-loser",
|
||||
displayPrefix: NODE_PREFIX,
|
||||
modelOverrideEligible: false,
|
||||
models: [{ id: "lost-model", name: "lost-model" }],
|
||||
},
|
||||
};
|
||||
const result = targets.toModelOverrideTargets(catalog);
|
||||
const [compatible, builtin] = result;
|
||||
|
||||
assert.equal(result.length, 2, "ineligible compatible node is skipped");
|
||||
assert.equal(compatible.target, `${NODE_PREFIX}/gpt-4o`);
|
||||
assert.equal(compatible.provider, NODE_PREFIX);
|
||||
assert.equal(compatible.label, `${NODE_PREFIX}/gpt-4o`);
|
||||
assert.ok(!compatible.target.includes(NODE_ID), "public target must not leak the node UUID");
|
||||
assert.ok(!result.some((t) => t.target.includes("lost-model")), "lost node not a target");
|
||||
assert.equal(builtin.target, "openai/gpt-4o");
|
||||
assert.equal(
|
||||
targets.isModelOverrideEligible(catalog[NODE_ID]),
|
||||
true,
|
||||
"unflagged compatible winner is eligible"
|
||||
);
|
||||
assert.equal(
|
||||
targets.isModelOverrideEligible(catalog["openai-compatible-chat-loser"]),
|
||||
false,
|
||||
"explicitly ineligible node is excluded"
|
||||
);
|
||||
assert.equal(targets.isModelOverrideEligible(catalog.openai), true, "built-in is eligible");
|
||||
});
|
||||
|
||||
it("PATCH canonicalizes a configured prefix to the internal node id and runtime lookup applies it", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
|
||||
// Persisted under the internal node id (where runtime lookup reads it).
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].provider, NODE_ID);
|
||||
assert.equal(stored[0].modelId, "gpt-4o");
|
||||
|
||||
// Runtime capability lookup resolves provider/model and applies the override.
|
||||
const resolved = caps.getResolvedModelCapabilities({
|
||||
provider: NODE_ID,
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.equal(resolved.maxOutputTokens, 123456);
|
||||
const explicit = caps.getExplicitModelOutputCap({ provider: NODE_ID, model: "gpt-4o" });
|
||||
assert.equal(explicit, 123456);
|
||||
});
|
||||
|
||||
it("runtime getModelInfo resolves prefix/model back to the node id (routing seam)", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
|
||||
assert.ok(info, "getModelInfo must resolve");
|
||||
assert.equal(info.provider, NODE_ID, "prefix must route to the internal node id at runtime");
|
||||
});
|
||||
|
||||
it("index winner matches runtime getModelInfo for a duplicated prefix", async () => {
|
||||
// Two nodes share the prefix. Runtime resolves the FIRST openai-compatible
|
||||
// node by id order. The prefix index must select that same winner so the
|
||||
// UI and PATCH agree with the routing seam.
|
||||
const nodeAId = NODE_ID;
|
||||
const nodeBId = "openai-compatible-chat-22222222-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: nodeBId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy B",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/b/v1",
|
||||
});
|
||||
await nodes.createProviderNode({
|
||||
id: nodeAId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy A",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/a/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
|
||||
{ id: "gpt-4o", name: "gpt-4o" },
|
||||
]);
|
||||
|
||||
const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`);
|
||||
assert.equal(info.provider, nodeAId, "runtime resolves the first node by id");
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
assert.equal(
|
||||
index.prefixToNode.get(NODE_PREFIX),
|
||||
info.provider,
|
||||
"index prefix→node must match the runtime winner"
|
||||
);
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, info.provider);
|
||||
assert.ok(index.eligibleNodeIds.has(info.provider));
|
||||
assert.ok(!index.eligibleNodeIds.has(nodeBId));
|
||||
|
||||
// And PATCH persists to the same winner runtime resolves to.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 77777,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
assert.equal(overrides.listModelCapabilityOverrides()[0].provider, info.provider);
|
||||
});
|
||||
|
||||
it("GET surfaces stored overrides under the public prefix and old raw rows still list/apply", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
|
||||
// New-style row saved via the API (canonicalized to node id).
|
||||
await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 99999,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Old raw-UUID-keyed row inserted directly (legacy pre-#9557 data).
|
||||
assert.equal(
|
||||
overrides.setModelCapabilityOverride(`${NODE_ID}/gpt-4o`, "max_output_tokens", 55555),
|
||||
true
|
||||
);
|
||||
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
assert.equal(get.status, 200);
|
||||
const payload = (await get.json()) as {
|
||||
overrides: Array<{ target: string; key: string; value: number }>;
|
||||
};
|
||||
const targetsFound = payload.overrides.map((entry) => entry.target).sort();
|
||||
assert.deepEqual(targetsFound, [`${NODE_PREFIX}/gpt-4o`, `${NODE_PREFIX}/gpt-4o`]);
|
||||
assert.ok(
|
||||
payload.overrides.every((entry) => !entry.target.includes(NODE_ID)),
|
||||
"public override list must not leak the node UUID"
|
||||
);
|
||||
|
||||
// Old raw row still applies at runtime.
|
||||
assert.equal(
|
||||
caps.getResolvedModelCapabilities({ provider: NODE_ID, model: "gpt-4o" }).maxOutputTokens,
|
||||
55555
|
||||
);
|
||||
});
|
||||
|
||||
it("GET/PATCH/DELETE JSON responses never leak the node UUID for a prefixed node", async () => {
|
||||
await seedNodeWithSyncedModel("gpt-4o");
|
||||
await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 333,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
assert.ok(!(await get.text()).includes(NODE_ID), "GET body must not contain node UUID");
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 444,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.ok(!(await patch.text()).includes(NODE_ID), "PATCH body must not contain node UUID");
|
||||
|
||||
const del = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${NODE_PREFIX}/gpt-4o&key=max_output_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(del.status, 200);
|
||||
const delBody = await del.text();
|
||||
assert.ok(!delBody.includes(NODE_ID), "DELETE body must not contain node UUID");
|
||||
// DELETE still returns the updated override list for the UI.
|
||||
const delPayload = JSON.parse(delBody) as { overrides: unknown[] };
|
||||
assert.ok(Array.isArray(delPayload.overrides), "DELETE returns { overrides }");
|
||||
assert.equal(delPayload.overrides.length, 1, "remaining override still present");
|
||||
});
|
||||
|
||||
it("duplicate prefix selects the same runtime winner; losing node never falls back to UUID", async () => {
|
||||
// Two nodes share the same prefix → the runtime winner is the FIRST
|
||||
// openai-compatible node by id order. The index and PATCH must choose that
|
||||
// same winner; the losing node must NOT be targetable/displayed under the
|
||||
// prefix and must NOT fall back to a node-UUID target.
|
||||
const nodeAId = NODE_ID;
|
||||
const nodeBId = "openai-compatible-chat-11111111-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: nodeBId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy B",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/b/v1",
|
||||
});
|
||||
await nodes.createProviderNode({
|
||||
id: nodeAId,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy A",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/a/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [
|
||||
{ id: "gpt-4o", name: "gpt-4o" },
|
||||
]);
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
// Runtime resolves the first openai-compatible node by id order → nodeAId.
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.status, "unique");
|
||||
assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, nodeAId, "winner is first by id");
|
||||
assert.equal(index.prefixToNode.get(NODE_PREFIX), nodeAId);
|
||||
assert.equal(index.nodeToPrefix.get(nodeAId), NODE_PREFIX);
|
||||
// Losing node is ineligible — no node-UUID target, no prefix→node mapping.
|
||||
assert.ok(!index.eligibleNodeIds.has(nodeBId), "losing node is ineligible");
|
||||
assert.ok(!index.prefixToNode.has(nodeBId), "losing node id must not be a prefix target");
|
||||
assert.ok(!index.nodeToPrefix.has(nodeBId), "losing node must not map to the shared prefix");
|
||||
|
||||
// Catalog advertises only the winner under the public prefix.
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ id: string; displayPrefix?: string }
|
||||
>;
|
||||
const winner = Object.values(catalog).find((p) => p.id === nodeAId);
|
||||
assert.equal(winner?.displayPrefix, NODE_PREFIX, "winner advertised under prefix");
|
||||
assert.ok(
|
||||
!Object.values(catalog).some((p) => p.id === nodeBId && p.displayPrefix === NODE_PREFIX),
|
||||
"losing node must not be advertised under the shared prefix"
|
||||
);
|
||||
|
||||
// PATCH via the prefix canonicalizes to the winner (first by id) and the
|
||||
// losing node is not targetable.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].provider, nodeAId, "PATCH must persist to the runtime winner");
|
||||
|
||||
// Raw compatible-node UUIDs are not public Model Overrides targets. The
|
||||
// losing node remains routable internally, but stale/direct clients must
|
||||
// use the configured public prefix rather than create an unmanageable row.
|
||||
const losePatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${nodeBId}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 7,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(losePatch.status, 400);
|
||||
const loseDelete = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${nodeBId}/gpt-4o`)}&key=max_input_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(loseDelete.status, 400);
|
||||
assert.equal(overrides.listModelCapabilityOverrides().length, 1);
|
||||
});
|
||||
|
||||
it("reserved prefix (cx → codex) is not advertised and not canonicalized to any node", async () => {
|
||||
const RESERVED = "cx";
|
||||
const reservedNodeId = "openai-compatible-chat-99999999-2222-4333-8444-555555555555";
|
||||
await nodes.createProviderNode({
|
||||
id: reservedNodeId,
|
||||
type: NODE_TYPE,
|
||||
prefix: RESERVED,
|
||||
name: "Reserved Hijack",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/cx/v1",
|
||||
});
|
||||
|
||||
const index = await prefixIndex.getProviderPrefixIndex();
|
||||
assert.equal(index.entries.get(RESERVED)?.status, "reserved");
|
||||
assert.ok(
|
||||
!index.nodeToPrefix.has(reservedNodeId),
|
||||
"reserved-prefix node must not be exposed as a compatible target"
|
||||
);
|
||||
assert.ok(!index.prefixToNode.has(RESERVED), "reserved prefix must not map to any node");
|
||||
|
||||
// Catalog: reserved prefix must not surface as a displayPrefix on any entry.
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ displayPrefix?: string; id?: string }
|
||||
>;
|
||||
assert.ok(
|
||||
!Object.values(catalog).some((provider) => provider.displayPrefix === RESERVED),
|
||||
"reserved prefix must not be advertised as a compatible public target"
|
||||
);
|
||||
|
||||
// PATCH using `cx/<model>` must route via resolveProviderAlias to the
|
||||
// built-in codex provider (runtime routes `cx/` to codex) — never 400, and
|
||||
// never persisted under the reserved-hijack node.
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${RESERVED}/gpt-4o`,
|
||||
key: "max_output_tokens",
|
||||
value: 1,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 200, "PATCH must route reserved prefix via resolveProviderAlias");
|
||||
|
||||
const stored = overrides.listModelCapabilityOverrides();
|
||||
assert.equal(
|
||||
stored.length,
|
||||
1,
|
||||
"reserved prefix override is persisted to the built-in provider"
|
||||
);
|
||||
assert.notEqual(stored[0].provider, reservedNodeId, "must never persist to the hijack node");
|
||||
assert.equal(stored[0].provider, "codex", "reserved prefix must resolve to built-in codex");
|
||||
|
||||
const rawPatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${reservedNodeId}/gpt-4o`,
|
||||
key: "max_input_tokens",
|
||||
value: 2,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(rawPatch.status, 400, "reserved node UUID must not be writable directly");
|
||||
});
|
||||
|
||||
it("pricing-only compatible-provider model entry carries displayPrefix + pricingKey", async () => {
|
||||
// Node exists but has NO synced/custom models; pricing is keyed by the
|
||||
// public prefix and must be reconciled onto the unique node without
|
||||
// duplicating entries, while preserving the original pricing namespace.
|
||||
await nodes.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: NODE_TYPE,
|
||||
prefix: NODE_PREFIX,
|
||||
name: "VibeProxy",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/v1",
|
||||
});
|
||||
const { updatePricing } = await import("../../src/lib/db/settings/pricing.ts");
|
||||
// Seed a user pricing row keyed by the node's public prefix.
|
||||
await updatePricing({
|
||||
[NODE_PREFIX]: { "priced-model": { input_cost_per_million: 1, output_cost_per_million: 2 } },
|
||||
});
|
||||
|
||||
const catalogRes = await pricingRoute.GET();
|
||||
const catalog = (await catalogRes.json()) as Record<
|
||||
string,
|
||||
{ id: string; displayPrefix?: string; pricingKey?: string; models: Array<{ id: string }> }
|
||||
>;
|
||||
const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID);
|
||||
assert.ok(entry, "pricing-only model must reconcile onto the compatible node");
|
||||
assert.equal(entry.displayPrefix, NODE_PREFIX);
|
||||
assert.equal(
|
||||
entry.pricingKey,
|
||||
NODE_PREFIX,
|
||||
"pricingKey must preserve the original pricing namespace"
|
||||
);
|
||||
assert.deepEqual(
|
||||
entry.models.map((model) => model.id),
|
||||
["priced-model"],
|
||||
"pricing-only entry must be created without relying on synced/custom models"
|
||||
);
|
||||
// No duplicate entry keyed by the public prefix alone.
|
||||
assert.ok(
|
||||
!Object.values(catalog).some(
|
||||
(provider) => provider.id === NODE_PREFIX && provider.displayPrefix !== NODE_PREFIX
|
||||
),
|
||||
"pricing must not create a duplicate node-keyed entry"
|
||||
);
|
||||
});
|
||||
|
||||
it("no-prefix fallback and built-in providers keep raw internal id / unchanged behavior", async () => {
|
||||
// Node with no prefix → target falls back to internal node id.
|
||||
const noPrefixNodeId = "openai-compatible-chat-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
await nodes.createProviderNode({
|
||||
id: noPrefixNodeId,
|
||||
type: NODE_TYPE,
|
||||
prefix: null,
|
||||
name: "NoPrefix",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://example.com/np/v1",
|
||||
});
|
||||
await models.replaceSyncedAvailableModelsForConnection(noPrefixNodeId, noPrefixNodeId, [
|
||||
{ id: "np-model", name: "np-model" },
|
||||
]);
|
||||
|
||||
const patch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: `${noPrefixNodeId}/np-model`,
|
||||
key: "max_output_tokens",
|
||||
value: 777,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(patch.status, 400, "no-prefix compatible node UUID must not be writable");
|
||||
assert.equal(overrides.listModelCapabilityOverrides().length, 0);
|
||||
|
||||
const rawDelete = await overrideRoute.DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${noPrefixNodeId}/np-model`)}&key=max_output_tokens`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
);
|
||||
assert.equal(rawDelete.status, 400);
|
||||
|
||||
// Model-Overrides eligibility seam: a compatible node with NO public prefix
|
||||
// is skipped from the public override list — it is never surfaced under a
|
||||
// generated node UUID. Built-in / no-compatible entries remain targetable.
|
||||
const get = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
const payload = (await get.json()) as { overrides: Array<{ target: string }> };
|
||||
assert.ok(
|
||||
!payload.overrides.some((entry) => entry.target === `${noPrefixNodeId}/np-model`),
|
||||
"no-public-prefix compatible node must be skipped from the override list"
|
||||
);
|
||||
|
||||
// Built-in provider (openai) unchanged.
|
||||
const openaiPatch = await overrideRoute.PATCH(
|
||||
new Request("http://localhost/api/model-capability-overrides", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: "openai/gpt-4o",
|
||||
key: "max_input_tokens",
|
||||
value: 888,
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(openaiPatch.status, 200);
|
||||
const openaiGet = await overrideRoute.GET(
|
||||
new Request("http://localhost/api/model-capability-overrides")
|
||||
);
|
||||
const openaiPayload = (await openaiGet.json()) as { overrides: Array<{ target: string }> };
|
||||
assert.ok(
|
||||
openaiPayload.overrides.some((entry) => entry.target === "openai/gpt-4o"),
|
||||
"built-in provider target must remain openai/gpt-4o"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,36 @@ 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,6 +15,21 @@ 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,3 +1,4 @@
|
||||
import { protectPipelinePayloads } from "../../src/lib/usage/callLogs/format.ts";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -34,6 +35,46 @@ 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,19 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { stripStoredItemReferences } from "../../open-sse/executors/codex.ts";
|
||||
import { applyResponsesInputPolicy } from "../../open-sse/services/responsesInputPolicy.ts";
|
||||
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts";
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
test("stripStoredItemReferences drops object items with type=reasoning", () => {
|
||||
test("applyResponsesInputPolicy drops object items with type=reasoning", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
@@ -29,7 +24,7 @@ test("stripStoredItemReferences drops object items with type=reasoning", () => {
|
||||
],
|
||||
};
|
||||
|
||||
stripStoredItemReferences(body);
|
||||
applyResponsesInputPolicy(body);
|
||||
|
||||
const input = body.input as Array<Record<string, unknown>>;
|
||||
// Both reasoning items must be gone.
|
||||
@@ -45,6 +40,66 @@ test("stripStoredItemReferences 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: [
|
||||
|
||||
@@ -169,6 +169,112 @@ 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);
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Issue #9557 UI regression: the Model Overrides dashboard tab must render and
|
||||
// PATCH/DELETE against the operator-configured public provider prefix
|
||||
// (e.g. `vibeproxy/gpt-4o`), never the generated `openai-compatible-chat-<uuid>`
|
||||
// node id. We mount the REAL ModelCapabilityOverridesTab and drive it through
|
||||
// its fetch-backed data hook with a stubbed `global.fetch`.
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ModelCapabilityOverridesTab from "@/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab";
|
||||
|
||||
const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441";
|
||||
const NODE_PREFIX = "vibeproxy";
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ModelCapabilityOverridesTab />);
|
||||
});
|
||||
roots.push({ root, el });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok: boolean } = { ok: true }): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.ok ? 200 : 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelCapabilityOverridesTab (issue #9557)", () => {
|
||||
it("renders the public prefix, never the node UUID, and PATCH/DELETE use prefix/model", async () => {
|
||||
const catalog = {
|
||||
[NODE_ID]: {
|
||||
id: NODE_ID,
|
||||
alias: NODE_ID,
|
||||
displayPrefix: NODE_PREFIX,
|
||||
name: "VibeProxy",
|
||||
authType: "unknown",
|
||||
format: "openai",
|
||||
models: [{ id: "gpt-4o", name: "gpt-4o" }],
|
||||
},
|
||||
};
|
||||
const overrides: Array<{ target: string; provider: string; key: string; value: number }> = [
|
||||
{
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
provider: NODE_PREFIX,
|
||||
key: "max_output_tokens",
|
||||
value: 123456,
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementation((input: any) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/pricing/models")) {
|
||||
return Promise.resolve(jsonResponse(catalog));
|
||||
}
|
||||
if (url.includes("/api/model-capability-overrides")) {
|
||||
return Promise.resolve(jsonResponse({ overrides }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
// Flush the async load.
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
// The target label (public prefix) must be visible.
|
||||
const bodyText = document.body.textContent ?? "";
|
||||
expect(bodyText).toContain(`${NODE_PREFIX}/gpt-4o`);
|
||||
expect(bodyText).not.toContain(NODE_ID);
|
||||
|
||||
// The stored override value is rendered.
|
||||
expect(bodyText).toContain("123456");
|
||||
|
||||
// Click the Add button to PATCH a new override on the currently-selected
|
||||
// (prefixed) target, then assert the request body used prefix/model.
|
||||
const addButton = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent ?? "").includes("Add key value")
|
||||
);
|
||||
expect(addButton).toBeTruthy();
|
||||
// Value field + Add → PATCH with a new max_input_tokens value.
|
||||
const valueInput = document.querySelector('input[type="number"]') as HTMLInputElement;
|
||||
expect(valueInput).toBeTruthy();
|
||||
// Select a different key for the new override.
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
act(() => {
|
||||
select.value = "max_input_tokens";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
// React controlled inputs ignore direct `.value` writes — use the native
|
||||
// descriptor so the onChange handler fires with the new value.
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(valueInput, "99999");
|
||||
valueInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
addButton!.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const patchCall = fetchMock.mock.calls.find(([input, init]) => {
|
||||
const u = String(input);
|
||||
const method = (init as RequestInit | undefined)?.method;
|
||||
return u.includes("/api/model-capability-overrides") && method === "PATCH";
|
||||
});
|
||||
expect(patchCall).toBeTruthy();
|
||||
const patchBody = JSON.parse(String(patchCall![1].body)) as { target: string };
|
||||
expect(patchBody.target).toBe(`${NODE_PREFIX}/gpt-4o`);
|
||||
expect(patchBody.target).not.toContain(NODE_ID);
|
||||
|
||||
// DELETE path: the row's Remove button must send a DELETE with prefix/model.
|
||||
// Re-mock GET to return the saved override so the row renders.
|
||||
const withNewOverride = [
|
||||
...overrides,
|
||||
{
|
||||
target: `${NODE_PREFIX}/gpt-4o`,
|
||||
provider: NODE_PREFIX,
|
||||
key: "max_input_tokens",
|
||||
value: 99999,
|
||||
},
|
||||
];
|
||||
fetchMock.mockImplementation((input: any, init: RequestInit | undefined) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (url.includes("/api/pricing/models")) return Promise.resolve(jsonResponse(catalog));
|
||||
if (url.includes("/api/model-capability-overrides")) {
|
||||
if (method === "PATCH")
|
||||
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
|
||||
if (method === "DELETE")
|
||||
return Promise.resolve(jsonResponse({ overrides: [withNewOverride[0]] }));
|
||||
return Promise.resolve(jsonResponse({ overrides: withNewOverride }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false }));
|
||||
});
|
||||
|
||||
// Re-render fresh to pick up the new override list.
|
||||
for (const { root, el } of roots.splice(0)) act(() => root.unmount());
|
||||
render();
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
const removeButtons = Array.from(document.querySelectorAll("button")).filter(
|
||||
(b) => (b.textContent ?? "").trim() === "Remove"
|
||||
);
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
removeButtons[0].click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const deleteCall = fetchMock.mock.calls.find(([input, init]) => {
|
||||
const method = (init as RequestInit | undefined)?.method;
|
||||
return method === "DELETE";
|
||||
});
|
||||
expect(deleteCall).toBeTruthy();
|
||||
const deleteUrl = String(deleteCall![0]);
|
||||
expect(deleteUrl).toContain(`target=${encodeURIComponent(`${NODE_PREFIX}/gpt-4o`)}`);
|
||||
expect(deleteUrl).not.toContain(NODE_ID);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user