mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 00:22:09 +03:00
Compare commits
24 Commits
fix/11291-
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe455d096d | ||
|
|
6853f8275d | ||
|
|
9b14896a6c | ||
|
|
29f26293c3 | ||
|
|
cb11592441 | ||
|
|
5ee646e68e | ||
|
|
6984676d95 | ||
|
|
79f8ae9d1e | ||
|
|
04b2c47940 | ||
|
|
24ac71465e | ||
|
|
8d6f91b558 | ||
|
|
c3698eedcb | ||
|
|
adca3b881c | ||
|
|
ac02c5b42f | ||
|
|
07d1816a45 | ||
|
|
3192eb88d5 | ||
|
|
8969526437 | ||
|
|
5518916725 | ||
|
|
3daa455e1f | ||
|
|
d282ec7ad6 | ||
|
|
1e81e521c0 | ||
|
|
a3c3117254 | ||
|
|
d9a883ec53 | ||
|
|
7913447bf0 |
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.
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -2,11 +2,11 @@ name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, release/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -104,7 +104,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
|
||||
// plugin provider, so `claude-primary` here carries no provider prefix.
|
||||
assert.ok(out["claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -159,11 +162,15 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
|
||||
// "claude-primary") must also stay unprefixed — OpenCode looks up
|
||||
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
|
||||
const claude = out["claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
|
||||
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
|
||||
// resolves the combo id directly.
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -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/10851-openapi-spec-auth-contract.md
Normal file
1
changelog.d/fixes/10851-openapi-spec-auth-contract.md
Normal file
@@ -0,0 +1 @@
|
||||
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.
|
||||
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming
|
||||
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))
|
||||
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 1–1,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018))
|
||||
@@ -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)).
|
||||
@@ -6866,7 +6866,11 @@ paths:
|
||||
Returns a structured JSON catalog parsed from this `openapi.yaml`,
|
||||
including info, servers, tags, schemas, and a flat list of endpoints
|
||||
(method, path, tags, summary, security, parameters, responses).
|
||||
Used by the in-app API explorer.
|
||||
Used by the in-app API explorer. When `requireLogin` is enabled, this
|
||||
management endpoint requires an authenticated dashboard session;
|
||||
otherwise it is available without authentication.
|
||||
security:
|
||||
- ManagementSessionAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Parsed OpenAPI catalog
|
||||
@@ -6920,6 +6924,10 @@ paths:
|
||||
type: string
|
||||
"404":
|
||||
description: openapi.yaml file not found on disk
|
||||
"401":
|
||||
$ref: "#/components/responses/ManagementAuthenticationRequired"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagementInvalidToken"
|
||||
"500":
|
||||
description: Failed to parse OpenAPI spec
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Database Schema & Operations Guide"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-23
|
||||
---
|
||||
|
||||
# Database Schema & Operations Guide
|
||||
@@ -43,12 +43,17 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("busy_timeout = 2000");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
// Settings > System & Storage > Cache Size is applied as KiB.
|
||||
db.pragma("cache_size = -16384");
|
||||
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
|
||||
```
|
||||
|
||||
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
|
||||
|
||||
The default cache size is **65,536 KiB (64 MiB)**. SQLite interprets a negative
|
||||
`cache_size` as an approximate upper bound in KiB and allocates pages on demand.
|
||||
**Settings > System & Storage > Cache Size** accepts integer values from **1 to
|
||||
1,000,000 KiB**; saving the setting applies it to the live database connection,
|
||||
and OmniRoute restores the persisted value at startup.
|
||||
|
||||
---
|
||||
|
||||
## Database Location
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
|
||||
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
|
||||
import {
|
||||
shouldDefaultAllowClassifier,
|
||||
detectClassifierFormat,
|
||||
buildDefaultAllowClaudeMessage,
|
||||
} from "./chatCore/claudeClassifierCompat.ts";
|
||||
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
|
||||
@@ -379,6 +380,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
||||
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
|
||||
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
@@ -778,11 +780,12 @@ export async function handleChatCore({
|
||||
classifierSettings.claudeClassifierCompat as string | undefined
|
||||
)
|
||||
) {
|
||||
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
|
||||
log?.warn?.(
|
||||
"CHAT",
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
|
||||
);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4910,12 +4913,14 @@ export async function handleChatCore({
|
||||
|
||||
// Translate response to client's expected format (usually OpenAI)
|
||||
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
|
||||
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
|
||||
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
|
||||
? translateNonStreamingResponse(
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
clientResponseFormat,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const memoryExtractionResponse = translatedResponse;
|
||||
@@ -4942,7 +4947,8 @@ export async function handleChatCore({
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
@@ -5465,7 +5471,8 @@ export async function handleChatCore({
|
||||
streamBody,
|
||||
clientResponseFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
extractToolSchemaMap(finalBody || translatedBody || body)
|
||||
) as Record<string, unknown>)
|
||||
: streamBody;
|
||||
const choices = cacheStreamBody.choices as
|
||||
|
||||
@@ -24,14 +24,19 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
|
||||
|
||||
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
|
||||
|
||||
/** The two synthetic-response shapes Claude Code's classifier can expect. */
|
||||
export type ClaudeClassifierFormat = "block" | "severity";
|
||||
|
||||
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
|
||||
const system = body?.system;
|
||||
if (typeof system === "string") return [system];
|
||||
if (Array.isArray(system)) {
|
||||
return system
|
||||
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
|
||||
? ((part as { text: string }).text)
|
||||
: ""))
|
||||
.map((part) =>
|
||||
part && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
@@ -60,6 +65,29 @@ export function shouldDefaultAllowClassifier(
|
||||
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which synthetic-response shape the classifier request expects.
|
||||
*
|
||||
* Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
|
||||
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
|
||||
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
|
||||
* stages and then fails closed — the same "blocking it for safety" failure this compat
|
||||
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
|
||||
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
|
||||
* the request is the classifier (via the system-prompt marker), so an unrelated app
|
||||
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
|
||||
*/
|
||||
export function detectClassifierFormat(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
): ClaudeClassifierFormat {
|
||||
const stopSequences = body?.stop_sequences;
|
||||
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
|
||||
return "severity";
|
||||
}
|
||||
return "block";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
|
||||
* body (matching the upstream reference implementation) — Claude Code's classifier
|
||||
@@ -67,7 +95,10 @@ export function shouldDefaultAllowClassifier(
|
||||
* satisfies both streaming and non-streaming callers without needing to plumb a
|
||||
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
|
||||
*/
|
||||
export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
export function buildDefaultAllowClaudeMessage(
|
||||
model?: string | null,
|
||||
format: ClaudeClassifierFormat = "block"
|
||||
): {
|
||||
success: true;
|
||||
response: Response;
|
||||
} {
|
||||
@@ -76,7 +107,12 @@ export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: model || "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "<block>no</block>" }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
|
||||
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
|
||||
import { stripEmptyOptionalToolArgs } from "../translator/response/openai-responses/pureHelpers.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -135,24 +136,28 @@ function findBestMessageText(output: unknown[]): {
|
||||
* Handles different provider response formats (Gemini, Claude, etc.)
|
||||
*
|
||||
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
|
||||
* @param toolSchemas - Optional Map<toolName, parametersSchema> for schema-aware optional-arg cleanup
|
||||
*/
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: JsonRecord,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): JsonRecord;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown {
|
||||
// If already in source format, return as-is
|
||||
if (targetFormat === sourceFormat) {
|
||||
@@ -219,6 +224,11 @@ export function translateNonStreamingResponse(
|
||||
toString(itemObj.id) ||
|
||||
`call_${Date.now()}_${toolCalls.length}`;
|
||||
let argsToEmit = itemObj.arguments;
|
||||
const rawName = toString(itemObj.name);
|
||||
const toolSchema = toolSchemas?.get(rawName);
|
||||
if (toolSchema) {
|
||||
argsToEmit = stripEmptyOptionalToolArgs(argsToEmit, rawName, toolSchema);
|
||||
}
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
@@ -229,7 +239,6 @@ export function translateNonStreamingResponse(
|
||||
|
||||
const fnArgs =
|
||||
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
|
||||
const rawName = toString(itemObj.name);
|
||||
// Strip Claude OAuth proxy_ prefix using toolNameMap
|
||||
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
|
||||
toolCalls.push({
|
||||
|
||||
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
|
||||
|
||||
@@ -12,7 +12,21 @@
|
||||
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
|
||||
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
|
||||
*
|
||||
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
|
||||
* `clampToLearned` implements nearest-tier clamping: smallest accepted >= demand,
|
||||
* falling back to the greatest accepted when demand exceeds every accepted value.
|
||||
* (#11295 — unified with the static "declared" clamp in
|
||||
* `executors/base/reasoningEffort.ts`, which already used nearest-tier semantics.
|
||||
* Before #11295, this learned clamp was downgrade-only — greatest accepted <=
|
||||
* demand — so the SAME accepted set {low,high,max} produced medium→low here but
|
||||
* medium→high via the declared path: identical inputs, opposite outputs,
|
||||
* depending only on whether the model had a static registry entry. #11274's
|
||||
* DeepSeek native mapping is the precedent for nearest-tier. This also fixes a
|
||||
* standalone bug: a request BELOW the learned floor (e.g. none/minimal on a
|
||||
* model that only ever advertised {low,high,max}) used to return null — no
|
||||
* clamp — so the too-low value passed straight through to the upstream, which
|
||||
* 400'd again on every subsequent request without ever learning a lower floor.
|
||||
* Nearest-tier naturally fixes this too: the smallest accepted value is always
|
||||
* >= any demand below the floor, so it is returned instead of null.
|
||||
*
|
||||
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
|
||||
* restart resets, the first request after a restart may re-learn at the cost of
|
||||
@@ -132,25 +146,39 @@ export function recordLearnedReasoningEffort(
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the greatest accepted value <= effortStr (downgrade only), or null
|
||||
* if effortStr is already accepted, below the minimum, or not in ORDER.
|
||||
* Return the nearest-tier accepted value for effortStr: the smallest accepted
|
||||
* value with rank >= effortStr's rank, or — when effortStr's rank exceeds every
|
||||
* accepted value (demand above the learned ceiling) — the greatest accepted
|
||||
* value. Returns null only when effortStr is already accepted (no clamp
|
||||
* needed), empty, or not a recognized member of REASONING_EFFORT_ORDER.
|
||||
*
|
||||
* Mirrors the declared-capability clamp in `executors/base/reasoningEffort.ts`
|
||||
* (#11295): both now use nearest-tier semantics so the same accepted set
|
||||
* produces the same mapping regardless of whether the model has a static
|
||||
* registry entry or was only learned reactively from an upstream 4xx.
|
||||
*/
|
||||
export function clampToLearned(effortStr: string, accepted: Set<string>): string | null {
|
||||
if (!effortStr || accepted.has(effortStr)) return null;
|
||||
const rank = rankOf(effortStr);
|
||||
if (rank === -1) return null;
|
||||
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
|
||||
if (rank < minRank) return null;
|
||||
let best: string | null = null;
|
||||
let bestRank = -1;
|
||||
|
||||
let nearestAbove: string | null = null;
|
||||
let nearestAboveRank = Infinity;
|
||||
let highest: string | null = null;
|
||||
let highestRank = -1;
|
||||
for (const v of accepted) {
|
||||
const r = rankOf(v);
|
||||
if (r <= rank && r > bestRank) {
|
||||
bestRank = r;
|
||||
best = v;
|
||||
if (r < 0) continue;
|
||||
if (r >= rank && r < nearestAboveRank) {
|
||||
nearestAboveRank = r;
|
||||
nearestAbove = v;
|
||||
}
|
||||
if (r > highestRank) {
|
||||
highestRank = r;
|
||||
highest = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return nearestAbove ?? highest;
|
||||
}
|
||||
|
||||
// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,31 @@ export function coerceToolSchemas(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
const NULL_OMISSION_NOTE = "null = omit this parameter";
|
||||
|
||||
function schemaTypeIncludes(type: unknown, wanted: string): boolean {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function isPlainStringType(type: unknown): boolean {
|
||||
return type === "string" || (Array.isArray(type) && type.length === 1 && type[0] === "string");
|
||||
}
|
||||
|
||||
function appendNullOmissionMarker(description: unknown): string {
|
||||
if (typeof description === "string" && description.length > 0) {
|
||||
return description.includes(NULL_OMISSION_NOTE)
|
||||
? description
|
||||
: `${description} (${NULL_OMISSION_NOTE})`;
|
||||
}
|
||||
return NULL_OMISSION_NOTE;
|
||||
}
|
||||
|
||||
function widenTypeWithNull(type: unknown): unknown {
|
||||
if (typeof type === "string") return [type, "null"];
|
||||
if (Array.isArray(type) && !type.includes("null")) return [...type, "null"];
|
||||
return type;
|
||||
}
|
||||
|
||||
// #7023 — Responses API strict mode forces every "optional" tool property into
|
||||
// `required`, so a model that intends to OMIT an optional enum property (no declared
|
||||
// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither
|
||||
@@ -299,7 +324,11 @@ export function coerceToolSchemas(tools: unknown): unknown {
|
||||
// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level
|
||||
// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches
|
||||
// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro).
|
||||
function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set<string>): boolean {
|
||||
function shouldInjectNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
@@ -312,19 +341,38 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
const widened: JsonRecord = { ...propSchema };
|
||||
const enumValues = propSchema.enum as unknown[];
|
||||
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
|
||||
if (typeof propSchema.type === "string") {
|
||||
widened.type = [propSchema.type, "null"];
|
||||
} else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) {
|
||||
widened.type = [...propSchema.type, "null"];
|
||||
}
|
||||
const note = "null = omit this parameter";
|
||||
widened.description =
|
||||
typeof propSchema.description === "string" && propSchema.description.length > 0
|
||||
? `${propSchema.description} (${note})`
|
||||
: note;
|
||||
widened.type = widenTypeWithNull(propSchema.type);
|
||||
widened.description = appendNullOmissionMarker(propSchema.description);
|
||||
return widened;
|
||||
}
|
||||
|
||||
// OpenCode `subagent.sessionID` (and any other optional default-less plain string) has
|
||||
// the same strict-mode omission problem as #7023 enums, but no enum to widen. Inject
|
||||
// the same nullable-union sentinel on top-level `properties[key]` only — do not recurse
|
||||
// into `items`/`anyOf`/`$defs`, and do not touch enums (owned by the helper above).
|
||||
function shouldInjectStringNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
!Array.isArray(propSchema.enum) &&
|
||||
isPlainStringType(propSchema.type) &&
|
||||
!schemaTypeIncludes(propSchema.type, "null") &&
|
||||
!required.has(key) &&
|
||||
!hasOwn(propSchema, "default")
|
||||
);
|
||||
}
|
||||
|
||||
function widenStringPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
return {
|
||||
...propSchema,
|
||||
type: widenTypeWithNull(propSchema.type),
|
||||
description: appendNullOmissionMarker(propSchema.description),
|
||||
};
|
||||
}
|
||||
|
||||
export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
@@ -356,6 +404,43 @@ export function injectOptionalEnumOmissionForTools(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
let changed = false;
|
||||
const nextProperties: JsonRecord = { ...schema.properties };
|
||||
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
if (!shouldInjectStringNullOmission(key, propSchema, required)) continue;
|
||||
nextProperties[key] = widenStringPropertyForNullOmission(propSchema as JsonRecord);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return schema;
|
||||
return { ...schema, properties: nextProperties };
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionForTools(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
if (isPlainObject(result.function) && "parameters" in result.function) {
|
||||
result.function = {
|
||||
...result.function,
|
||||
parameters: injectOptionalStringOmissionSentinel(result.function.parameters),
|
||||
};
|
||||
}
|
||||
if ("parameters" in result && !isPlainObject(result.function)) {
|
||||
result.parameters = injectOptionalStringOmissionSentinel(result.parameters);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeToolDescriptions(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => sanitizeToolDescription(tool));
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
injectOptionalEnumOmissionForTools,
|
||||
injectOptionalStringOmissionForTools,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
@@ -595,6 +596,12 @@ export function translateRequest(
|
||||
}
|
||||
|
||||
if (result.tools !== undefined) {
|
||||
// Plain-string omission must run before coerceToolSchemas() strips `default`,
|
||||
// so defaulted optional strings stay unsentinelled. Enum injection stays after
|
||||
// coercion to preserve the #7023 pipeline.
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
result.tools = injectOptionalStringOmissionForTools(result.tools);
|
||||
}
|
||||
result.tools = coerceToolSchemas(result.tools);
|
||||
result.tools = sanitizeToolDescriptions(result.tools);
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
|
||||
@@ -866,13 +866,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (!chunk) {
|
||||
// Iterate every still-open call needing schema-aware normalization, not just a
|
||||
// single one — multiple parallel calls can each be pending here if the stream
|
||||
// ends before their output_item.done arrives.
|
||||
// Iterate every still-open call with a buffered argument payload — argument
|
||||
// deltas are buffered for every tool, so an incomplete stream must flush every
|
||||
// buffered call, not only the historical uppercase Agent path.
|
||||
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
|
||||
if (state.toolCallByCallId instanceof Map) {
|
||||
for (const entry of state.toolCallByCallId.values()) {
|
||||
if (entry.needsNormalization && entry.argsBuffer) {
|
||||
if (entry.argsBuffer) {
|
||||
const toolSchema = state.toolSchemas?.get(entry.name);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
|
||||
pendingNormalized.push({
|
||||
|
||||
@@ -56,21 +56,35 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
|
||||
return allowlisted || (propSchema != null && !required.has(key));
|
||||
}
|
||||
|
||||
// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens
|
||||
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
|
||||
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
|
||||
// follows that idiom for a non-required, schema-declared property.
|
||||
function schemaTypeIncludes(type, wanted) {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function hasOmissionSentinel(propSchema) {
|
||||
if (!propSchema || typeof propSchema !== "object") return false;
|
||||
if (
|
||||
typeof propSchema.description !== "string" ||
|
||||
!propSchema.description.includes("null = omit this parameter")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
schemaTypeIncludes(propSchema.type, "null") ||
|
||||
(Array.isArray(propSchema.enum) && propSchema.enum.includes(null))
|
||||
);
|
||||
}
|
||||
|
||||
// #7023 — the request-side counterpart widens no-default optional properties to accept
|
||||
// `null`, meaning "omitted" (OpenAI's own nullable-union idiom for Responses-API strict
|
||||
// mode). Enums use injectOptionalEnumOmissionSentinel; plain strings use
|
||||
// injectOptionalStringOmissionSentinel. Drop the key when the model follows that idiom
|
||||
// for a non-required, schema-declared property, or when OmniRoute's marker is present
|
||||
// even after an upstream strictifies the field into `required`.
|
||||
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
|
||||
if (entry !== null) return false;
|
||||
if (toolName === "Agent") return true;
|
||||
if (propSchema == null) return false;
|
||||
const omissionSentinel =
|
||||
typeof propSchema === "object" &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
propSchema.enum.includes(null) &&
|
||||
typeof propSchema.description === "string" &&
|
||||
propSchema.description.includes("null = omit this parameter");
|
||||
return !required.has(key) || omissionSentinel;
|
||||
return !required.has(key) || hasOmissionSentinel(propSchema);
|
||||
}
|
||||
|
||||
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
|
||||
@@ -110,7 +124,11 @@ export function stripEmptyOptionalToolArgs(value, toolName, schema) {
|
||||
// supplied (schema-aware normalization is not restricted to the allowlist).
|
||||
// "Agent" also passes without a schema: isDroppableNullEntry drops its null
|
||||
// omission sentinels even when the strict schema snapshot is unavailable (#9423).
|
||||
if (!hasUsableSchema(schema) && !STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) && toolName !== "Agent") {
|
||||
if (
|
||||
!hasUsableSchema(schema) &&
|
||||
!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) &&
|
||||
toolName !== "Agent"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -75,76 +75,3 @@ omniroute mcp call <tool> [argsJson]
|
||||
```bash
|
||||
omniroute mcp scopes
|
||||
```
|
||||
|
||||
### `mcp tools`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tools
|
||||
```
|
||||
|
||||
### `mcp list`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--scope <s>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp list
|
||||
```
|
||||
|
||||
### `mcp info <name>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp info <name>
|
||||
```
|
||||
|
||||
### `mcp schema <name>`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--io <kind>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp schema <name>
|
||||
```
|
||||
|
||||
### `mcp audit`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp audit
|
||||
```
|
||||
|
||||
### `mcp tail`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--follow`
|
||||
- `--limit <n>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tail
|
||||
```
|
||||
|
||||
### `mcp stats`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--period <p>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp stats
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -439,13 +439,22 @@ type ProviderConnectionLike = {
|
||||
* whose stored `providerSpecificData.profileArn` matches the given ARN.
|
||||
* Returns null when profileArn is undefined/null or no match is found.
|
||||
*
|
||||
* #10815 hardened `findKiroConnectionByIdentity` to require an account-level
|
||||
* identifier (email or clientId) alongside a matching profileArn before
|
||||
* trusting the match — distinct Builder ID accounts (Google/GitHub social
|
||||
* login) can share the same CodeWhisperer profile ARN, and matching on ARN
|
||||
* alone let a second social login silently overwrite the first connection.
|
||||
* `email`/`clientId` here let a caller supply that account identifier; the
|
||||
* real `saveAndRespond()` call sites already do (see below).
|
||||
*
|
||||
* Exported for unit tests (#3615).
|
||||
*/
|
||||
export function findKiroConnectionByProfileArn(
|
||||
connections: ProviderConnectionLike[],
|
||||
profileArn: string | undefined
|
||||
profileArn: string | undefined,
|
||||
accountIdentity?: { email?: string | null; clientId?: string | null }
|
||||
): ProviderConnectionLike | null {
|
||||
return findKiroConnectionByIdentity(connections, { profileArn });
|
||||
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
|
||||
}
|
||||
|
||||
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +29,7 @@ import { canUpdateProviderApiKey } from "@/shared/providers/webSessionCredential
|
||||
import {
|
||||
refreshConnectionRateLimits,
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
} from "@/../open-sse/services/rateLimitManager";
|
||||
import {
|
||||
finalizeValidatedChatGptWebCodexSecrets,
|
||||
@@ -342,10 +343,18 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
// If rateLimitOverrides was included in the request, refresh the in-memory
|
||||
// rate limiter state so the change takes effect without a server restart.
|
||||
// Also ensure rate limit protection is active so the limiter is enforced.
|
||||
// Only (re)enable enforcement when rate limit protection is actually
|
||||
// persisted for this connection — this route never lets a caller flip
|
||||
// `rateLimitProtection` itself, so any drift here would silently start
|
||||
// queuing requests through Bottleneck for a connection whose DB row (and
|
||||
// the dashboard toggle reading it) both still say "off" (#11278).
|
||||
if (rateLimitOverrides !== undefined) {
|
||||
refreshConnectionRateLimits(id, updated?.rateLimitOverrides ?? null);
|
||||
enableRateLimitProtection(id);
|
||||
if (updated?.rateLimitProtection === true) {
|
||||
enableRateLimitProtection(id);
|
||||
} else {
|
||||
disableRateLimitProtection(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide sensitive fields
|
||||
|
||||
@@ -265,10 +265,6 @@ async function buildUnifiedModelsResponseCore(
|
||||
// try would let a crash here propagate as an unhandled rejection instead
|
||||
// (catalogCache.ts's in-flight coalescing does not fully consume rejections).
|
||||
const hiddenModelsByProvider = getHiddenModelsByProvider();
|
||||
const isModelHiddenBulk = (providerId: string, modelId: string): boolean => {
|
||||
const hiddenSet = hiddenModelsByProvider.get(providerId);
|
||||
return hiddenSet ? hiddenSet.has(modelId) : false;
|
||||
};
|
||||
let settings: Record<string, any> = {};
|
||||
try {
|
||||
settings = await getSettings();
|
||||
@@ -377,6 +373,35 @@ async function buildUnifiedModelsResponseCore(
|
||||
const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string =>
|
||||
providerIdToPrefix[providerId] || canonicalProviderId;
|
||||
|
||||
// #11300: the visibility toggle on a provider's dashboard page persists the
|
||||
// hidden-model row under whatever key the route's `[id]` param happened to be
|
||||
// (a node UUID, an alias like `cc`/`gh`/`cx`, or a canonical provider id) —
|
||||
// see `PATCH /api/provider-models`. The catalog loops below each key their own
|
||||
// lookup differently (raw connection provider, canonical id, or alias), so a
|
||||
// single-key lookup missed the override whenever the write key and the read key
|
||||
// diverged. Check every key a model could plausibly have been hidden under:
|
||||
// the raw key passed in, its resolved canonical provider id, that canonical id's
|
||||
// alias, and the compatible-provider-node prefix for either.
|
||||
const isModelHiddenBulk = (
|
||||
providerKey: string | null | undefined,
|
||||
modelId: string,
|
||||
canonicalProviderId?: string | null
|
||||
): boolean => {
|
||||
if (!providerKey || !modelId) return false;
|
||||
const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey);
|
||||
const alias =
|
||||
providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
|
||||
const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical];
|
||||
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter(
|
||||
(k): k is string => Boolean(k)
|
||||
);
|
||||
for (const key of keysToCheck) {
|
||||
const hiddenSet = hiddenModelsByProvider.get(key);
|
||||
if (hiddenSet?.has(modelId)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
await yieldCatalogBuildTurn();
|
||||
@@ -955,7 +980,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (!isModelSelectable(canonicalProviderId, model.id)) continue;
|
||||
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
if (isModelHiddenBulk(canonicalProviderId, model.id)) continue;
|
||||
if (isModelHiddenBulk(alias, model.id, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue;
|
||||
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing))
|
||||
continue;
|
||||
@@ -1018,7 +1043,15 @@ async function buildUnifiedModelsResponseCore(
|
||||
|
||||
for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) {
|
||||
if (!providerSupportsModel("codex", modelId)) continue;
|
||||
if (isModelHiddenBulk("codex", modelId)) continue;
|
||||
// #11300: a codex-native unprefixed model can also be hidden via the
|
||||
// `openai` provider page (codex runs on the openai-compatible connection)
|
||||
// or via the `cx` alias — check all three so a hide from any of them
|
||||
// suppresses the bare model id here.
|
||||
if (
|
||||
isModelHiddenBulk("codex", modelId) ||
|
||||
isModelHiddenBulk("openai", modelId)
|
||||
)
|
||||
continue;
|
||||
|
||||
const alias = providerIdToAlias.codex || "cx";
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
@@ -1079,7 +1112,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) {
|
||||
continue;
|
||||
}
|
||||
if (isModelHiddenBulk(providerId, sm.id)) continue;
|
||||
if (isModelHiddenBulk(providerId, sm.id, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, sm.id)) continue;
|
||||
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
|
||||
// `/v1/models`) return image/diffusion models with no modality info,
|
||||
@@ -1498,7 +1531,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, { ...model, id: modelId }))
|
||||
continue;
|
||||
if (model.isHidden === true) continue;
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to user-defined custom rows too.
|
||||
// Custom entries do not carry pricing, so shouldHidePaid() decides
|
||||
@@ -1682,7 +1715,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerKey, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to alias-backed rows too. Alias mappings
|
||||
// point at providerKey/modelId with no pricing, so shouldHidePaid()
|
||||
@@ -1756,7 +1789,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
for (const model of fallbackModels) {
|
||||
const modelId = typeof model.id === "string" ? model.id : null;
|
||||
if (!modelId) continue;
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to managed-fallback rows too. Compatible
|
||||
// provider fallbacks lack pricing; shouldHidePaid() decides via the
|
||||
|
||||
@@ -1267,6 +1267,7 @@
|
||||
"agentBridgeSubtitle": "Interceptar tráfego de agentes IDE",
|
||||
"trafficInspector": "Inspector de Tráfego",
|
||||
"trafficInspectorSubtitle": "Monitorar chamadas LLM + debugar tráfego HTTPS",
|
||||
"trafficInspectorPurpose": "Veja exatamente o que sua aplicação envia e recebe dos provedores de IA. Funciona com qualquer cliente compatível com OpenAI.",
|
||||
"cliCode": "CLI Code's",
|
||||
"cliCodeSubtitle": "Ferramentas de código que apontam para o OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1868,7 +1869,16 @@
|
||||
"directDownloadHint": "Ou baixe o formato do instalador respectivo diretamente:",
|
||||
"releaseNotes": "Notas de Lançamento",
|
||||
"readMore": "Leia Mais",
|
||||
"noAuthLabel": "Sem Autenticação"
|
||||
"noAuthLabel": "Sem Autenticação",
|
||||
"readinessEyebrow": "Prepare-se para rotear",
|
||||
"readinessTitle": "Envie sua primeira requisição",
|
||||
"readinessSubtitle": "Quatro pequenos passos. O OmniRoute verifica a prontidão conforme você avança.",
|
||||
"readinessStep1": "Conecte um provedor",
|
||||
"readinessStep2": "Configure a autenticação do endpoint",
|
||||
"readinessStep3": "Copie seu endpoint",
|
||||
"readinessStep4": "Envie uma requisição de teste",
|
||||
"readinessContinue": "Continuar configuração",
|
||||
"readinessDismiss": "Dispensar por agora"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Análises",
|
||||
@@ -6700,6 +6710,18 @@
|
||||
"sidebarVisibility": "Hide sidebar items",
|
||||
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter.",
|
||||
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when a...",
|
||||
"presetAll": "Tudo",
|
||||
"presetAllDesc": "Mostrar tudo",
|
||||
"presetEssentials": "Essenciais",
|
||||
"presetEssentialsDesc": "Caminho para iniciantes - Ferramentas avançadas continuam pesquisáveis",
|
||||
"presetMinimal": "Mínimo",
|
||||
"presetMinimalDesc": "Apenas páginas principais",
|
||||
"presetDeveloper": "Desenvolvedor",
|
||||
"presetDeveloperDesc": "Ferramentas de dev & proxy",
|
||||
"presetAdmin": "Admin",
|
||||
"presetAdminDesc": "Monitoramento & auditoria",
|
||||
"settingsSidebarTitle": "Personalização da Barra Lateral",
|
||||
"settingsSidebarDesc": "Escolha quais itens da barra lateral exibir. Essenciais mantém as ferramentas avançadas pesquisáveis.",
|
||||
"hideHealthLogs": "Ocultar Logs de Health Check",
|
||||
"hideHealthLogsDesc": "Quando ATIVADO, suprime mensagens [HealthCheck] no console do servidor",
|
||||
"themeAccent": "Cor do tema",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1267,6 +1267,7 @@
|
||||
"agentBridgeSubtitle": "Chặn lưu lượng agent IDE",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"trafficInspectorSubtitle": "Giám sát lệnh gọi LLM + gỡ lỗi mọi lưu lượng HTTPS",
|
||||
"trafficInspectorPurpose": "Xem chính xác những gì ứng dụng của bạn gửi đến và nhận từ các nhà cung cấp AI. Hoạt động với bất kỳ ứng dụng khách nào tương thích với OpenAI.",
|
||||
"cliCode": "CLI Code",
|
||||
"cliCodeSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1868,7 +1869,16 @@
|
||||
"directDownloadHint": "Hoặc tải trực tiếp định dạng trình cài đặt phù hợp:",
|
||||
"releaseNotes": "Ghi chú phát hành",
|
||||
"readMore": "Đọc thêm",
|
||||
"noAuthLabel": "Không xác thực"
|
||||
"noAuthLabel": "Không xác thực",
|
||||
"readinessEyebrow": "Chuẩn bị định tuyến",
|
||||
"readinessTitle": "Gửi yêu cầu đầu tiên của bạn",
|
||||
"readinessSubtitle": "Bốn bước nhỏ. OmniRoute kiểm tra mức độ sẵn sàng khi bạn thực hiện.",
|
||||
"readinessStep1": "Kết nối một nhà cung cấp",
|
||||
"readinessStep2": "Định cấu hình xác thực endpoint",
|
||||
"readinessStep3": "Sao chép endpoint của bạn",
|
||||
"readinessStep4": "Gửi một yêu cầu thử nghiệm",
|
||||
"readinessContinue": "Tiếp tục thiết lập",
|
||||
"readinessDismiss": "Bỏ qua lúc này"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Phân tích",
|
||||
@@ -6700,6 +6710,18 @@
|
||||
"sidebarVisibility": "Ẩn các mục trên thanh bên",
|
||||
"sidebarVisibilityDesc": "Ẩn bất kỳ mục điều hướng nào trên thanh bên để giảm bớt sự lộn xộn về mặt trực quan mà không vô hiệu hóa bất kỳ tính năng nào",
|
||||
"sidebarVisibilityHint": "Bất kỳ phần nào trên thanh bên sẽ tự động bị ẩn khi tất cả các mục bên trong nó đều bị ẩn",
|
||||
"presetAll": "Tất cả",
|
||||
"presetAllDesc": "Hiển thị mọi thứ",
|
||||
"presetEssentials": "Thiết yếu",
|
||||
"presetEssentialsDesc": "Lộ trình cho người mới bắt đầu - Công cụ nâng cao vẫn có thể tìm kiếm",
|
||||
"presetMinimal": "Tối giản",
|
||||
"presetMinimalDesc": "Chỉ các trang cốt lõi",
|
||||
"presetDeveloper": "Nhà phát triển",
|
||||
"presetDeveloperDesc": "Công cụ dev & proxy",
|
||||
"presetAdmin": "Quản trị",
|
||||
"presetAdminDesc": "Giám sát & kiểm toán",
|
||||
"settingsSidebarTitle": "Tùy chỉnh thanh bên",
|
||||
"settingsSidebarDesc": "Chọn các mục trên thanh bên sẽ hiển thị. Thiết yếu giữ cho các công cụ nâng cao vẫn có thể tìm kiếm.",
|
||||
"hideHealthLogs": "Ẩn nhật ký kiểm tra sức khỏe",
|
||||
"hideHealthLogsDesc": "Khi BẬT, sẽ chặn các thông báo [HealthCheck] trong bảng điều khiển máy chủ",
|
||||
"themeAccent": "Màu chủ đề",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -188,6 +188,32 @@ describe("injectMemory — edge cases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectMemory — Claude-family cache-safe splice gate (#11290)", () => {
|
||||
test("does not splice mid-array on anthropic when the last turn before the splice point is plain assistant text", () => {
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{ role: "assistant", content: "turn 1 answer" },
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
});
|
||||
const memories = [makeMemory("dark mode")];
|
||||
|
||||
const result = injectMemory(request, memories, "anthropic", { cacheSafe: true });
|
||||
|
||||
// The plain-text assistant turn must stay immediately followed by the final user
|
||||
// turn — no system message spliced between them (that shape is what Opus 5 rejects
|
||||
// with HTTP 400, #11290). Memory is merged into the leading system message instead.
|
||||
expect(result.messages).toHaveLength(4);
|
||||
expect(result.messages[0].role).toBe("system");
|
||||
expect(result.messages[0].content).toContain("Memory context: dark mode");
|
||||
expect(result.messages[0].content).toContain("SYSTEM PROMPT");
|
||||
expect(result.messages[2]).toEqual({ role: "assistant", content: "turn 1 answer" });
|
||||
expect(result.messages[3]).toEqual({ role: "user", content: "turn 2 question" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInjectMemory", () => {
|
||||
test("returns true when messages are present and enabled not set", () => {
|
||||
const request = makeRequest();
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
import { Memory } from "./types";
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
import {
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "../../shared/constants/providers";
|
||||
|
||||
const log = logger("MEMORY_INJECTION");
|
||||
|
||||
@@ -170,6 +174,43 @@ function injectSystemFirst(
|
||||
return { ...request, messages: [memorySystemMessage, ...messages] };
|
||||
}
|
||||
|
||||
/**
|
||||
* #11290: providers in the Claude family (direct Anthropic, and any
|
||||
* anthropic-compatible / Claude-Code-compatible passthrough connection) — the
|
||||
* ones affected by the stricter Opus 5 message-ordering validation described
|
||||
* below. Deliberately narrower than `systemMessageMustBeFirst()`'s strict-set:
|
||||
* this only gates the cache-safe mid-array splice, not the leading-system-message
|
||||
* requirement, so non-Claude providers keep the #3890 cache-hit optimization
|
||||
* unconditionally.
|
||||
*/
|
||||
function isClaudeFamilyProvider(provider: string | null | undefined): boolean {
|
||||
if (!provider) return false;
|
||||
const normalized = provider.toLowerCase().trim();
|
||||
return (
|
||||
normalized === "claude" ||
|
||||
normalized === "anthropic" ||
|
||||
isClaudeCodeCompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an assistant message's content ends in a server-side tool result
|
||||
* block (e.g. `web_search_tool_result`, `code_execution_tool_result`,
|
||||
* `mcp_tool_result` — any Anthropic content block whose type ends in
|
||||
* `_tool_result`, produced by a server-executed tool rather than a
|
||||
* client-executed one). `content` is typed as `string` on `ChatMessage` for
|
||||
* the common case, but the Claude-native wire shape carries an array of
|
||||
* content blocks — this only recognizes that richer shape.
|
||||
*/
|
||||
function endsWithServerToolResult(message: ChatMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") return false;
|
||||
const content = message.content as unknown;
|
||||
if (!Array.isArray(content) || content.length === 0) return false;
|
||||
const lastBlock = content[content.length - 1] as { type?: unknown } | null | undefined;
|
||||
return typeof lastBlock?.type === "string" && lastBlock.type.endsWith("_tool_result");
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a memory message at the #3890 cache-safe anchor (just before the last
|
||||
* user turn) when one exists, else prepend it. Shared by the system and user
|
||||
@@ -222,6 +263,24 @@ export function injectMemory(
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// #11290: Claude Opus 5 tightened server-side validation of the cache-safe
|
||||
// mid-array splice — a system message spliced right after a plain-text assistant
|
||||
// turn is rejected with HTTP 400 (the immediately preceding message must end in a
|
||||
// server-side tool result for a following system message to be accepted). Rather
|
||||
// than adding "claude"/"anthropic" outright to `systemMessageMustBeFirst()` (which
|
||||
// would revert the #3890 cache-hit optimization for every Claude request, including
|
||||
// the ones that work fine today), only fall back to the leading-system-message
|
||||
// placement for the specific requests where the turn right before the splice point
|
||||
// isn't a server tool result.
|
||||
if (
|
||||
supportsSystem &&
|
||||
cacheSafeIndex >= 0 &&
|
||||
isClaudeFamilyProvider(provider) &&
|
||||
!endsWithServerToolResult(messages[cacheSafeIndex - 1])
|
||||
) {
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// Strategy 1 (system): prepend before existing system messages, preserving the
|
||||
// caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user
|
||||
// message. Both honor the #3890 cache-safe anchor via placeMessage.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -499,6 +499,25 @@ export async function maybeClearRecoveredQuotaState(
|
||||
// the previous synthetic-cooldown guard.
|
||||
return connection;
|
||||
}
|
||||
} else if (
|
||||
connection.rateLimitedUntil &&
|
||||
new Date(connection.rateLimitedUntil).getTime() > Date.now()
|
||||
) {
|
||||
// Universal fallback guard for every lastErrorType other than
|
||||
// "quota_exhausted" (which gets the more precise per-window check above,
|
||||
// and may legitimately release early once the REAL window has reset even
|
||||
// while a synthetic rateLimitedUntil is still in the future). A future
|
||||
// rateLimitedUntil is a hard statement made by the 429/error handler that
|
||||
// persisted it (src/sse/services/auth.ts, src/app/api/providers/[id]/test/
|
||||
// route.ts) — no quota poll finding *some* usable window elsewhere should
|
||||
// be able to overrule it. Before this fix, ANY lastErrorType other than
|
||||
// "quota_exhausted" skipped straight to hasTransientState/
|
||||
// clearRecoveredProviderState() below with no rateLimitedUntil check at
|
||||
// all, so a multi-day cooldown (observed: 146h, Z.AI weekly quota) got
|
||||
// cleared on the very next quota sync a few minutes later — a
|
||||
// self-restart/burn loop that kept burning real upstream calls against a
|
||||
// known-exhausted connection (#11277).
|
||||
return connection;
|
||||
}
|
||||
|
||||
const hasTransientState =
|
||||
|
||||
@@ -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: {
|
||||
|
||||
15
tests/unit/11018-database-cache-docs.test.ts
Normal file
15
tests/unit/11018-database-cache-docs.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_DATABASE_SETTINGS } from "../../src/types/databaseSettings.ts";
|
||||
|
||||
const guide = readFileSync(new URL("../../docs/ops/DATABASE_GUIDE.md", import.meta.url), "utf8");
|
||||
|
||||
test("database guide keeps cache tuning aligned with runtime settings (#11018)", () => {
|
||||
const defaultCacheSize = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize;
|
||||
|
||||
assert.match(guide, new RegExp(`${defaultCacheSize.toLocaleString("en-US")} KiB`));
|
||||
assert.match(guide, /1 to\s+1,000,000 KiB/);
|
||||
assert.match(guide, /saving the setting applies it to the live database connection/);
|
||||
assert.match(guide, /restores the persisted value at startup/);
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -25,9 +25,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { updateSettings } = await import("../../src/lib/db/settings.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
|
||||
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
|
||||
);
|
||||
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
|
||||
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -58,6 +57,14 @@ const CLASSIFIER_BODY = {
|
||||
max_tokens: 8,
|
||||
};
|
||||
|
||||
// Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
|
||||
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
|
||||
const SEVERITY_CLASSIFIER_BODY = {
|
||||
...CLASSIFIER_BODY,
|
||||
stop_sequences: ["</severity>"],
|
||||
};
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
@@ -123,7 +130,12 @@ test("detector: always does NOT fire for normal chat without classifier marker (
|
||||
|
||||
test("detector: always fires when classifier marker is present", () => {
|
||||
const classifier = {
|
||||
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
|
||||
},
|
||||
],
|
||||
stop_sequences: ["</block>"],
|
||||
};
|
||||
assert.equal(
|
||||
@@ -133,6 +145,21 @@ test("detector: always fires when classifier marker is present", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
|
||||
|
||||
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
|
||||
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
|
||||
});
|
||||
|
||||
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
|
||||
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
|
||||
});
|
||||
|
||||
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
|
||||
assert.equal(detectClassifierFormat({}), "block");
|
||||
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
|
||||
});
|
||||
|
||||
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
|
||||
|
||||
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
|
||||
@@ -155,6 +182,16 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
|
||||
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
|
||||
});
|
||||
|
||||
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
|
||||
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
|
||||
assert.equal(built.success, true);
|
||||
const payload = (await built.response.json()) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(text, "<severity>0</severity>");
|
||||
});
|
||||
|
||||
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
|
||||
|
||||
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
|
||||
@@ -196,3 +233,44 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
|
||||
await updateSettings({ claudeClassifierCompat: "auto" });
|
||||
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalls++;
|
||||
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/messages",
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
|
||||
assert.equal(result.success, true, "handleChatCore must report success");
|
||||
const payload = (await (result as { response: Response }).response.json()) as {
|
||||
type: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
assert.equal(payload.type, "message");
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(
|
||||
text,
|
||||
"<severity>0</severity>",
|
||||
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,9 +2,15 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Repro for #6571 — REST-fallback path of `omniroute compression` (hit only when
|
||||
// /api/mcp/tools/call is not mounted, i.e. mcpCall()'s 404/501 branch) uses the
|
||||
// the MCP surface is not mounted, i.e. mcpCall()'s 404/501 branch) uses the
|
||||
// nonexistent `engine` field instead of the canonical `defaultMode` field, and
|
||||
// the table renderer prints "[object Object]" for nested object cells.
|
||||
//
|
||||
// #10960 moved the MCP transport from the never-mounted `/api/mcp/tools/call`
|
||||
// to the real Streamable HTTP endpoint `/api/mcp/stream` (mcpClient.mjs ->
|
||||
// callMcpEndpoint()). The REST-fallback trigger in these mocks must match
|
||||
// that endpoint, not the retired one, or mcpCallTool() throws on an
|
||||
// unmocked fetch instead of exercising the fallback path this test targets.
|
||||
|
||||
type MockResponse = Pick<Response, "ok" | "status" | "headers" | "json" | "text">;
|
||||
|
||||
@@ -45,7 +51,7 @@ test("restCompressionStatus (via runCompressionStatus REST fallback) should surf
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/mcp/stream")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/settings/compression")) {
|
||||
// Canonical server payload — NOTE: field is `defaultMode`, there is no `engine` key.
|
||||
// src/lib/db/compression.ts COMPRESSION_MODES / GET route just returns getCompressionSettings().
|
||||
@@ -85,7 +91,7 @@ test("restSetEngine (via runCompressionEngineSet REST fallback) should PUT `defa
|
||||
const putBodies: Record<string, unknown>[] = [];
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/mcp/stream")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/settings/compression") && init?.method === "PUT") {
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
||||
putBodies.push(body);
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* #11300 — Models toggled to "Hidden" on Provider pages are still listed in
|
||||
* `GET /v1/models`.
|
||||
*
|
||||
* `PATCH /api/provider-models?provider=<key>&modelId=<id>` persists the hidden
|
||||
* override under whatever key the dashboard's `[id]` route param happened to be
|
||||
* (an alias like `cc`/`gh`/`cx`, a canonical provider id, a compatible-provider
|
||||
* node UUID, or its configured prefix). `catalog.ts`'s `isModelHiddenBulk()` did
|
||||
* a single-key lookup, so a model stayed listed in `/v1/models` whenever the key
|
||||
* used to READ diverged from the key used to WRITE:
|
||||
*
|
||||
* - Static `PROVIDER_MODELS` loop checked only `canonicalProviderId` — a model
|
||||
* hidden under the alias (e.g. `cc` for Claude Code) never matched.
|
||||
* - The Codex-native-unprefixed loop checked only `"codex"` — a model hidden
|
||||
* via the `openai` provider page (codex often shares the openai-compatible
|
||||
* connection) never matched.
|
||||
* - The synced-discovery loop checked only the raw connection `providerId` —
|
||||
* a model hidden via the compatible-provider node's configured *prefix*
|
||||
* (the identifier the operator actually sees/uses on that node's page)
|
||||
* never matched.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11300-hidden-leak-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const { mergeModelCompatOverride } = await import("../../src/lib/localDb.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fetchCatalogIds(): Promise<string[]> {
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
assert.ok(Array.isArray(body.data), "response has data array");
|
||||
return body.data.map((m) => m.id);
|
||||
}
|
||||
|
||||
test("#11300 A: hiding a static model under its ALIAS (cc) excludes it under both cc/ and claude/ ids", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "claude",
|
||||
authType: "apikey",
|
||||
name: "claude-main",
|
||||
apiKey: "sk-test-11300a",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
// Sanity: before hiding, the model is advertised.
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes("cc/claude-opus-5"),
|
||||
`expected cc/claude-opus-5 to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}`
|
||||
);
|
||||
|
||||
// Operator hides the model on the provider page, whose route param is the
|
||||
// alias "cc" (not the canonical "claude").
|
||||
mergeModelCompatOverride("cc", "claude-opus-5", { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes("cc/claude-opus-5"),
|
||||
`#11300 RED: cc/claude-opus-5 hidden under alias "cc" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}`
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes("claude/claude-opus-5"),
|
||||
`#11300 RED: claude/claude-opus-5 hidden under alias "cc" must not appear either`
|
||||
);
|
||||
});
|
||||
|
||||
test("#11300 B: hiding a codex-native unprefixed model under \"openai\" excludes the bare model id", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
name: "codex-main",
|
||||
apiKey: "sk-test-11300b",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const nativeModelId = "gpt-5.6-sol";
|
||||
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes(nativeModelId),
|
||||
`expected bare "${nativeModelId}" to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}`
|
||||
);
|
||||
|
||||
// Hidden via the "openai" provider page (codex native models are commonly
|
||||
// reached through the shared openai-compatible connection).
|
||||
mergeModelCompatOverride("openai", nativeModelId, { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes(nativeModelId),
|
||||
`#11300 RED: bare "${nativeModelId}" hidden under "openai" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#11300 C: hiding a compatible-node synced model under its configured PREFIX excludes prefix/<model>", async () => {
|
||||
const NODE_ID = "openai-compatible-chat-11300-c0ffee00-0000-4000-8000-000000000000";
|
||||
const PREFIX = "deepseek-node-11300";
|
||||
|
||||
await providersDb.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: "openai-compatible",
|
||||
name: "Deepseek Node (11300 probe)",
|
||||
prefix: PREFIX,
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: NODE_ID,
|
||||
authType: "apikey",
|
||||
name: "deepseek-node-conn",
|
||||
apiKey: "sk-test-11300c",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
const modelId = "deepseek-v4-flash-0731";
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(NODE_ID, (connection as { id: string }).id, [
|
||||
{ id: modelId, name: "DeepSeek V4 Flash", source: "imported", supportedEndpoints: ["chat"] },
|
||||
]);
|
||||
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes(`${PREFIX}/${modelId}`),
|
||||
`expected ${PREFIX}/${modelId} to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}`
|
||||
);
|
||||
|
||||
// Operator hides the model via the node's page, which is keyed by the
|
||||
// configured prefix rather than the internal node UUID.
|
||||
mergeModelCompatOverride(PREFIX, modelId, { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes(`${PREFIX}/${modelId}`),
|
||||
`#11300 RED: ${PREFIX}/${modelId} hidden under prefix "${PREFIX}" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}`
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes(`${NODE_ID}/${modelId}`),
|
||||
`#11300 RED: ${NODE_ID}/${modelId} hidden under prefix "${PREFIX}" must not appear either`
|
||||
);
|
||||
});
|
||||
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");
|
||||
|
||||
|
||||
@@ -113,12 +113,18 @@ test("derived name is never empty or null", () => {
|
||||
|
||||
const FAKE_PROFILE_ARN = "arn:aws:iam::123456789012:user/sso-user";
|
||||
|
||||
const FAKE_CLIENT_ID = "client-abc";
|
||||
|
||||
const fakeConnectionWithArn = {
|
||||
id: "conn-abc",
|
||||
provider: "kiro",
|
||||
authType: "oauth",
|
||||
email: null,
|
||||
providerSpecificData: { profileArn: FAKE_PROFILE_ARN, region: "us-east-1" },
|
||||
providerSpecificData: {
|
||||
profileArn: FAKE_PROFILE_ARN,
|
||||
region: "us-east-1",
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConnectionNoArn = {
|
||||
@@ -129,13 +135,29 @@ const fakeConnectionNoArn = {
|
||||
providerSpecificData: { region: "us-east-1" },
|
||||
};
|
||||
|
||||
test("findKiroConnectionByProfileArn returns the matching connection", async () => {
|
||||
// The function should scan existing kiro connections and match by profileArn.
|
||||
test("findKiroConnectionByProfileArn returns the matching connection when an account identifier agrees", async () => {
|
||||
// #10815 — matching on profileArn alone is unsafe (distinct Builder ID
|
||||
// accounts can share a profile ARN), so the caller must also supply an
|
||||
// account-level identifier (email or clientId) that does not contradict
|
||||
// the stored connection, exactly like saveAndRespond()'s real call sites do.
|
||||
const result = await findKiroConnectionByProfileArn(
|
||||
[fakeConnectionWithArn, fakeConnectionNoArn],
|
||||
FAKE_PROFILE_ARN,
|
||||
{ clientId: FAKE_CLIENT_ID }
|
||||
);
|
||||
assert.deepEqual(result, fakeConnectionWithArn);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByProfileArn returns null for a profileArn-only match with no account identifier (#10815)", async () => {
|
||||
// Guards the #10815 fix: two different Builder ID accounts (Google/GitHub
|
||||
// social login) can share the same CodeWhisperer profile ARN, so trusting
|
||||
// an ARN match without any account identifier would let a second social
|
||||
// login silently overwrite the first connection.
|
||||
const result = await findKiroConnectionByProfileArn(
|
||||
[fakeConnectionWithArn, fakeConnectionNoArn],
|
||||
FAKE_PROFILE_ARN
|
||||
);
|
||||
assert.deepEqual(result, fakeConnectionWithArn);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByProfileArn returns null when no match exists", async () => {
|
||||
|
||||
@@ -130,13 +130,18 @@ test("a later, lower accepted-list does ratchet the cap down", () => {
|
||||
assert.equal((getLearnedReasoningEffort("acme", "model-x") as unknown as Set<string>).size, 2);
|
||||
});
|
||||
|
||||
test("clampToLearned medium→low when accepted is low,high,max", async () => {
|
||||
// #11295: nearest-tier semantics (smallest accepted >= demand) — unified with
|
||||
// the declared/static clamp. Was downgrade-only (greatest accepted <= demand,
|
||||
// medium→low) before #11295.
|
||||
test("clampToLearned medium→high when accepted is low,high,max (nearest-tier, #11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "low");
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
|
||||
});
|
||||
test("clampToLearned xhigh→high when accepted is low,high,max", async () => {
|
||||
// #11295: xhigh(rank 5) has no accepted tier >= it among {low,high,max}
|
||||
// (max=6 IS >= 5, so nearest-tier picks max) — was downgrade-only high before.
|
||||
test("clampToLearned xhigh→max when accepted is low,high,max (nearest-tier, #11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "high");
|
||||
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "max");
|
||||
});
|
||||
test("clampToLearned ultra→max when accepted is low,high,max", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
@@ -154,17 +159,25 @@ test("clampToLearned returns null when already accepted", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("low", new Set(["low", "high", "max"])), null);
|
||||
});
|
||||
test("clampToLearned returns null when effort < min (no upgrade)", async () => {
|
||||
// #11295: a sub-floor demand (below every accepted value) now maps to the
|
||||
// accepted floor instead of returning null. Pre-#11295 this returned null —
|
||||
// no clamp — so the too-low value passed straight through to the upstream,
|
||||
// which 400'd again on every subsequent request without ever learning a
|
||||
// lower floor.
|
||||
test("clampToLearned maps sub-floor demand to the accepted floor instead of null (#11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), null);
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
|
||||
});
|
||||
test("clampToLearned returns null for turbo (not in ORDER)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("turbo", new Set(["low", "high", "max"])), null);
|
||||
});
|
||||
test("clampToLearned returns null when effort is none but accepted is low,high,max", async () => {
|
||||
// #11295: none is below the learned floor {low,high,max} — nearest-tier maps
|
||||
// it to the floor (low) instead of returning null (no clamp, upstream 400s
|
||||
// again with no chance to ever learn a lower floor).
|
||||
test("clampToLearned maps none to the floor (low) when accepted is low,high,max (#11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), null);
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
|
||||
});
|
||||
test("recordLearned stores Set and getLearned returns Set", () => {
|
||||
const s = recordLearnedReasoningEffort("acme", "m1", ["low", "high", "max"]);
|
||||
|
||||
@@ -39,16 +39,20 @@ function multiTurn(): ChatRequest {
|
||||
|
||||
describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai");
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context"));
|
||||
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
|
||||
});
|
||||
|
||||
// Note: "openai" here stands in for any non-Claude-family provider that honors the
|
||||
// cache-safe mid-array splice (e.g. DashScope/Xiaomi MiMo via OpenAI-format
|
||||
// cache_control). Claude-family providers (anthropic/claude/CC-compatible) have their
|
||||
// own, narrower gate covered in the "#11290" describe block below.
|
||||
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
|
||||
const req = multiTurn();
|
||||
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
|
||||
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
|
||||
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
|
||||
@@ -75,8 +79,8 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
};
|
||||
const turn2 = multiTurn();
|
||||
|
||||
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
|
||||
const out1 = injectMemory(turn1, [mem("A")], "openai", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "openai", { cacheSafe: true });
|
||||
|
||||
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
|
||||
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
|
||||
@@ -103,3 +107,76 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
assert.equal(out.messages[1].content, "SYS");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #11290: Claude Opus 5 tightened server-side validation and started rejecting the
|
||||
* #3890 cache-safe mid-array splice with HTTP 400 whenever the assistant turn
|
||||
* immediately before the splice point is a plain-text turn (not a server-side tool
|
||||
* result). These tests pin the narrower, Claude-family-only gate added to
|
||||
* `injectMemory()`: fall back to leading-system-message placement in that specific
|
||||
* case, while still honoring the mid-array splice everywhere it is safe (non-Claude
|
||||
* providers unconditionally, and Claude providers whose preceding turn IS a server
|
||||
* tool result).
|
||||
*/
|
||||
describe("injectMemory cache-safe positioning — Claude-family server-tool-result gate (#11290)", () => {
|
||||
it("falls back to leading system-message placement for anthropic when the preceding assistant turn is plain text", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// No splice: the memory is merged into the leading system message instead of being
|
||||
// inserted right after the plain-text "turn 1 answer" assistant turn.
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
assert.equal(out.messages[1].content, "turn 1 question");
|
||||
assert.equal(out.messages[2].content, "turn 1 answer");
|
||||
assert.equal(out.messages[3].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("still splices mid-array for anthropic when the preceding assistant turn ends in a server tool result", () => {
|
||||
const req: ChatRequest = {
|
||||
model: "anthropic/claude-opus-5",
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} },
|
||||
{ type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [] },
|
||||
],
|
||||
} as unknown as ChatRequest["messages"][number],
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("applies the same fallback to a Claude-Code-compatible passthrough provider id", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic-compatible-cc-github-copilot", {
|
||||
cacheSafe: true,
|
||||
});
|
||||
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
});
|
||||
|
||||
it("does not gate non-Claude providers even without a server tool result", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
|
||||
// Unaffected by #11290: the mid-array splice is preserved for non-Claude providers.
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,13 @@ describe("injectMemory system-must-be-first (#6135)", () => {
|
||||
|
||||
it("regression: a NON-flagged provider keeps the existing cache-safe placement", () => {
|
||||
const req = multiTurn();
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
// #11290/#11303 added a Claude-family-specific reroute to injectSystemFirst()
|
||||
// for the mid-array splice (a system message right after a plain-text
|
||||
// assistant turn is rejected by Claude Opus 5), so "anthropic" no longer
|
||||
// exercises the plain cache-safe splice path this test targets. Use a
|
||||
// provider outside both the strict-system-first set AND the Claude family
|
||||
// to keep testing the original (still-current) cache-safe behavior.
|
||||
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
// Existing behavior: memory inserted just before the last user message (index 3).
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
|
||||
515
tests/unit/openai-responses-opencode-subagent-sessionid.test.ts
Normal file
515
tests/unit/openai-responses-opencode-subagent-sessionid.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// OpenCode `subagent.sessionID` is an optional plain string. Absence means "spawn a
|
||||
// new child". Responses/Codex strict mode forces every declared property into
|
||||
// `required`, so models invent fillers (`ses_`, `ses_new`, parent IDs) unless
|
||||
// OmniRoute offers `null` as the omission sentinel and strips it before the client
|
||||
// sees the tool call. This is the string counterpart of the #7023 enum sentinel.
|
||||
|
||||
const { injectOptionalStringOmissionSentinel, injectOptionalStringOmissionForTools } =
|
||||
await import("../../open-sse/translator/helpers/schemaCoercion.ts");
|
||||
const { stripEmptyOptionalToolArgs } =
|
||||
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { translateNonStreamingResponse } =
|
||||
await import("../../open-sse/handlers/responseTranslator.ts");
|
||||
const { extractToolSchemaMap } =
|
||||
await import("../../open-sse/translator/response/openai-responses/toolSchemas.ts");
|
||||
|
||||
const OMISSION_MARKER = "null = omit this parameter";
|
||||
|
||||
const OPENCODE_SUBAGENT_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: "string",
|
||||
description: "Continue a specific previous subagent conversation",
|
||||
},
|
||||
background: { type: "boolean" },
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
|
||||
const SUBAGENT_TOOL_CHAT = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "subagent",
|
||||
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
|
||||
},
|
||||
};
|
||||
|
||||
const SUBAGENT_TOOL_RESPONSES = {
|
||||
type: "function",
|
||||
name: "subagent",
|
||||
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
|
||||
};
|
||||
|
||||
const NATIVE_CUSTOM_TOOL = {
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
format: { type: "grammar", syntax: "lark", definition: "start: /.+/ " },
|
||||
};
|
||||
|
||||
function findTool(tools, name) {
|
||||
return tools.find((t) => t?.name === name || t?.function?.name === name);
|
||||
}
|
||||
|
||||
function toolParameters(tool) {
|
||||
return tool.parameters ?? tool.function?.parameters ?? tool.input_schema;
|
||||
}
|
||||
|
||||
function sessionIdSchema(params) {
|
||||
return params.properties.sessionID;
|
||||
}
|
||||
|
||||
function assertOmissionSentinel(prop) {
|
||||
assert.deepEqual(prop.type, ["string", "null"]);
|
||||
assert.match(
|
||||
prop.description,
|
||||
new RegExp(OMISSION_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
);
|
||||
assert.equal(Array.isArray(prop.enum), false);
|
||||
}
|
||||
|
||||
function collectArgs(chunks) {
|
||||
const list = Array.isArray(chunks) ? chunks : chunks ? [chunks] : [];
|
||||
let raw = "";
|
||||
let finishReason = null;
|
||||
for (const chunk of list) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
if (!choice) continue;
|
||||
const args = choice.delta?.tool_calls?.[0]?.function?.arguments;
|
||||
if (typeof args === "string") raw += args;
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||
}
|
||||
return { raw, finishReason, parsed: raw ? JSON.parse(raw) : null };
|
||||
}
|
||||
|
||||
test("RED: translateRequest OpenAI→Responses widens optional default-less sessionID", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
|
||||
};
|
||||
|
||||
const toResponses = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const tool = findTool(toResponses.tools, "subagent");
|
||||
const params = toolParameters(tool);
|
||||
assertOmissionSentinel(sessionIdSchema(params));
|
||||
assert.equal(params.properties.agent.type, "string");
|
||||
assert.equal(params.properties.background.type, "boolean");
|
||||
assert.deepEqual(params.required, ["agent", "description", "prompt"]);
|
||||
});
|
||||
|
||||
test("RED: same-format Responses applies string omission without flattening native tools", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
input: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_RESPONSES), structuredClone(NATIVE_CUSTOM_TOOL)],
|
||||
};
|
||||
|
||||
const sameFormat = translateRequest(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const functionTool = findTool(sameFormat.tools, "subagent");
|
||||
assertOmissionSentinel(sessionIdSchema(toolParameters(functionTool)));
|
||||
|
||||
const custom = sameFormat.tools.find((t) => t.name === "apply_patch");
|
||||
assert.equal(custom.type, "custom");
|
||||
assert.deepEqual(custom.format, NATIVE_CUSTOM_TOOL.format);
|
||||
assert.equal(custom.parameters, undefined);
|
||||
});
|
||||
|
||||
test("characterization: non-Responses target leaves sessionID unchanged", () => {
|
||||
const body = {
|
||||
model: "claude-3-7-sonnet",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
|
||||
};
|
||||
const toClaude = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"claude-3-7-sonnet",
|
||||
structuredClone(body)
|
||||
);
|
||||
const tool = toClaude.tools.find((t) => String(t.name).includes("subagent"));
|
||||
const schema = toolParameters(tool);
|
||||
assert.equal(schema.properties.sessionID.type, "string");
|
||||
assert.equal(
|
||||
String(schema.properties.sessionID.description || "").includes(OMISSION_MARKER),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("characterization: required string stays non-nullable; unmarked required null is kept", () => {
|
||||
const requiredOnly = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" } },
|
||||
required: ["sessionID"],
|
||||
});
|
||||
assert.equal(requiredOnly.properties.sessionID.type, "string");
|
||||
|
||||
const requiredNull = stripEmptyOptionalToolArgs(
|
||||
{ sessionID: null, agent: "explore" },
|
||||
"subagent",
|
||||
{
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" }, agent: { type: "string" } },
|
||||
required: ["sessionID", "agent"],
|
||||
}
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(requiredNull, "sessionID"), true);
|
||||
assert.equal(requiredNull.sessionID, null);
|
||||
});
|
||||
|
||||
test("characterization: optional string with default stays unsentinelled through translateRequest", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "subagent",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
sessionID: { type: "string", default: "" },
|
||||
},
|
||||
required: ["agent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const toResponses = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const params = toolParameters(findTool(toResponses.tools, "subagent"));
|
||||
assert.equal(params.properties.sessionID.type, "string");
|
||||
assert.equal(
|
||||
String(params.properties.sessionID.description || "").includes(OMISSION_MARKER),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("characterization: optional unmarked null is already stripped; real IDs are kept", () => {
|
||||
const optionalSchema = structuredClone(OPENCODE_SUBAGENT_SCHEMA);
|
||||
const stripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
},
|
||||
"subagent",
|
||||
optionalSchema
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
|
||||
|
||||
const kept = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "continue",
|
||||
prompt: "do work",
|
||||
sessionID: "ses_valid_child",
|
||||
},
|
||||
"subagent",
|
||||
optionalSchema
|
||||
);
|
||||
assert.equal(kept.sessionID, "ses_valid_child");
|
||||
});
|
||||
|
||||
test("RED: strictified required sessionID with OmniRoute marker still drops null", () => {
|
||||
const strictified = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
background: { type: "boolean" },
|
||||
},
|
||||
required: ["agent", "description", "prompt", "sessionID", "background"],
|
||||
};
|
||||
const stripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
},
|
||||
"subagent",
|
||||
strictified
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
|
||||
assert.equal(stripped.agent, "explore");
|
||||
});
|
||||
|
||||
test("characterization: empty sessionID is stripped; nested optional strings are not widened", () => {
|
||||
const emptyStripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: "",
|
||||
},
|
||||
"subagent",
|
||||
OPENCODE_SUBAGENT_SCHEMA
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(emptyStripped, "sessionID"), false);
|
||||
|
||||
const nested = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" } },
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
wrapper: {
|
||||
anyOf: [{ type: "object", properties: { sessionID: { type: "string" } } }],
|
||||
},
|
||||
$defs: {
|
||||
child: { type: "object", properties: { sessionID: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
});
|
||||
assert.equal(nested.properties.items.items.properties.sessionID.type, "string");
|
||||
assert.equal(nested.properties.wrapper.anyOf[0].properties.sessionID.type, "string");
|
||||
assert.equal(nested.properties.$defs.child.properties.sessionID.type, "string");
|
||||
|
||||
const mixedUnion = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: { value: { type: ["string", "number"] } },
|
||||
required: [],
|
||||
});
|
||||
assert.deepEqual(mixedUnion.properties.value.type, ["string", "number"]);
|
||||
});
|
||||
|
||||
test("characterization: string omission injection is idempotent", () => {
|
||||
const once = injectOptionalStringOmissionSentinel(structuredClone(OPENCODE_SUBAGENT_SCHEMA));
|
||||
const twice = injectOptionalStringOmissionSentinel(once);
|
||||
assertOmissionSentinel(sessionIdSchema(twice));
|
||||
assert.equal(twice.properties.sessionID.description.split(OMISSION_MARKER).length - 1, 1);
|
||||
const toolsOnce = injectOptionalStringOmissionForTools([
|
||||
structuredClone(SUBAGENT_TOOL_RESPONSES),
|
||||
]);
|
||||
const toolsTwice = injectOptionalStringOmissionForTools(toolsOnce);
|
||||
assertOmissionSentinel(toolParameters(toolsTwice[0]).properties.sessionID);
|
||||
});
|
||||
|
||||
test("characterization: fragmented deltas + output_item.done emit cleaned lowercase subagent args", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
const state = { toolSchemas: new Map([["subagent", schema]]) };
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const raw = JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
});
|
||||
const firstDelta = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw.slice(0, 40) },
|
||||
state
|
||||
);
|
||||
const secondDelta = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw.slice(40) },
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent", arguments: raw },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(firstDelta, null);
|
||||
assert.equal(secondDelta, null);
|
||||
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
|
||||
assert.equal(args.agent, "explore");
|
||||
assert.equal(args.prompt, "do work");
|
||||
});
|
||||
|
||||
test("RED: incomplete-stream flush emits cleaned lowercase subagent arguments", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
const state = { toolSchemas: new Map([["subagent", schema]]) };
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const raw = JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
});
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw },
|
||||
state
|
||||
);
|
||||
const flushed = openaiResponsesToOpenAIResponse(null, state);
|
||||
const { parsed, finishReason } = collectArgs(flushed);
|
||||
assert.ok(parsed);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(parsed, "sessionID"), false);
|
||||
assert.equal(parsed.agent, "explore");
|
||||
assert.equal(finishReason, "tool_calls");
|
||||
});
|
||||
|
||||
test("RED: non-streaming Responses translation drops sessionID null when given the schema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt", "sessionID"],
|
||||
};
|
||||
const responseBody = {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "subagent",
|
||||
arguments: JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
const translated = translateNonStreamingResponse(
|
||||
responseBody,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
null,
|
||||
new Map([["subagent", schema]])
|
||||
);
|
||||
const args = JSON.parse(translated.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
|
||||
assert.equal(args.agent, "explore");
|
||||
});
|
||||
|
||||
test("characterization: non-streaming keeps a real sessionID and legacy empty cleanup without schema", () => {
|
||||
const withId = translateNonStreamingResponse(
|
||||
{
|
||||
id: "resp_2",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_2",
|
||||
name: "subagent",
|
||||
arguments: JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "continue",
|
||||
prompt: "do work",
|
||||
sessionID: "ses_valid_child",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
const kept = JSON.parse(withId.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(kept.sessionID, "ses_valid_child");
|
||||
|
||||
const noSchema = translateNonStreamingResponse(
|
||||
{
|
||||
id: "resp_3",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_3",
|
||||
name: "other",
|
||||
arguments: { note: "", tags: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
const cleaned = JSON.parse(noSchema.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "tags"), false);
|
||||
});
|
||||
|
||||
test("characterization: extractToolSchemaMap still keys OpenCode subagent by lowercase name", () => {
|
||||
const map = extractToolSchemaMap({ tools: [structuredClone(SUBAGENT_TOOL_RESPONSES)] });
|
||||
assert.ok(map?.has("subagent"));
|
||||
assert.equal(map.get("subagent").properties.sessionID.type, "string");
|
||||
});
|
||||
@@ -34,6 +34,21 @@ test("every x-loopback-only path matches a LOCAL_ONLY prefix in routeGuard.ts",
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/openapi/spec documents its conditional management auth contract", () => {
|
||||
const operation = paths["/api/openapi/spec"]?.get;
|
||||
|
||||
assert.deepEqual(operation?.security, [{ ManagementSessionAuth: [] }]);
|
||||
assert.match(operation?.description ?? "", /When `requireLogin` is enabled/);
|
||||
assert.equal(
|
||||
operation?.responses?.["401"]?.$ref,
|
||||
"#/components/responses/ManagementAuthenticationRequired"
|
||||
);
|
||||
assert.equal(
|
||||
operation?.responses?.["403"]?.$ref,
|
||||
"#/components/responses/ManagementInvalidToken"
|
||||
);
|
||||
});
|
||||
|
||||
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
|
||||
@@ -83,7 +83,25 @@ test.after(async () => {
|
||||
});
|
||||
|
||||
test("successful GLM quota refresh clears transient rate-limit state", async () => {
|
||||
const connection = await createGlmConnectionWithTransientCooldown();
|
||||
// The cooldown must already be EXPIRED for a successful refresh to clear it
|
||||
// (#11277: a rateLimitedUntil still in the future is a hard statement from
|
||||
// the error handler that persisted it — no quota poll may overrule it,
|
||||
// regardless of lastErrorType). Before #11277's fix this test used a
|
||||
// still-future rateLimitedUntil and asserted it got cleared anyway, which
|
||||
// was the same defect class as the reported bug, just a shorter window.
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Recovery ${Date.now()}`,
|
||||
apiKey: "glm-test-key",
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastError: "rate limit exceeded",
|
||||
lastErrorType: "rate_limited",
|
||||
lastErrorSource: "executor",
|
||||
errorCode: 429,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
const connectionId = (connection as { id: string }).id;
|
||||
|
||||
await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => {
|
||||
@@ -101,6 +119,39 @@ test("successful GLM quota refresh clears transient rate-limit state", async ()
|
||||
assert.equal(updated.backoffLevel, 0, "backoffLevel should be reset to 0");
|
||||
});
|
||||
|
||||
test("a still-future rateLimitedUntil is not cleared by a successful quota refresh, regardless of lastErrorType (#11277)", async () => {
|
||||
const stillFutureRateLimitedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Still Cooling ${Date.now()}`,
|
||||
apiKey: "glm-test-key",
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: stillFutureRateLimitedUntil,
|
||||
lastError: "rate limit exceeded",
|
||||
lastErrorType: "rate_limited",
|
||||
lastErrorSource: "executor",
|
||||
errorCode: 429,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
const connectionId = (connection as { id: string }).id;
|
||||
|
||||
await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => {
|
||||
await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual");
|
||||
});
|
||||
|
||||
const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(
|
||||
updated.testStatus,
|
||||
"unavailable",
|
||||
"an active cooldown must stay locked even though the quota fetch succeeded"
|
||||
);
|
||||
assert.equal(updated.rateLimitedUntil, stillFutureRateLimitedUntil);
|
||||
});
|
||||
|
||||
async function createGlmConnectionWithStatus(status: string) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
@@ -334,6 +385,52 @@ test("Claude subscription quota still exhausted keeps the connection locked (no
|
||||
assert.equal(after.rateLimitedUntil, syntheticRateLimitedUntil);
|
||||
});
|
||||
|
||||
test("rate_limit_exceeded cooldown is not cleared early by an unrelated quota window looking usable (#11277)", async () => {
|
||||
// Reproduces #11277: a connection-scoped cooldown persisted with
|
||||
// lastErrorType "rate_limit_exceeded" (RateLimitReason.RATE_LIMIT_EXCEEDED)
|
||||
// and a long rateLimitedUntil (derived from an upstream reset hint — the
|
||||
// reported production case was ~146h) must NOT be cleared just because the
|
||||
// next scheduled quota sync reports hasUsableQuota()===true from some
|
||||
// unrelated window. Before the fix, only lastErrorType==="quota_exhausted"
|
||||
// reached the rateLimitedUntil guard, so every other reason (including
|
||||
// rate_limit_exceeded) skipped straight to clearRecoveredProviderState(),
|
||||
// producing a self-restart/burn loop on a multi-day cooldown.
|
||||
const farFutureRateLimitedUntil = new Date(Date.now() + 146 * 60 * 60 * 1000).toISOString();
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "opencode",
|
||||
authType: "apikey",
|
||||
name: `OpenCode RateLimitExceeded ${Date.now()}`,
|
||||
apiKey: "opencode-test-key",
|
||||
testStatus: "unavailable",
|
||||
isActive: true,
|
||||
lastError: "Account quota exhausted (opencode)",
|
||||
lastErrorType: "rate_limit_exceeded",
|
||||
errorCode: 429,
|
||||
rateLimitedUntil: farFutureRateLimitedUntil,
|
||||
backoffLevel: 1,
|
||||
});
|
||||
const connectionId = (created as { id: string }).id;
|
||||
const connection = await providersDb.getProviderConnectionById(connectionId);
|
||||
|
||||
// No `quotas` object at all (degraded/partial fetch shape) — this is the
|
||||
// exact shape that, pre-fix, fell straight through to hasTransientState
|
||||
// and cleared the cooldown for any lastErrorType other than quota_exhausted.
|
||||
const result = await providerLimits.maybeClearRecoveredQuotaState(connection, {
|
||||
quotas: { unrelated: { unlimited: true } },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.testStatus,
|
||||
"unavailable",
|
||||
"an active rate_limit_exceeded cooldown must stay locked"
|
||||
);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connectionId);
|
||||
assert.equal(after.testStatus, "unavailable");
|
||||
assert.equal(after.lastErrorType, "rate_limit_exceeded");
|
||||
assert.equal(after.rateLimitedUntil, farFutureRateLimitedUntil);
|
||||
});
|
||||
|
||||
test("CAS primitive clears when expected state matches", async () => {
|
||||
const created = await createGlmConnectionWithTransientCooldown();
|
||||
const connectionId = (created as { id: string }).id;
|
||||
|
||||
139
tests/unit/provider-patch-ratelimit-protection-11278.test.ts
Normal file
139
tests/unit/provider-patch-ratelimit-protection-11278.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Regression guard for #11278 — PATCH/PUT /api/providers/[id] silently enabled
|
||||
// runtime rate-limit protection (Bottleneck queuing) for ANY connection whose
|
||||
// request body included the `rateLimitOverrides` key, even `null`, regardless
|
||||
// of whether `rate_limit_protection` was actually persisted as on for that
|
||||
// connection in the DB.
|
||||
//
|
||||
// Root cause: src/app/api/providers/[id]/route.ts unconditionally called
|
||||
// enableRateLimitProtection(id) whenever `rateLimitOverrides !== undefined`
|
||||
// in the validated body. `EditConnectionModal.tsx` sends `rateLimitOverrides`
|
||||
// on every save regardless of whether the operator touched that section, so
|
||||
// saving ANY connection silently started queuing its requests through
|
||||
// Bottleneck — with the DB (`rate_limit_protection` column) and the dashboard
|
||||
// toggle both still showing the feature as off.
|
||||
//
|
||||
// Fix: only (re)enable the in-memory limiter when the persisted connection
|
||||
// (`updated.rateLimitProtection`, mapped from the DB row) is actually `true`;
|
||||
// otherwise explicitly disable it so runtime state can't drift ahead of the
|
||||
// DB. `rateLimitProtection` is never itself part of updateProviderConnectionSchema,
|
||||
// so this route can only read it from the persisted row — never set it.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11278-ratelimit-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.APP_LOG_TO_FILE = "false";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-11278-ratelimit";
|
||||
process.env.INITIAL_PASSWORD = "admin-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createProviderConnection, getProviderConnectionById } =
|
||||
await import("../../src/lib/db/providers.ts");
|
||||
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
|
||||
const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createConnection(rateLimitProtection: boolean) {
|
||||
return createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "OpenAI key",
|
||||
apiKey: "sk-test-key-value",
|
||||
priority: 1,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
rateLimitProtection,
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"PUT /api/providers/[id] does NOT enable rate-limit protection just because " +
|
||||
"rateLimitOverrides is present, when protection is off in the DB (#11278 RED->GREEN)",
|
||||
async () => {
|
||||
const connection = (await createConnection(false)) as Record<string, unknown>;
|
||||
assert.equal(connection.rateLimitProtection, false);
|
||||
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), false);
|
||||
|
||||
// Mirrors EditConnectionModal.tsx's handleSubmit(): it always sends
|
||||
// `rateLimitOverrides` on every save, even when the operator never
|
||||
// touched that section of the form.
|
||||
const payload = {
|
||||
name: connection.name,
|
||||
priority: connection.priority,
|
||||
rateLimitOverrides: null,
|
||||
};
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
`http://localhost/api/providers/${connection.id}`,
|
||||
{ method: "PUT", body: payload }
|
||||
);
|
||||
const response = await providerByIdRoute.PUT(request, {
|
||||
params: Promise.resolve({ id: connection.id as string }),
|
||||
});
|
||||
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
|
||||
|
||||
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(
|
||||
persisted.rateLimitProtection,
|
||||
false,
|
||||
"DB row must still show protection off — this route never sets rateLimitProtection"
|
||||
);
|
||||
assert.equal(
|
||||
rateLimitManager.isRateLimitEnabled(connection.id as string),
|
||||
false,
|
||||
"in-memory limiter must not silently diverge from the persisted DB state"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"PUT /api/providers/[id] keeps rate-limit protection ENABLED when it is " +
|
||||
"actually persisted as on in the DB",
|
||||
async () => {
|
||||
const connection = (await createConnection(true)) as Record<string, unknown>;
|
||||
assert.equal(connection.rateLimitProtection, true);
|
||||
|
||||
const payload = {
|
||||
name: connection.name,
|
||||
priority: connection.priority,
|
||||
rateLimitOverrides: { rpm: 30 },
|
||||
};
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
`http://localhost/api/providers/${connection.id}`,
|
||||
{ method: "PUT", body: payload }
|
||||
);
|
||||
const response = await providerByIdRoute.PUT(request, {
|
||||
params: Promise.resolve({ id: connection.id as string }),
|
||||
});
|
||||
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
|
||||
|
||||
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(persisted.rateLimitProtection, true);
|
||||
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), true);
|
||||
}
|
||||
);
|
||||
@@ -108,7 +108,7 @@ test("a second request for the same provider+model sends the learned value on th
|
||||
}
|
||||
});
|
||||
|
||||
test("400 please use low, high, or max clamps and retries once", async () => {
|
||||
test("400 please use low, high, or max clamps and retries once (nearest-tier: medium -> high, #11295)", async () => {
|
||||
const executor = new SimpleExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const capturedBodies: Record<string, unknown>[] = [];
|
||||
@@ -140,7 +140,10 @@ test("400 please use low, high, or max clamps and retries once", async () => {
|
||||
});
|
||||
assert.equal(capturedBodies.length, 2);
|
||||
assert.equal(capturedBodies[0].reasoning_effort, "medium");
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "low");
|
||||
// #11295: nearest-tier — smallest accepted >= demand — maps medium(3) to
|
||||
// high(4), the smallest accepted rank at or above it (was "low" under the
|
||||
// old downgrade-only direction).
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "high");
|
||||
const learned = getLearnedReasoningEffort("openai-compatible-chat-eaff6869", "x-preview-f-free") as unknown as Set<string>;
|
||||
assert.ok(learned instanceof Set);
|
||||
assert.ok(learned.has("low"));
|
||||
@@ -190,7 +193,7 @@ test("400 please use low, medium with ultra retries to medium", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("no-op clamp does not retry: learned {high,max} with low request stays single-fetch", async () => {
|
||||
test("sub-floor clamp now retries: learned {high,max} with low request clamps up to high (#11295)", async () => {
|
||||
const executor = new SimpleExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const capturedBodies: Record<string, unknown>[] = [];
|
||||
@@ -214,17 +217,20 @@ test("no-op clamp does not retry: learned {high,max} with low request stays sing
|
||||
};
|
||||
|
||||
try {
|
||||
// low is below the learned minimum {high,max}: downgrade-only passthrough,
|
||||
// sanitizer leaves the body unchanged -> no identical-body retry.
|
||||
// #11295: low is below the learned minimum {high,max}. Pre-#11295 this was
|
||||
// a downgrade-only passthrough (no clamp, no retry, upstream stayed 400
|
||||
// forever). Nearest-tier now clamps up to the accepted floor (high) and
|
||||
// retries once, succeeding.
|
||||
const result = await executor.execute({
|
||||
model: "x-preview-f-free-3",
|
||||
body: { reasoning_effort: "low" },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
});
|
||||
assert.equal(capturedBodies.length, 1);
|
||||
assert.equal(capturedBodies.length, 2);
|
||||
assert.equal(capturedBodies[0].reasoning_effort, "low");
|
||||
assert.equal(result.response.status, 400);
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "high");
|
||||
assert.equal(result.response.status, 200);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// #11295 — the learned clamp (reactive, from upstream 4xx) and the declared
|
||||
// clamp (static registry `supportedThinkingEfforts`) used to disagree on
|
||||
// direction for the identical accepted set {low,high,max}: the learned path
|
||||
// was downgrade-only (medium -> low) while the declared path was already
|
||||
// nearest-tier (medium -> high). Same inputs, opposite outputs, depending only
|
||||
// on whether the model happened to have a static registry entry. This test
|
||||
// proves the two paths now agree, and that a request below the learned floor
|
||||
// (previously silently passed through unmapped, returning null from
|
||||
// clampToLearned) is now mapped up to the nearest accepted tier instead.
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { clampToLearned } from "../../open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
import { sanitizeReasoningEffortForProvider } from "../../open-sse/executors/base/reasoningEffort.ts";
|
||||
import {
|
||||
recordLearnedReasoningEffort,
|
||||
__test_resetLearnedReasoningEffortCaps,
|
||||
} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
__test_resetLearnedReasoningEffortCaps();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
__test_resetLearnedReasoningEffortCaps();
|
||||
});
|
||||
|
||||
test("clampToLearned: nearest-tier medium -> high when accepted is {low,high,max} (was low pre-#11295)", () => {
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider maps medium identically for a LEARNED-only model and a DECLARED model with the same {low,high,max} accepted set", () => {
|
||||
// Learned side: a custom OpenAI-compatible connection that has no static
|
||||
// registry entry — the only source of truth is the reactively-learned set.
|
||||
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner", [
|
||||
"low",
|
||||
"high",
|
||||
"max",
|
||||
]);
|
||||
const learnedResult = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium" },
|
||||
"acme-oai-compatible",
|
||||
"custom-reasoner"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// Declared side: opencode-go/ox-alpha-free, whose registry entry declares
|
||||
// supportedThinkingEfforts: ["low", "high", "max"] (see reasoningEffort.ts
|
||||
// comment referencing the Console Go 400 case).
|
||||
const declaredResult = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium" },
|
||||
"opencode-go",
|
||||
"ox-alpha-free"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
assert.equal(learnedResult.reasoning_effort, "high");
|
||||
assert.equal(declaredResult.reasoning_effort, "high");
|
||||
assert.equal(learnedResult.reasoning_effort, declaredResult.reasoning_effort);
|
||||
});
|
||||
|
||||
test("sub-floor request (none) on a learned-only model with floor {low,high,max} maps to low, not a pass-through null-clamp", () => {
|
||||
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner-2", [
|
||||
"low",
|
||||
"high",
|
||||
"max",
|
||||
]);
|
||||
const result = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "none" },
|
||||
"acme-oai-compatible",
|
||||
"custom-reasoner-2"
|
||||
) as Record<string, unknown>;
|
||||
assert.equal(result.reasoning_effort, "low");
|
||||
});
|
||||
|
||||
test("clampToLearned: sub-floor demand (none) below accepted {low,high,max} maps to the accepted floor (low), not null", () => {
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
|
||||
});
|
||||
|
||||
test("clampToLearned: sub-floor demand (low) below accepted {high,max} maps to the accepted floor (high), not null", () => {
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
|
||||
});
|
||||
@@ -90,23 +90,25 @@ test("deepseek's non-ordinal max<->xhigh translation is untouched by the learned
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
});
|
||||
|
||||
test("proactive clamp: medium→low for learned {low,high,max}", () => {
|
||||
// #11295: nearest-tier — smallest accepted >= demand — replaces the old
|
||||
// downgrade-only (greatest accepted <= demand) direction.
|
||||
test("proactive clamp: medium→high for learned {low,high,max} (nearest-tier, #11295)", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free", ["low", "high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium", model: "x-preview-f-free" },
|
||||
"opencode-zen-direct",
|
||||
"x-preview-f-free"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "low");
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
});
|
||||
test("proactive clamp: xhigh→high for learned {low,high,max}", () => {
|
||||
test("proactive clamp: xhigh→max for learned {low,high,max} (nearest-tier, #11295)", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-2", ["low", "high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "xhigh", model: "x-preview-f-free-2" },
|
||||
"opencode-zen-direct",
|
||||
"x-preview-f-free-2"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
assert.equal(out.reasoning_effort, "max");
|
||||
});
|
||||
test("proactive clamp: ultra→max for learned {low,high,max}", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-3", ["low", "high", "max"]);
|
||||
@@ -135,14 +137,16 @@ test("proactive clamp: high→medium for learned {low,medium}", () => {
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "medium");
|
||||
});
|
||||
test("no upgrade: low stays low for learned {high,max}", () => {
|
||||
// #11295: sub-floor demand (low, below the learned floor {high,max}) now
|
||||
// clamps up to the floor instead of passing through unchanged.
|
||||
test("sub-floor clamp: low→high for learned {high,max} (#11295)", () => {
|
||||
recordLearnedReasoningEffort("acme", "m3", ["high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "low", model: "m3" },
|
||||
"acme",
|
||||
"m3"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "low");
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
});
|
||||
test("custom model ultra→medium for learned {low,medium}", () => {
|
||||
recordLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct-2", ["low", "medium"]);
|
||||
|
||||
@@ -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