From 17fee53dbd83991fd4c95483bf12d99bde29710e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:43:22 -0300 Subject: [PATCH] fix(providers): correct Nous Research key validation probe model + accept non-auth 4xx (#3881) (#3934) --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- src/lib/providers/validation.ts | 15 +++++++++- .../provider-validation-specialty.test.ts | 30 ++++++++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6aa2eb465..af055b7e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### 🐛 Fixed +- **fix(providers): Nous Research key validation no longer fails on a stale probe model** — adding a valid Nous Research API key reported "invalid" even though the same key worked via the portal's copy-shell `curl`. The validation probe sent `model: "nousresearch/hermes-4-70b"`, which Nous does not serve, so the API returned `400` and the validator (which only treated `200`/`429` as success) reported the key invalid. The probe now uses the real `Hermes-4-70B` slug, and any non-auth 4xx (`400`/`404`/`422`) is treated as a valid key (the request shape was wrong, not the credentials) — mirroring the longcat/nvidia validators so a future model rename can't re-break key validation. ([#3881](https://github.com/diegosouzapw/OmniRoute/issues/3881) — thanks @FerLuisxd) - **test(oauth): prove refresh_token preservation for the real gemini-cli / antigravity dispatch** — the #3679/#3766 regression test used a synthetic provider that routes through the generic `tokenUrl` path, so the fix was never proven for the actual Google-family providers, which dispatch through `refreshGoogleToken()` against the hardcoded `OAUTH_ENDPOINTS.google.token`. Added a test that drives `checkConnection` through the real `gemini-cli`/`antigravity` path (redirecting the Google token endpoint to a local server returning `invalid_grant`) and asserts the `refresh_token` is preserved (not nulled) — confirming these connections are not spuriously destroyed on a failed refresh. ([#3850](https://github.com/diegosouzapw/OmniRoute/issues/3850) — thanks @3xa228148) - **fix(oauth): clear setup message for GitLab Duo instead of "Internal server error"** — adding a GitLab Duo connection without a registered OAuth client returned an opaque `Internal server error` at the Add Connection step. `buildAuthUrl` **threw** when `GITLAB_DUO_OAUTH_CLIENT_ID` was missing, and the route swallowed it into a generic 500. It now returns `null` (mirroring the Qoder provider) and the authorize route surfaces an actionable message: register an OAuth app at `https://gitlab.com/-/profile/applications` with redirect URI `http://localhost:20128/callback` and scopes `ai_features read_user`, then set `GITLAB_DUO_OAUTH_CLIENT_ID`. ([#3861](https://github.com/diegosouzapw/OmniRoute/issues/3861) — thanks @sidinsearch) - **fix(db): persist the "Keep latest backups" retention setting** — changing the backup-retention count in Settings → Database backup retention had no effect: it always snapped back to 20 on refresh (and editing `.env` post-start was ignored too, since `process.env` isn't reloaded). `getDbBackupMaxFiles()` only read the `DB_BACKUP_MAX_FILES` env var — there was no setter and no persisted value. The value now round-trips through a dedicated `key_value` store (`getDbBackupMaxFiles` precedence: env override → persisted UI value → default 20), and the "Clean old backups" action persists the chosen count. Existing installs keep the historical default of 20 until explicitly changed. ([#3834](https://github.com/diegosouzapw/OmniRoute/issues/3834) — thanks @netstratego) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 21fea90721..76b38306df 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -9,6 +9,7 @@ "_rebaseline_2026_06_15_3906_proxy_direct_fallback": "PR #3906 own growth: oauth/[provider]/[action]/route.ts 916->918 (+2 = swap runWithProxyContext -> runWithProxyContextOrDirect at the control-plane callsites so a dead pinned proxy degrades to a direct connection instead of a 500; the longer call name + a prettier reflow add the lines). Cohesive control-plane change; not extractable.", "_rebaseline_2026_06_15_3871_empty_pool": "PR #3871 own growth: combo.ts 5203->5204 (+1 = guard expandAutoComboCandidatePool against an empty candidatePool array — Array.isArray(pool) && pool.length > 0 so [] falls through to active-connection expansion instead of early-returning). One-line correctness fix; not extractable.", "_rebaseline_2026_06_15_3911_sse_role": "PR #3911 own growth: openai-responses.ts 878->903 (+25 = withAssistantRoleOnFirstDelta wrapper that synthesizes role=assistant on the first Responses->Chat delta so strict streaming clients (langchain/n8n) do not drop tool_call deltas). Cohesive translator fix; not extractable.", + "_rebaseline_2026_06_15_3881_nous_validator": "Issue #3881 own growth: validation.ts 4394->4407 (+13 = treat any non-auth 4xx (400/404/422) from the Nous Research probe as a valid key — the request shape/model was wrong, not the credentials — mirroring the longcat/nvidia validators, plus the corrected Hermes-4-70B probe model). Cohesive validator branch; not extractable.", "cap": 800, "frozen": { "open-sse/config/providerRegistry.ts": 4708, @@ -97,7 +98,7 @@ "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4394, + "src/lib/providers/validation.ts": 4407, "src/lib/tailscaleTunnel.ts": 1189, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 949, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 9bbdf5644c..a335534488 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2120,7 +2120,7 @@ async function validateNousResearchProvider({ apiKey, providerSpecificData = {} typeof providerSpecificData.validationModelId === "string" && providerSpecificData.validationModelId.trim() ? providerSpecificData.validationModelId.trim() - : "nousresearch/hermes-4-70b"; + : "Hermes-4-70B"; try { const response = await validationWrite(chatUrl, { @@ -2157,6 +2157,19 @@ async function validateNousResearchProvider({ apiKey, providerSpecificData = {} if (response.status >= 500) { return { valid: false, error: `Provider unavailable (${response.status})` }; } + + // #3881: any other non-auth 4xx (e.g. 400 model-not-found, 404, 422) means the + // credentials were accepted — only the probe model/request shape was rejected. + // Treat as valid (mirrors the longcat/nvidia validators) so a model rename upstream + // can't make a working key read as "invalid". + if (response.status >= 400 && response.status < 500) { + return { + valid: true, + error: null, + method: "nous_chat_completions", + warning: `Credentials valid (probe returned ${response.status})`, + }; + } } catch (error: any) { return toValidationErrorResult(error); } diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index f4503cb8ab..991f117628 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -1780,7 +1780,7 @@ test("specialty validator accepts Nous Research credentials on chat completions" const headers = init.headers as Record; const body = JSON.parse(String(init.body)); assert.equal(headers.Authorization, "Bearer nous-key"); - assert.equal(body.model, "nousresearch/hermes-4-70b"); + assert.equal(body.model, "Hermes-4-70B"); return new Response( JSON.stringify({ id: "chatcmpl-nous", @@ -1823,6 +1823,34 @@ test("specialty validator rejects invalid Nous Research credentials", async () = assert.equal(nous.error, "Invalid API key"); }); +test("specialty validator accepts Nous Research key when probe model is rejected (400)", async () => { + // #3881: a valid key whose probe model is rejected (model-not-found / bad request) + // must still validate — the 4xx proves auth was accepted, only the request shape + // was wrong. Mirrors the longcat/nvidia validators. + globalThis.fetch = async (url, init = {}) => { + const target = String(url); + + if (target === "https://inference-api.nousresearch.com/v1/chat/completions") { + const headers = init.headers as Record; + assert.equal(headers.Authorization, "Bearer nous-key"); + return new Response( + JSON.stringify({ error: { message: "model not found", type: "invalid_request_error" } }), + { status: 400 } + ); + } + + throw new Error(`unexpected fetch: ${target}`); + }; + + const nous = await validateProviderApiKey({ + provider: "nous-research", + apiKey: "nous-key", + }); + + assert.equal(nous.valid, true); + assert.equal(nous.method, "nous_chat_completions"); +}); + test("specialty validator rejects invalid Poe credentials", async () => { globalThis.fetch = async (url, init = {}) => { const target = String(url);