Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5712364
fix(electron): bump electron 42→43 + build better-sqlite3 from source…
diegosouzapw Jul 8, 2026
44d501a
deps: bump the development group across 1 directory with 6 updates (#…
dependabot[bot] Jul 8, 2026
9cd18bf
fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.…
diegosouzapw Jul 8, 2026
4bc8cde
feat(db): add provider param filter config store (key_value namespace)
ThongAccount Jul 8, 2026
8c66807
feat(proxy): add detectUnsupportedParam regex for auto-learning
ThongAccount Jul 8, 2026
e135594
feat(proxy): extend stripUnsupportedParams with config-driven denylis…
ThongAccount Jul 8, 2026
bca5485
feat(proxy): wire auto-learn of unsupported params into 400-downgrade…
ThongAccount Jul 8, 2026
f5ba160
test: add tests for provider param filter denylist/allowlist/auto-learn
ThongAccount Jul 8, 2026
da4ca30
feat(proxy): add global auto-learn flag for unsupported params
ThongAccount Jul 8, 2026
0f319ac
fix: apply PR#6649 review feedback — model-scoped auto-learn and prec…
ThongAccount Jul 8, 2026
00ac985
feat(ui): add provider-level param filter section to detail page
ThongAccount Jul 8, 2026
3f42b1f
feat(ui): add model-level param filter fields in compat popover
ThongAccount Jul 8, 2026
f19e2ff
chore: gitignore .claude-flow/
ThongAccount Jul 8, 2026
d776002
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/6625-…
diegosouzapw Jul 8, 2026
aa1c9e3
fix(param-filters): review follow-ups — auth gate, error sanitization…
ThongAccount Jul 8, 2026
1324dc5
chore(param-filters): drop unrelated main-drift from the fork branch …
diegosouzapw Jul 8, 2026
e01ed7f
refactor(param-filters): split oversized functions — keep complexity …
ThongAccount Jul 8, 2026
964ac81
refactor(param-filters): decompose config parser helpers — keep cogni…
ThongAccount Jul 8, 2026
2a97832
Merge remote-tracking branch 'origin/release/v3.8.47' into feat/6625-…
diegosouzapw Jul 8, 2026
7f9aa3c
fix(changelog): restore sibling #6648 bullet eaten by merge auto-reso…
ThongAccount Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,4 @@ omniroute.md
# mise configuration
mise.toml
_artifacts/
.claude-flow/
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions open-sse/config/providerFieldStrips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 43 additions & 1 deletion open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`
);
Comment on lines +1293 to +1296

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the log message to include the model name so that operators can easily see which specific model triggered the auto-learning behavior.

Suggested change
log?.info?.(
"AUTO_LEARN",
`Auto-learned "${autoLearned}" for provider ${this.provider} from 400 on ${url} — retrying`
);
log?.info?.(
"AUTO_LEARN",
`Auto-learned "${autoLearned}" for provider ${this.provider} (model: ${model}) from 400 on ${url} — retrying`
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f319ac. Log message now includes model: Auto-learned "X" for provider Y (model: Z) …

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)}`
);
}
}
}
}

Expand Down
96 changes: 94 additions & 2 deletions open-sse/translator/paramSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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`
Expand Down Expand Up @@ -56,15 +57,106 @@ 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;
for (const key of rule.drop) {
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading