From f19a5ea007baef4f8e51438eee6de72321e3af3b Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Thu, 15 Oct 2026 15:51:31 +0000 Subject: [PATCH] =?UTF-8?q?gate:=20Claude=20Code=20behind=20it=20=E2=80=94?= =?UTF-8?q?=20OAuth=20bearer,=20anthropic-beta,=20token=20counting,=20the?= =?UTF-8?q?=20gate's=20own=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KOHEN.md | 20 ++++ deploy/k8s/configmap.yaml | 6 +- deploy/k8s/service.yaml | 6 +- specs/bikoret_tamid.md | 24 +++-- .../lib/vav_pipeline/bigdei_kodesh/mishmar.ex | 7 +- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 65 +++++++++---- vav_pipeline/lib/vav_pipeline/doorpost.ex | 2 +- vav_pipeline/lib/vav_pipeline/gate.ex | 4 +- vav_pipeline/lib/vav_pipeline/gate/shape.ex | 9 ++ vav_pipeline/lib/vav_pipeline/gate/stream.ex | 5 +- .../lib/vav_pipeline/gate/upstream.ex | 8 +- vav_pipeline/test/fixtures/manna/ledger.json | 1 - vav_pipeline/test/gate_test.exs | 93 +++++++++++++++++-- 13 files changed, 207 insertions(+), 43 deletions(-) delete mode 100644 vav_pipeline/test/fixtures/manna/ledger.json diff --git a/KOHEN.md b/KOHEN.md index e1841be9..c98dae4a 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -593,6 +593,26 @@ the finding, and the second reply is delivered marked with both refs. Streaming clients (`"stream": true`, Claude Code among them) are carried through as they stream; the fringe arrives as the last text block and the `bikoret` object on the final event — no headers, no rebuke on a stream. + +**Claude Code behind the gate.** In the shell that starts Claude Code: + +```bash +export ANTHROPIC_BASE_URL=https://bikoret.ch/gate/anthropic +export ANTHROPIC_CUSTOM_HEADERS="X-Bikoret-Key: " +claude +``` + +With a subscription Claude Code authenticates with its own OAuth bearer, +which the gate forwards together with `anthropic-beta`; with +`ANTHROPIC_API_KEY` set it sends `x-api-key`, also forwarded. Every reply +with text ends with its fringe; tool-only turns pass unmarked. Token +counting (`/v1/messages/count_tokens`) is carried through unexamined. +A coding agent makes a request per tool call: the gate's watch allows +120 a minute and 2,000 an hour per key, and each request spends one +`api_calls_external` and one compute unit of the day's manna — raise +`MANNA_API_CALLS_EXTERNAL` and `MANNA_COMPUTE_UNITS` in the ConfigMap +for the days a session runs behind the gate. Its requests carry a large +context: the Ingress accepts 32 MiB. Each upstream call spends one `api_calls_external` of the day's manna (Day 4): a provision with 0 shuts the gate (503 MANNA_DEPLETED) while the bedikah by hand still runs. Streaming is refused (422). A non-200 diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index e7dabbac..81cc9fbd 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -16,9 +16,11 @@ data: # Friday (manna-cronjob.yaml); Friday's is doubled and stands through # Shabbat. The kohen sets the amounts here (Pekudei: from the half-shekel # fund); a change takes effect at the next writing. - MANNA_COMPUTE_UNITS: "1000" + # A Claude Code session behind the gate makes a request per tool call: + # raised 2026-10-15 from 1000 / 500 when the gate opened to it. + MANNA_COMPUTE_UNITS: "5000" MANNA_MEMORY_MB: "32768" - MANNA_API_CALLS_EXTERNAL: "500" + MANNA_API_CALLS_EXTERNAL: "5000" MANNA_STORAGE_DELTA_MB: "100" MANNA_SANHEDRIN_PANELS_MAX: "5" diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml index da77b863..d23a6149 100644 --- a/deploy/k8s/service.yaml +++ b/deploy/k8s/service.yaml @@ -57,9 +57,9 @@ metadata: # A 71-judge Sanhedrin may deliberate for ten minutes (Construction §2.2). nginx.ingress.kubernetes.io/proxy-read-timeout: "620" nginx.ingress.kubernetes.io/proxy-send-timeout: "620" - # The bedikah accepts 65,536 bytes of output and 1 MiB of sources; - # leave room for the envelope. - nginx.ingress.kubernetes.io/proxy-body-size: "2m" + # The bedikah accepts 65,536 bytes of output and 1 MiB of sources; a + # coding agent's request through the gate carries its whole context. + nginx.ingress.kubernetes.io/proxy-body-size: "32m" nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: ingressClassName: nginx diff --git a/specs/bikoret_tamid.md b/specs/bikoret_tamid.md index 2dd61354..74159e1d 100644 --- a/specs/bikoret_tamid.md +++ b/specs/bikoret_tamid.md @@ -443,9 +443,14 @@ POST /gate/openai/v1/chat/completions body: the OpenAI Chat Completions reques Headers: Authorization: Bearer (or X-Bikoret-Key: ) x-api-key: forwarded; never stored, never logged + Authorization: Bearer with the courtyard key in X-Bikoret-Key + (what Claude Code sends with a subscription) Authorization: Bearer with the courtyard key in X-Bikoret-Key - anthropic-version: 2023-06-01 forwarded (default when absent) - X-Bikoret-Rebuke: once optional — hocheach tochiach + anthropic-version, anthropic-beta forwarded as sent (version defaults to 2023-06-01) + X-Bikoret-Rebuke: once optional — hocheach tochiach + +POST /gate/anthropic/v1/messages/count_tokens carried through under the caller's + credential; not a reply: not examined, no manna Response (200): the upstream's reply, with - the fringe appended to its last text block: @@ -482,11 +487,16 @@ reply is examined like the first (its record carries rebukes: ) and is the one delivered, marked with both. Never a third. Manna: one api_calls_external per upstream call, one compute unit per -examination; spent → 503 MANNA_DEPLETED before any call. Errors: 401/403 -(courtyard key), 422 (no upstream key, no messages, stream), 502 -UPSTREAM_UNAVAILABLE (the gate made no verdict and delivered nothing), -503 KORBAN/TALMUDIC_UNAVAILABLE (a 200 from the model is never delivered -unexamined). +examination; spent → 503 MANNA_DEPLETED before any call. The watch counts +the gate at its own windows — 120 a minute, 2,000 an hour per key — a +coding agent behind it makes a request per tool call. A reply with no +text (tool use only) is not a reply to a reader: delivered as it came, +no fringe, `bikoret.verdict: "none", reason: "no text"`. The sources +keep the newest user turns whole and drop the oldest past 1 MiB. Errors: +401/403 (courtyard key), 422 (no upstream credential, no messages, +stream with rebuke), 502 UPSTREAM_UNAVAILABLE (the gate made no verdict +and delivered nothing), 503 KORBAN/TALMUDIC_UNAVAILABLE (a 200 from the +model is never delivered unexamined). ``` **Anchoring is deterministic and lexical** (`korban_processor/bedikah.py`): diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex index d6bb2777..77915ce7 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex @@ -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 diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 30ce8eee..a4fad6da 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -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 diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index 93dec423..b844d9a8 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -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()] diff --git a/vav_pipeline/lib/vav_pipeline/gate.ex b/vav_pipeline/lib/vav_pipeline/gate.ex index b37055ab..5b706343 100644 --- a/vav_pipeline/lib/vav_pipeline/gate.ex +++ b/vav_pipeline/lib/vav_pipeline/gate.ex @@ -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) diff --git a/vav_pipeline/lib/vav_pipeline/gate/shape.ex b/vav_pipeline/lib/vav_pipeline/gate/shape.ex index e1d7c223..dc06d05d 100644 --- a/vav_pipeline/lib/vav_pipeline/gate/shape.ex +++ b/vav_pipeline/lib/vav_pipeline/gate/shape.ex @@ -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: [] diff --git a/vav_pipeline/lib/vav_pipeline/gate/stream.ex b/vav_pipeline/lib/vav_pipeline/gate/stream.ex index 6501a64a..89d13808 100644 --- a/vav_pipeline/lib/vav_pipeline/gate/stream.ex +++ b/vav_pipeline/lib/vav_pipeline/gate/stream.ex @@ -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) diff --git a/vav_pipeline/lib/vav_pipeline/gate/upstream.ex b/vav_pipeline/lib/vav_pipeline/gate/upstream.ex index a8c58003..61181165 100644 --- a/vav_pipeline/lib/vav_pipeline/gate/upstream.ex +++ b/vav_pipeline/lib/vav_pipeline/gate/upstream.ex @@ -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)}] diff --git a/vav_pipeline/test/fixtures/manna/ledger.json b/vav_pipeline/test/fixtures/manna/ledger.json deleted file mode 100644 index 3b70d713..00000000 --- a/vav_pipeline/test/fixtures/manna/ledger.json +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/vav_pipeline/test/gate_test.exs b/vav_pipeline/test/gate_test.exs index 1e8ea59d..aebc33ff 100644 --- a/vav_pipeline/test/gate_test.exs +++ b/vav_pipeline/test/gate_test.exs @@ -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