mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
13 Commits
fix/adapta
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe455d096d | ||
|
|
6853f8275d | ||
|
|
9b14896a6c | ||
|
|
29f26293c3 | ||
|
|
cb11592441 | ||
|
|
5ee646e68e | ||
|
|
6984676d95 | ||
|
|
79f8ae9d1e | ||
|
|
04b2c47940 | ||
|
|
24ac71465e | ||
|
|
8d6f91b558 | ||
|
|
c3698eedcb | ||
|
|
adca3b881c |
10
.env.example
10
.env.example
@@ -1027,6 +1027,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
|
||||
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
|
||||
#OMNI_COMPRESSION_WORKERS=2
|
||||
# Per-job worker timeout (ms). A timed-out worker is terminated and the request fails open.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 120000.
|
||||
#OMNI_COMPRESSION_WORKER_TIMEOUT_MS=120000
|
||||
# Terminate idle compression workers after this many milliseconds.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 60000.
|
||||
#OMNI_COMPRESSION_WORKER_IDLE_MS=60000
|
||||
|
||||
# T02 stacked-pipeline engine circuit-breaker (OPT-IN, default off). When enabled, a compression
|
||||
# engine that throws repeatedly across requests is skipped (fail-open) for a cooldown.
|
||||
# Used by: open-sse/services/compression/pipelineEngineBreaker.ts.
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"testFailed": "Teste do provedor falhou: {error}",
|
||||
"loginEnabled": "Login: habilitado (senha atualizada)",
|
||||
"loginDisabled": "Login: desabilitado",
|
||||
"providerInfo": "Provedor: {info}"
|
||||
"providerInfo": "Provedor: {info}",
|
||||
"opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute Doctor",
|
||||
@@ -254,7 +255,9 @@
|
||||
"no_recovery": "Desabilitar reinício automático em crash (modo debug)",
|
||||
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
|
||||
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema"
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema",
|
||||
"tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
|
||||
1
changelog.d/features/10590-google-ai-studio-tts.md
Normal file
1
changelog.d/features/10590-google-ai-studio-tts.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`.
|
||||
3
changelog.d/features/11023-compression-worker-pool.md
Normal file
3
changelog.d/features/11023-compression-worker-pool.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- Run synchronous RTK and Caveman request compression in a bounded worker-thread pool, keeping
|
||||
large `/v1/responses` compression heaps outside the HTTP isolate while preserving strict
|
||||
fail-open behavior and per-engine telemetry.
|
||||
1
changelog.d/fixes/10352-github-access-token-health.md
Normal file
1
changelog.d/fixes/10352-github-access-token-health.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(github):** proactive credential health now verifies GitHub access tokens through the existing Copilot token exchange, marks only a confirmed `401 Unauthorized` as expired, and leaves rate limits, permission failures, upstream failures, and network errors routable ([#10352](https://github.com/diegosouzapw/OmniRoute/issues/10352)) — thanks @RaviTharuma
|
||||
1
changelog.d/fixes/11319-upstream-proxy-host-spelling.md
Normal file
1
changelog.d/fixes/11319-upstream-proxy-host-spelling.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(db):** the upstream proxy URL check judges the host by address instead of by spelling, so `http://[::ffff:169.254.169.254]`, `[::ffff:10.0.0.5]`, ULA/link-local and CGNAT targets are refused like their dotted equivalents ([#11319](https://github.com/diegosouzapw/OmniRoute/pull/11319))
|
||||
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325))
|
||||
1
changelog.d/fixes/11326-kie-market-google-imagen-ids.md
Normal file
1
changelog.d/fixes/11326-kie-market-google-imagen-ids.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(kie):** map the remaining `google-imagen/*` KIE Market catalog ids (`nano-banana`, `nano-banana-pro`, `nano-banana-edit`) to their real, KIE-documented upstream `model` values — `#11225`'s fix only covered `nano-banana-2` ([#11326](https://github.com/diegosouzapw/OmniRoute/pull/11326)).
|
||||
1
changelog.d/fixes/11328-upstream-headers-proxy-auth.md
Normal file
1
changelog.d/fixes/11328-upstream-headers-proxy-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(security):** `proxy-authorization` and `proxy-authenticate` are refused as upstream/custom headers, so a proxy credential is no longer forwarded to the model provider — the canonical denylist now matches the RFC 7230 §6.1 set the rest of the codebase already strips ([#11328](https://github.com/diegosouzapw/OmniRoute/pull/11328))
|
||||
@@ -0,0 +1,3 @@
|
||||
- **fix(deps):** prevent pnpm from auto-installing the unused `@lobehub/ui` peer subtree of
|
||||
`@lobehub/icons`, keeping six unneeded packages with incompatible or unverifiable license
|
||||
metadata out of production installs ([#11342](https://github.com/diegosouzapw/OmniRoute/pull/11342)).
|
||||
@@ -531,6 +531,9 @@ detection above).
|
||||
| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. |
|
||||
| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). |
|
||||
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. |
|
||||
| `OMNI_COMPRESSION_WORKERS` | `2` | `open-sse/services/compression/compressionWorkerPool.ts` | Maximum concurrent synchronous RTK/Caveman workers; excess jobs wait FIFO. |
|
||||
| `OMNI_COMPRESSION_WORKER_TIMEOUT_MS` | `120000` | `open-sse/services/compression/compressionWorkerPool.ts` | Per-job timeout in milliseconds. Timed-out workers are terminated and the request fails open unchanged. |
|
||||
| `OMNI_COMPRESSION_WORKER_IDLE_MS` | `60000` | `open-sse/services/compression/compressionWorkerPool.ts` | Idle lifetime in milliseconds before an unused compression worker is terminated. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_ENABLED` | `false` | `open-sse/services/compression/pipelineEngineBreaker.ts` | T02 stacked-pipeline per-engine circuit-breaker master switch. **Opt-in (default off)** — when on, an engine that throws repeatedly across requests is skipped (fail-open) for a cooldown; off = byte-identical legacy behavior. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
|
||||
|
||||
@@ -287,6 +287,19 @@ export const AUDIO_TRANSLATION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
};
|
||||
|
||||
export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
google: {
|
||||
id: "google",
|
||||
credentialProviderId: "gemini",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
authType: "apikey",
|
||||
authHeader: "x-goog-api-key",
|
||||
format: "gemini-tts",
|
||||
models: [
|
||||
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS" },
|
||||
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS" },
|
||||
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS" },
|
||||
],
|
||||
},
|
||||
vertex: {
|
||||
id: "vertex",
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",
|
||||
|
||||
81
open-sse/executors/geminiTts.ts
Normal file
81
open-sse/executors/geminiTts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { extractInlineAudio, parsePcmSampleRate, pcmToWav } from "./vertexMedia.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
import { upstreamErrorResponse } from "../utils/audioResponse.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
|
||||
type GeminiTtsCredentials = {
|
||||
apiKey?: string | null;
|
||||
accessToken?: string | null;
|
||||
};
|
||||
|
||||
export class GeminiTtsUpstreamError extends Error {
|
||||
constructor(
|
||||
public readonly response: Response,
|
||||
public readonly body: string
|
||||
) {
|
||||
super(`Gemini TTS upstream error (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function geminiGenerateSpeech(
|
||||
credentials: GeminiTtsCredentials,
|
||||
options: { model: string; text: string; voice: string }
|
||||
): Promise<Buffer> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (credentials.apiKey) {
|
||||
headers["x-goog-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers.Authorization = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(options.model)}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: options.text }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: { voiceName: options.voice },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new GeminiTtsUpstreamError(response, await response.text());
|
||||
}
|
||||
|
||||
const inline = extractInlineAudio(await response.json());
|
||||
if (!inline) throw new Error("Gemini TTS response did not contain audio data");
|
||||
return pcmToWav(Buffer.from(inline.base64, "base64"), parsePcmSampleRate(inline.mimeType));
|
||||
}
|
||||
|
||||
export async function handleGeminiTtsSpeech(
|
||||
credentials: GeminiTtsCredentials,
|
||||
options: { model: string; text: string; voice?: unknown }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const wav = await geminiGenerateSpeech(credentials, {
|
||||
model: options.model,
|
||||
text: options.text,
|
||||
voice:
|
||||
typeof options.voice === "string" && options.voice.trim() ? options.voice.trim() : "Kore",
|
||||
});
|
||||
return new Response(new Uint8Array(wav), {
|
||||
status: 200,
|
||||
headers: { ...CORS_HEADERS, "Content-Type": "audio/wav" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GeminiTtsUpstreamError) {
|
||||
return upstreamErrorResponse(error.response, error.body);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse(500, `Speech request failed: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -156,13 +156,13 @@ export function pcmToWav(
|
||||
return Buffer.concat([header, pcm]);
|
||||
}
|
||||
|
||||
function parseSampleRate(mimeType: string | undefined): number {
|
||||
export function parsePcmSampleRate(mimeType: string | undefined): number {
|
||||
if (!mimeType) return 24000;
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
return match ? parseInt(match[1], 10) : 24000;
|
||||
}
|
||||
|
||||
function extractInlineAudio(
|
||||
export function extractInlineAudio(
|
||||
data: unknown
|
||||
): { base64: string; mimeType: string } | null {
|
||||
const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0]
|
||||
@@ -215,7 +215,7 @@ export async function vertexGenerateSpeech(
|
||||
const inline = extractInlineAudio(data);
|
||||
if (!inline) throw new Error("Vertex TTS returned no audio content");
|
||||
const pcm = Buffer.from(inline.base64, "base64");
|
||||
return { audio: pcmToWav(pcm, parseSampleRate(inline.mimeType)), contentType: "audio/wav" };
|
||||
return { audio: pcmToWav(pcm, parsePcmSampleRate(inline.mimeType)), contentType: "audio/wav" };
|
||||
}
|
||||
|
||||
/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"
|
||||
import { buildAuthHeaders } from "../config/registryUtils.ts";
|
||||
import { kieExecutor } from "../executors/kie.ts";
|
||||
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
|
||||
import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts";
|
||||
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
|
||||
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
|
||||
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
|
||||
@@ -889,6 +890,13 @@ export async function handleAudioSpeech({
|
||||
headers: { ...CORS_HEADERS, "Content-Type": contentType },
|
||||
});
|
||||
}
|
||||
if (providerConfig.format === "gemini-tts") {
|
||||
return handleGeminiTtsSpeech(credentials, {
|
||||
model: modelId,
|
||||
text: body.input,
|
||||
voice: body.voice,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerConfig.format === "hyperbolic") {
|
||||
return handleHyperbolicSpeech(providerConfig, body, token);
|
||||
|
||||
@@ -91,8 +91,20 @@ interface KieImageOptions {
|
||||
} | null;
|
||||
}
|
||||
|
||||
// KIE Market catalog ids are namespaced for OmniRoute's catalog
|
||||
// (`google-imagen/<model>`), but the KIE Market createTask API expects
|
||||
// vendor-specific upstream ids that do not follow a single consistent
|
||||
// pattern (confirmed against docs.kie.ai/market/google/* — see #11225,
|
||||
// #11296): nano-banana-2 and nano-banana-pro drop the vendor namespace
|
||||
// entirely, while nano-banana and nano-banana-edit use a `google/` prefix
|
||||
// instead of `google-imagen/`. Every other KIE Market namespace (seedream,
|
||||
// flux, ideogram, qwen, wan, grok-imagine, gpt) already matches its real
|
||||
// upstream id byte-for-byte, so this map stays scoped to google-imagen.
|
||||
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
|
||||
["google-imagen/nano-banana", "google/nano-banana"],
|
||||
["google-imagen/nano-banana-2", "nano-banana-2"],
|
||||
["google-imagen/nano-banana-pro", "nano-banana-pro"],
|
||||
["google-imagen/nano-banana-edit", "google/nano-banana-edit"],
|
||||
]);
|
||||
|
||||
export function resolveKieMarketUpstreamModelId(publicModelId: string): string {
|
||||
|
||||
40
open-sse/services/compression/compressionWorker.ts
Normal file
40
open-sse/services/compression/compressionWorker.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import {
|
||||
applyCompression,
|
||||
applyStackedCompression,
|
||||
type StackedCompressionStep,
|
||||
} from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionWorkerJob,
|
||||
CompressionWorkerMessage,
|
||||
} from "./compressionWorkerProtocol.ts";
|
||||
|
||||
if (!parentPort) throw new Error("compressionWorker must run in a worker thread");
|
||||
parentPort.on("message", (job: CompressionWorkerJob) => {
|
||||
try {
|
||||
const onEngineStep = (step: StackedCompressionStep) =>
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "step",
|
||||
step,
|
||||
} satisfies CompressionWorkerMessage);
|
||||
const result =
|
||||
job.mode === "stacked"
|
||||
? applyStackedCompression(job.body, job.options?.config?.stackedPipeline, {
|
||||
...job.options,
|
||||
onEngineStep,
|
||||
})
|
||||
: applyCompression(job.body, job.mode, job.options);
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "result",
|
||||
result,
|
||||
} satisfies CompressionWorkerMessage);
|
||||
} catch (error) {
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CompressionWorkerMessage);
|
||||
}
|
||||
});
|
||||
164
open-sse/services/compression/compressionWorkerPool.ts
Normal file
164
open-sse/services/compression/compressionWorkerPool.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { CompressionResult } from "./types.ts";
|
||||
import type { StackedCompressionStep } from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionWorkerJob,
|
||||
CompressionWorkerMessage,
|
||||
CompressionWorkerOptions,
|
||||
} from "./compressionWorkerProtocol.ts";
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
function workerUrl(): URL {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
|
||||
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
|
||||
}
|
||||
return new URL("compressionWorker.js", import.meta.url);
|
||||
}
|
||||
function unchanged(body: Record<string, unknown>): CompressionResult {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
interface PendingJob extends CompressionWorkerJob {
|
||||
originalBody: Record<string, unknown>;
|
||||
resolve: (result: CompressionResult) => void;
|
||||
onEngineStep?: (step: StackedCompressionStep) => void;
|
||||
}
|
||||
interface PoolWorker {
|
||||
worker: Worker;
|
||||
job: PendingJob | null;
|
||||
timeout: NodeJS.Timeout | null;
|
||||
idle: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export class CompressionWorkerPool {
|
||||
private readonly queue: PendingJob[] = [];
|
||||
private readonly workers = new Set<PoolWorker>();
|
||||
private nextId = 1;
|
||||
private readonly size: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly idleMs: number;
|
||||
|
||||
constructor({
|
||||
size = positiveInteger(process.env.OMNI_COMPRESSION_WORKERS, 2),
|
||||
timeoutMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS, 120_000),
|
||||
idleMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_IDLE_MS, 60_000),
|
||||
}: { size?: number; timeoutMs?: number; idleMs?: number } = {}) {
|
||||
this.size = Math.max(1, Math.floor(size));
|
||||
this.timeoutMs = Math.max(1, Math.floor(timeoutMs));
|
||||
this.idleMs = Math.max(1, Math.floor(idleMs));
|
||||
}
|
||||
|
||||
run(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionWorkerJob["mode"],
|
||||
options?: CompressionWorkerOptions,
|
||||
onEngineStep?: (step: StackedCompressionStep) => void
|
||||
): Promise<CompressionResult> {
|
||||
return new Promise((resolve) => {
|
||||
this.queue.push({
|
||||
id: this.nextId++,
|
||||
body,
|
||||
mode,
|
||||
options,
|
||||
originalBody: body,
|
||||
resolve,
|
||||
onEngineStep,
|
||||
});
|
||||
this.dispatch();
|
||||
});
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
for (const job of this.queue.splice(0)) job.resolve(unchanged(job.originalBody));
|
||||
await Promise.all([...this.workers].map((slot) => this.remove(slot, true)));
|
||||
}
|
||||
private spawn(): PoolWorker {
|
||||
const slot: PoolWorker = {
|
||||
worker: new Worker(workerUrl()),
|
||||
job: null,
|
||||
timeout: null,
|
||||
idle: null,
|
||||
};
|
||||
this.workers.add(slot);
|
||||
slot.worker.on("message", (message: CompressionWorkerMessage) =>
|
||||
this.handleMessage(slot, message)
|
||||
);
|
||||
slot.worker.on("error", () => this.fail(slot));
|
||||
slot.worker.on("exit", () => {
|
||||
if (this.workers.has(slot)) this.fail(slot);
|
||||
});
|
||||
return slot;
|
||||
}
|
||||
private dispatch(): void {
|
||||
while (this.queue.length) {
|
||||
let slot = [...this.workers].find((candidate) => !candidate.job);
|
||||
if (!slot && this.workers.size < this.size) slot = this.spawn();
|
||||
if (!slot) return;
|
||||
if (slot.idle) clearTimeout(slot.idle);
|
||||
const job = this.queue.shift();
|
||||
if (!job) return;
|
||||
slot.job = job;
|
||||
slot.timeout = setTimeout(() => this.fail(slot!), this.timeoutMs);
|
||||
slot.timeout.unref();
|
||||
const { originalBody: _body, resolve: _resolve, onEngineStep: _step, ...wireJob } = job;
|
||||
slot.worker.postMessage(wireJob);
|
||||
}
|
||||
}
|
||||
private handleMessage(slot: PoolWorker, message: CompressionWorkerMessage): void {
|
||||
const job = slot.job;
|
||||
if (!job || job.id !== message.id) return;
|
||||
if (message.type === "step") {
|
||||
try {
|
||||
job.onEngineStep?.(message.step);
|
||||
} catch {
|
||||
// Telemetry is best-effort.
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.finish(slot, message.type === "result" ? message.result : unchanged(job.originalBody));
|
||||
}
|
||||
private finish(slot: PoolWorker, result: CompressionResult): void {
|
||||
const job = slot.job;
|
||||
if (!job) return;
|
||||
if (slot.timeout) clearTimeout(slot.timeout);
|
||||
slot.timeout = null;
|
||||
slot.job = null;
|
||||
job.resolve(result);
|
||||
slot.idle = setTimeout(() => void this.remove(slot, false), this.idleMs);
|
||||
slot.idle.unref();
|
||||
this.dispatch();
|
||||
}
|
||||
private fail(slot: PoolWorker): void {
|
||||
const job = slot.job;
|
||||
if (job) job.resolve(unchanged(job.originalBody));
|
||||
slot.job = null;
|
||||
void this.remove(slot, true).finally(() => this.dispatch());
|
||||
}
|
||||
private async remove(slot: PoolWorker, terminate: boolean): Promise<void> {
|
||||
if (!this.workers.delete(slot)) return;
|
||||
if (slot.timeout) clearTimeout(slot.timeout);
|
||||
if (slot.idle) clearTimeout(slot.idle);
|
||||
if (terminate) await slot.worker.terminate().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let pool: CompressionWorkerPool | null = null;
|
||||
export function runCompressionInWorker(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionWorkerJob["mode"],
|
||||
options?: CompressionWorkerOptions,
|
||||
onEngineStep?: (step: StackedCompressionStep) => void
|
||||
): Promise<CompressionResult> {
|
||||
pool ??= new CompressionWorkerPool();
|
||||
return pool.run(body, mode, options, onEngineStep);
|
||||
}
|
||||
export async function closeCompressionWorkerPoolForTests(): Promise<void> {
|
||||
const active = pool;
|
||||
pool = null;
|
||||
await active?.close();
|
||||
}
|
||||
71
open-sse/services/compression/compressionWorkerProtocol.ts
Normal file
71
open-sse/services/compression/compressionWorkerProtocol.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
|
||||
import type { StackedCompressionStep } from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionStage,
|
||||
CompressionWireFormat,
|
||||
ImageTransportFidelity,
|
||||
} from "./engines/types.ts";
|
||||
|
||||
export interface CompressionWorkerOptions {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
providerTransport?: "direct" | "aggregator";
|
||||
provider?: string;
|
||||
imageTransportFidelity?: ImageTransportFidelity;
|
||||
sourceFormat?: CompressionWireFormat;
|
||||
targetFormat?: CompressionWireFormat;
|
||||
compressionStage?: CompressionStage;
|
||||
config?: CompressionConfig;
|
||||
}
|
||||
export interface CompressionWorkerJob {
|
||||
id: number;
|
||||
body: Record<string, unknown>;
|
||||
mode: CompressionMode;
|
||||
options?: CompressionWorkerOptions;
|
||||
}
|
||||
export type CompressionWorkerMessage =
|
||||
| { id: number; type: "step"; step: StackedCompressionStep }
|
||||
| { id: number; type: "result"; result: CompressionResult }
|
||||
| { id: number; type: "error"; error: string };
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
export function isStrictlySerializable(value: unknown, seen = new Set<object>()): boolean {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number"
|
||||
) {
|
||||
return typeof value !== "number" || Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== "object" || seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) return value.every((entry) => isStrictlySerializable(entry, seen));
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.values(value).every((entry) => isStrictlySerializable(entry, seen));
|
||||
}
|
||||
|
||||
const WORKER_STACK_ENGINES = new Set(["caveman", "rtk", "standard"]);
|
||||
export function isCompressionWorkerEligible(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: CompressionWorkerOptions
|
||||
): boolean {
|
||||
if (mode !== "standard" && mode !== "rtk" && mode !== "stacked") return false;
|
||||
if (mode === "stacked") {
|
||||
const pipeline = options?.config?.stackedPipeline;
|
||||
if (!Array.isArray(pipeline) || pipeline.length === 0) return false;
|
||||
if (
|
||||
pipeline.some((step) => {
|
||||
const engine = typeof step === "string" ? step : step.engine;
|
||||
return !WORKER_STACK_ENGINES.has(engine);
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return isStrictlySerializable({ body, mode, ...(options ? { options } : {}) });
|
||||
}
|
||||
@@ -519,6 +519,28 @@ async function runCompressionAsync(
|
||||
cachingContext?: CachingDetectionContext;
|
||||
}
|
||||
): Promise<CompressionResult> {
|
||||
const workerOptions = options
|
||||
? {
|
||||
model: options.model,
|
||||
supportsVision: options.supportsVision,
|
||||
providerTransport: options.providerTransport,
|
||||
provider: options.provider,
|
||||
imageTransportFidelity: options.imageTransportFidelity,
|
||||
sourceFormat: options.sourceFormat,
|
||||
targetFormat: options.targetFormat,
|
||||
compressionStage: options.compressionStage,
|
||||
config: options.config,
|
||||
}
|
||||
: undefined;
|
||||
const { isCompressionWorkerEligible } = await import("./compressionWorkerProtocol.ts");
|
||||
if (isCompressionWorkerEligible(body, mode, workerOptions)) {
|
||||
try {
|
||||
const { runCompressionInWorker } = await import("./compressionWorkerPool.ts");
|
||||
return await runCompressionInWorker(body, mode, workerOptions, options?.onEngineStep);
|
||||
} catch {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
}
|
||||
if (
|
||||
options?.config?.memoizeCompressionResults === true &&
|
||||
// Only memoize for an explicit principal — a missing principalId would collapse
|
||||
|
||||
@@ -28,12 +28,10 @@ export async function refreshCopilotToken(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
return { status: response.status };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -49,8 +47,8 @@ export async function refreshCopilotToken(
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message,
|
||||
errorType: error?.name || "Error",
|
||||
});
|
||||
return null;
|
||||
return { status: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "open-sse"
|
||||
# Match `.npmrc`'s legacy-peer-deps posture. OmniRoute imports only the deep
|
||||
# icon modules from @lobehub/icons; auto-installing its unused @lobehub/ui peer
|
||||
# pulls a large UI subtree (including packages without distributable licenses).
|
||||
autoInstallPeers: false
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
|
||||
@@ -33,6 +33,14 @@ const STANDALONE = process.env.OMNIROUTE_STANDALONE_DIR
|
||||
|
||||
const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js");
|
||||
const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts");
|
||||
const COMPRESSION_WORKER_REL = join("open-sse", "services", "compression", "compressionWorker.js");
|
||||
const COMPRESSION_WORKER_SRC = join(
|
||||
ROOT,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.ts"
|
||||
);
|
||||
const WORKER_REL = join(
|
||||
"open-sse",
|
||||
"services",
|
||||
@@ -107,9 +115,26 @@ function main() {
|
||||
);
|
||||
console.log("[colocate-standalone] ✅ call-log artifact worker bundled");
|
||||
|
||||
const compressionWorkerDest = join(STANDALONE, COMPRESSION_WORKER_REL);
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
COMPRESSION_WORKER_SRC,
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
`--outfile=${compressionWorkerDest}`,
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
console.log("[colocate-standalone] ✅ compression worker bundled");
|
||||
|
||||
// The call-log worker is always present; scope it to ESM immediately. The
|
||||
// optional LLMLingua worker dir is added below only when its deps are installed.
|
||||
const workerDirs = [dirname(callLogWorkerDest)];
|
||||
const workerDirs = [dirname(callLogWorkerDest), dirname(compressionWorkerDest)];
|
||||
|
||||
if (!hasOptionals) {
|
||||
console.log(
|
||||
|
||||
@@ -45,6 +45,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
|
||||
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
|
||||
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
|
||||
"open-sse/services/compression/compressionWorker.js",
|
||||
"src/lib/usage/callLogArtifactWorker.js",
|
||||
"package.json",
|
||||
"peer-stamp.mjs",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
|
||||
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
|
||||
import { stageOptionalPacks } from "./optionalPackStaging.mjs";
|
||||
import { runBuildTool } from "./buildToolRunner.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -169,6 +170,27 @@ assembleStandalone({
|
||||
// app they would point at the build machine's absolute paths and break on install.
|
||||
materializeSymlinks: true,
|
||||
});
|
||||
const compressionWorkerDest = join(
|
||||
ELECTRON_STANDALONE_DIR,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.js"
|
||||
);
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
join(ROOT, "open-sse", "services", "compression", "compressionWorker.ts"),
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
`--outfile=${compressionWorkerDest}`,
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
|
||||
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
|
||||
if (docsPrune.removedFiles > 0) {
|
||||
|
||||
@@ -407,6 +407,40 @@ if (existsSync(llmWorkerSrc)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.6b: Bundle synchronous compression worker ──────────────────
|
||||
const compressionWorkerSrc = join(
|
||||
ROOT,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.ts"
|
||||
);
|
||||
const compressionWorkerDest = join(
|
||||
DIST_DIR,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.js"
|
||||
);
|
||||
if (!existsSync(compressionWorkerSrc)) {
|
||||
throw new Error("Required compression worker source is missing");
|
||||
}
|
||||
console.log(" 🔨 Bundling compression worker...");
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
"open-sse/services/compression/compressionWorker.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
"--outfile=dist/open-sse/services/compression/compressionWorker.js",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
|
||||
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
|
||||
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
|
||||
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
|
||||
|
||||
@@ -90,13 +90,30 @@ export function baselineValue(metric, root = ROOT) {
|
||||
}
|
||||
}
|
||||
|
||||
// A line that is unambiguously a PASS. Test reporters print the file name on BOTH the
|
||||
// pass and the fail line, so a green line for a file whose NAME contains "fail"
|
||||
// (fail-fast-*.test.ts, failover-*.test.ts) must never be offered as a failure cause.
|
||||
const GREEN_LINE_RE = /^[✓✔√]/;
|
||||
|
||||
// Markers that are only meaningful at the START of a line: "FAIL" also occurs inside test
|
||||
// FILE NAMES and inside summary prose ("Test Files 1 failed"), so matching it anywhere —
|
||||
// and case-insensitively — reports a PASSING file as the cause of the red.
|
||||
const LINE_START_FAILURE_RE = /^(?:[✖✗×]|FAIL\b|not ok\b|REGRESS)/;
|
||||
|
||||
// Markers that are unambiguous ANYWHERE in the line: tsc and Node emit them mid-line
|
||||
// ("src/x.ts(10,5): error TS2322: ..."), so these stay unanchored. They are matched
|
||||
// case-SENSITIVELY because that is how the emitting tools actually spell them.
|
||||
const INLINE_FAILURE_RE = /\berror TS\d+\b|\bAssertionError\b|\bError:|\bREGRESS/;
|
||||
|
||||
/** Best-effort "first meaningful failure line" from captured command output. */
|
||||
export function firstFailureLine(out) {
|
||||
const lines = String(out || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const hit = lines.find((l) => /✖|✗|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l));
|
||||
const hit = lines.find(
|
||||
(l) => !GREEN_LINE_RE.test(l) && (LINE_START_FAILURE_RE.test(l) || INLINE_FAILURE_RE.test(l))
|
||||
);
|
||||
return (hit || lines[lines.length - 1] || "failed").slice(0, 200);
|
||||
}
|
||||
|
||||
@@ -232,6 +249,36 @@ export function fullCiTimeoutFor(gateId) {
|
||||
return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ci.yml gate scripts whose result the CURATED pass already records under a DIFFERENT id.
|
||||
// Without this map the --full-ci pass re-records them unconditionally as kind:"hard" while
|
||||
// the curated pass recorded them as kind:"drift", and the SAME gate is printed in BOTH
|
||||
// verdict buckets of one report (file-size / compression-budget appeared as a hard failure
|
||||
// and as drift simultaneously in the #9985 verdict).
|
||||
export const FULL_CI_CURATED_ALIASES = {
|
||||
lint: "lint-errors",
|
||||
"check:workflows": "workflow-lint",
|
||||
"check:complexity-ratchets": "complexity",
|
||||
};
|
||||
|
||||
/** Curated-pass id equivalent to a ci.yml gate script id ("check:file-size" -> "file-size"). */
|
||||
export function curatedEquivalentId(scriptId) {
|
||||
const id = String(scriptId || "");
|
||||
if (Object.hasOwn(FULL_CI_CURATED_ALIASES, id)) return FULL_CI_CURATED_ALIASES[id];
|
||||
return id.startsWith("check:") ? id.slice("check:".length) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket a --full-ci gate must be reported under: the classification the curated pass already
|
||||
* gave the equivalent gate, else "hard" (the --full-ci default for gates the curated list does
|
||||
* not cover). This only changes WHICH BUCKET a result is printed in — it never changes whether
|
||||
* a gate runs, nor whether it passed.
|
||||
*/
|
||||
export function fullCiKindFor(scriptId, results) {
|
||||
const equivalent = curatedEquivalentId(scriptId);
|
||||
const curated = (results || []).find((r) => r.id === scriptId || r.id === equivalent);
|
||||
return curated?.kind ?? "hard";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run.
|
||||
* Each entry: { id, job, args:["run", <script>, ...("--" + args)], env }.
|
||||
@@ -716,7 +763,10 @@ async function main() {
|
||||
record({
|
||||
id: g.id,
|
||||
label: `ci.yml:${g.job} → npm ${g.args.join(" ")}`,
|
||||
kind: "hard",
|
||||
// Respect the curated classification when the curated pass already ran an equivalent
|
||||
// gate under a different id — otherwise the same ratchet is reported as a HARD failure
|
||||
// here AND as drift above, in one self-contradicting verdict.
|
||||
kind: fullCiKindFor(g.id, results),
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ type AdaptaTutorialModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// The Adapta CTA href points at https://link.omniroute.online/adapta (our own
|
||||
// shortener, the `adapta` slug) so the click lands in our Kutt metrics. The visible
|
||||
// link text intentionally stays the real domain (agent.adapta.one/agentic-chat) so
|
||||
// users still see where they are going.
|
||||
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
|
||||
const t = useTranslations("providers.adaptaTutorial");
|
||||
|
||||
@@ -29,7 +33,7 @@ export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProp
|
||||
<p className="text-text-muted mt-0.5">
|
||||
{t("step1DescPrefix")}{" "}
|
||||
<a
|
||||
href="https://agent.adapta.one/agentic-chat"
|
||||
href="https://link.omniroute.online/adapta"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
|
||||
@@ -4242,7 +4242,7 @@
|
||||
"smokeSendSuccessWithTask": "message/send ok (tarefa {taskId}).",
|
||||
"smokeSendSuccess": "message/send ok.",
|
||||
"smokeStreamFailed": "Teste de fumo message/stream falhou.",
|
||||
"smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}).",
|
||||
"smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}{stateSuffix}).",
|
||||
"smokeStreamNoTaskId": "message/stream terminou sem ID de tarefa.",
|
||||
"health": "Estado de saúde",
|
||||
"ok": "OK",
|
||||
@@ -10202,7 +10202,7 @@
|
||||
"scanning": "A analisar...",
|
||||
"opencodeIntegration": "Integração OpenCode",
|
||||
"opencodeDetected": "opencode {version} detetado",
|
||||
"opencodeDesc": "Gera um {configFile} pronto a usar com a tua configuração OmniRoute",
|
||||
"opencodeDesc": "Gera um {configFile} pronto a usar com o URL base do OmniRoute e todos os modelos disponíveis — coloca-o na raiz do teu projeto e executa {command}.",
|
||||
"downloadConfig": "Descarregar {file}",
|
||||
"downloaded": "Descarregado!",
|
||||
"setupGuideTitle": "Guia de configuração",
|
||||
@@ -10395,7 +10395,7 @@
|
||||
"dbEntries": "Entradas na BD",
|
||||
"dbEntriesSub": "Persistido (SQLite)",
|
||||
"cacheHits": "Acertos de cache",
|
||||
"cacheHitsSub": "Acertos",
|
||||
"cacheHitsSub": "de {total} no total",
|
||||
"tokensSaved": "Tokens Poupançados",
|
||||
"tokensSavedSub": "Estimado a partir de acertos",
|
||||
"hitRate": "Taxa de acertos",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/** Upstream proxy config persistence for upstream_proxy_config table. */
|
||||
import { getDbInstance } from "./core";
|
||||
import {
|
||||
isCloudMetadataHost,
|
||||
isPrivateHost as isPrivateNetworkHost,
|
||||
mappedIpv4Host,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { ipVersion, normalizeHost } from "@/shared/network/privateHost";
|
||||
|
||||
/** Which embedded proxy handles the retry leg when mode === "fallback". */
|
||||
export type FallbackBackend = "cliproxyapi" | "dario";
|
||||
@@ -37,26 +43,39 @@ function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"];
|
||||
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
/** IPv4 multicast (224.0.0.0/4) — kept from this module's original rule set. */
|
||||
function isMulticastIpv4(host: string): boolean {
|
||||
const first = Number.parseInt(host.split(".")[0], 10);
|
||||
return ipVersion(host) === 4 && first >= 224 && first <= 239;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a proxy target that is private or cloud-metadata, judging the ADDRESS
|
||||
* rather than its spelling.
|
||||
*
|
||||
* This module used to carry its own prefix regexes, which matched only the
|
||||
* dotted form: `http://169.254.169.254` was refused while
|
||||
* `http://[::ffff:169.254.169.254]` — the same address, serialised by WHATWG
|
||||
* URL as `::ffff:a9fe:a9fe` — was accepted, as were `::ffff:10.0.0.5`,
|
||||
* `fd00::/8`, `fe80::/10` and CGNAT `100.64.0.0/10`. #10843 fixed exactly that
|
||||
* class in the shared guard; routing this copy through the same helpers keeps
|
||||
* the two from drifting apart again.
|
||||
*
|
||||
* The deliberate exception stays: CLIProxyAPI runs on localhost:8317, so
|
||||
* loopback is allowed — and now so is its mapped spelling, for the same
|
||||
* address-not-spelling reason.
|
||||
*/
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// CLIProxyAPI runs on localhost:8317 — allow loopback explicitly
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
|
||||
if (
|
||||
/^10\./.test(hostname) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||
/^192\.168\./.test(hostname)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^0\./.test(hostname) ||
|
||||
/^127\./.test(hostname) ||
|
||||
/^224\./.test(hostname) ||
|
||||
/^169\.254\./.test(hostname)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
const normalized = normalizeHost(hostname);
|
||||
const asIpv4 = mappedIpv4Host(normalized) ?? normalized;
|
||||
|
||||
if (LOOPBACK_HOSTNAMES.has(normalized) || LOOPBACK_HOSTNAMES.has(asIpv4)) return false;
|
||||
|
||||
return (
|
||||
isCloudMetadataHost(normalized) || isPrivateNetworkHost(normalized) || isMulticastIpv4(asIpv4)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateProxyUrl(
|
||||
|
||||
@@ -636,35 +636,45 @@ export async function checkConnection(conn) {
|
||||
copilotExpiresAtMs - Date.now() < TOKEN_EXPIRY_BUFFER;
|
||||
|
||||
let refreshedProviderSpecificData: Record<string, unknown> | null = null;
|
||||
if (copilotAboutToExpire) {
|
||||
const hideLogs = await shouldHideLogs();
|
||||
const proxyResolution = await resolveProxyForConnection(conn.id);
|
||||
const proxyConfig = extractResolvedProxyConfig(proxyResolution);
|
||||
const healthCheckLog = {
|
||||
info: (tag: string, msg: string) => {
|
||||
if (!hideLogs) console.log(LOG_PREFIX, `[${tag}]`, msg);
|
||||
},
|
||||
warn: (tag: string, msg: string) => {
|
||||
if (!hideLogs) console.warn(LOG_PREFIX, `[${tag}]`, msg);
|
||||
},
|
||||
error: (tag: string, msg: string, extra?: Record<string, unknown>) => {
|
||||
if (!hideLogs) console.error(LOG_PREFIX, `[${tag}]`, msg, extra || "");
|
||||
},
|
||||
};
|
||||
const hideLogs = await shouldHideLogs();
|
||||
const proxyResolution = await resolveProxyForConnection(conn.id);
|
||||
const proxyConfig = extractResolvedProxyConfig(proxyResolution);
|
||||
const healthCheckLog = {
|
||||
info: (tag: string, msg: string) => {
|
||||
if (!hideLogs) console.log(LOG_PREFIX, `[${tag}]`, msg);
|
||||
},
|
||||
warn: (tag: string, msg: string) => {
|
||||
if (!hideLogs) console.warn(LOG_PREFIX, `[${tag}]`, msg);
|
||||
},
|
||||
error: (tag: string, msg: string, extra?: Record<string, unknown>) => {
|
||||
if (!hideLogs) console.error(LOG_PREFIX, `[${tag}]`, msg, extra || "");
|
||||
},
|
||||
};
|
||||
|
||||
const copilotResult = await refreshCopilotToken(
|
||||
conn.accessToken,
|
||||
healthCheckLog,
|
||||
proxyConfig,
|
||||
getCopilotTokenBaseUrl(conn)
|
||||
);
|
||||
if (copilotResult?.token) {
|
||||
refreshedProviderSpecificData = {
|
||||
...providerSpecificData,
|
||||
copilotToken: copilotResult.token,
|
||||
copilotTokenExpiresAt: copilotResult.expiresAt,
|
||||
};
|
||||
}
|
||||
const copilotResult = await refreshCopilotToken(
|
||||
conn.accessToken,
|
||||
healthCheckLog,
|
||||
proxyConfig,
|
||||
getCopilotTokenBaseUrl(conn)
|
||||
);
|
||||
if (copilotResult?.status === 401) {
|
||||
await updateProviderConnection(conn.id, {
|
||||
testStatus: "expired",
|
||||
lastHealthCheckAt: now,
|
||||
lastError: "GitHub rejected the access token",
|
||||
lastErrorAt: now,
|
||||
lastErrorType: "github_access_token_invalid",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "github_access_token_invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (copilotResult?.token && copilotAboutToExpire) {
|
||||
refreshedProviderSpecificData = {
|
||||
...providerSpecificData,
|
||||
copilotToken: copilotResult.token,
|
||||
copilotTokenExpiresAt: copilotResult.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (canClearGitHubNoRefreshTokenState(conn)) {
|
||||
|
||||
@@ -10,6 +10,16 @@ const FORBIDDEN = new Set(
|
||||
"content-length",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
// The two RFC 7230 §6.1 hop-by-hop names this list was missing. They belong
|
||||
// to the connection between the client and OmniRoute (or its upstream
|
||||
// proxy), never to the request OmniRoute makes to the model provider —
|
||||
// forwarding `proxy-authorization` hands that proxy credential to the
|
||||
// provider. `src/lib/services/reverseProxy.ts` (HOP_BY_HOP),
|
||||
// `src/mitm/sanitizeHeaders.ts`, `src/mitm/inspector/httpProxyServer.ts`,
|
||||
// `src/mitm/tproxy/tlsCapture.ts` and `src/app/api/openapi/try/route.ts`
|
||||
// all already strip them; this list, the canonical one, did not.
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
|
||||
@@ -39,7 +39,7 @@ export class OutboundUrlGuardError extends Error {
|
||||
// `http://[::ffff:169.254.169.254]/` reaches these helpers as `::ffff:a9fe:a9fe`.
|
||||
// Matching the dotted spelling alone therefore misses every mapped address that
|
||||
// arrives through a parsed URL. Fold the embedded IPv4 back out before deciding.
|
||||
function mappedIpv4Host(hostname: string): string | null {
|
||||
export function mappedIpv4Host(hostname: string): string | null {
|
||||
const normalized = normalizeHost(hostname);
|
||||
if (!normalized.startsWith("::ffff:")) return null;
|
||||
const embedded = normalized.slice("::ffff:".length);
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function checkAndRefreshToken(provider: string, credentials: any) {
|
||||
updatedCredentials,
|
||||
resolveCopilotTokenBaseUrl(provider, updatedCredentials)
|
||||
);
|
||||
if (copilotToken) {
|
||||
if (copilotToken?.token) {
|
||||
await updateProviderCredentials(updatedCredentials.connectionId, {
|
||||
providerSpecificData: {
|
||||
...updatedCredentials.providerSpecificData,
|
||||
@@ -304,7 +304,7 @@ export async function refreshGitHubAndCopilotTokens(credentials: any) {
|
||||
const newGitHubCredentials = await refreshGitHubToken(credentials.refreshToken, credentials);
|
||||
if (newGitHubCredentials?.accessToken) {
|
||||
const copilotToken = await refreshCopilotToken(newGitHubCredentials.accessToken, credentials);
|
||||
if (copilotToken) {
|
||||
if (copilotToken?.token) {
|
||||
return {
|
||||
...newGitHubCredentials,
|
||||
providerSpecificData: {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// - stripVersion() — strips @version suffix from package keys
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import {
|
||||
classifyLicense,
|
||||
@@ -15,15 +16,19 @@ import {
|
||||
loadAllowlist,
|
||||
} from "../../../scripts/check/check-licenses.mjs";
|
||||
|
||||
const PNPM_WORKSPACE_URL = new URL("../../../pnpm-workspace.yaml", import.meta.url);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — synthetic allowlists for testing classifyLicense in isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeAllowlist(overrides: Partial<{
|
||||
allowed: string[];
|
||||
allowedExpressions: string[];
|
||||
exceptions: Record<string, { license: string; justification: string; risk: string }>;
|
||||
}> = {}) {
|
||||
function makeAllowlist(
|
||||
overrides: Partial<{
|
||||
allowed: string[];
|
||||
allowedExpressions: string[];
|
||||
exceptions: Record<string, { license: string; justification: string; risk: string }>;
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
allowed: ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "0BSD"],
|
||||
allowedExpressions: ["(MIT OR Apache-2.0)", "MIT AND ISC", "MIT*"],
|
||||
@@ -32,6 +37,15 @@ function makeAllowlist(overrides: Partial<{
|
||||
};
|
||||
}
|
||||
|
||||
test("pnpm does not auto-install the unused @lobehub/ui peer subtree", () => {
|
||||
const workspace = fs.readFileSync(PNPM_WORKSPACE_URL, "utf8");
|
||||
assert.match(
|
||||
workspace,
|
||||
/^autoInstallPeers:\s*false\s*$/m,
|
||||
"pnpm must match npm's legacy-peer-deps posture; @lobehub/ui is not a runtime dependency"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,7 +67,10 @@ test("stripVersion: handles scoped package without version", () => {
|
||||
});
|
||||
|
||||
test("stripVersion: handles nested scope-like name with version", () => {
|
||||
assert.equal(stripVersion("@aws-sdk/client-bedrock-runtime@3.1063.0"), "@aws-sdk/client-bedrock-runtime");
|
||||
assert.equal(
|
||||
stripVersion("@aws-sdk/client-bedrock-runtime@3.1063.0"),
|
||||
"@aws-sdk/client-bedrock-runtime"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,7 +167,10 @@ test("classifyLicense: LGPL package with registered exception returns 'exception
|
||||
});
|
||||
const result = classifyLicense("lgpl-native-pkg@1.2.3", "LGPL-3.0-or-later", allowlist);
|
||||
assert.equal(result.status, "exception");
|
||||
assert.ok(result.reason.includes("exception"), `reason should mention exception: ${result.reason}`);
|
||||
assert.ok(
|
||||
result.reason.includes("exception"),
|
||||
`reason should mention exception: ${result.reason}`
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyLicense: scoped package with exception: version is stripped for lookup", () => {
|
||||
|
||||
@@ -127,3 +127,20 @@ test("scoped layout runs a CJS server.js and an ESM worker.js side by side", ()
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("colocate-standalone bundles the required compression worker", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "colocate-compression-worker-"));
|
||||
try {
|
||||
writeFileSync(join(root, "server.js"), "module.exports = {};\n");
|
||||
execFileSync(process.execPath, ["scripts/build/colocate-standalone.mjs"], {
|
||||
cwd: join(import.meta.dirname, "..", "..", ".."),
|
||||
env: { ...process.env, OMNIROUTE_STANDALONE_DIR: root },
|
||||
stdio: "pipe",
|
||||
});
|
||||
const workerDir = join(root, "open-sse", "services", "compression");
|
||||
assert.equal(existsSync(join(workerDir, "compressionWorker.js")), true);
|
||||
assert.equal(JSON.parse(readFileSync(join(workerDir, "package.json"), "utf8")).type, "module");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,41 +4,33 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type CoreModule = typeof import("../../src/lib/db/core.ts");
|
||||
// Single shared tempDir for all tests — DATA_DIR/SQLITE_FILE are module-level consts
|
||||
// resolved once at first import, so we must create the temp dir and set DATA_DIR
|
||||
// BEFORE importing core.ts.
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tempDir;
|
||||
|
||||
// Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time,
|
||||
// so we must create the temp dir and import exactly once.
|
||||
type CoreModule = typeof import("../../src/lib/db/core.ts");
|
||||
let tempDir: string;
|
||||
let originalDataDir: string | undefined;
|
||||
let getDbInstance: CoreModule["getDbInstance"];
|
||||
let resetDbInstance: CoreModule["resetDbInstance"];
|
||||
let ensureDbInitialized: CoreModule["ensureDbInitialized"];
|
||||
let closeDbInstance: CoreModule["closeDbInstance"];
|
||||
// Import resetDbInstance ONCE at the top with the same ESM specifier the tests use,
|
||||
// so cleanup() operates on the real singleton (not a stale CJS require).
|
||||
// This is the FIRST import of core.ts, so DATA_DIR resolves to our tempDir.
|
||||
import {
|
||||
getDbInstance,
|
||||
resetDbInstance,
|
||||
ensureDbInitialized,
|
||||
closeDbInstance,
|
||||
} from "../../src/lib/db/core.ts";
|
||||
|
||||
before(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-"));
|
||||
originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tempDir;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
getDbInstance = core.getDbInstance;
|
||||
resetDbInstance = core.resetDbInstance;
|
||||
ensureDbInitialized = core.ensureDbInitialized;
|
||||
closeDbInstance = core.closeDbInstance;
|
||||
|
||||
// Clear any singleton left by a previous test file in the same shard
|
||||
// Clear any singleton left by a previous test file in the same shard.
|
||||
closeDbInstance();
|
||||
// Create a fresh DB in the temp dir (handles async driver initialization)
|
||||
// Create a fresh DB in the temp dir (handles async driver initialization).
|
||||
await ensureDbInitialized();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
resetDbInstance();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Let reset errors surface — no silent swallowing.
|
||||
resetDbInstance();
|
||||
if (originalDataDir !== undefined) {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
} else {
|
||||
@@ -90,9 +82,9 @@ test("getDbInstance creates tables from SCHEMA_SQL (proves initialization succee
|
||||
// The preservedCriticalState sentinel is captureSucceeded: true on fresh DB
|
||||
// (no existing file = no corruption path = initialized with default sentinel).
|
||||
// Verify this indirectly: the DB is fully functional and migrations ran.
|
||||
const migrationCount = db
|
||||
.prepare("SELECT COUNT(*) as c FROM _omniroute_migrations")
|
||||
.get() as { c: number };
|
||||
const migrationCount = db.prepare("SELECT COUNT(*) as c FROM _omniroute_migrations").get() as {
|
||||
c: number;
|
||||
};
|
||||
assert.ok(migrationCount.c >= 1, "at least one migration should be recorded");
|
||||
});
|
||||
|
||||
@@ -142,12 +134,14 @@ test("resetDbInstance clears the singleton so next call creates a new DB", async
|
||||
|
||||
// Write a marker row so we can prove the post-reset handle reopens the same
|
||||
// on-disk file through a freshly opened connection (not the cached one).
|
||||
db1.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"reset_ns",
|
||||
"marker",
|
||||
JSON.stringify({ v: 1 })
|
||||
);
|
||||
db1
|
||||
.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
|
||||
.run("reset_ns", "marker", JSON.stringify({ v: 1 }));
|
||||
|
||||
// Close the previous handle explicitly before resetting, so the file descriptor
|
||||
// is released before the next reopen (POSIX allows open fds to survive fs.rmSync,
|
||||
// but we want honest isolation, not accidental survival).
|
||||
closeDbInstance();
|
||||
resetDbInstance();
|
||||
|
||||
// Re-initialize after reset — drivers may need async pre-init (sql.js WASM)
|
||||
@@ -169,19 +163,14 @@ test("getDbInstance sets WAL journal mode", async () => {
|
||||
const db = getDbInstance();
|
||||
|
||||
const mode = db.pragma("journal_mode", { simple: true }) as string;
|
||||
assert.equal(
|
||||
String(mode).toLowerCase(),
|
||||
"wal",
|
||||
"on-disk DB should open in WAL journal mode"
|
||||
);
|
||||
assert.equal(String(mode).toLowerCase(), "wal", "on-disk DB should open in WAL journal mode");
|
||||
});
|
||||
|
||||
test("getDbInstance stores schema_version in db_meta", async () => {
|
||||
const db = getDbInstance();
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'")
|
||||
.get() as { value: string } | undefined;
|
||||
const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as
|
||||
{ value: string } | undefined;
|
||||
assert.ok(row, "db_meta should hold a schema_version row after init");
|
||||
assert.equal(row.value, "1", "schema_version should be seeded to '1'");
|
||||
});
|
||||
|
||||
161
tests/unit/compression/compression-worker.test.ts
Normal file
161
tests/unit/compression/compression-worker.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, describe, it } from "node:test";
|
||||
import {
|
||||
isCompressionWorkerEligible,
|
||||
isStrictlySerializable,
|
||||
} from "../../../open-sse/services/compression/compressionWorkerProtocol.ts";
|
||||
import {
|
||||
closeCompressionWorkerPoolForTests,
|
||||
CompressionWorkerPool,
|
||||
} from "../../../open-sse/services/compression/compressionWorkerPool.ts";
|
||||
import {
|
||||
applyCompression,
|
||||
applyCompressionAsync,
|
||||
} from "../../../open-sse/services/compression/strategySelector.ts";
|
||||
import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
const body = {
|
||||
model: "gpt-test",
|
||||
messages: [
|
||||
{ role: "system", content: "Answer accurately." },
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Please basically actually simply carefully help with this very important task. ".repeat(
|
||||
80
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
const config = {
|
||||
enabled: true,
|
||||
defaultMode: "stacked",
|
||||
autoTriggerTokens: 1,
|
||||
cacheMinutes: 0,
|
||||
preserveSystemPrompt: true,
|
||||
stackedPipeline: [{ engine: "rtk" }, { engine: "caveman" }],
|
||||
} as CompressionConfig;
|
||||
|
||||
function comparable<T extends { stats: { durationMs?: number; timestamp: number } | null }>(
|
||||
result: T
|
||||
) {
|
||||
if (!result.stats) return result;
|
||||
const {
|
||||
durationMs: _duration,
|
||||
timestamp: _timestamp,
|
||||
engineBreakdown,
|
||||
...stats
|
||||
} = result.stats as T["stats"] & {
|
||||
engineBreakdown?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const stableBreakdown = engineBreakdown?.map(({ durationMs: _stepDuration, ...step }) => step);
|
||||
return {
|
||||
...result,
|
||||
stats: {
|
||||
...stats,
|
||||
...(stableBreakdown ? { engineBreakdown: stableBreakdown } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
after(() => closeCompressionWorkerPoolForTests());
|
||||
|
||||
describe("compression worker eligibility", () => {
|
||||
it("accepts only standard, rtk, and approved rtk+caveman stacks", () => {
|
||||
assert.equal(isCompressionWorkerEligible(body, "standard", { config }), true);
|
||||
assert.equal(isCompressionWorkerEligible(body, "rtk", { config }), true);
|
||||
assert.equal(isCompressionWorkerEligible(body, "stacked", { config }), true);
|
||||
for (const mode of ["off", "lite", "aggressive", "ultra", "omniglyph"] as const) {
|
||||
assert.equal(isCompressionWorkerEligible(body, mode, { config }), false);
|
||||
}
|
||||
for (const engine of ["llmlingua", "omniglyph", "ccr", "session-dedup", "ultra"]) {
|
||||
assert.equal(
|
||||
isCompressionWorkerEligible(body, "stacked", {
|
||||
config: { ...config, stackedPipeline: [{ engine }] } as CompressionConfig,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects functions, symbols, classes, special objects, cycles, and non-finite numbers", () => {
|
||||
for (const value of [
|
||||
() => undefined,
|
||||
Symbol("x"),
|
||||
new Date(),
|
||||
new Map(),
|
||||
new Set(),
|
||||
/x/,
|
||||
NaN,
|
||||
Infinity,
|
||||
]) {
|
||||
assert.equal(isStrictlySerializable(value), false);
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
assert.equal(isStrictlySerializable(cyclic), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression worker execution", () => {
|
||||
it("matches the synchronous body and stats except timing fields", async () => {
|
||||
const sync = applyCompression(body, "stacked", { config });
|
||||
const async = await applyCompressionAsync(body, "stacked", { config });
|
||||
assert.deepEqual(comparable(async), comparable(sync));
|
||||
});
|
||||
|
||||
it("preserves Responses bodies and hard-budget results", async () => {
|
||||
const responsesBody = {
|
||||
model: "gpt-test",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "word ".repeat(600) }] }],
|
||||
};
|
||||
const hardBudgetConfig = { ...config, targetTokens: 100 };
|
||||
const sync = applyCompression(responsesBody, "stacked", { config: hardBudgetConfig });
|
||||
const async = await applyCompressionAsync(responsesBody, "stacked", {
|
||||
config: hardBudgetConfig,
|
||||
});
|
||||
assert.deepEqual(comparable(async), comparable(sync));
|
||||
});
|
||||
|
||||
it("relays per-engine progress from the worker", async () => {
|
||||
const steps: string[] = [];
|
||||
await applyCompressionAsync(body, "stacked", {
|
||||
config,
|
||||
onEngineStep: (step) => steps.push(step.engine),
|
||||
});
|
||||
assert.deepEqual(steps, ["rtk", "caveman"]);
|
||||
});
|
||||
|
||||
it("fails open without inline compression when a job times out", async () => {
|
||||
const pool = new CompressionWorkerPool({ size: 1, timeoutMs: 1, idleMs: 100 });
|
||||
try {
|
||||
const result = await pool.run(body, "stacked", { config });
|
||||
assert.deepEqual(result, { body, compressed: false, stats: null });
|
||||
} finally {
|
||||
await pool.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the parent event loop responsive while two workers overlap", async () => {
|
||||
const largeBody = {
|
||||
messages: Array.from({ length: 400 }, (_, index) => ({
|
||||
role: "user",
|
||||
content: `message ${index} ` + "basically actually simply ".repeat(400),
|
||||
})),
|
||||
};
|
||||
let ticked = false;
|
||||
const tick = new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
ticked = true;
|
||||
resolve();
|
||||
}, 0)
|
||||
);
|
||||
const jobs = Promise.all([
|
||||
applyCompressionAsync(largeBody, "standard", { config }),
|
||||
applyCompressionAsync(largeBody, "standard", { config }),
|
||||
]);
|
||||
await tick;
|
||||
assert.equal(ticked, true);
|
||||
await jobs;
|
||||
});
|
||||
});
|
||||
165
tests/unit/gemini-tts.test.ts
Normal file
165
tests/unit/gemini-tts.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const { AUDIO_SPEECH_PROVIDERS, parseSpeechModel } =
|
||||
await import("../../open-sse/config/audioRegistry.ts");
|
||||
const { geminiGenerateSpeech } = await import("../../open-sse/executors/geminiTts.ts");
|
||||
const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts");
|
||||
|
||||
test("Google Gemini TTS models parse publicly and remap to Gemini credentials", () => {
|
||||
assert.deepEqual(parseSpeechModel("google/gemini-2.5-flash-preview-tts"), {
|
||||
provider: "google",
|
||||
model: "gemini-2.5-flash-preview-tts",
|
||||
});
|
||||
assert.equal(AUDIO_SPEECH_PROVIDERS.google.credentialProviderId, "gemini");
|
||||
assert.deepEqual(
|
||||
AUDIO_SPEECH_PROVIDERS.google.models.map(({ id }) => id),
|
||||
["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"]
|
||||
);
|
||||
});
|
||||
|
||||
test("geminiGenerateSpeech sends the exact AI Studio generateContent contract and wraps PCM", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const pcm = Buffer.from([1, 2, 3, 4]);
|
||||
let captured: { url: string; init: RequestInit } | undefined;
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
captured = { url: String(input), init };
|
||||
return Response.json({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
data: pcm.toString("base64"),
|
||||
mimeType: "audio/L16;codec=pcm;rate=16000",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
try {
|
||||
const wav = await geminiGenerateSpeech(
|
||||
{ apiKey: "gemini-key" },
|
||||
{ model: "gemini-2.5-flash-preview-tts", text: "Hello", voice: "Kore" }
|
||||
);
|
||||
assert.equal(
|
||||
captured?.url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent"
|
||||
);
|
||||
assert.equal(
|
||||
(captured?.init.headers as Record<string, string>)["Content-Type"],
|
||||
"application/json"
|
||||
);
|
||||
assert.equal(
|
||||
(captured?.init.headers as Record<string, string>)["x-goog-api-key"],
|
||||
"gemini-key"
|
||||
);
|
||||
assert.equal((captured?.init.headers as Record<string, string>).Authorization, undefined);
|
||||
assert.deepEqual(JSON.parse(String(captured?.init.body)), {
|
||||
contents: [{ parts: [{ text: "Hello" }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(wav.subarray(0, 4).toString("ascii"), "RIFF");
|
||||
assert.equal(wav.readUInt32LE(24), 16000);
|
||||
assert.deepEqual(wav.subarray(44), pcm);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech returns WAV and defaults the AI Studio voice to Kore", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let payload: {
|
||||
generationConfig: {
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: string } } };
|
||||
};
|
||||
};
|
||||
globalThis.fetch = async (_input, init = {}) => {
|
||||
payload = JSON.parse(String(init.body));
|
||||
return Response.json({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
data: Buffer.from([5, 6]).toString("base64"),
|
||||
mimeType: "audio/L16;rate=24000",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "google/gemini-2.5-pro-preview-tts",
|
||||
input: "Speak",
|
||||
},
|
||||
credentials: { apiKey: "gemini-key" },
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "audio/wav");
|
||||
assert.equal(
|
||||
payload.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName,
|
||||
"Kore"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech rejects an AI Studio response without audio", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => Response.json({ candidates: [{ content: { parts: [] } }] });
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "google/gemini-2.5-flash-preview-tts",
|
||||
input: "Silent",
|
||||
},
|
||||
credentials: { apiKey: "gemini-key" },
|
||||
});
|
||||
const payload = (await response.json()) as { error: { message: string } };
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(
|
||||
payload.error.message,
|
||||
"Speech request failed: Gemini TTS response did not contain audio data"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech preserves AI Studio upstream errors", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({ error: { message: "quota exhausted" } }, { status: 429 });
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "google/gemini-2.5-flash-preview-tts",
|
||||
input: "Limited",
|
||||
},
|
||||
credentials: { apiKey: "gemini-key" },
|
||||
});
|
||||
const payload = (await response.json()) as { error: { message: string } };
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(payload.error.message, "quota exhausted");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
94
tests/unit/i18n-placeholder-parity.test.ts
Normal file
94
tests/unit/i18n-placeholder-parity.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// A translation that drops a placeholder silently loses the value it carried:
|
||||
// the string still renders, just without the number, path or command the
|
||||
// English copy promised. Nothing checked for that, and three strings had
|
||||
// drifted (all in `pt`):
|
||||
//
|
||||
// a2aDashboard.smokeStreamSuccessWithTask lost {stateSuffix}
|
||||
// agents.opencodeDesc lost {command}
|
||||
// cache.cacheHitsSub lost {total} ("of {total} total" -> "Acertos")
|
||||
//
|
||||
// Placeholder sets are compared, not counts or order: a locale may reorder or
|
||||
// repeat them, but it may not introduce one English never defined (it would
|
||||
// render literally) or drop one (its value disappears).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const messagesDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"i18n",
|
||||
"messages"
|
||||
);
|
||||
|
||||
type Json = { [key: string]: string | Json };
|
||||
|
||||
function loadLocale(file: string): Json {
|
||||
return JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")) as Json;
|
||||
}
|
||||
|
||||
function flatten(value: Json, prefix = ""): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const dotted = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof child === "string") out.set(dotted, child);
|
||||
else if (child && typeof child === "object") {
|
||||
for (const [k, v] of flatten(child, dotted)) out.set(k, v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names an ICU message interpolates: `{name}` and the argument of a typed
|
||||
* placeholder such as `{count, plural, ...}`. Nested sub-messages are covered
|
||||
* because the scan is a plain sweep of the whole string.
|
||||
*/
|
||||
function placeholders(message: string): Set<string> {
|
||||
return new Set(
|
||||
[...message.matchAll(/\{\s*([a-zA-Z0-9_]+)\s*[,}]/g)].map((match) => match[1])
|
||||
);
|
||||
}
|
||||
|
||||
const english = flatten(loadLocale("en.json"));
|
||||
const locales = readdirSync(messagesDir)
|
||||
.filter((file) => file.endsWith(".json") && file !== "en.json")
|
||||
.sort();
|
||||
|
||||
test("every locale keeps the placeholders its English source defines", () => {
|
||||
const drift: string[] = [];
|
||||
|
||||
for (const file of locales) {
|
||||
for (const [key, translated] of flatten(loadLocale(file))) {
|
||||
const source = english.get(key);
|
||||
if (typeof source !== "string") continue;
|
||||
|
||||
const expected = placeholders(source);
|
||||
const actual = placeholders(translated);
|
||||
const missing = [...expected].filter((name) => !actual.has(name));
|
||||
const unknown = [...actual].filter((name) => !expected.has(name));
|
||||
if (missing.length === 0 && unknown.length === 0) continue;
|
||||
|
||||
drift.push(
|
||||
`${file} ${key}\n` +
|
||||
` en: ${source}\n` +
|
||||
` ${file.replace(".json", "")}: ${translated}\n` +
|
||||
` missing=[${missing.join(", ")}] unknown=[${unknown.join(", ")}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(drift, [], `\n placeholder drift:\n ${drift.join("\n ")}\n`);
|
||||
});
|
||||
|
||||
test("the checker itself recognises the drift it is meant to catch", () => {
|
||||
// Without this the test above could pass by never matching anything.
|
||||
assert.deepEqual([...placeholders("of {total} total")], ["total"]);
|
||||
assert.deepEqual([...placeholders("ok (task {taskId}{stateSuffix}).")], ["taskId", "stateSuffix"]);
|
||||
assert.deepEqual([...placeholders("{count, plural, one {# item} other {# items}}")], ["count"]);
|
||||
assert.deepEqual([...placeholders("Acertos")], []);
|
||||
});
|
||||
@@ -103,7 +103,7 @@ function resolveLiveKieMarketCatalog() {
|
||||
}));
|
||||
}
|
||||
|
||||
test("KIE Market resolver changes exactly one id in the live market catalog", () => {
|
||||
test("KIE Market resolver changes exactly the 4 google-imagen ids in the live market catalog", () => {
|
||||
const roundTrips = resolveLiveKieMarketCatalog();
|
||||
const changed = roundTrips.filter(({ publicModelId, upstreamModelId }) => {
|
||||
return upstreamModelId !== publicModelId;
|
||||
@@ -114,12 +114,31 @@ test("KIE Market resolver changes exactly one id in the live market catalog", ()
|
||||
publicModelId: "google-imagen/nano-banana-2",
|
||||
upstreamModelId: "nano-banana-2",
|
||||
},
|
||||
{
|
||||
publicModelId: "google-imagen/nano-banana",
|
||||
upstreamModelId: "google/nano-banana",
|
||||
},
|
||||
{
|
||||
publicModelId: "google-imagen/nano-banana-pro",
|
||||
upstreamModelId: "nano-banana-pro",
|
||||
},
|
||||
{
|
||||
publicModelId: "google-imagen/nano-banana-edit",
|
||||
upstreamModelId: "google/nano-banana-edit",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const REWRITTEN_GOOGLE_IMAGEN_MARKET_IDS = new Set([
|
||||
"google-imagen/nano-banana",
|
||||
"google-imagen/nano-banana-2",
|
||||
"google-imagen/nano-banana-pro",
|
||||
"google-imagen/nano-banana-edit",
|
||||
]);
|
||||
|
||||
test("KIE Market resolver preserves every other live market catalog id byte-identically", () => {
|
||||
for (const { publicModelId, upstreamModelId } of resolveLiveKieMarketCatalog()) {
|
||||
if (publicModelId !== "google-imagen/nano-banana-2") {
|
||||
if (!REWRITTEN_GOOGLE_IMAGEN_MARKET_IDS.has(publicModelId)) {
|
||||
assert.equal(
|
||||
upstreamModelId,
|
||||
publicModelId,
|
||||
@@ -129,8 +148,8 @@ test("KIE Market resolver preserves every other live market catalog id byte-iden
|
||||
}
|
||||
});
|
||||
|
||||
test("KIE Market resolver keeps exactly one explicit upstream id mapping", () => {
|
||||
assert.equal(KIE_MARKET_UPSTREAM_MODEL_IDS.size, 1);
|
||||
test("KIE Market resolver keeps exactly the explicit google-imagen upstream id mappings (#11296)", () => {
|
||||
assert.equal(KIE_MARKET_UPSTREAM_MODEL_IDS.size, 4);
|
||||
});
|
||||
|
||||
test("KIE Market resolver passes an unknown namespaced id through byte-identically", () => {
|
||||
@@ -160,6 +179,36 @@ test("KIE Market createTask sends the bare upstream model id for Nano Banana 2 (
|
||||
assert.equal(captured.result.data.data[0].url, "https://example.com/kie-market-image.png");
|
||||
});
|
||||
|
||||
test("KIE Market createTask sends the KIE upstream id for Nano Banana (#11296)", async () => {
|
||||
const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana");
|
||||
|
||||
assert.equal(
|
||||
captured.create.body.model,
|
||||
"google/nano-banana",
|
||||
"KIE Market createTask must send the KIE-documented google/nano-banana upstream id"
|
||||
);
|
||||
});
|
||||
|
||||
test("KIE Market createTask sends the bare upstream model id for Nano Banana Pro (#11296)", async () => {
|
||||
const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana-pro");
|
||||
|
||||
assert.equal(
|
||||
captured.create.body.model,
|
||||
"nano-banana-pro",
|
||||
"KIE Market createTask must send the KIE-documented nano-banana-pro upstream id"
|
||||
);
|
||||
});
|
||||
|
||||
test("KIE Market createTask sends the KIE upstream id for Nano Banana Edit (#11296)", async () => {
|
||||
const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana-edit");
|
||||
|
||||
assert.equal(
|
||||
captured.create.body.model,
|
||||
"google/nano-banana-edit",
|
||||
"KIE Market createTask must send the KIE-documented google/nano-banana-edit upstream id"
|
||||
);
|
||||
});
|
||||
|
||||
test("KIE Market createTask leaves genuinely namespaced upstream ids untouched (#11225 control)", async () => {
|
||||
const captured = await runKieMarketGeneration("kie/seedream/4.5-text-to-image");
|
||||
|
||||
|
||||
@@ -38,6 +38,99 @@ async function resetStorage() {
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test("GitHub access-token health demotes only a verified 401 and stores no secrets", async () => {
|
||||
for (const status of [200, 401, 403, 429, 500]) {
|
||||
await resetStorage();
|
||||
const accessToken = `ghp_status_${status}_secret`;
|
||||
const responseSecret = `response-${status}-secret`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const consoleOutput: unknown[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => consoleOutput.push(args);
|
||||
globalThis.fetch = (async () =>
|
||||
status === 200
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
token: `copilot-${status}-secret`,
|
||||
expires_at: Math.floor(Date.now() / 1000) + 1800,
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
: new Response(responseSecret, { status })) as typeof fetch;
|
||||
|
||||
try {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: `GitHub ${status}`,
|
||||
accessToken,
|
||||
healthCheckInterval: 60,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
copilotToken: "existing-copilot-secret",
|
||||
copilotTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection({
|
||||
...connection,
|
||||
lastHealthCheckAt: new Date(Date.now() - 61 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
assert.equal(updated?.testStatus, status === 401 ? "expired" : "active");
|
||||
assert.equal(updated?.lastHealthCheckAt !== connection.lastHealthCheckAt, true);
|
||||
assert.equal(JSON.stringify(updated).includes(responseSecret), false);
|
||||
assert.equal(JSON.stringify(consoleOutput).includes(accessToken), false);
|
||||
assert.equal(JSON.stringify(consoleOutput).includes(responseSecret), false);
|
||||
if (status === 401) {
|
||||
assert.equal(updated?.errorCode, "github_access_token_invalid");
|
||||
assert.equal(updated?.lastErrorType, "github_access_token_invalid");
|
||||
assert.equal(updated?.lastErrorSource, "oauth");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub access-token health keeps network failures active", async () => {
|
||||
await resetStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: "GitHub network",
|
||||
accessToken: "ghp_network_secret",
|
||||
healthCheckInterval: 60,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
copilotToken: "existing-copilot-secret",
|
||||
copilotTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection({
|
||||
...connection,
|
||||
lastHealthCheckAt: new Date(Date.now() - 61 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.equal(updated?.lastHealthCheckAt !== connection.lastHealthCheckAt, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
async function withHttpServer(handler, fn) {
|
||||
const server = http.createServer(handler);
|
||||
|
||||
|
||||
@@ -124,55 +124,81 @@ test("checkConnection leaves a non-refresh provider with no refresh token untouc
|
||||
|
||||
test("checkConnection keeps GitHub Copilot access-token-only connections active", async () => {
|
||||
await resetStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
token: "verified-copilot-token",
|
||||
expires_at: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
)) as typeof fetch;
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: "GitHub Access Token Account",
|
||||
accessToken: "github-access-token",
|
||||
refreshToken: null,
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
},
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
try {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: "GitHub Access Token Account",
|
||||
accessToken: "github-access-token",
|
||||
refreshToken: null,
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
},
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(getCreatedConnectionId(connection));
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.notEqual(updated?.errorCode, "no_refresh_token");
|
||||
assert.ok(updated?.lastHealthCheckAt);
|
||||
const updated = await providersDb.getProviderConnectionById(getCreatedConnectionId(connection));
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.notEqual(updated?.errorCode, "no_refresh_token");
|
||||
assert.ok(updated?.lastHealthCheckAt);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("checkConnection clears stale no_refresh_token state for usable GitHub Copilot connections", async () => {
|
||||
await resetStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
token: "verified-copilot-token",
|
||||
expires_at: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
)) as typeof fetch;
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: "GitHub False Expired Account",
|
||||
accessToken: "github-access-token",
|
||||
refreshToken: null,
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
},
|
||||
testStatus: "expired",
|
||||
errorCode: "no_refresh_token",
|
||||
lastError: "No refresh token available — re-authenticate this account.",
|
||||
isActive: true,
|
||||
});
|
||||
try {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
name: "GitHub False Expired Account",
|
||||
accessToken: "github-access-token",
|
||||
refreshToken: null,
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
},
|
||||
testStatus: "expired",
|
||||
errorCode: "no_refresh_token",
|
||||
lastError: "No refresh token available — re-authenticate this account.",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(getCreatedConnectionId(connection));
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.equal(updated?.errorCode ?? null, null);
|
||||
assert.equal(updated?.lastError ?? null, null);
|
||||
assert.ok(updated?.lastHealthCheckAt);
|
||||
const updated = await providersDb.getProviderConnectionById(getCreatedConnectionId(connection));
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.equal(updated?.errorCode ?? null, null);
|
||||
assert.equal(updated?.lastError ?? null, null);
|
||||
assert.ok(updated?.lastHealthCheckAt);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// Boundary regression for #8182 vs #5326: the terminal-skip guard added by #8182
|
||||
|
||||
@@ -739,6 +739,35 @@ test("refreshCopilotToken returns the short-lived copilot token", async () => {
|
||||
assert.equal(calls[0].options.headers.Authorization, "token github-access-token");
|
||||
});
|
||||
|
||||
test("refreshCopilotToken reports HTTP outcomes without logging response bodies", async () => {
|
||||
const secret = "ghp_never-log-this";
|
||||
const responseBody = `credential ${secret} rejected`;
|
||||
|
||||
for (const status of [401, 403, 429, 500]) {
|
||||
const log = createLog();
|
||||
const result = await withMockedFetch(
|
||||
async () => textResponse(responseBody, status),
|
||||
() => refreshCopilotToken(secret, log)
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status });
|
||||
assert.equal(JSON.stringify(log.entries).includes(secret), false);
|
||||
assert.equal(JSON.stringify(log.entries).includes(responseBody), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshCopilotToken distinguishes network failures from HTTP failures", async () => {
|
||||
const log = createLog();
|
||||
const result = await withMockedFetch(
|
||||
async () => {
|
||||
throw new Error("socket closed");
|
||||
},
|
||||
() => refreshCopilotToken("ghp_network-test", log)
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: null });
|
||||
});
|
||||
|
||||
test("supportsTokenRefresh, isUnrecoverableRefreshError and formatProviderCredentials cover provider helpers", async () => {
|
||||
const log = createLog();
|
||||
|
||||
|
||||
66
tests/unit/upstream-headers-proxy-auth.test.ts
Normal file
66
tests/unit/upstream-headers-proxy-auth.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// `FORBIDDEN` in src/shared/constants/upstreamHeaders.ts is documented as the
|
||||
// hop-by-hop / Host / framing denylist, and it was missing two of the RFC 7230
|
||||
// §6.1 names. Measured before the fix:
|
||||
//
|
||||
// proxy-authorization upstream=allow custom=allow
|
||||
// proxy-authenticate upstream=allow custom=allow
|
||||
// proxy-connection upstream=BLOCK custom=BLOCK
|
||||
//
|
||||
// `proxy-authorization` is the one that costs something: it authenticates the
|
||||
// hop to the operator's own proxy, so forwarding it hands that credential to
|
||||
// the model provider. Five other modules in this repo already strip it
|
||||
// (reverseProxy HOP_BY_HOP, mitm/sanitizeHeaders, inspector/httpProxyServer,
|
||||
// tproxy/tlsCapture, openapi/try) — the canonical list did not.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
isForbiddenUpstreamHeaderName,
|
||||
isForbiddenCustomHeaderName,
|
||||
} from "../../src/shared/constants/upstreamHeaders.ts";
|
||||
import { HOP_BY_HOP } from "../../src/lib/services/reverseProxy.ts";
|
||||
import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts";
|
||||
|
||||
test("proxy-authorization and proxy-authenticate are refused", () => {
|
||||
for (const name of ["proxy-authorization", "proxy-authenticate"]) {
|
||||
assert.equal(isForbiddenUpstreamHeaderName(name), true, name);
|
||||
assert.equal(isForbiddenCustomHeaderName(name), true, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("the refusal is case-insensitive, like every other name in the list", () => {
|
||||
for (const name of ["Proxy-Authorization", "PROXY-AUTHENTICATE", " Proxy-Authorization "]) {
|
||||
assert.equal(isForbiddenUpstreamHeaderName(name), true, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamHeadersMap drops them and keeps the rest", () => {
|
||||
const out = sanitizeUpstreamHeadersMap({
|
||||
"Proxy-Authorization": "Basic c2VjcmV0",
|
||||
"Proxy-Authenticate": "Basic realm=x",
|
||||
"X-Custom": "ok",
|
||||
});
|
||||
|
||||
assert.deepEqual(out, { "X-Custom": "ok" });
|
||||
});
|
||||
|
||||
test("the canonical list now covers every hop-by-hop name reverseProxy strips", () => {
|
||||
// `reverseProxy.HOP_BY_HOP` is the repo's own RFC 7230 §6.1 list. The two
|
||||
// lists drifting apart is what this fix repairs, so compare them directly —
|
||||
// `trailers` is the TE token, spelled `trailer` as a header name.
|
||||
const missing = [...HOP_BY_HOP]
|
||||
.map((name) => (name === "trailers" ? "trailer" : name))
|
||||
.filter((name) => !isForbiddenUpstreamHeaderName(name));
|
||||
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test("ordinary headers are still allowed", () => {
|
||||
for (const name of ["x-custom", "x-forwarded-for", "user-agent", "accept"]) {
|
||||
assert.equal(isForbiddenUpstreamHeaderName(name), false, name);
|
||||
}
|
||||
// Auth headers stay allowed as *upstream* headers (the credential layer owns
|
||||
// them) while remaining forbidden as operator-supplied custom headers.
|
||||
assert.equal(isForbiddenUpstreamHeaderName("authorization"), false);
|
||||
assert.equal(isForbiddenCustomHeaderName("authorization"), true);
|
||||
});
|
||||
115
tests/unit/upstream-proxy-host-spelling.test.ts
Normal file
115
tests/unit/upstream-proxy-host-spelling.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// `validateProxyUrl()` refused a private/metadata proxy target by matching
|
||||
// dotted-quad prefixes, so the same address in another spelling walked through.
|
||||
// Measured on release/v3.8.50 (ac02c5b42):
|
||||
//
|
||||
// http://169.254.169.254 -> blocked
|
||||
// http://[::ffff:169.254.169.254] -> ALLOWED (same address, mapped)
|
||||
// http://[::ffff:a9fe:a9fe] -> ALLOWED (how WHATWG URL serialises it)
|
||||
// http://[::ffff:10.0.0.5] -> ALLOWED
|
||||
// http://[fd00::1] -> ALLOWED (ULA)
|
||||
// http://[fe80::1] -> ALLOWED (link-local)
|
||||
// http://100.64.0.1 -> ALLOWED (CGNAT)
|
||||
//
|
||||
// #10843 fixed this class in the shared outbound guard; this module kept a
|
||||
// private copy of the classification and did not get the fix.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { validateProxyUrl } from "../../src/lib/db/upstreamProxy.ts";
|
||||
|
||||
function isValid(url: string): boolean {
|
||||
return validateProxyUrl(url).valid;
|
||||
}
|
||||
|
||||
test("a mapped-IPv4 spelling of a blocked address is blocked too", () => {
|
||||
for (const url of [
|
||||
"http://[::ffff:169.254.169.254]", // cloud metadata, mapped
|
||||
"http://[::ffff:a9fe:a9fe]", // the same, as WHATWG URL serialises it
|
||||
"http://[::ffff:10.0.0.5]", // RFC1918, mapped
|
||||
"http://[::ffff:192.168.1.1]",
|
||||
"http://[::ffff:172.16.0.1]",
|
||||
]) {
|
||||
assert.equal(isValid(url), false, `${url} must be refused`);
|
||||
}
|
||||
});
|
||||
|
||||
test("private IPv6 ranges are blocked", () => {
|
||||
for (const url of ["http://[fd00::1]", "http://[fc00::1]", "http://[fe80::1]"]) {
|
||||
assert.equal(isValid(url), false, `${url} must be refused`);
|
||||
}
|
||||
});
|
||||
|
||||
test("CGNAT space is blocked", () => {
|
||||
// 100.64.0.0/10 is carrier-grade NAT, not public address space.
|
||||
assert.equal(isValid("http://100.64.0.1"), false);
|
||||
assert.equal(isValid("http://100.127.255.254"), false);
|
||||
// …but the neighbouring public /8 addresses are not.
|
||||
assert.equal(isValid("http://100.63.255.255"), true);
|
||||
assert.equal(isValid("http://100.128.0.1"), true);
|
||||
});
|
||||
|
||||
test("every address the dotted rules already refused is still refused", () => {
|
||||
for (const url of [
|
||||
"http://169.254.169.254",
|
||||
"http://metadata.google.internal",
|
||||
"http://metadata.aws.internal",
|
||||
"http://10.0.0.5",
|
||||
"http://172.16.0.1",
|
||||
"http://172.31.255.255",
|
||||
"http://192.168.1.1",
|
||||
"http://0.0.0.0",
|
||||
"http://127.0.0.2",
|
||||
"http://224.0.0.1", // IPv4 multicast, the only octet the old rule covered
|
||||
]) {
|
||||
assert.equal(isValid(url), false, `${url} must still be refused`);
|
||||
}
|
||||
});
|
||||
|
||||
test("multicast is refused across the whole /4, not just 224/8", () => {
|
||||
// Widened on purpose, and the one deliberate behaviour change here beyond
|
||||
// the spelling fix: the old rule was `/^224\./`, so 225–239 were accepted.
|
||||
// None of 224.0.0.0/4 can be a proxy.
|
||||
for (const url of ["http://224.0.0.1", "http://231.7.7.7", "http://239.255.255.250"]) {
|
||||
assert.equal(isValid(url), false, `${url} must be refused`);
|
||||
}
|
||||
assert.equal(isValid("http://240.0.0.1"), true, "just outside the /4 is unchanged");
|
||||
});
|
||||
|
||||
test("loopback stays allowed — CLIProxyAPI runs on localhost:8317", () => {
|
||||
for (const url of [
|
||||
"http://localhost:8317",
|
||||
"http://127.0.0.1:8317",
|
||||
"http://[::1]:8317",
|
||||
// Judging the address rather than its spelling cuts both ways: the mapped
|
||||
// form of 127.0.0.1 is the same host the exception exists for.
|
||||
"http://[::ffff:127.0.0.1]:8317",
|
||||
]) {
|
||||
assert.equal(isValid(url), true, `${url} must stay allowed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary public proxies stay allowed", () => {
|
||||
for (const url of [
|
||||
"http://proxy.example.com",
|
||||
"https://proxy.example.com:3128",
|
||||
"http://8.8.8.8:3128",
|
||||
"http://[2606:4700::1111]",
|
||||
"http://172.32.0.1", // just outside 172.16.0.0/12
|
||||
"http://192.169.0.1", // just outside 192.168.0.0/16
|
||||
]) {
|
||||
assert.equal(isValid(url), true, `${url} must stay allowed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the non-host validations are unchanged", () => {
|
||||
assert.deepEqual(validateProxyUrl("https://proxy.example.com"), {
|
||||
valid: true,
|
||||
url: "https://proxy.example.com",
|
||||
});
|
||||
assert.equal(validateProxyUrl("ftp://proxy.example.com").valid, false);
|
||||
assert.match(String(validateProxyUrl("not-a-url").error), /Invalid URL/);
|
||||
assert.match(
|
||||
String(validateProxyUrl("http://169.254.169.254").error),
|
||||
/private\/internal address/
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ const {
|
||||
extractCiGates,
|
||||
FULL_CI_SKIP,
|
||||
fullCiTimeoutFor,
|
||||
curatedEquivalentId,
|
||||
fullCiKindFor,
|
||||
} = mod;
|
||||
|
||||
const extract = extractCiGates as (
|
||||
@@ -361,3 +363,117 @@ test("extractCiGates: the REAL ci.yml yields the base-reds that leaked in v3.8.4
|
||||
}
|
||||
assert.ok(ids.size >= 20, "the real gate set is substantial (>= 20 static gates)");
|
||||
});
|
||||
|
||||
// ─── Verdict accuracy (review of the #9985 release-green verdict) ────────────
|
||||
|
||||
test("firstFailureLine never blames a PASSING line whose test FILE NAME contains 'fail' (#9985)", () => {
|
||||
// Observed in the 2026-08-23 verdict: the reported "cause" of the unit red was
|
||||
// ✓ …fail-fast-concurrency-gate.test.ts (4 tests) 203ms
|
||||
// i.e. a GREEN line, matched only because the unanchored /FAIL/i marker hit the
|
||||
// substring "fail" inside the file name. The real ✖ line was three lines below.
|
||||
const out = [
|
||||
"> omniroute@3.8.50 test:unit",
|
||||
" ✓ tests/unit/runtime/fail-fast-concurrency-gate.test.ts (4 tests) 203ms",
|
||||
" ✓ tests/unit/router/failover-budget.test.ts (9 tests) 41ms",
|
||||
" ✖ tests/unit/router/pricing.test.ts > picks the cheapest candidate",
|
||||
"AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 2 !== 3",
|
||||
].join("\n");
|
||||
const hit = firstFailureLine(out);
|
||||
assert.doesNotMatch(hit, /fail-fast-concurrency-gate/, "a green line is never the failure cause");
|
||||
assert.doesNotMatch(hit, /failover-budget/, "a green line is never the failure cause");
|
||||
assert.match(hit, /pricing\.test\.ts/, "the real failing line must be reported instead");
|
||||
});
|
||||
|
||||
test("firstFailureLine still recognises every legitimate failure marker", () => {
|
||||
const cases: [string, RegExp][] = [
|
||||
["ok 1 - warms up\nnot ok 2 - routes to the cheapest key\n", /not ok 2/],
|
||||
["Test Files 1 failed\nFAIL tests/unit/router/pricing.test.ts\n", /^FAIL /],
|
||||
["src/x.ts(10,5): error TS2322: Type 'string' is not assignable.", /error TS2322/],
|
||||
["✗ db-rules: raw sqlite handle left open", /db-rules/],
|
||||
["Error: ENOENT: no such file or directory, open 'dist/server.js'", /ENOENT/],
|
||||
["[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797", /REGRESS/],
|
||||
["[file-size] REGRESSED: open-sse/router.ts 1204 > cap 1100", /REGRESSED/],
|
||||
];
|
||||
for (const [out, expected] of cases) {
|
||||
assert.match(firstFailureLine(out), expected, `marker lost for: ${out.slice(0, 40)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("firstFailureLine falls back to the last line when nothing matches", () => {
|
||||
assert.equal(firstFailureLine("warming up\nall quiet\n"), "all quiet");
|
||||
assert.equal(firstFailureLine(""), "failed");
|
||||
});
|
||||
|
||||
test("curatedEquivalentId maps a ci.yml gate script onto the curated pass id (#9985)", () => {
|
||||
assert.equal(curatedEquivalentId("check:file-size"), "file-size");
|
||||
assert.equal(curatedEquivalentId("check:compression-budget"), "compression-budget");
|
||||
// Curated ids that are NOT just the script name minus "check:".
|
||||
assert.equal(curatedEquivalentId("check:workflows"), "workflow-lint");
|
||||
assert.equal(curatedEquivalentId("check:complexity-ratchets"), "complexity");
|
||||
assert.equal(curatedEquivalentId("lint"), "lint-errors");
|
||||
// An uncurated gate keeps a stable, non-colliding identity.
|
||||
assert.equal(curatedEquivalentId("check:route-validation:t06"), "route-validation:t06");
|
||||
});
|
||||
|
||||
test("fullCiKindFor honours the curated classification of an already-known gate (#9985)", () => {
|
||||
const curated = [
|
||||
{ id: "file-size", kind: "drift", ok: false },
|
||||
{ id: "compression-budget", kind: "drift", ok: false },
|
||||
{ id: "workflow-lint", kind: "drift", ok: false },
|
||||
{ id: "docs-all", kind: "hard", ok: true },
|
||||
{ id: "lint-errors", kind: "hard", ok: true },
|
||||
];
|
||||
// Ratchets curated as DRIFT must stay drift when --full-ci re-runs them from ci.yml...
|
||||
assert.equal(fullCiKindFor("check:file-size", curated), "drift");
|
||||
assert.equal(fullCiKindFor("check:compression-budget", curated), "drift");
|
||||
assert.equal(fullCiKindFor("check:workflows", curated), "drift");
|
||||
// ...real-defect gates stay hard...
|
||||
assert.equal(fullCiKindFor("check:docs-all", curated), "hard");
|
||||
assert.equal(fullCiKindFor("lint", curated), "hard");
|
||||
// ...and a gate the curated pass never ran defaults to hard (the --full-ci contract).
|
||||
assert.equal(fullCiKindFor("check:bundle-size", curated), "hard");
|
||||
assert.equal(fullCiKindFor("check:route-validation:t06", curated), "hard");
|
||||
});
|
||||
|
||||
test("one gate can never land in BOTH verdict buckets of the same report (#9985)", () => {
|
||||
// The 2026-08-23 verdict listed file-size and compression-budget as hard failures
|
||||
// AND as drift, in the same table, because the --full-ci pass re-recorded every
|
||||
// ci.yml gate as kind:"hard" and the dedupe only compared raw ids.
|
||||
const curated = [
|
||||
{ id: "file-size", kind: "drift", ok: false },
|
||||
{ id: "compression-budget", kind: "drift", ok: false },
|
||||
];
|
||||
const fromCiYaml = ["check:file-size", "check:compression-budget"].map((id) => ({
|
||||
id,
|
||||
kind: fullCiKindFor(id, curated),
|
||||
ok: false,
|
||||
}));
|
||||
const v = computeVerdict([...curated, ...fromCiYaml]);
|
||||
const hardGates = new Set(v.hardFailures.map((r) => curatedEquivalentId(r.id)));
|
||||
const contradictions = v.drift
|
||||
.map((r) => curatedEquivalentId(r.id))
|
||||
.filter((id) => hardGates.has(id));
|
||||
assert.deepEqual(
|
||||
contradictions,
|
||||
[],
|
||||
"a gate reported as hard must not also be reported as drift"
|
||||
);
|
||||
assert.equal(
|
||||
v.releaseGreen,
|
||||
true,
|
||||
"a curated-drift ratchet must not block the release via the --full-ci path"
|
||||
);
|
||||
});
|
||||
|
||||
test("the --full-ci loop classifies from the curated results, not a hardcoded kind (#9985)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/kind:\s*fullCiKindFor\(g\.id,\s*results\)/,
|
||||
"--full-ci must classify each ci.yml gate through fullCiKindFor()"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user