mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168) * fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179) * fix(guardrails/chat): stop Vision Bridge hijacking credentialed models to opencode-zen OpenCode (and similar clients) often send image parts in long sessions. Vision Bridge treated the request model as non-vision and whole-request- rerouted to getBestVisionModel(), which preferred opencode-* (priority 0). That landed on a noauth connection and returned 401 Missing API key — while proxies/combos still logged the original target (zai/glm-5.2, grok-cli, …). Also: after resolveRoutingModel(X-Route-Model), keep body.model aligned so the post-guardrail "body.model !== modelStr" path cannot undo the routing header. - visionBridge: skip whole-request reroute when original model has usable creds - visionBridge: refuse reroute to targets known unusable (noauth without key) - visionBridgeRouter: deprioritize opencode-* for auto vision pick - chat: alignBodyModelWithRouting + only adopt true guardrail model mutations - tests: VB-CRED-01/02 + alignBodyModelWithRouting coverage * fix(guardrails/chat): keep chat.ts under the file-size ratchet and update stale vision-bridge tests for the credential-aware reroute skip - Extract the routing-model reconciliation logic (X-Route-Model align, post-guardrail reroute policy re-check, hook model override) into RoutingModelOps helpers in resolveRoutingModel.ts, shrinking chat.ts back under the frozen 1796-line file-size baseline (was 1837). - Update tests/unit/guardrails/vision-bridge-callmodel.test.ts: the fallback mock must match whichever API shape the selected fallback model actually calls (OpenAI-compatible vs Anthropic), since the vision-bridge router priority fix in this PR can now legitimately select an Anthropic fallback model instead of always defaulting to an OpenAI-shaped opencode-* model. - Update tests/unit/vision-bridge-policy-reroute-6640.test.ts: per this PR's own VB-CRED-01 test, a credentialed original model is now intentionally never whole-request-rerouted (it always falls through to describe-then- forward) — so the pre-existing #6640 tests are updated to assert the final, user-facing answer always comes from the original credentialed model, matching the new intended behavior instead of the retired whole-request-reroute path. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(guardrails/chat): reduce isProviderConnectionUsable cyclomatic complexity to satisfy the project-wide complexity ratchet The new isProviderConnectionUsable helper (complexity 21) regressed the project-wide complexity ratchet from 2056 to 2057. Refactor it to use Set membership checks and small extracted helpers (hasNonEmptyString, hasOAuthCredential) instead of chained === / || comparisons — same behavior, verified by the existing "isProviderConnectionUsable rejects noauth without api key" test, with complexity back under the 15-per-function threshold and the project-wide ratchet back at the 2056 baseline. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
176 lines
7.8 KiB
TypeScript
176 lines
7.8 KiB
TypeScript
// Regression tests for PR #6640 review findings — Vision Bridge's individual-model
|
|
// auto-reroute (route an image-bearing request straight to a vision-capable model
|
|
// instead of describe-then-forward) swaps `body.model` INSIDE the guardrail
|
|
// pipeline, which runs AFTER `chat.ts` already called `enforceApiKeyPolicy()`
|
|
// against the original model. Without a re-check, a key restricted via
|
|
// `allowedModels` could silently execute against an unvetted reroute target.
|
|
//
|
|
// UPDATED for PR #7204 (Vision Bridge no-credentialed-hijack fix): when the
|
|
// ORIGINAL model already has a usable, credentialed connection (as seeded
|
|
// below via `seedConnection("openai", ...)`), Vision Bridge now deliberately
|
|
// never whole-request-reroutes it to another model (see
|
|
// `VisionBridgeGuardrail.preCall` step 9 / `VB-CRED-01` in
|
|
// tests/unit/guardrails/visionBridge.test.ts) — it always falls through to
|
|
// describe-then-forward: an internal call describes the image via the vision
|
|
// model, then the description is forwarded as text to the ORIGINAL,
|
|
// already-approved model, which produces the final, user-facing answer.
|
|
//
|
|
// These tests exercise the real `handleChat()` pipeline end-to-end (real DB,
|
|
// real guardrail registry, mocked upstream fetch) to prove the policy
|
|
// invariant that motivated #6640 still holds under the new #7204 behavior:
|
|
// 1. A vision-bridge target NOT in the key's `allowedModels` is only ever
|
|
// used internally (image description) — it never becomes the final,
|
|
// user-facing answering model. That role always stays with the
|
|
// original, already-approved model.
|
|
// 2. Even when the vision target IS inside `allowedModels`, a credentialed
|
|
// original model is still never whole-request-rerouted — the original
|
|
// model remains the final answerer (no regression from #7204's intent).
|
|
// 3. An explicit `settings.visionBridgeModel` operator override is honored
|
|
// as the internal describe-path model, but — consistent with #7204 —
|
|
// does not override a credentialed original model as the final answerer.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
|
|
|
|
const harness = await createChatPipelineHarness("vision-bridge-policy-reroute-6640");
|
|
const { handleChat, buildRequest, buildOpenAIResponse, resetStorage, seedConnection, seedApiKey, settingsDb } =
|
|
harness;
|
|
|
|
function imageBearingBody(model: string) {
|
|
return {
|
|
model,
|
|
stream: false,
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "What is in this image?" },
|
|
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
await resetStorage();
|
|
});
|
|
|
|
test.after(async () => {
|
|
await harness.cleanup();
|
|
});
|
|
|
|
test("#6640: guardrail reroute to a model outside allowedModels is rejected — falls back to the original allowed model", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-primary" });
|
|
// Vision Bridge auto-reroutes non-vision models with images to the best
|
|
// available vision-capable model; pin it to a DIFFERENT model than the one
|
|
// the key is allowed to use, so a real reroute is guaranteed to happen and
|
|
// to conflict with the allowlist.
|
|
await settingsDb.updateSettings({ visionBridgeModel: "openai/gpt-4o-mini" });
|
|
|
|
const apiKey = await seedApiKey({ allowedModels: ["openai/gpt-3.5-turbo"] });
|
|
|
|
const fetchCalls: Array<{ body: Record<string, unknown> | null }> = [];
|
|
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
|
|
fetchCalls.push({ body: init.body ? JSON.parse(String(init.body)) : null });
|
|
return buildOpenAIResponse("described");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: imageBearingBody("openai/gpt-3.5-turbo"),
|
|
})
|
|
);
|
|
|
|
// The vision-bridge target (gpt-4o-mini) is not in allowedModels — the
|
|
// request must NOT be silently executed against it as the final answering
|
|
// model. Under #7204, the credentialed original model (gpt-3.5-turbo) is
|
|
// never whole-request-rerouted in the first place, so the vision target can
|
|
// only ever appear as an internal, non-final describe call. Either way, the
|
|
// FINAL upstream call (the one whose response the user actually receives)
|
|
// must never be the disallowed model.
|
|
if (response.status === 200) {
|
|
assert.ok(fetchCalls.length >= 1, "at least one upstream call expected");
|
|
const finalCall = fetchCalls[fetchCalls.length - 1];
|
|
assert.equal(
|
|
finalCall.body?.model,
|
|
"gpt-3.5-turbo",
|
|
"the final, user-facing answer must come from the original allowed model, not the disallowed vision target"
|
|
);
|
|
} else {
|
|
assert.equal(fetchCalls.length, 0, "a rejected request must never reach the upstream");
|
|
}
|
|
});
|
|
|
|
test("#6640: a credentialed original model is never whole-request-rerouted, even when the vision target is inside allowedModels (PR #7204)", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-primary" });
|
|
await settingsDb.updateSettings({ visionBridgeModel: "openai/gpt-4o-mini" });
|
|
|
|
// This key allows BOTH the original model and the vision target — before
|
|
// #7204 this would have made the whole-request reroute to gpt-4o-mini the
|
|
// final answering model. #7204 deliberately changes this: a credentialed
|
|
// original model (gpt-3.5-turbo, seeded above) is never whole-request-
|
|
// rerouted regardless of what's allowed — it always stays the final
|
|
// answerer, with the vision target used only for the internal image
|
|
// description (see VB-CRED-01 in tests/unit/guardrails/visionBridge.test.ts).
|
|
const apiKey = await seedApiKey({
|
|
allowedModels: ["openai/gpt-3.5-turbo", "openai/gpt-4o-mini"],
|
|
});
|
|
|
|
const fetchCalls: Array<{ body: Record<string, unknown> | null }> = [];
|
|
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
|
|
fetchCalls.push({ body: init.body ? JSON.parse(String(init.body)) : null });
|
|
return buildOpenAIResponse("described");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: imageBearingBody("openai/gpt-3.5-turbo"),
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.ok(fetchCalls.length >= 1, "at least one upstream call expected");
|
|
const finalCall = fetchCalls[fetchCalls.length - 1];
|
|
assert.equal(
|
|
finalCall.body?.model,
|
|
"gpt-3.5-turbo",
|
|
"the credentialed original model must remain the final answerer — no whole-request reroute (PR #7204), even though gpt-4o-mini is allowed"
|
|
);
|
|
});
|
|
|
|
test("#6640: settings.visionBridgeModel override does not displace a credentialed original model as the final answerer (PR #7204)", async () => {
|
|
await seedConnection("openai", { apiKey: "sk-openai-primary" });
|
|
await settingsDb.updateSettings({ visionBridgeModel: "openai/gpt-4o-mini" });
|
|
|
|
// No allowlist restriction — nothing to enforce here; this proves the
|
|
// settings threading itself (independent of the policy re-check above).
|
|
const apiKey = await seedApiKey();
|
|
|
|
const fetchCalls: Array<{ body: Record<string, unknown> | null }> = [];
|
|
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
|
|
fetchCalls.push({ body: init.body ? JSON.parse(String(init.body)) : null });
|
|
return buildOpenAIResponse("described");
|
|
};
|
|
|
|
const response = await handleChat(
|
|
buildRequest({
|
|
authKey: apiKey.key,
|
|
body: imageBearingBody("openai/gpt-3.5-turbo"),
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.ok(fetchCalls.length >= 1, "at least one upstream call expected");
|
|
const finalCall = fetchCalls[fetchCalls.length - 1];
|
|
assert.equal(
|
|
finalCall.body?.model,
|
|
"gpt-3.5-turbo",
|
|
"the configured settings.visionBridgeModel must not override the credentialed original model as the final answerer (PR #7204)"
|
|
);
|
|
});
|