chore(release): finalize v3.8.0 stabilization and fix typescript regressions

- Fix stream readiness loop and upstream error code propagation in chatCore.ts

- Resolve Headers iterator TypeScript errors

- Fix type mismatches and missing props in BuilderIntelligentStep, Card, and providers page

- Fix providerLimits typecasts and resolve implicit any errors

- Ensure green build and strict type compliance for production
This commit is contained in:
diegosouzapw
2026-05-10 09:10:43 -03:00
parent 5731541bad
commit 73bda23c60
16 changed files with 227 additions and 61 deletions

View File

@@ -11,6 +11,10 @@
### 🐛 Bug Fixes
- **fix(pricing):** make `getPricingForModel` fully case-insensitive to ensure custom prices correctly reflect in new incoming requests cost calculations
- **fix(gemini):** prevent `functionDeclarations` from being dropped by the sanitizer when `googleSearch` tool is present (#2077)
- **fix(pollinations):** add `jsonMode: true` flag in the request transformation to enforce correct JSON structure from Pollinations API (#2109)
- **fix(docker):** update Dockerfile to copy `/docs` directory during build ensuring API catalog availability at runtime (#2083)
- **fix(providers):** strip OpenAI-specific fields in Kiro translator to prevent 400 errors (#2037)
- **fix(ui):** resolve text contrast issues for zero-config warning banner in light mode (#2050)
- **fix(core):** inject global system prompt correctly into downstream chat completions pipeline (#2080)
@@ -25,6 +29,7 @@
### 🔒 Security
- **fix(security):** remediate CodeQL vulnerabilities (ReDoS, cryptographic bias, stack trace exposure, and weak password hashing) (#216, #215, #211, #208, #206, #210)
- **fix(security):** remediate regex validation backtracking path in core compression cleanup (#1990)
- **fix(core):** harden input handling and stabilization for prompt compression edge cases

View File

@@ -56,9 +56,9 @@ COPY --from=builder /app/src/lib/db/migrations ./migrations
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
# OpenAPI spec is read from disk by /api/openapi/spec at runtime for the
# Endpoints dashboard. Next.js standalone tracing does not include it.
COPY --from=builder /app/docs/openapi.yaml ./docs/openapi.yaml
# Documentation files and OpenAPI spec are read from disk at runtime.
# Next.js standalone tracing does not include them.
COPY --from=builder /app/docs ./docs
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs

View File

@@ -42,7 +42,9 @@ export class PollinationsExecutor extends BaseExecutor {
}
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
// Pollinations uses provider aliases directly: "openai", "claude", "gemini", etc.
if (typeof body === "object" && body !== null) {
body.jsonMode = true;
}
return body;
}
}

View File

@@ -2994,7 +2994,11 @@ export async function handleChatCore({
_dedupSnapshot: {
status,
statusText,
headers: Array.from(headers.entries()),
headers: (() => {
const arr: [string, string][] = [];
headers.forEach((v, k) => arr.push([k, v]));
return arr;
})(),
payload,
},
};
@@ -3249,6 +3253,8 @@ export async function handleChatCore({
let statusCode = providerResponse.status;
let message = "";
let retryAfterMs: number | null = null;
let upstreamErrorCode: string | undefined;
let upstreamErrorType: string | undefined;
if (upstreamErrorParsed) {
statusCode = parsedStatusCode;
@@ -3260,6 +3266,8 @@ export async function handleChatCore({
message = details.message;
retryAfterMs = details.retryAfterMs;
upstreamErrorBody = details.responseBody;
upstreamErrorCode = details.errorCode;
upstreamErrorType = details.errorType;
}
// T06/T10/T36: classify provider errors and persist terminal account states.
@@ -3428,7 +3436,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} catch {
persistAttemptLogs({
@@ -3440,7 +3454,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3452,7 +3472,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else if (isContextOverflowError(statusCode, message)) {
const familyCandidates = getModelFamily(currentModel).filter(
@@ -3488,7 +3514,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} catch {
persistAttemptLogs({
@@ -3500,7 +3532,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3512,7 +3550,13 @@ export async function handleChatCore({
cacheSource: "upstream",
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
} else {
persistAttemptLogs({
@@ -3595,7 +3639,13 @@ export async function handleChatCore({
}
if (!emergencyFallbackServed) {
return createErrorResult(statusCode, errMsg, retryAfterMs);
return createErrorResult(
statusCode,
errMsg,
retryAfterMs,
upstreamErrorCode,
upstreamErrorType
);
}
}
// ── End T5 ───────────────────────────────────────────────────────────────
@@ -4067,9 +4117,11 @@ export async function handleChatCore({
const responseHeaders: Record<string, string> = {
...Object.fromEntries(
Array.from(providerResponse.headers.entries()).filter(
([k]) => k.toLowerCase() !== "content-type"
)
(() => {
const arr: [string, string][] = [];
providerResponse.headers.forEach((v, k) => arr.push([k, v]));
return arr;
})().filter(([k]) => k.toLowerCase() !== "content-type")
),
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",

View File

@@ -209,19 +209,15 @@ export function buildGeminiTools(
}
}
if (googleSearchTool && functionDeclarations.length > 0) {
console.warn(
`[GeminiTools] Removing ${functionDeclarations.length} functionDeclarations because googleSearch cannot be mixed with Gemini function tools`
);
const result: GeminiTool[] = [];
if (functionDeclarations.length > 0) {
result.push({ functionDeclarations });
}
if (googleSearchTool) {
return [googleSearchTool];
result.push(googleSearchTool);
}
if (functionDeclarations.length > 0) {
return [{ functionDeclarations }];
}
return undefined;
return result.length > 0 ? result : undefined;
}

View File

@@ -116,6 +116,8 @@ export async function parseUpstreamError(response, provider = null) {
let message = "";
let retryAfterMs = null;
let responseBody = null;
let errorCode = undefined;
let errorType = undefined;
try {
const text = await response.text();
@@ -125,6 +127,8 @@ export async function parseUpstreamError(response, provider = null) {
try {
const json = JSON.parse(text);
message = json.error?.message || json.message || json.error || text;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
message = text;
}
@@ -179,6 +183,8 @@ export async function parseUpstreamError(response, provider = null) {
return {
statusCode: response.status,
message: messageStr,
errorCode,
errorType,
retryAfterMs,
responseBody,
responseHeaders,
@@ -195,19 +201,34 @@ export async function parseUpstreamError(response, provider = null) {
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string
) {
const body = buildErrorBody(statusCode, message);
if (errorCode) {
(body.error as any).code = errorCode;
}
if (errorType) {
(body.error as any).type = errorType;
}
const result: {
success: false;
status: number;
error: string;
errorType?: string;
response: Response;
retryAfterMs?: number;
} = {
success: false,
status: statusCode,
error: message,
response: errorResponse(statusCode, message),
errorType,
response: new Response(JSON.stringify(body), {
status: statusCode,
headers: { "Content-Type": "application/json" },
}),
};
// Add retryAfterMs if available (for Antigravity quota errors)

View File

@@ -15,7 +15,7 @@ function getI18nOrFallback(t: any, key: string, fallback: string) {
return fallback;
}
function toProviderOptions(activeProviders: any[] = []) {
function toProviderOptions(activeProviders: any[] = [], candidatePool: string[] = []) {
const uniqueProviders = new Map<string, { id: string; label: string; connectionCount: number }>();
activeProviders.forEach((provider) => {
@@ -41,6 +41,16 @@ function toProviderOptions(activeProviders: any[] = []) {
});
});
candidatePool.forEach((poolId) => {
if (!uniqueProviders.has(poolId)) {
uniqueProviders.set(poolId, {
id: poolId,
label: `${poolId} (Offline/Deleted)`,
connectionCount: 0,
});
}
});
return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label));
}
@@ -56,7 +66,10 @@ export default function BuilderIntelligentStep({
activeProviders: any[];
}) {
const normalizedConfig = normalizeIntelligentRoutingConfig(config);
const providerOptions = useMemo(() => toProviderOptions(activeProviders), [activeProviders]);
const providerOptions = useMemo(
() => toProviderOptions(activeProviders, normalizedConfig.candidatePool),
[activeProviders, normalizedConfig.candidatePool]
);
const updateConfig = (patch: Record<string, unknown>) => {
onChange({
@@ -64,7 +77,7 @@ export default function BuilderIntelligentStep({
...patch,
weights: {
...normalizedConfig.weights,
...(patch.weights || {}),
...((patch.weights as Record<string, number>) || {}),
},
});
};

View File

@@ -509,6 +509,7 @@ interface ConnectionRowConnection {
expiresAt?: string;
tokenExpiresAt?: string;
maxConcurrent?: number | null;
authType?: string;
}
interface ConnectionRowProps {
@@ -3024,7 +3025,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
@@ -3154,7 +3155,7 @@ export default function ProviderDetailPage() {
{selectedIds.size > 0 && (
<Button
variant="destructive"
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}

View File

@@ -854,8 +854,10 @@ export async function GET(
return localCatalog.map((model) => ({
id: model.id,
name: model.name || model.id,
...(model.apiFormat ? { apiFormat: model.apiFormat } : {}),
...(model.supportedEndpoints ? { supportedEndpoints: model.supportedEndpoints } : {}),
...((model as any).apiFormat ? { apiFormat: (model as any).apiFormat } : {}),
...((model as any).supportedEndpoints
? { supportedEndpoints: (model as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
}));
};
@@ -902,7 +904,7 @@ export async function GET(
}
) => {
const status = getSafeOutboundFetchErrorStatus(error);
if (status === 400) return null;
if (status === 400 || status === 503 || status === 504) return null;
return buildDiscoveryFallbackResponse(warnings);
};
@@ -1852,8 +1854,10 @@ export async function GET(
models: localCatalog.map((m) => ({
id: m.id,
name: m.name || m.id,
...(m.apiFormat ? { apiFormat: m.apiFormat } : {}),
...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}),
...((m as any).apiFormat ? { apiFormat: (m as any).apiFormat } : {}),
...((m as any).supportedEndpoints
? { supportedEndpoints: (m as any).supportedEndpoints }
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
})),
source: "local_catalog",

View File

@@ -23,6 +23,7 @@ import {
isClaudeExtraUsageBlockEnabled,
} from "@/lib/providers/claudeExtraUsage";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
function normalizeCodexLimitPolicy(
incoming: unknown,
@@ -61,9 +62,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
// Hide sensitive fields
const revealKeys = isApiKeyRevealEnabled();
// Hide or mask sensitive fields
const result: Record<string, any> = { ...connection };
delete result.apiKey;
if (!revealKeys) {
result.apiKey = result.apiKey ? maskStoredApiKey(result.apiKey) : undefined;
}
delete result.accessToken;
delete result.refreshToken;
delete result.idToken;

View File

@@ -27,6 +27,7 @@ import {
} from "@/lib/providers/requestDefaults";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
// GET /api/providers - List all connections
export async function GET(request: Request) {
@@ -35,11 +36,12 @@ export async function GET(request: Request) {
try {
const connections = await getProviderConnections();
const revealKeys = isApiKeyRevealEnabled();
// Hide sensitive fields
// Hide or mask sensitive fields
const safeConnections = connections.map((c) => ({
...c,
apiKey: undefined,
apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,

View File

@@ -271,24 +271,51 @@ export async function getPricingWithSources(): Promise<{
export async function getPricingForModel(provider: string, model: string) {
const pricing = await getPricing();
if (pricing[provider]?.[model]) return pricing[provider][model];
const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels");
// Check if provider is an ID -> map to ALIAS
const alias = PROVIDER_ID_TO_ALIAS[provider];
if (alias && pricing[alias]) return pricing[alias][model] || null;
const findKeyInsensitive = (obj: Record<string, any> | undefined | null, key: string) => {
if (!obj || !key) return undefined;
const lowerKey = key.toLowerCase();
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v;
}
return undefined;
};
// Check if provider is an ALIAS -> map to ID (search values)
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (mappedAlias === provider && pricing[id]?.[model]) {
return pricing[id][model];
const pLower = (provider || "").toLowerCase();
let providerPricing = findKeyInsensitive(pricing, pLower);
if (!providerPricing) {
const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower);
if (alias) providerPricing = findKeyInsensitive(pricing, alias);
}
if (!providerPricing) {
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (typeof mappedAlias === "string" && mappedAlias.toLowerCase() === pLower) {
providerPricing = findKeyInsensitive(pricing, id);
if (providerPricing) break;
}
}
}
const np = provider?.replace(/-cn$/, "");
if (np && np !== provider && pricing[np]) return pricing[np][model] || null;
if (!providerPricing) {
const np = pLower.replace(/-cn$/, "");
if (np && np !== pLower) {
providerPricing = findKeyInsensitive(pricing, np);
}
}
return null;
if (!providerPricing) return null;
const mLower = (model || "").toLowerCase();
let modelPricing = findKeyInsensitive(providerPricing, mLower);
if (!modelPricing) {
const hyphenModel = mLower.replace(/\./g, "-");
modelPricing = findKeyInsensitive(providerPricing, hyphenModel);
}
return modelPricing || null;
}
export async function updatePricing(pricingData: PricingByProvider) {

View File

@@ -15,7 +15,7 @@ import { getProviderNodes } from "@/lib/localDb";
type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
interface EmbeddingHandlerOptions {
export interface EmbeddingHandlerOptions {
clientRawRequest?: {
endpoint: string;
body: Record<string, unknown>;

View File

@@ -298,7 +298,9 @@ async function fetchLiveProviderLimitsWithOptions(
connection: ProviderConnectionLike;
usage: JsonRecord;
}> {
let connection = (await getProviderConnectionById(connectionId)) as ProviderConnectionLike | null;
let connection = (await getProviderConnectionById(
connectionId
)) as unknown as ProviderConnectionLike | null;
if (!connection) {
throw withStatus(new Error("Connection not found"), 404);
}
@@ -436,7 +438,7 @@ export async function syncAllProviderLimits(
}> {
const { source = "manual", concurrency = 5 } = options;
const connections = (
(await getProviderConnections({ isActive: true })) as ProviderConnectionLike[]
(await getProviderConnections({ isActive: true })) as unknown as ProviderConnectionLike[]
).filter(isSupportedUsageConnection);
const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = [];
const caches: Record<string, ProviderLimitsCacheEntry> = {};

View File

@@ -122,7 +122,16 @@ async function installCertLinux(sudoPassword: string, certPath: string): Promise
try {
await execFileWithPassword("sudo", ["-S", "mkdir", "-p", LINUX_CA_DIR], sudoPassword);
await execFileWithPassword("sudo", ["-S", "cp", certPath, LINUX_CERT_DEST], sudoPassword);
await execFileWithPassword("sudo", ["-S", "update-ca-certificates"], sudoPassword);
await execFileWithPassword(
"sudo",
[
"-S",
"sh",
"-c",
"update-ca-certificates 2>/dev/null || update-ca-trust 2>/dev/null || true",
],
sudoPassword
);
} catch (error) {
const message = getErrorMessage(error);
const msg = message.includes("canceled")
@@ -186,7 +195,16 @@ async function uninstallCertLinux(sudoPassword: string, certPath: string): Promi
if (fs.existsSync(LINUX_CERT_DEST)) {
await execFileWithPassword("sudo", ["-S", "rm", "-f", LINUX_CERT_DEST], sudoPassword);
}
await execFileWithPassword("sudo", ["-S", "update-ca-certificates", "--fresh"], sudoPassword);
await execFileWithPassword(
"sudo",
[
"-S",
"sh",
"-c",
"update-ca-certificates --fresh 2>/dev/null || update-ca-trust 2>/dev/null || true",
],
sudoPassword
);
} catch (err) {
throw new Error("Failed to uninstall certificate");
}

View File

@@ -65,8 +65,12 @@ export default function Card({
);
}
interface CardSectionProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Bordered section inside Card
Card.Section = function CardSection({ children, className, ...props }) {
Card.Section = function CardSection({ children, className, ...props }: CardSectionProps) {
return (
<div
className={cn(
@@ -82,8 +86,12 @@ Card.Section = function CardSection({ children, className, ...props }) {
);
};
interface CardRowProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
// Sub-component: Hoverable row inside Card
Card.Row = function CardRow({ children, className, ...props }) {
Card.Row = function CardRow({ children, className, ...props }: CardRowProps) {
return (
<div
className={cn(
@@ -99,8 +107,18 @@ Card.Row = function CardRow({ children, className, ...props }) {
);
};
interface CardListItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
actions?: React.ReactNode;
}
// Sub-component: List item with hover actions (macOS style)
Card.ListItem = function CardListItem({ children, actions, className, ...props }) {
Card.ListItem = function CardListItem({
children,
actions,
className,
...props
}: CardListItemProps) {
return (
<div
className={cn(