gate: Claude Code behind it — OAuth bearer, anthropic-beta, token counting, the gate's own watch
A coding agent's traffic differs from a hand-made request: with a subscription Claude Code authenticates with an OAuth bearer and sends anthropic-beta; it counts tokens at /v1/messages/count_tokens; it makes a request per tool call; its tool-only turns have no text; its context runs to megabytes. So: with the courtyard key in X-Bikoret-Key, the caller's Authorization: Bearer is forwarded to Anthropic as its credential, and anthropic-beta with it; count_tokens is carried through unexamined and uncharged; the watch counts the gate at its own windows (120/min, 2,000/h per key — tier :gate); a reply with no text is delivered as it came with bikoret.verdict "none" and no fringe block (stream and non-stream); the sources keep the newest user turns whole and drop the oldest past 1 MiB. Ingress body 32 MiB; ConfigMap manna amounts raised to 5000/5000 (the kohen's lever). Tamid §1.1, KOHEN "Claude Code behind the gate". Doorpost re-pinned. Elixir 617/0 read before this commit; gate_test 25. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a16617fffe
commit
f19a5ea007
13 files changed
+207
-43
No files matched your search
@@ -34,7 +34,7 @@ defmodule VavPipeline.BigdeiKodesh.Mishmar do
|
||||
@table __MODULE__
|
||||
@sweep_ms :timer.minutes(1)
|
||||
|
||||
@type tier :: :peah | :courtyard | :chatzer
|
||||
@type tier :: :peah | :courtyard | :chatzer | :gate
|
||||
@type subject :: binary()
|
||||
|
||||
@default_limits [
|
||||
@@ -47,7 +47,10 @@ defmodule VavPipeline.BigdeiKodesh.Mishmar do
|
||||
# Construction §2.3: five panels at once, deliberation measured in
|
||||
# minutes. A key convening more than five cases a minute is not a
|
||||
# court, it is a loop.
|
||||
chatzer: [minute: 5, hour: 60]
|
||||
chatzer: [minute: 5, hour: 60],
|
||||
# The gate (Construction §3.10): a coding agent behind it makes a
|
||||
# request per tool call. Admitted with a courtyard key, counted here.
|
||||
gate: [minute: 120, hour: 2_000]
|
||||
]
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
|
||||
@@ -163,13 +163,17 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
#
|
||||
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
|
||||
# not merely for its shape. Until 2026-10-06 any Bearer value passed.
|
||||
defp through_the_gate(conn, tier, serve) do
|
||||
defp through_the_gate(conn, tier, serve), do: through_the_gate(conn, tier, tier, serve)
|
||||
|
||||
# `watch` may differ from `tier`: the gate admits courtyard keys but
|
||||
# counts knocks at the gate's own, wider windows.
|
||||
defp through_the_gate(conn, tier, watch, serve) do
|
||||
case Gate.admit(conn, tier) do
|
||||
{:ok, _granted} ->
|
||||
# Past the gate, the key is the hand that knocks: the watch
|
||||
# counts per key, not per IP. A refused key never reaches it.
|
||||
{:ok, key} = Gate.bearer(conn)
|
||||
under_the_watch(conn, tier, key, serve)
|
||||
under_the_watch(conn, watch, key, serve)
|
||||
|
||||
{:error, :tier_too_low} ->
|
||||
send_error(conn, 403, "FORBIDDEN", %{
|
||||
@@ -218,11 +222,16 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
# request's context in hand and delivered with its fringe.
|
||||
|
||||
post "/gate/anthropic/v1/messages" do
|
||||
through_the_gate(conn, :courtyard, &process_gate(&1, :anthropic))
|
||||
through_the_gate(conn, :courtyard, :gate, &process_gate(&1, :anthropic))
|
||||
end
|
||||
|
||||
# Token counting is not a reply: carried through, not examined, not charged.
|
||||
post "/gate/anthropic/v1/messages/count_tokens" do
|
||||
through_the_gate(conn, :courtyard, :gate, &passthrough(&1, :anthropic, "/count_tokens"))
|
||||
end
|
||||
|
||||
post "/gate/openai/v1/chat/completions" do
|
||||
through_the_gate(conn, :courtyard, &process_gate(&1, :openai))
|
||||
through_the_gate(conn, :courtyard, :gate, &process_gate(&1, :openai))
|
||||
end
|
||||
|
||||
# === HAKHEL — the scroll read again before all (bishe'arecha §VIII) ===
|
||||
@@ -713,12 +722,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
_ -> :none
|
||||
end
|
||||
|
||||
extra =
|
||||
case get_req_header(conn, "anthropic-version") do
|
||||
[v] when upstream == :anthropic -> [{"anthropic-version", v}]
|
||||
_ when upstream == :anthropic -> [{"anthropic-version", "2023-06-01"}]
|
||||
_ -> []
|
||||
end
|
||||
extra = forwarded_headers(conn, upstream)
|
||||
|
||||
cond do
|
||||
body["stream"] == true and rebuke == :once ->
|
||||
@@ -740,12 +744,16 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
end
|
||||
end
|
||||
|
||||
# The caller's upstream key: x-api-key (Anthropic), or Authorization: Bearer
|
||||
# when X-Bikoret-Key carries the courtyard key (OpenAI).
|
||||
# The caller's upstream credential. Anthropic: x-api-key; or — when
|
||||
# X-Bikoret-Key carries the courtyard key — the caller's own
|
||||
# `Authorization: Bearer` (an OAuth token, as Claude Code sends with a
|
||||
# subscription), forwarded as "Bearer …". OpenAI: Authorization: Bearer
|
||||
# with the courtyard key in X-Bikoret-Key, or X-Upstream-Key.
|
||||
defp upstream_key(conn, :anthropic) do
|
||||
case get_req_header(conn, "x-api-key") do
|
||||
[key] when key != "" -> {:ok, key}
|
||||
_ -> {:error, "Missing 'x-api-key': the caller's own Anthropic key", "Send the Anthropic key in x-api-key and the courtyard key in Authorization: Bearer (or X-Bikoret-Key)"}
|
||||
case {get_req_header(conn, "x-api-key"), get_req_header(conn, "x-bikoret-key"), get_req_header(conn, "authorization")} do
|
||||
{[key], _, _} when key != "" -> {:ok, key}
|
||||
{_, [_], ["Bearer " <> token]} when token != "" -> {:ok, "Bearer " <> token}
|
||||
_ -> {:error, "Missing the caller's own Anthropic credential", "Send the Anthropic key in x-api-key (courtyard key in Authorization: Bearer or X-Bikoret-Key), or an OAuth token in Authorization: Bearer with the courtyard key in X-Bikoret-Key"}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -757,6 +765,31 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
end
|
||||
end
|
||||
|
||||
# Headers the model's own API needs, carried as sent; nothing else of the caller's reaches the model.
|
||||
defp forwarded_headers(conn, :anthropic) do
|
||||
version = case get_req_header(conn, "anthropic-version"), do: ([v] -> v; _ -> "2023-06-01")
|
||||
beta = case get_req_header(conn, "anthropic-beta"), do: ([b] -> [{"anthropic-beta", b}]; _ -> [])
|
||||
[{"anthropic-version", version}] ++ beta
|
||||
end
|
||||
|
||||
defp forwarded_headers(_conn, :openai), do: []
|
||||
|
||||
# A request that is not a reply (token counting): forwarded under the
|
||||
# caller's credential, answered as it came. Not examined, no manna.
|
||||
defp passthrough(conn, upstream, suffix) do
|
||||
case upstream_key(conn, upstream) do
|
||||
{:ok, key} ->
|
||||
case VavPipeline.Gate.Upstream.forward_to(VavPipeline.Gate.Upstream.url(upstream) <> suffix, upstream, conn.body_params, key, forwarded_headers(conn, upstream)) do
|
||||
{:ok, status, reply} -> send_json(conn, status, reply)
|
||||
{:error, reason} -> send_error(conn, 502, "UPSTREAM_UNAVAILABLE", %{issue: reason, correction: "Retry", resubmit: true})
|
||||
end
|
||||
|
||||
{:error, issue, correction} ->
|
||||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
defp gate_body_ok(body) when is_map(body) do
|
||||
cond do
|
||||
not is_list(body["messages"]) or body["messages"] == [] ->
|
||||
@@ -806,7 +839,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
|
||||
defp respond_gate(conn, {:ok, %{status: 200, reply: reply, bikoret: bikoret}}) do
|
||||
conn
|
||||
|> put_resp_header("x-bikoret-bedikah", bikoret.bedikah_ref)
|
||||
|> then(fn c -> if bikoret[:bedikah_ref], do: put_resp_header(c, "x-bikoret-bedikah", bikoret.bedikah_ref), else: c end)
|
||||
|> put_resp_header("x-bikoret-verdict", bikoret.verdict)
|
||||
|> send_json(200, Map.put(reply, "bikoret", Map.put(bikoret, :disclosure, disclosure())))
|
||||
end
|
||||
|
||||
@@ -180,7 +180,7 @@ defmodule VavPipeline.Doorpost do
|
||||
# changes; the update is a deliberate operator commit. A bare
|
||||
# hash-update commit (without a corresponding spec change in the
|
||||
# same commit) is itself a drift signal.
|
||||
@expected_hash "40e7d7d1520948e0ba8ea7e5e2ad5bc063c88bc7eae1d14d172498399928fdf9"
|
||||
@expected_hash "1706ff1b417c41a0074c9c50f88feb37f8bc5f63a1ad38b1a3e4a32339d6fa5e"
|
||||
|
||||
@doc "Return the list of files that constitute the category-grade specification."
|
||||
@spec files() :: [String.t()]
|
||||
|
||||
@@ -60,6 +60,8 @@ defmodule VavPipeline.Gate do
|
||||
{:ok, deliver(upstream, reply, first, nil)}
|
||||
end
|
||||
else
|
||||
# A reply with no text (tool use only) is not a reply to a reader: delivered as it came, the object says so.
|
||||
{:no_text, reply} -> {:ok, %{status: 200, reply: reply, bedikah: nil, first: nil, bikoret: %{verdict: "none", reason: "no text", model: Shape.reply_model(upstream, reply)}}}
|
||||
# A non-200 from the upstream passes through as it came.
|
||||
{:ok, status, reply} -> {:ok, %{status: status, reply: reply, bedikah: nil, first: nil, bikoret: nil}}
|
||||
{:error, reason} when is_binary(reason) -> {:error, :upstream_unavailable, reason}
|
||||
@@ -95,7 +97,7 @@ defmodule VavPipeline.Gate do
|
||||
defp examine(upstream, body, reply, opts) do
|
||||
case Shape.reply_text(upstream, reply) do
|
||||
nil ->
|
||||
{:error, :upstream_unavailable, "the reply from #{upstream} carries no text to examine"}
|
||||
{:no_text, reply}
|
||||
|
||||
text ->
|
||||
provenance = Shape.provenance(upstream)
|
||||
|
||||
@@ -48,11 +48,20 @@ defmodule VavPipeline.Gate.Shape do
|
||||
system ++ user_sources(messages, &openai_content_text/1)
|
||||
end
|
||||
|
||||
# The newest turns are kept whole; older ones are dropped once the
|
||||
# sources pass the cap — a coding agent's conversation runs to megabytes.
|
||||
@max_source_bytes 1_048_576
|
||||
|
||||
defp user_sources(messages, text_fun) do
|
||||
messages
|
||||
|> Enum.filter(&(&1["role"] == "user"))
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.flat_map(fn {m, i} -> text_fun.(m) |> wrap("user_#{i}") end)
|
||||
|> Enum.reverse()
|
||||
|> Enum.reduce({[], 0}, fn src, {kept, bytes} ->
|
||||
if bytes + byte_size(src.text) <= @max_source_bytes, do: {[src | kept], bytes + byte_size(src.text)}, else: {kept, @max_source_bytes}
|
||||
end)
|
||||
|> elem(0)
|
||||
end
|
||||
|
||||
defp wrap("", _id), do: []
|
||||
|
||||
@@ -222,7 +222,8 @@ defmodule VavPipeline.Gate.Stream do
|
||||
{"\n\n— bikoret: not examined (#{failure})", %{verdict: "none", reason: failure, streamed: true}}
|
||||
|
||||
nil ->
|
||||
{"\n\n— bikoret: no text to examine", %{verdict: "none", reason: "no text", streamed: true}}
|
||||
# A turn with no text (tool use only) is not a reply to a reader: no block, the object says so.
|
||||
{nil, %{verdict: "none", reason: "no text", streamed: true}}
|
||||
|
||||
{:waiting, why} ->
|
||||
{"\n\n— bikoret: not examined (manna: #{why})", %{verdict: "none", reason: "manna #{why}", streamed: true}}
|
||||
@@ -231,7 +232,7 @@ defmodule VavPipeline.Gate.Stream do
|
||||
{"\n\n— bikoret: not examined (#{kind}: #{reason})", %{verdict: "none", reason: "#{kind}: #{reason}", streamed: true}}
|
||||
end
|
||||
|
||||
state = inject(state, trailer, bikoret)
|
||||
state = if trailer, do: inject(state, trailer, bikoret), else: state
|
||||
state = Enum.reduce(state.held, state, fn raw, st -> emit(st, with_object(st.upstream, raw, bikoret) <> "\n\n") end)
|
||||
|
||||
result = case examined, do: ({:ok, r} -> r; _ -> nil)
|
||||
|
||||
@@ -33,8 +33,10 @@ defmodule VavPipeline.Gate.Upstream do
|
||||
"""
|
||||
@spec forward(atom(), map(), String.t(), [{String.t(), String.t()}]) ::
|
||||
{:ok, non_neg_integer(), map()} | {:error, String.t()}
|
||||
def forward(upstream, body, key, extra \\ []) do
|
||||
url = url(upstream)
|
||||
def forward(upstream, body, key, extra \\ []), do: forward_to(url(upstream), upstream, body, key, extra)
|
||||
|
||||
@doc "As `forward/4`, to an explicit URL (the upstream's other endpoints, e.g. token counting)."
|
||||
def forward_to(url, upstream, body, key, extra \\ []) do
|
||||
payload = Jason.encode!(body)
|
||||
|
||||
headers =
|
||||
@@ -82,6 +84,8 @@ defmodule VavPipeline.Gate.Upstream do
|
||||
end
|
||||
end
|
||||
|
||||
# An Anthropic credential given as "Bearer …" is the caller's OAuth token: forwarded as such.
|
||||
defp auth_headers(:anthropic, "Bearer " <> token), do: [{~c"authorization", String.to_charlist("Bearer " <> token)}]
|
||||
defp auth_headers(:anthropic, key), do: [{~c"x-api-key", String.to_charlist(key)}]
|
||||
defp auth_headers(:openai, key), do: [{~c"authorization", String.to_charlist("Bearer " <> key)}]
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"consumed":{"api_calls_external":0,"compute_units":7,"memory_mb":0,"sanhedrin_panels_max":0,"storage_delta_mb":0},"cycle_date":"2026-10-15","written_at":"2026-10-15T15:23:26.323290Z"}
|
||||
@@ -28,9 +28,40 @@ defmodule VavPipeline.GateTest do
|
||||
plug :match
|
||||
plug :dispatch
|
||||
|
||||
post "/v1/messages/count_tokens" do
|
||||
case {get_req_header(conn, "x-api-key"), get_req_header(conn, "authorization")} do
|
||||
{["sk-good"], _} -> send_resp(conn, 200, Jason.encode!(%{"input_tokens" => 42}))
|
||||
{_, ["Bearer sk-good-oauth"]} -> send_resp(conn, 200, Jason.encode!(%{"input_tokens" => 42}))
|
||||
_ -> send_resp(conn, 401, Jason.encode!(%{"type" => "error", "error" => %{"type" => "authentication_error", "message" => "invalid"}}))
|
||||
end
|
||||
end
|
||||
|
||||
post "/v1/messages" do
|
||||
case get_req_header(conn, "x-api-key") do
|
||||
["sk-good"] ->
|
||||
# Tool-only turn: a reply with no text block.
|
||||
conn =
|
||||
if conn.body_params["max_tokens"] == 7 and conn.body_params["stream"] == true do
|
||||
sse = fn conn, ev, map -> {:ok, conn} = chunk(conn, "event: #{ev}\ndata: #{Jason.encode!(map)}\n\n"); conn end
|
||||
conn = conn |> put_resp_content_type("text/event-stream") |> send_chunked(200)
|
||||
conn = sse.(conn, "message_start", %{type: "message_start", message: %{id: "msg_t", model: "claude-test", role: "assistant", content: []}})
|
||||
conn = sse.(conn, "content_block_start", %{type: "content_block_start", index: 0, content_block: %{type: "tool_use", id: "tu_1", name: "read", input: %{}}})
|
||||
conn = sse.(conn, "content_block_delta", %{type: "content_block_delta", index: 0, delta: %{type: "input_json_delta", partial_json: "{\"path\":\"x\"}"}})
|
||||
conn = sse.(conn, "content_block_stop", %{type: "content_block_stop", index: 0})
|
||||
conn = sse.(conn, "message_delta", %{type: "message_delta", delta: %{stop_reason: "tool_use"}, usage: %{output_tokens: 3}})
|
||||
sse.(conn, "message_stop", %{type: "message_stop"})
|
||||
else
|
||||
conn
|
||||
end
|
||||
|
||||
if conn.state == :chunked do
|
||||
conn
|
||||
else
|
||||
auth = case {get_req_header(conn, "x-api-key"), get_req_header(conn, "authorization")} do
|
||||
{["sk-good"], _} -> :ok
|
||||
{_, ["Bearer sk-good-oauth"]} -> if get_req_header(conn, "anthropic-beta") == ["oauth-2025-04-20"], do: :ok, else: :no_beta
|
||||
_ -> :bad
|
||||
end
|
||||
case auth do
|
||||
:ok ->
|
||||
last = conn.body_params["messages"] |> List.last()
|
||||
text = if is_binary(last["content"]) and String.contains?(last["content"], "[bikoret"), do: "CLEAN: the report says revenue rose.", else: "FRAMED: I want to be honest with you. The honest answer is that revenue rose."
|
||||
|
||||
@@ -49,9 +80,13 @@ defmodule VavPipeline.GateTest do
|
||||
send_resp(conn, 200, Jason.encode!(reply))
|
||||
end
|
||||
|
||||
:no_beta ->
|
||||
send_resp(conn, 400, Jason.encode!(%{"type" => "error", "error" => %{"type" => "invalid_request_error", "message" => "anthropic-beta required for oauth"}}))
|
||||
|
||||
_ ->
|
||||
send_resp(conn, 401, Jason.encode!(%{"type" => "error", "error" => %{"type" => "authentication_error", "message" => "invalid x-api-key"}}))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -284,10 +319,10 @@ defmodule VavPipeline.GateTest do
|
||||
assert {:error, :upstream_unavailable, "upstream anthropic unreachable: econnrefused"} = Gate.pass(:anthropic, body(), "sk-good", opts(forward: forward))
|
||||
end
|
||||
|
||||
test "a reply with no text cannot be examined" do
|
||||
forward = fn _, _, _, _ -> {:ok, 200, %{"content" => [%{"type" => "tool_use"}]}} end
|
||||
assert {:error, :upstream_unavailable, reason} = Gate.pass(:anthropic, body(), "sk-good", opts(forward: forward))
|
||||
assert reason =~ "no text"
|
||||
test "a reply with no text (tool use only) is delivered as it came; the object says no text" do
|
||||
forward = fn _, _, _, _ -> {:ok, 200, %{"model" => "claude-test", "content" => [%{"type" => "tool_use"}]}} end
|
||||
assert {:ok, %{status: 200, bedikah: nil, reply: %{"content" => [%{"type" => "tool_use"}]}, bikoret: %{verdict: "none", reason: "no text"}}} =
|
||||
Gate.pass(:anthropic, body(), "sk-good", opts(forward: forward))
|
||||
end
|
||||
|
||||
test "manna: one external call per upstream request; spent calls wait before any call is made" do
|
||||
@@ -352,6 +387,22 @@ defmodule VavPipeline.GateTest do
|
||||
assert {:waiting, :api_calls_exhausted} = Stream.pass(:anthropic, Map.put(body(), "stream", true), "sk-good", collect(), opts())
|
||||
end
|
||||
|
||||
test "a tool-only streamed turn gets no fringe block; the object says no text" do
|
||||
alias VavPipeline.Gate.Stream
|
||||
assert {:ok, %{status: 200, bedikah: nil, bikoret: %{verdict: "none", reason: "no text"}, emitter: chunks}} =
|
||||
Stream.pass(:anthropic, body() |> Map.put("stream", true) |> Map.put("max_tokens", 7), "sk-good", collect(), opts())
|
||||
names = Enum.join(chunks) |> String.split("\n\n", trim: true) |> Enum.map(&(&1 |> String.split("\n") |> hd()))
|
||||
assert names == ["event: message_start", "event: content_block_start", "event: content_block_delta", "event: content_block_stop", "event: message_delta", "event: message_stop"]
|
||||
assert Enum.join(chunks) =~ "\"bikoret\":{"
|
||||
end
|
||||
|
||||
test "sources keep the newest turns whole and drop the oldest past 1 MiB" do
|
||||
big = String.duplicate("x", 700_000)
|
||||
b = %{"messages" => [%{"role" => "user", "content" => big <> " one"}, %{"role" => "user", "content" => big <> " two"}, %{"role" => "user", "content" => "last"}]}
|
||||
ids = Shape.sources(:anthropic, b) |> Enum.map(& &1.id)
|
||||
assert ids == ["user_2", "user_3"]
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# The door
|
||||
# ------------------------------------------------------------
|
||||
@@ -392,6 +443,36 @@ defmodule VavPipeline.GateTest do
|
||||
assert Jason.decode!(conn.resp_body)["error"]["type"] == "authentication_error"
|
||||
end
|
||||
|
||||
test "an OAuth bearer with the courtyard key in X-Bikoret-Key is forwarded, anthropic-beta with it" do
|
||||
conn =
|
||||
conn(:post, "/gate/anthropic/v1/messages", Jason.encode!(body()))
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("x-bikoret-key", "test-token")
|
||||
|> put_req_header("authorization", "Bearer sk-good-oauth")
|
||||
|> put_req_header("anthropic-beta", "oauth-2025-04-20")
|
||||
|> Router.call(@opts)
|
||||
|
||||
# reached the model (which demanded the beta header) — then, with no Korban in this VM, 503
|
||||
assert conn.status == 503
|
||||
assert Jason.decode!(conn.resp_body)["error"]["code"] == "KORBAN_UNAVAILABLE"
|
||||
end
|
||||
|
||||
test "token counting is carried through under the caller's credential, unexamined" do
|
||||
conn =
|
||||
conn(:post, "/gate/anthropic/v1/messages/count_tokens", Jason.encode!(%{"model" => "x", "messages" => [%{"role" => "user", "content" => "hi"}]}))
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("authorization", "Bearer test-token")
|
||||
|> put_req_header("x-api-key", "sk-good")
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.status == 200
|
||||
assert Jason.decode!(conn.resp_body) == %{"input_tokens" => 42}
|
||||
end
|
||||
|
||||
test "the gate counts knocks at its own windows: 120 a minute, not the courtyard's 20" do
|
||||
assert VavPipeline.BigdeiKodesh.Mishmar.limits(:gate) == [minute: 120, hour: 2_000]
|
||||
end
|
||||
|
||||
test "an upstream 401 comes back as the upstream's own 401" do
|
||||
conn = post_gate(body(), upstream: "sk-bad")
|
||||
assert conn.status == 401
|
||||
|
||||
Reference in new issue
Block a user