test/openagents/inference_test.exs

main at 58e6347eeb72 · 11 KB

defmodule OpenAgents.InferenceTest do
  use OpenAgents.DataCase, async: false
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]

  alias OpenAgents.Inference
  alias OpenAgents.Inference.Grant
  alias OpenAgents.Inference.{Models, Pricing}
  alias OpenAgents.Machines
  alias OpenAgents.Repo
  alias OpenAgents.UnpricedLane

  defp scope(key) do
    owner = github_user("inf-#{key}")
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)

    {:ok, %{code: code}} =
      Machines.start_pairing(%{
        "name" => "inf-box-#{key}",
        "tier" => "curated",
        "platform" => "linux-x64",
        "agent_version" => "0.1.0",
        "roots" => ["/home/x/code"]
      })

    {:ok, machine} = Machines.approve_pairing(owner, code)

    %{
      owner_visitor_id: conversation.visitor_id,
      conversation_id: conversation.id,
      machine_id: machine.id
    }
  end

  describe "mint/1" do
    test "mints an active grant, returns the plaintext once, stores only a digest" do
      input = scope("mint")
      {:ok, grant, token} = Inference.mint(input)

      assert grant.status == "active"
      assert grant.model_id == OpenAgents.Inference.Models.default_id()
      assert String.starts_with?(token, "sig_")
      assert grant.max_calls > 0
      # Only the digest is stored; the token is not recoverable from the row.
      refute grant.token_digest == token
      assert grant.token_digest == :crypto.hash(:sha256, token)
    end

    test "pins a named model, publishing the public id rather than the vendor one" do
      {:ok, grant, _token} =
        Inference.mint(Map.put(scope("mint-named"), :model_id, "glm-5.3-flash"))

      assert grant.model_id == "glm-5.3-flash"
    end

    test "refuses a model the proxy cannot route, naming only the model" do
      input = Map.put(scope("mint-unrouted"), :model_id, "attacker/gpt-9-ultra")

      assert {:error, changeset} = Inference.mint(input)
      assert [model_id: {sentence, _opts}] = changeset.errors
      assert sentence =~ "is not a model this proxy routes"
    end
  end

  describe "resolve/1" do
    test "resolves an active, in-budget grant" do
      {:ok, _grant, token} = Inference.mint(scope("resolve-ok"))
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(token)
    end

    test "fails closed for unknown, revoked, expired, and exhausted grants" do
      assert {:error, :grant_not_found} = Inference.resolve("sig_nope")

      {:ok, grant, token} = Inference.mint(scope("resolve-revoked"))
      {:ok, _} = Inference.revoke(grant)
      assert {:error, :grant_revoked} = Inference.resolve(token)

      # Mint already-expired via a negative TTL (expires_at is immutable once
      # written, per the trigger — the correct way to test expiry is at mint).
      previous = Application.fetch_env!(:openagents, :inference_grant_ttl_seconds)
      Application.put_env(:openagents, :inference_grant_ttl_seconds, -60)
      {:ok, expired, expired_token} = Inference.mint(scope("resolve-expired"))
      Application.put_env(:openagents, :inference_grant_ttl_seconds, previous)

      assert {:error, :grant_expired} = Inference.resolve(expired_token)
      # And it was transitioned to a terminal state, not left active.
      assert Repo.get(Grant, expired.id).status == "expired"
    end

    test "refuses an over-budget grant" do
      {:ok, grant, token} = Inference.mint(scope("resolve-budget"))
      # Meter enough to reach the token ceiling.
      {:ok, _} =
        Inference.record_usage(grant, %{
          "input_tokens" => grant.max_total_tokens,
          "output_tokens" => 0
        })

      assert {:error, :grant_exhausted} = Inference.resolve(token)
    end
  end

  describe "record_usage/2" do
    test "merges usage, prices cost, increments the call count" do
      {:ok, grant, _token} = Inference.mint(scope("usage"))
      {:ok, once} = Inference.record_usage(grant, %{"input_tokens" => 100, "output_tokens" => 40})

      assert once.call_count == 1
      assert once.usage["input_tokens"] == 100
      assert once.usage["output_tokens"] == 40
      assert once.usage["total_tokens"] == 140
      assert once.usage["estimated_cost_microusd"] >= 0
      assert once.usage["pricing_id"] == Pricing.pricing_id_for(grant.model_id)
      assert once.usage["schema"] == "sarah.inference_grant_usage.v1"

      {:ok, twice} = Inference.record_usage(once, %{"input_tokens" => 10, "output_tokens" => 5})
      assert twice.call_count == 2
      assert twice.usage["input_tokens"] == 110
      assert twice.usage["total_tokens"] == 155
    end

    test "records cache read input tokens when the provider reports them" do
      {:ok, grant, _token} = Inference.mint(scope("usage-cache"))

      {:ok, once} =
        Inference.record_usage(grant, %{
          "input_tokens" => 100,
          "output_tokens" => 40,
          "cache_read_input_tokens" => 20
        })

      assert once.usage["input_tokens"] == 100
      assert once.usage["output_tokens"] == 40
      assert once.usage["cache_read_input_tokens"] == 20
      assert once.usage["total_tokens"] == 140

      {:ok, twice} =
        Inference.record_usage(once, %{
          "input_tokens" => 10,
          "output_tokens" => 5,
          "cache_read_input_tokens" => 3
        })

      assert twice.usage["cache_read_input_tokens"] == 23
    end

    test "omits cache splits when the provider does not report them" do
      {:ok, grant, _token} = Inference.mint(scope("usage-no-cache"))

      {:ok, once} =
        Inference.record_usage(grant, %{"input_tokens" => 100, "output_tokens" => 40})

      refute Map.has_key?(once.usage, "cache_read_input_tokens")
      refute Map.has_key?(once.usage, "cache_write_input_tokens")
      assert once.usage["input_tokens"] == 100
      assert once.usage["total_tokens"] == 140
    end

    test "preserves existing cache read tokens when a later call reports none" do
      {:ok, grant, _token} = Inference.mint(scope("usage-cache-persist"))

      {:ok, once} =
        Inference.record_usage(grant, %{
          "input_tokens" => 100,
          "output_tokens" => 40,
          "cache_read_input_tokens" => 20
        })

      {:ok, twice} =
        Inference.record_usage(once, %{"input_tokens" => 10, "output_tokens" => 5})

      assert twice.usage["cache_read_input_tokens"] == 20
      refute Map.has_key?(twice.usage, "cache_write_input_tokens")
    end

    test "flips the grant to exhausted when a ceiling is reached" do
      {:ok, grant, _token} = Inference.mint(scope("usage-exhaust"))

      {:ok, exhausted} =
        Inference.record_usage(grant, %{
          "input_tokens" => grant.max_total_tokens + 1,
          "output_tokens" => 0
        })

      assert exhausted.status == "exhausted"
      assert exhausted.exhausted_at != nil
    end

    test "refuses to meter a terminal grant" do
      {:ok, grant, _token} = Inference.mint(scope("usage-terminal"))
      {:ok, revoked} = Inference.revoke(grant)
      assert {:error, :grant_not_active} = Inference.record_usage(revoked, %{"input_tokens" => 1})
    end

    test "prices cost from the model's declared rates, with cached reads at the cached rate" do
      {:ok, grant, _token} = Inference.mint(scope("usage-priced"))

      input = 100
      output = 40
      cache_read = 20

      {:ok, metered} =
        Inference.record_usage(grant, %{
          "input_tokens" => input,
          "output_tokens" => output,
          "cache_read_input_tokens" => cache_read
        })

      pricing = Pricing.effective_pricing(Models.default())

      expected =
        ((input - cache_read) * pricing.input_per_million_tokens +
           cache_read * pricing.cached_input_per_million_tokens +
           output * pricing.output_per_million_tokens)
        |> div(1_000_000)

      assert metered.usage["estimated_cost_microusd"] == expected
      # The record names the table it was priced against, so the figure can be
      # dereferenced rather than trusted (METER-001).
      assert metered.usage["pricing_id"] == pricing.id
    end

    test "an unpriced model records no estimated cost — and no zero" do
      # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn.
      # The behaviour it demonstrated is unchanged, so the lane is admitted
      # here for the length of this test rather than shipped for everyone.
      previous = UnpricedLane.admit!()
      on_exit(fn -> UnpricedLane.restore(previous) end)

      {:ok, grant, _token} =
        Inference.mint(Map.put(scope("usage-unpriced"), :model_id, UnpricedLane.id()))

      {:ok, metered} =
        Inference.record_usage(grant, %{
          "input_tokens" => 100,
          "output_tokens" => 40
        })

      refute Map.has_key?(metered.usage, "estimated_cost_microusd")
      refute metered.usage["estimated_cost_microusd"] == 0
      # The record says why there is no cost rather than leaving a reader to
      # infer it from a missing key.
      assert metered.usage["pricing_id"] == "unpriced"
      # Tokens are still measured. Unpriced is not unmetered: the call is
      # evidenced, only its price is unknown.
      assert metered.usage["total_tokens"] == 140
    end
  end

  describe "fences" do
    test "mint/1 accepts a thread fence and names no conversation" do
      owner = github_user("inf-thread")
      {:ok, thread} = OpenAgents.Threads.open(owner, "Reach a model from a thread")

      {:ok, grant, token} =
        Inference.mint(%{
          owner_visitor_id: thread.owner_visitor_id,
          thread_id: thread.id,
          machine_id: nil
        })

      assert grant.thread_id == thread.id
      assert grant.conversation_id == nil
      assert {:ok, %Grant{status: "active"}} = Inference.resolve(token)
    end

    test "mint/1 refuses a grant naming both fences or neither" do
      input = scope("inf-both")

      assert {:error, changeset} =
               Inference.mint(Map.put(input, :thread_id, Ecto.UUID.generate()))

      assert %{thread_id: _} = errors_on(changeset)

      assert {:error, changeset} = Inference.mint(Map.delete(input, :conversation_id))
      assert %{thread_id: _} = errors_on(changeset)
    end

    test "revoke_active_for_thread revokes every active grant for one thread" do
      owner = github_user("inf-thread-fence")
      {:ok, thread} = OpenAgents.Threads.open(owner, "Fence a thread")

      {:ok, _grant, token} =
        Inference.mint(%{
          owner_visitor_id: thread.owner_visitor_id,
          thread_id: thread.id,
          machine_id: nil
        })

      assert {1, nil} = Inference.revoke_active_for_thread(thread.id)
      assert {:error, :grant_revoked} = Inference.resolve(token)
      assert {0, nil} = Inference.revoke_active_for_thread(nil)
    end
  end

  describe "generation fence" do
    test "revoke_active_for_conversation revokes every active grant for one conversation" do
      scope = scope("fence")
      {:ok, _g1, t1} = Inference.mint(scope)
      {:ok, _g2, t2} = Inference.mint(scope)

      {count, _} = Inference.revoke_active_for_conversation(scope.conversation_id)
      assert count == 2
      assert {:error, :grant_revoked} = Inference.resolve(t1)
      assert {:error, :grant_revoked} = Inference.resolve(t2)
    end
  end

  describe "immutability" do
    test "a terminal grant cannot be updated (database trigger)" do
      {:ok, grant, _token} = Inference.mint(scope("immutable"))
      {:ok, revoked} = Inference.revoke(grant)

      assert_raise Postgrex.Error, ~r/terminal/, fn ->
        revoked
        |> Ecto.Changeset.change(call_count: 99)
        |> Repo.update()
      end
    end
  end
end