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
32 changes: 18 additions & 14 deletions bin/ci/validate_nginx_template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions spec/unit/access_spec.lua
Original file line number Diff line number Diff line change
@@ -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")
30 changes: 30 additions & 0 deletions spec/unit/features/access.feature
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions spec/unit/features/wrapper.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/nginx/access.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading