mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
Compare commits
55 Commits
fix/audio-
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c3a0db121 | ||
|
|
91de8b3f82 | ||
|
|
e911807066 | ||
|
|
ee675a233c | ||
|
|
0dbc44121f | ||
|
|
0f73d4e432 | ||
|
|
ec91f760c6 | ||
|
|
8ede1cc801 | ||
|
|
266e39d36d | ||
|
|
05b1311884 | ||
|
|
6143da70d1 | ||
|
|
9a4cca4bc2 | ||
|
|
fa923d974e | ||
|
|
081f482680 | ||
|
|
62fd7f7c20 | ||
|
|
4ff9f0df0a | ||
|
|
767c01d195 | ||
|
|
2355f7beb3 | ||
|
|
ce4abd7ef4 | ||
|
|
bf71656637 | ||
|
|
d2fd88dfbc | ||
|
|
568888d7f1 | ||
|
|
dc185e5aab | ||
|
|
9a3f550d88 | ||
|
|
de32d5ae58 | ||
|
|
4e1d21f756 | ||
|
|
f5629d2166 | ||
|
|
06f41cda63 | ||
|
|
a2e5bd1dfc | ||
|
|
fbcd57db56 | ||
|
|
9e86a738ed | ||
|
|
99d19f8f35 | ||
|
|
ef5703c5e9 | ||
|
|
5d873e42d7 | ||
|
|
48124fca5a | ||
|
|
397ec88751 | ||
|
|
7f2d75d6d5 | ||
|
|
1a8d38655d | ||
|
|
7366bb6c3a | ||
|
|
c481ee3312 | ||
|
|
242cc5e797 | ||
|
|
d2dd39ea78 | ||
|
|
558412c9bb | ||
|
|
3ee5c1b4ae | ||
|
|
4a7e8ddc16 | ||
|
|
2c720f1fa7 | ||
|
|
8718d2b62f | ||
|
|
92a27f268a | ||
|
|
d925f6bf73 | ||
|
|
2f264d96dc | ||
|
|
c9daf99e37 | ||
|
|
4bda22583e | ||
|
|
ae54c6221c | ||
|
|
0a72988bde | ||
|
|
32da2a1afe |
41
.env.example
41
.env.example
@@ -1232,6 +1232,13 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
|
||||
|
||||
# ── Provider probe (credential validation / model discovery) ──
|
||||
# Timeout in ms for provider validationRead and modelsProbe presets.
|
||||
# Default: 8000 (was 5000). Raise it if slow endpoints (Cerebras, Cloudflare AI, Groq)
|
||||
# cause flapping between active/error in the dashboard.
|
||||
# Used by: src/shared/network/safeOutboundFetch.ts — centralized timeout resolution.
|
||||
# OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS=8000
|
||||
|
||||
# ── Proxy/relay fetch (connection pooling, #9158) ──
|
||||
# Used by: open-sse/utils/proxyFetch.ts.
|
||||
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
|
||||
@@ -1324,6 +1331,28 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
|
||||
|
||||
# ── Provider-level circuit breaker thresholds and cooldowns ──
|
||||
# Used by: open-sse/config/constants.ts (PROVIDER_PROFILES → accountFallback).
|
||||
# These control the provider-level fuse (entire provider cooldown after repeated
|
||||
# failures) — distinct from the per-key breaker above. Defaults match the
|
||||
# historical PROVIDER_PROFILES values. Raise to tolerate transient upstream
|
||||
# sheds without blacklisting the provider; lower to fail over faster.
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD=10
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS=900000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS=300000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD=5
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER=8
|
||||
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT=2
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD=15
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS=1800000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS=600000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD=7
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER=4
|
||||
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT=3
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD=2
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS=300000
|
||||
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS=60000
|
||||
|
||||
# ── Context-cache pin health gate ──
|
||||
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
|
||||
# provider that is durably unhealthy, the pin is dropped to allow failover.
|
||||
@@ -1449,6 +1478,11 @@ APP_LOG_TO_FILE=true
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
|
||||
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
|
||||
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
|
||||
# {_truncated, messageCount, ...} summary instead of the full clone
|
||||
# (default: 1024 KB / 1MB). Raise this if the dashboard's "Full
|
||||
# Conversation" transcript panel shows a placeholder instead of the
|
||||
# actual messages for long agentic conversations.
|
||||
|
||||
# Maximum rows in the proxy_logs SQLite table.
|
||||
# Default: 100000
|
||||
@@ -2350,7 +2384,8 @@ INSPECTOR_INTERNAL_INGEST_TOKEN=
|
||||
# unset): path to a file whose trimmed content is the token.
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
|
||||
# Quota Sharing (Group B — planos 16+22)
|
||||
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# sqlite | redis
|
||||
QUOTA_STORE_DRIVER=sqlite
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
@@ -2626,10 +2661,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
|
||||
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
|
||||
|
||||
# Maximum request/response body size before chat-log summarization, in KiB.
|
||||
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
|
||||
# CHAT_LOG_MAX_BODY_KB=1024
|
||||
|
||||
# Adobe Firefly browser renewal and durable session cache (enabled by default).
|
||||
# Used by: open-sse/services/adobeFireflySession.ts.
|
||||
# ADOBE_FIREFLY_BROWSER_REFRESH=1
|
||||
|
||||
61
.github/workflows/ci.yml
vendored
61
.github/workflows/ci.yml
vendored
@@ -1256,6 +1256,63 @@ jobs:
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
|
||||
# drive it over HTTP; neither needs provider credentials. They were documented in
|
||||
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
|
||||
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
|
||||
# positional filter) — so nothing had executed them for as long as that was true.
|
||||
test-ecosystem:
|
||||
name: Ecosystem E2E (live server)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# needs: changes (not build) — the runner boots its own dev server.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:ecosystem
|
||||
|
||||
test-protocols-e2e:
|
||||
name: Protocol Clients E2E (live server, advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
|
||||
# real discrepancy that had been invisible while it could not run: GET
|
||||
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
|
||||
# is a pre-existing contract question, not a defect introduced by wiring the job
|
||||
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
|
||||
# continue-on-error) the moment #10049 lands.
|
||||
continue-on-error: true
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:protocols:e2e
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1278,6 +1335,8 @@ jobs:
|
||||
- test-e2e
|
||||
- test-integration
|
||||
- test-security
|
||||
- test-ecosystem
|
||||
- test-protocols-e2e
|
||||
steps:
|
||||
- name: Download i18n results
|
||||
continue-on-error: true
|
||||
@@ -1358,6 +1417,8 @@ jobs:
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -57,7 +57,12 @@ import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@ope
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { z } from "zod";
|
||||
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
|
||||
import {
|
||||
createLogger,
|
||||
logger as _logger,
|
||||
type Logger as _Logger,
|
||||
type LogLevel as _LogLevel,
|
||||
} from "./logger.js";
|
||||
import {
|
||||
PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR,
|
||||
shortProviderLabel as _shortProviderLabel,
|
||||
@@ -717,6 +722,7 @@ export async function forceSyncOmniRouteModels(args: {
|
||||
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
|
||||
providersFetcher?: OmniRouteProvidersFetcher;
|
||||
now?: () => number;
|
||||
logger?: _Logger;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
count: number;
|
||||
@@ -737,6 +743,11 @@ export async function forceSyncOmniRouteModels(args: {
|
||||
const compressionMetaFetcher =
|
||||
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
|
||||
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
|
||||
const logger =
|
||||
args.logger ??
|
||||
createLogger(
|
||||
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
|
||||
);
|
||||
const features = resolved.features ?? {};
|
||||
const wantCombos = features.combos !== false;
|
||||
const wantAutoCombos = features.autoCombos !== false;
|
||||
@@ -847,8 +858,8 @@ export async function forceSyncOmniRouteModels(args: {
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
|
||||
logger.info(
|
||||
`force sync ok providerId=${resolved.providerId} ` +
|
||||
`models=${rawModels.length} combos=${rawCombos.length} ` +
|
||||
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`
|
||||
);
|
||||
@@ -880,6 +891,7 @@ export async function forceSyncOmniRouteModels(args: {
|
||||
export function createOmniRouteSyncModelsTool(args: {
|
||||
resolved: ResolvedOmniRoutePluginOptions;
|
||||
cache: OmniRouteFetchCache;
|
||||
logger?: _Logger;
|
||||
}): ReturnType<typeof tool> {
|
||||
const { resolved, cache } = args;
|
||||
return tool({
|
||||
@@ -893,7 +905,7 @@ export function createOmniRouteSyncModelsTool(args: {
|
||||
.describe("Optional reason for the sync (logging only)"),
|
||||
},
|
||||
async execute(toolArgs) {
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache });
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache, logger: args.logger });
|
||||
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
|
||||
if (!result.ok) {
|
||||
return {
|
||||
@@ -932,10 +944,16 @@ export function startOmniRouteAutoSync(args: {
|
||||
resolved: ResolvedOmniRoutePluginOptions;
|
||||
cache: OmniRouteFetchCache;
|
||||
intervalMs?: number;
|
||||
logger?: _Logger;
|
||||
}): () => void {
|
||||
const resolved = args.resolved;
|
||||
const cache = args.cache;
|
||||
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
|
||||
const logger =
|
||||
args.logger ??
|
||||
createLogger(
|
||||
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
|
||||
);
|
||||
if (!intervalMs || intervalMs <= 0) {
|
||||
return () => {};
|
||||
}
|
||||
@@ -948,11 +966,9 @@ export function startOmniRouteAutoSync(args: {
|
||||
if (stopped) return;
|
||||
if (inFlight) return;
|
||||
inFlight = (async () => {
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache });
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache, logger });
|
||||
if (!result.ok) {
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`
|
||||
);
|
||||
logger.error(`auto-sync failed providerId=${resolved.providerId}: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
if (lastCount === undefined) {
|
||||
@@ -960,15 +976,15 @@ export function startOmniRouteAutoSync(args: {
|
||||
return;
|
||||
}
|
||||
if (result.count !== lastCount) {
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync catalog size changed ${lastCount} → ${result.count} ` +
|
||||
logger.info(
|
||||
`auto-sync catalog size changed ${lastCount} → ${result.count} ` +
|
||||
`(providerId=${resolved.providerId})`
|
||||
);
|
||||
lastCount = result.count;
|
||||
}
|
||||
})()
|
||||
.catch((err) => {
|
||||
console.warn("[omniroute-plugin] auto-sync tick error", err);
|
||||
logger.error(`auto-sync tick error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
@@ -982,9 +998,7 @@ export function startOmniRouteAutoSync(args: {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`
|
||||
);
|
||||
logger.info(`auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
@@ -994,6 +1008,9 @@ export function startOmniRouteAutoSync(args: {
|
||||
|
||||
export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
|
||||
const logger = createLogger(
|
||||
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
|
||||
);
|
||||
// T-07: a single per-plugin-instance cache shared between the provider
|
||||
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
|
||||
// hooks fire within the same Plugin invocation, so a shared cache keeps
|
||||
@@ -1010,7 +1027,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
const _hash: string =
|
||||
((globalThis as Record<string, unknown>).__PLUGIN_GIT_HASH__ as string) ?? "unknown";
|
||||
const _prefixes = resolved.features?.apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
|
||||
_logger.always(
|
||||
logger.info(
|
||||
`v${_ver} (${_hash}) initialized` +
|
||||
` providerId=${resolved.providerId}` +
|
||||
` baseURL=${resolved.baseURL ?? "(from auth.json)"}` +
|
||||
@@ -1020,14 +1037,11 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`
|
||||
);
|
||||
|
||||
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
|
||||
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
|
||||
|
||||
// Background auto-discovery while the harness is running (Pi parity).
|
||||
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
|
||||
startOmniRouteAutoSync({ resolved, cache: sharedCache });
|
||||
startOmniRouteAutoSync({ resolved, cache: sharedCache, logger });
|
||||
|
||||
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
|
||||
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache, logger });
|
||||
const bareProviderId = resolved.omnirouteProviderId;
|
||||
|
||||
// Config hook: keep existing catalog shim, and register slash command
|
||||
@@ -1035,6 +1049,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
// Pi-style registerCommand API; tools + command templates are the native path).
|
||||
const baseConfigHook = createOmniRouteConfigHook(resolved, {
|
||||
cache: sharedCache,
|
||||
logger,
|
||||
diskSnapshotReader: defaultDiskSnapshotReader,
|
||||
diskSnapshotWriter: defaultDiskSnapshotWriter,
|
||||
});
|
||||
@@ -4256,6 +4271,7 @@ export function buildStaticProviderEntry(
|
||||
rawAutoCombos?: OmniRouteRawAutoCombo[]
|
||||
): OmniRouteStaticProviderEntry {
|
||||
const models: Record<string, OmniRouteStaticModelEntry> = {};
|
||||
const rawModelKeys = new Set<string>();
|
||||
|
||||
// usableOnly filter — compute once when feature enabled AND we have
|
||||
// connection data to filter against. Soft-fail (empty connections list)
|
||||
@@ -4412,7 +4428,9 @@ export function buildStaticProviderEntry(
|
||||
// provider prefix (`<providerId>/<raw-id>`) is unreachable. Keys are the
|
||||
// raw id verbatim; ids that already contain `/` (e.g. `cc/claude-opus-4-7`)
|
||||
// keep it because the slash is part of the upstream model id itself.
|
||||
models[raw.id] = entry;
|
||||
const key = raw.id;
|
||||
models[key] = entry;
|
||||
rawModelKeys.add(key);
|
||||
}
|
||||
|
||||
// Combo entries → stripped LCD shape. Each combo is keyed as
|
||||
@@ -4609,8 +4627,9 @@ export function buildStaticProviderEntry(
|
||||
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
|
||||
// resolves credentials for the nonexistent provider `opencode-omniroute`
|
||||
// instead of `omniroute`. See #7976.
|
||||
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] =
|
||||
entry;
|
||||
const key = buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!;
|
||||
models[key] = entry;
|
||||
rawModelKeys.delete(key);
|
||||
|
||||
// Make this combo's resolved entry available to parent combos
|
||||
// that reference it via combo-ref. Use the friendly name since
|
||||
@@ -4645,8 +4664,10 @@ export function buildStaticProviderEntry(
|
||||
// Use the variant as the key: "auto", "auto/coding", etc.
|
||||
const key = autoComboModelId(autoCombo.variant);
|
||||
if (models[key]) {
|
||||
// Collision with a raw model or DB combo — auto combo wins (log once)
|
||||
if (!reportedCollisions.has(key)) {
|
||||
// `/v1/models` mirrors auto combos under the same stable id. Replacing
|
||||
// that expected raw twin is silent; every other collision still warns.
|
||||
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
|
||||
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
|
||||
reportedCollisions.add(key);
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
|
||||
@@ -4654,6 +4675,7 @@ export function buildStaticProviderEntry(
|
||||
}
|
||||
}
|
||||
models[key] = entry;
|
||||
rawModelKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5136,13 +5158,13 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
|
||||
* `auth.json[providerId].baseURL`),
|
||||
* (e) `input.provider[providerId]` is ALREADY set (operator override
|
||||
* wins — we never clobber manually-curated catalogs).
|
||||
* Each no-op path emits ONE debug-level breadcrumb to `console.warn`
|
||||
* Each no-op path emits ONE debug-level breadcrumb through the leveled logger
|
||||
* so the operator can diagnose without log spam. Malformed `auth.json`
|
||||
* warns once and continues as if the file were missing.
|
||||
* - Fail-open on fetcher errors: a `/v1/models` failure → still publish
|
||||
* a stub `{models: {}}` provider block (so OC has a complete-shape
|
||||
* entry to render). A `/api/combos` failure → publish models-only.
|
||||
* Both paths emit ONE `console.warn`.
|
||||
* Both paths emit ONE error-level logger message.
|
||||
* - When the provider hook (T-03/T-05) has ALREADY populated the shared
|
||||
* cache for this (baseURL, apiKey) tuple, we reuse the raw payloads
|
||||
* directly — no second fetch. (And vice-versa: the config hook fires
|
||||
@@ -5165,8 +5187,8 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
|
||||
* - `cache` — shared fetch-result cache (see
|
||||
* `OmniRouteFetchCache`). Pass the same Map the
|
||||
* provider hook owns to dedupe round-trips.
|
||||
* - `logger` — `{warn}` sink for breadcrumb capture in tests.
|
||||
* Defaults to `console`.
|
||||
* - `logger` — injected sink for breadcrumb capture in tests.
|
||||
* Defaults to the plugin's leveled logger.
|
||||
*/
|
||||
export function createOmniRouteConfigHook(
|
||||
opts?: OmniRoutePluginOptions,
|
||||
@@ -5182,7 +5204,11 @@ export function createOmniRouteConfigHook(
|
||||
diskSnapshotWriter?: OmniRouteDiskSnapshotWriter;
|
||||
now?: () => number;
|
||||
cache?: OmniRouteFetchCache;
|
||||
logger?: { warn: (...args: unknown[]) => void };
|
||||
logger?: {
|
||||
error?: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug?: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
} = {}
|
||||
): (input: Config) => Promise<void> {
|
||||
const resolved = resolveOmniRoutePluginOptions(opts);
|
||||
@@ -5198,7 +5224,11 @@ export function createOmniRouteConfigHook(
|
||||
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
|
||||
const now = deps.now ?? Date.now;
|
||||
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
|
||||
const logger = deps.logger ?? console;
|
||||
const logger = deps.logger ?? _logger;
|
||||
const logAt = (level: "error" | "warn" | "debug", message: string): void => {
|
||||
const sink = logger[level] ?? logger.warn;
|
||||
sink.call(logger, message);
|
||||
};
|
||||
const features = resolved.features ?? {};
|
||||
const wantAutoCombos = features.autoCombos !== false;
|
||||
const wantEnrichment = features.enrichment !== false;
|
||||
@@ -5213,9 +5243,7 @@ export function createOmniRouteConfigHook(
|
||||
// generated block. Detect-and-respect before any I/O.
|
||||
const existingProviders = (input as { provider?: Record<string, unknown> }).provider;
|
||||
if (existingProviders && existingProviders[resolved.providerId] !== undefined) {
|
||||
logger.warn(
|
||||
`[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user`
|
||||
);
|
||||
logAt("debug", `config shim skipped: provider.${resolved.providerId} already set by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5230,7 +5258,7 @@ export function createOmniRouteConfigHook(
|
||||
}
|
||||
|
||||
if (authJson === null) {
|
||||
logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing");
|
||||
logAt("warn", "config shim: auth.json failed to parse; treating as missing");
|
||||
authJson = undefined;
|
||||
}
|
||||
|
||||
@@ -5257,9 +5285,7 @@ export function createOmniRouteConfigHook(
|
||||
// (c) no apiKey — silent no-op (with debug breadcrumb). The operator
|
||||
// hasn't run `/connect <providerId>` yet, OR the stored credential
|
||||
// isn't api-flavored. OC will handle the `/connect` flow at runtime.
|
||||
logger.warn(
|
||||
`[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}`
|
||||
);
|
||||
logAt("debug", `config shim skipped: no apiKey for providerId=${resolved.providerId}`);
|
||||
return;
|
||||
}
|
||||
// Management-plane catalog reads may use a narrower read-only token.
|
||||
@@ -5272,9 +5298,7 @@ export function createOmniRouteConfigHook(
|
||||
const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined;
|
||||
const baseURL = resolved.baseURL ?? storedBaseURL ?? "";
|
||||
if (!baseURL) {
|
||||
logger.warn(
|
||||
`[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}`
|
||||
);
|
||||
logAt("debug", `config shim skipped: no baseURL for providerId=${resolved.providerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5319,8 +5343,9 @@ export function createOmniRouteConfigHook(
|
||||
// Log snapshot age (accept any age — instant beats empty).
|
||||
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
|
||||
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
|
||||
logger.warn(
|
||||
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
||||
logAt(
|
||||
"warn",
|
||||
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5346,9 +5371,9 @@ export function createOmniRouteConfigHook(
|
||||
try {
|
||||
localRawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
|
||||
err
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: /v1/models fetch failed; publishing stub provider entry: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
localRawModels = [];
|
||||
modelsFetchThrew = true;
|
||||
@@ -5359,9 +5384,9 @@ export function createOmniRouteConfigHook(
|
||||
try {
|
||||
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
|
||||
err
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: /api/combos fetch failed; publishing models-only static catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5380,9 +5405,9 @@ export function createOmniRouteConfigHook(
|
||||
try {
|
||||
localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
|
||||
err
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: /api/pricing/models fetch failed; publishing raw-id static catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5392,9 +5417,9 @@ export function createOmniRouteConfigHook(
|
||||
try {
|
||||
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
|
||||
err
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: /api/context/combos fetch failed; publishing combos without compression suffix: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5404,9 +5429,9 @@ export function createOmniRouteConfigHook(
|
||||
try {
|
||||
localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
|
||||
err
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5429,8 +5454,9 @@ export function createOmniRouteConfigHook(
|
||||
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
|
||||
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
|
||||
if (snapshot && snapshot.rawModels.length > 0) {
|
||||
logger.warn(
|
||||
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
|
||||
logAt(
|
||||
"warn",
|
||||
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
|
||||
);
|
||||
localRawModels = snapshot.rawModels;
|
||||
localRawCombos = snapshot.rawCombos;
|
||||
@@ -5452,6 +5478,7 @@ export function createOmniRouteConfigHook(
|
||||
rawConnections: localRawConnections,
|
||||
expiresAt: now() + resolved.modelCacheTtl,
|
||||
});
|
||||
});
|
||||
|
||||
// Startup diagnostics (file-based) — fires at startup via config hook
|
||||
if (resolved.features?.startupDebug === true) {
|
||||
@@ -5530,7 +5557,10 @@ export function createOmniRouteConfigHook(
|
||||
} else {
|
||||
const refreshP = doRefresh()
|
||||
.catch((err: unknown) => {
|
||||
logger.warn("[omniroute-plugin] config shim: background refresh failed", err);
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: background refresh failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
_inflightRefresh.delete(cacheKey);
|
||||
@@ -5556,7 +5586,10 @@ export function createOmniRouteConfigHook(
|
||||
} else {
|
||||
const refreshP = doRefresh()
|
||||
.catch((err: unknown) => {
|
||||
logger.warn("[omniroute-plugin] config shim: refresh failed", err);
|
||||
logAt(
|
||||
"error",
|
||||
`config shim: refresh failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
_inflightRefresh.delete(cacheKey);
|
||||
@@ -5611,8 +5644,9 @@ export function createOmniRouteConfigHook(
|
||||
if (features.mcpAutoEmit === true) {
|
||||
const mcpKey = features.mcpToken ?? apiKey;
|
||||
if (!mcpKey) {
|
||||
logger.warn(
|
||||
`[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
|
||||
logAt(
|
||||
"debug",
|
||||
`mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
|
||||
);
|
||||
} else {
|
||||
const inputWithMcp = input as { mcp?: Record<string, unknown> };
|
||||
@@ -5620,9 +5654,7 @@ export function createOmniRouteConfigHook(
|
||||
inputWithMcp.mcp = {};
|
||||
}
|
||||
if (inputWithMcp.mcp[resolved.providerId] !== undefined) {
|
||||
logger.warn(
|
||||
`[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`
|
||||
);
|
||||
logAt("debug", `mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`);
|
||||
} else {
|
||||
// Strip a trailing `/v1` from baseURL when present so we land on
|
||||
// the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream.
|
||||
|
||||
@@ -36,39 +36,47 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
|
||||
return `${prefix} [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
function buildLogger(getLevel: () => LogLevel) {
|
||||
return {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
|
||||
// ── Tagged child loggers ──────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "error") &&
|
||||
console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "warn") &&
|
||||
console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "info") &&
|
||||
console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "debug") &&
|
||||
console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
// ── Tagged child loggers ────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof buildLogger>;
|
||||
|
||||
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
|
||||
export function createLogger(level: LogLevel): Logger {
|
||||
return buildLogger(() => level);
|
||||
}
|
||||
|
||||
/** Backward-compatible module-global logger controlled by setLogLevel(). */
|
||||
export const logger: Logger = buildLogger(() => _level);
|
||||
|
||||
@@ -13,6 +13,22 @@ import {
|
||||
forceSyncOmniRouteModels,
|
||||
type OmniRouteFetchCache,
|
||||
} from "../src/index.js";
|
||||
import { getLogLevel, setLogLevel } from "../src/logger.js";
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
@@ -35,7 +51,10 @@ test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
|
||||
|
||||
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
|
||||
assert.equal(
|
||||
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
|
||||
@@ -112,6 +131,76 @@ test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async
|
||||
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.deepEqual(lines, []);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "info",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("info");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
|
||||
@@ -763,6 +763,37 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
|
||||
|
||||
let block: OmniRouteStaticProviderEntry;
|
||||
try {
|
||||
block = buildStaticProviderEntry(
|
||||
[{ id: "auto/coding" }],
|
||||
[],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
|
||||
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
|
||||
assert.deepEqual(
|
||||
warnings.filter((warning) => warning.includes("collides with an existing model")),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Schema parity (modalities / cost / release_date / limit cleanup)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
218
@omniroute/opencode-plugin/tests/log-level.test.ts
Normal file
218
@omniroute/opencode-plugin/tests/log-level.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
|
||||
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||
|
||||
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
||||
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
||||
|
||||
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
|
||||
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
|
||||
const originals = Object.fromEntries(
|
||||
consoleMethods.map((method) => [method, console[method]])
|
||||
) as Record<ConsoleMethod, typeof console.warn>;
|
||||
|
||||
for (const method of consoleMethods) {
|
||||
console[method] = (...args: unknown[]) => {
|
||||
entries[method].push(args);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const method of consoleMethods) console[method] = originals[method];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rendered(entries: ConsoleEntries): string[] {
|
||||
return consoleMethods.flatMap((method) =>
|
||||
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
|
||||
);
|
||||
}
|
||||
|
||||
async function capturePluginLifecycle(args: {
|
||||
level: LogLevel;
|
||||
autoSyncIntervalMs: number;
|
||||
invokeConfig?: boolean;
|
||||
}): Promise<string[]> {
|
||||
const previousDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
const previousLevel = getLogLevel();
|
||||
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
|
||||
process.env.OPENCODE_DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
const entries = await captureConsole(async () => {
|
||||
const hooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: args.autoSyncIntervalMs,
|
||||
features: { logLevel: args.level },
|
||||
});
|
||||
if (args.invokeConfig) {
|
||||
assert.equal(typeof hooks.config, "function");
|
||||
await hooks.config!({} as Config);
|
||||
}
|
||||
});
|
||||
return rendered(entries);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = previousDataDir;
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("logLevel error suppresses the initialization banner", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses factory config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "error",
|
||||
autoSyncIntervalMs: 0,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "debug",
|
||||
autoSyncIntervalMs: 60_000,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("initialized")),
|
||||
"initialization banner emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("auto-sync enabled")),
|
||||
"auto-sync message emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("config shim skipped")),
|
||||
"config breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("debug instance retains config diagnostics after an error instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const debugHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await debugHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
|
||||
});
|
||||
|
||||
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const errorHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await errorHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
|
||||
const entries: unknown[][] = [];
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute": { type: "api", key: "test-key" },
|
||||
}),
|
||||
fetcher: async () => {
|
||||
throw new Error("models unavailable");
|
||||
},
|
||||
combosFetcher: async () => {
|
||||
throw new Error("combos unavailable");
|
||||
},
|
||||
logger: {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await hook({} as Config);
|
||||
|
||||
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
|
||||
assert.deepEqual(
|
||||
entries.map((args) => args.length),
|
||||
[1, 1],
|
||||
"each failure is emitted as one concise argument"
|
||||
);
|
||||
const lines = entries.map(([message]) => String(message));
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
|
||||
);
|
||||
assert.equal(
|
||||
entries.flat().some((arg) => arg instanceof Error),
|
||||
false,
|
||||
"no raw Error object emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("logger error output remains visible at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
logger.error("genuine startup failure");
|
||||
})
|
||||
);
|
||||
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -434,16 +434,16 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
## Testing
|
||||
|
||||
| What | Command |
|
||||
| ----------------------- | --------------------------------------------------------------------------- |
|
||||
| Unit tests | `npm run test:unit` |
|
||||
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
|
||||
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
|
||||
| Ecosystem | `npm run test:ecosystem` |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
| What | Command |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- |
|
||||
| Unit tests | `npm run test:unit` |
|
||||
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
|
||||
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` (CI job `test-protocols-e2e`, advisory — #10049) |
|
||||
| Ecosystem | `npm run test:ecosystem` (CI job `test-ecosystem`, blocking) |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
|
||||
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://platform.kimi.ai?aff=omniroute">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
|
||||
</a>
|
||||
</p>
|
||||
@@ -248,7 +248,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://platform.kimi.ai?aff=omniroute">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
|
||||
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
|
||||
@@ -260,7 +260,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<td>
|
||||
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
|
||||
<br/><br/>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -139,6 +139,21 @@ export function shouldRetryError(err, opts = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a non-2xx status means "this server does not serve this route"
|
||||
* rather than "your request was wrong".
|
||||
*
|
||||
* Commands that keep a local SQLite fallback must not treat these as fatal:
|
||||
* a CLI newer (or older) than the server it is talking to will hit routes that
|
||||
* simply are not mounted, and aborting there strands the user with an
|
||||
* unactionable `HTTP 404` even though the local path would have worked.
|
||||
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
|
||||
* locally would paper over a real problem.
|
||||
*/
|
||||
export function isRouteUnavailableStatus(status) {
|
||||
return status === 404 || status === 405 || status === 501;
|
||||
}
|
||||
|
||||
export function statusToExitCode(status) {
|
||||
if (status >= 200 && status < 300) return 0;
|
||||
if (status === 408) return 124;
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function runComboListCommand(opts = {}) {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
let combos = [];
|
||||
let activeCombo = null;
|
||||
let listError = null;
|
||||
|
||||
if (kind === "http") {
|
||||
const [listRes, activeRes] = await Promise.all([
|
||||
@@ -161,6 +162,12 @@ export async function runComboListCommand(opts = {}) {
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
combos = Array.isArray(data) ? data : (data.combos ?? []);
|
||||
} else {
|
||||
// The server answered, but not with a combo list. Falling through to
|
||||
// an empty array here rendered "No combos configured" — which is
|
||||
// indistinguishable from genuine emptiness and reads as real state,
|
||||
// so a transport/auth failure looked like a wiped configuration.
|
||||
listError = listRes.status;
|
||||
}
|
||||
if (activeRes.ok) {
|
||||
const settings = await activeRes.json();
|
||||
@@ -171,11 +178,25 @@ export async function runComboListCommand(opts = {}) {
|
||||
}
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
|
||||
return 0;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return listError ? 1 : 0;
|
||||
}
|
||||
|
||||
printHeading(t("combo.title"));
|
||||
if (listError) {
|
||||
console.error(
|
||||
t("common.error", {
|
||||
message: `could not list combos from the server (HTTP ${listError})`,
|
||||
})
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (combos.length === 0) {
|
||||
console.log(t("combo.noCombos"));
|
||||
return 0;
|
||||
|
||||
@@ -288,27 +288,44 @@ async function checkNodeRuntime(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
|
||||
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
|
||||
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
|
||||
*/
|
||||
export function prebuiltBinaryName(
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
report = process.report
|
||||
) {
|
||||
let prefix = platform;
|
||||
if (platform === "linux") {
|
||||
let isMusl = false;
|
||||
try {
|
||||
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
|
||||
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
|
||||
} catch {
|
||||
isMusl = false;
|
||||
}
|
||||
prefix = isMusl ? "linuxmusl" : "linux";
|
||||
}
|
||||
return `${prefix}-${arch}.node`;
|
||||
}
|
||||
|
||||
async function checkNativeBinary(rootDir) {
|
||||
// node-gyp layout — present only when better-sqlite3 was compiled locally.
|
||||
const buildRoots = [
|
||||
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3"),
|
||||
];
|
||||
const prebuildName = prebuiltBinaryName();
|
||||
const candidates = [
|
||||
path.join(
|
||||
rootDir,
|
||||
"app",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
|
||||
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")),
|
||||
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without
|
||||
// these, doctor warns on every prebuilt install even though the binary is
|
||||
// present and loading fine.
|
||||
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)),
|
||||
];
|
||||
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!binaryPath) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../provider-store.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
import { loadAvailableProviders } from "../provider-catalog.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
function getValidProviderIds() {
|
||||
@@ -184,7 +184,10 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
|
||||
console.log(t("keys.added", { provider: providerLower }));
|
||||
return 0;
|
||||
}
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
// A missing route means this server does not implement the endpoint —
|
||||
// fall through to the local SQLite path below rather than stranding the
|
||||
// user. Real client errors still abort.
|
||||
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -41,11 +41,83 @@ function toYaml(obj, indent = 0) {
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
// Keys that live alongside operations inside a Path Item Object but are not
|
||||
// themselves operations (OpenAPI 3.x Path Item fields).
|
||||
const NON_OPERATION_PATH_KEYS = new Set([
|
||||
"parameters",
|
||||
"summary",
|
||||
"description",
|
||||
"servers",
|
||||
"$ref",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `GET /api/openapi/spec` answers with a compact catalog
|
||||
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
|
||||
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
|
||||
* spec. Normalize either shape into the flat rows the CLI renders so the
|
||||
* commands work against both instead of silently printing nothing.
|
||||
*/
|
||||
export function extractEndpoints(spec) {
|
||||
if (!spec || typeof spec !== "object") return [];
|
||||
|
||||
if (spec.paths && typeof spec.paths === "object") {
|
||||
const rows = [];
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
if (!pathItem || typeof pathItem !== "object") continue;
|
||||
for (const [method, def] of Object.entries(pathItem)) {
|
||||
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
|
||||
if (!def || typeof def !== "object") continue;
|
||||
rows.push({
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: def.summary ?? def.description ?? "",
|
||||
operationId: def.operationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (Array.isArray(spec.endpoints)) {
|
||||
return spec.endpoints
|
||||
.filter((entry) => entry && typeof entry === "object" && entry.path)
|
||||
.map((entry) => ({
|
||||
method: String(entry.method ?? "GET").toUpperCase(),
|
||||
path: entry.path,
|
||||
summary: entry.summary ?? entry.description ?? "",
|
||||
operationId: entry.operationId,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Sorted, de-duplicated list of paths across either shape. */
|
||||
export function extractPaths(spec) {
|
||||
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
|
||||
}
|
||||
|
||||
function matchesSearch(row, query) {
|
||||
if (!query) return true;
|
||||
const needle = query.toLowerCase();
|
||||
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
function validateBasic(spec) {
|
||||
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
|
||||
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
|
||||
if (!spec.info) throw new Error("missing info object");
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
|
||||
// A real OpenAPI document must carry a version field and a paths object.
|
||||
if (spec.openapi || spec.swagger) {
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
return;
|
||||
}
|
||||
|
||||
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
|
||||
if (Array.isArray(spec.endpoints)) return;
|
||||
|
||||
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
|
||||
}
|
||||
|
||||
const endpointSchema = [
|
||||
@@ -132,20 +204,7 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const rows = [];
|
||||
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, def] of Object.entries(methods)) {
|
||||
if (["parameters", "summary"].includes(method)) continue;
|
||||
const summary = def.summary ?? def.description ?? "";
|
||||
if (
|
||||
opts.search &&
|
||||
!path.includes(opts.search) &&
|
||||
!summary.toLowerCase().includes(opts.search.toLowerCase())
|
||||
)
|
||||
continue;
|
||||
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
|
||||
}
|
||||
}
|
||||
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
|
||||
emit(rows, cmd.optsWithGlobals(), endpointSchema);
|
||||
});
|
||||
|
||||
@@ -159,9 +218,8 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const paths = Object.keys(spec.paths ?? {}).sort();
|
||||
emit(
|
||||
paths.map((p) => ({ path: p })),
|
||||
extractPaths(spec).map((p) => ({ path: p })),
|
||||
cmd.optsWithGlobals()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,9 +129,33 @@ function buildTestInput(connection, apiKey) {
|
||||
}
|
||||
|
||||
async function runProviderTest(db, connection) {
|
||||
// Only API-key connections can be probed with a stored credential. OAuth /
|
||||
// no-auth connections have nothing for testProviderApiKey() to send, and
|
||||
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
||||
// test marked perfectly healthy OAuth connections as broken *and* persisted
|
||||
// that verdict to provider_connections.test_status.
|
||||
if (connection.authType !== "apikey") {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
valid: false,
|
||||
skipped: true,
|
||||
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = getProviderApiKey(connection);
|
||||
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
|
||||
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
|
||||
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
||||
// Persisting it would overwrite a good test_status with a failure.
|
||||
if (result.unsupported) {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
...result,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
updateProviderTestResult(db, connection.id, result);
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const CLI_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_ROOT_DIR = join(CLI_DIR, "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export const COMMON_PROVIDERS = [
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
@@ -17,94 +15,201 @@ export const COMMON_PROVIDERS = [
|
||||
];
|
||||
|
||||
function normalizeCatalogCategory(exportName) {
|
||||
const raw = exportName
|
||||
.replace(/_PROVIDERS$/, "")
|
||||
.toLowerCase()
|
||||
.replaceAll("_", "-");
|
||||
const raw = exportName.split("_PROVIDERS")[0].toLowerCase().replaceAll("_", "-");
|
||||
if (raw === "apikey") return "api-key";
|
||||
return raw;
|
||||
}
|
||||
|
||||
function loadTypeScript() {
|
||||
try {
|
||||
return require("typescript");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Advance past a string literal, template literal, or comment starting at `i`.
|
||||
* Returns the index just after it, or -1 when `i` does not start one. Keeping
|
||||
* the scanner string/comment aware is what lets it walk braces safely — provider
|
||||
* notes routinely contain `{`, `}` and apostrophes.
|
||||
*/
|
||||
function skipNonCode(source, i) {
|
||||
const c = source[i];
|
||||
|
||||
function getPropertyName(ts, name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getObjectProperty(ts, objectLiteral, propertyName) {
|
||||
return objectLiteral.properties.find(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) && getPropertyName(ts, property.name) === propertyName
|
||||
);
|
||||
}
|
||||
|
||||
function getStringProperty(ts, objectLiteral, propertyName) {
|
||||
const property = getObjectProperty(ts, objectLiteral, propertyName);
|
||||
const initializer = property?.initializer;
|
||||
if (!initializer) return null;
|
||||
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
||||
return initializer.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBooleanProperty(ts, objectLiteral, propertyName) {
|
||||
const property = getObjectProperty(ts, objectLiteral, propertyName);
|
||||
const initializer = property?.initializer;
|
||||
return initializer?.kind === ts.SyntaxKind.TrueKeyword;
|
||||
}
|
||||
|
||||
function extractProviderBlocks(source, filePath) {
|
||||
const ts = loadTypeScript();
|
||||
if (!ts) return [];
|
||||
|
||||
const providers = [];
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name)) continue;
|
||||
const exportName = declaration.name.text;
|
||||
if (!exportName.endsWith("_PROVIDERS")) continue;
|
||||
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
for (let j = i + 1; j < source.length; j++) {
|
||||
if (source[j] === "\\") {
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = normalizeCatalogCategory(exportName);
|
||||
for (const property of declaration.initializer.properties) {
|
||||
if (!ts.isPropertyAssignment(property)) continue;
|
||||
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
|
||||
|
||||
const key = getPropertyName(ts, property.name);
|
||||
if (!key) continue;
|
||||
|
||||
const id = getStringProperty(ts, property.initializer, "id") || key;
|
||||
const name = getStringProperty(ts, property.initializer, "name") || id;
|
||||
|
||||
providers.push({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
alias: getStringProperty(ts, property.initializer, "alias"),
|
||||
website: getStringProperty(ts, property.initializer, "website"),
|
||||
deprecated: getBooleanProperty(ts, property.initializer, "deprecated"),
|
||||
hasFree: getBooleanProperty(ts, property.initializer, "hasFree"),
|
||||
passthroughModels: getBooleanProperty(ts, property.initializer, "passthroughModels"),
|
||||
});
|
||||
}
|
||||
if (source[j] === c) return j + 1;
|
||||
}
|
||||
});
|
||||
return source.length;
|
||||
}
|
||||
|
||||
if (c === "/" && source[i + 1] === "/") {
|
||||
const nl = source.indexOf("\n", i);
|
||||
return nl === -1 ? source.length : nl;
|
||||
}
|
||||
|
||||
if (c === "/" && source[i + 1] === "*") {
|
||||
const close = source.indexOf("*/", i + 2);
|
||||
return close === -1 ? source.length : close + 2;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Index of the `}` matching the `{` at `openIdx`, or -1. */
|
||||
function findMatchingBrace(source, openIdx) {
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < source.length; i++) {
|
||||
const skipped = skipNonCode(source, i);
|
||||
if (skipped !== -1) {
|
||||
i = skipped - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const MEMBER_KEY = /(?:([A-Za-z_$][\w$]*)|"([^"]*)"|'([^']*)')\s*:/y;
|
||||
|
||||
/**
|
||||
* Parse the direct members of the object literal whose `{` is at `openIdx`.
|
||||
* Returns `[{ key, value }]` with `value` as the raw source slice.
|
||||
*/
|
||||
function parseObjectMembers(source, openIdx) {
|
||||
// An unbalanced literal (a missing `},` in a large data file — see #10093)
|
||||
// should not blank the whole catalog: scan to end-of-source so the entries
|
||||
// before the damage are still recovered.
|
||||
const matching = findMatchingBrace(source, openIdx);
|
||||
const close = matching === -1 ? source.length : matching;
|
||||
|
||||
const members = [];
|
||||
let i = openIdx + 1;
|
||||
|
||||
while (i < close) {
|
||||
if (/[\s,;]/.test(source[i])) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The key match MUST be attempted before skipNonCode: quoted keys such as
|
||||
// `"duckduckgo-web":` start with a quote, and skipping them as string
|
||||
// literals both loses the entry and desynchronizes the walk, which then
|
||||
// reports nested keys (`notice`, …) as top-level providers.
|
||||
MEMBER_KEY.lastIndex = i;
|
||||
const match = MEMBER_KEY.exec(source);
|
||||
if (!match) {
|
||||
const skipped = skipNonCode(source, i);
|
||||
i = skipped !== -1 ? skipped : i + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1] ?? match[2] ?? match[3];
|
||||
let valueStart = MEMBER_KEY.lastIndex;
|
||||
while (valueStart < close && /\s/.test(source[valueStart])) valueStart++;
|
||||
|
||||
let valueEnd;
|
||||
if (source[valueStart] === "{" || source[valueStart] === "[") {
|
||||
const openChar = source[valueStart];
|
||||
const closeChar = openChar === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let j = valueStart;
|
||||
for (; j < close; j++) {
|
||||
const s2 = skipNonCode(source, j);
|
||||
if (s2 !== -1) {
|
||||
j = s2 - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === openChar) depth++;
|
||||
else if (source[j] === closeChar) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
valueEnd = j + 1;
|
||||
} else {
|
||||
let j = valueStart;
|
||||
for (; j < close; j++) {
|
||||
const s2 = skipNonCode(source, j);
|
||||
if (s2 !== -1) {
|
||||
j = s2 - 1;
|
||||
continue;
|
||||
}
|
||||
if (source[j] === ",") break;
|
||||
}
|
||||
valueEnd = j;
|
||||
}
|
||||
|
||||
members.push({ key, value: source.slice(valueStart, valueEnd), valueStart });
|
||||
// Guarantee forward progress even on malformed input.
|
||||
i = valueEnd > i ? valueEnd : i + 1;
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
/** First string literal in a raw value (handles `"a" + "b"` continuations). */
|
||||
function readString(raw) {
|
||||
if (raw == null) return null;
|
||||
const match = raw.match(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/);
|
||||
if (!match) return null;
|
||||
return (match[1] ?? match[2]).replace(/\\(.)/g, "$1");
|
||||
}
|
||||
|
||||
function readBoolean(raw) {
|
||||
return String(raw).trim() === "true";
|
||||
}
|
||||
|
||||
const PROVIDER_EXPORT =
|
||||
/(?:export\s+)?const\s+([A-Z0-9_]*_PROVIDERS[A-Z0-9_]*)\s*(?::[^=]+)?=\s*\{/g;
|
||||
|
||||
/**
|
||||
* Extract provider entries from a catalog source file.
|
||||
*
|
||||
* Deliberately dependency-free: `typescript` is a devDependency, so requiring it
|
||||
* at runtime made this silently return [] on every published install (#10080).
|
||||
* These files are pure data literals, so a string/comment-aware brace walk is
|
||||
* both sufficient and stable.
|
||||
*/
|
||||
export function extractProviderBlocks(source) {
|
||||
const providers = [];
|
||||
PROVIDER_EXPORT.lastIndex = 0;
|
||||
|
||||
let exportMatch;
|
||||
while ((exportMatch = PROVIDER_EXPORT.exec(source)) !== null) {
|
||||
const exportName = exportMatch[1];
|
||||
const openIdx = source.indexOf("{", exportMatch.index + exportMatch[0].length - 1);
|
||||
if (openIdx === -1) continue;
|
||||
|
||||
const category = normalizeCatalogCategory(exportName);
|
||||
|
||||
for (const entry of parseObjectMembers(source, openIdx)) {
|
||||
if (!entry.value.startsWith("{")) continue; // spread / non-object member
|
||||
const fields = new Map(
|
||||
parseObjectMembers(source, entry.valueStart).map((f) => [f.key, f.value])
|
||||
);
|
||||
|
||||
const id = readString(fields.get("id")) || entry.key;
|
||||
providers.push({
|
||||
id,
|
||||
name: readString(fields.get("name")) || id,
|
||||
category,
|
||||
alias: readString(fields.get("alias")),
|
||||
website: readString(fields.get("website")),
|
||||
deprecated: readBoolean(fields.get("deprecated")),
|
||||
hasFree: readBoolean(fields.get("hasFree")),
|
||||
passthroughModels: readBoolean(fields.get("passthroughModels")),
|
||||
});
|
||||
}
|
||||
|
||||
// An unbalanced literal (see #10093) yields -1 here. Resetting lastIndex to
|
||||
// 0 would restart the scan from the top forever, so stop instead — the
|
||||
// entries recovered above are still returned.
|
||||
const closeIdx = findMatchingBrace(source, openIdx);
|
||||
if (closeIdx === -1) break;
|
||||
PROVIDER_EXPORT.lastIndex = closeIdx + 1;
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -126,9 +231,31 @@ function resolveProviderCatalogPath(rootDir, options = {}) {
|
||||
if (configuredPath) {
|
||||
return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath);
|
||||
}
|
||||
|
||||
// The catalog used to be one god-file at constants/providers.ts. It was
|
||||
// decomposed into constants/providers/**, leaving the barrel with nothing but
|
||||
// re-exports and an empty `FREE_PROVIDERS = {}` — so parsing it alone yielded
|
||||
// zero providers and the CLI silently fell back to COMMON_PROVIDERS (#10080).
|
||||
// Prefer the directory; keep the legacy file for older trees.
|
||||
const catalogDir = join(rootDir, "src", "shared", "constants", "providers");
|
||||
if (existsSync(catalogDir)) return catalogDir;
|
||||
return join(rootDir, "src", "shared", "constants", "providers.ts");
|
||||
}
|
||||
|
||||
/** Every .ts catalog file under `dir`, one level of subdirectories deep. */
|
||||
function collectCatalogFiles(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectCatalogFiles(full));
|
||||
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function loadAvailableProviders(options = {}) {
|
||||
const rootDir = typeof options === "string" ? options : options.rootDir || DEFAULT_ROOT_DIR;
|
||||
const providersPath = resolveProviderCatalogPath(rootDir, options);
|
||||
@@ -138,8 +265,10 @@ export function loadAvailableProviders(options = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const source = readFileSync(providersPath, "utf-8");
|
||||
const providers = extractProviderBlocks(source, providersPath);
|
||||
const sources = statSync(providersPath).isDirectory()
|
||||
? collectCatalogFiles(providersPath)
|
||||
: [providersPath];
|
||||
const providers = sources.flatMap((file) => extractProviderBlocks(readFileSync(file, "utf-8")));
|
||||
if (providers.length === 0) return fallbackAvailableProviders();
|
||||
|
||||
const seen = new Set();
|
||||
|
||||
@@ -93,6 +93,28 @@ function migrateElectronServerEnv(dataDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `.env` value with dotenv-compatible comment handling.
|
||||
*
|
||||
* Without this, `KEY=value # note` stored the comment text as part of the
|
||||
* value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and
|
||||
* consumers compare it with `===`, so annotating a variable inline silently
|
||||
* disabled it (#10100).
|
||||
*
|
||||
* Quoted values are returned verbatim — a `#` inside quotes is data. For
|
||||
* unquoted values a `#` *preceded by whitespace* starts a comment, so
|
||||
* `pass#word` is preserved.
|
||||
*/
|
||||
function parseEnvValue(raw) {
|
||||
const value = String(raw).trim();
|
||||
|
||||
const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/);
|
||||
if (quoted) return quoted[2];
|
||||
|
||||
const commentIdx = value.search(/\s#/);
|
||||
return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim();
|
||||
}
|
||||
|
||||
function loadEnvFile() {
|
||||
const envPaths = [];
|
||||
const loadedEnvPaths = [];
|
||||
@@ -128,9 +150,8 @@ function loadEnvFile() {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value.replace(/^["']|["']$/g, "");
|
||||
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/features/9085-poolside-laguna-model-ids.md
Normal file
1
changelog.d/features/9085-poolside-laguna-model-ids.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** publish Poolside's Laguna Preview catalog statically — `poolside/laguna-xs-2.1` and `poolside/laguna-s-2.1` (262144 context, 32768 max completion, tools + reasoning, text-only), so the models are routable and visible before a key is configured instead of only after live discovery. Pins the catalog form of the XS id against the `laguna-xs.2` variant carried by third-party listings. ([#9085](https://github.com/diegosouzapw/OmniRoute/issues/9085))
|
||||
1
changelog.d/features/crofai-reasoning-efforts.md
Normal file
1
changelog.d/features/crofai-reasoning-efforts.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(crof): advertise reasoning-effort tiers (none/low/medium/high/max) for live-discovered and seed models, so the catalog, Playground, and Combo Builder surface <model>-<tier> aliases and requests resolve max upstream
|
||||
1
changelog.d/fixes/10125-incremental-call-log-rotation.md
Normal file
1
changelog.d/fixes/10125-incremental-call-log-rotation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **perf(logging):** bound each scheduled call-log rotation pass to incremental database and filesystem work (#10125)
|
||||
1
changelog.d/fixes/10127-early-sse-heartbeat.md
Normal file
1
changelog.d/fixes/10127-early-sse-heartbeat.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(streaming):** start early SSE heartbeats when Responses or Messages requests opt into streaming through the request body (#10127)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139))
|
||||
1
changelog.d/fixes/10169-thinking-budget-docs-i18n.md
Normal file
1
changelog.d/fixes/10169-thinking-budget-docs-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(settings):** document Thinking Budget modes (passthrough vs auto-strip); fix dashboard i18n key collision that showed Auto Combo routing copy on the thinking tab; clarify independence from compression/cache ([#10169](https://github.com/diegosouzapw/OmniRoute/pull/10169))
|
||||
1
changelog.d/fixes/10202-responses-vision-bridge.md
Normal file
1
changelog.d/fixes/10202-responses-vision-bridge.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(guardrails):** Vision Bridge handles OpenAI Responses `input`/`input_image` requests before combo vision filtering ([#10202](https://github.com/diegosouzapw/OmniRoute/pull/10202)) — thanks @Zartharas
|
||||
@@ -0,0 +1 @@
|
||||
- **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)).
|
||||
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))
|
||||
1
changelog.d/fixes/9821-mcp-pack-unit-stall.md
Normal file
1
changelog.d/fixes/9821-mcp-pack-unit-stall.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(test):** remove live `npm pack` from MCP files unit test (it stalled concurrent `test:unit` via prepare→husky + monorepo pack walk); keep the static #3578 `files` allowlist + negation guards in unit and fold #3821 pack assertions into `check:pack-artifact` / `check:pack-policy` (already `--ignore-scripts`).
|
||||
1
changelog.d/fixes/discovery-metadata-effort-tiers.md
Normal file
1
changelog.d/fixes/discovery-metadata-effort-tiers.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(discovery): parse upstream reasoning tiers nested under metadata.reasoning.supported_efforts (neuralwatt /v1/models shape) so synced openai-compatible models advertise effort aliases
|
||||
1
changelog.d/fixes/opencode-force-cli-ua.md
Normal file
1
changelog.d/fixes/opencode-force-cli-ua.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** when `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, a non-CLI client User-Agent (e.g. `curl/8.5.0`, SDKs) on opencode-go/opencode-zen/opencode-free requests is now REPLACED with the synthesized `opencode-cli/1.0.0` instead of being honored — opencode.ai's free tier (`/zen/v1`) returns `FreeUsageLimitError` 429 for generic client UAs egressing from datacenter IPs, which made the #5997 CLI-identity synthesis ineffective for non-CLI clients. Client UAs already matching `opencode-cli/…` are preserved (the real CLI's versioned identity stays intact); all other client-supplied `x-opencode-*` headers keep client-wins. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (7, incl. non-CLI UA replaced + CLI UA preserved). (#5997 follow-up)
|
||||
@@ -127,12 +127,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"TS2304": 5
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
|
||||
"TS2322": 3,
|
||||
"TS2739": 1,
|
||||
@@ -147,9 +141,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
|
||||
"TS2739": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ Simple guides for using OmniRoute — no technical background needed.
|
||||
|
||||
- [SETUP_GUIDE.md](guides/SETUP_GUIDE.md) — first-time setup of OmniRoute.
|
||||
- [USER_GUIDE.md](guides/USER_GUIDE.md) — daily usage of the dashboard and API.
|
||||
- [THINKING_BUDGET.md](guides/THINKING_BUDGET.md) — thinking/reasoning budget modes (passthrough vs auto-strip).
|
||||
- [FEATURES.md](guides/FEATURES.md) — dashboard feature gallery.
|
||||
- [TIERS.md](guides/TIERS.md) — OmniRoute tiers explained (user guide).
|
||||
- [USAGE_QUOTA_GUIDE.md](guides/USAGE_QUOTA_GUIDE.md) — usage, quota & spend tracking.
|
||||
|
||||
@@ -171,6 +171,22 @@ codex -c model_reasoning_effort=low "rename variable x to count"
|
||||
codex -c model_reasoning_effort=xhigh "design the auth module"
|
||||
```
|
||||
|
||||
Also set a reasoning **summary** so Desktop can render thinking text (not only encrypted blobs):
|
||||
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
model_reasoning_effort = "xhigh" # or ultra when supported
|
||||
model_reasoning_summary = "detailed" # auto | concise | detailed | none
|
||||
```
|
||||
|
||||
### OmniRoute Thinking Budget (server setting)
|
||||
|
||||
On the OmniRoute host, **Settings → AI → Thinking Budget** must be **`passthrough`** for Codex effort/summary to reach upstream. Mode **`auto` strips** all client `reasoning` / `reasoning_effort` fields and will empty thinking panels even when Codex is configured correctly.
|
||||
|
||||
Full guide: [THINKING_BUDGET.md](./THINKING_BUDGET.md).
|
||||
|
||||
Compression and prompt cache are independent and keep working under `passthrough`.
|
||||
|
||||
---
|
||||
|
||||
## Profiles — named configurations per model/workflow
|
||||
|
||||
89
docs/guides/THINKING_BUDGET.md
Normal file
89
docs/guides/THINKING_BUDGET.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: "Thinking Budget"
|
||||
version: 3.8.49
|
||||
lastUpdated: 2026-08-12
|
||||
---
|
||||
|
||||
# Thinking Budget
|
||||
|
||||
> **Dashboard:** Settings → **AI** → Thinking Budget
|
||||
> **API:** `GET` / `PUT` `/api/settings/thinking-budget`
|
||||
> **Source:** `open-sse/services/thinkingBudget.ts`
|
||||
|
||||
Thinking Budget controls whether OmniRoute **rewrites client thinking/reasoning parameters** on the way to providers. It does **not** turn compression, routing, or prompt cache on or off.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | What OmniRoute does | When to use |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`passthrough`** (default) | Leaves client fields alone (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini `thinking_config`, etc.). | **Codex / Desktop / any client that should control effort + reasoning summaries.** Required for visible thinking panels when the client requests `reasoning.summary`. |
|
||||
| **`auto`** | **Strips all** thinking/reasoning fields from the request body before upstream. | Only when you deliberately want the **provider** to invent defaults and you do **not** need client-controlled thinking. **Not** “auto-show thinking”. |
|
||||
| **`custom`** | Overwrites every request with a fixed thinking token budget. | Hard cap on thinking tokens for all traffic. |
|
||||
| **`adaptive`** | Scales budget from a base effort using message count, tools, and prompt length. | Soft token control without fully stripping client intent. |
|
||||
|
||||
### What `auto` removes
|
||||
|
||||
When mode is `auto`, `stripThinkingConfig()` deletes (among others):
|
||||
|
||||
- OpenAI / Responses: `reasoning`, `reasoning_effort`
|
||||
- Claude: `thinking`, and `output_config.effort` when present
|
||||
- Gemini: `generationConfig.thinking_config` / `thinkingConfig`
|
||||
|
||||
If a client (e.g. Codex Desktop) sent `reasoning: { effort: "ultra", summary: "detailed" }`, **auto drops that object**. Upstream may still bill some reasoning tokens, but often returns **empty or encrypted-only** reasoning items — so the UI shows no useful thinking stream.
|
||||
|
||||
## What this is **not**
|
||||
|
||||
| Feature | Relationship |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Compression** (Caveman, RTK, stacked, …) | Separate pipeline. Works under every thinking-budget mode. |
|
||||
| **Prompt / semantic cache** | Separate. Unaffected by thinking-budget mode. |
|
||||
| **Combo routing / fallbacks** | Separate. Unaffected. |
|
||||
| **API-key token limits / cost budgets** | Separate. Unaffected. |
|
||||
| **Reasoning replay cache** | Multi-turn re-inject for strict providers (DeepSeek, Kimi, Qwen-thinking, …). Not the same as Desktop “show thinking”. |
|
||||
| **Decrypting `encrypted_content`** | **Impossible.** OpenAI/Codex private reasoning blobs are opaque. OmniRoute never decrypts them (#7095 / #7176 / #7304). |
|
||||
|
||||
## Visible thinking (Codex / Responses clients)
|
||||
|
||||
For a client to show thinking text you need **all** of:
|
||||
|
||||
1. Thinking Budget mode = **`passthrough`** (or custom/adaptive that still leaves summary requests intact enough for the path you use).
|
||||
2. Client asks for a summary, e.g. Codex `model_reasoning_summary = "detailed"` / `auto` (not `none`).
|
||||
3. Upstream actually streams `response.reasoning_summary_text.*` (or a non-empty `reasoning.summary` on the item).
|
||||
|
||||
If you only get “encrypted private reasoning”, either:
|
||||
|
||||
- mode was **`auto`** (client request was stripped), or
|
||||
- upstream returned `encrypted_content` without summary text (provider limitation; OmniRoute can only surface a placeholder, not plaintext).
|
||||
|
||||
## API examples
|
||||
|
||||
```bash
|
||||
# Read
|
||||
curl -sS https://localhost:20128/api/settings/thinking-budget \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
|
||||
# Recommended for Codex / Desktop thinking visibility
|
||||
curl -sS -X PUT https://localhost:20128/api/settings/thinking-budget \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
|
||||
```
|
||||
|
||||
Schema (`updateThinkingBudgetSchema`): `mode` ∈ `passthrough|auto|custom|adaptive`; optional `customBudget`, `effortLevel`, `baseBudget`, `complexityMultiplier`.
|
||||
|
||||
### Persistence / restart
|
||||
|
||||
Value is stored under settings key `thinkingBudget` and hydrated at process start (`hydrateThinkingBudgetConfig`). After changing via DB or some non-API paths, **restart the OmniRoute process** so the in-memory singleton matches disk.
|
||||
|
||||
## Operator checklist
|
||||
|
||||
- [ ] Codex / Desktop users: mode = **passthrough**
|
||||
- [ ] Compression still enabled if you want token savings on **messages**, not by stripping thinking
|
||||
- [ ] Do not expect `auto` to “show more thinking”
|
||||
- [ ] Encrypted-only summaries are a **provider** behavior; passthrough cannot decrypt them
|
||||
|
||||
## Related docs
|
||||
|
||||
- [REASONING_REPLAY.md](../routing/REASONING_REPLAY.md) — multi-turn `reasoning_content` cache
|
||||
- [USER_GUIDE.md](./USER_GUIDE.md) — Settings dashboard tabs
|
||||
- [API_REFERENCE.md](../reference/API_REFERENCE.md) — settings endpoints
|
||||
@@ -895,15 +895,15 @@ curl -X POST http://localhost:20128/api/db-backups/import \
|
||||
|
||||
The settings page is organized into **7 tabs** for easy navigation:
|
||||
|
||||
| Tab | Contents |
|
||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
|
||||
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
|
||||
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
|
||||
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
|
||||
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
|
||||
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
|
||||
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
|
||||
| Tab | Contents |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
|
||||
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
|
||||
| **AI** | Thinking budget (passthrough / auto-strip / custom / adaptive — see [THINKING_BUDGET.md](./THINKING_BUDGET.md)), global system prompt, prompt cache stats |
|
||||
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
|
||||
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
|
||||
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
|
||||
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
|
||||
|
||||
General no longer duplicates read-only logging and cache notes. Database retention and
|
||||
optimization settings are persisted through `/api/settings/database`; manual cache clearing uses
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"pages": [
|
||||
"SETUP_GUIDE",
|
||||
"USER_GUIDE",
|
||||
"THINKING_BUDGET",
|
||||
"DOCKER_GUIDE",
|
||||
"ELECTRON_GUIDE",
|
||||
"FEATURES",
|
||||
|
||||
@@ -6840,9 +6840,17 @@ paths:
|
||||
- Images
|
||||
summary: Document OCR
|
||||
description: >-
|
||||
Mistral OCR–compatible document OCR endpoint. Accepts a JSON body
|
||||
referencing a document/image and returns extracted text. Success
|
||||
responses carry the `X-OmniRoute-*` cost-telemetry headers.
|
||||
Multi-provider document OCR endpoint (Mistral OCR–compatible request
|
||||
and response shape). Accepts a JSON body referencing a document/image
|
||||
and returns extracted text. `model` selects the provider via a
|
||||
`provider/model` prefix (e.g. `mistral/mistral-ocr-latest`,
|
||||
`azure-document-intelligence/prebuilt-read`); a bare model id (e.g.
|
||||
`mistral-ocr-latest`) resolves to its registered provider, and an
|
||||
omitted `model` defaults to Mistral. Azure Document Intelligence is
|
||||
asynchronous upstream — the handler polls the returned operation
|
||||
until it succeeds or fails before responding, so this endpoint can
|
||||
take longer to return for that provider. Success responses carry the
|
||||
`X-OmniRoute-*` cost-telemetry headers.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@@ -6854,6 +6862,11 @@ paths:
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
description: >-
|
||||
`provider/model` id or bare model id. Registered ids:
|
||||
`mistral/mistral-ocr-latest`,
|
||||
`azure-document-intelligence/prebuilt-read`. Defaults to
|
||||
`mistral-ocr-latest` when omitted.
|
||||
document:
|
||||
type: object
|
||||
responses:
|
||||
|
||||
@@ -17,6 +17,7 @@ Complete reference for all OmniRoute API endpoints.
|
||||
- [Chat Completions](#chat-completions)
|
||||
- [Embeddings](#embeddings)
|
||||
- [Image Generation](#image-generation)
|
||||
- [Document OCR](#document-ocr)
|
||||
- [List Models](#list-models)
|
||||
- [Provider Plugin Manifest](#provider-plugin-manifest)
|
||||
- [Compatibility Endpoints](#compatibility-endpoints)
|
||||
@@ -61,24 +62,24 @@ Content-Type: application/json
|
||||
|
||||
### Custom Headers
|
||||
|
||||
| Header | Direction | Description |
|
||||
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
|
||||
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
|
||||
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
|
||||
| `X-Session-Id` | Request | Sticky session key for external session affinity |
|
||||
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
|
||||
| Header | Direction | Description |
|
||||
| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
|
||||
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
|
||||
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
|
||||
| `X-Session-Id` | Request | Sticky session key for external session affinity |
|
||||
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
|
||||
| `X-OmniRoute-Session-Id` | Request | Caller-supplied session/conversation tag (also feeds memory). When present, persisted verbatim to `call_logs.session_tag` for per-session cost attribution (#8249) — never synthesized when absent |
|
||||
| `Idempotency-Key` | Request | Dedup key (5s window) |
|
||||
| `X-Request-Id` | Request | Alternative dedup key |
|
||||
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
|
||||
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
|
||||
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
|
||||
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
|
||||
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
|
||||
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
|
||||
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
|
||||
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
|
||||
| `Idempotency-Key` | Request | Dedup key (5s window) |
|
||||
| `X-Request-Id` | Request | Alternative dedup key |
|
||||
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
|
||||
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
|
||||
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
|
||||
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
|
||||
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
|
||||
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
|
||||
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
|
||||
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
|
||||
|
||||
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
|
||||
|
||||
@@ -199,6 +200,53 @@ GET /v1/images/generations
|
||||
|
||||
---
|
||||
|
||||
## Document OCR
|
||||
|
||||
```bash
|
||||
POST /v1/ocr
|
||||
Authorization: Bearer your-api-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "mistral/mistral-ocr-latest",
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/invoice.pdf"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`model` selects the OCR provider via a `provider/model` prefix; a bare model id (e.g.
|
||||
`mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to
|
||||
Mistral (`mistral-ocr-latest`). Registered providers (`open-sse/config/ocrRegistry.ts`):
|
||||
|
||||
| Provider id | Model id | `model` value | Notes |
|
||||
| ----------------------------- | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `mistral` | `mistral-ocr-latest` | `mistral/mistral-ocr-latest` (or bare `mistral-ocr-latest`) | Synchronous — the response is returned directly from the single upstream call. |
|
||||
| `azure-document-intelligence` | `prebuilt-read` | `azure-document-intelligence/prebuilt-read` | Asynchronous upstream (`analyze` + poll) — see below. |
|
||||
|
||||
Both providers respond in the same Mistral-shaped body:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [{ "index": 0, "markdown": "# Extracted text..." }],
|
||||
"model": "mistral-ocr-latest",
|
||||
"usage_info": { "pages_processed": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
### Azure Document Intelligence poll flow
|
||||
|
||||
Azure Document Intelligence's `analyze` API is asynchronous: the initial request returns an
|
||||
`Operation-Location` header instead of a body, and the result must be polled for. The handler
|
||||
(`open-sse/handlers/ocr.ts`) polls that URL every second for up to 30 attempts, fails fast (does
|
||||
not keep polling) on a non-`ok` poll response or a `"failed"` status, and returns `504` if the
|
||||
operation is still running after the attempt budget is exhausted. The final Azure response is
|
||||
normalized into the same `pages`/`markdown` shape used by Mistral before being returned to the
|
||||
caller, so client code does not need to special-case the provider.
|
||||
|
||||
---
|
||||
|
||||
## List Models
|
||||
|
||||
```bash
|
||||
@@ -349,9 +397,9 @@ Web/search provider abstraction (Tavily, Brave, Exa, Serper, etc.).
|
||||
Extract content from a URL via a configured web-fetch provider (Firecrawl, Jina
|
||||
Reader, Tavily Extract, TinyFish Fetch).
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | -------------- | ------------------------------------------------------------------------- |
|
||||
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------- | --------------------------------------------------------- |
|
||||
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
|
||||
|
||||
**Auth:** Bearer API key (`extractApiKey` + `isValidApiKey`). Policy enforced via `enforceApiKeyPolicy`.
|
||||
|
||||
@@ -489,18 +537,18 @@ call**, so the reported `X-OmniRoute-Response-Latency` is near-zero
|
||||
(benchmarking, p50/p99 monitoring) should check the
|
||||
`X-OmniRoute-Cache-Latency` response header:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| Value | Meaning |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| `synthetic` | Response served from cache; latency is not real upstream time |
|
||||
| *(absent)* | Response from real upstream call |
|
||||
| _(absent)_ | Response from real upstream call |
|
||||
|
||||
### Per-key cache bypass
|
||||
|
||||
API keys can opt out of semantic cache reads via `cacheDefaultMode`:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `legacy` | Normal cache behavior (default) |
|
||||
| Value | Behavior |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| `legacy` | Normal cache behavior (default) |
|
||||
| `bypass` | Skip cache lookup entirely; always hit upstream |
|
||||
|
||||
Set at key creation (`POST /api/keys`) or update (`PATCH /api/keys/[id]`):
|
||||
@@ -561,28 +609,28 @@ X-OmniRoute-No-Cache: true
|
||||
|
||||
### Usage & Analytics
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------- | --------------- | ------------------------------- |
|
||||
| `/api/usage/history` | GET | Usage history |
|
||||
| `/api/usage/logs` | GET | Usage logs |
|
||||
| `/api/usage/request-logs` | GET | Request-level logs |
|
||||
| `/api/usage/[connectionId]` | GET | Per-connection usage |
|
||||
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
|
||||
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
|
||||
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/api/usage/history` | GET | Usage history |
|
||||
| `/api/usage/logs` | GET | Usage logs |
|
||||
| `/api/usage/request-logs` | GET | Request-level logs |
|
||||
| `/api/usage/[connectionId]` | GET | Per-connection usage |
|
||||
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
|
||||
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
|
||||
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
|
||||
|
||||
### Settings
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------------- | ------------- | --------------------------------------------------- |
|
||||
| `/api/settings` | GET/PUT/PATCH | General settings |
|
||||
| `/api/settings/proxy` | GET/PUT | Network proxy config |
|
||||
| `/api/settings/proxy/test` | POST | Test proxy connection |
|
||||
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
|
||||
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
|
||||
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
|
||||
| `/api/settings/compression` | GET/PUT | Global compression config |
|
||||
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/api/settings` | GET/PUT/PATCH | General settings |
|
||||
| `/api/settings/proxy` | GET/PUT | Network proxy config |
|
||||
| `/api/settings/proxy/test` | POST | Test proxy connection |
|
||||
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
|
||||
| `/api/settings/thinking-budget` | GET/PUT | Thinking/reasoning **request** rewrite mode (passthrough / auto-strip / custom / adaptive). Independent of compression. See [THINKING_BUDGET.md](../guides/THINKING_BUDGET.md). |
|
||||
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
|
||||
| `/api/settings/compression` | GET/PUT | Global compression config |
|
||||
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
|
||||
|
||||
### Context & Compression
|
||||
|
||||
@@ -603,13 +651,13 @@ X-OmniRoute-No-Cache: true
|
||||
|
||||
### Monitoring
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
|
||||
| Endpoint | Method | Description |
|
||||
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
|
||||
|
||||
### Backup & Export/Import
|
||||
|
||||
|
||||
@@ -680,6 +680,7 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
|
||||
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
|
||||
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
|
||||
| `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. |
|
||||
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
|
||||
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
|
||||
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
|
||||
@@ -714,6 +715,21 @@ Provider-level circuit breaker tuning. Defaults reflect the scaled values used s
|
||||
| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | Reset window (ms) for API-key provider breaker. |
|
||||
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Consecutive failure threshold for local providers (Ollama, LM Studio, ...). |
|
||||
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | Reset window (ms) for local provider breaker. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD` | `10` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire OAuth provider enters cooldown. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS` | `900000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for OAuth providers. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the OAuth provider threshold is reached. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD` | `5` | `open-sse/config/constants.ts` | OAuth provider enters DEGRADED at this many failures. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER` | `8` | `open-sse/config/constants.ts` | OAuth provider max resetTimeout escalation multiplier. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT` | `2` | `open-sse/config/constants.ts` | OAuth provider escalates after this many open cycles. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD` | `15` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire API-key provider enters cooldown. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS` | `1800000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for API-key providers. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS` | `600000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the API-key provider threshold is reached. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD` | `7` | `open-sse/config/constants.ts` | API-key provider enters DEGRADED at this many failures. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER` | `4` | `open-sse/config/constants.ts` | API-key provider max resetTimeout escalation multiplier. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT` | `3` | `open-sse/config/constants.ts` | API-key provider escalates after this many open cycles. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Provider-level breaker: failures before the entire local provider enters cooldown. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for local providers. |
|
||||
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS` | `60000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the local provider threshold is reached. |
|
||||
| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | Backoff depth at which a context-cache pin's provider is deemed durably unhealthy and the pin is dropped for failover. |
|
||||
| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | Anti-flap window (ms) tolerating brief transient cooldowns before dropping a context-cache pin. |
|
||||
|
||||
@@ -753,6 +769,7 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
|
||||
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
|
||||
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
|
||||
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Whole request/response body size (KB) before it's replaced by a bare summary instead of the full clone. Raise this if long agentic conversations show a placeholder instead of the real messages in the dashboard. |
|
||||
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
|
||||
|
||||
---
|
||||
@@ -1446,7 +1463,6 @@ These settings were introduced after the previous environment-contract snapshot.
|
||||
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
|
||||
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
|
||||
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
|
||||
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
|
||||
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
|
||||
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
|
||||
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |
|
||||
|
||||
203
electron/package-lock.json
generated
203
electron/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^43.2.0",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -297,45 +297,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
|
||||
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"minimist": "^1.2.8",
|
||||
"postject": "^1.0.0-alpha.6"
|
||||
},
|
||||
"bin": {
|
||||
"electron-windows-sign": "bin/electron-windows-sign.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
|
||||
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -1130,15 +1091,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1415,9 +1367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "43.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz",
|
||||
"integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==",
|
||||
"version": "43.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-43.3.0.tgz",
|
||||
"integrity": "sha512-nLlvu0WFjftWsSaTkV2B/c4NDuJBspTyXu8vKSQ6vLvFt8uG3NgN49LLKcXddwX0GqVvAQDhciWp+4xOdTdhew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1459,19 +1411,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder-squirrel-windows": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
|
||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
"electron-winstaller": "5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
|
||||
@@ -1506,66 +1445,6 @@
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
"fs-extra": "^7.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"temp": "^0.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2480,20 +2359,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2757,36 +2622,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"postject": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
@@ -2981,21 +2816,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
@@ -3251,21 +3071,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/temp": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-file": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^43.2.0",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -229,13 +229,13 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD", 8),
|
||||
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS", 60000),
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: 10, // Scaled for 500+ connections (was 3)
|
||||
providerFailureWindowMs: 900000, // 15min window (was 10min)
|
||||
providerCooldownMs: 300000, // 5min cooldown when threshold reached
|
||||
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD", 10), // Scaled for 500+ connections (was 3)
|
||||
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS", 900000), // 15min window (was 10min)
|
||||
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS", 300000), // 5min cooldown when threshold reached
|
||||
// Adaptive circuit breaker v2 settings
|
||||
degradationThreshold: 5, // Enter DEGRADED at this many failures
|
||||
maxBackoffMultiplier: 8, // Max 8x resetTimeout escalation
|
||||
backoffEscalationCount: 2, // Escalate after 2 open cycles
|
||||
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD", 5), // Enter DEGRADED at this many failures
|
||||
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER", 8), // Max 8x resetTimeout escalation
|
||||
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT", 2), // Escalate after 2 open cycles
|
||||
},
|
||||
apikey: {
|
||||
transientCooldown: 3000, // 3s (API providers recover faster)
|
||||
@@ -244,12 +244,12 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD", 12),
|
||||
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: 15, // Scaled for 500+ connections (was 5)
|
||||
providerFailureWindowMs: 1800000, // 30min window (was 20min)
|
||||
providerCooldownMs: 600000, // 10min cooldown when threshold reached
|
||||
degradationThreshold: 7,
|
||||
maxBackoffMultiplier: 4,
|
||||
backoffEscalationCount: 3,
|
||||
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
|
||||
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min)
|
||||
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
|
||||
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
|
||||
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
|
||||
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
|
||||
},
|
||||
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
|
||||
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
|
||||
@@ -261,9 +261,9 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD", 2),
|
||||
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS", 15000),
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: 2, // 2 failures trigger provider cooldown
|
||||
providerFailureWindowMs: 300000, // 5min window for counting failures
|
||||
providerCooldownMs: 60000, // 1min cooldown when threshold reached
|
||||
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD", 2), // 2 failures trigger provider cooldown
|
||||
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS", 300000), // 5min window for counting failures
|
||||
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS", 60000), // 1min cooldown when threshold reached
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
* derives membership from here instead of duplicating it by hand, so adding a
|
||||
* provider to a registry automatically surfaces it — no second edit, no drift.
|
||||
*
|
||||
* Kinds without a backing registry (imageToText, webSearch, webFetch, llm) are
|
||||
* still declared explicitly via `serviceKinds` on the provider entry; callers
|
||||
* union the two sources.
|
||||
* `imageToText` is additionally derived from `OCR_PROVIDERS` (see
|
||||
* `resolveProviderServiceKinds`): a provider registered in the OCR registry gets
|
||||
* `imageToText` for free, no manual `serviceKinds` edit needed. Kinds without any
|
||||
* backing registry (webSearch, webFetch, llm) are still declared explicitly via
|
||||
* `serviceKinds` on the provider entry; callers union declared + derived sources.
|
||||
*/
|
||||
import { AUDIO_TRANSCRIPTION_PROVIDERS, AUDIO_SPEECH_PROVIDERS } from "./audioRegistry.ts";
|
||||
import { VIDEO_PROVIDERS } from "./videoRegistry.ts";
|
||||
@@ -58,7 +60,8 @@ export function getRegistryMediaKinds(providerId: string): RegistryMediaKind[] {
|
||||
|
||||
/**
|
||||
* Full set of serviceKinds for a provider: the explicitly declared ones (llm,
|
||||
* web*, imageToText) unioned with the media kinds derived from the registries.
|
||||
* web*, imageToText) unioned with the media kinds derived from the registries,
|
||||
* plus `imageToText` derived from the OCR registry when not already declared.
|
||||
*/
|
||||
export function resolveProviderServiceKinds(
|
||||
providerId: string,
|
||||
@@ -66,5 +69,8 @@ export function resolveProviderServiceKinds(
|
||||
): string[] {
|
||||
const set = new Set<string>(declared ?? []);
|
||||
for (const kind of getRegistryMediaKinds(providerId)) set.add(kind);
|
||||
if (Object.prototype.hasOwnProperty.call(OCR_PROVIDERS, providerId)) {
|
||||
set.add("imageToText");
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface OcrProvider {
|
||||
authType: string;
|
||||
authHeader: string;
|
||||
models: OcrModel[];
|
||||
transformation?: OcrTransformation;
|
||||
}
|
||||
|
||||
export interface ParsedOcrModel {
|
||||
@@ -23,6 +24,86 @@ export interface ParsedOcrModel {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface OcrResponseShape {
|
||||
pages: Array<{ index: number; markdown: string }>;
|
||||
model: string;
|
||||
usage_info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OcrTransformation {
|
||||
buildRequest(args: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
body: Record<string, unknown>;
|
||||
modelId: string;
|
||||
}): { url: string; init: RequestInit };
|
||||
parseResponse(raw: unknown): OcrResponseShape;
|
||||
/** Async providers (Azure DI): return the poll URL from the first response, else null. */
|
||||
pollUrl?(res: Response): string | null;
|
||||
}
|
||||
|
||||
export const MISTRAL_PASSTHROUGH: OcrTransformation = {
|
||||
buildRequest({ baseUrl, token, body, modelId }) {
|
||||
return {
|
||||
url: baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...body, model: modelId }),
|
||||
},
|
||||
};
|
||||
},
|
||||
parseResponse(raw) {
|
||||
return raw as OcrResponseShape;
|
||||
},
|
||||
};
|
||||
|
||||
export function getOcrTransformation(providerId: string): OcrTransformation {
|
||||
return OCR_PROVIDERS[providerId]?.transformation ?? MISTRAL_PASSTHROUGH;
|
||||
}
|
||||
|
||||
const AZURE_DI_API_VERSION = "2024-11-30";
|
||||
|
||||
function azureDiSource(document: Record<string, unknown> | undefined): Record<string, string> {
|
||||
if (!document) return {};
|
||||
const url = String(document.document_url ?? document.image_url ?? "");
|
||||
if (url.startsWith("data:")) {
|
||||
const comma = url.indexOf(",");
|
||||
return { base64Source: comma >= 0 ? url.slice(comma + 1) : "" };
|
||||
}
|
||||
return url ? { urlSource: url } : {};
|
||||
}
|
||||
|
||||
export const AZURE_DI_TRANSFORMATION: OcrTransformation = {
|
||||
buildRequest({ baseUrl, token, body, modelId }) {
|
||||
const root = baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
url: `${root}/documentintelligence/documentModels/${modelId}:analyze?api-version=${AZURE_DI_API_VERSION}&outputContentFormat=markdown`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": token },
|
||||
body: JSON.stringify(azureDiSource(body.document as Record<string, unknown>)),
|
||||
},
|
||||
};
|
||||
},
|
||||
pollUrl(res) {
|
||||
return res.headers.get("Operation-Location");
|
||||
},
|
||||
parseResponse(raw) {
|
||||
const r = raw as {
|
||||
analyzeResult?: { content?: string; pages?: unknown[] };
|
||||
};
|
||||
const pageCount = r.analyzeResult?.pages?.length ?? 1;
|
||||
// Azure returns the whole-document markdown in `content`; we mirror it into the
|
||||
// Mistral shape as a single aggregated "page" (index 0), preserving pageCount.
|
||||
return {
|
||||
pages: [{ index: 0, markdown: r.analyzeResult?.content ?? "" }],
|
||||
model: "prebuilt-read",
|
||||
usage_info: { pages_processed: pageCount },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
@@ -31,6 +112,14 @@ export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }],
|
||||
},
|
||||
"azure-document-intelligence": {
|
||||
id: "azure-document-intelligence",
|
||||
baseUrl: "",
|
||||
authType: "apikey",
|
||||
authHeader: "Ocp-Apim-Subscription-Key",
|
||||
models: [{ id: "prebuilt-read", name: "Azure Document Intelligence (Read)" }],
|
||||
transformation: AZURE_DI_TRANSFORMATION,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
const CROF_REASONING_EFFORTS = ["none", "low", "medium", "high", "max"] as const;
|
||||
|
||||
export const crofProvider: RegistryEntry = {
|
||||
id: "crof",
|
||||
@@ -9,30 +10,147 @@ export const crofProvider: RegistryEntry = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// Seed list — runtime /v1/models discovery keeps this fresh.
|
||||
// Source: GET https://crof.ai/v1/models (2026-05-17).
|
||||
// Source: GET https://crof.ai/v1/models (2026-08-10; includes models absent from the 2026-05-17 roster).
|
||||
models: [
|
||||
{
|
||||
id: "deepseek-v4-pro-precision",
|
||||
name: "DeepSeek V4 Pro (Precision)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro-lightning",
|
||||
name: "DeepSeek V4 Pro (Lightning)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash-0731",
|
||||
name: "DeepSeek V4 Flash 0731",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "kimi-k2.6-precision", name: "Kimi K2.6 (Precision)", supportsReasoning: true },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", supportsReasoning: true },
|
||||
{ id: "kimi-k2.5-lightning", name: "Kimi K2.5 (Lightning)", supportsReasoning: true },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5", supportsReasoning: true },
|
||||
{ id: "glm-5.1-precision", name: "GLM 5.1 (Precision)", supportsReasoning: true },
|
||||
{ id: "glm-5.1", name: "GLM 5.1", supportsReasoning: true },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "mimo-v2.5-pro-precision", name: "Mimo 2.5 Pro (Precision)", supportsReasoning: true },
|
||||
{ id: "mimo-v2.5-pro", name: "Mimo 2.5 Pro", supportsReasoning: true },
|
||||
{ id: "gemma-4-31b-it", name: "Gemma 4 31B", supportsReasoning: true },
|
||||
{
|
||||
id: "kimi-k2.6-precision",
|
||||
name: "Kimi K2.6 (Precision)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k3",
|
||||
name: "Kimi K3",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k3-eco",
|
||||
name: "Kimi K3 Eco",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5-lightning",
|
||||
name: "Kimi K2.5 (Lightning)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "glm-5.1-precision",
|
||||
name: "GLM 5.1 (Precision)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7-flash",
|
||||
name: "GLM 4.7 Flash",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "mimo-v2.5-pro-precision",
|
||||
name: "Mimo 2.5 Pro (Precision)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "mimo-v2.5-pro",
|
||||
name: "Mimo 2.5 Pro",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "gemma-4-31b-it",
|
||||
name: "Gemma 4 31B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "qwen3.6-27b", name: "Qwen3.6 27B", supportsReasoning: true },
|
||||
{ id: "qwen3.5-397b-a17b", name: "Qwen3.5 397B A17B", supportsReasoning: true },
|
||||
{ id: "qwen3.5-9b", name: "Qwen3.5 9B", supportsReasoning: true },
|
||||
{
|
||||
id: "qwen3.6-27b",
|
||||
name: "Qwen3.6 27B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "qwen3.5-397b-a17b",
|
||||
name: "Qwen3.5 397B A17B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: "qwen3.5-9b",
|
||||
name: "Qwen3.5 9B",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* The key is genuinely optional: probed live 2026-08-11 with no Authorization
|
||||
* header, /chat/completions still answered 200 (kilo-auto/free routed to
|
||||
* stepfun/step-3.7-flash) — so authType stays "optional", matching ovhcloud.
|
||||
*/
|
||||
export const kilo_gatewayProvider: RegistryEntry = {
|
||||
id: "kilo-gateway",
|
||||
alias: "kg",
|
||||
@@ -7,7 +12,7 @@ export const kilo_gatewayProvider: RegistryEntry = {
|
||||
executor: "default",
|
||||
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
|
||||
modelsUrl: "https://api.kilo.ai/api/gateway/models",
|
||||
authType: "apikey",
|
||||
authType: "optional",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { RegistryEntry } from "../../../shared.ts";
|
||||
|
||||
export const KIMI_WEB_STATIC_MODELS = [
|
||||
{ id: "k3", name: "K3", supportsReasoning: true },
|
||||
{ id: "k2d6", name: "K2.6", supportsReasoning: true },
|
||||
{ id: "k3", name: "K3", supportsReasoning: true, toolCalling: false },
|
||||
{ id: "k2d6", name: "K2.6", supportsReasoning: true, toolCalling: false },
|
||||
];
|
||||
|
||||
export const kimi_webProvider: RegistryEntry = {
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Poolside — first-party OpenAI-compatible inference host (inference.poolside.ai).
|
||||
*
|
||||
* Keys are self-service (`sky_…`, platform.poolside.ai). The catalog endpoint is
|
||||
* authenticated: without a key `/v1/models` answers 401 with the body
|
||||
* `No Authorization header provided`, which is what an earlier generic probe read
|
||||
* back as "invalid key" and led to the entry being dropped (#2723, #3054).
|
||||
* With a key it answers 200 and returns exactly the two Preview models below
|
||||
* (authenticated probe 2026-08-07, #9085).
|
||||
*
|
||||
* The IDs here are the ones the live catalog returns — `poolside/laguna-xs-2.1`,
|
||||
* not the `laguna-xs.2` form carried by third-party listings and by the
|
||||
* aggregator catalogs in this repo (routeway, cline), whose IDs are namespaced by
|
||||
* the aggregator and do not address this host. Both models are text-only, report
|
||||
* `tools` and `reasoning`, and are free during Preview. `passthroughModels` stays
|
||||
* on so live discovery keeps admitting models the Preview adds later; upstream
|
||||
* publishes no rate-limit headers.
|
||||
*/
|
||||
export const poolsideProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "poolside",
|
||||
alias: "poolside",
|
||||
baseUrl: "https://inference.poolside.ai/v1/chat/completions",
|
||||
modelsUrl: "https://inference.poolside.ai/v1/models",
|
||||
models: [],
|
||||
models: [
|
||||
{
|
||||
id: "poolside/laguna-xs-2.1",
|
||||
name: "Laguna XS 2.1",
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
contextLength: 262144,
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
{
|
||||
id: "poolside/laguna-s-2.1",
|
||||
name: "Laguna S 2.1",
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
contextLength: 262144,
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
],
|
||||
passthroughModels: true,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KIMI_CODING_ANTHROPIC_URL,
|
||||
KIMI_CODING_OPENAI_URL,
|
||||
} from "../config/providers/registry/kimi/coding/runtime.ts";
|
||||
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import type { ProviderCredentials } from "./base.ts";
|
||||
@@ -182,6 +183,7 @@ function normalizeOpenAIRequest(
|
||||
delete next.max_tokens;
|
||||
|
||||
applyOpenAIThinking(next, policy);
|
||||
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
|
||||
|
||||
if (stream) {
|
||||
next.stream_options = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import type { ProviderCredentials } from "./base.ts";
|
||||
|
||||
@@ -103,6 +104,7 @@ export function normalizeMoonshotRequest(model: string, body: unknown): unknown
|
||||
if (!normalizedModel.startsWith("kimi-")) return body;
|
||||
|
||||
const next: JsonRecord = { ...record };
|
||||
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
|
||||
const isK3 = /^kimi-k3(?:$|-)/.test(normalizedModel);
|
||||
const isK27 = /^kimi-k2\.7-code(?:$|-)/.test(normalizedModel);
|
||||
const isK26 = /^kimi-k2\.6(?:$|-)/.test(normalizedModel);
|
||||
|
||||
@@ -367,7 +367,9 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
// value risks upstream rejection (#5720 regressed with "opencode/local"), and this
|
||||
// is deployment-specific. So it stays OFF by default and the VPS operator enables it
|
||||
// with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied
|
||||
// headers always take precedence.
|
||||
// headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is
|
||||
// replaced with the synthesized CLI UA because opencode.ai's free tier rejects
|
||||
// generic client UAs from datacenter IPs (FreeUsageLimitError 429).
|
||||
const synthesizeCli = /^(1|true|yes|on)$/i.test(
|
||||
process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? ""
|
||||
);
|
||||
|
||||
@@ -148,6 +148,36 @@ export interface PplxBlock {
|
||||
}>;
|
||||
goals?: Array<{ description?: string }>;
|
||||
};
|
||||
// Workflow API (`intended_usage: "workflow_root"`). Perplexity moved the answer
|
||||
// text here from markdown_block: it now arrives as one WORKFLOW_ITEM_TEXT item
|
||||
// whose `text_payload.variant` is "answer", nested under a workflow step. Other
|
||||
// variants ("thinking") and item types (queries, sources) are not answer text.
|
||||
workflow_block?: PplxWorkflowBlock;
|
||||
}
|
||||
|
||||
export interface PplxWorkflowTextPayload {
|
||||
text?: string;
|
||||
chunks?: string[];
|
||||
variant?: string;
|
||||
is_streaming?: boolean;
|
||||
}
|
||||
|
||||
export interface PplxWorkflowItem {
|
||||
type?: string;
|
||||
variant?: string;
|
||||
payload?: { text_payload?: PplxWorkflowTextPayload };
|
||||
}
|
||||
|
||||
export interface PplxWorkflowStep {
|
||||
status?: string;
|
||||
title?: string;
|
||||
tool_name?: string;
|
||||
items?: PplxWorkflowItem[];
|
||||
}
|
||||
|
||||
export interface PplxWorkflowBlock {
|
||||
status?: string;
|
||||
steps?: PplxWorkflowStep[];
|
||||
}
|
||||
|
||||
export interface PplxUpsellInformation {
|
||||
@@ -427,6 +457,134 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer-text items carry this `variant`; "thinking" and friends are not answer text. */
|
||||
const WORKFLOW_ANSWER_VARIANT = "answer";
|
||||
|
||||
/**
|
||||
* mdState key for one workflow answer item. Keyed per step+item so the
|
||||
* `/chunks/<k>` indices of two concurrent items can never overwrite each other.
|
||||
*/
|
||||
function workflowUsageKey(stepIdx: number, itemIdx: number): string {
|
||||
return `workflow_root:${stepIdx}:${itemIdx}`;
|
||||
}
|
||||
|
||||
function isAnswerItem(item: PplxWorkflowItem | undefined): boolean {
|
||||
if (!item) return false;
|
||||
const payloadVariant = item.payload?.text_payload?.variant;
|
||||
return (payloadVariant ?? item.variant) === WORKFLOW_ANSWER_VARIANT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed an accumulator from a materialized answer item. Chunks win over `text`:
|
||||
* the terminal frame can carry a `text` that lags the chunk track (same
|
||||
* precedence markdown_block already uses for `chunks` over `answer`).
|
||||
*/
|
||||
function seedFromAnswerItem(acc: MarkdownAccumulator, item: PplxWorkflowItem): void {
|
||||
const tp = item.payload?.text_payload;
|
||||
if (!tp) return;
|
||||
if (Array.isArray(tp.chunks) && tp.chunks.length > 0) {
|
||||
acc.chunks = tp.chunks.map((c) => String(c));
|
||||
} else if (typeof tp.text === "string" && tp.text.length > 0) {
|
||||
acc.chunks = [tp.text];
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAcc(mdState: Map<string, MarkdownAccumulator>, key: string): MarkdownAccumulator {
|
||||
let acc = mdState.get(key);
|
||||
if (!acc) {
|
||||
acc = { chunks: [] };
|
||||
mdState.set(key, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a `field: "workflow_block"` diff patch set.
|
||||
*
|
||||
* Live shapes (Aug 2026 capture, pplx-auto / mode=copilot):
|
||||
* {op:"add", path:"/steps/1", value:{items:[…]}}
|
||||
* {op:"add", path:"/steps/0/items/1", value:{…}}
|
||||
* {op:"add", path:"/steps/1/items/0/payload/text_payload/chunks/2", value:"…"}
|
||||
* {op:"replace", path:"/steps/1/items/0/payload/text_payload/text", value:"…"}
|
||||
*
|
||||
* Only answer-variant items are accumulated; step/status patches are ignored.
|
||||
*/
|
||||
export function applyWorkflowDiff(
|
||||
mdState: Map<string, MarkdownAccumulator>,
|
||||
patches: PplxDiffPatch[]
|
||||
): void {
|
||||
for (const patch of patches) {
|
||||
const path = patch.path ?? "";
|
||||
|
||||
// Whole step materialized — pick up every answer item it carries.
|
||||
const stepMatch = /^\/steps\/(\d+)$/.exec(path);
|
||||
if (stepMatch) {
|
||||
const stepIdx = Number.parseInt(stepMatch[1], 10);
|
||||
const step = (patch.value ?? {}) as PplxWorkflowStep;
|
||||
(step.items ?? []).forEach((item, itemIdx) => {
|
||||
if (!isAnswerItem(item)) return;
|
||||
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single item appended to an existing step.
|
||||
const itemMatch = /^\/steps\/(\d+)\/items\/(\d+)$/.exec(path);
|
||||
if (itemMatch) {
|
||||
const item = (patch.value ?? {}) as PplxWorkflowItem;
|
||||
if (!isAnswerItem(item)) continue;
|
||||
const key = workflowUsageKey(
|
||||
Number.parseInt(itemMatch[1], 10),
|
||||
Number.parseInt(itemMatch[2], 10)
|
||||
);
|
||||
seedFromAnswerItem(ensureAcc(mdState, key), item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Incremental chunk append — the streaming hot path.
|
||||
const chunkMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/chunks\/(\d+)$/.exec(
|
||||
path
|
||||
);
|
||||
if (chunkMatch && typeof patch.value === "string") {
|
||||
const key = workflowUsageKey(
|
||||
Number.parseInt(chunkMatch[1], 10),
|
||||
Number.parseInt(chunkMatch[2], 10)
|
||||
);
|
||||
// Only extend a track already seeded by an answer item: a chunk patch
|
||||
// carries no variant, so an unseeded key could be a "thinking" track.
|
||||
const acc = mdState.get(key);
|
||||
if (!acc) continue;
|
||||
acc.chunks[Number.parseInt(chunkMatch[3], 10)] = patch.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Terminal `text` materialization — only used when no chunks arrived.
|
||||
const textMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/text$/.exec(path);
|
||||
if (textMatch && typeof patch.value === "string" && patch.value.length > 0) {
|
||||
const key = workflowUsageKey(
|
||||
Number.parseInt(textMatch[1], 10),
|
||||
Number.parseInt(textMatch[2], 10)
|
||||
);
|
||||
const acc = mdState.get(key);
|
||||
if (!acc || acc.chunks.join("").length > 0) continue;
|
||||
acc.chunks = [patch.value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Accumulate every answer item of a materialized workflow_block. */
|
||||
export function applyWorkflowBlock(
|
||||
mdState: Map<string, MarkdownAccumulator>,
|
||||
workflow: PplxWorkflowBlock
|
||||
): void {
|
||||
(workflow.steps ?? []).forEach((step, stepIdx) => {
|
||||
(step.items ?? []).forEach((item, itemIdx) => {
|
||||
if (!isAnswerItem(item)) return;
|
||||
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant answer from the COMPLETED frame's `text` step-blob.
|
||||
*
|
||||
@@ -646,6 +804,18 @@ export async function* extractContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Content: workflow_block answer items. Perplexity migrated the answer text
|
||||
// here from markdown_block, so this must run BEFORE the isAnswerTextUsage
|
||||
// gate — the carrying usage is "workflow_root", which that gate rejects.
|
||||
if (block.workflow_block) {
|
||||
applyWorkflowBlock(mdState, block.workflow_block);
|
||||
continue;
|
||||
}
|
||||
if (block.diff_block?.field === "workflow_block") {
|
||||
applyWorkflowDiff(mdState, block.diff_block.patches ?? []);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content: answer-text blocks (schematized diff frames OR materialized
|
||||
// markdown_block on the final COMPLETED frame).
|
||||
if (!isAnswerTextUsage(usage)) continue;
|
||||
|
||||
@@ -46,11 +46,21 @@ import {
|
||||
} from "../shared/zedAuth.ts";
|
||||
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
|
||||
|
||||
// Wire values for the `provider` field of POST /completions. These are NOT
|
||||
// display names: cloud.zed.dev matches them exactly, and an unrecognized value
|
||||
// fails the whole request with `500 {"message":"An internal server error
|
||||
// occurred."}` before the model is ever looked at — which is why every model id,
|
||||
// including invalid ones, produced an identical 500.
|
||||
//
|
||||
// The spellings come from Zed's own GET /models catalog, which reports
|
||||
// `anthropic`, `open_ai` and `google` (note the underscore); `x_ai` follows the
|
||||
// same convention. Feeding a catalog value back through normalizeZedProvider is
|
||||
// therefore identity, as it must be.
|
||||
const ZED_PROVIDER = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAi",
|
||||
google: "Google",
|
||||
xai: "XAi",
|
||||
anthropic: "anthropic",
|
||||
openai: "open_ai",
|
||||
google: "google",
|
||||
xai: "x_ai",
|
||||
} as const;
|
||||
|
||||
type ZedProviderName = (typeof ZED_PROVIDER)[keyof typeof ZED_PROVIDER];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts";
|
||||
import {
|
||||
extractRequestToolIdentityMap,
|
||||
toToolNameAliasMap,
|
||||
} from "./chatCore/requestToolIdentity.ts";
|
||||
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
|
||||
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
|
||||
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
|
||||
@@ -144,7 +147,11 @@ import {
|
||||
getExplicitModelOutputCap,
|
||||
resolveInputTokenCapForGate,
|
||||
} from "@/lib/modelCapabilities.ts";
|
||||
import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts";
|
||||
import {
|
||||
checkRequestCapabilityFit,
|
||||
deriveRequestCapabilityRequirements,
|
||||
buildCapabilityMismatchMessage,
|
||||
} from "@/shared/constants/capabilities/capabilityFilter.ts";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
|
||||
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
|
||||
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
|
||||
@@ -418,6 +425,7 @@ export async function handleChatCore({
|
||||
comboStrategy = null,
|
||||
isCombo = false,
|
||||
routingComboId = null,
|
||||
sessionAffinityKey = null,
|
||||
comboStepId = null,
|
||||
comboExecutionKey = null,
|
||||
cachedSettings = null,
|
||||
@@ -881,6 +889,10 @@ export async function handleChatCore({
|
||||
"x-omniroute-session-id"
|
||||
)) || null;
|
||||
const pipelineSessionId = explicitSessionIdHeader || skillRequestId;
|
||||
const reasoningReplaySessionKey = sessionAffinityKey || explicitSessionIdHeader;
|
||||
const reasoningCacheScope = reasoningReplaySessionKey
|
||||
? `api-key:${String(apiKeyInfo?.id ?? "local")}\x1f${String(reasoningReplaySessionKey)}`
|
||||
: null;
|
||||
// persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context
|
||||
// once so the 16 call sites keep passing only the per-attempt args (byte-identical).
|
||||
const persistAttemptLogs = (args: PersistAttemptLogsArgs) =>
|
||||
@@ -2039,6 +2051,7 @@ export async function handleChatCore({
|
||||
preserveDeveloperRole,
|
||||
preserveCacheControl,
|
||||
copilotClient: copilotCompatibleReasoning,
|
||||
reasoningCacheScope,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2203,6 +2216,7 @@ export async function handleChatCore({
|
||||
preserveCacheControl,
|
||||
signatureNamespace: connectionId,
|
||||
copilotClient: copilotCompatibleReasoning,
|
||||
reasoningCacheScope,
|
||||
...(preCompressionBody ? { preCompressionBody } : {}),
|
||||
}
|
||||
);
|
||||
@@ -2317,13 +2331,8 @@ export async function handleChatCore({
|
||||
// response toolNameMap so the response translator can restore tool names
|
||||
// from their lowercased form (#9568). Only merge string-valued entries
|
||||
// (tool name aliases), not object-valued namespace identities (#7936).
|
||||
if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) {
|
||||
const hasStringValues = [...requestToolIdentityMap.values()].every(
|
||||
(v: unknown) => typeof v === "string"
|
||||
);
|
||||
if (hasStringValues) {
|
||||
toolNameMap = requestToolIdentityMap;
|
||||
}
|
||||
if (!toolNameMap) {
|
||||
toolNameMap = toToolNameAliasMap(requestToolIdentityMap);
|
||||
}
|
||||
delete translatedBody._toolNameMap;
|
||||
delete translatedBody._disableToolPrefix;
|
||||
@@ -2637,8 +2646,11 @@ export async function handleChatCore({
|
||||
}
|
||||
// === /Quota Share enforcement PRE-hook ===
|
||||
if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) {
|
||||
const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }),
|
||||
deriveRequestCapabilityRequirements(body as Record<string, unknown>), provider);
|
||||
const fit = checkRequestCapabilityFit(
|
||||
getResolvedModelCapabilities({ provider, model: effectiveModel }),
|
||||
deriveRequestCapabilityRequirements(body as Record<string, unknown>),
|
||||
provider
|
||||
);
|
||||
if (!fit.compatible) {
|
||||
const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel);
|
||||
log?.warn?.("CAPABILITY", msg);
|
||||
@@ -4380,16 +4392,23 @@ export async function handleChatCore({
|
||||
// Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses
|
||||
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
|
||||
try {
|
||||
const firstChoice = translatedResponse?.choices?.[0];
|
||||
const cacheResponse = translatedResponse?.choices?.[0]
|
||||
? translatedResponse
|
||||
: needsTranslation(responsePayloadFormat, FORMATS.OPENAI)
|
||||
? translateNonStreamingResponse(
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
const msg = firstChoice?.message;
|
||||
// The response being cached now will be replayed as history on the *next*
|
||||
// turn, where the read side (translator/index.ts) keys the lookup by the
|
||||
// message's real position in that future `messages` array — i.e. right
|
||||
// after everything the client sent this turn.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
|
||||
?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
scope: reasoningCacheScope,
|
||||
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the response
|
||||
@@ -4815,14 +4834,24 @@ export async function handleChatCore({
|
||||
if (normalizedStreamStatus === 200 && streamResponseBody) {
|
||||
try {
|
||||
const streamBody = streamResponseBody as Record<string, unknown>;
|
||||
const choices = streamBody.choices as { message?: Record<string, unknown> }[] | undefined;
|
||||
const cacheStreamBody = Array.isArray(streamBody.choices)
|
||||
? streamBody
|
||||
: needsTranslation(clientResponseFormat, FORMATS.OPENAI)
|
||||
? (translateNonStreamingResponse(
|
||||
streamBody,
|
||||
clientResponseFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
) as Record<string, unknown>)
|
||||
: streamBody;
|
||||
const choices = cacheStreamBody.choices as
|
||||
{ message?: Record<string, unknown> }[] | undefined;
|
||||
const msg = choices?.[0]?.message;
|
||||
// See the non-streaming capture above: messageIndex must match the
|
||||
// position this message will occupy in the *next* turn's history.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
|
||||
?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
scope: reasoningCacheScope,
|
||||
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the stream
|
||||
|
||||
@@ -60,9 +60,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
|
||||
|
||||
/**
|
||||
* Truncate a large object for logging. If its JSON representation exceeds
|
||||
* the configured max body size (getChatLogMaxBodyBytes()), return a
|
||||
* lightweight summary instead of the full clone. This prevents
|
||||
* persistAttemptLogs from holding multi-MB references to translatedBody
|
||||
* getChatLogMaxBodyBytes() (default 1MB; CHAT_LOG_MAX_BODY_KB env override),
|
||||
* return a lightweight summary instead of the full clone. This prevents
|
||||
* persistAttemptLogs from holding unbounded references to translatedBody
|
||||
* across 17 call sites per request.
|
||||
*
|
||||
* When the summarized object carries a `tools` definition, re-attach it
|
||||
@@ -77,6 +77,9 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
|
||||
if (value === null || value === undefined) return value as null | undefined;
|
||||
if (typeof value !== "object") return value as unknown as Record<string, unknown>;
|
||||
const maxBodyBytes = getChatLogMaxBodyBytes();
|
||||
// Pass maxBodyBytes as the early-exit point — otherwise estimateSizeFast's
|
||||
// own default 256KB early-exit caps what it can ever report, silently
|
||||
// making any configured threshold above 256KB unreachable (#trunc-limit-config).
|
||||
const estimatedSize = estimateSizeFast(value, maxBodyBytes);
|
||||
if (estimatedSize <= maxBodyBytes) return value as Record<string, unknown>;
|
||||
// Object is too large — return a summary instead of a deep clone
|
||||
@@ -88,6 +91,11 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
|
||||
if (typeof obj.model === "string") summary.model = obj.model;
|
||||
if (typeof obj.provider === "string") summary.provider = obj.provider;
|
||||
if (Array.isArray(obj.messages)) summary.messageCount = obj.messages.length;
|
||||
// Responses API bodies use `input[]`, not `messages[]` (OpenAI-chat/Gemini-only
|
||||
// field name) — without this, a large /v1/responses request got summarized
|
||||
// with no count at all, leaving the dashboard's "Full Conversation" panel
|
||||
// nothing to base its "N messages not shown" placeholder on.
|
||||
else if (Array.isArray(obj.input)) summary.messageCount = obj.input.length;
|
||||
if (Array.isArray(obj.contents)) summary.contentCount = obj.contents.length;
|
||||
if (typeof obj.stream === "boolean") summary.stream = obj.stream;
|
||||
if (Array.isArray(obj.tools)) summary.tools = cloneBoundedChatLogPayload(obj.tools);
|
||||
|
||||
@@ -5,14 +5,27 @@
|
||||
* Extracted from handleChatCore's non-streaming success path: assemble the context object passed to
|
||||
* `guardrailRegistry.runPostCallHooks`. Pure value builder — no side effects, no early-returns. The
|
||||
* `disabledGuardrails` field is resolved via `resolveDisabledGuardrails` (injectable for tests).
|
||||
* Behaviour is byte-identical to the previous inline literal, including the `method: "POST"` /
|
||||
* `stream: false` constants and the headers/endpoint null-coalescing.
|
||||
* Preserves the previous field mapping and constants while narrowing values from
|
||||
* the untyped request boundary to the public guardrail contract.
|
||||
*/
|
||||
import { resolveDisabledGuardrails as defaultResolveDisabled } from "@/lib/guardrails";
|
||||
import {
|
||||
resolveDisabledGuardrails as defaultResolveDisabled,
|
||||
type GuardrailContext,
|
||||
} from "@/lib/guardrails";
|
||||
|
||||
type LoggerLike = unknown;
|
||||
type LoggerLike = GuardrailContext["log"];
|
||||
type HeadersLike = Headers | Record<string, unknown> | null;
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function buildPostCallGuardrailContext(
|
||||
args: {
|
||||
apiKeyInfo: unknown;
|
||||
@@ -25,23 +38,24 @@ export function buildPostCallGuardrailContext(
|
||||
clientResponseFormat: unknown;
|
||||
},
|
||||
resolveDisabledGuardrails: typeof defaultResolveDisabled = defaultResolveDisabled
|
||||
) {
|
||||
): GuardrailContext {
|
||||
const headers = (args.clientRawRequest?.headers as HeadersLike) ?? null;
|
||||
const apiKeyInfo = optionalRecord(args.apiKeyInfo);
|
||||
return {
|
||||
apiKeyInfo: args.apiKeyInfo,
|
||||
apiKeyInfo,
|
||||
disabledGuardrails: resolveDisabledGuardrails({
|
||||
apiKeyInfo: (args.apiKeyInfo as Record<string, unknown> | null) ?? null,
|
||||
apiKeyInfo,
|
||||
body: args.body,
|
||||
headers,
|
||||
}),
|
||||
endpoint: args.clientRawRequest?.endpoint || null,
|
||||
endpoint: optionalString(args.clientRawRequest?.endpoint),
|
||||
headers,
|
||||
log: args.log,
|
||||
method: "POST",
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
sourceFormat: args.responsePayloadFormat,
|
||||
sourceFormat: optionalString(args.responsePayloadFormat),
|
||||
stream: false,
|
||||
targetFormat: args.clientResponseFormat,
|
||||
} as const;
|
||||
targetFormat: optionalString(args.clientResponseFormat),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
type NamespaceIdentity = { namespace: string; name: string };
|
||||
export type NamespaceIdentity = { namespace: string; name: string };
|
||||
|
||||
/**
|
||||
* Return a string-valued copy only when the complete map is an alias ledger.
|
||||
*
|
||||
* The legacy `_toolNameMap` side channel can carry either response aliases or
|
||||
* namespace identities. Checking every value before copying keeps those two
|
||||
* contracts separate and gives callers a real `Map<string, string>` instead of
|
||||
* asserting an identity map into the alias shape.
|
||||
*/
|
||||
export function toToolNameAliasMap(
|
||||
map: ReadonlyMap<string, unknown> | null
|
||||
): Map<string, string> | null {
|
||||
if (!map || map.size === 0) return null;
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
for (const [wireName, originalName] of map) {
|
||||
if (typeof originalName !== "string") return null;
|
||||
aliases.set(wireName, originalName);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the #7936 request-tool identity map from the translated body and
|
||||
|
||||
@@ -5,21 +5,43 @@ import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
* Handles POST /v1/ocr (Mistral OCR API format).
|
||||
*/
|
||||
|
||||
import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts";
|
||||
import {
|
||||
getOcrProvider,
|
||||
getOcrTransformation,
|
||||
parseOcrModel,
|
||||
OCR_PROVIDERS,
|
||||
} from "../config/ocrRegistry.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
|
||||
const OCR_POLL_MAX_ATTEMPTS = 30;
|
||||
const OCR_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Handle OCR request
|
||||
*
|
||||
* Dispatches to the per-provider transformation (see `open-sse/config/ocrRegistry.ts`)
|
||||
* to build the upstream request, then (for async providers like Azure Document
|
||||
* Intelligence) polls the returned operation URL until it succeeds or fails,
|
||||
* before normalizing the response into the Mistral OCR shape.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - JSON body { model, document }
|
||||
* @param {Object} options.credentials - Provider credentials { apiKey }
|
||||
* @param {Object} options.credentials - Provider credentials { apiKey, accessToken, baseUrl }
|
||||
* @param {Function} [options.fetchImpl] - DI hook for tests; defaults to global fetch
|
||||
* @param {Function} [options.sleepImpl] - DI hook for tests; defaults to a real setTimeout-based sleep
|
||||
* @returns {Response}
|
||||
*/
|
||||
/** @returns {Promise<unknown>} */
|
||||
export async function handleOcr({ body, credentials }) {
|
||||
export async function handleOcr({
|
||||
body,
|
||||
credentials,
|
||||
fetchImpl = fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
if (!body.document) {
|
||||
return errorResponse(400, "document is required");
|
||||
@@ -31,7 +53,10 @@ export async function handleOcr({ body, credentials }) {
|
||||
const providerConfig = providerId ? getOcrProvider(providerId) : null;
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`);
|
||||
return errorResponse(
|
||||
400,
|
||||
`No OCR provider found for model "${model}". Available: ${Object.keys(OCR_PROVIDERS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
@@ -39,18 +64,15 @@ export async function handleOcr({ body, credentials }) {
|
||||
return errorResponse(401, `No credentials for OCR provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const baseUrl = credentials?.baseUrl || providerConfig.baseUrl;
|
||||
if (!baseUrl) {
|
||||
return errorResponse(400, `No base URL configured for OCR provider: ${providerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model: modelId,
|
||||
}),
|
||||
});
|
||||
const transformation = getOcrTransformation(providerId);
|
||||
const { url, init } = transformation.buildRequest({ baseUrl, token, body, modelId });
|
||||
const res = await fetchImpl(url, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
@@ -63,7 +85,17 @@ export async function handleOcr({ body, credentials }) {
|
||||
});
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const pollUrl = transformation.pollUrl?.(res) ?? null;
|
||||
let data: unknown;
|
||||
if (pollUrl) {
|
||||
const authHeader = buildAuthHeader(providerConfig.authHeader, token);
|
||||
data = await pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl });
|
||||
if (data instanceof Response) return data;
|
||||
} else {
|
||||
data = await res.json();
|
||||
}
|
||||
|
||||
const parsed = transformation.parseResponse(data);
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: providerId,
|
||||
@@ -72,8 +104,48 @@ export async function handleOcr({ body, credentials }) {
|
||||
latencyMs: Date.now() - startTime,
|
||||
requestId: generateRequestId(),
|
||||
});
|
||||
return new Response(JSON.stringify(data), { status: 200, headers });
|
||||
return new Response(JSON.stringify(parsed), { status: 200, headers });
|
||||
} catch (err) {
|
||||
return errorResponse(500, `OCR request failed: ${err.message}`);
|
||||
console.error("[OCR]", err);
|
||||
return errorResponse(500, "OCR request failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the same auth header used for the initial upstream request, so the
|
||||
* poll GET (e.g. Azure Document Intelligence's Operation-Location) authenticates
|
||||
* identically.
|
||||
*/
|
||||
function buildAuthHeader(authHeader: string, token: string): Record<string, string> {
|
||||
if (authHeader === "bearer") {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return { [authHeader]: token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll an async OCR operation (Azure Document Intelligence) until it succeeds or fails.
|
||||
*
|
||||
* @returns {Promise<unknown|Response>} the parsed JSON body on success, or an error Response
|
||||
*/
|
||||
async function pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl }) {
|
||||
for (let attempt = 0; attempt < OCR_POLL_MAX_ATTEMPTS; attempt++) {
|
||||
await sleepImpl(OCR_POLL_INTERVAL_MS);
|
||||
const pollRes = await fetchImpl(pollUrl, {
|
||||
method: "GET",
|
||||
headers: authHeader,
|
||||
});
|
||||
if (!pollRes.ok) {
|
||||
console.error("[OCR] poll error", pollRes.status);
|
||||
return errorResponse(502, "OCR analysis failed");
|
||||
}
|
||||
const json = await pollRes.json();
|
||||
if (json.status === "succeeded") {
|
||||
return json;
|
||||
}
|
||||
if (json.status === "failed") {
|
||||
return errorResponse(502, "OCR analysis failed");
|
||||
}
|
||||
}
|
||||
return errorResponse(504, "OCR analysis timed out");
|
||||
}
|
||||
|
||||
@@ -85,18 +85,29 @@ describe("runChaosPanel", () => {
|
||||
});
|
||||
|
||||
describe("serializeChaosPart", () => {
|
||||
it("emits a comment + omni-chaos-part event envelope", () => {
|
||||
it("emits a comment + omni-chaos-part event envelope when custom event is requested", () => {
|
||||
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
|
||||
const s = serializeChaosPart(part, false);
|
||||
const s = serializeChaosPart(part, false, true);
|
||||
expect(s).toContain("event: omni-chaos-part");
|
||||
expect(s).toContain('"type":"omni-chaos-part"');
|
||||
expect(s).toContain('"model":"a/gpt"');
|
||||
expect(s).toContain(": chaos 0 ok a/gpt");
|
||||
});
|
||||
|
||||
it("emits ONLY the SSE comment (no event/data) by default for OpenAI-compatible clients", () => {
|
||||
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
|
||||
const s = serializeChaosPart(part, false);
|
||||
// comment line kept (ignored by every SSE parser by spec)
|
||||
expect(s).toContain(": chaos 0 ok a/gpt");
|
||||
// NO custom event/data — those break openai-node / @ai-sdk validators
|
||||
expect(s).not.toContain("event: omni-chaos-part");
|
||||
expect(s).not.toContain('"type":"omni-chaos-part"');
|
||||
expect(s).not.toMatch(/^data:/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleChaosChat", () => {
|
||||
it("emits broadcast events + final OpenAI chunk", async () => {
|
||||
it("emits ONLY SSE comments (no custom event) by default + final OpenAI chunk", async () => {
|
||||
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
|
||||
const res = await handleChaosChat({
|
||||
body: { messages: [] },
|
||||
@@ -106,13 +117,27 @@ describe("handleChaosChat", () => {
|
||||
expect(res.headers.get("X-OmniRoute-Chaos")).toBe("true");
|
||||
expect(res.headers.get("X-OmniRoute-Chaos-Panel")).toBe("2");
|
||||
const body = await res.text();
|
||||
// each model gets a broadcast event
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
// NO custom event by default — OpenAI-compatible parsers choke on it
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
|
||||
expect(body.match(/^: chaos /gm)?.length ?? 0).toBe(2);
|
||||
// final canonical chunk carries the primary answer
|
||||
expect(body).toContain("ans-b/opus");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("emits omni-chaos-part events when stream_options.include_chaos_parts is set", async () => {
|
||||
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
|
||||
const res = await handleChaosChat({
|
||||
body: { messages: [], stream_options: { include_chaos_parts: true } },
|
||||
models: ["a/gpt", "b/opus"],
|
||||
handleSingleModel: handle,
|
||||
});
|
||||
const body = await res.text();
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
expect(body).toContain("ans-b/opus");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("degrades to a direct call when only one model", async () => {
|
||||
const handle = fakeHandle(async () => textResponse("solo"));
|
||||
const res = await handleChaosChat({
|
||||
@@ -138,8 +163,8 @@ describe("handleChaosChat", () => {
|
||||
// client learns via the error final chunk rather than a bare 503.
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
// each model gets a broadcast fail event
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
|
||||
// NO custom events by default (comments only), error conveyed via final chunk
|
||||
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
|
||||
expect(body).toContain("All chaos panel models failed");
|
||||
expect(body).toContain("[DONE]");
|
||||
});
|
||||
|
||||
@@ -53,16 +53,28 @@ export type ChaosPart = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the SSE comment/event wrapper for one chaos panel part.
|
||||
* We emit a custom event name `omni-chaos-part` so a protocol-aware IDE can
|
||||
* split it out; non-aware clients reading OpenAI-style SSE will simply ignore
|
||||
* the unknown event and use the final `data:` chunk below.
|
||||
* Build the SSE wrapper for one chaos panel part.
|
||||
*
|
||||
* By DEFAULT only an SSE comment (`: chaos ...`) is emitted — comments are
|
||||
* ignored by every SSE parser per spec, so OpenAI-compatible clients
|
||||
* (openai-node, @ai-sdk/openai-compatible, …) never see a non-`choices`
|
||||
* `data:` payload and their schema validation cannot fail with an
|
||||
* `invalid_union` error.
|
||||
*
|
||||
* When `emitCustomEvent` is true (opt-in via
|
||||
* `stream_options.include_chaos_parts`), the custom event name
|
||||
* `omni-chaos-part` + metadata `data:` block is also emitted so a
|
||||
* protocol-aware IDE can split panels out.
|
||||
*
|
||||
* The part's text is NOT included in the metadata event — it arrives in the
|
||||
* final `data:` chunk for the primary model. This keeps each broadcast event
|
||||
* small (metadata-only) so SSE buffering stays predictable.
|
||||
*/
|
||||
export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
|
||||
export function serializeChaosPart(
|
||||
part: ChaosPart,
|
||||
isFinal: boolean,
|
||||
emitCustomEvent = false
|
||||
): string {
|
||||
const meta = {
|
||||
type: "omni-chaos-part",
|
||||
model: part.model,
|
||||
@@ -71,11 +83,11 @@ export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
|
||||
final: isFinal,
|
||||
...(part.error ? { error: part.error } : {}),
|
||||
};
|
||||
return (
|
||||
`: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n` +
|
||||
`event: omni-chaos-part\n` +
|
||||
`data: ${JSON.stringify(meta)}\n\n`
|
||||
);
|
||||
const comment = `: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n`;
|
||||
if (!emitCustomEvent) {
|
||||
return comment + "\n";
|
||||
}
|
||||
return comment + `event: omni-chaos-part\n` + `data: ${JSON.stringify(meta)}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,9 +342,11 @@ function concatSseText(sse: string): string {
|
||||
* `config.chaos.enabled` flag is set (the `auto/chaos` virtual combo).
|
||||
*
|
||||
* Returns a single Response whose body is an SSE stream:
|
||||
* - one `omni-chaos-part` event per panel model, enqueued PROGRESSIVELY as
|
||||
* each model lands (so the client starts receiving answers immediately,
|
||||
* without waiting for the whole panel to finish)
|
||||
* - one SSE comment (`: chaos N ...`) per panel model, enqueued
|
||||
* PROGRESSIVELY as each model lands (comments are ignored by every SSE
|
||||
* parser, so OpenAI-compatible clients see only the final chunk)
|
||||
* - when `stream_options.include_chaos_parts: true` is set, the per-panel
|
||||
* `omni-chaos-part` custom event is emitted instead of the bare comment
|
||||
* - a final `data:` OpenAI-style chunk carrying the primary model's answer
|
||||
* (so non-aware clients / IDEs still get a usable completion)
|
||||
* - a terminating `data: [DONE]`
|
||||
@@ -354,6 +368,14 @@ export async function handleChaosChat(opts: {
|
||||
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
|
||||
const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs;
|
||||
const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel;
|
||||
// Opt-in gate: only protocol-aware clients request the custom event. OpenAI
|
||||
// SDK validators choke on any `data:` payload without `choices`/`error`, so
|
||||
// the default MUST be comment-only output.
|
||||
const streamOptions = (body as Record<string, unknown> | null | undefined)?.stream_options;
|
||||
const emitCustomEvent =
|
||||
typeof streamOptions === "object" &&
|
||||
streamOptions !== null &&
|
||||
(streamOptions as Record<string, unknown>).include_chaos_parts === true;
|
||||
if (panel.length === 0) {
|
||||
return errorResponse(400, "Chaos combo has no models");
|
||||
}
|
||||
@@ -396,7 +418,7 @@ export async function handleChaosChat(opts: {
|
||||
hardTimeout,
|
||||
log,
|
||||
onResult: async (part) => {
|
||||
await safeEnqueue(serializeChaosPart(part, false));
|
||||
await safeEnqueue(serializeChaosPart(part, false, emitCustomEvent));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1908,7 +1908,23 @@ export async function handleComboChat({
|
||||
!isTokenLimitBreach &&
|
||||
!scopedFailure &&
|
||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
||||
// failoverBeforeRetry means what it says: prefer the next sibling
|
||||
// target over hammering this one again. Without this check, a
|
||||
// transient error always re-hit the SAME model up to maxRetries
|
||||
// times regardless of the setting — config.failoverBeforeRetry was
|
||||
// threaded through to skipUpstreamRetry (a different, lower-level
|
||||
// retry mechanism) but never consulted here, so a rate-limited
|
||||
// model got maxRetries+1 back-to-back attempts on itself before
|
||||
// this loop's own fallback-to-next-target ever ran (#2417). Only
|
||||
// skip the same-model retry when `nextTarget` (computed above)
|
||||
// actually gives us somewhere to fail over to — with no sibling
|
||||
// left, skipping just burns the last attempt for nothing.
|
||||
if (
|
||||
retry < maxRetries &&
|
||||
isTransient &&
|
||||
!providerExhausted &&
|
||||
(!config.failoverBeforeRetry || !nextTarget)
|
||||
) {
|
||||
if (
|
||||
!protectedPriorityTarget &&
|
||||
provider &&
|
||||
@@ -1996,6 +2012,24 @@ export async function handleComboChat({
|
||||
strategy,
|
||||
target: toRecordedTarget(target),
|
||||
});
|
||||
// LKGP (#919) mirror of the success-path set below: a just-failed target
|
||||
// must not keep re-pinning itself as the "last known good" choice for the
|
||||
// *next* separate request. Circuit breaker / model lockout deliberately
|
||||
// don't react to request-scoped failure classes (see scopedFailure below),
|
||||
// so nothing else clears this stale pin.
|
||||
void (async () => {
|
||||
try {
|
||||
const { clearLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
clearLKGP(combo.name, target.executionKey),
|
||||
clearLKGP(combo.name, combo.id || combo.name),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
recordedAttempts++;
|
||||
lastError = errorText || String(result.status);
|
||||
comboErrors.push({
|
||||
@@ -2626,7 +2660,8 @@ async function handleRoundRobinCombo({
|
||||
filteredTargets,
|
||||
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
|
||||
// stickiness engages on the /v1/responses surface, not just Chat Completions.
|
||||
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
|
||||
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
|
||||
combo.name
|
||||
);
|
||||
const rrAffinity = applyPromptCacheAffinity(
|
||||
filteredTargets,
|
||||
@@ -3123,7 +3158,18 @@ async function handleRoundRobinCombo({
|
||||
!isTokenLimitBreach &&
|
||||
!scopedFailure &&
|
||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
||||
// See the same guard's comment in the "auto" strategy loop above —
|
||||
// failoverBeforeRetry must prevent this same-model retry too, not
|
||||
// just the lower-level skipUpstreamRetry mechanism. Only skip when
|
||||
// `offset + 1 < modelCount` means a sibling target is actually left
|
||||
// in this rotation; with none left, skipping just wastes the attempt.
|
||||
const hasNextRrTarget = offset + 1 < modelCount;
|
||||
if (
|
||||
retry < maxRetries &&
|
||||
isTransient &&
|
||||
!providerExhausted &&
|
||||
(!config.failoverBeforeRetry || !hasNextRrTarget)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3135,6 +3181,22 @@ async function handleRoundRobinCombo({
|
||||
strategy: "round-robin",
|
||||
target: toRecordedTarget(target),
|
||||
});
|
||||
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
|
||||
// that comment for why this must happen (nothing else clears a pin left
|
||||
// by a request-scoped failure class like a stream-readiness timeout).
|
||||
void (async () => {
|
||||
try {
|
||||
const { clearLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
clearLKGP(combo.name, target.executionKey),
|
||||
clearLKGP(combo.name, combo.id || combo.name),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
recordedAttempts++;
|
||||
lastError = errorText || String(result.status);
|
||||
lastStatus = result.status;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* Design
|
||||
* ──────
|
||||
* • Hash key: SHA-256 of the FIRST user message → first 16 hex chars.
|
||||
* • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity
|
||||
* at production call sites → first 16 hex chars.
|
||||
* Using only the first message gives a stable key that does not change as
|
||||
* the conversation grows, yet still identifies the conversation reliably.
|
||||
* • Headroom gate: before reusing the sticky connection we re-check that its
|
||||
@@ -317,6 +318,23 @@ export function deriveMessageHash(
|
||||
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep one conversation's prompt-cache affinity local to the Combo that learned
|
||||
* it. Without this namespace, two different Combos receiving the same first
|
||||
* user message share a binding and can silently reorder each other's targets.
|
||||
* The unscoped form remains available for direct callers and backwards-compatible
|
||||
* unit seams; production dispatchers always provide their Combo name.
|
||||
*/
|
||||
function scopeMessageHash(messageHash: string, namespace?: string): string {
|
||||
if (!namespace) return messageHash;
|
||||
return createHash("sha256")
|
||||
.update(namespace)
|
||||
.update("\0")
|
||||
.update(messageHash)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Evict expired entries and enforce the hard cap. */
|
||||
function evict(): void {
|
||||
const now = Date.now();
|
||||
@@ -424,19 +442,22 @@ export interface ApplyStickinessResult {
|
||||
*
|
||||
* @param orderedTargets Targets already ordered by the combo strategy.
|
||||
* @param messages Request body.messages.
|
||||
* @param namespace Combo identity that owns this sticky binding.
|
||||
* @returns Result with (possibly reordered) targets.
|
||||
*/
|
||||
export async function applySessionStickiness(
|
||||
orderedTargets: ResolvedComboTarget[],
|
||||
messages: Array<{ role?: string; content?: unknown }> | null | undefined
|
||||
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
|
||||
namespace?: string
|
||||
): Promise<ApplyStickinessResult> {
|
||||
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
|
||||
|
||||
try {
|
||||
if (orderedTargets.length <= 1) return noOp;
|
||||
|
||||
const messageHash = deriveMessageHash(messages);
|
||||
if (!messageHash) return noOp;
|
||||
const rawMessageHash = deriveMessageHash(messages);
|
||||
if (!rawMessageHash) return noOp;
|
||||
const messageHash = scopeMessageHash(rawMessageHash, namespace);
|
||||
|
||||
const existing = stickyMap.get(messageHash);
|
||||
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };
|
||||
|
||||
@@ -498,7 +498,8 @@ async function applyContinuityFilters(
|
||||
initialOrderedTargets,
|
||||
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
|
||||
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
|
||||
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
|
||||
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
|
||||
combo.name
|
||||
);
|
||||
let orderedTargets = sticky.targets;
|
||||
if (!cacheStrategyAffinityApplied) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* @see Issue #1628
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
clearAllReasoningCache,
|
||||
cleanupExpiredReasoning,
|
||||
@@ -137,8 +138,8 @@ type AssistantMessageLike = {
|
||||
};
|
||||
|
||||
type AssistantMessageCacheContext = {
|
||||
requestId?: string;
|
||||
messageIndex?: number;
|
||||
scope?: string;
|
||||
historyMessages?: AssistantMessageLike[];
|
||||
};
|
||||
|
||||
type ToolCallLike = {
|
||||
@@ -234,8 +235,79 @@ export function cacheReasoningByKey(
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssistantMessageCacheKey(requestId: string, messageIndex: number): string {
|
||||
return `request:${requestId}:message:${messageIndex}`;
|
||||
function stableCacheValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableCacheValue);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.filter((key) => key !== "reasoning" && key !== "reasoning_content")
|
||||
.sort()
|
||||
.map((key) => [key, stableCacheValue(record[key])])
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalizeMessageContent(content: unknown): unknown {
|
||||
if (!Array.isArray(content)) return stableCacheValue(content ?? null);
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
textParts.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!part || typeof part !== "object") return stableCacheValue(content);
|
||||
const record = part as Record<string, unknown>;
|
||||
if (
|
||||
(record.type === "text" || record.type === "input_text" || record.type === "output_text") &&
|
||||
typeof record.text === "string"
|
||||
) {
|
||||
textParts.push(record.text);
|
||||
continue;
|
||||
}
|
||||
return stableCacheValue(content);
|
||||
}
|
||||
return textParts.join("");
|
||||
}
|
||||
|
||||
function canonicalizeHistoryMessage(message: AssistantMessageLike): unknown {
|
||||
const record = message as Record<string, unknown>;
|
||||
const toolCalls = Array.isArray(record.tool_calls)
|
||||
? record.tool_calls.map((toolCall) => {
|
||||
const call = toolCall as Record<string, unknown>;
|
||||
const fn = (call.function ?? {}) as Record<string, unknown>;
|
||||
return stableCacheValue({
|
||||
type: call.type,
|
||||
function: { name: fn.name, arguments: fn.arguments },
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
return stableCacheValue({
|
||||
role: record.role,
|
||||
name: record.name,
|
||||
content: canonicalizeMessageContent(record.content),
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAssistantMessageCacheKey(
|
||||
scope: string | null | undefined,
|
||||
messages: AssistantMessageLike[],
|
||||
messageIndex: number
|
||||
): string {
|
||||
const normalizedScope = scope?.trim();
|
||||
if (!normalizedScope || !Number.isInteger(messageIndex) || messageIndex < 0) return "";
|
||||
const message = messages[messageIndex];
|
||||
if (!message || message.role !== "assistant") return "";
|
||||
|
||||
const transcript = messages.slice(0, messageIndex + 1).map(canonicalizeHistoryMessage);
|
||||
const digest = createHash("sha256")
|
||||
.update(normalizedScope)
|
||||
.update("\x1f")
|
||||
.update(JSON.stringify(transcript))
|
||||
.digest("hex");
|
||||
return `conversation:${digest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,18 +354,15 @@ export function cacheReasoningFromAssistantMessage(
|
||||
.filter((id) => id.length > 0)
|
||||
: [];
|
||||
if (toolCallIds.length === 0) {
|
||||
const requestId = context?.requestId?.trim();
|
||||
const messageIndex = context?.messageIndex;
|
||||
if (!requestId || typeof messageIndex !== "number" || !Number.isInteger(messageIndex)) {
|
||||
return 0;
|
||||
}
|
||||
const scope = context?.scope?.trim();
|
||||
const historyMessages = context?.historyMessages;
|
||||
if (!scope || !Array.isArray(historyMessages)) return 0;
|
||||
|
||||
cacheReasoningByKey(
|
||||
buildAssistantMessageCacheKey(requestId, messageIndex),
|
||||
provider,
|
||||
model,
|
||||
reasoning
|
||||
);
|
||||
const messages = [...historyMessages, message];
|
||||
const cacheKey = buildAssistantMessageCacheKey(scope, messages, messages.length - 1);
|
||||
if (!cacheKey) return 0;
|
||||
|
||||
cacheReasoningByKey(cacheKey, provider, model, reasoning);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -329,7 +398,8 @@ export function lookupReasoning(toolCallId: string): string | null {
|
||||
}
|
||||
|
||||
// 2. Fallback to DB
|
||||
let dbResult: { reasoning: string; provider: string; model: string } | null = null;
|
||||
let dbResult: { reasoning: string; provider: string; model: string; expiresAt: string } | null =
|
||||
null;
|
||||
try {
|
||||
dbResult = getReasoningCache(toolCallId);
|
||||
} catch {
|
||||
@@ -341,6 +411,11 @@ export function lookupReasoning(toolCallId: string): string | null {
|
||||
misses++;
|
||||
return null;
|
||||
}
|
||||
const persistedExpiresAt = Date.parse(dbResult.expiresAt);
|
||||
if (!Number.isFinite(persistedExpiresAt) || persistedExpiresAt <= Date.now()) {
|
||||
misses++;
|
||||
return null;
|
||||
}
|
||||
hits++;
|
||||
let promotedReasoning = dbResult.reasoning;
|
||||
if (promotedReasoning.length > MAX_ENTRY_BYTES) {
|
||||
@@ -351,7 +426,7 @@ export function lookupReasoning(toolCallId: string): string | null {
|
||||
reasoning: promotedReasoning,
|
||||
provider: dbResult.provider,
|
||||
model: dbResult.model,
|
||||
expiresAt: Date.now() + TTL_MS,
|
||||
expiresAt: persistedExpiresAt,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return promotedReasoning;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* Thinking Budget Control — Phase 2
|
||||
*
|
||||
* Provides proxy-level control over AI thinking/reasoning budgets.
|
||||
* Modes: auto, passthrough, custom, adaptive
|
||||
* Proxy-level control of **client thinking/reasoning request fields**
|
||||
* (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini thinking_config).
|
||||
*
|
||||
* Modes (see Dashboard → Settings → AI → Thinking Budget):
|
||||
* - passthrough: leave client fields unchanged (required for Codex visible thinking)
|
||||
* - auto: STRIP all thinking/reasoning fields before upstream (not “auto-show thinking”)
|
||||
* - custom: force a fixed token budget on every request
|
||||
* - adaptive: scale budget from a base effort by request complexity
|
||||
*
|
||||
* Independent of compression, prompt cache, combo routing, and API-key token limits.
|
||||
* Does **not** decrypt OpenAI/Codex `encrypted_content` reasoning blobs.
|
||||
*/
|
||||
|
||||
// Thinking budget modes
|
||||
export const ThinkingMode = {
|
||||
AUTO: "auto", // Let provider decide (remove client's budget)
|
||||
PASSTHROUGH: "passthrough", // No changes (current behavior)
|
||||
AUTO: "auto", // Strip all client thinking/reasoning fields (provider invents defaults)
|
||||
PASSTHROUGH: "passthrough", // No changes — client fully controls thinking
|
||||
CUSTOM: "custom", // Set fixed budget
|
||||
ADAPTIVE: "adaptive", // Scale based on request complexity
|
||||
};
|
||||
@@ -247,7 +256,9 @@ export function applyThinkingBudget(
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO mode: strip all thinking configuration, let provider decide
|
||||
* AUTO mode: strip all thinking/reasoning configuration from the request body.
|
||||
* Upstream then runs without client-requested effort/summary — this can hide
|
||||
* thinking panels in Codex/Desktop and is the opposite of “show thinking”.
|
||||
*/
|
||||
function stripThinkingConfig(body: unknown) {
|
||||
const result: JsonRecord = { ...toRecord(body) };
|
||||
|
||||
@@ -163,3 +163,20 @@ export function sanitizeOpenAITool(tool: unknown): unknown {
|
||||
export function sanitizeOpenAITools(tools: unknown[]): unknown[] {
|
||||
return tools.map(sanitizeOpenAITool);
|
||||
}
|
||||
|
||||
export function flattenOpenAIToolRootAnyOf(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const next = { ...tool };
|
||||
const fn = isPlainObject(next.function) ? { ...next.function } : next;
|
||||
if (!isPlainObject(fn.parameters) || !hasOwn(fn.parameters, "anyOf")) return tool;
|
||||
|
||||
const parameters = { ...fn.parameters };
|
||||
delete parameters.anyOf;
|
||||
fn.parameters = parameters;
|
||||
if (fn !== next) next.function = fn;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,6 +209,12 @@ export function createResponsesApiTransformStream(
|
||||
funcItemTypes: {},
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
// Cached at first computation (see toolCallOutputIndexBase) so every
|
||||
// added/delta/done event for a given tool call — including ones emitted
|
||||
// later from the finish_reason handler or flush(), where the reasoning/
|
||||
// message state used to derive the base is no longer meaningful to
|
||||
// recompute — shares exactly the same output_index.
|
||||
funcOutputIndex: {} as Record<string, number>,
|
||||
completedOutputItems: [] as Array<{
|
||||
output_index: number;
|
||||
item: Record<string, unknown>;
|
||||
@@ -380,6 +386,27 @@ export function createResponsesApiTransformStream(
|
||||
}
|
||||
};
|
||||
|
||||
// Tool calls sit after reasoning (if any) AND after a text message (if one
|
||||
// was actually emitted this turn). The provider's own tool_calls[].index is
|
||||
// scoped only to the tool_calls array and legitimately restarts at 0 — using
|
||||
// it directly as the Responses API output_index collides with whatever
|
||||
// reasoning/message item already claimed that slot, and a client that
|
||||
// tracks response items by output_index silently drops the tool call.
|
||||
//
|
||||
// Computed once per tcIdx (from the chunk's own choice index, `chunkIdx`)
|
||||
// and cached in state.funcOutputIndex so every added/delta/done event for
|
||||
// that call — including ones emitted later from the finish_reason handler
|
||||
// or flush(), which have no fresh chunk/reasoning/message state to
|
||||
// recompute from — shares exactly the same output_index.
|
||||
const computeToolCallOutputIndex = (chunkIdx, tcIdx) => {
|
||||
if (state.funcOutputIndex[tcIdx] === undefined) {
|
||||
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : chunkIdx;
|
||||
const base = state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx;
|
||||
state.funcOutputIndex[tcIdx] = base + normalizeOutputIndex(tcIdx);
|
||||
}
|
||||
return state.funcOutputIndex[tcIdx];
|
||||
};
|
||||
|
||||
const emitToolCallAdded = (controller, idx) => {
|
||||
if (state.funcItemAdded[idx] || !state.funcCallIds[idx]) return false;
|
||||
|
||||
@@ -390,7 +417,7 @@ export function createResponsesApiTransformStream(
|
||||
|
||||
emit(controller, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: idx,
|
||||
output_index: state.funcOutputIndex[idx],
|
||||
item: {
|
||||
id: `fc_${state.funcCallIds[idx]}`,
|
||||
type: itemType,
|
||||
@@ -406,7 +433,7 @@ export function createResponsesApiTransformStream(
|
||||
const closeToolCall = (controller, idx, recordAsCompleted = true) => {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const normalizedIndex = normalizeOutputIndex(idx);
|
||||
const normalizedIndex = state.funcOutputIndex[idx];
|
||||
let args = state.funcArgsBuf[idx] || "{}";
|
||||
const toolName = state.funcNames[idx] || "";
|
||||
emitToolCallAdded(controller, idx);
|
||||
@@ -750,6 +777,7 @@ export function createResponsesApiTransformStream(
|
||||
|
||||
for (const tc of delta.tool_calls) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const outputIndex = computeToolCallOutputIndex(idx, tcIdx);
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
@@ -765,6 +793,10 @@ export function createResponsesApiTransformStream(
|
||||
delete state.funcItemTypes[tcIdx];
|
||||
delete state.funcArgsDone[tcIdx];
|
||||
delete state.funcItemDone[tcIdx];
|
||||
// Deliberately keep funcOutputIndex[tcIdx]: the replacement call
|
||||
// reuses the same positional slot, so it should keep the same
|
||||
// output_index rather than recomputing (which could drift if
|
||||
// msgItemAdded state shifted mid-turn).
|
||||
}
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
@@ -786,7 +818,7 @@ export function createResponsesApiTransformStream(
|
||||
emit(controller, "response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${state.funcCallIds[tcIdx]}`,
|
||||
output_index: tcIdx,
|
||||
output_index: outputIndex,
|
||||
delta: state.funcArgsBuf[tcIdx],
|
||||
});
|
||||
}
|
||||
@@ -825,7 +857,7 @@ export function createResponsesApiTransformStream(
|
||||
emit(controller, "response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${refCallId}`,
|
||||
output_index: tcIdx,
|
||||
output_index: outputIndex,
|
||||
delta: emittedDelta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ import { getResolvedModelCapabilities, supportsReasoning } from "../services/mod
|
||||
import { normalizeRoles } from "../services/roleNormalizer.ts";
|
||||
import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts";
|
||||
import {
|
||||
buildAssistantMessageCacheKey,
|
||||
lookupReasoning,
|
||||
recordReplay,
|
||||
requiresReasoningReplay,
|
||||
} from "../services/reasoningCache.ts";
|
||||
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
|
||||
import {
|
||||
normalizeResponsesReasoningEffort,
|
||||
RESPONSES_STORE_MARKER,
|
||||
} from "./request/openai-responses/helpers.ts";
|
||||
|
||||
bootstrapTranslatorRegistry();
|
||||
export { register } from "./registry.ts";
|
||||
@@ -146,25 +150,6 @@ function normalizeOpenAIResponsesRequest(body) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getReasoningCacheRequestId(body: Record<string, unknown> | null | undefined): string {
|
||||
if (!body || typeof body !== "object") return "";
|
||||
|
||||
const requestId =
|
||||
body._reasoningCacheRequestId ??
|
||||
body.reasoningCacheRequestId ??
|
||||
body.request_id ??
|
||||
body.requestId;
|
||||
return typeof requestId === "string" ? requestId.trim() : "";
|
||||
}
|
||||
|
||||
function getAssistantMessageCacheKey(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
messageIndex: number
|
||||
): string {
|
||||
const requestId = getReasoningCacheRequestId(body);
|
||||
return requestId ? `request:${requestId}:message:${messageIndex}` : "";
|
||||
}
|
||||
|
||||
function hasNonEmptyReasoningContent(message: Record<string, unknown>): boolean {
|
||||
return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0;
|
||||
}
|
||||
@@ -250,6 +235,7 @@ export function translateRequest(
|
||||
preserveCacheControl?: boolean;
|
||||
signatureNamespace?: string | null;
|
||||
preCompressionBody?: Record<string, unknown> | null;
|
||||
reasoningCacheScope?: string | null;
|
||||
/** UA-detected GitHub Copilot client. Forwarded to translators via the
|
||||
* transient `_copilotClient` credential flag (see openai-responses → openai). */
|
||||
copilotClient?: boolean;
|
||||
@@ -265,13 +251,6 @@ export function translateRequest(
|
||||
const normalizedModel = String(model ?? "");
|
||||
const isKimiCoding =
|
||||
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
|
||||
const requiresExplicitReasoningReplay = requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
allowLegacyFallback: false,
|
||||
});
|
||||
const preserveResponsesReasoning =
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;
|
||||
|
||||
// Phase 2: Apply thinking budget control before normalization
|
||||
result = applyThinkingBudget(result);
|
||||
@@ -282,6 +261,29 @@ export function translateRequest(
|
||||
// Normalize thinking config: remove if lastMessage is not user
|
||||
normalizeThinkingConfig(result);
|
||||
|
||||
// Resolve the replay contract before Responses input is converted: conversion
|
||||
// must know whether reasoning items are protocol history rather than display metadata.
|
||||
const resolvedCapabilities = getResolvedModelCapabilities({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
});
|
||||
const replayRequirements = {
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
thinkingEnabled: hasThinkingConfig(result),
|
||||
supportsReasoning: supportsReasoning({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
}),
|
||||
interleavedField: resolvedCapabilities?.interleavedField ?? null,
|
||||
};
|
||||
const isReasoner = requiresReasoningReplay(replayRequirements);
|
||||
const requiresExplicitReasoningReplay = requiresReasoningReplay({
|
||||
...replayRequirements,
|
||||
allowLegacyFallback: false,
|
||||
});
|
||||
const preserveResponsesReasoning = sourceFormat === FORMATS.OPENAI_RESPONSES && isReasoner;
|
||||
|
||||
// Ensure tool_calls have id; optionally normalize to 9-char for providers like Mistral
|
||||
ensureToolCallIds(result, { use9CharId });
|
||||
|
||||
@@ -421,24 +423,6 @@ export function translateRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
|
||||
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
|
||||
// replay providers) and the cache re-injection further down.
|
||||
const resolvedCapabilities = getResolvedModelCapabilities({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
});
|
||||
const isReasoner = requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
thinkingEnabled: hasThinkingConfig(result),
|
||||
supportsReasoning: supportsReasoning({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
}),
|
||||
interleavedField: resolvedCapabilities?.interleavedField ?? null,
|
||||
});
|
||||
|
||||
// Always normalize to clean OpenAI format when target is OpenAI
|
||||
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||
if (targetFormat === FORMATS.OPENAI) {
|
||||
@@ -650,7 +634,11 @@ export function translateRequest(
|
||||
|
||||
const cacheKey = hasToolCalls
|
||||
? msg.tool_calls[0]?.id
|
||||
: getAssistantMessageCacheKey(result, messageIndex);
|
||||
: buildAssistantMessageCacheKey(
|
||||
options?.reasoningCacheScope,
|
||||
result.messages,
|
||||
messageIndex
|
||||
);
|
||||
if (cacheKey) {
|
||||
const cached = lookupReasoning(cacheKey);
|
||||
if (cached) {
|
||||
@@ -700,6 +688,19 @@ export function translateRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// #<store-marker-leak>: a Responses-source request stashes the client's
|
||||
// `store` intent under this internal marker (see the Responses -> OpenAI
|
||||
// step above) so a later OpenAI -> Responses re-conversion can restore it
|
||||
// as `store`. When the destination stays in Chat Completions shape (no
|
||||
// such re-conversion happens), nothing else consumes the marker, and it
|
||||
// was leaking verbatim into the real upstream request body — e.g. OpenAI
|
||||
// itself rejects it with "Unknown parameter: '_omnirouteResponsesStore'".
|
||||
// Always drop it here: any handler that still needs the client's original
|
||||
// `store` value would have already read the marker before this point.
|
||||
if (RESPONSES_STORE_MARKER in result) {
|
||||
delete result[RESPONSES_STORE_MARKER];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -235,21 +235,23 @@ export function openaiResponsesToOpenAIRequest(
|
||||
|
||||
if (itemType === "message") {
|
||||
const role = toString(item.role);
|
||||
// Flush pending assistant message with tool calls
|
||||
if (currentAssistantMsg) {
|
||||
messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
if (role !== "assistant" && pendingReasoningContent) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: pendingReasoningContent,
|
||||
});
|
||||
pendingReasoningContent = "";
|
||||
|
||||
if (role !== "assistant") {
|
||||
if (currentAssistantMsg) {
|
||||
messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
if (pendingReasoningContent) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: pendingReasoningContent,
|
||||
});
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending tool results
|
||||
// Flush pending tool results before the next explicit message boundary.
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const toolResult of pendingToolResults) {
|
||||
messages.push(toolResult);
|
||||
@@ -292,12 +294,29 @@ export function openaiResponsesToOpenAIRequest(
|
||||
})
|
||||
: item.content;
|
||||
|
||||
const message: JsonRecord = { role, content };
|
||||
if (role === "assistant" && pendingReasoningContent) {
|
||||
message.reasoning_content = pendingReasoningContent;
|
||||
pendingReasoningContent = "";
|
||||
if (role === "assistant") {
|
||||
if (!currentAssistantMsg) {
|
||||
currentAssistantMsg = { role, content };
|
||||
} else if (currentAssistantMsg.content == null && content != null) {
|
||||
currentAssistantMsg.content = content;
|
||||
} else if (content != null) {
|
||||
const existingContent = currentAssistantMsg.content;
|
||||
currentAssistantMsg.content = [
|
||||
...(Array.isArray(existingContent) ? existingContent : [existingContent]),
|
||||
...(Array.isArray(content) ? content : [content]),
|
||||
];
|
||||
}
|
||||
if (pendingReasoningContent) {
|
||||
currentAssistantMsg.reasoning_content = appendReasoningContent(
|
||||
currentAssistantMsg.reasoning_content,
|
||||
pendingReasoningContent
|
||||
);
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
messages.push(message);
|
||||
|
||||
messages.push({ role, content });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,12 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
|
||||
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
|
||||
// HTTP 400 from Anthropic.
|
||||
if (!isKimiCoding) {
|
||||
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
|
||||
const fitted = fitThinkingToMaxTokens(
|
||||
model,
|
||||
Number(result.max_tokens) || 0,
|
||||
result.thinking,
|
||||
routedProvider
|
||||
);
|
||||
result.max_tokens = fitted.maxTokens;
|
||||
if (fitted.thinking === undefined) {
|
||||
delete result.thinking;
|
||||
|
||||
@@ -7,9 +7,9 @@ import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts";
|
||||
const MIN_CLAUDE_THINKING_BUDGET = 1024;
|
||||
const MIN_RESPONSE_ROOM = 1024;
|
||||
|
||||
function safeCapMaxOutputTokens(model: string): number | null {
|
||||
function safeCapMaxOutputTokens(model: string, provider?: string | null): number | null {
|
||||
try {
|
||||
const cap = capMaxOutputTokens(model);
|
||||
const cap = capMaxOutputTokens(provider ? { provider, model } : model);
|
||||
return typeof cap === "number" && cap > 0 ? cap : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -31,6 +31,12 @@ function safeCapMaxOutputTokens(model: string): number | null {
|
||||
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
|
||||
* thinking entirely (cap too tight for any reasoning).
|
||||
*
|
||||
* `provider` scopes the cap lookup to a provider-specific override (e.g. a
|
||||
* dashboard-set `max_output_tokens` for `opencode-go/qwen3.7-plus`) when the
|
||||
* model-only entry has no cap of its own. Without it, a model whose real
|
||||
* ceiling is only known per-provider resolves to no cap at all and the
|
||||
* synthesized `max_tokens` goes out unbounded (#10139).
|
||||
*
|
||||
* Worked example (real-world Opus 4.7 case that previously 400'd):
|
||||
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
|
||||
* model cap = 128000.
|
||||
@@ -42,9 +48,10 @@ function safeCapMaxOutputTokens(model: string): number | null {
|
||||
export function fitThinkingToMaxTokens(
|
||||
model: string,
|
||||
callerMaxTokens: number,
|
||||
thinking: Record<string, unknown> | undefined
|
||||
thinking: Record<string, unknown> | undefined,
|
||||
provider?: string | null
|
||||
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
|
||||
const modelCap = safeCapMaxOutputTokens(model);
|
||||
const modelCap = safeCapMaxOutputTokens(model, provider);
|
||||
const requestedBudget = Number(thinking?.budget_tokens) || 0;
|
||||
|
||||
// No budgeted thinking — just cap max_tokens to the model output ceiling.
|
||||
|
||||
@@ -528,9 +528,21 @@ function emitToolCall(state, emit, tc) {
|
||||
|
||||
// Custom tools are surfaced as custom_tool_call items and stream raw input instead of the
|
||||
// function_call_arguments.* events used for regular function tools. (#1007)
|
||||
//
|
||||
// apply_patch defaults to custom (native Codex CLI convention: the model emits it
|
||||
// without the client ever declaring it as a tool) UNLESS the client's own request
|
||||
// explicitly declared it with a `parameters` JSON schema — i.e. as a plain
|
||||
// `type:"function"` tool (state.toolSchemas, populated from body.tools by
|
||||
// extractToolSchemaMap()). Live incident: a client that registers apply_patch as a
|
||||
// function tool and only implements function_call dispatch never recognized the
|
||||
// custom_tool_call item this produced, so the tool call was silently never executed
|
||||
// and no follow-up request ever carried a result back. PR #7905 already intended this
|
||||
// precedence ("...while preserving explicit function-tool precedence") but its
|
||||
// unconditional `toolName === "apply_patch"` OR never actually implemented the carve-out.
|
||||
const toolName = state.funcNames[tcIdx] || funcName || "";
|
||||
const isCustomTool =
|
||||
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
|
||||
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
|
||||
state.customToolNames?.has?.(toolName) === true;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
|
||||
const callId = state.funcCallIds[tcIdx];
|
||||
@@ -597,8 +609,11 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
|
||||
const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx);
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
const toolName = state.funcNames[idx] || "";
|
||||
// See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the
|
||||
// same classification independently for their respective add/close call sites).
|
||||
const isCustomTool =
|
||||
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
|
||||
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
|
||||
state.customToolNames?.has?.(toolName) === true;
|
||||
|
||||
let funcItem;
|
||||
if (isCustomTool) {
|
||||
|
||||
@@ -47,7 +47,10 @@ function findHeader(headers: Record<string, string>, name: string): string | und
|
||||
* the OpenCode CLI identity headers that Cloudflare requires on VPS egress
|
||||
* (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session
|
||||
* UUIDs, but ONLY for keys the client did not already supply. Client values always
|
||||
* win; these defaults only fill gaps. (#5997)
|
||||
* win; these defaults only fill gaps. User-Agent is the one exception: a client UA
|
||||
* that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the
|
||||
* synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs
|
||||
* from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229)
|
||||
*/
|
||||
export function forwardOpencodeClientHeaders(
|
||||
headers: Record<string, string>,
|
||||
@@ -100,14 +103,22 @@ export function forwardOpencodeClientHeaders(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress, but only for
|
||||
* keys the client did not already supply (client values always win). (#5997)
|
||||
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
|
||||
* x-opencode-* headers, client values always win (defaults only fill gaps). The
|
||||
* User-Agent is the exception: a non-CLI client UA (curl, python, SDKs) is replaced
|
||||
* with the synthesized CLI UA, because opencode.ai's free tier flags generic client
|
||||
* UAs from datacenter IPs (FreeUsageLimitError 429). A client UA that already looks
|
||||
* like the OpenCode CLI (opencode-cli/...) is preserved so the real CLI's versioned
|
||||
* identity stays intact. (#5997, follow-up)
|
||||
*/
|
||||
function applyCliDefaults(
|
||||
headers: Record<string, string>,
|
||||
cliDefaults: { userAgent: string; client: string; project: string }
|
||||
): void {
|
||||
if (!headers["User-Agent"] && !headers["user-agent"]) {
|
||||
const existingUa = headers["User-Agent"] || headers["user-agent"];
|
||||
const clientUaIsCliLike =
|
||||
typeof existingUa === "string" && /^opencode-cli\//i.test(existingUa.trim());
|
||||
if (!clientUaIsCliLike) {
|
||||
setUserAgentHeader(headers, cliDefaults.userAgent);
|
||||
}
|
||||
headers["x-opencode-client"] ||= cliDefaults.client;
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
hasUnsupportedReasoningSignal,
|
||||
} from "./reasoningFields.ts";
|
||||
import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts";
|
||||
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
|
||||
import {
|
||||
caseInsensitiveToolNameLookup,
|
||||
restoreOpenAIToolNames,
|
||||
@@ -787,6 +788,29 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
let upstreamErrorForwarded = false;
|
||||
const providerPayloadCollector = createStructuredSSECollector({
|
||||
stage: "provider_response",
|
||||
// #9315: compute the summary live from every pushed chunk (not just the
|
||||
// ones that survive the storage cap below) so a long stream never shows a
|
||||
// stale/incomplete "provider response" in the dashboard.
|
||||
//
|
||||
// Real bug: this was unconditionally `sourceFormat` (the CLIENT's wire
|
||||
// format — see this function's own @param doc above). In TRANSLATE mode
|
||||
// the chunks pushed here are the RAW PROVIDER response, whose format is
|
||||
// `targetFormat` (@param "Provider format (for translate mode)"), not
|
||||
// sourceFormat. Whenever a client's format differs from the provider's
|
||||
// (e.g. a Responses-API client routed to a plain-OpenAI-chat-completions
|
||||
// upstream — the OpenClaw/opencode-zen case that surfaced this live), the
|
||||
// reducer picked for `sourceFormat` could never recognize the provider's
|
||||
// actual event shape, so it never left its empty initial state — the
|
||||
// dashboard's "Provider Response" panel permanently showed
|
||||
// `output: []`/empty while "Client Response" (built from
|
||||
// separately-accumulated state, unaffected by this) correctly showed full
|
||||
// content, reading as if the two panels simply disagreed. PASSTHROUGH
|
||||
// mode has no separate provider/client format split — nothing gets
|
||||
// translated, so the provider's raw chunks genuinely ARE in sourceFormat
|
||||
// (and real passthrough callers, e.g. createPassthroughStreamWithLogger,
|
||||
// don't even pass targetFormat) — keep using sourceFormat there.
|
||||
format: mode === STREAM_MODE.TRANSLATE ? targetFormat : sourceFormat,
|
||||
fallbackModel: model,
|
||||
});
|
||||
const clientPayloadCollector = createStructuredSSECollector({
|
||||
stage: "client_response",
|
||||
@@ -1641,7 +1665,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// retry." with finish_reason: "stop" — clients (Goose/opencode) feed that
|
||||
// text back as a turn and spin in a retry loop. This restores the #3400
|
||||
// behavior that #3422 inadvertently reverted (regression #3388/#3502).
|
||||
if (Array.isArray(parsed.choices) && (parsed.choices.length === 0 ||
|
||||
if (
|
||||
Array.isArray(parsed.choices) &&
|
||||
(parsed.choices.length === 0 ||
|
||||
(parsed.choices.length === 1 &&
|
||||
parsed.choices[0]?.delta &&
|
||||
typeof parsed.choices[0].delta === "object" &&
|
||||
@@ -2483,7 +2509,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// #9315 switched the summary to the accumulated responseBody to avoid
|
||||
// stale/truncated event data — but responseBody here is synthesized in
|
||||
// chat-completion shape, which loses the Responses API `response` object.
|
||||
// Keep the events-derived summary for OPENAI_RESPONSES only.
|
||||
// Keep the events-derived summary for OPENAI_RESPONSES only. responseBody
|
||||
// itself never carries an `object` marker (it's built purely for the
|
||||
// client, which doesn't need one) — the dashboard's Provider Response
|
||||
// panel does, so stamp `object: "chat.completion"` on a shallow copy
|
||||
// used only for this summary, leaving responseBody itself untouched.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
@@ -2491,7 +2521,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
sourceFormat,
|
||||
model
|
||||
)
|
||||
: responseBody,
|
||||
: { object: "chat.completion", ...responseBody },
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
@@ -2600,11 +2630,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
error: err.message,
|
||||
errorCode: err.code,
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
buildStreamSummaryFromEvents(
|
||||
providerPayloadCollector.getEvents(),
|
||||
targetFormat,
|
||||
model
|
||||
),
|
||||
providerPayloadCollector.getSummary(),
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(errorBody, {
|
||||
@@ -2783,7 +2809,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
usage: state?.usage,
|
||||
responseBody,
|
||||
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
|
||||
// the synthesized chat-shaped responseBody drops the `response` object.
|
||||
// the synthesized chat-shaped responseBody drops the `response` object,
|
||||
// and (like the passthrough branch) never carries an `object` marker at
|
||||
// all — stamp `object: "chat.completion"` on a shallow copy used only
|
||||
// for this summary; responseBody itself (sent to the client / below)
|
||||
// stays untouched.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
targetFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
@@ -2791,7 +2821,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
targetFormat,
|
||||
model
|
||||
)
|
||||
: responseBody,
|
||||
: { object: "chat.completion", ...responseBody },
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
|
||||
@@ -12,6 +12,16 @@ type CollectorOptions = {
|
||||
maxEvents?: number;
|
||||
maxBytes?: number;
|
||||
stage?: string;
|
||||
// When set, every pushed payload — even ones dropped from the retained
|
||||
// `events` array once maxEvents/maxBytes is hit — is also fed to a live
|
||||
// per-format summary reducer, so build()'s summary reflects the FULL
|
||||
// stream, not just the surviving (possibly truncated) event slice.
|
||||
// See #9315: reconstructing the summary from getEvents() after the fact
|
||||
// means a long stream that exceeds the cap gets a stale/incomplete
|
||||
// "provider response" (missing tool_calls, wrong finish_reason, cut-off
|
||||
// content) even though the actual served response was correct.
|
||||
format?: string | null;
|
||||
fallbackModel?: string | null;
|
||||
};
|
||||
|
||||
type BuildOptions = {
|
||||
@@ -20,6 +30,11 @@ type BuildOptions = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface SummaryReducer {
|
||||
ingest(payload: JsonRecord): void;
|
||||
finalize(): unknown;
|
||||
}
|
||||
|
||||
function getEventName(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
||||
|
||||
@@ -113,13 +128,15 @@ function tryParseJson(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const payloads = events
|
||||
.map((evt) => asRecord(evt.data))
|
||||
.filter((payload) => Object.keys(payload).length);
|
||||
if (payloads.length === 0) return null;
|
||||
// ─── Per-format live reducers ────────────────────────────────────────────────
|
||||
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
|
||||
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
|
||||
// just restructured so it can be fed one payload at a time as chunks arrive —
|
||||
// including chunks that will later be dropped from the retained event array
|
||||
// once the collector's storage cap is hit.
|
||||
|
||||
const first = payloads[0];
|
||||
function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let first: JsonRecord | null = null;
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
type ToolCall = {
|
||||
@@ -156,124 +173,126 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string
|
||||
return `seq:${unknownToolCallSeq}`;
|
||||
};
|
||||
|
||||
for (const chunk of payloads) {
|
||||
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
|
||||
const delta = asRecord(choice.delta);
|
||||
return {
|
||||
ingest(chunk: JsonRecord) {
|
||||
if (Object.keys(chunk).length === 0) return;
|
||||
if (!first) first = chunk;
|
||||
|
||||
if (typeof delta.content === "string" && delta.content.length > 0) {
|
||||
contentParts.push(delta.content);
|
||||
}
|
||||
if (Array.isArray(delta.content)) {
|
||||
for (const part of delta.content) {
|
||||
const partObj = asRecord(part);
|
||||
if (typeof partObj.text === "string" && partObj.text.length > 0) {
|
||||
contentParts.push(partObj.text);
|
||||
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
|
||||
const delta = asRecord(choice.delta);
|
||||
|
||||
if (typeof delta.content === "string" && delta.content.length > 0) {
|
||||
contentParts.push(delta.content);
|
||||
}
|
||||
if (Array.isArray(delta.content)) {
|
||||
for (const part of delta.content) {
|
||||
const partObj = asRecord(part);
|
||||
if (typeof partObj.text === "string" && partObj.text.length > 0) {
|
||||
contentParts.push(partObj.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
}
|
||||
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
|
||||
if (
|
||||
typeof delta.reasoning === "string" &&
|
||||
delta.reasoning.length > 0 &&
|
||||
!delta.reasoning_content
|
||||
) {
|
||||
reasoningParts.push(delta.reasoning);
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const item of delta.tool_calls) {
|
||||
const toolCall = asRecord(item);
|
||||
const key = getToolCallKey(toolCall);
|
||||
const existing = toolCalls.get(key);
|
||||
const deltaArgs =
|
||||
typeof asRecord(toolCall.function).arguments === "string"
|
||||
? String(asRecord(toolCall.function).arguments)
|
||||
: "";
|
||||
|
||||
if (!existing) {
|
||||
toolCalls.set(key, {
|
||||
id: typeof toolCall.id === "string" ? toolCall.id : null,
|
||||
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
|
||||
type: toString(toolCall.type, "function"),
|
||||
function: {
|
||||
name: toString(asRecord(toolCall.function).name, "unknown"),
|
||||
arguments: deltaArgs,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
|
||||
if (
|
||||
(!Number.isInteger(existing.index) || existing.index < 0) &&
|
||||
Number.isInteger(toolCall.index)
|
||||
) {
|
||||
existing.index = Number(toolCall.index);
|
||||
}
|
||||
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
|
||||
existing.function.name = String(asRecord(toolCall.function).name);
|
||||
}
|
||||
existing.function.arguments += deltaArgs;
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
}
|
||||
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
|
||||
if (
|
||||
typeof delta.reasoning === "string" &&
|
||||
delta.reasoning.length > 0 &&
|
||||
!delta.reasoning_content
|
||||
) {
|
||||
reasoningParts.push(delta.reasoning);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
||||
finishReason = choice.finish_reason;
|
||||
}
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
usage = { ...asRecord(chunk.usage) };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const item of delta.tool_calls) {
|
||||
const toolCall = asRecord(item);
|
||||
const key = getToolCallKey(toolCall);
|
||||
const existing = toolCalls.get(key);
|
||||
const deltaArgs =
|
||||
typeof asRecord(toolCall.function).arguments === "string"
|
||||
? String(asRecord(toolCall.function).arguments)
|
||||
: "";
|
||||
|
||||
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
|
||||
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
|
||||
const message: JsonRecord = {
|
||||
role: "assistant",
|
||||
content: joinedContent || null,
|
||||
if (!existing) {
|
||||
toolCalls.set(key, {
|
||||
id: typeof toolCall.id === "string" ? toolCall.id : null,
|
||||
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
|
||||
type: toString(toolCall.type, "function"),
|
||||
function: {
|
||||
name: toString(asRecord(toolCall.function).name, "unknown"),
|
||||
arguments: deltaArgs,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
|
||||
if (
|
||||
(!Number.isInteger(existing.index) || existing.index < 0) &&
|
||||
Number.isInteger(toolCall.index)
|
||||
) {
|
||||
existing.index = Number(toolCall.index);
|
||||
}
|
||||
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
|
||||
existing.function.name = String(asRecord(toolCall.function).name);
|
||||
}
|
||||
existing.function.arguments += deltaArgs;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
||||
finishReason = choice.finish_reason;
|
||||
}
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
usage = { ...asRecord(chunk.usage) };
|
||||
}
|
||||
},
|
||||
|
||||
finalize(): unknown {
|
||||
if (!first) return null;
|
||||
|
||||
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
|
||||
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
|
||||
const message: JsonRecord = {
|
||||
role: "assistant",
|
||||
content: joinedContent || null,
|
||||
};
|
||||
if (joinedReasoning) {
|
||||
message.reasoning_content = joinedReasoning;
|
||||
}
|
||||
|
||||
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
message.tool_calls = finalToolCalls;
|
||||
}
|
||||
|
||||
const result: JsonRecord = {
|
||||
id: toString(first.id, `chatcmpl-${Date.now()}`),
|
||||
object: "chat.completion",
|
||||
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
|
||||
model: toString(first.model, fallbackModel || "unknown"),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message,
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (usage && Object.keys(usage).length > 0) {
|
||||
result.usage = usage;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
if (joinedReasoning) {
|
||||
message.reasoning_content = joinedReasoning;
|
||||
}
|
||||
|
||||
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
message.tool_calls = finalToolCalls;
|
||||
}
|
||||
|
||||
const result: JsonRecord = {
|
||||
id: toString(first.id, `chatcmpl-${Date.now()}`),
|
||||
object: "chat.completion",
|
||||
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
|
||||
model: toString(first.model, fallbackModel || "unknown"),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message,
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (usage && Object.keys(usage).length > 0) {
|
||||
result.usage = usage;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildResponsesSummary(
|
||||
events: StructuredSSEEvent[],
|
||||
fallbackModel?: string | null
|
||||
): unknown {
|
||||
const payloads = events
|
||||
.map((evt) => asRecord(evt.data))
|
||||
.filter((payload) => Object.keys(payload).length);
|
||||
if (payloads.length === 0) return null;
|
||||
|
||||
function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
let completed: JsonRecord | null = null;
|
||||
let latestResponse: JsonRecord | null = null;
|
||||
let usage: JsonRecord | null = null;
|
||||
@@ -289,67 +308,72 @@ function buildResponsesSummary(
|
||||
]
|
||||
: [];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const eventType = toString(payload.type);
|
||||
if (
|
||||
eventType === "response.completed" &&
|
||||
payload.response &&
|
||||
typeof payload.response === "object"
|
||||
) {
|
||||
completed = asRecord(payload.response);
|
||||
}
|
||||
if (payload.response && typeof payload.response === "object") {
|
||||
latestResponse = asRecord(payload.response);
|
||||
} else if (payload.object === "response") {
|
||||
latestResponse = payload;
|
||||
}
|
||||
if (
|
||||
eventType === "response.output_text.delta" &&
|
||||
typeof payload.delta === "string" &&
|
||||
payload.delta.length > 0
|
||||
) {
|
||||
textParts.push(payload.delta);
|
||||
}
|
||||
if (payload.usage && typeof payload.usage === "object") {
|
||||
usage = { ...asRecord(payload.usage) };
|
||||
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
|
||||
usage = { ...asRecord(asRecord(payload.response).usage) };
|
||||
}
|
||||
}
|
||||
|
||||
const picked = completed || latestResponse;
|
||||
if (picked && Object.keys(picked).length > 0) {
|
||||
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
|
||||
return {
|
||||
id: toString(picked.id, `resp_${Date.now()}`),
|
||||
object: "response",
|
||||
model: toString(picked.model, fallbackModel || "unknown"),
|
||||
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
|
||||
usage: picked.usage ?? usage ?? null,
|
||||
status: toString(picked.status, completed ? "completed" : "in_progress"),
|
||||
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
|
||||
metadata: asRecord(picked.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `resp_${Date.now()}`,
|
||||
object: "response",
|
||||
model: fallbackModel || "unknown",
|
||||
output: buildOutputFromText(),
|
||||
usage: usage ?? null,
|
||||
status: "completed",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
ingest(payload: JsonRecord) {
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sawAny = true;
|
||||
|
||||
const eventType = toString(payload.type);
|
||||
if (
|
||||
eventType === "response.completed" &&
|
||||
payload.response &&
|
||||
typeof payload.response === "object"
|
||||
) {
|
||||
completed = asRecord(payload.response);
|
||||
}
|
||||
if (payload.response && typeof payload.response === "object") {
|
||||
latestResponse = asRecord(payload.response);
|
||||
} else if (payload.object === "response") {
|
||||
latestResponse = payload;
|
||||
}
|
||||
if (
|
||||
eventType === "response.output_text.delta" &&
|
||||
typeof payload.delta === "string" &&
|
||||
payload.delta.length > 0
|
||||
) {
|
||||
textParts.push(payload.delta);
|
||||
}
|
||||
if (payload.usage && typeof payload.usage === "object") {
|
||||
usage = { ...asRecord(payload.usage) };
|
||||
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
|
||||
usage = { ...asRecord(asRecord(payload.response).usage) };
|
||||
}
|
||||
},
|
||||
|
||||
finalize(): unknown {
|
||||
if (!sawAny) return null;
|
||||
|
||||
const picked = completed || latestResponse;
|
||||
if (picked && Object.keys(picked).length > 0) {
|
||||
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
|
||||
return {
|
||||
id: toString(picked.id, `resp_${Date.now()}`),
|
||||
object: "response",
|
||||
model: toString(picked.model, fallbackModel || "unknown"),
|
||||
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
|
||||
usage: picked.usage ?? usage ?? null,
|
||||
status: toString(picked.status, completed ? "completed" : "in_progress"),
|
||||
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
|
||||
metadata: asRecord(picked.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `resp_${Date.now()}`,
|
||||
object: "response",
|
||||
model: fallbackModel || "unknown",
|
||||
output: buildOutputFromText(),
|
||||
usage: usage ?? null,
|
||||
status: "completed",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const payloads = events
|
||||
.map((evt) => asRecord(evt.data))
|
||||
.filter((payload) => Object.keys(payload).length);
|
||||
if (payloads.length === 0) return null;
|
||||
|
||||
function createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
type ClaudeBlock =
|
||||
| { type: "text"; index: number; text: string }
|
||||
| { type: "thinking"; index: number; thinking: string; signature?: string }
|
||||
@@ -379,172 +403,177 @@ function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string
|
||||
// non-streaming JSON path. Last-writer-wins: the final snapshot is authoritative.
|
||||
let contextManagement: JsonRecord | null = null;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const eventType = toString(payload.type);
|
||||
if (
|
||||
payload.context_management &&
|
||||
typeof payload.context_management === "object" &&
|
||||
!Array.isArray(payload.context_management)
|
||||
) {
|
||||
contextManagement = asRecord(payload.context_management);
|
||||
}
|
||||
if (eventType === "message_start") {
|
||||
const message = asRecord(payload.message);
|
||||
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
|
||||
model = toString(message.model, model);
|
||||
role = toString(message.role, role);
|
||||
mergeUsage(usage, message.usage);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
ingest(payload: JsonRecord) {
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sawAny = true;
|
||||
|
||||
if (eventType === "content_block_start") {
|
||||
const index = toNumber(payload.index, blocks.size);
|
||||
const contentBlock = asRecord(payload.content_block);
|
||||
const blockType = toString(contentBlock.type);
|
||||
|
||||
if (blockType === "thinking") {
|
||||
blocks.set(index, {
|
||||
type: "thinking",
|
||||
index,
|
||||
thinking: toString(contentBlock.thinking),
|
||||
signature:
|
||||
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
|
||||
});
|
||||
} else if (blockType === "tool_use") {
|
||||
blocks.set(index, {
|
||||
type: "tool_use",
|
||||
index,
|
||||
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
|
||||
name: toString(contentBlock.name),
|
||||
input: cloneLogPayload(contentBlock.input ?? {}),
|
||||
inputJson: "",
|
||||
});
|
||||
} else {
|
||||
blocks.set(index, {
|
||||
type: "text",
|
||||
index,
|
||||
text: toString(contentBlock.text),
|
||||
});
|
||||
const eventType = toString(payload.type);
|
||||
if (
|
||||
payload.context_management &&
|
||||
typeof payload.context_management === "object" &&
|
||||
!Array.isArray(payload.context_management)
|
||||
) {
|
||||
contextManagement = asRecord(payload.context_management);
|
||||
}
|
||||
if (eventType === "message_start") {
|
||||
const message = asRecord(payload.message);
|
||||
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
|
||||
model = toString(message.model, model);
|
||||
role = toString(message.role, role);
|
||||
mergeUsage(usage, message.usage);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "content_block_delta") {
|
||||
const index = toNumber(payload.index, 0);
|
||||
const delta = asRecord(payload.delta);
|
||||
const deltaType = toString(delta.type);
|
||||
const existing = blocks.get(index);
|
||||
if (eventType === "content_block_start") {
|
||||
const index = toNumber(payload.index, blocks.size);
|
||||
const contentBlock = asRecord(payload.content_block);
|
||||
const blockType = toString(contentBlock.type);
|
||||
|
||||
if (deltaType === "input_json_delta") {
|
||||
const toolUse =
|
||||
existing && existing.type === "tool_use"
|
||||
if (blockType === "thinking") {
|
||||
blocks.set(index, {
|
||||
type: "thinking",
|
||||
index,
|
||||
thinking: toString(contentBlock.thinking),
|
||||
signature:
|
||||
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
|
||||
});
|
||||
} else if (blockType === "tool_use") {
|
||||
blocks.set(index, {
|
||||
type: "tool_use",
|
||||
index,
|
||||
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
|
||||
name: toString(contentBlock.name),
|
||||
input: cloneLogPayload(contentBlock.input ?? {}),
|
||||
inputJson: "",
|
||||
});
|
||||
} else {
|
||||
blocks.set(index, {
|
||||
type: "text",
|
||||
index,
|
||||
text: toString(contentBlock.text),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "content_block_delta") {
|
||||
const index = toNumber(payload.index, 0);
|
||||
const delta = asRecord(payload.delta);
|
||||
const deltaType = toString(delta.type);
|
||||
const existing = blocks.get(index);
|
||||
|
||||
if (deltaType === "input_json_delta") {
|
||||
const toolUse =
|
||||
existing && existing.type === "tool_use"
|
||||
? existing
|
||||
: {
|
||||
type: "tool_use" as const,
|
||||
index,
|
||||
id: `toolu_${Date.now()}_${index}`,
|
||||
name: "",
|
||||
input: {},
|
||||
inputJson: "",
|
||||
};
|
||||
toolUse.inputJson += toString(delta.partial_json);
|
||||
blocks.set(index, toolUse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
|
||||
const thinking =
|
||||
existing && existing.type === "thinking"
|
||||
? existing
|
||||
: { type: "thinking" as const, index, thinking: "", signature: undefined };
|
||||
thinking.thinking += toString(delta.thinking);
|
||||
blocks.set(index, thinking);
|
||||
return;
|
||||
}
|
||||
|
||||
const textBlock =
|
||||
existing && existing.type === "text"
|
||||
? existing
|
||||
: {
|
||||
type: "tool_use" as const,
|
||||
type: "text" as const,
|
||||
index,
|
||||
id: `toolu_${Date.now()}_${index}`,
|
||||
name: "",
|
||||
input: {},
|
||||
inputJson: "",
|
||||
text: "",
|
||||
};
|
||||
toolUse.inputJson += toString(delta.partial_json);
|
||||
blocks.set(index, toolUse);
|
||||
continue;
|
||||
textBlock.text += toString(delta.text);
|
||||
blocks.set(index, textBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
|
||||
const thinking =
|
||||
existing && existing.type === "thinking"
|
||||
? existing
|
||||
: { type: "thinking" as const, index, thinking: "", signature: undefined };
|
||||
thinking.thinking += toString(delta.thinking);
|
||||
blocks.set(index, thinking);
|
||||
continue;
|
||||
if (eventType === "message_delta") {
|
||||
const delta = asRecord(payload.delta);
|
||||
stopReason = toString(delta.stop_reason, stopReason);
|
||||
stopSequence =
|
||||
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
|
||||
mergeUsage(usage, payload.usage);
|
||||
return;
|
||||
}
|
||||
|
||||
const textBlock =
|
||||
existing && existing.type === "text"
|
||||
? existing
|
||||
: {
|
||||
type: "text" as const,
|
||||
index,
|
||||
text: "",
|
||||
};
|
||||
textBlock.text += toString(delta.text);
|
||||
blocks.set(index, textBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "message_delta") {
|
||||
const delta = asRecord(payload.delta);
|
||||
stopReason = toString(delta.stop_reason, stopReason);
|
||||
stopSequence =
|
||||
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
|
||||
mergeUsage(usage, payload.usage);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
|
||||
mergeUsage(usage, payload.usage);
|
||||
}
|
||||
finalize(): unknown {
|
||||
if (!sawAny) return null;
|
||||
|
||||
const content = [...blocks.values()]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.flatMap<ClaudeContentBlock>((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: block.text,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
return block.thinking
|
||||
? [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: block.thinking,
|
||||
...(block.signature ? { signature: block.signature } : {}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
const content = [...blocks.values()]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.flatMap<ClaudeContentBlock>((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: block.text,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
return block.thinking
|
||||
? [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: block.thinking,
|
||||
...(block.signature ? { signature: block.signature } : {}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const parsedInput =
|
||||
block.inputJson.trim().length > 0
|
||||
? tryParseJson(block.inputJson)
|
||||
: cloneLogPayload(block.input);
|
||||
return [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: parsedInput,
|
||||
},
|
||||
];
|
||||
});
|
||||
const parsedInput =
|
||||
block.inputJson.trim().length > 0
|
||||
? tryParseJson(block.inputJson)
|
||||
: cloneLogPayload(block.input);
|
||||
return [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: parsedInput,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
id: messageId || `msg_${Date.now()}`,
|
||||
type: "message",
|
||||
role,
|
||||
model,
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
...(stopSequence ? { stop_sequence: stopSequence } : {}),
|
||||
...(Object.keys(usage).length > 0 ? { usage } : {}),
|
||||
...(contextManagement ? { context_management: contextManagement } : {}),
|
||||
return {
|
||||
id: messageId || `msg_${Date.now()}`,
|
||||
type: "message",
|
||||
role,
|
||||
model,
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
...(stopSequence ? { stop_sequence: stopSequence } : {}),
|
||||
...(Object.keys(usage).length > 0 ? { usage } : {}),
|
||||
...(contextManagement ? { context_management: contextManagement } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const payloads = events
|
||||
.map((evt) => asRecord(evt.data))
|
||||
.filter((payload) => Object.keys(payload).length);
|
||||
if (payloads.length === 0) return null;
|
||||
|
||||
function createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
const parts: JsonRecord[] = [];
|
||||
const usageMetadata: JsonRecord = {};
|
||||
let modelVersion = fallbackModel || "gemini";
|
||||
@@ -565,54 +594,110 @@ function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string
|
||||
parts.push(part);
|
||||
};
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
|
||||
modelVersion = payload.modelVersion;
|
||||
}
|
||||
mergeUsage(usageMetadata, payload.usageMetadata);
|
||||
|
||||
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
|
||||
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
|
||||
finishReason = candidate.finishReason;
|
||||
}
|
||||
|
||||
const content = asRecord(candidate.content);
|
||||
if (typeof content.role === "string" && content.role.length > 0) {
|
||||
role = content.role;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.parts)) continue;
|
||||
for (const item of content.parts) {
|
||||
const part = asRecord(item);
|
||||
if (part.functionCall && typeof part.functionCall === "object") {
|
||||
parts.push({
|
||||
functionCall: cloneLogPayload(part.functionCall),
|
||||
});
|
||||
} else if (typeof part.text === "string" && part.text.length > 0) {
|
||||
appendPart({
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role,
|
||||
parts,
|
||||
},
|
||||
finishReason,
|
||||
},
|
||||
],
|
||||
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
|
||||
modelVersion,
|
||||
ingest(payload: JsonRecord) {
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sawAny = true;
|
||||
|
||||
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
|
||||
modelVersion = payload.modelVersion;
|
||||
}
|
||||
mergeUsage(usageMetadata, payload.usageMetadata);
|
||||
|
||||
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
|
||||
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
|
||||
finishReason = candidate.finishReason;
|
||||
}
|
||||
|
||||
const content = asRecord(candidate.content);
|
||||
if (typeof content.role === "string" && content.role.length > 0) {
|
||||
role = content.role;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.parts)) return;
|
||||
for (const item of content.parts) {
|
||||
const part = asRecord(item);
|
||||
if (part.functionCall && typeof part.functionCall === "object") {
|
||||
parts.push({
|
||||
functionCall: cloneLogPayload(part.functionCall),
|
||||
});
|
||||
} else if (typeof part.text === "string" && part.text.length > 0) {
|
||||
appendPart({
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
finalize(): unknown {
|
||||
if (!sawAny) return null;
|
||||
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role,
|
||||
parts,
|
||||
},
|
||||
finishReason,
|
||||
},
|
||||
],
|
||||
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
|
||||
modelVersion,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSummaryReducer(
|
||||
format: string | null | undefined,
|
||||
fallbackModel?: string | null
|
||||
): SummaryReducer | undefined {
|
||||
const normalized = normalizeFormat(format);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
switch (normalized) {
|
||||
case FORMATS.OPENAI_RESPONSES:
|
||||
return createResponsesReducer(fallbackModel);
|
||||
case FORMATS.CLAUDE:
|
||||
return createClaudeReducer(fallbackModel);
|
||||
case FORMATS.GEMINI:
|
||||
case FORMATS.ANTIGRAVITY:
|
||||
return createGeminiReducer(fallbackModel);
|
||||
default:
|
||||
return createOpenAIReducer(fallbackModel);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const reducer = createOpenAIReducer(fallbackModel);
|
||||
for (const evt of events) reducer.ingest(asRecord(evt.data));
|
||||
return reducer.finalize();
|
||||
}
|
||||
|
||||
function buildResponsesSummary(
|
||||
events: StructuredSSEEvent[],
|
||||
fallbackModel?: string | null
|
||||
): unknown {
|
||||
const reducer = createResponsesReducer(fallbackModel);
|
||||
for (const evt of events) reducer.ingest(asRecord(evt.data));
|
||||
return reducer.finalize();
|
||||
}
|
||||
|
||||
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const reducer = createClaudeReducer(fallbackModel);
|
||||
for (const evt of events) reducer.ingest(asRecord(evt.data));
|
||||
return reducer.finalize();
|
||||
}
|
||||
|
||||
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
|
||||
const reducer = createGeminiReducer(fallbackModel);
|
||||
for (const evt of events) reducer.ingest(asRecord(evt.data));
|
||||
return reducer.finalize();
|
||||
}
|
||||
|
||||
export function buildStreamSummaryFromEvents(
|
||||
events: StructuredSSEEvent[],
|
||||
fallbackFormat?: string | null,
|
||||
@@ -666,19 +751,25 @@ export function compactStructuredStreamPayload(payload: unknown): unknown {
|
||||
}
|
||||
|
||||
export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
const { maxEvents = 200, maxBytes = 49152, stage } = options;
|
||||
const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options;
|
||||
const events: StructuredSSEEvent[] = [];
|
||||
let usedBytes = 0;
|
||||
let droppedEvents = 0;
|
||||
// Live-updated on every push() regardless of the storage cap above — see
|
||||
// the CollectorOptions.format doc comment for why (#9315).
|
||||
const reducer = createSummaryReducer(format, fallbackModel);
|
||||
|
||||
return {
|
||||
push(payload: unknown, explicitEvent?: string) {
|
||||
if (payload === null || payload === undefined) return;
|
||||
|
||||
const clonedData = cloneLogPayload(payload);
|
||||
reducer?.ingest(asRecord(clonedData));
|
||||
|
||||
const event: StructuredSSEEvent = {
|
||||
index: events.length + droppedEvents,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: cloneLogPayload(payload),
|
||||
data: clonedData,
|
||||
};
|
||||
|
||||
const eventName = explicitEvent || getEventName(payload);
|
||||
@@ -700,6 +791,17 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
return events.map((event) => cloneLogPayload(event));
|
||||
},
|
||||
|
||||
// The reducer-computed summary, built incrementally from EVERY pushed
|
||||
// payload (see CollectorOptions.format) — unlike
|
||||
// buildStreamSummaryFromEvents(getEvents(), ...), this is correct even
|
||||
// once the collector has truncated its retained event array. Returns
|
||||
// undefined if no format was configured (e.g. the client-response
|
||||
// collector, which builds its summary from independently-accumulated
|
||||
// response state instead).
|
||||
getSummary(): unknown {
|
||||
return reducer?.finalize();
|
||||
},
|
||||
|
||||
build(summary?: unknown, buildOptions: BuildOptions = {}) {
|
||||
const { includeEvents = true } = buildOptions;
|
||||
return {
|
||||
|
||||
2056
package-lock.json
generated
2056
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -350,54 +350,54 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@cyclonedx/cyclonedx-npm": "6.0.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@size-limit/file": "^12.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@size-limit/file": "^13.0.3",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/tap-runner": "^9.6.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"bun": "1.3.14",
|
||||
"c8": "^12.0.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"concurrently": "^10.0.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"ctrf": "^0.2.1",
|
||||
"dpdm": "^4.2.0",
|
||||
"dpdm": "^4.3.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"eslint-plugin-sonarjs": "^4.1.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"fumadocs-mdx": "^15.0.7",
|
||||
"fumadocs-mdx": "^15.2.2",
|
||||
"glob": "^13.0.6",
|
||||
"httpyac": "^6.16.7",
|
||||
"husky": "^9.1.7",
|
||||
"jscpd": "^4.2.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"junit-to-ctrf": "^0.0.14",
|
||||
"knip": "^6.18.0",
|
||||
"knip": "^6.32.0",
|
||||
"license-checker-rseidelsohn": "^5.0.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lint-staged": "^17.3.0",
|
||||
"lockfile-lint": "^5.0.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"opencode-ai": "1.18.8",
|
||||
"opencode-ai": "1.18.15",
|
||||
"playwright-ctrf-json-reporter": "^0.0.29",
|
||||
"prettier": "^3.8.3",
|
||||
"promptfoo": "^0.121.18",
|
||||
"size-limit": "^12.1.0",
|
||||
"prettier": "^3.9.6",
|
||||
"promptfoo": "^0.122.0",
|
||||
"size-limit": "^13.0.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"type-coverage": "^2.29.7",
|
||||
"type-coverage": "^2.30.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vitest": "^4.1.7",
|
||||
"wait-on": "^9.0.10",
|
||||
"wait-on": "^9.1.0",
|
||||
"wtfnode": "^0.10.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
132
scripts/build/mcpPublishedFilesClosure.ts
Normal file
132
scripts/build/mcpPublishedFilesClosure.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared MCP publish-path helpers (#3578 / #3821).
|
||||
*
|
||||
* Unit tests use the static `files` allowlist walker (no subprocess).
|
||||
* The pack-artifact gate uses the same helpers against a real
|
||||
* `npm pack --dry-run --ignore-scripts` file list so concurrent unit
|
||||
* suites never shell out to `npm pack`.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeArtifactPath } from "./pack-artifact-policy.ts";
|
||||
|
||||
/** Co-located test / spec paths that must never ship in the npm tarball. */
|
||||
export const PACK_ARTIFACT_TEST_FILE_RE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
||||
|
||||
/** Negations that must stay in package.json `files` (static unit guard). */
|
||||
export const REQUIRED_PACKAGE_FILES_TEST_NEGATIONS: readonly string[] = [
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.test.tsx",
|
||||
"!**/*.test.js",
|
||||
"!**/*.test.mjs",
|
||||
"!**/*.spec.ts",
|
||||
"!**/*.spec.tsx",
|
||||
];
|
||||
|
||||
/** Spot-check file from the original #3578 bug report. */
|
||||
export const MCP_CLOSURE_SPOT_CHECK_PATH = "src/lib/combos/steps.ts";
|
||||
|
||||
function resolveImport(root: string, fromFile: string, spec: string): string | null {
|
||||
let base: string;
|
||||
if (spec.startsWith("@/")) base = path.join("src", spec.slice(2));
|
||||
else if (spec.startsWith("@omniroute/open-sse/"))
|
||||
base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length));
|
||||
else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index");
|
||||
else if (spec.startsWith("./") || spec.startsWith("../"))
|
||||
base = path.join(path.dirname(fromFile), spec);
|
||||
else return null; // bare package — not our source
|
||||
base = base.replace(/\.(ts|tsx|js|mjs)$/, "");
|
||||
const cands = [
|
||||
base + ".ts",
|
||||
base + ".tsx",
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.tsx"),
|
||||
base + ".js",
|
||||
base + ".mjs",
|
||||
];
|
||||
for (const c of cands) if (fs.existsSync(path.join(root, c))) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitive import closure of the MCP server entrypoints under `src/` + `open-sse/`.
|
||||
*/
|
||||
export function computeMcpClosure(root: string = process.cwd()): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const f of fs.readdirSync(path.join(root, "open-sse/mcp-server"))) {
|
||||
if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f);
|
||||
}
|
||||
for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) {
|
||||
const abs = path.join(root, d);
|
||||
if (fs.existsSync(abs))
|
||||
for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stack = [...roots];
|
||||
const importRe =
|
||||
/(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
while (stack.length) {
|
||||
const f = stack.pop() as string;
|
||||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
let src: string;
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, f), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = importRe.exec(src))) {
|
||||
const spec = m[1] || m[2];
|
||||
if (!spec) continue;
|
||||
const r = resolveImport(root, f, spec);
|
||||
if (r && !seen.has(r)) stack.push(r);
|
||||
}
|
||||
}
|
||||
return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/"));
|
||||
}
|
||||
|
||||
/** Whether `file` is covered by a package.json `files` allowlist entry. */
|
||||
export function isCoveredByFiles(file: string, filesEntries: string[]): boolean {
|
||||
for (const entry of filesEntries) {
|
||||
if (entry.startsWith("!")) continue; // negations are not positive coverage
|
||||
if (entry.endsWith("/")) {
|
||||
if (file === entry.slice(0, -1) || file.startsWith(entry)) return true;
|
||||
} else if (file === entry || file.startsWith(entry + "/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Packed paths that look like test / spec files (over-inclusion). */
|
||||
export function findLeakedTestArtifactPaths(filePaths: string[]): string[] {
|
||||
return filePaths
|
||||
.map(normalizeArtifactPath)
|
||||
.filter(Boolean)
|
||||
.filter((filePath) => PACK_ARTIFACT_TEST_FILE_RE.test(filePath))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** MCP closure members missing from a packed (or candidate) path set. */
|
||||
export function findMissingMcpClosurePaths(
|
||||
packedPaths: string[],
|
||||
closurePaths: string[] = computeMcpClosure()
|
||||
): string[] {
|
||||
const packed = new Set(packedPaths.map(normalizeArtifactPath).filter(Boolean));
|
||||
return closurePaths
|
||||
.map(normalizeArtifactPath)
|
||||
.filter(Boolean)
|
||||
.filter((filePath) => !packed.has(filePath))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Required `files` negation entries that are absent from package.json. */
|
||||
export function findMissingPackageFilesTestNegations(filesEntries: string[]): string[] {
|
||||
const present = new Set(filesEntries);
|
||||
return REQUIRED_PACKAGE_FILES_TEST_NEGATIONS.filter((entry) => !present.has(entry));
|
||||
}
|
||||
@@ -228,6 +228,59 @@ export function normalizeArtifactPath(filePath: string): string {
|
||||
.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
/** Extract complete JSON values from npm's mixed stdout/stderr-style output. */
|
||||
export function parseJsonValuesOutput(output: string): unknown[] {
|
||||
const values: unknown[] = [];
|
||||
for (let start = 0; start < output.length; start++) {
|
||||
if (output[start] !== "[" && output[start] !== "{") continue;
|
||||
|
||||
const stack: string[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let end = start; end < output.length; end++) {
|
||||
const char = output[end];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
} else if (char === "[" || char === "{") {
|
||||
stack.push(char);
|
||||
} else if (char === "]" || char === "}") {
|
||||
const expectedOpen = char === "]" ? "[" : "{";
|
||||
if (stack.at(-1) !== expectedOpen) break;
|
||||
stack.pop();
|
||||
if (stack.length === 0) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(output.slice(start, end + 1));
|
||||
values.push(parsed);
|
||||
start = end;
|
||||
} catch {
|
||||
// This bracket pair was not a complete JSON value; continue scanning.
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Extract the first matching JSON array from npm's mixed stdout/stderr-style output. */
|
||||
export function parseJsonArrayOutput(
|
||||
output: string,
|
||||
matches: (parsed: unknown[]) => boolean = () => true
|
||||
): unknown[] {
|
||||
const parsed = parseJsonValuesOutput(output).find(
|
||||
(value): value is unknown[] => Array.isArray(value) && matches(value)
|
||||
);
|
||||
if (!parsed) throw new Error("Expected a valid JSON array in command output.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that are NEVER publishable, whatever the allowlist says.
|
||||
*
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
MCP_CLOSURE_SPOT_CHECK_PATH,
|
||||
computeMcpClosure,
|
||||
findLeakedTestArtifactPaths,
|
||||
findMissingMcpClosurePaths,
|
||||
} from "./mcpPublishedFilesClosure.ts";
|
||||
import {
|
||||
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
PACK_ARTIFACT_REQUIRED_PATHS,
|
||||
findMissingArtifactPaths,
|
||||
findUnexpectedArtifactPaths,
|
||||
parseJsonValuesOutput,
|
||||
} from "./pack-artifact-policy.ts";
|
||||
|
||||
const __filename: string = fileURLToPath(import.meta.url);
|
||||
@@ -24,12 +31,29 @@ function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
|
||||
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
|
||||
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
|
||||
|
||||
return execFileSync(command, commandArgs, {
|
||||
if (stdio === "inherit") {
|
||||
execFileSync(command, commandArgs, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr || result.stdout || `npm exited with status ${result.status}`).trim()
|
||||
);
|
||||
}
|
||||
return `${result.stdout || ""}\n${result.stderr || ""}`;
|
||||
}
|
||||
|
||||
function ensureAppStagingReady(): void {
|
||||
@@ -43,15 +67,39 @@ function ensureAppStagingReady(): void {
|
||||
runNpm(["run", "build:cli"], "inherit");
|
||||
}
|
||||
|
||||
function runPackDryRun(): any {
|
||||
type PackReport = {
|
||||
files: Array<{ path: string }>;
|
||||
filename?: string;
|
||||
entryCount?: number;
|
||||
size?: number;
|
||||
unpackedSize?: number;
|
||||
};
|
||||
|
||||
function findPackReport(value: unknown): PackReport | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const report = findPackReport(item);
|
||||
if (report) return report;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Array.isArray(record.files)) return record as unknown as PackReport;
|
||||
for (const child of Object.values(record)) {
|
||||
const report = findPackReport(child);
|
||||
if (report) return report;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runPackDryRun(): PackReport {
|
||||
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
||||
|
||||
const jsonStart = output.indexOf("[");
|
||||
const jsonEnd = output.lastIndexOf("]");
|
||||
const jsonPayload =
|
||||
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
|
||||
const parsed = JSON.parse(jsonPayload);
|
||||
const packReport = Array.isArray(parsed) ? parsed[0] : null;
|
||||
const packReport = parseJsonValuesOutput(output)
|
||||
.map(findPackReport)
|
||||
.find((report): report is PackReport => report !== null);
|
||||
|
||||
if (!packReport || !Array.isArray(packReport.files)) {
|
||||
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
|
||||
@@ -78,17 +126,17 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
|
||||
// required-runtime-files check (which needs the built dist/), running ONLY the
|
||||
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
|
||||
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
|
||||
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
|
||||
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
|
||||
// required-runtime-files check (which needs the built dist/). Source-side policy checks
|
||||
// still run against the real `npm pack --dry-run` file list: unexpected files (e.g. stray
|
||||
// bin/*.sh), test/spec leaks, and missing MCP closure files. This catches source regressions
|
||||
// cheaply on the fast-path (PR→release), instead of only on the release PR's full Package
|
||||
// Artifact job. See incident v3.8.36 (#5029).
|
||||
const POLICY_ONLY = process.argv.includes("--policy-only");
|
||||
|
||||
try {
|
||||
if (!POLICY_ONLY) ensureAppStagingReady();
|
||||
const packReport = runPackDryRun();
|
||||
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
|
||||
const artifactPaths: string[] = packReport.files.map((file) => file.path);
|
||||
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
|
||||
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
@@ -97,11 +145,20 @@ try {
|
||||
? []
|
||||
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
|
||||
|
||||
// #3821 — broad `files` prefixes (open-sse/, src/lib/, ...) would otherwise allow
|
||||
// co-located *.test.* / __tests__ leaks; ban them explicitly on the real pack list.
|
||||
const leakedTestPaths: string[] = findLeakedTestArtifactPaths(artifactPaths);
|
||||
|
||||
// #3578 — MCP runs from published TypeScript source; every reachable file must pack.
|
||||
const mcpClosure: string[] = computeMcpClosure(ROOT);
|
||||
const missingMcpPaths: string[] = findMissingMcpClosurePaths(artifactPaths, mcpClosure);
|
||||
|
||||
console.log("📦 npm pack artifact summary");
|
||||
console.log(` File: ${packReport.filename}`);
|
||||
console.log(` Entry count: ${packReport.entryCount}`);
|
||||
console.log(` Packed size: ${formatBytes(packReport.size)}`);
|
||||
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
|
||||
console.log(` MCP closure: ${mcpClosure.length} source files checked`);
|
||||
|
||||
if (unexpectedPaths.length > 0) {
|
||||
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
|
||||
@@ -117,7 +174,33 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
|
||||
if (leakedTestPaths.length > 0) {
|
||||
console.error(
|
||||
"\n❌ Test/spec files leaked into the npm publish artifact (tighten package.json files negations):"
|
||||
);
|
||||
for (const leakedPath of leakedTestPaths) {
|
||||
console.error(` - ${leakedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingMcpPaths.length > 0) {
|
||||
console.error(
|
||||
"\n❌ MCP-reachable source files are missing from the npm publish artifact (would 404 --mcp):"
|
||||
);
|
||||
for (const missingPath of missingMcpPaths) {
|
||||
console.error(` - ${missingPath}`);
|
||||
}
|
||||
if (missingMcpPaths.includes(MCP_CLOSURE_SPOT_CHECK_PATH)) {
|
||||
console.error(` (includes the #3578 bug file ${MCP_CLOSURE_SPOT_CHECK_PATH})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
unexpectedPaths.length > 0 ||
|
||||
missingRequiredPaths.length > 0 ||
|
||||
leakedTestPaths.length > 0 ||
|
||||
missingMcpPaths.length > 0
|
||||
) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,15 @@ async function main() {
|
||||
|
||||
const vitestProcess = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
|
||||
[
|
||||
"./node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
|
||||
// this file — the run then dies with "No test files found".
|
||||
"--config",
|
||||
"vitest.e2e-live.config.ts",
|
||||
"tests/e2e/ecosystem.test.ts",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: testEnv,
|
||||
|
||||
@@ -73,8 +73,11 @@ async function main() {
|
||||
[
|
||||
"./node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
"--environment",
|
||||
"node",
|
||||
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
|
||||
// this file — the run then dies with "No test files found". The config also
|
||||
// sets environment: node, so the flag is no longer needed here.
|
||||
"--config",
|
||||
"vitest.e2e-live.config.ts",
|
||||
"tests/e2e/protocol-clients.test.ts",
|
||||
],
|
||||
{
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// One-shot: FASE 3 helper, safe to delete after merge.
|
||||
//
|
||||
// Moves existing i18n mirror docs from `docs/i18n/<lang>/docs/X.md` into the
|
||||
// matching subfolder `docs/i18n/<lang>/docs/<sub>/X.md`, mirroring the new
|
||||
// docs/ layout. Uses `git mv` to preserve history.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/docs/move-i18n-mirrors.mjs [--dry]
|
||||
//
|
||||
// Notes:
|
||||
// - Skips files that don't appear in DOC_TO_SUBFOLDER (e.g., the legacy
|
||||
// `cloudflare-zero-trust-guide.md` or `features/` subfolder — those will be
|
||||
// handled in FASE 5 when translations are regenerated).
|
||||
// - Idempotent: if the target already lives under a subfolder, the entry is
|
||||
// skipped.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const I18N_DIR = path.join(ROOT, "docs", "i18n");
|
||||
|
||||
const DRY = process.argv.includes("--dry");
|
||||
|
||||
const DOC_TO_SUBFOLDER = {
|
||||
// architecture
|
||||
"ARCHITECTURE.md": "architecture",
|
||||
"CODEBASE_DOCUMENTATION.md": "architecture",
|
||||
"REPOSITORY_MAP.md": "architecture",
|
||||
"AUTHZ_GUIDE.md": "architecture",
|
||||
"RESILIENCE_GUIDE.md": "architecture",
|
||||
// guides
|
||||
"SETUP_GUIDE.md": "guides",
|
||||
"USER_GUIDE.md": "guides",
|
||||
"DOCKER_GUIDE.md": "guides",
|
||||
"ELECTRON_GUIDE.md": "guides",
|
||||
"TERMUX_GUIDE.md": "guides",
|
||||
"PWA_GUIDE.md": "guides",
|
||||
"TROUBLESHOOTING.md": "guides",
|
||||
"UNINSTALL.md": "guides",
|
||||
"I18N.md": "guides",
|
||||
"FEATURES.md": "guides",
|
||||
// reference
|
||||
"API_REFERENCE.md": "reference",
|
||||
"PROVIDER_REFERENCE.md": "reference",
|
||||
"openapi.yaml": "reference",
|
||||
"ENVIRONMENT.md": "reference",
|
||||
"CLI-TOOLS.md": "reference",
|
||||
"FREE_TIERS.md": "reference",
|
||||
// frameworks
|
||||
"MCP-SERVER.md": "frameworks",
|
||||
"A2A-SERVER.md": "frameworks",
|
||||
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
|
||||
"CLOUD_AGENT.md": "frameworks",
|
||||
"SKILLS.md": "frameworks",
|
||||
"MEMORY.md": "frameworks",
|
||||
"WEBHOOKS.md": "frameworks",
|
||||
"EVALS.md": "frameworks",
|
||||
// routing
|
||||
"AUTO-COMBO.md": "routing",
|
||||
"REASONING_REPLAY.md": "routing",
|
||||
// security
|
||||
"GUARDRAILS.md": "security",
|
||||
"COMPLIANCE.md": "security",
|
||||
"STEALTH_GUIDE.md": "security",
|
||||
// compression
|
||||
"COMPRESSION_GUIDE.md": "compression",
|
||||
"COMPRESSION_ENGINES.md": "compression",
|
||||
"COMPRESSION_RULES_FORMAT.md": "compression",
|
||||
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
|
||||
"RTK_COMPRESSION.md": "compression",
|
||||
// ops
|
||||
"RELEASE_CHECKLIST.md": "ops",
|
||||
"COVERAGE_PLAN.md": "ops",
|
||||
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
|
||||
"VM_DEPLOYMENT_GUIDE.md": "ops",
|
||||
"PROXY_GUIDE.md": "ops",
|
||||
"TUNNELS_GUIDE.md": "ops",
|
||||
};
|
||||
|
||||
let moved = 0;
|
||||
let skipped = 0;
|
||||
const seenLocales = [];
|
||||
|
||||
for (const locale of fs.readdirSync(I18N_DIR)) {
|
||||
const localeDir = path.join(I18N_DIR, locale);
|
||||
const stat = fs.statSync(localeDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
const docsDir = path.join(localeDir, "docs");
|
||||
if (!fs.existsSync(docsDir)) continue;
|
||||
seenLocales.push(locale);
|
||||
|
||||
for (const fname of fs.readdirSync(docsDir)) {
|
||||
const sub = DOC_TO_SUBFOLDER[fname];
|
||||
if (!sub) continue; // not in our mapping (e.g. features/, cloudflare-zero-trust-guide.md)
|
||||
|
||||
const src = path.join(docsDir, fname);
|
||||
if (!fs.statSync(src).isFile()) continue;
|
||||
|
||||
const subDir = path.join(docsDir, sub);
|
||||
const dst = path.join(subDir, fname);
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
console.log(`would move: ${path.relative(ROOT, src)} -> ${path.relative(ROOT, dst)}`);
|
||||
moved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(subDir)) fs.mkdirSync(subDir, { recursive: true });
|
||||
const relSrc = path.relative(ROOT, src);
|
||||
const relDst = path.relative(ROOT, dst);
|
||||
try {
|
||||
execFileSync("git", ["mv", "-k", "--", relSrc, relDst], {
|
||||
cwd: ROOT,
|
||||
stdio: "pipe",
|
||||
});
|
||||
moved++;
|
||||
} catch {
|
||||
// fallback: copy + delete; emulate `|| true` for the rm by ignoring its failure
|
||||
fs.renameSync(src, dst);
|
||||
try {
|
||||
execFileSync("git", ["rm", "--cached", "--", relSrc], { cwd: ROOT, stdio: "pipe" });
|
||||
} catch {
|
||||
// file may not be tracked yet — safe to ignore
|
||||
}
|
||||
execFileSync("git", ["add", "--", relDst], { cwd: ROOT, stdio: "pipe" });
|
||||
moved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[i18n-mirrors] locales=${seenLocales.length} moved=${moved} skipped=${skipped}${DRY ? " (dry-run)" : ""}`
|
||||
);
|
||||
@@ -319,7 +319,15 @@ curl -X PUT https://localhost:20128/api/settings/system-prompt \
|
||||
|
||||
Get thinking budget configuration
|
||||
|
||||
Returns the current thinking/reasoning budget settings for AI models.
|
||||
Returns proxy-level thinking/reasoning **request rewrite** settings:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `mode` | `passthrough` (leave client reasoning alone — **required for Codex visible thinking**), `auto` (**strips** all client thinking fields), `custom`, `adaptive` |
|
||||
| `customBudget` | Fixed budget when `mode=custom` |
|
||||
| `effortLevel` | Base effort when `mode=adaptive` |
|
||||
|
||||
**Not** compression and **not** “decrypt encrypted reasoning”. Full guide: `docs/guides/THINKING_BUDGET.md`.
|
||||
|
||||
```bash
|
||||
curl https://localhost:20128/api/settings/thinking-budget \
|
||||
@@ -330,13 +338,17 @@ curl https://localhost:20128/api/settings/thinking-budget \
|
||||
|
||||
Update thinking budget configuration
|
||||
|
||||
Example — keep client-controlled reasoning (Codex/Desktop):
|
||||
|
||||
```bash
|
||||
curl -X PUT https://localhost:20128/api/settings/thinking-budget \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
|
||||
```
|
||||
|
||||
Warning: `mode=auto` deletes `reasoning` / `reasoning_effort` / Claude `thinking` from the outbound body before upstream. That can empty thinking panels even when the client requested Ultra + summary.
|
||||
|
||||
### GET /api/tags
|
||||
|
||||
List Ollama-compatible model tags
|
||||
|
||||
@@ -12,7 +12,11 @@ import { shouldShowKimiSponsorBanner } from "./kimiSponsorBannerGate";
|
||||
// plan (kimi.com/code) to the API platform at Moonshot's request: coding plan
|
||||
// subscriptions are closed to most new users, so that traffic could not
|
||||
// convert.
|
||||
const KIMI_PLATFORM_AFF_URL = "https://platform.kimi.ai?aff=omniroute";
|
||||
// Dedicated tracked link issued by Moonshot 2026-08 for the 15% first-top-up
|
||||
// bonus campaign (offer valid through 2026-09-30 — revisit the 15% copy in the
|
||||
// i18n `kimiSponsorBanner.description` strings after that date if not renewed).
|
||||
const KIMI_PLATFORM_AFF_URL =
|
||||
"https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute";
|
||||
|
||||
// Versioned dismissal key — bump the suffix (e.g. `-v3`) if the banner's
|
||||
// offer/copy ever changes materially enough to warrant re-showing it to
|
||||
|
||||
@@ -713,6 +713,8 @@ export default function CustomModelsSection({
|
||||
</button>
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={providerId}
|
||||
modelId={model.id!}
|
||||
effectiveModelNormalize={(p) =>
|
||||
effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,34 @@ function recordToHeaderRows(rec: Record<string, string>, genId: () => string): H
|
||||
return entries.map(([name, value]) => ({ id: genId(), name, value }));
|
||||
}
|
||||
|
||||
// Bounded re-run budget for the model param-filter save: if the draft changed while the PUT was
|
||||
// in flight, the save repeats with the newer draft instead of clearing the dirty flag on a
|
||||
// payload that no longer matches what the user typed (#8910).
|
||||
const PARAM_SAVE_MAX_ATTEMPTS = 3;
|
||||
|
||||
// Param filters are stored as one document per provider. Model rows render independent popover
|
||||
// instances, so their GET -> whole-document PUT transactions must share a provider-level queue;
|
||||
// instance-local saving refs cannot prevent sibling rows from overwriting each other's updates.
|
||||
const paramFilterSaveQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function serializeProviderParamFilterSave<T>(
|
||||
providerId: string,
|
||||
save: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = paramFilterSaveQueues.get(providerId) ?? Promise.resolve();
|
||||
const result = previous.then(save);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
paramFilterSaveQueues.set(providerId, tail);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
if (paramFilterSaveQueues.get(providerId) === tail) paramFilterSaveQueues.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommaList(text: string): string[] {
|
||||
return text
|
||||
? text
|
||||
@@ -43,6 +71,21 @@ interface ParamFilterConfigLike {
|
||||
autoLearn?: boolean;
|
||||
}
|
||||
|
||||
// An unsaved param-filter draft, bound to the provider/model it was typed for. The save path
|
||||
// writes through THIS target instead of the props the callback happens to close over, so a draft
|
||||
// can never be persisted under a provider/model the user never edited (#8910).
|
||||
interface ParamFilterDraft {
|
||||
key: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
block: string;
|
||||
allow: string;
|
||||
}
|
||||
|
||||
function paramTargetKeyOf(providerId: string, modelId: string): string {
|
||||
return `${providerId}\u0000${modelId}`;
|
||||
}
|
||||
|
||||
// Builds the PUT body for the model-level block/allow save. Extracted so the
|
||||
// caller's async handler stays simple — this is pure payload-shaping logic.
|
||||
function buildModelParamFilterPayload(
|
||||
@@ -97,6 +140,8 @@ export interface ModelCompatPopoverProps {
|
||||
|
||||
export default function ModelCompatPopover({
|
||||
t,
|
||||
providerId,
|
||||
modelId,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
getUpstreamHeadersRecord,
|
||||
@@ -110,8 +155,8 @@ export default function ModelCompatPopover({
|
||||
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
|
||||
const [blockText, setBlockText] = useState("");
|
||||
const [allowText, setAllowText] = useState("");
|
||||
const [paramDirty, setParamDirty] = useState(false);
|
||||
const [paramSaving, setParamSaving] = useState(false);
|
||||
const [paramSaveFailed, setParamSaveFailed] = useState(false);
|
||||
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
|
||||
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -126,6 +171,78 @@ export default function ModelCompatPopover({
|
||||
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
|
||||
headerRowsRef.current = headerRows;
|
||||
|
||||
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
|
||||
// latest typed values instead of the values captured when the handler was created (#8910).
|
||||
const paramSavingRef = useRef(false);
|
||||
// Every unsaved draft, keyed by the provider/model it was typed for. A per-target map (rather
|
||||
// than a single slot) is required because a live popover can be re-pointed at another target
|
||||
// while a draft is still unsaved: with one slot the next keystroke on the new target destroyed
|
||||
// the previous target's unsaved work, and the new target's successful save then cleared the
|
||||
// failure indicator — a green UI over data that was never written, i.e. exactly the silent
|
||||
// data loss reported in #8910. Drafts are recorded when edited — never derived from a completed
|
||||
// load — so an unsaved draft survives even when no load ever succeeded for that target, and
|
||||
// every write lands on the draft's own target rather than whatever the popover now points at.
|
||||
const paramDraftsRef = useRef<Map<string, ParamFilterDraft>>(new Map());
|
||||
|
||||
const paramTargetKey = paramTargetKeyOf(providerId, modelId);
|
||||
const paramTargetRef = useRef<{ key: string; providerId: string; modelId: string }>({
|
||||
key: paramTargetKey,
|
||||
providerId,
|
||||
modelId,
|
||||
});
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
|
||||
const blockTextRef = useRef("");
|
||||
const allowTextRef = useRef("");
|
||||
blockTextRef.current = blockText;
|
||||
allowTextRef.current = allowText;
|
||||
// Which target the values currently in the fields belong to. Guards the invariant that
|
||||
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
|
||||
// displayed — the desync that let one model's server values be saved under another (#8910).
|
||||
const fieldsTargetKeyRef = useRef<string | null>(null);
|
||||
|
||||
const applyParamFields = useCallback((targetKey: string, block: string, allow: string) => {
|
||||
fieldsTargetKeyRef.current = targetKey;
|
||||
blockTextRef.current = block;
|
||||
allowTextRef.current = allow;
|
||||
setBlockText(block);
|
||||
setAllowText(allow);
|
||||
}, []);
|
||||
|
||||
// Every edit rewrites the draft for the CURRENT target. The counterpart field is only trusted
|
||||
// when the values on screen belong to this target; otherwise it is taken from this target's own
|
||||
// pending draft (or empty), so another target's value can never be captured into this draft and
|
||||
// then persisted here (#8910). Object identity doubles as the draft revision an in-flight save
|
||||
// compares against.
|
||||
const editParamDraft = useCallback(
|
||||
(field: "block" | "allow", value: string) => {
|
||||
const target = paramTargetRef.current;
|
||||
const fieldsOwned = fieldsTargetKeyRef.current === target.key;
|
||||
const pending = paramDraftsRef.current.get(target.key);
|
||||
const block =
|
||||
field === "block" ? value : fieldsOwned ? blockTextRef.current : (pending?.block ?? "");
|
||||
const allow =
|
||||
field === "allow" ? value : fieldsOwned ? allowTextRef.current : (pending?.allow ?? "");
|
||||
applyParamFields(target.key, block, allow);
|
||||
paramDraftsRef.current.set(target.key, {
|
||||
key: target.key,
|
||||
providerId: target.providerId,
|
||||
modelId: target.modelId,
|
||||
block,
|
||||
allow,
|
||||
});
|
||||
},
|
||||
[applyParamFields]
|
||||
);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const genHeaderRowId = () => {
|
||||
headerRowIdRef.current += 1;
|
||||
return `uh-${headerRowIdRef.current}`;
|
||||
@@ -168,40 +285,140 @@ export default function ModelCompatPopover({
|
||||
// Load model-level block/allow from param-filters API
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draftKey = paramTargetKeyOf(providerId, modelId);
|
||||
const draftForThisTarget = () => paramDraftsRef.current.get(draftKey);
|
||||
// The fields must always show THIS target's content, and nothing else may ever be read back
|
||||
// out of them. A draft that is still pending for this exact provider/model was never persisted
|
||||
// (failed or exhausted save): restore it into the inputs instead of loading server state over
|
||||
// it, because reloading here would silently revert it — the very complaint behind #8910.
|
||||
const pending = draftForThisTarget();
|
||||
if (pending) {
|
||||
applyParamFields(draftKey, pending.block, pending.allow);
|
||||
return;
|
||||
}
|
||||
// No draft for this target: drop whatever the previously displayed target left on screen so
|
||||
// the fields can never present (or contribute) another target's values.
|
||||
if (fieldsTargetKeyRef.current !== draftKey) applyParamFields(draftKey, "", "");
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
// Re-check after the await: the user may have typed while the GET was in flight, and a
|
||||
// load result must never overwrite (or acknowledge) a draft that is not on the server.
|
||||
if (draftForThisTarget()) return;
|
||||
const modelCfg = data?.models?.[modelId];
|
||||
setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : "");
|
||||
setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : "");
|
||||
applyParamFields(
|
||||
draftKey,
|
||||
modelCfg ? (modelCfg.block ?? []).join(", ") : "",
|
||||
modelCfg ? (modelCfg.allow ?? []).join(", ") : ""
|
||||
);
|
||||
// A load never clears a draft: any draft still pending here belongs to a DIFFERENT
|
||||
// target and is still owed a write to that target (#8910). The failure indicator is
|
||||
// only cleared once nothing is left unsaved anywhere.
|
||||
if (paramDraftsRef.current.size === 0) setParamSaveFailed(false);
|
||||
} catch {
|
||||
setBlockText("");
|
||||
setAllowText("");
|
||||
// Keep whatever the user has in the fields (and its dirty flag) on load failure.
|
||||
}
|
||||
setParamDirty(false);
|
||||
})();
|
||||
}, [open]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Reload only when opening or when the popover targets a different provider/model.
|
||||
}, [open, providerId, modelId, applyParamFields]);
|
||||
|
||||
// Drains EVERY pending draft, each written through its OWN provider/model — never the props this
|
||||
// callback happens to be bound to — so a draft typed for target A can never be persisted under a
|
||||
// target B the user never edited, and re-pointing the popover cannot destroy target A's unsaved
|
||||
// work (#8910). Each draft is re-read after every await for the same reason the load effect
|
||||
// re-checks its guard: the user can keep typing while a write is in flight.
|
||||
const saveModelParamFilters = useCallback(async () => {
|
||||
if (!paramDirty) return;
|
||||
setParamSaving(true);
|
||||
if (paramDraftsRef.current.size === 0 || paramSavingRef.current) return;
|
||||
paramSavingRef.current = true;
|
||||
if (mountedRef.current) setParamSaving(true);
|
||||
|
||||
// Returns true once nothing is owed for this target any more.
|
||||
const saveDraftForTarget = async (key: string): Promise<boolean> => {
|
||||
// Re-run while the draft changed under the in-flight write: the payload is snapshotted
|
||||
// before the PUT resolves, so a keystroke landing in that window would otherwise be
|
||||
// acknowledged (draft dropped) but never persisted — the #8910 lost update.
|
||||
for (let attempt = 0; attempt < PARAM_SAVE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const draft = paramDraftsRef.current.get(key);
|
||||
if (!draft) return true;
|
||||
try {
|
||||
const wroteDraft = await serializeProviderParamFilterSave(draft.providerId, async () => {
|
||||
const res = await fetch(`/api/providers/${draft.providerId}/param-filters`);
|
||||
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
|
||||
const current = await res.json();
|
||||
// The fetched config belongs to draft.providerId; if the draft was replaced by a newer
|
||||
// one while the GET (or this instance's queue wait) was in flight, restart fresh.
|
||||
if (paramDraftsRef.current.get(key) !== draft) return false;
|
||||
const payload = buildModelParamFilterPayload(
|
||||
current,
|
||||
draft.modelId,
|
||||
draft.block,
|
||||
draft.allow
|
||||
);
|
||||
const putRes = await fetch(`/api/providers/${draft.providerId}/param-filters`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!putRes.ok) throw new Error(`param-filters PUT failed: ${putRes.status}`);
|
||||
return true;
|
||||
});
|
||||
if (!wroteDraft) continue;
|
||||
// Only the exact draft that was written may be discarded.
|
||||
if (paramDraftsRef.current.get(key) === draft) {
|
||||
paramDraftsRef.current.delete(key);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Save failed — the draft (and the target it belongs to) is intentionally preserved so
|
||||
// the next blur/close/unmount save retries it against its own provider/model.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Budget exhausted (the user is still typing): stay dirty for this target.
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
const current = await res.json();
|
||||
const payload = buildModelParamFilterPayload(current, modelId, blockText, allowText);
|
||||
await fetch(`/api/providers/${providerId}/param-filters`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setParamDirty(false);
|
||||
} catch {
|
||||
// Silently ignore save error
|
||||
// Do not snapshot the keys once: a second target can become dirty while an earlier target's
|
||||
// PUT is in flight. Keep selecting live drafts until only revisions that already failed in
|
||||
// this drain remain. Remember the failed object (not just its key), so a newer edit for that
|
||||
// target that lands while another request is pending still gets one save attempt.
|
||||
const failedDrafts = new Map<string, ParamFilterDraft>();
|
||||
while (true) {
|
||||
const next = Array.from(paramDraftsRef.current.entries()).find(
|
||||
([key, draft]) => failedDrafts.get(key) !== draft
|
||||
);
|
||||
if (!next) break;
|
||||
const [key] = next;
|
||||
if (!(await saveDraftForTarget(key))) {
|
||||
const failedDraft = paramDraftsRef.current.get(key);
|
||||
if (failedDraft) failedDrafts.set(key, failedDraft);
|
||||
}
|
||||
}
|
||||
// The indicator tracks unsaved work across ALL targets: a successful write for the target
|
||||
// now on screen must not signal "saved" while another target's draft is still owed a write.
|
||||
if (mountedRef.current) setParamSaveFailed(paramDraftsRef.current.size > 0);
|
||||
} finally {
|
||||
setParamSaving(false);
|
||||
paramSavingRef.current = false;
|
||||
if (mountedRef.current) setParamSaving(false);
|
||||
}
|
||||
}, [paramDirty, blockText, allowText]);
|
||||
}, []);
|
||||
|
||||
// Persist pending param-filter drafts when the popover closes, unmounts, or is re-pointed at a
|
||||
// different provider/model (#8910).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
void saveModelParamFilters();
|
||||
};
|
||||
}, [open, paramTargetKey, saveModelParamFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setValuePeekRowId(null);
|
||||
@@ -360,10 +577,7 @@ export default function ModelCompatPopover({
|
||||
<input
|
||||
type="text"
|
||||
value={blockText}
|
||||
onChange={(e) => {
|
||||
setBlockText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onChange={(e) => editParamDraft("block", e.target.value)}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder={t("compatBlockedParamsPlaceholder")}
|
||||
disabled={disabled}
|
||||
@@ -376,16 +590,22 @@ export default function ModelCompatPopover({
|
||||
"Blocked params (stripped from requests)"
|
||||
)}
|
||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||
{paramSaveFailed && !paramSaving && (
|
||||
<span
|
||||
role="alert"
|
||||
className="ml-1 font-medium text-red-600 dark:text-red-400"
|
||||
title={t("failedSaveConnectionRetry")}
|
||||
>
|
||||
● {t("failed")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={allowText}
|
||||
onChange={(e) => {
|
||||
setAllowText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onChange={(e) => editParamDraft("allow", e.target.value)}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder={t("compatAllowedParamsPlaceholder")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the concurrency defects found while fixing #8910:
|
||||
// 1. an edit landing after the PUT payload snapshot but before the PUT resolves must still
|
||||
// be persisted (lost update);
|
||||
// 2. a save that failed must not be silently reverted on reopen, and the failure must be
|
||||
// visible in the panel.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter save concurrency (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists an edit typed after the payload snapshot but before the PUT resolves", async () => {
|
||||
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let releasePut: (() => void) | null = null;
|
||||
let holdPut = false;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
serverState = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
if (holdPut) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releasePut = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
holdPut = true;
|
||||
// Blur starts the save: GET resolves, payload is snapshotted, PUT is issued and held open.
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(releasePut).not.toBeNull();
|
||||
|
||||
// The user keeps typing while that PUT is still in flight, then closes the popover.
|
||||
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
holdPut = false;
|
||||
await act(async () => {
|
||||
releasePut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(serverState.models).toEqual({
|
||||
"gpt-test": { block: ["temperature", "seed"], allow: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the draft and surfaces the failure when the save fails, instead of reverting on reopen", async () => {
|
||||
const serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putAttempts = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putAttempts += 1;
|
||||
return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await closePopoverByOutsideClick();
|
||||
expect(putAttempts).toBe(1);
|
||||
|
||||
// Reopening must NOT clobber the unsaved draft with server state (#8910 complaint class).
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
// The failure is visible to the user rather than silently swallowed.
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// ...and the retained draft is actually retryable through the normal close path.
|
||||
await closePopoverByOutsideClick();
|
||||
expect(putAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("clears the failure indicator once a later save succeeds", async () => {
|
||||
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let failNextPut = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (failNextPut) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
serverState = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
failNextPut = false;
|
||||
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(serverState.models).toEqual({
|
||||
"gpt-test": { block: ["temperature", "seed"], allow: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for #8910: sibling model-row popovers for the same provider must serialize
|
||||
// their whole-document param-filter updates so one successful save cannot erase the other.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
type ParamFilterState = {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let releaseFirstPut: (() => void) | null;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 60) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
releaseFirstPut = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves both model updates when sibling popovers save the same provider concurrently", async () => {
|
||||
let server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = { ...body, models: body.models ?? {} };
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const props = (modelId: string) => ({
|
||||
t: (key: string) => key,
|
||||
providerId: "openai",
|
||||
modelId,
|
||||
effectiveModelNormalize: () => false,
|
||||
effectiveModelPreserveDeveloper: () => true,
|
||||
getUpstreamHeadersRecord: () => ({}),
|
||||
onCompatPatch: vi.fn(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<div>
|
||||
<div id="model-a">
|
||||
<ModelCompatPopover {...props("model-a")} />
|
||||
</div>
|
||||
<div id="model-b">
|
||||
<ModelCompatPopover {...props("model-b")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const triggerA = container.querySelector("#model-a button") as HTMLButtonElement;
|
||||
const triggerB = container.querySelector("#model-b button") as HTMLButtonElement;
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => triggerA.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
triggerB.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
await act(async () => triggerB.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the cross-target write defect found while fixing #8910.
|
||||
//
|
||||
// ModelCompatPopover instances are not always keyed by a stable identity (CompatibleModelsSection
|
||||
// keys by `${alias}:${modelId}`, PassthroughModelsSection by the full model string, and providerId
|
||||
// is threaded from route/page state), so a re-render can re-point a LIVE, mounted popover at a
|
||||
// different provider/model. When the draft for the old target failed to save, the save callback —
|
||||
// now bound to the new target — used to PUT the old draft into the new target's config,
|
||||
// destructively overwriting a model/provider the user never edited.
|
||||
//
|
||||
// Contract asserted here: a write always lands on the provider/model the draft was typed for, and
|
||||
// an orphaned draft whose target is no longer displayed is preserved (retried later) rather than
|
||||
// silently dropped or redirected.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover(props: { providerId: string; modelId: string }) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId={props.providerId}
|
||||
modelId={props.modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter cross-target writes (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("never writes a draft typed for one model under a different model", async () => {
|
||||
const server: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "model-b": { block: ["bval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: { url: string; models?: Record<string, unknown> }[] = [];
|
||||
let getFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push({ url, models: body.models });
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover({ providerId: "openai", modelId: "model-a" });
|
||||
await openPopover();
|
||||
// The load failed, so the fields are empty; the user types a draft for model-a.
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
|
||||
// A re-render re-points this live popover at model-b while model-a's draft is still dirty.
|
||||
// The close-time save for model-a runs here and fails (its GET is still 503).
|
||||
renderPopover({ providerId: "openai", modelId: "model-b" });
|
||||
await flushEffects();
|
||||
|
||||
// The network recovers and the popover closes: the retried save must target model-a.
|
||||
getFails = false;
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
// model-b's real server config is untouched...
|
||||
expect(server.models["model-b"]).toEqual({ block: ["bval"], allow: [] });
|
||||
// ...and the orphaned model-a draft is not silently dropped either — it lands on model-a.
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
|
||||
expect(puts.every((p) => p.url.includes("/openai/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("never writes a draft typed for one provider under a different provider", async () => {
|
||||
const byProvider: Record<string, ParamFilterState> = {
|
||||
alpha: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
beta: {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["betaval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
},
|
||||
};
|
||||
const puts: { providerId: string; models?: Record<string, unknown> }[] = [];
|
||||
let getFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const providerId = url.match(/providers\/([^/]+)\//)![1];
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push({ providerId, models: body.models });
|
||||
byProvider[providerId].models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover({ providerId: "alpha", modelId: "gpt-test" });
|
||||
await openPopover();
|
||||
await act(async () => setInputValue(blockInput()!, "alpha-only"));
|
||||
|
||||
// Re-point the live popover at provider beta while alpha's draft is dirty and its save fails.
|
||||
renderPopover({ providerId: "beta", modelId: "gpt-test" });
|
||||
await flushEffects();
|
||||
|
||||
getFails = false;
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
// beta must receive no write at all; its stored config survives intact.
|
||||
expect(puts.filter((p) => p.providerId === "beta")).toEqual([]);
|
||||
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["betaval"], allow: [] } });
|
||||
// The alpha draft is preserved and eventually persisted under alpha.
|
||||
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-only"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the load-effect clobber defects found while fixing #8910:
|
||||
// 1. a draft typed while the initial load GET is still in flight must survive the load
|
||||
// result and still be persisted on close (otherwise keystrokes vanish silently);
|
||||
// 2. after a failed initial load, the retained draft must not be destroyed by the next
|
||||
// successful reopen load — that reopen is the user's retry.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 12) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter load clobber (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps and saves a draft typed while the initial load GET is still in flight", async () => {
|
||||
const serverState: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["old"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: ParamFilterState[] = [];
|
||||
let releaseGet: (() => void) | null = null;
|
||||
let heldFirstGet = false;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (!heldFirstGet) {
|
||||
heldFirstGet = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseGet = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
// The field is already rendered while the load GET is still pending — the user types into it.
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await act(async () => {
|
||||
releaseGet?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
// The load result must not overwrite the dirty draft.
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
|
||||
});
|
||||
|
||||
it("does not clobber a retained draft with the successful reopen load after a failed initial load", async () => {
|
||||
const serverState: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["serverval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: ParamFilterState[] = [];
|
||||
let failGet = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (failGet) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
// First open: the load GET fails, so nothing is loaded and the field stays empty.
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("");
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
// Close: the save's own GET still fails, so the draft is retained and flagged as failed.
|
||||
await closePopoverByOutsideClick();
|
||||
expect(puts.length).toBe(0);
|
||||
|
||||
// The network recovers and the user reopens the popover to retry the save.
|
||||
failGet = false;
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
// The unsaved-state indicator must still be visible — nothing was persisted.
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
await closePopoverByOutsideClick();
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("preserves a new target edited while the older target save is in flight", async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let server = {
|
||||
block: [] as string[],
|
||||
allow: [] as string[],
|
||||
models: {} as Record<string, { block: string[]; allow: string[] }>,
|
||||
autoLearn: false,
|
||||
};
|
||||
let releaseFirstPut: (() => void) | null = null;
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as typeof server;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = body;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (modelId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const blurBlock = async () => {
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
renderFor("model-a");
|
||||
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await blurBlock();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
renderFor("model-b");
|
||||
await flushEffects();
|
||||
expect(blockInput().value).toBe("");
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await blurBlock();
|
||||
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("does not lose the new target when unmounted during the older target save", async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let server = {
|
||||
block: [] as string[],
|
||||
allow: [] as string[],
|
||||
models: {} as Record<string, { block: string[]; allow: string[] }>,
|
||||
autoLearn: false,
|
||||
};
|
||||
let releaseFirstPut: (() => void) | null = null;
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as typeof server;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = body;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (modelId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const blurBlock = async () => {
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
renderFor("model-a");
|
||||
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await blurBlock();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
renderFor("model-b");
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await blurBlock();
|
||||
|
||||
// Every close/unmount save is rejected while model-a owns paramSavingRef. The active save took
|
||||
// its key snapshot before model-b existed, so model-b has no later save scheduled.
|
||||
act(() => root.unmount());
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the two target-re-point defects found while fixing #8910.
|
||||
//
|
||||
// A ModelCompatPopover instance is not always keyed by a stable identity, so a re-render can
|
||||
// re-point a LIVE, still-open popover at a different provider/model while a draft for the previous
|
||||
// target is unsaved. Two failures followed from that:
|
||||
//
|
||||
// 1. (C10) The inputs are driven by blockText/allowText, which used to be written only by the
|
||||
// load effect — and that effect early-returned whenever a draft was dirty. Re-pointing
|
||||
// A -> B -> A therefore left B's server values on screen under A, and the next keystroke
|
||||
// snapshotted them into A's draft, persisting B's content into A's entry.
|
||||
// 2. (C11) The pending draft lived in a single slot that every edit overwrote, so typing into
|
||||
// the new target destroyed the previous target's unsaved work, and the new target's
|
||||
// successful save cleared the failure indicator — a green UI over data never written.
|
||||
//
|
||||
// Contract asserted here: the fields always show the displayed target's own content (its pending
|
||||
// draft when it has one), never another target's; and every target's unsaved draft survives until
|
||||
// it is actually persisted to its own provider/model.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover(modelId: string) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 delegates onBlur through focusout — a bare "blur" event does not reach the handler.
|
||||
async function blurBlockInput() {
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter target re-point (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("restores the dirty draft into the fields on return, instead of showing the other model's values", async () => {
|
||||
const server: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "model-b": { block: ["secret-b"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
let putFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
const body = JSON.parse(String(init.body));
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover("model-a");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
// model-a has no server entry, so the field loads empty; the user types a draft whose save fails.
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
await blurBlockInput();
|
||||
expect(blockInput()!.value).toBe("aaa");
|
||||
|
||||
// The live popover is re-pointed at model-b, which does have a server value...
|
||||
renderPopover("model-b");
|
||||
await flushEffects();
|
||||
expect(blockInput()!.value).toBe("secret-b");
|
||||
|
||||
// ...and back to model-a, whose draft is still unsaved: the field must show model-a's draft.
|
||||
renderPopover("model-a");
|
||||
await flushEffects();
|
||||
expect(blockInput()!.value).toBe("aaa");
|
||||
|
||||
// The user appends to what they can see and closes; the write must stay inside model-a.
|
||||
putFails = false;
|
||||
await act(async () => setInputValue(blockInput()!, `${blockInput()!.value}, extra`));
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa", "extra"], allow: [] });
|
||||
expect(JSON.stringify(server.models["model-a"])).not.toContain("secret-b");
|
||||
expect(server.models["model-b"]).toEqual({ block: ["secret-b"], allow: [] });
|
||||
});
|
||||
|
||||
it("keeps one model's unsaved draft alive while the user edits another model", async () => {
|
||||
const server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
const body = JSON.parse(String(init.body));
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover("model-a");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
await blurBlockInput();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// Re-pointed at model-b; the network recovers and the user edits model-b.
|
||||
renderPopover("model-b");
|
||||
await flushEffects();
|
||||
putFails = false;
|
||||
await act(async () => setInputValue(blockInput()!, "bbb"));
|
||||
await blurBlockInput();
|
||||
|
||||
// model-a's draft must not have been destroyed by the model-b edit: both are persisted...
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
|
||||
expect(server.models["model-b"]).toEqual({ block: ["bbb"], allow: [] });
|
||||
// ...and with nothing left unsaved anywhere the failure indicator is finally cleared.
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not report success while another target's draft is still unsaved", async () => {
|
||||
const byProvider: Record<string, ParamFilterState> = {
|
||||
alpha: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
beta: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
};
|
||||
let failing = new Set(["alpha"]);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const providerId = url.match(/providers\/([^/]+)\//)![1];
|
||||
if (init?.method === "PUT") {
|
||||
if (failing.has(providerId)) {
|
||||
return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
}
|
||||
const body = JSON.parse(String(init.body));
|
||||
byProvider[providerId].models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (providerId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId={providerId}
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderFor("alpha");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput()!, "alpha-draft"));
|
||||
await blurBlockInput();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// beta saves fine, but alpha is still broken: the indicator must stay up.
|
||||
renderFor("beta");
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput()!, "beta-draft"));
|
||||
await blurBlockInput();
|
||||
|
||||
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["beta-draft"], allow: [] } });
|
||||
expect(byProvider.alpha.models).toEqual({});
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// Once alpha recovers, the preserved draft is written to alpha and the indicator clears.
|
||||
failing = new Set();
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-draft"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover model param filters (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists the latest block and allow drafts when an outside mousedown closes the popover", async () => {
|
||||
const apiPath = "/api/providers/openai/param-filters";
|
||||
let serverState: ParamFilterState = {
|
||||
block: ["provider-block"],
|
||||
allow: ["provider-allow"],
|
||||
models: {
|
||||
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
|
||||
},
|
||||
autoLearn: true,
|
||||
};
|
||||
const putBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) !== apiPath) throw new Error(`Unexpected param-filter URL: ${input}`);
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
putBodies.push(body);
|
||||
serverState = body;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => structuredClone(serverState),
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await openPopover();
|
||||
const blockInput = document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const allowInput = document.querySelector(
|
||||
'input[placeholder="compatAllowedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(blockInput, "temperature, top_p");
|
||||
setInputValue(allowInput, "tools, response_format");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
document.querySelector('input[placeholder="compatBlockedParamsPlaceholder"]')
|
||||
).toBeNull();
|
||||
expect(putBodies).toEqual([
|
||||
{
|
||||
block: ["provider-block"],
|
||||
allow: ["provider-allow"],
|
||||
models: {
|
||||
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
|
||||
"gpt-test": {
|
||||
block: ["temperature", "top_p"],
|
||||
allow: ["tools", "response_format"],
|
||||
},
|
||||
},
|
||||
autoLearn: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await openPopover();
|
||||
expect(
|
||||
(
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement
|
||||
).value
|
||||
).toBe("temperature, top_p");
|
||||
expect(
|
||||
(
|
||||
document.querySelector(
|
||||
'input[placeholder="compatAllowedParamsPlaceholder"]'
|
||||
) as HTMLInputElement
|
||||
).value
|
||||
).toBe("tools, response_format");
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,8 @@ describe("phase-1d extractions (#3501)", () => {
|
||||
const c = renderComponent(
|
||||
<ModelCompatPopover
|
||||
t={(k: string) => k}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
@@ -190,6 +192,8 @@ describe("phase-1d extractions (#3501)", () => {
|
||||
const c = renderComponent(
|
||||
<ModelCompatPopover
|
||||
t={(k: string) => k}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => true}
|
||||
effectiveModelPreserveDeveloper={() => false}
|
||||
getUpstreamHeadersRecord={() => ({ "X-Custom": "value" })}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function EditConnectionModal({
|
||||
accountId: "",
|
||||
codexReasoningEffort: "medium",
|
||||
codexServiceTier: "default" as CodexServiceTier,
|
||||
codexOpenaiStoreEnabled: false,
|
||||
openaiResponsesStoreEnabled: false,
|
||||
preserveEncryptedReasoning: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
@@ -330,7 +330,7 @@ export default function EditConnectionModal({
|
||||
accountId: existingAccountId,
|
||||
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
|
||||
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
|
||||
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
openaiResponsesStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
preserveEncryptedReasoning:
|
||||
connection.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
consoleApiKey: existingConsoleApiKey,
|
||||
@@ -634,8 +634,6 @@ export default function EditConnectionModal({
|
||||
? { serviceTier: formData.codexServiceTier }
|
||||
: {}),
|
||||
};
|
||||
updates.providerSpecificData.openaiStoreEnabled =
|
||||
formData.codexOpenaiStoreEnabled === true;
|
||||
}
|
||||
if (isAntigravityFamily) {
|
||||
updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null;
|
||||
@@ -662,6 +660,8 @@ export default function EditConnectionModal({
|
||||
if (isResponsesConnection && updates.providerSpecificData) {
|
||||
updates.providerSpecificData.preserveEncryptedReasoning =
|
||||
formData.preserveEncryptedReasoning === true;
|
||||
updates.providerSpecificData.openaiStoreEnabled =
|
||||
formData.openaiResponsesStoreEnabled === true;
|
||||
}
|
||||
const freeOnlyChanged =
|
||||
showFreeModelsToggle &&
|
||||
@@ -704,6 +704,16 @@ export default function EditConnectionModal({
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
const openaiResponsesStoreToggle = isResponsesConnection ? (
|
||||
<Toggle
|
||||
checked={formData.openaiResponsesStoreEnabled}
|
||||
onChange={(checked) =>
|
||||
setFormData({ ...formData, openaiResponsesStoreEnabled: checked })
|
||||
}
|
||||
label={t("openaiResponsesStoreLabel")}
|
||||
description={t("openaiResponsesStoreDescription")}
|
||||
/>
|
||||
) : null;
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={t("editConnection")} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -759,12 +769,6 @@ export default function EditConnectionModal({
|
||||
"Default uses the normal Codex tier. Priority shows as Fast; Flex uses the flex service tier when available."
|
||||
)}
|
||||
/>
|
||||
<Toggle
|
||||
checked={formData.codexOpenaiStoreEnabled}
|
||||
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
|
||||
label={t("openaiResponsesStoreLabel")}
|
||||
description={t("openaiResponsesStoreDescription")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isClaude && (
|
||||
@@ -798,6 +802,7 @@ export default function EditConnectionModal({
|
||||
/>
|
||||
)}
|
||||
{preserveEncryptedReasoningToggle}
|
||||
{openaiResponsesStoreToggle}
|
||||
<Toggle
|
||||
checked={formData.disableCooling}
|
||||
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
|
||||
|
||||
@@ -4,29 +4,31 @@ import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Dedicated i18n keys — do NOT reuse settings.auto / autoDesc (those are routing
|
||||
// "Auto Combo" strings and previously made Thinking Budget look like a routing mode).
|
||||
const MODES = [
|
||||
{
|
||||
value: "passthrough",
|
||||
labelKey: "passthrough",
|
||||
descKey: "passthroughDesc",
|
||||
labelKey: "thinkingModePassthrough",
|
||||
descKey: "thinkingModePassthroughDesc",
|
||||
icon: "arrow_forward",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
labelKey: "auto",
|
||||
descKey: "autoDesc",
|
||||
labelKey: "thinkingModeAuto",
|
||||
descKey: "thinkingModeAutoDesc",
|
||||
icon: "auto_awesome",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
labelKey: "custom",
|
||||
descKey: "customDesc",
|
||||
labelKey: "thinkingModeCustom",
|
||||
descKey: "thinkingModeCustomDesc",
|
||||
icon: "tune",
|
||||
},
|
||||
{
|
||||
value: "adaptive",
|
||||
labelKey: "adaptive",
|
||||
descKey: "adaptiveDesc",
|
||||
labelKey: "thinkingModeAdaptive",
|
||||
descKey: "thinkingModeAdaptiveDesc",
|
||||
icon: "trending_up",
|
||||
},
|
||||
];
|
||||
@@ -94,6 +96,9 @@ export default function ThinkingBudgetTab() {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("thinkingBudgetTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("thinkingBudgetDesc")}</p>
|
||||
<p className="text-xs text-text-muted mt-1 leading-relaxed">
|
||||
{t("thinkingBudgetIndependenceHint")}
|
||||
</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ANTHROPIC_PING_FRAME,
|
||||
} from "@omniroute/open-sse/utils/earlyStreamKeepalive";
|
||||
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
|
||||
import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
@@ -54,22 +55,27 @@ async function postHandler(request: any, context: any, preParsedBody: any = null
|
||||
// /v1/responses (#2544). Anthropic clients ignore SSE comments for their watchdog, so
|
||||
// emit a real `event: ping` (ANTHROPIC_PING_FRAME). Non-streaming callers keep the
|
||||
// verbatim path.
|
||||
const accept = String(request.headers?.get?.("accept") || "").toLowerCase();
|
||||
if (accept.includes("text/event-stream")) {
|
||||
let model;
|
||||
let body = preParsedBody;
|
||||
if (body == null) {
|
||||
try {
|
||||
const body = preParsedBody ?? (await request.clone().json().catch(() => null));
|
||||
model = body?.model;
|
||||
body = await request
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
} catch {
|
||||
// body unavailable / non-JSON — fall back to the default keepalive threshold
|
||||
// body unavailable / non-JSON — handleChat will return its normal validation error
|
||||
}
|
||||
return await withEarlyStreamKeepalive(handleChat(request, null, preParsedBody), {
|
||||
}
|
||||
const accept = String(request.headers?.get?.("accept") || "");
|
||||
const wantsStreaming = resolveStreamFlag(body?.stream, accept, "claude");
|
||||
if (wantsStreaming) {
|
||||
return await withEarlyStreamKeepalive(handleChat(request, null, body), {
|
||||
signal: request.signal,
|
||||
thresholdMs: resolveKeepaliveThreshold(model),
|
||||
thresholdMs: resolveKeepaliveThreshold(body?.model),
|
||||
keepaliveFrame: ANTHROPIC_PING_FRAME,
|
||||
});
|
||||
}
|
||||
return await handleChat(request, null, preParsedBody);
|
||||
return await handleChat(request, null, body);
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user