From 5716734b65275b1f4df05310d72c93cbc40a3ddf Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 16 Mar 2026 14:56:31 +0000 Subject: [PATCH 1/2] feat(access): implement hybrid mode routing (issue #41) In hybrid mode, requests to known LLM provider prefixes (/openai, /anthropic, etc.) are routed through wrapper.access_handler(); all other requests fall through to decision_api.access_handler() for normal enforcement. Added "hybrid" to entrypoint.sh allowed modes. 36 BDD scenarios pass. Co-Authored-By: Claude Sonnet 4.6 --- docker/entrypoint.sh | 5 +++-- spec/unit/features/wrapper.feature | 13 +++++++++++++ src/nginx/access.lua | 12 +++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 412d380..91f6e8c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -19,8 +19,9 @@ fi if [ "${FAIRVISOR_MODE:-decision_service}" != "decision_service" ] && \ [ "${FAIRVISOR_MODE:-decision_service}" != "reverse_proxy" ] && \ - [ "${FAIRVISOR_MODE:-decision_service}" != "wrapper" ]; then - echo "fairvisor: FAIRVISOR_MODE must be decision_service, reverse_proxy, or wrapper" >&2 + [ "${FAIRVISOR_MODE:-decision_service}" != "wrapper" ] && \ + [ "${FAIRVISOR_MODE:-decision_service}" != "hybrid" ]; then + echo "fairvisor: FAIRVISOR_MODE must be decision_service, reverse_proxy, wrapper, or hybrid" >&2 exit 1 fi diff --git a/spec/unit/features/wrapper.feature b/spec/unit/features/wrapper.feature index b8fa0de..a2f40b2 100644 --- a/spec/unit/features/wrapper.feature +++ b/spec/unit/features/wrapper.feature @@ -193,6 +193,19 @@ Feature: LLM Proxy Wrapper Mode unit behavior When access_handler is called Then response exit code is 429 + Rule: hybrid mode routing decision + Scenario: hybrid mode — provider path triggers wrapper access_handler + Given the nginx mock is set up for access_handler + And request auth header is "Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMTIzIn0.:sk-abc123" + And request path is "/openai/v1/chat/completions" + When access_handler is called + Then upstream url contains "api.openai.com" + And ngx exit was not called + + Scenario: hybrid mode — get_provider returns nil for non-provider path + When I call get_provider for path "/api/v1/some-internal-endpoint" + Then provider is nil + Rule: wrapper init Scenario: init with valid deps table returns true When I call wrapper init with valid deps diff --git a/src/nginx/access.lua b/src/nginx/access.lua index a3ae16e..5c544e3 100644 --- a/src/nginx/access.lua +++ b/src/nginx/access.lua @@ -6,7 +6,17 @@ if mode == "wrapper" then return end -if mode ~= "reverse_proxy" then +if mode == "hybrid" then + local wrapper = require("fairvisor.wrapper") + local provider = wrapper.get_provider(ngx.var.uri or "") + if provider then + wrapper.access_handler() + return + end + -- No provider match — fall through to decision_api enforcement +end + +if mode ~= "reverse_proxy" and mode ~= "hybrid" then return end From 5d455ddb0b6e3e4bbe5f0ef88e6e97d67a202ee7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 16 Mar 2026 15:18:04 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(access):=20address=20codex=20review=20?= =?UTF-8?q?=E2=80=94=20validate=5Fnginx=5Ftemplate=20hybrid=20mode=20+=20a?= =?UTF-8?q?ccess.lua=20dispatch=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bin/ci/validate_nginx_template.sh: loop over decision_service, wrapper, hybrid to validate nginx config renders correctly for all modes - spec/unit/access_spec.lua + features/access.feature: 5 BDD scenarios that load src/nginx/access.lua via loadfile and verify the actual dispatch logic (hybrid→wrapper, hybrid→decision_api, wrapper, reverse_proxy, decision_service) Co-Authored-By: Claude Sonnet 4.6 --- bin/ci/validate_nginx_template.sh | 32 +++++++----- spec/unit/access_spec.lua | 87 +++++++++++++++++++++++++++++++ spec/unit/features/access.feature | 30 +++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 spec/unit/access_spec.lua create mode 100644 spec/unit/features/access.feature diff --git a/bin/ci/validate_nginx_template.sh b/bin/ci/validate_nginx_template.sh index 9214dc1..941bdcf 100755 --- a/bin/ci/validate_nginx_template.sh +++ b/bin/ci/validate_nginx_template.sh @@ -3,17 +3,21 @@ set -euo pipefail RUNTIME_IMAGE="${FAIRVISOR_RUNTIME_IMAGE:-fairvisor-runtime-ci}" -docker run --rm \ - --entrypoint sh \ - -e "FAIRVISOR_SHARED_DICT_SIZE=${FAIRVISOR_SHARED_DICT_SIZE:-4m}" \ - -e "FAIRVISOR_LOG_LEVEL=${FAIRVISOR_LOG_LEVEL:-info}" \ - -e "FAIRVISOR_MODE=${FAIRVISOR_MODE:-decision_service}" \ - -e "FAIRVISOR_BACKEND_URL=${FAIRVISOR_BACKEND_URL:-http://127.0.0.1:8081}" \ - -e "FAIRVISOR_WORKER_PROCESSES=${FAIRVISOR_WORKER_PROCESSES:-1}" \ - -e "FAIRVISOR_UPSTREAM_TIMEOUT_MS=${FAIRVISOR_UPSTREAM_TIMEOUT_MS:-30000}" \ - "${RUNTIME_IMAGE}" -c ' - envsubst '"'"'${FAIRVISOR_SHARED_DICT_SIZE} ${FAIRVISOR_LOG_LEVEL} ${FAIRVISOR_MODE} ${FAIRVISOR_BACKEND_URL} ${FAIRVISOR_WORKER_PROCESSES} ${FAIRVISOR_UPSTREAM_TIMEOUT_MS}'"'"' \ - < /opt/fairvisor/nginx.conf.template \ - > /tmp/nginx.conf - openresty -t -c /tmp/nginx.conf -p /usr/local/openresty/nginx/ -' +for mode in decision_service wrapper hybrid; do + echo "Validating nginx config for FAIRVISOR_MODE=${mode}..." + docker run --rm \ + --entrypoint sh \ + -e "FAIRVISOR_SHARED_DICT_SIZE=${FAIRVISOR_SHARED_DICT_SIZE:-4m}" \ + -e "FAIRVISOR_LOG_LEVEL=${FAIRVISOR_LOG_LEVEL:-info}" \ + -e "FAIRVISOR_MODE=${mode}" \ + -e "FAIRVISOR_BACKEND_URL=${FAIRVISOR_BACKEND_URL:-http://127.0.0.1:8081}" \ + -e "FAIRVISOR_WORKER_PROCESSES=${FAIRVISOR_WORKER_PROCESSES:-1}" \ + -e "FAIRVISOR_UPSTREAM_TIMEOUT_MS=${FAIRVISOR_UPSTREAM_TIMEOUT_MS:-30000}" \ + "${RUNTIME_IMAGE}" -c ' + envsubst '"'"'${FAIRVISOR_SHARED_DICT_SIZE} ${FAIRVISOR_LOG_LEVEL} ${FAIRVISOR_MODE} ${FAIRVISOR_BACKEND_URL} ${FAIRVISOR_WORKER_PROCESSES} ${FAIRVISOR_UPSTREAM_TIMEOUT_MS}'"'"' \ + < /opt/fairvisor/nginx.conf.template \ + > /tmp/nginx.conf + openresty -t -c /tmp/nginx.conf -p /usr/local/openresty/nginx/ + ' + echo "OK: FAIRVISOR_MODE=${mode}" +done diff --git a/spec/unit/access_spec.lua b/spec/unit/access_spec.lua new file mode 100644 index 0000000..fc4ed04 --- /dev/null +++ b/spec/unit/access_spec.lua @@ -0,0 +1,87 @@ +package.path = "./src/?.lua;./src/?/init.lua;./spec/?.lua;./spec/?/init.lua;" .. package.path + +local mock_ngx = require("helpers.mock_ngx") +local gherkin = require("helpers.gherkin") + +-- --------------------------------------------------------------------------- +-- Call tracking (reset per scenario) +-- --------------------------------------------------------------------------- +local _calls = {} + +-- --------------------------------------------------------------------------- +-- Lightweight mock — mimics wrapper.get_provider prefix logic without +-- loading the real module (which pulls in ngx at module init time). +-- --------------------------------------------------------------------------- +local _PROVIDER_PREFIXES = { + "/gemini-compat", "/openai", "/anthropic", "/gemini", + "/grok", "/groq", "/mistral", "/deepseek", "/perplexity", + "/together", "/fireworks", "/cerebras", "/ollama", +} + +local _wrapper_mock = { + get_provider = function(path) + if type(path) ~= "string" then return nil end + for _, prefix in ipairs(_PROVIDER_PREFIXES) do + if path:sub(1, #prefix) == prefix then + local next_char = path:sub(#prefix + 1, #prefix + 1) + if next_char == "/" or next_char == "" then + return { prefix = prefix } + end + end + end + return nil + end, + access_handler = function() + _calls.wrapper = (_calls.wrapper or 0) + 1 + end, +} + +local _decision_mock = { + access_handler = function() + _calls.decision_api = (_calls.decision_api or 0) + 1 + end, +} + +-- Pre-populate package.loaded so access.lua picks up mocks via require() +package.loaded["fairvisor.wrapper"] = _wrapper_mock +package.loaded["fairvisor.decision_api"] = _decision_mock + +-- --------------------------------------------------------------------------- +-- Step definitions +-- --------------------------------------------------------------------------- +local runner = gherkin.new({ describe = describe, context = context, it = it }) + +runner:given("^nginx mode is \"(.-)\" and uri is \"(.-)\"$", function(ctx, mode, uri) + mock_ngx.setup_ngx() + ngx.var.fairvisor_mode = mode + ngx.var.uri = uri + _calls = {} +end) + +runner:when("^the access dispatcher runs$", function(ctx) + local chunk, err = loadfile("src/nginx/access.lua") + assert.is_not_nil(chunk, "loadfile(src/nginx/access.lua) failed: " .. tostring(err)) + chunk() +end) + +runner:then_("^wrapper access_handler is called$", function(ctx) + assert.is_truthy((_calls.wrapper or 0) > 0, + "expected wrapper.access_handler() to be called but it was not") +end) + +runner:then_("^decision_api access_handler is called$", function(ctx) + assert.is_truthy((_calls.decision_api or 0) > 0, + "expected decision_api.access_handler() to be called but it was not") +end) + +runner:then_("^neither handler is called$", function(ctx) + assert.equals(0, _calls.wrapper or 0, + "wrapper.access_handler should not be called") + assert.equals(0, _calls.decision_api or 0, + "decision_api.access_handler should not be called") +end) + +-- --------------------------------------------------------------------------- +-- Run scenarios from feature file +-- --------------------------------------------------------------------------- +runner:feature_file_relative("features/access.feature") diff --git a/spec/unit/features/access.feature b/spec/unit/features/access.feature new file mode 100644 index 0000000..0bf00ec --- /dev/null +++ b/spec/unit/features/access.feature @@ -0,0 +1,30 @@ +Feature: Access phase dispatcher routing + + Rule: hybrid mode + Scenario: hybrid mode routes known provider path through wrapper + Given nginx mode is "hybrid" and uri is "/openai/v1/chat/completions" + When the access dispatcher runs + Then wrapper access_handler is called + + Scenario: hybrid mode routes non-provider path through decision_api + Given nginx mode is "hybrid" and uri is "/api/internal/metrics" + When the access dispatcher runs + Then decision_api access_handler is called + + Rule: wrapper mode + Scenario: wrapper mode routes all paths through wrapper + Given nginx mode is "wrapper" and uri is "/openai/v1/chat/completions" + When the access dispatcher runs + Then wrapper access_handler is called + + Rule: reverse_proxy mode + Scenario: reverse_proxy mode routes through decision_api + Given nginx mode is "reverse_proxy" and uri is "/api/v1/data" + When the access dispatcher runs + Then decision_api access_handler is called + + Rule: decision_service mode + Scenario: decision_service mode calls neither handler + Given nginx mode is "decision_service" and uri is "/openai/v1/chat" + When the access dispatcher runs + Then neither handler is called