Skip to content
This repository was archived by the owner on Apr 24, 2026. It is now read-only.

Commit 7c1bf86

Browse files
levleontievcodexclaude
authored
feat(access): hybrid mode routing (issue #41) (#43)
* 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 <noreply@anthropic.com> * fix(access): address codex review — validate_nginx_template hybrid mode + access.lua dispatch tests - 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 <noreply@anthropic.com> --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 597135c commit 7c1bf86

6 files changed

Lines changed: 162 additions & 17 deletions

File tree

bin/ci/validate_nginx_template.sh

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ set -euo pipefail
33

44
RUNTIME_IMAGE="${FAIRVISOR_RUNTIME_IMAGE:-fairvisor-runtime-ci}"
55

6-
docker run --rm \
7-
--entrypoint sh \
8-
-e "FAIRVISOR_SHARED_DICT_SIZE=${FAIRVISOR_SHARED_DICT_SIZE:-4m}" \
9-
-e "FAIRVISOR_LOG_LEVEL=${FAIRVISOR_LOG_LEVEL:-info}" \
10-
-e "FAIRVISOR_MODE=${FAIRVISOR_MODE:-decision_service}" \
11-
-e "FAIRVISOR_BACKEND_URL=${FAIRVISOR_BACKEND_URL:-http://127.0.0.1:8081}" \
12-
-e "FAIRVISOR_WORKER_PROCESSES=${FAIRVISOR_WORKER_PROCESSES:-1}" \
13-
-e "FAIRVISOR_UPSTREAM_TIMEOUT_MS=${FAIRVISOR_UPSTREAM_TIMEOUT_MS:-30000}" \
14-
"${RUNTIME_IMAGE}" -c '
15-
envsubst '"'"'${FAIRVISOR_SHARED_DICT_SIZE} ${FAIRVISOR_LOG_LEVEL} ${FAIRVISOR_MODE} ${FAIRVISOR_BACKEND_URL} ${FAIRVISOR_WORKER_PROCESSES} ${FAIRVISOR_UPSTREAM_TIMEOUT_MS}'"'"' \
16-
< /opt/fairvisor/nginx.conf.template \
17-
> /tmp/nginx.conf
18-
openresty -t -c /tmp/nginx.conf -p /usr/local/openresty/nginx/
19-
'
6+
for mode in decision_service wrapper hybrid; do
7+
echo "Validating nginx config for FAIRVISOR_MODE=${mode}..."
8+
docker run --rm \
9+
--entrypoint sh \
10+
-e "FAIRVISOR_SHARED_DICT_SIZE=${FAIRVISOR_SHARED_DICT_SIZE:-4m}" \
11+
-e "FAIRVISOR_LOG_LEVEL=${FAIRVISOR_LOG_LEVEL:-info}" \
12+
-e "FAIRVISOR_MODE=${mode}" \
13+
-e "FAIRVISOR_BACKEND_URL=${FAIRVISOR_BACKEND_URL:-http://127.0.0.1:8081}" \
14+
-e "FAIRVISOR_WORKER_PROCESSES=${FAIRVISOR_WORKER_PROCESSES:-1}" \
15+
-e "FAIRVISOR_UPSTREAM_TIMEOUT_MS=${FAIRVISOR_UPSTREAM_TIMEOUT_MS:-30000}" \
16+
"${RUNTIME_IMAGE}" -c '
17+
envsubst '"'"'${FAIRVISOR_SHARED_DICT_SIZE} ${FAIRVISOR_LOG_LEVEL} ${FAIRVISOR_MODE} ${FAIRVISOR_BACKEND_URL} ${FAIRVISOR_WORKER_PROCESSES} ${FAIRVISOR_UPSTREAM_TIMEOUT_MS}'"'"' \
18+
< /opt/fairvisor/nginx.conf.template \
19+
> /tmp/nginx.conf
20+
openresty -t -c /tmp/nginx.conf -p /usr/local/openresty/nginx/
21+
'
22+
echo "OK: FAIRVISOR_MODE=${mode}"
23+
done

docker/entrypoint.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ fi
1919

2020
if [ "${FAIRVISOR_MODE:-decision_service}" != "decision_service" ] && \
2121
[ "${FAIRVISOR_MODE:-decision_service}" != "reverse_proxy" ] && \
22-
[ "${FAIRVISOR_MODE:-decision_service}" != "wrapper" ]; then
23-
echo "fairvisor: FAIRVISOR_MODE must be decision_service, reverse_proxy, or wrapper" >&2
22+
[ "${FAIRVISOR_MODE:-decision_service}" != "wrapper" ] && \
23+
[ "${FAIRVISOR_MODE:-decision_service}" != "hybrid" ]; then
24+
echo "fairvisor: FAIRVISOR_MODE must be decision_service, reverse_proxy, wrapper, or hybrid" >&2
2425
exit 1
2526
fi
2627

spec/unit/access_spec.lua

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package.path = "./src/?.lua;./src/?/init.lua;./spec/?.lua;./spec/?/init.lua;" .. package.path
2+
3+
local mock_ngx = require("helpers.mock_ngx")
4+
local gherkin = require("helpers.gherkin")
5+
6+
-- ---------------------------------------------------------------------------
7+
-- Call tracking (reset per scenario)
8+
-- ---------------------------------------------------------------------------
9+
local _calls = {}
10+
11+
-- ---------------------------------------------------------------------------
12+
-- Lightweight mock — mimics wrapper.get_provider prefix logic without
13+
-- loading the real module (which pulls in ngx at module init time).
14+
-- ---------------------------------------------------------------------------
15+
local _PROVIDER_PREFIXES = {
16+
"/gemini-compat", "/openai", "/anthropic", "/gemini",
17+
"/grok", "/groq", "/mistral", "/deepseek", "/perplexity",
18+
"/together", "/fireworks", "/cerebras", "/ollama",
19+
}
20+
21+
local _wrapper_mock = {
22+
get_provider = function(path)
23+
if type(path) ~= "string" then return nil end
24+
for _, prefix in ipairs(_PROVIDER_PREFIXES) do
25+
if path:sub(1, #prefix) == prefix then
26+
local next_char = path:sub(#prefix + 1, #prefix + 1)
27+
if next_char == "/" or next_char == "" then
28+
return { prefix = prefix }
29+
end
30+
end
31+
end
32+
return nil
33+
end,
34+
access_handler = function()
35+
_calls.wrapper = (_calls.wrapper or 0) + 1
36+
end,
37+
}
38+
39+
local _decision_mock = {
40+
access_handler = function()
41+
_calls.decision_api = (_calls.decision_api or 0) + 1
42+
end,
43+
}
44+
45+
-- Pre-populate package.loaded so access.lua picks up mocks via require()
46+
package.loaded["fairvisor.wrapper"] = _wrapper_mock
47+
package.loaded["fairvisor.decision_api"] = _decision_mock
48+
49+
-- ---------------------------------------------------------------------------
50+
-- Step definitions
51+
-- ---------------------------------------------------------------------------
52+
local runner = gherkin.new({ describe = describe, context = context, it = it })
53+
54+
runner:given("^nginx mode is \"(.-)\" and uri is \"(.-)\"$", function(ctx, mode, uri)
55+
mock_ngx.setup_ngx()
56+
ngx.var.fairvisor_mode = mode
57+
ngx.var.uri = uri
58+
_calls = {}
59+
end)
60+
61+
runner:when("^the access dispatcher runs$", function(ctx)
62+
local chunk, err = loadfile("src/nginx/access.lua")
63+
assert.is_not_nil(chunk, "loadfile(src/nginx/access.lua) failed: " .. tostring(err))
64+
chunk()
65+
end)
66+
67+
runner:then_("^wrapper access_handler is called$", function(ctx)
68+
assert.is_truthy((_calls.wrapper or 0) > 0,
69+
"expected wrapper.access_handler() to be called but it was not")
70+
end)
71+
72+
runner:then_("^decision_api access_handler is called$", function(ctx)
73+
assert.is_truthy((_calls.decision_api or 0) > 0,
74+
"expected decision_api.access_handler() to be called but it was not")
75+
end)
76+
77+
runner:then_("^neither handler is called$", function(ctx)
78+
assert.equals(0, _calls.wrapper or 0,
79+
"wrapper.access_handler should not be called")
80+
assert.equals(0, _calls.decision_api or 0,
81+
"decision_api.access_handler should not be called")
82+
end)
83+
84+
-- ---------------------------------------------------------------------------
85+
-- Run scenarios from feature file
86+
-- ---------------------------------------------------------------------------
87+
runner:feature_file_relative("features/access.feature")

spec/unit/features/access.feature

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Feature: Access phase dispatcher routing
2+
3+
Rule: hybrid mode
4+
Scenario: hybrid mode routes known provider path through wrapper
5+
Given nginx mode is "hybrid" and uri is "/openai/v1/chat/completions"
6+
When the access dispatcher runs
7+
Then wrapper access_handler is called
8+
9+
Scenario: hybrid mode routes non-provider path through decision_api
10+
Given nginx mode is "hybrid" and uri is "/api/internal/metrics"
11+
When the access dispatcher runs
12+
Then decision_api access_handler is called
13+
14+
Rule: wrapper mode
15+
Scenario: wrapper mode routes all paths through wrapper
16+
Given nginx mode is "wrapper" and uri is "/openai/v1/chat/completions"
17+
When the access dispatcher runs
18+
Then wrapper access_handler is called
19+
20+
Rule: reverse_proxy mode
21+
Scenario: reverse_proxy mode routes through decision_api
22+
Given nginx mode is "reverse_proxy" and uri is "/api/v1/data"
23+
When the access dispatcher runs
24+
Then decision_api access_handler is called
25+
26+
Rule: decision_service mode
27+
Scenario: decision_service mode calls neither handler
28+
Given nginx mode is "decision_service" and uri is "/openai/v1/chat"
29+
When the access dispatcher runs
30+
Then neither handler is called

spec/unit/features/wrapper.feature

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,19 @@ Feature: LLM Proxy Wrapper Mode unit behavior
193193
When access_handler is called
194194
Then response exit code is 429
195195

196+
Rule: hybrid mode routing decision
197+
Scenario: hybrid mode — provider path triggers wrapper access_handler
198+
Given the nginx mock is set up for access_handler
199+
And request auth header is "Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMTIzIn0.:sk-abc123"
200+
And request path is "/openai/v1/chat/completions"
201+
When access_handler is called
202+
Then upstream url contains "api.openai.com"
203+
And ngx exit was not called
204+
205+
Scenario: hybrid mode — get_provider returns nil for non-provider path
206+
When I call get_provider for path "/api/v1/some-internal-endpoint"
207+
Then provider is nil
208+
196209
Rule: wrapper init
197210
Scenario: init with valid deps table returns true
198211
When I call wrapper init with valid deps

src/nginx/access.lua

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@ if mode == "wrapper" then
66
return
77
end
88

9-
if mode ~= "reverse_proxy" then
9+
if mode == "hybrid" then
10+
local wrapper = require("fairvisor.wrapper")
11+
local provider = wrapper.get_provider(ngx.var.uri or "")
12+
if provider then
13+
wrapper.access_handler()
14+
return
15+
end
16+
-- No provider match — fall through to decision_api enforcement
17+
end
18+
19+
if mode ~= "reverse_proxy" and mode ~= "hybrid" then
1020
return
1121
end
1222

0 commit comments

Comments
 (0)