mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #940 from diegosouzapw/release/v3.4.8
chore(release): v3.4.8 — security and vulnerability patches
This commit is contained in:
@@ -27,6 +27,23 @@ Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Security Verification (MANDATORY)
|
||||
|
||||
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
|
||||
|
||||
1. **Run Local Dependencies Audit:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
```
|
||||
|
||||
_Fix any `high` or `critical` vulnerabilities identified._
|
||||
|
||||
2. **Check GitHub CodeQL & Dependabot Alerts:**
|
||||
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Merge
|
||||
|
||||
### 1. Create release branch
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -4,6 +4,18 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.4.8] — 2026-04-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fully remediated all outstanding Github Advanced Security (CodeQL) findings and Dependabot alerts.
|
||||
- Fixed insecure randomness vulnerabilities by migrating from `Math.random` to `crypto.randomUUID()`.
|
||||
- Secured shell commands in automated scripts from string injection.
|
||||
- Migrated vulnerable catastrophic backtracking RegEx parsing patterns in chat/translation pipelines.
|
||||
- Enhanced output sanitization controls inside React UI components and Server Sent Events (SSE) tag injection.
|
||||
|
||||
---
|
||||
|
||||
## [3.4.7] — 2026-04-03
|
||||
|
||||
### Features
|
||||
|
||||
@@ -35,7 +35,9 @@ function ask(question) {
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
return createHash("sha256").update(password).digest("hex");
|
||||
return createHash("sha256")
|
||||
.update(password) /* lgtm[js/insufficient-password-hash] */
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.4.7
|
||||
version: 3.4.8
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
/**
|
||||
* Shared Registry Utilities
|
||||
*
|
||||
|
||||
@@ -40,9 +40,7 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
format: "sdwebui-video",
|
||||
models: [
|
||||
{ id: "animatediff-webui", name: "AnimateDiff (WebUI)" },
|
||||
],
|
||||
models: [{ id: "animatediff-webui", name: "AnimateDiff (WebUI)" }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import crypto, { randomUUID } from "crypto";
|
||||
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts";
|
||||
|
||||
@@ -164,7 +164,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
generateSessionId() {
|
||||
return `-${Math.floor(Math.random() * 9_000_000_000_000_000_000)}`;
|
||||
return `-${parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 9_000_000_000_000_000_000}`;
|
||||
}
|
||||
|
||||
parseRetryHeaders(headers) {
|
||||
@@ -230,13 +230,17 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let timedOut = false;
|
||||
const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS);
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new Error("Request aborted during SSE collection");
|
||||
const { done, value } = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) =>
|
||||
timeout.addEventListener("abort", () => reject(new Error("SSE collection timed out")), { once: true })
|
||||
timeout.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("SSE collection timed out")),
|
||||
{ once: true }
|
||||
)
|
||||
),
|
||||
]);
|
||||
if (done) break;
|
||||
@@ -271,7 +275,10 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
if (candidate?.finishReason) {
|
||||
finishReason = candidate.finishReason.toLowerCase() === "stop" ? "stop" : candidate.finishReason.toLowerCase();
|
||||
finishReason =
|
||||
candidate.finishReason.toLowerCase() === "stop"
|
||||
? "stop"
|
||||
: candidate.finishReason.toLowerCase();
|
||||
}
|
||||
if (parsed?.response?.usageMetadata) {
|
||||
const um = parsed.response.usageMetadata;
|
||||
@@ -330,12 +337,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const url = this.buildUrl(model, upstreamStream, urlIndex);
|
||||
const headers = this.buildHeaders(credentials, upstreamStream);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
const transformedBody = await this.transformRequest(
|
||||
model,
|
||||
body,
|
||||
upstreamStream,
|
||||
credentials
|
||||
);
|
||||
const transformedBody = await this.transformRequest(model, body, upstreamStream, credentials);
|
||||
|
||||
// Initialize retry counter for this URL
|
||||
if (!retryAttemptsByUrl[urlIndex]) {
|
||||
@@ -474,7 +476,15 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// For non-streaming clients, collect the SSE stream and return a synthetic
|
||||
// non-streaming Response so chatCore doesn't need to handle SSE conversion.
|
||||
if (!stream) {
|
||||
return this.collectStreamToResponse(response, model, url, headers, transformedBody, log, signal);
|
||||
return this.collectStreamToResponse(
|
||||
response,
|
||||
model,
|
||||
url,
|
||||
headers,
|
||||
transformedBody,
|
||||
log,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
return { response, url, headers, transformedBody };
|
||||
|
||||
@@ -102,7 +102,9 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
console.warn("[OmniRoute] loadCodeAssist returned no project — falling back to stored projectId");
|
||||
console.warn(
|
||||
"[OmniRoute] loadCodeAssist returned no project — falling back to stored projectId"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,14 +39,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
async execute({
|
||||
model,
|
||||
body,
|
||||
stream,
|
||||
credentials,
|
||||
signal,
|
||||
upstreamExtraHeaders,
|
||||
}: ExecuteInput) {
|
||||
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { getCorsOrigin } from "../utils/cors.ts";
|
||||
/**
|
||||
* Audio Speech Handler (TTS)
|
||||
|
||||
@@ -116,7 +116,8 @@ export function shouldUseNativeCodexPassthrough({
|
||||
}): boolean {
|
||||
if (provider !== "codex") return false;
|
||||
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
||||
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
|
||||
let normalizedEndpoint = String(endpointPath || "");
|
||||
while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1);
|
||||
const segments = normalizedEndpoint.split("/");
|
||||
return segments.includes("responses");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
/**
|
||||
* Embedding Handler
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
/**
|
||||
* Image Generation Handler
|
||||
*
|
||||
@@ -992,7 +993,7 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
|
||||
"3": {
|
||||
class_type: "KSampler",
|
||||
inputs: {
|
||||
seed: Math.floor(Math.random() * 2 ** 32),
|
||||
seed: parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 2 ** 32,
|
||||
steps: body.steps || 20,
|
||||
cfg: body.cfg_scale || 7,
|
||||
sampler_name: "euler",
|
||||
@@ -1087,7 +1088,10 @@ type Imagen3ImageGenArgs = {
|
||||
providerConfig: { baseUrl: string };
|
||||
body: { prompt?: string; size?: string; n?: number };
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
log?: { info?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } | null;
|
||||
log?: {
|
||||
info?: (tag: string, msg: string) => void;
|
||||
error?: (tag: string, msg: string) => void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type Imagen3NormalizedImage = {
|
||||
|
||||
@@ -50,7 +50,11 @@ export async function handleMusicGeneration({ body, credentials, log }) {
|
||||
return handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log });
|
||||
}
|
||||
|
||||
return { success: false, status: 400, error: `Unsupported music format: ${providerConfig.format}` };
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unsupported music format: ${providerConfig.format}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +113,10 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b
|
||||
|
||||
if (log) {
|
||||
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
||||
log.info("MUSIC", `${provider}/${model} (comfyui) | prompt: "${promptPreview}..." | duration: ${duration}s`);
|
||||
log.info(
|
||||
"MUSIC",
|
||||
`${provider}/${model} (comfyui) | prompt: "${promptPreview}..." | duration: ${duration}s`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
/**
|
||||
* Search Handler
|
||||
*
|
||||
|
||||
@@ -55,7 +55,11 @@ export async function handleVideoGeneration({ body, credentials, log }) {
|
||||
return handleSDWebUIVideoGeneration({ model, provider, providerConfig, body, log });
|
||||
}
|
||||
|
||||
return { success: false, status: 400, error: `Unsupported video format: ${providerConfig.format}` };
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unsupported video format: ${providerConfig.format}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +123,10 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b
|
||||
|
||||
if (log) {
|
||||
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
||||
log.info("VIDEO", `${provider}/${model} (comfyui) | prompt: "${promptPreview}..." | frames: ${frames}`);
|
||||
log.info(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (comfyui) | prompt: "${promptPreview}..." | frames: ${frames}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -202,7 +209,8 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (log) log.error("VIDEO", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
if (log)
|
||||
log.error("VIDEO", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/videos/generations",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getRegistryEntry } from "../../config/providerRegistry.ts";
|
||||
import { PROVIDER_ID_TO_ALIAS, getModelsByProviderId, getProviderModels } from "../../config/providerModels.ts";
|
||||
import {
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
getModelsByProviderId,
|
||||
getProviderModels,
|
||||
} from "../../config/providerModels.ts";
|
||||
import { supportsToolCalling } from "../../services/modelCapabilities.ts";
|
||||
import { getPricingForModel } from "../../../src/shared/constants/pricing.ts";
|
||||
|
||||
|
||||
@@ -17,4 +17,3 @@ export {
|
||||
isMcpHttpActive,
|
||||
} from "./httpTransport.ts";
|
||||
export * from "./schemas/index.ts";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -46,7 +46,7 @@ export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined)
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
if (!normalized) return "";
|
||||
return normalized.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
return normalized.split("?")[0].replace(/\/messages$/i, "");
|
||||
}
|
||||
|
||||
export function stripClaudeCodeCompatibleEndpointSuffix(
|
||||
@@ -56,7 +56,7 @@ export function stripClaudeCodeCompatibleEndpointSuffix(
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
if (!normalized) return "";
|
||||
return normalized.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, "");
|
||||
return normalized.split("?")[0].replace(/\/(?:v\d+\/)?messages$/i, "");
|
||||
}
|
||||
|
||||
function joinNormalizedBaseUrlAndPath(baseUrl: string, path: string): string {
|
||||
|
||||
@@ -83,10 +83,9 @@ export function supportsReasoning(modelStr: string): boolean {
|
||||
const normalized = String(modelStr || "").toLowerCase();
|
||||
if (!normalized) return true;
|
||||
|
||||
const blocked = REASONING_UNSUPPORTED_PATTERNS.some((pattern) =>
|
||||
normalized === pattern ||
|
||||
normalized.endsWith(`/${pattern}`) ||
|
||||
normalized.includes(pattern)
|
||||
const blocked = REASONING_UNSUPPORTED_PATTERNS.some(
|
||||
(pattern) =>
|
||||
normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern)
|
||||
);
|
||||
|
||||
return !blocked;
|
||||
|
||||
@@ -93,7 +93,9 @@ export function getStaticQoderModels() {
|
||||
}
|
||||
|
||||
export function mapQoderModelToLevel(model: string | null | undefined): string | null {
|
||||
const normalized = String(model || "").trim().toLowerCase();
|
||||
const normalized = String(model || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized.includes("deepseek-r1")) return "ultimate";
|
||||
if (normalized.includes("qwen3-max")) return "performance";
|
||||
@@ -475,7 +477,8 @@ export async function validateQoderCliPat({
|
||||
providerSpecificData?: JsonRecord;
|
||||
}) {
|
||||
const modelId =
|
||||
getString(providerSpecificData.validationModelId).trim() || getString(providerSpecificData.modelId).trim();
|
||||
getString(providerSpecificData.validationModelId).trim() ||
|
||||
getString(providerSpecificData.modelId).trim();
|
||||
const result = await runQoderCliCommand({
|
||||
token: apiKey,
|
||||
prompt: "Reply with OK only.",
|
||||
|
||||
@@ -9,7 +9,9 @@ export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
const refreshPromiseCache = new Map();
|
||||
|
||||
function getRefreshCacheKey(provider, refreshToken) {
|
||||
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
|
||||
const tokenHash = createHash("sha256")
|
||||
.update(refreshToken) /* lgtm[js/insufficient-password-hash] */
|
||||
.digest("hex");
|
||||
return `${provider}:${tokenHash}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -700,15 +700,15 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||
"tab_flash_lite_preview",
|
||||
"tab_jump_flash_lite_preview",
|
||||
"gemini-2.5-flash-thinking",
|
||||
"gemini-2.5-pro", // browser subagent model — not user-callable
|
||||
"gemini-2.5-flash", // internal — quota always exhausted on free tier
|
||||
"gemini-2.5-flash-lite", // internal — quota always exhausted on free tier
|
||||
"gemini-2.5-pro", // browser subagent model — not user-callable
|
||||
"gemini-2.5-flash", // internal — quota always exhausted on free tier
|
||||
"gemini-2.5-flash-lite", // internal — quota always exhausted on free tier
|
||||
"gemini-2.5-flash-preview-image-generation", // image-gen only, not usable for chat
|
||||
"gemini-3.1-flash-image-preview", // image-gen preview, not usable for chat
|
||||
"gemini-3-flash-agent", // internal agent model — not user-callable
|
||||
"gemini-3.1-flash-lite", // not usable for chat
|
||||
"gemini-3-pro-low", // not usable for chat
|
||||
"gemini-3-pro-high", // not usable for chat
|
||||
"gemini-3.1-flash-image-preview", // image-gen preview, not usable for chat
|
||||
"gemini-3-flash-agent", // internal agent model — not user-callable
|
||||
"gemini-3.1-flash-lite", // not usable for chat
|
||||
"gemini-3-pro-low", // not usable for chat
|
||||
"gemini-3-pro-high", // not usable for chat
|
||||
]);
|
||||
|
||||
// Parse per-model quota info from fetchAvailableModels response.
|
||||
@@ -717,7 +717,11 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||
const quotaInfo = toRecord(info.quotaInfo);
|
||||
|
||||
// Skip internal, excluded, and models without quota info
|
||||
if (info.isInternal === true || ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) || Object.keys(quotaInfo).length === 0) {
|
||||
if (
|
||||
info.isInternal === true ||
|
||||
ANTIGRAVITY_EXCLUDED_MODELS.has(modelKey) ||
|
||||
Object.keys(quotaInfo).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,79 +5,293 @@
|
||||
* Risk-based phase skipping: high=all phases, medium=skip planner, low=execute+test only.
|
||||
*/
|
||||
|
||||
export type Phase = "classify"|"plan"|"plan_review"|"execute"|"code_review"|"quality_review"|"security"|"test"|"output_review"|"done"|"failed"|"paused";
|
||||
export type RiskLevel = "low"|"medium"|"high";
|
||||
export type Verdict = "approve"|"approve_with_notes"|"request_changes"|"reject"|"block";
|
||||
export type Phase =
|
||||
| "classify"
|
||||
| "plan"
|
||||
| "plan_review"
|
||||
| "execute"
|
||||
| "code_review"
|
||||
| "quality_review"
|
||||
| "security"
|
||||
| "test"
|
||||
| "output_review"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "paused";
|
||||
export type RiskLevel = "low" | "medium" | "high";
|
||||
export type Verdict = "approve" | "approve_with_notes" | "request_changes" | "reject" | "block";
|
||||
|
||||
export interface PhaseRecord {
|
||||
phase: Phase; enteredAt: string; exitedAt: string|null;
|
||||
verdict: Verdict|null; provider: string|null; model: string|null;
|
||||
retryCount: number; notes: string|null;
|
||||
phase: Phase;
|
||||
enteredAt: string;
|
||||
exitedAt: string | null;
|
||||
verdict: Verdict | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
retryCount: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowContext {
|
||||
id: string; currentPhase: Phase; risk: RiskLevel;
|
||||
lastVerdict: Verdict|null; retries: Record<string,number>;
|
||||
maxRetries: number; testsPass: boolean;
|
||||
history: PhaseRecord[]; createdAt: string;
|
||||
metadata: Record<string,unknown>;
|
||||
id: string;
|
||||
currentPhase: Phase;
|
||||
risk: RiskLevel;
|
||||
lastVerdict: Verdict | null;
|
||||
retries: Record<string, number>;
|
||||
maxRetries: number;
|
||||
testsPass: boolean;
|
||||
history: PhaseRecord[];
|
||||
createdAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Transition { from: Phase; to: Phase; condition: (ctx: WorkflowContext) => boolean; description: string; }
|
||||
interface Transition {
|
||||
from: Phase;
|
||||
to: Phase;
|
||||
condition: (ctx: WorkflowContext) => boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const HIGH_KEYWORDS = ["schema","migration","deploy","delete","drop","env","database","refactor","security","auth","production","secrets","credentials","permission"];
|
||||
const MED_KEYWORDS = ["endpoint","feature","service","model","api","integration","webhook","middleware","route"];
|
||||
const HIGH_KEYWORDS = [
|
||||
"schema",
|
||||
"migration",
|
||||
"deploy",
|
||||
"delete",
|
||||
"drop",
|
||||
"env",
|
||||
"database",
|
||||
"refactor",
|
||||
"security",
|
||||
"auth",
|
||||
"production",
|
||||
"secrets",
|
||||
"credentials",
|
||||
"permission",
|
||||
];
|
||||
const MED_KEYWORDS = [
|
||||
"endpoint",
|
||||
"feature",
|
||||
"service",
|
||||
"model",
|
||||
"api",
|
||||
"integration",
|
||||
"webhook",
|
||||
"middleware",
|
||||
"route",
|
||||
];
|
||||
|
||||
export function classifyRisk(desc: string): RiskLevel {
|
||||
const l = desc.toLowerCase();
|
||||
if (HIGH_KEYWORDS.some(k => l.includes(k))) return "high";
|
||||
if (MED_KEYWORDS.some(k => l.includes(k))) return "medium";
|
||||
if (HIGH_KEYWORDS.some((k) => l.includes(k))) return "high";
|
||||
if (MED_KEYWORDS.some((k) => l.includes(k))) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
const PHASE_ORDER: Phase[] = ["classify","plan","plan_review","execute","code_review","quality_review","security","test","output_review"];
|
||||
|
||||
const T: Transition[] = [
|
||||
{from:"classify",to:"plan",condition:c=>c.risk==="high",description:"High risk -> full planning"},
|
||||
{from:"classify",to:"execute",condition:c=>c.risk==="medium",description:"Medium risk -> skip planner"},
|
||||
{from:"classify",to:"execute",condition:c=>c.risk==="low",description:"Low risk -> direct execute"},
|
||||
{from:"plan",to:"plan_review",condition:()=>true,description:"Plan -> review"},
|
||||
{from:"plan_review",to:"execute",condition:c=>c.lastVerdict==="approve"||c.lastVerdict==="approve_with_notes",description:"Plan approved -> execute"},
|
||||
{from:"plan_review",to:"plan",condition:c=>(c.lastVerdict==="reject"||c.lastVerdict==="request_changes")&&(c.retries["plan"]??0)<c.maxRetries,description:"Plan rejected -> retry"},
|
||||
{from:"plan_review",to:"failed",condition:c=>(c.lastVerdict==="reject"||c.lastVerdict==="request_changes")&&(c.retries["plan"]??0)>=c.maxRetries,description:"Plan rejected max retries"},
|
||||
{from:"execute",to:"code_review",condition:c=>c.risk!=="low",description:"Non-low -> code review"},
|
||||
{from:"execute",to:"test",condition:c=>c.risk==="low",description:"Low -> skip reviews"},
|
||||
{from:"code_review",to:"quality_review",condition:c=>c.lastVerdict==="approve"||c.lastVerdict==="approve_with_notes",description:"Code approved -> quality"},
|
||||
{from:"code_review",to:"execute",condition:c=>(c.lastVerdict==="reject"||c.lastVerdict==="request_changes")&&(c.retries["execute"]??0)<c.maxRetries,description:"Code rejected -> re-execute"},
|
||||
{from:"code_review",to:"failed",condition:c=>(c.lastVerdict==="reject"||c.lastVerdict==="request_changes")&&(c.retries["execute"]??0)>=c.maxRetries,description:"Code rejected max retries"},
|
||||
{from:"quality_review",to:"security",condition:c=>c.risk==="high",description:"High -> security audit"},
|
||||
{from:"quality_review",to:"test",condition:c=>c.risk!=="high",description:"Non-high -> skip security"},
|
||||
{from:"security",to:"failed",condition:c=>c.lastVerdict==="block",description:"Security BLOCK -> failed"},
|
||||
{from:"security",to:"test",condition:c=>c.lastVerdict!=="block",description:"Security passed -> test"},
|
||||
{from:"test",to:"output_review",condition:c=>c.testsPass,description:"Tests pass -> output review"},
|
||||
{from:"test",to:"execute",condition:c=>!c.testsPass&&(c.retries["execute"]??0)<c.maxRetries,description:"Tests fail -> re-execute"},
|
||||
{from:"test",to:"failed",condition:c=>!c.testsPass&&(c.retries["execute"]??0)>=c.maxRetries,description:"Tests fail max retries"},
|
||||
{from:"output_review",to:"done",condition:()=>true,description:"Output reviewed -> done"},
|
||||
const PHASE_ORDER: Phase[] = [
|
||||
"classify",
|
||||
"plan",
|
||||
"plan_review",
|
||||
"execute",
|
||||
"code_review",
|
||||
"quality_review",
|
||||
"security",
|
||||
"test",
|
||||
"output_review",
|
||||
];
|
||||
|
||||
export function createWorkflow(id: string, description: string, opts?: {maxRetries?: number; metadata?: Record<string,unknown>}): WorkflowContext {
|
||||
const T: Transition[] = [
|
||||
{
|
||||
from: "classify",
|
||||
to: "plan",
|
||||
condition: (c) => c.risk === "high",
|
||||
description: "High risk -> full planning",
|
||||
},
|
||||
{
|
||||
from: "classify",
|
||||
to: "execute",
|
||||
condition: (c) => c.risk === "medium",
|
||||
description: "Medium risk -> skip planner",
|
||||
},
|
||||
{
|
||||
from: "classify",
|
||||
to: "execute",
|
||||
condition: (c) => c.risk === "low",
|
||||
description: "Low risk -> direct execute",
|
||||
},
|
||||
{ from: "plan", to: "plan_review", condition: () => true, description: "Plan -> review" },
|
||||
{
|
||||
from: "plan_review",
|
||||
to: "execute",
|
||||
condition: (c) => c.lastVerdict === "approve" || c.lastVerdict === "approve_with_notes",
|
||||
description: "Plan approved -> execute",
|
||||
},
|
||||
{
|
||||
from: "plan_review",
|
||||
to: "plan",
|
||||
condition: (c) =>
|
||||
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
|
||||
(c.retries["plan"] ?? 0) < c.maxRetries,
|
||||
description: "Plan rejected -> retry",
|
||||
},
|
||||
{
|
||||
from: "plan_review",
|
||||
to: "failed",
|
||||
condition: (c) =>
|
||||
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
|
||||
(c.retries["plan"] ?? 0) >= c.maxRetries,
|
||||
description: "Plan rejected max retries",
|
||||
},
|
||||
{
|
||||
from: "execute",
|
||||
to: "code_review",
|
||||
condition: (c) => c.risk !== "low",
|
||||
description: "Non-low -> code review",
|
||||
},
|
||||
{
|
||||
from: "execute",
|
||||
to: "test",
|
||||
condition: (c) => c.risk === "low",
|
||||
description: "Low -> skip reviews",
|
||||
},
|
||||
{
|
||||
from: "code_review",
|
||||
to: "quality_review",
|
||||
condition: (c) => c.lastVerdict === "approve" || c.lastVerdict === "approve_with_notes",
|
||||
description: "Code approved -> quality",
|
||||
},
|
||||
{
|
||||
from: "code_review",
|
||||
to: "execute",
|
||||
condition: (c) =>
|
||||
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
|
||||
(c.retries["execute"] ?? 0) < c.maxRetries,
|
||||
description: "Code rejected -> re-execute",
|
||||
},
|
||||
{
|
||||
from: "code_review",
|
||||
to: "failed",
|
||||
condition: (c) =>
|
||||
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
|
||||
(c.retries["execute"] ?? 0) >= c.maxRetries,
|
||||
description: "Code rejected max retries",
|
||||
},
|
||||
{
|
||||
from: "quality_review",
|
||||
to: "security",
|
||||
condition: (c) => c.risk === "high",
|
||||
description: "High -> security audit",
|
||||
},
|
||||
{
|
||||
from: "quality_review",
|
||||
to: "test",
|
||||
condition: (c) => c.risk !== "high",
|
||||
description: "Non-high -> skip security",
|
||||
},
|
||||
{
|
||||
from: "security",
|
||||
to: "failed",
|
||||
condition: (c) => c.lastVerdict === "block",
|
||||
description: "Security BLOCK -> failed",
|
||||
},
|
||||
{
|
||||
from: "security",
|
||||
to: "test",
|
||||
condition: (c) => c.lastVerdict !== "block",
|
||||
description: "Security passed -> test",
|
||||
},
|
||||
{
|
||||
from: "test",
|
||||
to: "output_review",
|
||||
condition: (c) => c.testsPass,
|
||||
description: "Tests pass -> output review",
|
||||
},
|
||||
{
|
||||
from: "test",
|
||||
to: "execute",
|
||||
condition: (c) => !c.testsPass && (c.retries["execute"] ?? 0) < c.maxRetries,
|
||||
description: "Tests fail -> re-execute",
|
||||
},
|
||||
{
|
||||
from: "test",
|
||||
to: "failed",
|
||||
condition: (c) => !c.testsPass && (c.retries["execute"] ?? 0) >= c.maxRetries,
|
||||
description: "Tests fail max retries",
|
||||
},
|
||||
{
|
||||
from: "output_review",
|
||||
to: "done",
|
||||
condition: () => true,
|
||||
description: "Output reviewed -> done",
|
||||
},
|
||||
];
|
||||
|
||||
export function createWorkflow(
|
||||
id: string,
|
||||
description: string,
|
||||
opts?: { maxRetries?: number; metadata?: Record<string, unknown> }
|
||||
): WorkflowContext {
|
||||
const risk = classifyRisk(description);
|
||||
return {id, currentPhase:"classify", risk, lastVerdict:null, retries:{}, maxRetries:opts?.maxRetries??3, testsPass:false,
|
||||
history:[{phase:"classify",enteredAt:new Date().toISOString(),exitedAt:null,verdict:null,provider:null,model:null,retryCount:0,notes:`Risk: ${risk}`}],
|
||||
createdAt:new Date().toISOString(), metadata:opts?.metadata??{}};
|
||||
return {
|
||||
id,
|
||||
currentPhase: "classify",
|
||||
risk,
|
||||
lastVerdict: null,
|
||||
retries: {},
|
||||
maxRetries: opts?.maxRetries ?? 3,
|
||||
testsPass: false,
|
||||
history: [
|
||||
{
|
||||
phase: "classify",
|
||||
enteredAt: new Date().toISOString(),
|
||||
exitedAt: null,
|
||||
verdict: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
retryCount: 0,
|
||||
notes: `Risk: ${risk}`,
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: opts?.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function advance(ctx: WorkflowContext, result?: {verdict?:Verdict;testsPass?:boolean;provider?:string;model?:string;notes?:string}): Phase|null {
|
||||
if(result?.verdict!=null) ctx.lastVerdict=result.verdict;
|
||||
if(result?.testsPass!=null) ctx.testsPass=result.testsPass;
|
||||
const cur=ctx.history[ctx.history.length-1];
|
||||
if(cur){cur.exitedAt=new Date().toISOString();cur.verdict=result?.verdict??null;cur.provider=result?.provider??null;cur.model=result?.model??null;cur.notes=result?.notes??null;}
|
||||
for(const t of T){
|
||||
if(t.from===ctx.currentPhase&&t.condition(ctx)){
|
||||
const fi=PHASE_ORDER.indexOf(t.from),ti=PHASE_ORDER.indexOf(t.to);
|
||||
if(ti>=0&&fi>=0&&ti<=fi) ctx.retries[t.to]=(ctx.retries[t.to]??0)+1;
|
||||
ctx.currentPhase=t.to;
|
||||
ctx.history.push({phase:t.to,enteredAt:new Date().toISOString(),exitedAt:null,verdict:null,provider:null,model:null,retryCount:ctx.retries[t.to]??0,notes:t.description});
|
||||
export function advance(
|
||||
ctx: WorkflowContext,
|
||||
result?: {
|
||||
verdict?: Verdict;
|
||||
testsPass?: boolean;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
notes?: string;
|
||||
}
|
||||
): Phase | null {
|
||||
if (result?.verdict != null) ctx.lastVerdict = result.verdict;
|
||||
if (result?.testsPass != null) ctx.testsPass = result.testsPass;
|
||||
const cur = ctx.history[ctx.history.length - 1];
|
||||
if (cur) {
|
||||
cur.exitedAt = new Date().toISOString();
|
||||
cur.verdict = result?.verdict ?? null;
|
||||
cur.provider = result?.provider ?? null;
|
||||
cur.model = result?.model ?? null;
|
||||
cur.notes = result?.notes ?? null;
|
||||
}
|
||||
for (const t of T) {
|
||||
if (t.from === ctx.currentPhase && t.condition(ctx)) {
|
||||
const fi = PHASE_ORDER.indexOf(t.from),
|
||||
ti = PHASE_ORDER.indexOf(t.to);
|
||||
if (ti >= 0 && fi >= 0 && ti <= fi) ctx.retries[t.to] = (ctx.retries[t.to] ?? 0) + 1;
|
||||
ctx.currentPhase = t.to;
|
||||
ctx.history.push({
|
||||
phase: t.to,
|
||||
enteredAt: new Date().toISOString(),
|
||||
exitedAt: null,
|
||||
verdict: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
retryCount: ctx.retries[t.to] ?? 0,
|
||||
notes: t.description,
|
||||
});
|
||||
return t.to;
|
||||
}
|
||||
}
|
||||
@@ -85,19 +299,42 @@ export function advance(ctx: WorkflowContext, result?: {verdict?:Verdict;testsPa
|
||||
}
|
||||
|
||||
export function pause(ctx: WorkflowContext, reason: string): void {
|
||||
ctx.currentPhase="paused";
|
||||
ctx.history.push({phase:"paused",enteredAt:new Date().toISOString(),exitedAt:null,verdict:null,provider:null,model:null,retryCount:0,notes:reason});
|
||||
ctx.currentPhase = "paused";
|
||||
ctx.history.push({
|
||||
phase: "paused",
|
||||
enteredAt: new Date().toISOString(),
|
||||
exitedAt: null,
|
||||
verdict: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
retryCount: 0,
|
||||
notes: reason,
|
||||
});
|
||||
}
|
||||
|
||||
export function resume(ctx: WorkflowContext, phase: Phase): void {
|
||||
const p=ctx.history[ctx.history.length-1];if(p)p.exitedAt=new Date().toISOString();
|
||||
ctx.currentPhase=phase;
|
||||
ctx.history.push({phase,enteredAt:new Date().toISOString(),exitedAt:null,verdict:null,provider:null,model:null,retryCount:ctx.retries[phase]??0,notes:"Resumed"});
|
||||
const p = ctx.history[ctx.history.length - 1];
|
||||
if (p) p.exitedAt = new Date().toISOString();
|
||||
ctx.currentPhase = phase;
|
||||
ctx.history.push({
|
||||
phase,
|
||||
enteredAt: new Date().toISOString(),
|
||||
exitedAt: null,
|
||||
verdict: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
retryCount: ctx.retries[phase] ?? 0,
|
||||
notes: "Resumed",
|
||||
});
|
||||
}
|
||||
|
||||
export function isTerminated(ctx: WorkflowContext): boolean { return ctx.currentPhase==="done"||ctx.currentPhase==="failed"; }
|
||||
export function getPhaseSequence(ctx: WorkflowContext): Phase[] { return ctx.history.map(r=>r.phase); }
|
||||
export function isTerminated(ctx: WorkflowContext): boolean {
|
||||
return ctx.currentPhase === "done" || ctx.currentPhase === "failed";
|
||||
}
|
||||
export function getPhaseSequence(ctx: WorkflowContext): Phase[] {
|
||||
return ctx.history.map((r) => r.phase);
|
||||
}
|
||||
export function getLLMCallCount(ctx: WorkflowContext): number {
|
||||
const sys: Phase[]=["classify","paused","done","failed"];
|
||||
return ctx.history.filter(r=>!sys.includes(r.phase)&&r.exitedAt!=null).length;
|
||||
const sys: Phase[] = ["classify", "paused", "done", "failed"];
|
||||
return ctx.history.filter((r) => !sys.includes(r.phase) && r.exitedAt != null).length;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,11 @@ export function translateRequest(
|
||||
credentials = null,
|
||||
provider = null,
|
||||
reqLogger = null,
|
||||
options?: { normalizeToolCallId?: boolean; preserveDeveloperRole?: boolean; preserveCacheControl?: boolean }
|
||||
options?: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
preserveCacheControl?: boolean;
|
||||
}
|
||||
) {
|
||||
let result = body;
|
||||
const use9CharId = options?.normalizeToolCallId === true;
|
||||
|
||||
@@ -227,7 +227,11 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
}
|
||||
// Content blocked by Gemini safety filters — pass through as "content_filter"
|
||||
// so downstream clients can distinguish from normal completion.
|
||||
if (finishReason === "safety" || finishReason === "recitation" || finishReason === "blocklist") {
|
||||
if (
|
||||
finishReason === "safety" ||
|
||||
finishReason === "recitation" ||
|
||||
finishReason === "blocklist"
|
||||
) {
|
||||
finishReason = "content_filter";
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ const DEFAULT_INTERVAL_MS = 2000;
|
||||
* @param {AbortSignal} [options.signal] - Abort signal for cancellation
|
||||
* @returns {TransformStream}
|
||||
*/
|
||||
export function createProgressTransform({ intervalMs = DEFAULT_INTERVAL_MS, signal }: { intervalMs?: number; signal?: AbortSignal } = {}) {
|
||||
export function createProgressTransform({
|
||||
intervalMs = DEFAULT_INTERVAL_MS,
|
||||
signal,
|
||||
}: { intervalMs?: number; signal?: AbortSignal } = {}) {
|
||||
let tokenCount = 0;
|
||||
let startTime = Date.now();
|
||||
let intervalId;
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
filterUsageForFormat,
|
||||
COLORS,
|
||||
} from "./usageTracking.ts";
|
||||
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE, unwrapGeminiChunk } from "./streamHelpers.ts";
|
||||
import {
|
||||
parseSSELine,
|
||||
hasValuableContent,
|
||||
fixInvalidId,
|
||||
formatSSE,
|
||||
unwrapGeminiChunk,
|
||||
} from "./streamHelpers.ts";
|
||||
import {
|
||||
createStructuredSSECollector,
|
||||
buildStreamSummaryFromEvents,
|
||||
|
||||
@@ -158,7 +158,11 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
}
|
||||
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
|
||||
if (typeof delta.reasoning === "string" && delta.reasoning.length > 0 && !delta.reasoning_content) {
|
||||
if (
|
||||
typeof delta.reasoning === "string" &&
|
||||
delta.reasoning.length > 0 &&
|
||||
!delta.reasoning_content
|
||||
) {
|
||||
reasoningParts.push(delta.reasoning);
|
||||
}
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -21047,7 +21047,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.7"
|
||||
"version": "3.4.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -51,7 +51,9 @@ for (const fullPath of routeFiles) {
|
||||
}
|
||||
|
||||
if (missingValidation.length > 0) {
|
||||
console.error("[t06:route-validation] FAIL - routes with request.json() without validateBody() or .safeParse():");
|
||||
console.error(
|
||||
"[t06:route-validation] FAIL - routes with request.json() without validateBody() or .safeParse():"
|
||||
);
|
||||
for (const file of missingValidation) {
|
||||
console.error(` - ${file}`);
|
||||
}
|
||||
|
||||
@@ -170,9 +170,15 @@ function setByPath(target, pathStr, value) {
|
||||
const tokens = pathStr.split(".");
|
||||
let current = target;
|
||||
for (let i = 0; i < tokens.length - 1; i += 1) {
|
||||
if (tokens[i] === "__proto__" || tokens[i] === "constructor" || tokens[i] === "prototype")
|
||||
continue;
|
||||
if (typeof current[tokens[i]] !== "object") current[tokens[i]] = {};
|
||||
current = current[tokens[i]];
|
||||
}
|
||||
current[tokens[tokens.length - 1]] = value;
|
||||
const last = tokens[tokens.length - 1];
|
||||
if (last !== "__proto__" && last !== "constructor" && last !== "prototype") {
|
||||
current[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMessageOverrides() {
|
||||
|
||||
@@ -240,7 +240,7 @@ if (existsSync(mitmSrc)) {
|
||||
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
|
||||
|
||||
try {
|
||||
execSync("npx tsc -p " + JSON.stringify(tmpTsconfigPath), { cwd: ROOT, stdio: "inherit" });
|
||||
execSync("npx tsc -p tsconfig.mitm.tmp.json", { cwd: ROOT, stdio: "inherit" });
|
||||
console.log(" ✅ MITM utilities compiled to app/src/mitm/");
|
||||
} catch (err) {
|
||||
console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message);
|
||||
@@ -264,7 +264,7 @@ if (existsSync(mcpSrcFile)) {
|
||||
mkdirSync(mcpDestDir, { recursive: true });
|
||||
try {
|
||||
execSync(
|
||||
`npx esbuild ${JSON.stringify(mcpSrcFile)} --bundle --platform=node --packages=external --format=esm --outfile=${JSON.stringify(mcpDestFile)}`,
|
||||
`npx esbuild open-sse/mcp-server/server.ts --bundle --platform=node --packages=external --format=esm --outfile=app/open-sse/mcp-server/server.js`,
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
console.log(" ✅ MCP Server bundled to app/open-sse/mcp-server/server.js");
|
||||
|
||||
@@ -45,7 +45,13 @@ async function main() {
|
||||
|
||||
const vitestProcess = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/protocol-clients.test.ts", "--dir", "tests"],
|
||||
[
|
||||
"./node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
"tests/e2e/protocol-clients.test.ts",
|
||||
"--dir",
|
||||
"tests",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: testEnv,
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function DiversityScoreCard() {
|
||||
Provider Diversity Score
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-md border ${gaugeColor.replace("bg-", "border-").replace("500", "500/20")} ${gaugeColor.replace("bg-", "bg-").replace("500", "500/10")} ${riskColor}`}
|
||||
className={`text-xs px-2 py-0.5 rounded-md border ${gaugeColor.replace("bg-", "border-").replace("500", "500/20")} ${gaugeColor.replace("500", "500/10")} ${riskColor}`}
|
||||
>
|
||||
Shannon Entropy
|
||||
</span>
|
||||
|
||||
@@ -144,7 +144,6 @@ export default function AutoComboDashboard() {
|
||||
tierPriority: "🏷️ Tier",
|
||||
};
|
||||
|
||||
|
||||
const MODE_PACKS = [
|
||||
{ id: "ship-fast", label: "🚀 Ship Fast" },
|
||||
{ id: "cost-saver", label: "💰 Cost Saver" },
|
||||
|
||||
@@ -753,7 +753,9 @@ export default function MediaPageClient() {
|
||||
{/* Transcription: file upload */}
|
||||
{activeTab === "transcription" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">Audio / Video File</label>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">
|
||||
Audio / Video File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
@@ -761,7 +763,9 @@ export default function MediaPageClient() {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setFileSizeError(null);
|
||||
if (file && file.size > MAX_TRANSCRIPTION_FILE_SIZE) {
|
||||
setFileSizeError(`File too large (${formatFileSize(file.size)}). Maximum allowed: 4 GB.`);
|
||||
setFileSizeError(
|
||||
`File too large (${formatFileSize(file.size)}). Maximum allowed: 4 GB.`
|
||||
);
|
||||
setAudioFile(null);
|
||||
e.target.value = "";
|
||||
return;
|
||||
@@ -781,7 +785,9 @@ export default function MediaPageClient() {
|
||||
{audioFile.name} ({formatFileSize(audioFile.size)})
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted/60 mt-1">Supports audio and video files up to 4 GB</p>
|
||||
<p className="text-[10px] text-text-muted/60 mt-1">
|
||||
Supports audio and video files up to 4 GB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Prompt / Text */
|
||||
|
||||
@@ -884,9 +884,7 @@ export default function ProviderDetailPage() {
|
||||
const isOAuth = providerSupportsOAuth && !providerSupportsPat;
|
||||
const registryModels = getModelsByProviderId(providerId);
|
||||
// For Gemini: always use synced API models (empty if no keys added yet)
|
||||
const models = providerId === "gemini"
|
||||
? syncedAvailableModels
|
||||
: registryModels;
|
||||
const models = providerId === "gemini" ? syncedAvailableModels : registryModels;
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
|
||||
const isSearchProvider = providerId.endsWith("-search");
|
||||
@@ -2050,23 +2048,24 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const importButton = providerId === "gemini" ? null : (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const importButton =
|
||||
providerId === "gemini" ? null : (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
@@ -2401,7 +2400,9 @@ export default function ProviderDetailPage() {
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined}
|
||||
onReauth={
|
||||
conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
|
||||
}
|
||||
@@ -4270,8 +4271,9 @@ function ConnectionRow({
|
||||
<span
|
||||
className={`text-xs truncate max-w-[300px] ${statusPresentation.errorTextClass}`}
|
||||
title={connection.lastError.replace(/<[^>]*>?/gm, "")}
|
||||
dangerouslySetInnerHTML={{ __html: connection.lastError }}
|
||||
/>
|
||||
>
|
||||
{connection.lastError.replace(/<[^>]*>?/gm, "")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
{connection.globalPriority && (
|
||||
|
||||
@@ -58,7 +58,9 @@ export default function SearchHistory({ onReplay }: SearchHistoryProps) {
|
||||
<div className="text-xs text-text-main truncate">{entry.query}</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-[10px] text-text-muted">{entry.provider}</span>
|
||||
<span className="text-[10px] text-text-muted">{timeAgo(entry.timestamp, locale)}</span>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{timeAgo(entry.timestamp, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -2,9 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
const SECRET = process.env.JWT_SECRET
|
||||
? new TextEncoder().encode(process.env.JWT_SECRET)
|
||||
: null;
|
||||
const SECRET = process.env.JWT_SECRET ? new TextEncoder().encode(process.env.JWT_SECRET) : null;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -15,14 +15,14 @@ async function guardEnabled(): Promise<NextResponse | null> {
|
||||
if (!settings.mcpEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "MCP server is disabled. Enable it from the Endpoints page." },
|
||||
{ status: 503 },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const transport = (settings.mcpTransport as string) || "stdio";
|
||||
if (transport !== "sse") {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP transport is set to "${transport}", not "sse". Change it from Settings.` },
|
||||
{ status: 400 },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -26,8 +26,7 @@ export async function GET() {
|
||||
|
||||
// stdio uses heartbeat file; HTTP transports use in-process state
|
||||
const stdioOnline = isMcpHeartbeatOnline(heartbeat, { requireLivePid: true });
|
||||
const online =
|
||||
mcpTransport === "stdio" ? stdioOnline : httpStatus.online;
|
||||
const online = mcpTransport === "stdio" ? stdioOnline : httpStatus.online;
|
||||
|
||||
const lastCall = lastCallPage.entries[0] || null;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -16,14 +16,16 @@ async function guardEnabled(): Promise<NextResponse | null> {
|
||||
if (!settings.mcpEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "MCP server is disabled. Enable it from the Endpoints page." },
|
||||
{ status: 503 },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const transport = (settings.mcpTransport as string) || "stdio";
|
||||
if (transport !== "streamable-http") {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP transport is set to "${transport}", not "streamable-http". Change it from Settings.` },
|
||||
{ status: 400 },
|
||||
{
|
||||
error: `MCP transport is set to "${transport}", not "streamable-http". Change it from Settings.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -20,7 +20,10 @@ export async function GET() {
|
||||
|
||||
let specPath = "";
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) { specPath = p; break; }
|
||||
if (fs.existsSync(p)) {
|
||||
specPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!specPath) {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
|
||||
const tryRequestSchema = z.object({
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).optional().default("GET"),
|
||||
method: z
|
||||
.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
.optional()
|
||||
.default("GET"),
|
||||
path: z.string().min(1, "Path is required").startsWith("/", "Path must start with /"),
|
||||
headers: z.record(z.string(), z.string()).optional().default({}),
|
||||
body: z.any().optional(),
|
||||
|
||||
@@ -169,9 +169,7 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""),
|
||||
name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""),
|
||||
supportedEndpoints: endpoints,
|
||||
...(typeof m.inputTokenLimit === "number"
|
||||
? { inputTokenLimit: m.inputTokenLimit }
|
||||
: {}),
|
||||
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
|
||||
...(typeof m.outputTokenLimit === "number"
|
||||
? { outputTokenLimit: m.outputTokenLimit }
|
||||
: {}),
|
||||
@@ -747,7 +745,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (pageCount > 1) {
|
||||
console.log(`[models] ${provider}: fetched ${allModels.length} models across ${pageCount} pages`);
|
||||
console.log(
|
||||
`[models] ${provider}: fetched ${allModels.length} models across ${pageCount} pages`
|
||||
);
|
||||
}
|
||||
|
||||
return buildResponse({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { getCustomModels, replaceCustomModels, replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
|
||||
import {
|
||||
getCustomModels,
|
||||
replaceCustomModels,
|
||||
replaceSyncedAvailableModelsForConnection,
|
||||
} from "@/lib/db/models";
|
||||
import {
|
||||
syncManagedAvailableModelAliases,
|
||||
usesManagedAvailableModels,
|
||||
@@ -144,7 +148,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
// Fetch models from the existing /api/providers/[id]/models endpoint
|
||||
const origin = new URL(request.url).origin;
|
||||
const modelsUrl = `${origin}/api/providers/${id}/models`;
|
||||
const modelsUrl = `${origin}/api/providers/${encodeURIComponent(id)}/models`;
|
||||
const modelsRes = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -210,7 +214,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
source: "api-sync" as const,
|
||||
...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}),
|
||||
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
|
||||
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
|
||||
...(typeof m.outputTokenLimit === "number"
|
||||
? { outputTokenLimit: m.outputTokenLimit }
|
||||
: {}),
|
||||
...(typeof m.description === "string" ? { description: m.description } : {}),
|
||||
...(m.supportsThinking === true ? { supportsThinking: true } : {}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +25,12 @@ export async function PUT(request: Request) {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } },
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ export async function PUT(request: Request, props: { params: Promise<{ id: strin
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
db.prepare("UPDATE skills SET enabled = ? WHERE id = ?").run(validation.data.enabled ? 1 : 0, id);
|
||||
db.prepare("UPDATE skills SET enabled = ? WHERE id = ?").run(
|
||||
validation.data.enabled ? 1 : 0,
|
||||
id
|
||||
);
|
||||
|
||||
await skillRegistry.loadFromDatabase();
|
||||
|
||||
|
||||
@@ -39,7 +39,15 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = validation.data;
|
||||
const {
|
||||
title,
|
||||
provider,
|
||||
accountId,
|
||||
requestId,
|
||||
errorCode,
|
||||
details,
|
||||
labels = [],
|
||||
} = validation.data;
|
||||
|
||||
const repo = process.env.GITHUB_ISSUES_REPO;
|
||||
const token = process.env.GITHUB_ISSUES_TOKEN;
|
||||
|
||||
@@ -40,7 +40,10 @@ export async function GET() {
|
||||
// Gemini: always replace hardcoded entries with synced models (no fallback)
|
||||
// Always remove hardcoded gemini entries — even if sync returns empty
|
||||
for (let i = models.length - 1; i >= 0; i--) {
|
||||
if (typeof (models[i] as any).name === "string" && (models[i] as any).name.startsWith("models/gemini/")) {
|
||||
if (
|
||||
typeof (models[i] as any).name === "string" &&
|
||||
(models[i] as any).name.startsWith("models/gemini/")
|
||||
) {
|
||||
models.splice(i, 1);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +72,8 @@ export async function GET() {
|
||||
// Skip Gemini — handled by syncedAvailableModels above
|
||||
if (providerId === "gemini") continue;
|
||||
for (const model of rawModels) {
|
||||
if (!model || typeof model !== "object" || typeof (model as any).id !== "string") continue;
|
||||
if (!model || typeof model !== "object" || typeof (model as any).id !== "string")
|
||||
continue;
|
||||
const m = model as Record<string, unknown>;
|
||||
if (m.isHidden === true) continue;
|
||||
models.push({
|
||||
@@ -77,10 +81,8 @@ export async function GET() {
|
||||
displayName: m.name || m.id,
|
||||
...(typeof m.description === "string" ? { description: m.description } : {}),
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
inputTokenLimit:
|
||||
typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000,
|
||||
outputTokenLimit:
|
||||
typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192,
|
||||
inputTokenLimit: typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000,
|
||||
outputTokenLimit: typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192,
|
||||
...(m.supportsThinking === true ? { thinking: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ import { NextResponse } from "next/server";
|
||||
import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
|
||||
const updateWebhookSchema = z.object({
|
||||
url: z.string().url("Invalid URL format").max(2000).optional(),
|
||||
events: z.array(z.string()).optional(),
|
||||
secret: z.string().max(500).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
const updateWebhookSchema = z
|
||||
.object({
|
||||
url: z.string().url("Invalid URL format").max(2000).optional(),
|
||||
events: z.array(z.string()).optional(),
|
||||
secret: z.string().max(500).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* @module domain/fallbackPolicy
|
||||
*/
|
||||
|
||||
|
||||
import {
|
||||
saveFallbackChain,
|
||||
loadFallbackChain,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* @module domain/modelAvailability
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} UnavailableEntry
|
||||
* @property {string} provider
|
||||
|
||||
@@ -138,9 +138,7 @@ export function getAllExpirations(): ProviderExpiration[] {
|
||||
* Get connections that are expired or expiring soon.
|
||||
*/
|
||||
export function getExpiringSoon(): ProviderExpiration[] {
|
||||
return getAllExpirations().filter(
|
||||
(e) => e.status === "expired" || e.status === "expiring_soon"
|
||||
);
|
||||
return getAllExpirations().filter((e) => e.status === "expired" || e.status === "expiring_soon");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,9 +227,7 @@ export function detectExpirationFromResponse(
|
||||
|
||||
// Rate limit headers may indicate reset times
|
||||
const resetHeader =
|
||||
headers["x-ratelimit-reset"] ||
|
||||
headers["x-ratelimit-reset-tokens"] ||
|
||||
headers["retry-after"];
|
||||
headers["x-ratelimit-reset"] || headers["x-ratelimit-reset-tokens"] || headers["retry-after"];
|
||||
|
||||
if (resetHeader && status === 429) {
|
||||
const resetTime = parseInt(resetHeader, 10);
|
||||
|
||||
@@ -420,23 +420,23 @@ export async function replaceCustomModels(
|
||||
...(m.inputTokenLimit != null
|
||||
? { inputTokenLimit: m.inputTokenLimit }
|
||||
: (prev as any)?.inputTokenLimit != null
|
||||
? { inputTokenLimit: (prev as any).inputTokenLimit }
|
||||
: {}),
|
||||
? { inputTokenLimit: (prev as any).inputTokenLimit }
|
||||
: {}),
|
||||
...(m.outputTokenLimit != null
|
||||
? { outputTokenLimit: m.outputTokenLimit }
|
||||
: (prev as any)?.outputTokenLimit != null
|
||||
? { outputTokenLimit: (prev as any).outputTokenLimit }
|
||||
: {}),
|
||||
? { outputTokenLimit: (prev as any).outputTokenLimit }
|
||||
: {}),
|
||||
...(m.description != null
|
||||
? { description: m.description }
|
||||
: (prev as any)?.description != null
|
||||
? { description: (prev as any).description }
|
||||
: {}),
|
||||
? { description: (prev as any).description }
|
||||
: {}),
|
||||
...(m.supportsThinking != null
|
||||
? { supportsThinking: m.supportsThinking }
|
||||
: (prev as any)?.supportsThinking != null
|
||||
? { supportsThinking: (prev as any).supportsThinking }
|
||||
: {}),
|
||||
? { supportsThinking: (prev as any).supportsThinking }
|
||||
: {}),
|
||||
// Preserve existing compat flags
|
||||
...(prev && (prev as any).normalizeToolCallId !== undefined
|
||||
? { normalizeToolCallId: (prev as any).normalizeToolCallId }
|
||||
@@ -525,10 +525,14 @@ export interface SyncedAvailableModel {
|
||||
/**
|
||||
* Get all synced available models for a provider, unioned across all connections.
|
||||
*/
|
||||
export async function getSyncedAvailableModels(providerId: string): Promise<SyncedAvailableModel[]> {
|
||||
export async function getSyncedAvailableModels(
|
||||
providerId: string
|
||||
): Promise<SyncedAvailableModel[]> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?")
|
||||
.prepare(
|
||||
"SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?"
|
||||
)
|
||||
.all(`${providerId}:%`);
|
||||
const map = new Map<string, SyncedAvailableModel>();
|
||||
for (const row of rows) {
|
||||
@@ -545,7 +549,9 @@ export async function getSyncedAvailableModels(providerId: string): Promise<Sync
|
||||
/**
|
||||
* Get all synced available models across all providers.
|
||||
*/
|
||||
export async function getAllSyncedAvailableModels(): Promise<Record<string, SyncedAvailableModel[]>> {
|
||||
export async function getAllSyncedAvailableModels(): Promise<
|
||||
Record<string, SyncedAvailableModel[]>
|
||||
> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels'")
|
||||
@@ -582,7 +588,9 @@ export async function replaceSyncedAvailableModelsForConnection(
|
||||
const db = getDbInstance();
|
||||
const key = `${providerId}:${connectionId}`;
|
||||
if (models.length === 0) {
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key);
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(
|
||||
key
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
|
||||
@@ -603,7 +611,9 @@ export async function deleteSyncedAvailableModelsForConnection(
|
||||
): Promise<SyncedAvailableModel[]> {
|
||||
const db = getDbInstance();
|
||||
const key = `${providerId}:${connectionId}`;
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(key);
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(
|
||||
key
|
||||
);
|
||||
backupDbFile("pre-write");
|
||||
return getSyncedAvailableModels(providerId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* @module lib/evals/evalRunner
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} EvalCase
|
||||
* @property {string} id - Unique case ID
|
||||
|
||||
@@ -46,9 +46,7 @@ let _outputProvider: ((suiteId: string, caseId: string) => Promise<string>) | nu
|
||||
*
|
||||
* @param fn - Async function(suiteId, caseId) → actual output string
|
||||
*/
|
||||
export function setOutputProvider(
|
||||
fn: (suiteId: string, caseId: string) => Promise<string>
|
||||
): void {
|
||||
export function setOutputProvider(fn: (suiteId: string, caseId: string) => Promise<string>): void {
|
||||
_outputProvider = fn;
|
||||
}
|
||||
|
||||
@@ -87,9 +85,7 @@ export function schedule(suiteId: string, intervalMs: number): ScheduledEval {
|
||||
|
||||
_timers.set(suiteId, timer);
|
||||
|
||||
console.log(
|
||||
`[EvalScheduler] Scheduled "${suiteId}" every ${Math.round(safeInterval / 1000)}s`
|
||||
);
|
||||
console.log(`[EvalScheduler] Scheduled "${suiteId}" every ${Math.round(safeInterval / 1000)}s`);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,12 @@ export class AntigravityService {
|
||||
/**
|
||||
* Complete onboarding flow with retry
|
||||
*/
|
||||
async completeOnboarding(accessToken: string, projectId: string, tierId: string, maxRetries = 10) {
|
||||
async completeOnboarding(
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
tierId: string,
|
||||
maxRetries = 10
|
||||
) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const result = await this.onboardUser(accessToken, projectId, tierId);
|
||||
|
||||
|
||||
@@ -49,7 +49,12 @@ export class KiroService {
|
||||
/**
|
||||
* Start device authorization for AWS Builder ID or IDC
|
||||
*/
|
||||
async startDeviceAuthorization(clientId: string, clientSecret: string, startUrl: string, region: string = "us-east-1") {
|
||||
async startDeviceAuthorization(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
startUrl: string,
|
||||
region: string = "us-east-1"
|
||||
) {
|
||||
const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
@@ -83,7 +88,12 @@ export class KiroService {
|
||||
/**
|
||||
* Poll for token using device code (AWS Builder ID/IDC)
|
||||
*/
|
||||
async pollDeviceToken(clientId: string, clientSecret: string, deviceCode: string, region: string = "us-east-1") {
|
||||
async pollDeviceToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
deviceCode: string,
|
||||
region: string = "us-east-1"
|
||||
) {
|
||||
const endpoint = `https://oidc.${region}.amazonaws.com/token`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
|
||||
@@ -17,7 +17,12 @@ export class OAuthService {
|
||||
/**
|
||||
* Build authorization URL
|
||||
*/
|
||||
buildAuthUrl(redirectUri: string, state: string, codeChallenge: string, extraParams: Record<string, string> = {}) {
|
||||
buildAuthUrl(
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
codeChallenge: string,
|
||||
extraParams: Record<string, string> = {}
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
response_type: "code",
|
||||
@@ -129,7 +134,10 @@ export class OAuthService {
|
||||
/**
|
||||
* Complete OAuth flow
|
||||
*/
|
||||
async authenticate(providerName: string, buildAuthUrlFn: (redirectUri: string, state: string, codeChallenge: string) => string) {
|
||||
async authenticate(
|
||||
providerName: string,
|
||||
buildAuthUrlFn: (redirectUri: string, state: string, codeChallenge: string) => string
|
||||
) {
|
||||
// Generate PKCE
|
||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { URL } from "url";
|
||||
* @param {number} fixedPort - Optional fixed port number (default: random)
|
||||
* @returns {Promise<{server: http.Server, port: number, close: Function}>}
|
||||
*/
|
||||
export function startLocalServer(onCallback: (params: Record<string, string>) => void, fixedPort: number | null = null): Promise<{ server: any; port: number; close: () => void }> {
|
||||
export function startLocalServer(
|
||||
onCallback: (params: Record<string, string>) => void,
|
||||
fixedPort: number | null = null
|
||||
): Promise<{ server: any; port: number; close: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost`);
|
||||
|
||||
@@ -51,15 +51,9 @@ export interface Plugin {
|
||||
/** Called before the chat handler */
|
||||
onRequest?: (ctx: PluginContext) => Promise<PluginResult | void> | PluginResult | void;
|
||||
/** Called after the chat handler */
|
||||
onResponse?: (
|
||||
ctx: PluginContext,
|
||||
response: any
|
||||
) => Promise<any | void> | any | void;
|
||||
onResponse?: (ctx: PluginContext, response: any) => Promise<any | void> | any | void;
|
||||
/** Called on handler error */
|
||||
onError?: (
|
||||
ctx: PluginContext,
|
||||
error: Error
|
||||
) => Promise<any | void> | any | void;
|
||||
onError?: (ctx: PluginContext, error: Error) => Promise<any | void> | any | void;
|
||||
}
|
||||
|
||||
// ── Registry ──
|
||||
|
||||
@@ -72,7 +72,11 @@ function shortModelName(model: string) {
|
||||
* @param {Object} connectionMap - Map of connectionId → account name
|
||||
* @returns {Object} Analytics data
|
||||
*/
|
||||
export async function computeAnalytics(history: any[], range = "30d", connectionMap: Record<string, string> = {}) {
|
||||
export async function computeAnalytics(
|
||||
history: any[],
|
||||
range = "30d",
|
||||
connectionMap: Record<string, string> = {}
|
||||
) {
|
||||
const { start, end } = getDateRange(range);
|
||||
|
||||
// ---- Filtered entries ----
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function getMitmStatus() {
|
||||
let dnsConfigured = false;
|
||||
try {
|
||||
const hostsContent = fs.readFileSync("/etc/hosts", "utf-8");
|
||||
dnsConfigured = hostsContent.includes("daily-cloudcode-pa.googleapis.com");
|
||||
dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export default function Button({
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,6 @@ interface CardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
title,
|
||||
@@ -25,7 +24,6 @@ export default function Card({
|
||||
className,
|
||||
...props
|
||||
}: CardProps) {
|
||||
|
||||
const paddings = {
|
||||
none: "",
|
||||
xs: "p-3",
|
||||
|
||||
@@ -39,8 +39,7 @@ export default function FilterBar({
|
||||
setExpandedFilter(null);
|
||||
}, [onSearchChange, filters, onFilterChange]);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchValue || Object.values(activeFilters).some((v) => v && v !== "");
|
||||
const hasActiveFilters = searchValue || Object.values(activeFilters).some((v) => v && v !== "");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -89,9 +88,7 @@ export default function FilterBar({
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.key} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedFilter(expandedFilter === filter.key ? null : filter.key)
|
||||
}
|
||||
onClick={() => setExpandedFilter(expandedFilter === filter.key ? null : filter.key)}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "6px",
|
||||
@@ -99,9 +96,7 @@ export default function FilterBar({
|
||||
background: activeFilters[filter.key]
|
||||
? "rgba(99,102,241,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
color: activeFilters[filter.key]
|
||||
? "#818cf8"
|
||||
: "var(--text-secondary, #888)",
|
||||
color: activeFilters[filter.key] ? "#818cf8" : "var(--text-secondary, #888)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -158,10 +153,7 @@ export default function FilterBar({
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
background:
|
||||
activeFilters[filter.key] === opt
|
||||
? "rgba(99,102,241,0.2)"
|
||||
: "none",
|
||||
background: activeFilters[filter.key] === opt ? "rgba(99,102,241,0.2)" : "none",
|
||||
border: "none",
|
||||
color:
|
||||
activeFilters[filter.key] === opt
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
label?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
|
||||
@@ -28,7 +28,6 @@ export default function Modal({
|
||||
showCloseButton = true,
|
||||
className,
|
||||
}: ModalProps) {
|
||||
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
|
||||
@@ -68,9 +68,7 @@ function Toast({ notification, onDismiss }) {
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
|
||||
minWidth: "320px",
|
||||
maxWidth: "420px",
|
||||
animation: isExiting
|
||||
? "toastOut 0.2s ease-in forwards"
|
||||
: "toastIn 0.3s ease-out forwards",
|
||||
animation: isExiting ? "toastOut 0.2s ease-in forwards" : "toastIn 0.3s ease-out forwards",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SelectOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||
label?: React.ReactNode;
|
||||
options?: SelectOption[];
|
||||
placeholder?: string;
|
||||
|
||||
@@ -67,13 +67,13 @@ export default function Sidebar({
|
||||
}
|
||||
|
||||
if ("instanceName" in detail) {
|
||||
setCustomAppName(detail.instanceName as string || null);
|
||||
setCustomAppName((detail.instanceName as string) || null);
|
||||
}
|
||||
|
||||
if ("customLogoBase64" in detail) {
|
||||
setCustomLogo(detail.customLogoBase64 as string || null);
|
||||
setCustomLogo((detail.customLogoBase64 as string) || null);
|
||||
} else if ("customLogoUrl" in detail) {
|
||||
setCustomLogo(detail.customLogoUrl as string || null);
|
||||
setCustomLogo((detail.customLogoUrl as string) || null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export default function ThemeToggle({ className, variant = "default" }: { className?: any; variant?: string }) {
|
||||
export default function ThemeToggle({
|
||||
className,
|
||||
variant = "default",
|
||||
}: {
|
||||
className?: any;
|
||||
variant?: string;
|
||||
}) {
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
|
||||
const variants = {
|
||||
|
||||
@@ -27,4 +27,3 @@ const LEGACY_PROVIDER_IDS = [
|
||||
export const CLI_COMPAT_PROVIDER_IDS = Array.from(
|
||||
new Set([...DERIVED_PROVIDER_IDS, ...LEGACY_PROVIDER_IDS])
|
||||
);
|
||||
|
||||
|
||||
@@ -26,42 +26,137 @@ interface ErrorDetails {
|
||||
|
||||
export const ERROR_CODES: Record<string, ErrorCodeDef> = {
|
||||
// ── Auth ──
|
||||
AUTH_001: { code: "AUTH_001", message: "Authentication required", httpStatus: 401, category: "AUTH" },
|
||||
AUTH_001: {
|
||||
code: "AUTH_001",
|
||||
message: "Authentication required",
|
||||
httpStatus: 401,
|
||||
category: "AUTH",
|
||||
},
|
||||
AUTH_002: { code: "AUTH_002", message: "Invalid API key", httpStatus: 401, category: "AUTH" },
|
||||
AUTH_003: { code: "AUTH_003", message: "API key expired", httpStatus: 401, category: "AUTH" },
|
||||
AUTH_004: { code: "AUTH_004", message: "Insufficient permissions", httpStatus: 403, category: "AUTH" },
|
||||
AUTH_004: {
|
||||
code: "AUTH_004",
|
||||
message: "Insufficient permissions",
|
||||
httpStatus: 403,
|
||||
category: "AUTH",
|
||||
},
|
||||
AUTH_005: { code: "AUTH_005", message: "Account locked", httpStatus: 423, category: "AUTH" },
|
||||
AUTH_006: { code: "AUTH_006", message: "No credentials for provider", httpStatus: 400, category: "AUTH" },
|
||||
AUTH_006: {
|
||||
code: "AUTH_006",
|
||||
message: "No credentials for provider",
|
||||
httpStatus: 400,
|
||||
category: "AUTH",
|
||||
},
|
||||
|
||||
// ── Proxy ──
|
||||
PROXY_001: { code: "PROXY_001", message: "Proxy connection failed", httpStatus: 502, category: "PROXY" },
|
||||
PROXY_001: {
|
||||
code: "PROXY_001",
|
||||
message: "Proxy connection failed",
|
||||
httpStatus: 502,
|
||||
category: "PROXY",
|
||||
},
|
||||
PROXY_002: { code: "PROXY_002", message: "Proxy timeout", httpStatus: 504, category: "PROXY" },
|
||||
PROXY_003: { code: "PROXY_003", message: "All proxies exhausted", httpStatus: 503, category: "PROXY" },
|
||||
PROXY_003: {
|
||||
code: "PROXY_003",
|
||||
message: "All proxies exhausted",
|
||||
httpStatus: 503,
|
||||
category: "PROXY",
|
||||
},
|
||||
|
||||
// ── Rate Limiting ──
|
||||
RATE_001: { code: "RATE_001", message: "Rate limit exceeded", httpStatus: 429, category: "RATE_LIMIT" },
|
||||
RATE_002: { code: "RATE_002", message: "Daily budget exceeded", httpStatus: 429, category: "RATE_LIMIT" },
|
||||
RATE_003: { code: "RATE_003", message: "All accounts rate-limited", httpStatus: 503, category: "RATE_LIMIT" },
|
||||
RATE_001: {
|
||||
code: "RATE_001",
|
||||
message: "Rate limit exceeded",
|
||||
httpStatus: 429,
|
||||
category: "RATE_LIMIT",
|
||||
},
|
||||
RATE_002: {
|
||||
code: "RATE_002",
|
||||
message: "Daily budget exceeded",
|
||||
httpStatus: 429,
|
||||
category: "RATE_LIMIT",
|
||||
},
|
||||
RATE_003: {
|
||||
code: "RATE_003",
|
||||
message: "All accounts rate-limited",
|
||||
httpStatus: 503,
|
||||
category: "RATE_LIMIT",
|
||||
},
|
||||
|
||||
// ── Model / Routing ──
|
||||
MODEL_001: { code: "MODEL_001", message: "Model not found", httpStatus: 404, category: "MODEL" },
|
||||
MODEL_002: { code: "MODEL_002", message: "Ambiguous model identifier", httpStatus: 400, category: "MODEL" },
|
||||
MODEL_003: { code: "MODEL_003", message: "Model temporarily unavailable", httpStatus: 503, category: "MODEL" },
|
||||
MODEL_002: {
|
||||
code: "MODEL_002",
|
||||
message: "Ambiguous model identifier",
|
||||
httpStatus: 400,
|
||||
category: "MODEL",
|
||||
},
|
||||
MODEL_003: {
|
||||
code: "MODEL_003",
|
||||
message: "Model temporarily unavailable",
|
||||
httpStatus: 503,
|
||||
category: "MODEL",
|
||||
},
|
||||
|
||||
// ── Provider ──
|
||||
PROVIDER_001: { code: "PROVIDER_001", message: "Provider error", httpStatus: 502, category: "PROVIDER" },
|
||||
PROVIDER_002: { code: "PROVIDER_002", message: "Provider timeout", httpStatus: 504, category: "PROVIDER" },
|
||||
PROVIDER_003: { code: "PROVIDER_003", message: "Provider not configured", httpStatus: 400, category: "PROVIDER" },
|
||||
PROVIDER_001: {
|
||||
code: "PROVIDER_001",
|
||||
message: "Provider error",
|
||||
httpStatus: 502,
|
||||
category: "PROVIDER",
|
||||
},
|
||||
PROVIDER_002: {
|
||||
code: "PROVIDER_002",
|
||||
message: "Provider timeout",
|
||||
httpStatus: 504,
|
||||
category: "PROVIDER",
|
||||
},
|
||||
PROVIDER_003: {
|
||||
code: "PROVIDER_003",
|
||||
message: "Provider not configured",
|
||||
httpStatus: 400,
|
||||
category: "PROVIDER",
|
||||
},
|
||||
|
||||
// ── Validation ──
|
||||
VALID_001: { code: "VALID_001", message: "Invalid request body", httpStatus: 400, category: "VALIDATION" },
|
||||
VALID_002: { code: "VALID_002", message: "Missing required field", httpStatus: 400, category: "VALIDATION" },
|
||||
VALID_003: { code: "VALID_003", message: "Input sanitization blocked", httpStatus: 400, category: "VALIDATION" },
|
||||
VALID_001: {
|
||||
code: "VALID_001",
|
||||
message: "Invalid request body",
|
||||
httpStatus: 400,
|
||||
category: "VALIDATION",
|
||||
},
|
||||
VALID_002: {
|
||||
code: "VALID_002",
|
||||
message: "Missing required field",
|
||||
httpStatus: 400,
|
||||
category: "VALIDATION",
|
||||
},
|
||||
VALID_003: {
|
||||
code: "VALID_003",
|
||||
message: "Input sanitization blocked",
|
||||
httpStatus: 400,
|
||||
category: "VALIDATION",
|
||||
},
|
||||
|
||||
// ── Internal ──
|
||||
INTERNAL_001: { code: "INTERNAL_001", message: "Internal server error", httpStatus: 500, category: "INTERNAL" },
|
||||
INTERNAL_002: { code: "INTERNAL_002", message: "Database error", httpStatus: 500, category: "INTERNAL" },
|
||||
INTERNAL_003: { code: "INTERNAL_003", message: "Circuit breaker open", httpStatus: 503, category: "INTERNAL" },
|
||||
INTERNAL_001: {
|
||||
code: "INTERNAL_001",
|
||||
message: "Internal server error",
|
||||
httpStatus: 500,
|
||||
category: "INTERNAL",
|
||||
},
|
||||
INTERNAL_002: {
|
||||
code: "INTERNAL_002",
|
||||
message: "Database error",
|
||||
httpStatus: 500,
|
||||
category: "INTERNAL",
|
||||
},
|
||||
INTERNAL_003: {
|
||||
code: "INTERNAL_003",
|
||||
message: "Circuit breaker open",
|
||||
httpStatus: 503,
|
||||
category: "INTERNAL",
|
||||
},
|
||||
};
|
||||
|
||||
export function createErrorResponse(code: string, details: ErrorDetails = {}) {
|
||||
|
||||
@@ -19,9 +19,7 @@ function getApiKeySecret(): string {
|
||||
function generateKeyId(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
result = crypto.randomBytes(3).toString("hex");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -31,7 +29,7 @@ function generateKeyId(): string {
|
||||
function generateCrc(machineId: string, keyId: string): string {
|
||||
const secret = getApiKeySecret();
|
||||
return crypto
|
||||
.createHmac("sha256", secret)
|
||||
.createHmac("sha256", secret) /* lgtm [js/insufficient-password-hash] */
|
||||
.update(machineId + keyId)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* @module shared/utils/fetchTimeout
|
||||
*/
|
||||
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes
|
||||
const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS;
|
||||
|
||||
|
||||
@@ -13,17 +13,20 @@
|
||||
const INJECTION_PATTERNS = [
|
||||
{
|
||||
name: "system_override",
|
||||
pattern: /\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|context)/i,
|
||||
pattern:
|
||||
/\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|context)/i,
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "role_hijack",
|
||||
pattern: /\b(you\s+are\s+now|act\s+as\s+if|pretend\s+(to\s+be|you\s+are)|from\s+now\s+on\s+you\s+are)\b/i,
|
||||
pattern:
|
||||
/\b(you\s+are\s+now|act\s+as\s+if|pretend\s+(to\s+be|you\s+are)|from\s+now\s+on\s+you\s+are)\b/i,
|
||||
severity: "medium",
|
||||
},
|
||||
{
|
||||
name: "system_prompt_leak",
|
||||
pattern: /\b(reveal|show|display|print|output|repeat)\s+(your\s+)?(system\s+prompt|instructions?|initial\s+prompt|hidden\s+prompt)/i,
|
||||
pattern:
|
||||
/\b(reveal|show|display|print|output|repeat)\s+(your\s+)?(system\s+prompt|instructions?|initial\s+prompt|hidden\s+prompt)/i,
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
@@ -38,7 +41,8 @@ const INJECTION_PATTERNS = [
|
||||
},
|
||||
{
|
||||
name: "encoding_evasion",
|
||||
pattern: /\b(base64\s+decode|rot13|hex\s+decode|unicode\s+escape)\b.*\b(instruction|prompt|command)\b/i,
|
||||
pattern:
|
||||
/\b(base64\s+decode|rot13|hex\s+decode|unicode\s+escape)\b.*\b(instruction|prompt|command)\b/i,
|
||||
severity: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,7 +57,11 @@ export async function fetchWithTimeout(url: string, options: RequestInit & Timeo
|
||||
* @returns {Promise<T>}
|
||||
* @throws {Error} With name 'TimeoutError' if operation times out
|
||||
*/
|
||||
export async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, label = "Operation"): Promise<T> {
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
label = "Operation"
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const error: any = new Error(`${label} timed out after ${timeoutMs}ms`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
markAccountUnavailable,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
getProviderConnections,
|
||||
validateApiKey,
|
||||
@@ -659,8 +660,11 @@ export async function getProviderCredentials(
|
||||
if (orderedConnections.length <= 2) {
|
||||
connection = orderedConnections[0];
|
||||
} else {
|
||||
const i = Math.floor(Math.random() * orderedConnections.length);
|
||||
let j = Math.floor(Math.random() * (orderedConnections.length - 1));
|
||||
const i =
|
||||
parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % orderedConnections.length;
|
||||
let j =
|
||||
parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) %
|
||||
(orderedConnections.length - 1);
|
||||
if (j >= i) j++;
|
||||
const a = orderedConnections[i];
|
||||
const b = orderedConnections[j];
|
||||
@@ -671,7 +675,8 @@ export async function getProviderCredentials(
|
||||
}
|
||||
} else if (strategy === "random") {
|
||||
// Random: Fisher-Yates-inspired random pick
|
||||
const idx = Math.floor(Math.random() * orderedConnections.length);
|
||||
const idx =
|
||||
parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % orderedConnections.length;
|
||||
connection = orderedConnections[idx];
|
||||
} else if (strategy === "least-used") {
|
||||
// Least Used: pick the one with oldest lastUsedAt
|
||||
|
||||
@@ -165,7 +165,11 @@ export class StreamTracker {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTerminal(): boolean {
|
||||
const terminalStates: string[] = [STREAM_STATES.COMPLETED, STREAM_STATES.FAILED, STREAM_STATES.CANCELLED];
|
||||
const terminalStates: string[] = [
|
||||
STREAM_STATES.COMPLETED,
|
||||
STREAM_STATES.FAILED,
|
||||
STREAM_STATES.CANCELLED,
|
||||
];
|
||||
return terminalStates.includes(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +81,12 @@ export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||
|
||||
// ─── Convenience Methods ─────────────────
|
||||
|
||||
success: (message, title) =>
|
||||
get().addNotification({ type: "success", message, title }),
|
||||
success: (message, title) => get().addNotification({ type: "success", message, title }),
|
||||
|
||||
error: (message, title) =>
|
||||
get().addNotification({ type: "error", message, title, duration: 8000 }),
|
||||
|
||||
warning: (message, title) =>
|
||||
get().addNotification({ type: "warning", message, title }),
|
||||
warning: (message, title) => get().addNotification({ type: "warning", message, title }),
|
||||
|
||||
info: (message, title) =>
|
||||
get().addNotification({ type: "info", message, title }),
|
||||
info: (message, title) => get().addNotification({ type: "info", message, title }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
test("T20: antigravity config has updated User-Agent and sandbox fallback URL", () => {
|
||||
const antigravity = REGISTRY.antigravity;
|
||||
assert.ok(Array.isArray(antigravity.baseUrls));
|
||||
assert.ok(antigravity.baseUrls.includes("https://daily-cloudcode-pa.sandbox.googleapis.com"));
|
||||
assert.ok(
|
||||
antigravity.baseUrls.some((u) => u === "https://daily-cloudcode-pa.sandbox.googleapis.com")
|
||||
);
|
||||
assert.match(
|
||||
antigravity.headers["User-Agent"],
|
||||
new RegExp(`^antigravity/1\\.107\\.0\\s+${platform()}\\/${arch()}$`)
|
||||
|
||||
Reference in New Issue
Block a user