Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Thanks to everyone whose work landed in v3.8.12:
- **sse/groq:** non-reasoning Groq models (`llama-3.3-70b-versatile`, `llama-4-scout`) are now flagged `supportsReasoning: false`, so `reasoning_effort` / `output_config.effort` / `thinking` are stripped before dispatch instead of being forwarded and rejected with HTTP 400 — fixes the Claude Code → Groq regression of #764 ([#3258](https://github.com/diegosouzapw/OmniRoute/issues/3258))
- **api/images:** `POST /v1/images/edits` to a custom OpenAI-compatible provider no longer forwards an empty `model`. The multipart body is now built as a `Buffer` with an explicit boundary instead of a global `FormData` — the patched undici `fetch` serialized a native `FormData` as the literal string `[object FormData]` (text/plain), dropping every field including `model` ([#3273](https://github.com/diegosouzapw/OmniRoute/issues/3273))
- **api/webhooks:** webhook URLs may now target a private/internal address (e.g. `192.168.x`, a docker-internal host) when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` — the webhook guard reuses the same explicit opt-in as private provider URLs (default OFF; protocol and embedded-credential checks stay unconditional). Cloud-metadata / link-local endpoints (`169.254.169.254`, `metadata.google.internal`, `100.100.100.200`, `169.254.0.0/16`) are blocked **unconditionally** even with the opt-in on, and the webhook test endpoint redacts the upstream response body for private targets (no SSRF→IAM-credential pivot, no content exfiltration) ([#3269](https://github.com/diegosouzapw/OmniRoute/issues/3269))
- **sse/qoder:** a valid Qoder Personal Access Token is no longer wrongly reported as "expired" when the Cosy validation endpoint returns a generic `Internal Server Error` (HTTP 500). A Cosy 500 only marks the PAT invalid when its body carries an explicit auth signal; a generic server fault now falls back to the #1391 valid-bypass rule ([#3247](https://github.com/diegosouzapw/OmniRoute/issues/3247))

---

Expand Down
20 changes: 13 additions & 7 deletions open-sse/services/qoderCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,18 +503,24 @@ export async function validateQoderCliPat({
return { valid: true, error: null, unsupported: false };
}

// Treat 5xx as valid bypass to prevent false negatives from legacy Qoder APIs (issue #1391)
// Treat 5xx as a valid bypass to prevent false negatives from legacy Qoder APIs (#1391).
// A Cosy `{"success":false}` 500 is ambiguous: it can be a genuine auth rejection OR a
// transient/generic upstream "Internal Server Error". Only mark the PAT invalid when the
// body carries an EXPLICIT auth signal — a generic 500 is a server fault, not an auth
// verdict, so a working PAT must not be reported as expired (#3247, narrowing #2860).
if (res.status >= 500) {
const isCosyAppError =
/"success"\s*:\s*false/.test(errorDetail) &&
(/"msgCode"\s*:\s*500/.test(errorDetail) || /internal\s*server\s*error/i.test(errorDetail));
const isCosyResponse = /"success"\s*:\s*false/.test(errorDetail);
const hasAuthSignal =
/(unauthorized|forbidden|expired|revoked|not\s*authorized|permission\s*denied|access\s*denied|invalid\s*(?:token|credential|api[\s_-]*key)|token\s*(?:invalid|expired|revoked))/i.test(
errorDetail
);

if (isCosyAppError) {
if (isCosyResponse && hasAuthSignal) {
return {
valid: false,
error:
`Authentication failed (HTTP ${res.status}). The Qoder Cosy server returned an Internal Server Error. ` +
"This typically indicates that your Personal Access Token is invalid, expired, or not authorized. " +
`Authentication failed (HTTP ${res.status}). The Qoder Cosy server rejected the token ` +
"as invalid, expired, or not authorized. " +
"Please check your token at https://qoder.com/account/integrations." +
(errorDetail ? ` Server response: ${errorDetail}` : ""),
unsupported: false,
Expand Down
38 changes: 30 additions & 8 deletions tests/unit/qoder-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,37 +365,59 @@ test("validateQoderCliPat treats 5xx HTTP failures as valid bypass", async () =>
}
});

test("validateQoderCliPat rejects 500 HTTP failures if response is Cosy app-level auth error", async () => {
// #3247: a generic Cosy 500 (`{"success":false,...,"msgCode":500,"message":"Internal
// Server Error"}`) is a SERVER fault, not a reliable auth verdict — a PAT that works in
// the Qoder CLI was being wrongly marked "expired". Per the older #1391 rule, a generic
// 5xx is now a valid bypass; only an explicit auth signal in the body marks it invalid.
test("validateQoderCliPat treats a generic Cosy 500 (no auth signal) as a valid bypass (#3247)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (String(url).includes("/ping")) return new Response("pong", { status: 200 });
return new Response(
' { "success" : false, "traceId": "a4e5de61929400b9243b4f6e49756906", "msgCode" : 500 } ',
'{"success":false,"traceId":"a4e5de61929400b9243b4f6e49756906","msgCode":500,"msgInfo":"Internal Server Error","message":"Internal Server Error"}',
{ status: 500 }
);
};

try {
const result = await qoderCli.validateQoderCliPat({ apiKey: "invalid-pat" });
assert.equal(result.valid, false);
assert.match(result.error!, /Authentication failed \(HTTP 500\)/);
const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-valid-token" });
assert.equal(result.valid, true);
assert.match(result.error!, /treating PAT as valid/);
} finally {
globalThis.fetch = originalFetch;
}
});

test("validateQoderCliPat treats a generic 'Internal Server Error' 500 as a valid bypass (#3247)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (String(url).includes("/ping")) return new Response("pong", { status: 200 });
return new Response(' { "success" : false, "error": "Internal Server Error" } ', {
status: 500,
});
};

try {
const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-valid-token" });
assert.equal(result.valid, true);
assert.match(result.error!, /treating PAT as valid/);
} finally {
globalThis.fetch = originalFetch;
}
});

test("validateQoderCliPat rejects 500 HTTP failures using regex for Internal Server Error with whitespace", async () => {
test("validateQoderCliPat still rejects a Cosy 500 that carries an explicit auth signal (#2860)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (String(url).includes("/ping")) return new Response("pong", { status: 200 });
return new Response(
' { "success" : false, "error": "Internal Server Error" } ',
'{"success":false,"msgCode":500,"message":"token invalid or unauthorized"}',
{ status: 500 }
);
};

try {
const result = await qoderCli.validateQoderCliPat({ apiKey: "invalid-pat" });
const result = await qoderCli.validateQoderCliPat({ apiKey: "pt-bad-token" });
assert.equal(result.valid, false);
assert.match(result.error!, /Authentication failed \(HTTP 500\)/);
} finally {
Expand Down