mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
feat: add setting for provider/model-specific parameters (#6649)
* fix(electron): bump electron 42→43 + build better-sqlite3 from source (ABI 148) (#6605) fix(electron): bump electron 42→43 + rebuild better-sqlite3 from source against the Electron ABI (148). Electron 43 raises NODE_MODULE_VERSION to 148; better-sqlite3@12.11.1 has no electron-v148 prebuild, so the packaged app died with 'Nenhum driver SQLite disponível'. prepare-electron-standalone now compiles better-sqlite3 from source against the electron headers into build/Release (where 'bindings' resolves it). Validated by Electron Package Smoke (green) + local (node_register_module_v148). Supersedes #6378. (--admin: the only reds are SonarQube/SonarCloud failing on a coverage-report artifact digest-mismatch — a GitHub Actions infra flake, not this diff; Sonar is green on main and the diff touches only the electron build.) * deps: bump the development group across 1 directory with 6 updates (#6588) deps: bump the development group (6 updates). Rebased onto current main; all checks green after the electron-smoke fix (#6605). * fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump (#6620) fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump. undici 8.6+ changed ProxyAgent to forward plain-HTTP via request-proxy instead of CONNECT, breaking OAuth refresh through a connection proxy (501). proxyDispatcher now passes proxyTunnel:true. Validated: Unit Tests 3/8 (the OAuth-proxy test) green, new regression test green (fails without the fix on undici 8.7), SonarQube green. Supersedes #6380. (--admin: the only red is Electron Package Smoke failing on a next-build artifact 'digest-mismatch' — a GitHub Actions infra flake corrupting the asar ('file data stream has unexpected number of bytes'); the better-sqlite3 rebuild itself succeeded (gyp ok) and the electron path is unchanged from #6605 which passed the smoke. Not this diff.) * feat(db): add provider param filter config store (key_value namespace) Add paramFilters.ts module for CRUD against provider_param_filters namespace in the key_value table, with in-memory cache + generation counter invalidation. Supports denylist/allowlist per provider and per model, plus auto-learn flag. Migration 118 documents the namespace (no schema change). Issue: #6625 * feat(proxy): add detectUnsupportedParam regex for auto-learning Add UNSUPPORTED_PARAM_RE and detectUnsupportedParam() to extract the offending parameter name from upstream 400 error messages like 'Unsupported parameter(s): thinking'. Issue: #6625 * feat(proxy): extend stripUnsupportedParams with config-driven denylist/allowlist Add applyConfigFilters() called after hardcoded STRIP_RULES in stripUnsupportedParams(). Config-driven rules (DB-backed via paramFilters.ts) support provider-level and model-level: 1. Provider denylist (delete body[key]) 2. Model denylist (delete body[key]) 3. Provider allowlist (restore from pre-strip snapshot) 4. Model allowlist (restore from pre-strip snapshot) Allowlist only restores keys the client actually sent — never introduces new params. Issue: #6625 * feat(proxy): wire auto-learn of unsupported params into 400-downgrade loop When a provider returns 400 with 'Unsupported parameter: X' and the provider config has autoLearn enabled, auto-detect the param name via detectUnsupportedParam(), persist it to the provider's block list via addParamToBlocklist(), then strip and retry. Issue: #6625 * test: add tests for provider param filter denylist/allowlist/auto-learn Three new test files: - param-filters-apply.test.ts — hardcoded rules regression + direct applyConfigFilters tests (no DB dependency) - param-filters-db.test.ts — CRUD against key_value, cache invalidation, full filter pipeline (DB-backed config → stripUnsupportedParams), 16 tests in isolated temp DB - param-filters-auto-learn.test.ts — UNSUPPORTED_PARAM_RE regex matching and detectUnsupportedParam edge cases All existing tests unchanged and passing. Issue: #6625 * feat(proxy): add global auto-learn flag for unsupported params Add isAutoLearnGloballyEnabled() and setGlobalAutoLearnEnabled() to paramFilters.ts. The global flag (stored as key __global__ in the provider_param_filters namespace) acts as a master switch: when enabled, ALL providers auto-learn unsupported params from 400 errors. In base.ts, the auto-learn check now evaluates: shouldAutoLearn = isAutoLearnGloballyEnabled() || perProviderConfig?.autoLearn Global flag defaults to false (opt-in). Tests cover enable/disable/ default/no-interference-with-per-provider-config. Issue: #6625 * fix: apply PR#6649 review feedback — model-scoped auto-learn and precedence order Fixes from gemini-code-assist[bot] review: - HIGH: Auto-learn now scoped to the specific model that triggered the 400 (addParamToBlocklist(this.provider, autoLearned, model)) instead of adding to the provider-level blocklist globally - HIGH: Reordered applyConfigFilters so model-level operations run AFTER provider-level operations (model denylist → model allowlist override provider allowlist → provider denylist) - MEDIUM: Include model name in auto-learn log message Adds regression test verifying model-level denylist beats provider-level allowlist. Issue: #6625 PR: #6649 * feat(ui): add provider-level param filter section to detail page Add ProviderParamFilterSection component rendered on each provider detail page, backed by GET|PUT|DELETE /api/providers/[id]/param-filters. UI allows operators to configure: - Blocked params (comma-separated, stripped from outgoing requests) - Allowed params (comma-separated, re-added after denylist stripping) - Auto-learn toggle (per-provider, enables auto-learning from 400 errors) Wired into ProviderDetailPageClient.tsx between the Playground panel and the Modals section. Issue: #6625 PR: #6649 * feat(ui): add model-level param filter fields in compat popover Extend ModelCompatPopover with Blocked params and Allowed params text inputs for model-level denylist/allowlist overrides. Model-specific block/allow data is persisted via the param-filters API endpoint (PUT /api/providers/:id/param-filters) with the model scope under the models key. Both ModelRow and PassthroughModelRow now pass providerId and modelId to the popover. Issue: #6625 PR: #6649 * chore: gitignore .claude-flow/ * fix(param-filters): review follow-ups — auth gate, error sanitization, Zod body validation, typecheck, file-size, i18n keys Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(param-filters): drop unrelated main-drift from the fork branch (deps/electron/proxy files belong to #6620/#6605/#6588, not this PR) * refactor(param-filters): split oversized functions — keep complexity gate at baseline Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(param-filters): decompose config parser helpers — keep cognitive-complexity gate at baseline Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): restore sibling #6648 bullet eaten by merge auto-resolve + re-insert #6649 entry Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
This commit is contained in:
parent
2d9f85d587
commit
1188847f0f
23 changed files with 1439 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -233,3 +233,4 @@ omniroute.md
|
|||
# mise configuration
|
||||
mise.toml
|
||||
_artifacts/
|
||||
.claude-flow/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
|
||||
_Living section — bullets land here as PRs merge into `release/v3.8.47` (parallel-cycle model; cycle opened at the v3.8.46 release freeze). Finalized at the v3.8.47 release._
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer <apiKey>` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy).
|
||||
|
|
|
|||
|
|
@ -22,6 +22,26 @@ export function findOffendingField(bodyText: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex to extract an unsupported parameter name from upstream 400 error text.
|
||||
* Matches:
|
||||
* - "Unsupported parameter(s): thinking"
|
||||
* - "Unsupported parameter: max_tokens"
|
||||
* - "Unsupported parameter 'reasoning_budget'"
|
||||
*/
|
||||
export const UNSUPPORTED_PARAM_RE =
|
||||
/unsupported\s+parameter\w*(?:\s*\(s\))?[:\s]+["'`]?(\w+)["'`]?/i;
|
||||
|
||||
/**
|
||||
* Extract a single unsupported parameter name from a 400 error body,
|
||||
* or null if the error does not match the known pattern.
|
||||
*/
|
||||
export function detectUnsupportedParam(bodyText: string): string | null {
|
||||
if (typeof bodyText !== "string" || !bodyText) return null;
|
||||
const match = UNSUPPORTED_PARAM_RE.exec(bodyText);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** Immutably drop request fields Groq rejects with a 400. */
|
||||
export function stripGroqUnsupportedFields<T extends Record<string, unknown>>(body: T): T {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ import {
|
|||
normalizeAnthropicHeaderVariants,
|
||||
} from "../config/anthropicHeaders.ts";
|
||||
import { applyContextEditingToBody } from "../config/contextEditing.ts";
|
||||
import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts";
|
||||
import {
|
||||
findOffendingField,
|
||||
detectUnsupportedParam,
|
||||
stripGroqUnsupportedFields,
|
||||
} from "../config/providerFieldStrips.ts";
|
||||
import {
|
||||
getParamFilterConfig,
|
||||
addParamToBlocklist,
|
||||
isAutoLearnGloballyEnabled,
|
||||
} from "@/lib/db/paramFilters";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
|
|
@ -1261,6 +1270,39 @@ export class BaseExecutor {
|
|||
`Upstream 400 rejected ${offending} on ${url} — retrying without it`
|
||||
);
|
||||
response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
|
||||
} else {
|
||||
// Auto-learn: detect "Unsupported parameter" errors and persist to DB
|
||||
// when the provider config has autoLearn enabled (#6625).
|
||||
const autoLearned = detectUnsupportedParam(errText);
|
||||
if (
|
||||
autoLearned &&
|
||||
!strippedFields.has(autoLearned) &&
|
||||
(transformedBody as Record<string, unknown>)[autoLearned] !== undefined
|
||||
) {
|
||||
try {
|
||||
const config = getParamFilterConfig(this.provider);
|
||||
const shouldAutoLearn = isAutoLearnGloballyEnabled() || config?.autoLearn === true;
|
||||
if (shouldAutoLearn) {
|
||||
strippedFields.add(autoLearned);
|
||||
addParamToBlocklist(this.provider, autoLearned, model);
|
||||
delete (transformedBody as Record<string, unknown>)[autoLearned];
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.info?.(
|
||||
"AUTO_LEARN",
|
||||
`Auto-learned "${autoLearned}" for provider ${this.provider} (model: ${model}) from 400 on ${url} — retrying`
|
||||
);
|
||||
response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
|
||||
}
|
||||
} catch (learnError) {
|
||||
log?.warn?.(
|
||||
"AUTO_LEARN",
|
||||
`Failed to persist auto-learned param "${autoLearned}" for ${this.provider}: ${String(learnError)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
// introduces new keys and never throws on null/undefined bodies — call sites
|
||||
// can chain it without extra guards.
|
||||
|
||||
import { getParamFilterConfig, ModelParamFilter, ProviderParamFilter } from "@/lib/db/paramFilters";
|
||||
|
||||
type StripRule = {
|
||||
provider?: string;
|
||||
match: RegExp | ((model: string) => boolean);
|
||||
|
|
@ -25,8 +27,7 @@ const STRIP_RULES: StripRule[] = [
|
|||
// GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713
|
||||
{
|
||||
provider: "github",
|
||||
match: (m: string) =>
|
||||
/claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m),
|
||||
match: (m: string) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m),
|
||||
drop: ["thinking", "reasoning_effort"],
|
||||
},
|
||||
// NVIDIA NIM z-ai/glm-5.2: OpenAI-compatible wrapper rejects BOTH the `reasoning`
|
||||
|
|
@ -56,6 +57,11 @@ export function stripUnsupportedParams<T>(
|
|||
): T {
|
||||
if (!model || !body || typeof body !== "object") return body;
|
||||
const rec = body as unknown as Record<string, unknown>;
|
||||
// Snapshot the original body before any mutations so the allowlist can
|
||||
// restore params that were stripped by hardcoded or config-driven denylist.
|
||||
const snapshot = { ...rec };
|
||||
|
||||
// Phase 1: Hardcoded rules (unchanged)
|
||||
for (const rule of STRIP_RULES) {
|
||||
if (rule.provider && rule.provider !== provider) continue;
|
||||
if (!matches(rule, model)) continue;
|
||||
|
|
@ -63,8 +69,94 @@ export function stripUnsupportedParams<T>(
|
|||
if (rec[key] !== undefined) delete rec[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Config-driven rules from DB
|
||||
applyConfigFilters(provider, model, rec, snapshot);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore keys from `snapshot` into `body` for every key listed in `allow`,
|
||||
* but only when the key was present in the original request. Shared by the
|
||||
* provider-level and model-level allowlist passes below.
|
||||
*/
|
||||
function restoreAllowedKeys(
|
||||
body: Record<string, unknown>,
|
||||
snapshot: Record<string, unknown>,
|
||||
allow: readonly string[]
|
||||
): void {
|
||||
for (const key of allow) {
|
||||
if (key in snapshot) {
|
||||
body[key] = snapshot[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the provider-level denylist, then restore the provider-level
|
||||
* allowlist from `snapshot`. Runs BEFORE model-level operations so
|
||||
* model-level settings can override provider-level ones.
|
||||
*/
|
||||
function applyProviderLevelFilters(
|
||||
body: Record<string, unknown>,
|
||||
snapshot: Record<string, unknown>,
|
||||
config: ProviderParamFilter
|
||||
): void {
|
||||
for (const key of config.block) {
|
||||
delete body[key];
|
||||
}
|
||||
if (config.allow.length > 0) {
|
||||
restoreAllowedKeys(body, snapshot, config.allow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the model-level denylist (overrides the provider-level allowlist),
|
||||
* then restore the model-level allowlist from `snapshot` (final pass, most
|
||||
* specific wins).
|
||||
*/
|
||||
function applyModelLevelFilters(
|
||||
body: Record<string, unknown>,
|
||||
snapshot: Record<string, unknown>,
|
||||
modelCfg: ModelParamFilter | undefined
|
||||
): void {
|
||||
if (modelCfg?.block) {
|
||||
for (const key of modelCfg.block) {
|
||||
delete body[key];
|
||||
}
|
||||
}
|
||||
if (modelCfg?.allow) {
|
||||
restoreAllowedKeys(body, snapshot, modelCfg.allow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply config-driven denylist + allowlist rules from the DB-backed
|
||||
* ProviderParamFilter store. Order of operations:
|
||||
* 1. Provider-level denylist
|
||||
* 2. Model-level denylist
|
||||
* 3. Provider-level allowlist (restores from snapshot)
|
||||
* 4. Model-level allowlist (restores from snapshot)
|
||||
*
|
||||
* The allowlist only restores keys that were present in the original request
|
||||
* (the snapshot). It never introduces new params the client didn't send.
|
||||
*/
|
||||
export function applyConfigFilters(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
body: Record<string, unknown>,
|
||||
snapshot: Record<string, unknown>
|
||||
): void {
|
||||
if (!provider || !body) return;
|
||||
const config = getParamFilterConfig(provider);
|
||||
if (!config) return;
|
||||
|
||||
applyProviderLevelFilters(body, snapshot, config);
|
||||
|
||||
const modelCfg = config.models?.[model ?? ""];
|
||||
applyModelLevelFilters(body, snapshot, modelCfg);
|
||||
}
|
||||
|
||||
// Exported for unit tests only — do not import from production code.
|
||||
export const __STRIP_RULES_FOR_TEST: ReadonlyArray<StripRule> = STRIP_RULES;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import { useModelVisibilityHandlers } from "./hooks/useModelVisibilityHandlers";
|
|||
import { useModelCompatState } from "./hooks/useModelCompatState";
|
||||
import { useConnectionGate } from "./hooks/useConnectionGate";
|
||||
import { useProviderNodeActions } from "./hooks/useProviderNodeActions";
|
||||
import ProviderPlaygroundPanel from "./components/ProviderPlaygroundPanel";
|
||||
import ProviderExtraPanels from "./components/ProviderExtraPanels";
|
||||
import ProviderModelsSection from "./components/ProviderModelsSection";
|
||||
import CustomModelsSection from "./components/CustomModelsSection";
|
||||
import ConnectionsListPanel from "./components/ConnectionsListPanel";
|
||||
|
|
@ -696,8 +696,8 @@ export default function ProviderDetailPageClient() {
|
|||
{/* Search provider info */}
|
||||
{isSearchProvider && <SearchProviderCard providerId={providerId} t={t} />}
|
||||
|
||||
{/* Playground panel — rendered for providers that declare serviceKinds */}
|
||||
<ProviderPlaygroundPanel providerId={providerId} />
|
||||
{/* Playground + param filters — extracted to components/ProviderExtraPanels.tsx (#6649) */}
|
||||
<ProviderExtraPanels providerId={providerId} />
|
||||
|
||||
{/* Modals — Phase 1t.5: extracted to components/ProviderModalsPanel.tsx */}
|
||||
<ProviderModalsPanel
|
||||
|
|
|
|||
|
|
@ -26,12 +26,54 @@ function recordToHeaderRows(rec: Record<string, string>, genId: () => string): H
|
|||
return entries.map(([name, value]) => ({ id: genId(), name, value }));
|
||||
}
|
||||
|
||||
function parseCommaList(text: string): string[] {
|
||||
return text
|
||||
? text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
interface ParamFilterConfigLike {
|
||||
block?: string[];
|
||||
allow?: string[];
|
||||
models?: Record<string, unknown>;
|
||||
autoLearn?: boolean;
|
||||
}
|
||||
|
||||
// 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(
|
||||
current: ParamFilterConfigLike | null | undefined,
|
||||
modelId: string,
|
||||
blockText: string,
|
||||
allowText: string
|
||||
) {
|
||||
const updatedModels: Record<string, unknown> = { ...(current?.models ?? {}) };
|
||||
const block = parseCommaList(blockText);
|
||||
const allow = parseCommaList(allowText);
|
||||
if (block.length > 0 || allow.length > 0) {
|
||||
updatedModels[modelId] = { block, allow };
|
||||
} else {
|
||||
delete updatedModels[modelId];
|
||||
}
|
||||
return {
|
||||
block: current?.block ?? [],
|
||||
allow: current?.allow ?? [],
|
||||
models: Object.keys(updatedModels).length > 0 ? updatedModels : undefined,
|
||||
autoLearn: current?.autoLearn ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ModelCompatPopoverProps {
|
||||
t: (key: string) => string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
effectiveModelNormalize: (protocol: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (protocol: string) => boolean;
|
||||
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
|
||||
|
|
@ -65,6 +107,10 @@ export default function ModelCompatPopover({
|
|||
const [open, setOpen] = useState(false);
|
||||
const [protocol, setProtocol] = useState<string>(MODEL_COMPAT_PROTOCOL_KEYS[0]);
|
||||
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
|
||||
const [blockText, setBlockText] = useState("");
|
||||
const [allowText, setAllowText] = useState("");
|
||||
const [paramDirty, setParamDirty] = useState(false);
|
||||
const [paramSaving, setParamSaving] = useState(false);
|
||||
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
|
||||
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -118,6 +164,44 @@ export default function ModelCompatPopover({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
|
||||
}, [open, protocol]);
|
||||
|
||||
// Load model-level block/allow from param-filters API
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
const data = await res.json();
|
||||
const modelCfg = data?.models?.[modelId];
|
||||
setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : "");
|
||||
setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : "");
|
||||
} catch {
|
||||
setBlockText("");
|
||||
setAllowText("");
|
||||
}
|
||||
setParamDirty(false);
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
const saveModelParamFilters = useCallback(async () => {
|
||||
if (!paramDirty) return;
|
||||
setParamSaving(true);
|
||||
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
|
||||
} finally {
|
||||
setParamSaving(false);
|
||||
}
|
||||
}, [paramDirty, blockText, allowText]);
|
||||
|
||||
useEffect(() => {
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
|
|
@ -266,6 +350,48 @@ export default function ModelCompatPopover({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Param filters — model-level block/allow (#6625) */}
|
||||
<div className="mt-4 space-y-2.5">
|
||||
<label className="block text-[11px] font-semibold text-text-main">
|
||||
{t("compatParamFiltersLabel") ?? "Param Filters"}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={blockText}
|
||||
onChange={(e) => {
|
||||
setBlockText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder="thinking, … (comma-separated)"
|
||||
disabled={disabled}
|
||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
||||
{paramSaving && " ● saving…"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={allowText}
|
||||
onChange={(e) => {
|
||||
setAllowText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder="reasoning, … (comma-separated)"
|
||||
disabled={disabled}
|
||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border-2 border-zinc-200 bg-zinc-100 p-3 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<label className="block text-[11px] font-semibold text-text-main mb-1">
|
||||
{t("compatUpstreamHeadersLabel")}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ export interface ModelRowProps {
|
|||
export default function ModelRow({
|
||||
model,
|
||||
fullModel,
|
||||
provider,
|
||||
alias,
|
||||
copied,
|
||||
onCopy,
|
||||
|
|
@ -445,6 +446,8 @@ export default function ModelRow({
|
|||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={provider}
|
||||
modelId={model.id}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { ModelSourceBadge, type ModelCompatSavePatch } from "./ModelRow";
|
|||
export interface PassthroughModelRowProps {
|
||||
modelId: string;
|
||||
fullModel: string;
|
||||
provider: string;
|
||||
alias?: string | null;
|
||||
source?: string;
|
||||
isFree?: boolean;
|
||||
|
|
@ -64,6 +65,7 @@ export default function PassthroughModelRow({
|
|||
effectiveModelPreserveDeveloper,
|
||||
getUpstreamHeadersRecord,
|
||||
saveModelCompatFlags,
|
||||
provider,
|
||||
compatDisabled,
|
||||
onToggleHidden,
|
||||
togglingHidden,
|
||||
|
|
@ -223,6 +225,8 @@ export default function PassthroughModelRow({
|
|||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={provider}
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ export default function PassthroughModelsSection({
|
|||
key={fullModel as string}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
provider={providerId}
|
||||
alias={alias}
|
||||
source={source}
|
||||
isFree={isFree}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
// Groups the provider detail page's "extra" sections — playground + param
|
||||
// filters — behind a single import/render call from ProviderDetailPageClient.tsx.
|
||||
// Extracted so the frozen host file stays within the file-size ratchet
|
||||
// (#6649 review follow-up: keeps ProviderDetailPageClient.tsx at its ≤784 cap).
|
||||
|
||||
import ProviderPlaygroundPanel from "./ProviderPlaygroundPanel";
|
||||
import ProviderParamFilterSection from "./ProviderParamFilterSection";
|
||||
|
||||
export default function ProviderExtraPanels({ providerId }: { providerId: string }) {
|
||||
return (
|
||||
<>
|
||||
{/* Playground panel — rendered for providers that declare serviceKinds */}
|
||||
<ProviderPlaygroundPanel providerId={providerId} />
|
||||
|
||||
{/* Param filters — denylist/allowlist config per provider/model (#6625) */}
|
||||
<ProviderParamFilterSection providerId={providerId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderParamFilterSection — Denylist/allowlist config for provider-level
|
||||
* request parameter filtering (#6625).
|
||||
*
|
||||
* Renders a card on the provider detail page where operators can configure
|
||||
* which request params to strip (block) or selectively re-add (allow) before
|
||||
* sending to the upstream provider.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
interface ProviderParamFilterSectionProps {
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
interface ParamFilterConfig {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block?: string[]; allow?: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
function parseCommaList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatCommaList(arr: string[]): string {
|
||||
return arr.join(", ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch helpers — isolate the HTTP + response-shape concerns so the
|
||||
// component's handlers stay focused on state transitions + user feedback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchParamFilterConfig(providerId: string): Promise<ParamFilterConfig> {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
block: Array.isArray(data.block) ? data.block : [],
|
||||
allow: Array.isArray(data.allow) ? data.allow : [],
|
||||
autoLearn: typeof data.autoLearn === "boolean" ? data.autoLearn : false,
|
||||
};
|
||||
}
|
||||
|
||||
async function throwOnErrorResponse(res: Response): Promise<void> {
|
||||
if (res.ok) return;
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
async function putParamFilterConfig(providerId: string, body: ParamFilterConfig): Promise<void> {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await throwOnErrorResponse(res);
|
||||
}
|
||||
|
||||
async function deleteParamFilterConfig(providerId: string): Promise<void> {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`, { method: "DELETE" });
|
||||
await throwOnErrorResponse(res);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State hook — owns config load/save/reset so the component body stays JSX-only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string>) => string;
|
||||
|
||||
// Wraps a raw state setter so updating the draft value also marks the form
|
||||
// dirty — used for the three form-local draft fields below.
|
||||
function useDirtySetter<T>(setValue: (value: T) => void, setDirty: (value: boolean) => void) {
|
||||
return useCallback(
|
||||
(value: T) => {
|
||||
setValue(value);
|
||||
setDirty(true);
|
||||
},
|
||||
[setValue, setDirty]
|
||||
);
|
||||
}
|
||||
|
||||
function useProviderParamFilterConfig(providerId: string, t: Translate) {
|
||||
const notify = useNotificationStore();
|
||||
const [, setConfig] = useState<ParamFilterConfig>({ block: [], allow: [], autoLearn: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [blockText, setBlockTextState] = useState("");
|
||||
const [allowText, setAllowTextState] = useState("");
|
||||
const [autoLearn, setAutoLearnState] = useState(false);
|
||||
|
||||
const setBlockText = useDirtySetter(setBlockTextState, setDirty);
|
||||
const setAllowText = useDirtySetter(setAllowTextState, setDirty);
|
||||
const setAutoLearn = useDirtySetter(setAutoLearnState, setDirty);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cfg = await fetchParamFilterConfig(providerId);
|
||||
setConfig(cfg);
|
||||
setBlockTextState(formatCommaList(cfg.block));
|
||||
setAllowTextState(formatCommaList(cfg.allow));
|
||||
setAutoLearnState(cfg.autoLearn);
|
||||
} catch (err) {
|
||||
notify.notify(t("paramFiltersLoadError", { error: errorMessage(err) }), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: ParamFilterConfig = {
|
||||
block: parseCommaList(blockText),
|
||||
allow: parseCommaList(allowText),
|
||||
autoLearn,
|
||||
};
|
||||
await putParamFilterConfig(providerId, body);
|
||||
setConfig(body);
|
||||
setDirty(false);
|
||||
notify.notify(t("paramFiltersSaveSuccess"), "success");
|
||||
} catch (err) {
|
||||
notify.notify(t("paramFiltersSaveError", { error: errorMessage(err) }), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [providerId, blockText, allowText, autoLearn, notify, t]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteParamFilterConfig(providerId);
|
||||
setConfig({ block: [], allow: [], autoLearn: false });
|
||||
setBlockTextState("");
|
||||
setAllowTextState("");
|
||||
setAutoLearnState(false);
|
||||
setDirty(false);
|
||||
notify.notify(t("paramFiltersResetSuccess"), "success");
|
||||
} catch (err) {
|
||||
notify.notify(t("paramFiltersResetError", { error: errorMessage(err) }), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
dirty,
|
||||
blockText,
|
||||
allowText,
|
||||
autoLearn,
|
||||
setBlockText,
|
||||
setAllowText,
|
||||
setAutoLearn,
|
||||
handleSave,
|
||||
handleReset,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presentational sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ParamFilterSectionSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white p-5 dark:bg-zinc-950">
|
||||
<div className="h-5 w-48 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
|
||||
<div className="mt-4 h-20 animate-pulse rounded bg-zinc-100 dark:bg-zinc-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamFilterSectionHeader({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">
|
||||
{t("paramFiltersSectionTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">
|
||||
{t.rich("paramFiltersSectionHint", {
|
||||
code: (chunks) => (
|
||||
<code className="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 rounded">{chunks}</code>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParamListFieldProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ParamListField({ label, hint, value, placeholder, onChange }: ParamListFieldProps) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-border bg-white px-3 py-2 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary dark:bg-zinc-900"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoLearnToggleProps {
|
||||
t: (key: string) => string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function AutoLearnToggle({ t, checked, onChange }: AutoLearnToggleProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-xs font-medium text-text-main">
|
||||
{t("paramFiltersAutoLearnLabel")}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-text-muted mt-1 ml-5">{t("paramFiltersAutoLearnHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParamFilterActionsProps {
|
||||
t: (key: string) => string;
|
||||
saving: boolean;
|
||||
dirty: boolean;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function ParamFilterActions({ t, saving, dirty, onSave, onReset }: ParamFilterActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving || !dirty}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">save</span>
|
||||
)}
|
||||
{saving ? t("paramFiltersSaving") : t("paramFiltersSaveChanges")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-text-muted hover:text-text-main hover:border-primary/40 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
{t("paramFiltersResetToDefault")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ProviderParamFilterSection({
|
||||
providerId,
|
||||
}: ProviderParamFilterSectionProps) {
|
||||
const t = useTranslations("providers");
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
dirty,
|
||||
blockText,
|
||||
allowText,
|
||||
autoLearn,
|
||||
setBlockText,
|
||||
setAllowText,
|
||||
setAutoLearn,
|
||||
handleSave,
|
||||
handleReset,
|
||||
} = useProviderParamFilterConfig(providerId, t);
|
||||
|
||||
if (loading) {
|
||||
return <ParamFilterSectionSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white p-5 dark:bg-zinc-950">
|
||||
<ParamFilterSectionHeader t={t} />
|
||||
<ParamListField
|
||||
label={t("paramFiltersBlockedLabel")}
|
||||
hint={t("paramFiltersBlockedHint")}
|
||||
value={blockText}
|
||||
placeholder="thinking, reasoning_budget, … (comma-separated)"
|
||||
onChange={setBlockText}
|
||||
/>
|
||||
<ParamListField
|
||||
label={t("paramFiltersAllowedLabel")}
|
||||
hint={t("paramFiltersAllowedHint")}
|
||||
value={allowText}
|
||||
placeholder="reasoning, … (comma-separated)"
|
||||
onChange={setAllowText}
|
||||
/>
|
||||
<AutoLearnToggle t={t} checked={autoLearn} onChange={setAutoLearn} />
|
||||
<ParamFilterActions
|
||||
t={t}
|
||||
saving={saving}
|
||||
dirty={dirty}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/app/api/providers/[id]/param-filters/route.ts
Normal file
80
src/app/api/providers/[id]/param-filters/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getParamFilterConfig,
|
||||
setParamFilterConfig,
|
||||
deleteParamFilterConfig,
|
||||
} from "@/lib/db/paramFilters";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { updateParamFilterConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
/**
|
||||
* GET /api/providers/[id]/param-filters
|
||||
* Returns the param filter config for a provider, or null if not configured.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const config = getParamFilterConfig(id);
|
||||
return NextResponse.json(config ?? { block: [], allow: [], autoLearn: false });
|
||||
} catch (error) {
|
||||
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/providers/[id]/param-filters
|
||||
* Upsert the param filter config for a provider.
|
||||
* Body: { block?: string[], allow?: string[], models?: Record<string, { block?: string[], allow?: string[] }>, autoLearn?: boolean }
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const validation = validateBody(updateParamFilterConfigSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { block, allow, models, autoLearn } = validation.data;
|
||||
|
||||
setParamFilterConfig(id, {
|
||||
block: block ?? [],
|
||||
allow: allow ?? [],
|
||||
models,
|
||||
autoLearn: autoLearn ?? false,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/providers/[id]/param-filters
|
||||
* Remove the param filter config for a provider (reset to no filtering).
|
||||
*/
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
deleteParamFilterConfig(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -4319,6 +4319,25 @@
|
|||
"targetFormatAuto": "Default (auto)",
|
||||
"targetFormatGemini": "Gemini",
|
||||
"targetFormatAntigravity": "Antigravity",
|
||||
"compatParamFiltersLabel": "Param Filters",
|
||||
"compatBlockedParamsHint": "Blocked params (stripped from requests)",
|
||||
"compatAllowedParamsHint": "Allowed params (re-added after deny)",
|
||||
"paramFiltersSectionTitle": "Param Filters",
|
||||
"paramFiltersSectionHint": "Strip or re-add request parameters before sending to this provider. Used to avoid 400 errors from providers that reject certain params (e.g. NVIDIA NIM rejects <code>thinking</code>).",
|
||||
"paramFiltersBlockedLabel": "Blocked parameters",
|
||||
"paramFiltersBlockedHint": "These params are stripped from outgoing requests (denylist).",
|
||||
"paramFiltersAllowedLabel": "Allowed parameters",
|
||||
"paramFiltersAllowedHint": "These params are re-added after denylist stripping (only if the client sent them).",
|
||||
"paramFiltersAutoLearnLabel": "Auto-learn from 400 errors",
|
||||
"paramFiltersAutoLearnHint": "When enabled, if the upstream returns a 400 with \"Unsupported parameter: X\", the param is automatically added to the block list and the request retried.",
|
||||
"paramFiltersSaving": "Saving…",
|
||||
"paramFiltersSaveChanges": "Save Changes",
|
||||
"paramFiltersResetToDefault": "Reset to Default",
|
||||
"paramFiltersLoadError": "Failed to load param filter config: {error}",
|
||||
"paramFiltersSaveSuccess": "Param filter config saved",
|
||||
"paramFiltersSaveError": "Failed to save param filter config: {error}",
|
||||
"paramFiltersResetSuccess": "Param filter config reset to defaults",
|
||||
"paramFiltersResetError": "Failed to reset param filter config: {error}",
|
||||
"compatUpstreamHeadersLabel": "Extra upstream headers",
|
||||
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
|
||||
"compatUpstreamHeaderName": "Header name",
|
||||
|
|
|
|||
7
src/lib/db/migrations/118_provider_param_filters.sql
Normal file
7
src/lib/db/migrations/118_provider_param_filters.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- 118_provider_param_filters.sql
|
||||
-- Documents the provider_param_filters namespace in the key_value table.
|
||||
-- No schema change — the key_value table already exists.
|
||||
-- Key = provider ID, value = JSON with shape:
|
||||
-- { block: string[], allow: string[], models?: { [modelId]: { block?: string[], allow?: string[] } }, autoLearn?: boolean }
|
||||
--
|
||||
-- See: src/lib/db/paramFilters.ts
|
||||
238
src/lib/db/paramFilters.ts
Normal file
238
src/lib/db/paramFilters.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* db/paramFilters.ts — Provider/Model parameter filter configuration.
|
||||
*
|
||||
* CRUD against the key_value table under namespace "provider_param_filters".
|
||||
* Follows the established key_value pattern from databaseSettings.ts.
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
const NAMESPACE = "provider_param_filters";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModelParamFilter {
|
||||
block?: string[];
|
||||
allow?: string[];
|
||||
}
|
||||
|
||||
export interface ProviderParamFilter {
|
||||
/** Provider-level params to strip from all requests to this provider. */
|
||||
block: string[];
|
||||
/** Provider-level params to re-add after denylist stripping. */
|
||||
allow: string[];
|
||||
/** Model-specific overrides (stricter than provider-level). */
|
||||
models?: Record<string, ModelParamFilter>;
|
||||
/** When true, upstream 400 "Unsupported parameter" errors auto-populate block. */
|
||||
autoLearn?: boolean;
|
||||
}
|
||||
|
||||
// ── Cache ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let filterCache: Map<string, ProviderParamFilter> | null = null;
|
||||
let cacheGeneration = 0;
|
||||
|
||||
function bumpCacheGeneration(): void {
|
||||
cacheGeneration++;
|
||||
filterCache = null;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseStoredValue(raw: unknown): unknown {
|
||||
if (typeof raw !== "string") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function toNormalizedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((k): k is string => typeof k === "string") : [];
|
||||
}
|
||||
|
||||
function toModelParamFilter(raw: Record<string, unknown>): ModelParamFilter | null {
|
||||
const block = toStringArray(raw.block);
|
||||
const allow = toStringArray(raw.allow);
|
||||
if (block.length === 0 && allow.length === 0) return null;
|
||||
const filter: ModelParamFilter = {};
|
||||
if (block.length > 0) filter.block = block;
|
||||
if (allow.length > 0) filter.allow = allow;
|
||||
return filter;
|
||||
}
|
||||
|
||||
function toModelParamFilters(raw: unknown): Record<string, ModelParamFilter> {
|
||||
const models: Record<string, ModelParamFilter> = {};
|
||||
if (!isRecord(raw)) return models;
|
||||
for (const [modelId, val] of Object.entries(raw)) {
|
||||
if (!isRecord(val)) continue;
|
||||
const filter = toModelParamFilter(val);
|
||||
if (filter) models[modelId] = filter;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function toProviderParamFilter(raw: unknown): ProviderParamFilter | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const block = toStringArray(raw.block);
|
||||
const allow = toStringArray(raw.allow);
|
||||
const models = toModelParamFilters(raw.models);
|
||||
const autoLearn = typeof raw.autoLearn === "boolean" ? raw.autoLearn : false;
|
||||
return { block, allow, models: Object.keys(models).length > 0 ? models : undefined, autoLearn };
|
||||
}
|
||||
|
||||
// ── Read ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readNamespace(namespace: string): Record<string, unknown> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = ?")
|
||||
.all(namespace) as Array<{ key: string; value: string }>;
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
values[row.key] = parseStoredValue(row.value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function loadAllConfigs(): Map<string, ProviderParamFilter> {
|
||||
const raw = readNamespace(NAMESPACE);
|
||||
const map = new Map<string, ProviderParamFilter>();
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = toProviderParamFilter(value);
|
||||
if (parsed) {
|
||||
map.set(key, parsed);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Warm the cache on module load so the first call to getParamFilterConfig
|
||||
* does not hit the DB. Idempotent — subsequent calls return the cached map.
|
||||
*/
|
||||
export function loadParamFilterConfigs(): Map<string, ProviderParamFilter> {
|
||||
if (filterCache === null) {
|
||||
filterCache = loadAllConfigs();
|
||||
}
|
||||
return filterCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the param filter config for a single provider, or null if not configured.
|
||||
* Uses an in-memory cache refreshed on write.
|
||||
*/
|
||||
export function getParamFilterConfig(provider: string): ProviderParamFilter | null {
|
||||
return toNormalizedString(provider) ? (loadParamFilterConfigs().get(provider) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the entire param filter config for a provider.
|
||||
* Invalidates the in-memory cache.
|
||||
*/
|
||||
export function setParamFilterConfig(provider: string, config: ProviderParamFilter): void {
|
||||
if (!toNormalizedString(provider)) return;
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// Normalize the filter before persisting
|
||||
const normalized: ProviderParamFilter = {
|
||||
block: config.block ?? [],
|
||||
allow: config.allow ?? [],
|
||||
autoLearn: config.autoLearn ?? false,
|
||||
models: config.models && Object.keys(config.models).length > 0 ? config.models : undefined,
|
||||
};
|
||||
|
||||
stmt.run(NAMESPACE, provider, JSON.stringify(normalized));
|
||||
bumpCacheGeneration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the param filter config for a provider.
|
||||
* Resets to no filtering for that provider.
|
||||
*/
|
||||
export function deleteParamFilterConfig(provider: string): void {
|
||||
if (!toNormalizedString(provider)) return;
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?");
|
||||
stmt.run(NAMESPACE, provider);
|
||||
bumpCacheGeneration();
|
||||
}
|
||||
|
||||
// ── Global auto-learn flag ──────────────────────────────────────────────────
|
||||
|
||||
const GLOBAL_AUTOLEARN_KEY = "__global__";
|
||||
|
||||
/**
|
||||
* Check whether the global auto-learn flag is enabled.
|
||||
* When enabled, ALL providers auto-learn unsupported params from 400 errors
|
||||
* regardless of their per-provider autoLearn setting.
|
||||
* Only fires when this is explicitly configured, else returns false.
|
||||
*/
|
||||
export function isAutoLearnGloballyEnabled(): boolean {
|
||||
const globalCfg = getParamFilterConfig(GLOBAL_AUTOLEARN_KEY);
|
||||
return globalCfg?.autoLearn === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable global auto-learn globally for all providers.
|
||||
* When enabled, the per-provider autoLearn flag is still honored after
|
||||
* the global check (either being on is sufficient to trigger auto-learn).
|
||||
*/
|
||||
export function setGlobalAutoLearnEnabled(enabled: boolean): void {
|
||||
const existing = getParamFilterConfig(GLOBAL_AUTOLEARN_KEY);
|
||||
setParamFilterConfig(GLOBAL_AUTOLEARN_KEY, {
|
||||
block: existing?.block ?? [],
|
||||
allow: existing?.allow ?? [],
|
||||
autoLearn: enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-learn helper: add a single parameter to a provider's block list.
|
||||
* If the field is already in the block list (or the config does not exist),
|
||||
* this is a no-op. Optionally scoped to a specific model.
|
||||
*/
|
||||
export function addParamToBlocklist(provider: string, paramName: string, model?: string): void {
|
||||
if (!toNormalizedString(provider) || !toNormalizedString(paramName)) return;
|
||||
|
||||
const existing = getParamFilterConfig(provider) ?? {
|
||||
block: [],
|
||||
allow: [],
|
||||
autoLearn: false,
|
||||
};
|
||||
|
||||
if (model) {
|
||||
// Model-level
|
||||
const models = existing.models ?? {};
|
||||
const modelCfg = models[model] ?? {};
|
||||
|
||||
if (Array.isArray(modelCfg.block) && modelCfg.block.includes(paramName)) return;
|
||||
|
||||
const updatedBlock = [...(modelCfg.block ?? []), paramName];
|
||||
models[model] = { ...modelCfg, block: updatedBlock };
|
||||
existing.models = models;
|
||||
} else {
|
||||
// Provider-level
|
||||
if (existing.block.includes(paramName)) return;
|
||||
existing.block = [...existing.block, paramName];
|
||||
}
|
||||
|
||||
setParamFilterConfig(provider, existing);
|
||||
}
|
||||
|
|
@ -795,3 +795,5 @@ export { exportProxyLogsSince } from "./db/proxyLogs";
|
|||
// only); re-exported here for the historical localDb import contract.
|
||||
// ---------------------------------------------------------------------------
|
||||
export { markConnectionRateLimitedUntil, clearConnectionRateLimit } from "./db/providers";
|
||||
// Provider param filters — denylist/allowlist config per provider/model (#6625)
|
||||
export * from "./db/paramFilters";
|
||||
|
|
|
|||
|
|
@ -418,6 +418,21 @@ export const batchUpdateProviderConnectionsSchema = z.object({
|
|||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
// PUT /api/providers/[id]/param-filters — upsert provider/model param filter config
|
||||
const paramFilterListSchema = z.array(z.string().trim().min(1).max(200)).max(500);
|
||||
|
||||
const modelParamFilterSchema = z.object({
|
||||
block: paramFilterListSchema.optional(),
|
||||
allow: paramFilterListSchema.optional(),
|
||||
});
|
||||
|
||||
export const updateParamFilterConfigSchema = z.object({
|
||||
block: paramFilterListSchema.optional(),
|
||||
allow: paramFilterListSchema.optional(),
|
||||
models: z.record(z.string().trim().min(1).max(200), modelParamFilterSchema).optional(),
|
||||
autoLearn: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const validateProviderApiKeySchema = z
|
||||
.object({
|
||||
provider: z.string().trim().min(1, "Provider and API key required"),
|
||||
|
|
|
|||
|
|
@ -98,6 +98,34 @@ test("provider validation routes require management authentication before readin
|
|||
}
|
||||
});
|
||||
|
||||
test("provider param-filters route requires management authentication on GET/PUT/DELETE (#6649)", () => {
|
||||
const routePath = "src/app/api/providers/[id]/param-filters/route.ts";
|
||||
const content = fs.readFileSync(routePath, "utf8");
|
||||
|
||||
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
|
||||
|
||||
// GET, PUT, and DELETE must each gate on requireManagementAuth() before
|
||||
// touching the param filter config.
|
||||
const handlers = ["GET", "PUT", "DELETE"];
|
||||
for (const handler of handlers) {
|
||||
const handlerStart = content.indexOf(`export async function ${handler}(`);
|
||||
assert.ok(handlerStart >= 0, `${routePath} is missing a ${handler} handler`);
|
||||
const nextHandlerStart = content.indexOf("export async function", handlerStart + 1);
|
||||
const handlerBody = content.slice(
|
||||
handlerStart,
|
||||
nextHandlerStart === -1 ? content.length : nextHandlerStart
|
||||
);
|
||||
assert.ok(
|
||||
handlerBody.includes("const authError = await requireManagementAuth(request);"),
|
||||
`${routePath} ${handler} handler must call requireManagementAuth(request)`
|
||||
);
|
||||
assert.ok(
|
||||
handlerBody.includes("if (authError) return authError;"),
|
||||
`${routePath} ${handler} handler must short-circuit on authError`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity CLI (agy) credential import routes require management authentication before reading the body", () => {
|
||||
// Routes that parse a JSON body — auth MUST run before request.json().
|
||||
const jsonBodyRoutes = [
|
||||
|
|
@ -221,6 +249,7 @@ test("management routes sanitize error.message before returning it to clients",
|
|||
"src/app/api/evals/[suiteId]/route.ts",
|
||||
"src/app/api/providers/[id]/models/route.ts",
|
||||
"src/app/api/providers/[id]/sync-models/route.ts",
|
||||
"src/app/api/providers/[id]/param-filters/route.ts",
|
||||
"src/app/api/sessions/route.ts",
|
||||
"src/app/api/storage/health/route.ts",
|
||||
"src/app/api/sync/cloud/route.ts",
|
||||
|
|
|
|||
48
tests/unit/param-filters-apply.test.ts
Normal file
48
tests/unit/param-filters-apply.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Static import — this module does NOT depend on the DB (tests applyConfigFilters
|
||||
// directly, which is a pure function; the DB-backed parent function stripUnsupportedParams
|
||||
// is only tested for its non-DB code paths here).
|
||||
import {
|
||||
stripUnsupportedParams,
|
||||
applyConfigFilters,
|
||||
} from "../../open-sse/translator/paramSupport.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripUnsupportedParams — hardcoded STRIP_RULES (regression guard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("stripUnsupportedParams strips known hardcoded fields from body", () => {
|
||||
const body = { model: "claude-opus-4", temperature: 0.7, max_tokens: 100 };
|
||||
const result = stripUnsupportedParams("anthropic", "claude-opus-4-20250514", body);
|
||||
assert.equal((result as Record<string, unknown>).temperature, undefined);
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, 100);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams leaves unrelated models alone", () => {
|
||||
const body = { model: "gpt-4", temperature: 0.7 };
|
||||
const result = stripUnsupportedParams("openai", "gpt-4", body);
|
||||
assert.equal((result as Record<string, unknown>).temperature, 0.7);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams returns body unchanged for null/undefined args", () => {
|
||||
const body = { model: "gpt-4" };
|
||||
assert.equal(stripUnsupportedParams(null, null, body), body);
|
||||
assert.equal(stripUnsupportedParams("test", null, body), body);
|
||||
assert.equal(stripUnsupportedParams("test", "", body), body);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyConfigFilters — config-driven denylist + allowlist (direct, no DB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("applyConfigFilters no-op when provider undefined or null", () => {
|
||||
const body1 = { thinking: "enabled", max_tokens: 100 };
|
||||
applyConfigFilters("nonexistent-provider", "my-model", body1, { ...body1 });
|
||||
assert.deepEqual(body1, { thinking: "enabled", max_tokens: 100 });
|
||||
|
||||
const body2 = { thinking: "enabled" };
|
||||
applyConfigFilters(null, "my-model", body2, { ...body2 });
|
||||
assert.deepEqual(body2, { thinking: "enabled" });
|
||||
});
|
||||
68
tests/unit/param-filters-auto-learn.test.ts
Normal file
68
tests/unit/param-filters-auto-learn.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// This file tests the regex/matching logic of detectUnsupportedParam and the
|
||||
// UNSUPPORTED_PARAM_RE constant. These are pure functions with no DB dependency.
|
||||
// DB-backed persistence tests (addParamToBlocklist) live in param-filters-db.test.ts.
|
||||
import {
|
||||
UNSUPPORTED_PARAM_RE,
|
||||
detectUnsupportedParam,
|
||||
} from "../../open-sse/config/providerFieldStrips.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UNSUPPORTED_PARAM_RE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("UNSUPPORTED_PARAM_RE matches NIM-style 'Unsupported parameter(s): thinking'", () => {
|
||||
const m = UNSUPPORTED_PARAM_RE.exec("Unsupported parameter(s): thinking");
|
||||
assert.notEqual(m, null);
|
||||
assert.equal(m![1], "thinking");
|
||||
});
|
||||
|
||||
test("UNSUPPORTED_PARAM_RE matches 'Unsupported parameter: max_tokens'", () => {
|
||||
const m = UNSUPPORTED_PARAM_RE.exec("Unsupported parameter: max_tokens");
|
||||
assert.notEqual(m, null);
|
||||
assert.equal(m![1], "max_tokens");
|
||||
});
|
||||
|
||||
test("UNSUPPORTED_PARAM_RE matches 'Unsupported parameter 'reasoning_budget''", () => {
|
||||
const m = UNSUPPORTED_PARAM_RE.exec("Unsupported parameter 'reasoning_budget'");
|
||||
assert.notEqual(m, null);
|
||||
assert.equal(m![1], "reasoning_budget");
|
||||
});
|
||||
|
||||
test("UNSUPPORTED_PARAM_RE matches case-insensitively", () => {
|
||||
const m = UNSUPPORTED_PARAM_RE.exec("unsupported parameter(s): thinking");
|
||||
assert.notEqual(m, null);
|
||||
assert.equal(m![1], "thinking");
|
||||
});
|
||||
|
||||
test("UNSUPPORTED_PARAM_RE does not match unrelated error text", () => {
|
||||
assert.equal(UNSUPPORTED_PARAM_RE.exec("rate limit exceeded"), null);
|
||||
assert.equal(UNSUPPORTED_PARAM_RE.exec("Internal server error"), null);
|
||||
assert.equal(UNSUPPORTED_PARAM_RE.exec(""), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectUnsupportedParam
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("detectUnsupportedParam extracts param name from NIM-style errors", () => {
|
||||
assert.equal(detectUnsupportedParam("Unsupported parameter(s): thinking"), "thinking");
|
||||
assert.equal(
|
||||
detectUnsupportedParam("Unsupported parameter: reasoning_budget"),
|
||||
"reasoning_budget"
|
||||
);
|
||||
assert.equal(detectUnsupportedParam("Unsupported parameter 'max_tokens'"), "max_tokens");
|
||||
});
|
||||
|
||||
test("detectUnsupportedParam returns null for non-matching text", () => {
|
||||
assert.equal(detectUnsupportedParam("all good"), null);
|
||||
assert.equal(detectUnsupportedParam(""), null);
|
||||
assert.equal(detectUnsupportedParam("400 Bad Request"), null);
|
||||
});
|
||||
|
||||
test("detectUnsupportedParam returns null for null/undefined", () => {
|
||||
assert.equal(detectUnsupportedParam(null as unknown as string), null);
|
||||
assert.equal(detectUnsupportedParam(undefined as unknown as string), null);
|
||||
});
|
||||
255
tests/unit/param-filters-db.test.ts
Normal file
255
tests/unit/param-filters-db.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Set DATA_DIR BEFORE any DB modules are imported so core.ts uses the temp dir.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pf-db-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
// Dynamic imports — these modules initialize SQLite on load, so DATA_DIR must be
|
||||
// set first.
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const {
|
||||
setParamFilterConfig,
|
||||
getParamFilterConfig,
|
||||
deleteParamFilterConfig,
|
||||
loadParamFilterConfigs,
|
||||
addParamToBlocklist,
|
||||
isAutoLearnGloballyEnabled,
|
||||
setGlobalAutoLearnEnabled,
|
||||
} = await import("../../src/lib/db/paramFilters.ts");
|
||||
const { stripUnsupportedParams } = await import("../../open-sse/translator/paramSupport.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setParamFilterConfig / getParamFilterConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("setParamFilterConfig stores and getParamFilterConfig retrieves a config", () => {
|
||||
const config = {
|
||||
block: ["thinking", "reasoning_budget"],
|
||||
allow: [],
|
||||
autoLearn: true,
|
||||
};
|
||||
setParamFilterConfig("test-provider", config);
|
||||
const retrieved = getParamFilterConfig("test-provider");
|
||||
assert.notEqual(retrieved, null);
|
||||
assert.deepEqual(retrieved!.block, ["thinking", "reasoning_budget"]);
|
||||
assert.deepEqual(retrieved!.allow, []);
|
||||
assert.equal(retrieved!.autoLearn, true);
|
||||
});
|
||||
|
||||
test("getParamFilterConfig returns null for unconfigured provider", () => {
|
||||
assert.equal(getParamFilterConfig("nonexistent"), null);
|
||||
});
|
||||
|
||||
test("getParamFilterConfig returns null for empty provider", () => {
|
||||
assert.equal(getParamFilterConfig(""), null);
|
||||
});
|
||||
|
||||
test("setParamFilterConfig with model overrides stores correctly", () => {
|
||||
const config = {
|
||||
block: ["thinking"],
|
||||
allow: [],
|
||||
models: {
|
||||
"deepseek-r1": { block: ["max_tokens"] },
|
||||
"deepseek-r2": { block: ["temperature"], allow: ["reasoning"] },
|
||||
},
|
||||
autoLearn: false,
|
||||
};
|
||||
setParamFilterConfig("nvidia", config);
|
||||
const retrieved = getParamFilterConfig("nvidia");
|
||||
assert.notEqual(retrieved, null);
|
||||
assert.deepEqual(retrieved!.models?.["deepseek-r1"]?.block, ["max_tokens"]);
|
||||
assert.deepEqual(retrieved!.models?.["deepseek-r2"]?.block, ["temperature"]);
|
||||
assert.deepEqual(retrieved!.models?.["deepseek-r2"]?.allow, ["reasoning"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteParamFilterConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deleteParamFilterConfig removes config and getParamFilterConfig returns null", () => {
|
||||
setParamFilterConfig("ephemeral", { block: ["param1"], allow: [], autoLearn: false });
|
||||
assert.notEqual(getParamFilterConfig("ephemeral"), null);
|
||||
deleteParamFilterConfig("ephemeral");
|
||||
assert.equal(getParamFilterConfig("ephemeral"), null);
|
||||
});
|
||||
|
||||
test("deleteParamFilterConfig is a no-op for unconfigured provider", () => {
|
||||
deleteParamFilterConfig("nothing-here");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadParamFilterConfigs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("loadParamFilterConfigs returns all configured providers", () => {
|
||||
setParamFilterConfig("provider-a", { block: ["a"], allow: [], autoLearn: false });
|
||||
setParamFilterConfig("provider-b", { block: ["b"], allow: [], autoLearn: true });
|
||||
const all = loadParamFilterConfigs();
|
||||
assert.equal(all.has("provider-a"), true);
|
||||
assert.equal(all.has("provider-b"), true);
|
||||
assert.equal(all.get("provider-a")!.block[0], "a");
|
||||
assert.equal(all.get("provider-b")!.autoLearn, true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addParamToBlocklist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("addParamToBlocklist adds param to provider-level block list", () => {
|
||||
setParamFilterConfig("test", { block: ["thinking"], allow: [], autoLearn: false });
|
||||
addParamToBlocklist("test", "reasoning_budget");
|
||||
const config = getParamFilterConfig("test");
|
||||
assert.deepEqual(config!.block, ["thinking", "reasoning_budget"]);
|
||||
});
|
||||
|
||||
test("addParamToBlocklist is idempotent — does not add duplicate", () => {
|
||||
setParamFilterConfig("test-dup", { block: ["thinking"], allow: [], autoLearn: false });
|
||||
addParamToBlocklist("test-dup", "thinking");
|
||||
addParamToBlocklist("test-dup", "thinking");
|
||||
const config = getParamFilterConfig("test-dup");
|
||||
assert.deepEqual(config!.block, ["thinking"]);
|
||||
});
|
||||
|
||||
test("addParamToBlocklist creates config if none exists", () => {
|
||||
addParamToBlocklist("fresh-provider", "thinking");
|
||||
const config = getParamFilterConfig("fresh-provider");
|
||||
assert.notEqual(config, null);
|
||||
assert.deepEqual(config!.block, ["thinking"]);
|
||||
assert.equal(config!.autoLearn, false);
|
||||
});
|
||||
|
||||
test("addParamToBlocklist with model param adds to model-level block list", () => {
|
||||
setParamFilterConfig("test-model", { block: [], allow: [], autoLearn: false });
|
||||
addParamToBlocklist("test-model", "temperature", "deepseek-r1");
|
||||
const config = getParamFilterConfig("test-model");
|
||||
assert.deepEqual(config!.models?.["deepseek-r1"]?.block, ["temperature"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global auto-learn flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("isAutoLearnGloballyEnabled returns false by default", () => {
|
||||
assert.equal(isAutoLearnGloballyEnabled(), false);
|
||||
});
|
||||
|
||||
test("setGlobalAutoLearnEnabled(true) enables global auto-learn", () => {
|
||||
setGlobalAutoLearnEnabled(true);
|
||||
assert.equal(isAutoLearnGloballyEnabled(), true);
|
||||
});
|
||||
|
||||
test("setGlobalAutoLearnEnabled(false) disables global auto-learn", () => {
|
||||
setGlobalAutoLearnEnabled(true);
|
||||
assert.equal(isAutoLearnGloballyEnabled(), true);
|
||||
setGlobalAutoLearnEnabled(false);
|
||||
assert.equal(isAutoLearnGloballyEnabled(), false);
|
||||
});
|
||||
|
||||
test("setGlobalAutoLearnEnabled does not affect per-provider configs", () => {
|
||||
setParamFilterConfig("test-global-safe", { block: ["thinking"], allow: [], autoLearn: false });
|
||||
setGlobalAutoLearnEnabled(true);
|
||||
const config = getParamFilterConfig("test-global-safe");
|
||||
assert.deepEqual(config!.block, ["thinking"]);
|
||||
assert.equal(config!.autoLearn, false);
|
||||
// Global is independent
|
||||
assert.equal(isAutoLearnGloballyEnabled(), true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full pipeline: DB config → stripUnsupportedParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("stripUnsupportedParams strips config-driven provider-level denylist", () => {
|
||||
setParamFilterConfig("nvidia", {
|
||||
block: ["thinking", "reasoning_budget"],
|
||||
allow: [],
|
||||
autoLearn: false,
|
||||
});
|
||||
loadParamFilterConfigs();
|
||||
|
||||
const body = { model: "deepseek-r1", thinking: "enabled", max_tokens: 100 };
|
||||
const result = stripUnsupportedParams("nvidia", "deepseek-r1", body);
|
||||
assert.equal((result as Record<string, unknown>).thinking, undefined);
|
||||
assert.equal((result as Record<string, unknown>).reasoning_budget, undefined);
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, 100);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams strips config-driven model-level denylist (stricter)", () => {
|
||||
setParamFilterConfig("nvidia", {
|
||||
block: ["thinking"],
|
||||
allow: [],
|
||||
models: { "deepseek-r1": { block: ["max_tokens"] } },
|
||||
autoLearn: false,
|
||||
});
|
||||
loadParamFilterConfigs();
|
||||
|
||||
const body = { model: "deepseek-r1", thinking: "enabled", max_tokens: 100, temperature: 0.7 };
|
||||
const result = stripUnsupportedParams("nvidia", "deepseek-r1", body);
|
||||
assert.equal((result as Record<string, unknown>).thinking, undefined);
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, undefined);
|
||||
assert.equal((result as Record<string, unknown>).temperature, 0.7);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams allowlist restores a denied param from original body", () => {
|
||||
setParamFilterConfig("nvidia", {
|
||||
block: ["thinking", "reasoning_budget"],
|
||||
allow: ["thinking"],
|
||||
autoLearn: false,
|
||||
});
|
||||
loadParamFilterConfigs();
|
||||
|
||||
const body = { model: "deepseek-r1", thinking: "enabled", max_tokens: 100 };
|
||||
const result = stripUnsupportedParams("nvidia", "deepseek-r1", body);
|
||||
assert.equal((result as Record<string, unknown>).thinking, "enabled");
|
||||
assert.equal((result as Record<string, unknown>).reasoning_budget, undefined);
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, 100);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams allowlist does not introduce params not in original body", () => {
|
||||
setParamFilterConfig("nvidia", {
|
||||
block: [],
|
||||
allow: ["nonexistent_param"],
|
||||
autoLearn: false,
|
||||
});
|
||||
loadParamFilterConfigs();
|
||||
|
||||
const body = { model: "deepseek-r1", thinking: "enabled" };
|
||||
const result = stripUnsupportedParams("nvidia", "deepseek-r1", body);
|
||||
assert.equal((result as Record<string, unknown>).nonexistent_param, undefined);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams model-level denylist overrides provider-level allowlist", () => {
|
||||
setParamFilterConfig("nvidia", {
|
||||
block: ["thinking"],
|
||||
allow: ["thinking"],
|
||||
models: { "deepseek-r1": { block: ["thinking"] } },
|
||||
autoLearn: false,
|
||||
});
|
||||
loadParamFilterConfigs();
|
||||
|
||||
// Provider allowlist re-adds thinking, but model-level denylist strips it again
|
||||
const body = { model: "deepseek-r1", thinking: "enabled", max_tokens: 100 };
|
||||
const result = stripUnsupportedParams("nvidia", "deepseek-r1", body);
|
||||
assert.equal(
|
||||
(result as Record<string, unknown>).thinking,
|
||||
undefined,
|
||||
"model denylist should win over provider allowlist"
|
||||
);
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, 100);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams no-op when no DB config exists (default behavior)", () => {
|
||||
const body = { model: "deepseek-r1", thinking: "enabled", max_tokens: 100 };
|
||||
const result = stripUnsupportedParams("unknown", "deepseek-r1", body);
|
||||
assert.equal((result as Record<string, unknown>).thinking, "enabled");
|
||||
assert.equal((result as Record<string, unknown>).max_tokens, 100);
|
||||
});
|
||||
|
|
@ -26,7 +26,12 @@ describe("proxyDispatcher SOCKS5 host handling", () => {
|
|||
|
||||
describe("proxyDispatcher family directive", () => {
|
||||
it("encodes family from a config object onto the URL", () => {
|
||||
const url = proxyConfigToUrl({ type: "http", host: "proxy.example.com", port: 8080, family: "ipv6" });
|
||||
const url = proxyConfigToUrl({
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
family: "ipv6",
|
||||
});
|
||||
assert.ok(url!.includes("family=ipv6"), url!);
|
||||
});
|
||||
it("derives 6 for an explicit ipv6 directive on a hostname proxy", () => {
|
||||
|
|
@ -38,7 +43,10 @@ describe("proxyDispatcher family directive", () => {
|
|||
assert.equal(__resolveDispatcherFamilyForTest("http://proxy.example.com:8080"), null);
|
||||
});
|
||||
it("throws (fail-closed) when family=ipv6 contradicts a v4 literal", () => {
|
||||
assert.throws(() => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"), /family/i);
|
||||
assert.throws(
|
||||
() => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"),
|
||||
/family/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue