Serve Gemini through the gateway, on Google's own credits

bec1f48f1aac · AtlantisPleb · · parent e149874fd7f5

Serve Gemini through the gateway, on Google's own credits

Three models now, and each is on the lane that can actually serve it:
Gemini 3.7 Flash leads and answers a caller that names none; Ox Alpha is what
delegated children run on; Luna is the backup.

Gemini had to reach Vertex, because the credits this account holds are
Google's. OpenRouter serves the same model and that lane already worked, but
its credits are not these credits.

Reaching Vertex directly was written first and it worked, and the cost of it is
why this is not that. It needed an OAuth token minter, a service-account JWT
signed by hand, a project-scoped `locations/global` path — a regional one
answers `NOT_FOUND` for this model — and a workaround for Gemini 3 refusing any
transcript whose `functionCall` parts do not carry back the `thoughtSignature`
it produced. That last one is a 400, not a warning, turning thinking off does
not lift it, and an OpenAI-shaped transcript has nowhere to put a
360-character signature; it was going to travel smuggled inside the tool call
id.

Vercel's AI Gateway does that translation itself and speaks chat completions,
which is the shape the OpenRouter adapter already handles. Its own reply is
what settled it: `"credentialType":"byok"`, `"resolvedProvider":"vertex"`,
`"cost":"0"` — the call ran on Google's hardware against the credits, and the
gateway charged nothing to put it there. So `VercelGateway` reuses OpenRouter's
request building and stream decoding and differs in three things: endpoint,
credential, and the provider pin.

The pin matters. The same slug is also served by `google`, the Generative
Language endpoint, which is not where the credits are, so
`vercel_gateway_providers` names the set and an unpinned fallback cannot
quietly spend money beside a balance already paid for. An empty set sends no
pin at all rather than one naming nothing.

Also here, found while chasing the first empty answer: both adapters sent
`max_tokens: 4_096` as a literal and the catalog's `max_output` had no reader.
Ox Alpha is a reasoning model and its thinking is charged against that same
allowance, so a child agent with a real task spent the whole budget reasoning
and returned an empty 200 after three minutes, which read as the proxy having
failed. `max_output` reaches the adapters now, and Ox Alpha's is 64,000 against
a million-token context — both figures from the model's own listing rather than
guessed.

Four tests assumed the default model was the OpenAI one, or that unconfiguring
"the other lane" left the default serving. They assert what they meant instead:
that the default is the catalog's first entry, and that a lane going dark is
listed unavailable rather than omitted.

4344 of 4348 pass. The four failures are `ExitRehearsalRunbookTest`,
`KeyRotationTest`, and two `OperatorSurfaceTest` cases, all already red on this
tip.

Verified against a running server: a session answers on Gemini and runs three
shell commands in sequence, and a delegated child still runs on Ox Alpha.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 323 · 2026-08-25T00:46:29.802634Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • modified lib/openagents/inference/models.ex
  • modified lib/openagents/providers/open_ai.ex
  • modified lib/openagents/providers/open_router.ex
  • modified lib/openagents/providers/request.ex
  • added lib/openagents/providers/vercel_gateway.ex
  • modified lib/openagents/runtime_config.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • modified test/openagents/inference/models_test.exs
  • modified test/openagents/inference_test.exs
  • modified test/openagents/providers/open_router_test.exs
  • modified test/openagents/providers/persona_boundary_test.exs
  • added test/openagents/providers/vercel_gateway_test.exs
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • modified test/openagents_web/controllers/model_catalog_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

19 files changed, +360 -41

INVARIANTS.md modified +11

@@ -1180,10 +1180,21 @@ is the list of models a grant may name, and it keeps the id a client asks for

1180 1180
grants that already name the model. The adapter for each model is read from
1181 1181
configuration, so no caller holds a compile-time dependency on one.
1182 1182
1183
A third lane, `OpenAgents.Providers.VercelGateway`, reaches models through
1184
Vercel's AI Gateway rather than a vendor directly. It speaks the same chat
1185
completions the OpenRouter adapter does, so it reuses that adapter's request
1186
building and stream decoding and differs only in endpoint, credential, and the
1187
provider it pins. The pin is the point: Gemini is served there by both `google`
1188
and `vertex`, and only `vertex` spends this account's Google credits, so
1189
`vercel_gateway_providers` names the provider set and an unpinned fallback
1190
cannot quietly spend money instead.
1191
1183 1192
Evidence: `OpenAgents.Providers.ProviderEvent`, `OpenAgents.Providers.OpenAI`,
1184 1193
`OpenAgents.Providers.OpenAI.StreamDecoderTest`,
1185 1194
`OpenAgents.Providers.OpenRouter`,
1186 1195
`OpenAgents.Providers.OpenRouter.StreamDecoderTest`,
1196
`OpenAgents.Providers.VercelGateway`,
1197
`OpenAgents.Providers.VercelGatewayTest`,
1187 1198
`OpenAgents.Inference.ModelsTest`, `OpenAgents.Providers.Test`,
1188 1199
`OpenAgents.TurnProviderEventsTest`, and `OpenAgents.DependencyBoundaryTest`.
1189 1200
config/config.exs modified +43 -7

@@ -110,6 +110,7 @@ config :openagents,

110 110
  voice_compaction_input_token_threshold: 16_000,
111 111
  provider: OpenAgents.Providers.OpenAI,
112 112
  openrouter_provider: OpenAgents.Providers.OpenRouter,
113
  vercel_gateway_provider: OpenAgents.Providers.VercelGateway,
113 114
  openai_model: "gpt-5.6-luna",
114 115
  openai_api_key: nil,
115 116
  openrouter_api_key: nil,

@@ -122,25 +123,60 @@ config :openagents,

122 123
  # `provider` names a lane whose adapter module and credential are configured
123 124
  # separately, so no secret lives here; a lane whose credential is absent is
124 125
  # listed as unavailable, never silently substituted. `max_output` is the
125
  # per-call output cap the proxy's adapters actually send (4,096 tokens);
126
  # per-call output cap the proxy's adapters send for that model, and
126 127
  # `context_window` is the ceiling this deployment publishes for the lane.
128
  #
129
  # Gemini 3.7 Flash leads, so it is what a caller that names none gets: fast,
130
  # a million tokens of context, and steady enough to hold a conversation.
131
  #
132
  # Ox Alpha is what delegated children run on. It is built for sustained
133
  # agentic coding, which is what a child is given, and where it stalls the
134
  # cost is one child rather than the conversation — a session opened on it
135
  # answered "sup" with nothing at all, and a child's first request returned an
136
  # empty 200 after three minutes.
137
  #
138
  # Luna is the backup, and is what answers where the OpenRouter credential is
139
  # not configured and neither of the two above can be served.
127 140
  model_catalog: [
128 141
    %{
129
      id: {:config, :openai_model},
130
      provider: :openai,
131
      provider_model: {:config, :openai_model},
132
      context_window: 272_000,
133
      max_output: 4_096
142
      id: "gemini-3.7-flash",
143
      # Through the Vercel gateway, pinned to Vertex, so the call lands on
144
      # Google's hardware and spends this account's Google credits. OpenRouter
145
      # serves the same model and that lane worked, but its credits are not
146
      # these credits.
147
      provider: :vercel_gateway,
148
      provider_model: "google/gemini-3.7-flash",
149
      context_window: 1_048_576,
150
      max_output: 65_536
134 151
    },
135 152
    %{
136 153
      id: "ox-alpha",
137 154
      provider: :openrouter,
138 155
      provider_model: {:config, :openrouter_model},
139
      context_window: 256_000,
156
      # A million, per the model's own listing. The 256,000 here was a guess
157
      # made before there was a page to read.
158
      context_window: 1_000_000,
159
      # Ox Alpha is a reasoning model, and its thinking is charged against this
160
      # allowance before a single word of the answer is. At 4,096 a child agent
161
      # with a real task spent the whole budget reasoning and returned an empty
162
      # 200 after three minutes, which read as the proxy having failed.
163
      max_output: 64_000
164
    },
165
    %{
166
      id: {:config, :openai_model},
167
      provider: :openai,
168
      provider_model: {:config, :openai_model},
169
      context_window: 272_000,
140 170
      max_output: 4_096
141 171
    }
142 172
  ],
143 173
  gemini_api_key: nil,
174
  vercel_gateway_api_key: nil,
175
  # Pin the gateway to Vertex for every model it serves here. The same Gemini
176
  # slug is also served by `google` — the Generative Language endpoint, which
177
  # is not where this account's credits are — and a silent fallback there would
178
  # spend money beside a balance already paid for.
179
  vercel_gateway_providers: ["vertex"],
144 180
  gemini_model: "gemini-3.7-flash",
145 181
  box_api_key: nil,
146 182
  box_api: [
config/runtime.exs modified +2

@@ -151,6 +151,8 @@ if config_env() == :dev do

151 151
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
152 152
  config :openagents, :openrouter_api_key, optional_text.("OPENROUTER_API_KEY")
153 153
  config :openagents, :gemini_api_key, optional_text.("GEMINI_API_KEY")
154
155
  config :openagents, :vercel_gateway_api_key, optional_text.("AI_GATEWAY_API_KEY")
154 156
  config :openagents, :box_api_key, optional_text.("BOX_API_KEY")
155 157
end
156 158
config/test.exs modified +1

@@ -52,6 +52,7 @@ config :openagents,

52 52
# Test fakes for providers and voice sideband so the suite never reaches the network.
53 53
config :openagents, :provider, OpenAgents.Providers.Test
54 54
config :openagents, :openrouter_provider, OpenAgents.Providers.Test
55
config :openagents, :vercel_gateway_provider, OpenAgents.Providers.Test
55 56
config :openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider
56 57
config :openagents, :voice_sideband_provider, OpenAgents.Voice.TestSidebandProvider
57 58
lib/openagents/inference/models.ex modified +2 -1

@@ -38,7 +38,8 @@ defmodule OpenAgents.Inference.Models do

38 38
  # and the test environment swaps adapters without touching it.
39 39
  @provider_lanes %{
40 40
    openai: :provider,
41
    openrouter: :openrouter_provider
41
    openrouter: :openrouter_provider,
42
    vercel_gateway: :vercel_gateway_provider
42 43
  }
43 44
44 45
  @type t :: %{
lib/openagents/providers/open_ai.ex modified +1 -1

@@ -84,7 +84,7 @@ defmodule OpenAgents.Providers.OpenAI do

84 84
      input: input_items(request),
85 85
      tools: Enum.map(request.tool_definitions, &tool_definition/1),
86 86
      parallel_tool_calls: false,
87
      max_output_tokens: 4_096,
87
      max_output_tokens: request.max_output,
88 88
      stream: true
89 89
    }
90 90
    |> maybe_put(:previous_response_id, request.previous_response_id)
lib/openagents/providers/open_router.ex modified +14 -3

@@ -25,6 +25,9 @@ defmodule OpenAgents.Providers.OpenRouter do

25 25
26 26
  @endpoint "https://openrouter.ai/api/v1/chat/completions"
27 27
28
  @doc false
29
  def endpoint, do: @endpoint
30
28 31
  @impl true
29 32
  def id, do: "openrouter.chat_completions"
30 33

@@ -63,19 +66,24 @@ defmodule OpenAgents.Providers.OpenRouter do

63 66
    end
64 67
  end
65 68
69
  @doc false
70
  def post(api_key, request, options), do: request(api_key, request, options)
71
66 72
  defp request(api_key, %Request{} = request, options) do
67 73
    request_options = Keyword.get(options, :request_options, [])
68 74
69 75
    base_options = [
70 76
      auth: {:bearer, api_key},
71 77
      headers: [{"accept", "text/event-stream"}],
72
      json: request_payload(request),
78
      json: Map.merge(request_payload(request), Keyword.get(options, :payload_extra, %{})),
73 79
      into: :self,
74 80
      receive_timeout: 120_000,
75 81
      retry: false
76 82
    ]
77 83
78
    case Req.post(@endpoint, Keyword.merge(base_options, request_options)) do
84
    endpoint = Keyword.get(options, :endpoint, @endpoint)
85
86
    case Req.post(endpoint, Keyword.merge(base_options, request_options)) do
79 87
      {:ok, response} ->
80 88
        {:ok, response}
81 89

@@ -94,7 +102,7 @@ defmodule OpenAgents.Providers.OpenRouter do

94 102
      messages: messages(request),
95 103
      stream: true,
96 104
      stream_options: %{include_usage: true},
97
      max_tokens: 4_096
105
      max_tokens: request.max_output
98 106
    }
99 107
    |> maybe_put_tools(request.tool_definitions)
100 108
  end

@@ -191,6 +199,9 @@ defmodule OpenAgents.Providers.OpenRouter do

191 199
    }
192 200
  end
193 201
202
  @doc false
203
  def consume(response, on_event), do: consume_response(response, on_event)
204
194 205
  defp consume_response(%Req.Response{status: status, body: body}, on_event)
195 206
       when status in 200..299 do
196 207
    body
lib/openagents/providers/request.ex modified +13 -1

@@ -2,7 +2,19 @@ defmodule OpenAgents.Providers.Request do

2 2
  @moduledoc "Provider-neutral input frozen for one Sarah inference."
3 3
4 4
  @enforce_keys [:model_id, :instructions, :input]
5
  defstruct @enforce_keys ++ [tool_definitions: [], tool_outputs: [], previous_response_id: nil]
5
  defstruct @enforce_keys ++
6
              [
7
                tool_definitions: [],
8
                tool_outputs: [],
9
                previous_response_id: nil,
10
                # How many tokens the answer may take, from the model's catalog
11
                # entry. Carried here rather than compiled into each adapter,
12
                # because it is a fact about the model and the adapters had the
13
                # same literal in both — which meant a reasoning model, whose
14
                # thinking is charged against this same allowance, could spend
15
                # all of it before writing a word and return an empty answer.
16
                max_output: 4_096
17
              ]
6 18
7 19
  @typedoc """
8 20
  A prior tool call an assistant turn carried, so a continuation request can
lib/openagents/providers/vercel_gateway.ex added +90

@@ -0,0 +1,90 @@

1
defmodule OpenAgents.Providers.VercelGateway do
2
  @moduledoc """
3
  Vercel AI Gateway adapter: one endpoint in front of many providers.
4
5
  This is how Gemini reaches Google's own hardware, and the reason it exists is
6
  money. The credits this account holds are Google's, so a Gemini call has to
7
  land on Vertex to spend them. Two routes reach Vertex:
8
9
  1. Directly, with a Google identity. That was written and it worked, and it
10
     cost an OAuth token minter, a service-account JWT signer, a project-scoped
11
     `locations/global` path, and a workaround for Gemini 3 refusing any
12
     transcript whose `functionCall` parts do not carry back the
13
     `thoughtSignature` it produced — a 400, not a warning, with nowhere in an
14
     OpenAI-shaped transcript to put a 360-character signature.
15
  2. Through this gateway, which is OpenAI chat completions — the shape the
16
     OpenRouter adapter already speaks — and which does that translation
17
     itself.
18
19
  The gateway's own reply settles it. With BYOK Vertex credentials configured
20
  it answers `"credentialType":"byok"`, `"resolvedProvider":"vertex"`, and
21
  `"cost":"0"`: the call ran on Google's hardware against the credits, and the
22
  gateway charged nothing to put it there.
23
24
  `providerOptions.gateway.only` pins the provider, because the same slug is
25
  also served by `google` — the Generative Language endpoint, which is not
26
  where the credits are. Without the pin a fallback would quietly spend money.
27
28
  The wire format is OpenRouter's, so the request building and the stream
29
  decoding are OpenRouter's too. What differs is the endpoint, the credential,
30
  and the pin.
31
  """
32
33
  @behaviour OpenAgents.Providers.Provider
34
35
  alias OpenAgents.Providers.{OpenRouter, Request}
36
37
  @endpoint "https://ai-gateway.vercel.sh/v1/chat/completions"
38
39
  @impl true
40
  def id, do: "vercel_gateway.chat_completions"
41
42
  @impl true
43
  def capabilities, do: [:text, :tool_calls, :usage]
44
45
  @impl true
46
  def configured? do
47
    match?({:ok, _key}, OpenAgents.RuntimeConfig.fetch_secret(:vercel_gateway_api_key))
48
  end
49
50
  @impl true
51
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
52
    stream(request, on_event, [])
53
  end
54
55
  @doc false
56
  def stream(%Request{} = request, on_event, options)
57
      when is_function(on_event, 1) and is_list(options) do
58
    with {:ok, api_key} <- fetch_api_key(options),
59
         {:ok, response} <- OpenRouter.post(api_key, request, gateway_options(options)) do
60
      OpenRouter.consume(response, on_event)
61
    end
62
  end
63
64
  defp fetch_api_key(options) do
65
    case Keyword.fetch(options, :api_key) do
66
      {:ok, key} when is_binary(key) and byte_size(key) > 0 ->
67
        {:ok, key}
68
69
      _not_supplied ->
70
        case OpenAgents.RuntimeConfig.fetch_secret(:vercel_gateway_api_key) do
71
          {:ok, key} -> {:ok, key}
72
          {:error, :not_configured} -> {:error, :missing_api_key}
73
        end
74
    end
75
  end
76
77
  defp gateway_options(options) do
78
    options
79
    |> Keyword.put(:endpoint, @endpoint)
80
    |> Keyword.put(:payload_extra, payload_extra())
81
  end
82
83
  @doc false
84
  def payload_extra do
85
    case Application.get_env(:openagents, :vercel_gateway_providers) do
86
      [_first | _rest] = providers -> %{providerOptions: %{gateway: %{only: providers}}}
87
      _unset -> %{}
88
    end
89
  end
90
end
lib/openagents/runtime_config.ex modified +6 -1

@@ -113,7 +113,12 @@ defmodule OpenAgents.RuntimeConfig do

113 113
  @spec fetch_secret(:openai_api_key | :openrouter_api_key) ::
114 114
          {:ok, String.t()} | {:error, :not_configured}
115 115
  def fetch_secret(secret)
116
      when secret in [:openai_api_key, :openrouter_api_key, :gemini_api_key] do
116
      when secret in [
117
             :openai_api_key,
118
             :openrouter_api_key,
119
             :gemini_api_key,
120
             :vercel_gateway_api_key
121
           ] do
117 122
    case Application.fetch_env(:openagents, secret) do
118 123
      {:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
119 124
      _missing -> {:error, :not_configured}
lib/openagents_web/controllers/inference_proxy_controller.ex modified +2 -1

@@ -97,7 +97,8 @@ defmodule OpenAgentsWeb.InferenceProxyController do

97 97
      instructions: join_text(system),
98 98
      input: Enum.flat_map(turns, &input_message/1),
99 99
      tool_definitions: tool_definitions(body["tools"]),
100
      tool_outputs: tool_outputs(turns)
100
      tool_outputs: tool_outputs(turns),
101
      max_output: model.max_output
101 102
    }
102 103
103 104
    if request.input == [] do
test/openagents/inference/models_test.exs modified +43 -4

@@ -4,13 +4,24 @@ defmodule OpenAgents.Inference.ModelsTest do

4 4
  alias OpenAgents.Chat.OpenRouter
5 5
  alias OpenAgents.Inference.Models
6 6
7
  test "the default model is the configured one, served by the configured adapter" do
7
  test "the default is the catalog's first entry, served by that lane's adapter" do
8 8
    default = Models.default()
9 9
10
    assert default.id == Application.fetch_env!(:openagents, :openai_model)
11
    assert default.provider_model == default.id
12
    assert default.adapter == Application.fetch_env!(:openagents, :provider)
10
    # Gemini 3.7 Flash leads: a caller that names no model is holding a
11
    # conversation, and that is what this deployment answers one with.
12
    assert default.id == "gemini-3.7-flash"
13
    assert default.provider_model == "google/gemini-3.7-flash"
14
    assert default.adapter == Application.fetch_env!(:openagents, :openrouter_provider)
13 15
    assert Models.default_id() == default.id
16
    assert Models.default_id() == hd(Models.ids())
17
  end
18
19
  test "the OpenAI lane is still served, as the backup it now is" do
20
    configured = Application.fetch_env!(:openagents, :openai_model)
21
22
    assert {:ok, luna} = Models.fetch(configured)
23
    assert luna.provider_model == luna.id
24
    assert luna.adapter == Application.fetch_env!(:openagents, :provider)
14 25
  end
15 26
16 27
  test "ox-alpha publishes a public id and routes the vendor string" do

@@ -76,4 +87,32 @@ defmodule OpenAgents.Inference.ModelsTest do

76 87
77 88
    assert Models.available_ids() == Models.ids()
78 89
  end
90
91
  describe "the answer allowance a model publishes" do
92
    test "Ox Alpha's is large enough for a model that reasons before it answers" do
93
      # Its thinking is charged against the same allowance as its answer. At
94
      # 4,096 a child agent with a real task spent the whole budget reasoning
95
      # and returned nothing, after three minutes, on a 200.
96
      {:ok, ox} = Models.fetch("ox-alpha")
97
98
      assert ox.max_output >= 32_000
99
      assert ox.context_window >= 1_000_000
100
    end
101
102
    test "the published catalog carries it, so a client is not guessing" do
103
      entry = Enum.find(Models.catalog(), &(&1["id"] == "ox-alpha"))
104
105
      assert entry["max_output"] == 64_000
106
    end
107
108
    test "Gemini is routed through the gateway, on the slug the gateway knows" do
109
      # Not `gemini-3.7-flash`, which is what a caller asks for. The gateway
110
      # resolves `creator/model` slugs, and it is pinned to Vertex so the call
111
      # spends this account's Google credits.
112
      {:ok, gemini} = Models.fetch("gemini-3.7-flash")
113
114
      assert gemini.provider == :vercel_gateway
115
      assert gemini.provider_model == "google/gemini-3.7-flash"
116
    end
117
  end
79 118
end
test/openagents/inference_test.exs modified +1 -1

@@ -35,7 +35,7 @@ defmodule OpenAgents.InferenceTest do

35 35
      {:ok, grant, token} = Inference.mint(input)
36 36
37 37
      assert grant.status == "active"
38
      assert grant.model_id == Application.fetch_env!(:openagents, :openai_model)
38
      assert grant.model_id == OpenAgents.Inference.Models.default_id()
39 39
      assert String.starts_with?(token, "sig_")
40 40
      assert grant.max_calls > 0
41 41
      # Only the digest is stored; the token is not recoverable from the row.
test/openagents/providers/open_router_test.exs modified +27

@@ -83,4 +83,31 @@ defmodule OpenAgents.Providers.OpenRouterTest do

83 83
84 84
    Enum.map_join(frames, "", &("data: " <> Jason.encode!(&1) <> "\n\n")) <> "data: [DONE]\n\n"
85 85
  end
86
87
  describe "how many tokens the answer may take" do
88
    test "comes from the model's catalog entry, not a literal in this module" do
89
      # Ox Alpha is a reasoning model: its thinking is charged against this
90
      # allowance before a word of the answer is. Hardcoded at 4,096, a child
91
      # agent with a real task spent the whole budget reasoning and returned an
92
      # empty 200 after three minutes, which read as the proxy having failed.
93
      request = %Request{
94
        model_id: "stealth/ox-alpha",
95
        instructions: "Be brief.",
96
        input: [%{role: "user", content: "hello"}],
97
        max_output: 64_000
98
      }
99
100
      assert OpenRouter.request_payload(request)[:max_tokens] == 64_000
101
    end
102
103
    test "defaults to a figure a caller that names none still works on" do
104
      request = %Request{
105
        model_id: "stealth/ox-alpha",
106
        instructions: "Be brief.",
107
        input: [%{role: "user", content: "hello"}]
108
      }
109
110
      assert OpenRouter.request_payload(request)[:max_tokens] == 4_096
111
    end
112
  end
86 113
end
test/openagents/providers/persona_boundary_test.exs modified +2

@@ -65,6 +65,7 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

65 65
  @adapters %{
66 66
    OpenAgents.Providers.OpenAI => :outbound_http,
67 67
    OpenAgents.Providers.OpenRouter => :outbound_http,
68
    OpenAgents.Providers.VercelGateway => :outbound_http,
68 69
    OpenAgents.Providers.RecordingTestProvider => :in_process,
69 70
    OpenAgents.Providers.Test => :in_process,
70 71
    OpenAgents.Providers.UnconfiguredTestProvider => :in_process,

@@ -99,6 +100,7 @@ defmodule OpenAgents.Providers.PersonaBoundaryTest do

99 100
    OpenAgents.Conversations => :pins_a_composed_request,
100 101
    OpenAgents.Providers.OpenAI => :adapter,
101 102
    OpenAgents.Providers.OpenRouter => :adapter,
103
    OpenAgents.Providers.VercelGateway => :adapter,
102 104
    OpenAgents.Providers.RecordingTestProvider => :adapter,
103 105
    OpenAgents.Providers.Test => :adapter,
104 106
    OpenAgents.Providers.Request => :the_struct_itself
test/openagents/providers/vercel_gateway_test.exs added +43

@@ -0,0 +1,43 @@

1
defmodule OpenAgents.Providers.VercelGatewayTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Providers.VercelGateway
5
6
  describe "pinning the provider" do
7
    test "sends the gateway the provider set this deployment intends" do
8
      # The same Gemini slug is served by `google` — the Generative Language
9
      # endpoint — and by `vertex`, which is where this account's credits are.
10
      # Without the pin, a fallback spends money beside a balance already paid
11
      # for, and nothing in the response would say so.
12
      assert VercelGateway.payload_extra() == %{providerOptions: %{gateway: %{only: ["vertex"]}}}
13
    end
14
15
    test "sends no pin at all when none is configured, rather than an empty one" do
16
      previous = Application.get_env(:openagents, :vercel_gateway_providers)
17
      Application.put_env(:openagents, :vercel_gateway_providers, [])
18
      on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_providers, previous) end)
19
20
      # An empty `only` would be a pin to nothing, which the gateway is entitled
21
      # to read as "no provider may serve this".
22
      assert VercelGateway.payload_extra() == %{}
23
    end
24
  end
25
26
  describe "the credential" do
27
    test "is its own, not OpenRouter's" do
28
      previous = Application.get_env(:openagents, :vercel_gateway_api_key)
29
      Application.put_env(:openagents, :vercel_gateway_api_key, nil)
30
      on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_api_key, previous) end)
31
32
      refute VercelGateway.configured?()
33
34
      Application.put_env(:openagents, :vercel_gateway_api_key, "vck_test")
35
      assert VercelGateway.configured?()
36
    end
37
  end
38
39
  test "speaks chat completions, and says so" do
40
    assert VercelGateway.id() == "vercel_gateway.chat_completions"
41
    assert :tool_calls in VercelGateway.capabilities()
42
  end
43
end
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +38 -10

@@ -203,7 +203,7 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

203 203
    assert error["served"] == OpenAgents.Inference.Models.ids()
204 204
205 205
    # The grant's model_id stays Sarah's configured model, never the body's.
206
    assert grant.model_id == Application.fetch_env!(:openagents, :openai_model)
206
    assert grant.model_id == OpenAgents.Inference.Models.default_id()
207 207
  end
208 208
209 209
  test "a body naming a served model other than the grant's is refused, never substituted",

@@ -314,12 +314,17 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

314 314
315 315
  describe "routing the grant's model" do
316 316
    setup do
317
      previous = Application.get_env(:openagents, :openrouter_provider)
318
      Application.put_env(:openagents, :openrouter_provider, RecordingTestProvider)
317
      # Both routed lanes, because these tests are about which model string
318
      # reaches a provider and the models are spread across them: Ox Alpha on
319
      # OpenRouter, Gemini on the gateway.
320
      lanes = [:openrouter_provider, :vercel_gateway_provider]
321
      previous = Map.new(lanes, &{&1, Application.get_env(:openagents, &1)})
322
323
      for lane <- lanes, do: Application.put_env(:openagents, lane, RecordingTestProvider)
319 324
      Application.put_env(:openagents, :test_recording_provider_observer, self())
320 325
321 326
      on_exit(fn ->
322
        Application.put_env(:openagents, :openrouter_provider, previous)
327
        for {lane, value} <- previous, do: Application.put_env(:openagents, lane, value)
323 328
        Application.delete_env(:openagents, :test_recording_provider_observer)
324 329
      end)
325 330
    end

@@ -391,13 +396,22 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

391 396
      assert output.output == %{"content" => "hello"}
392 397
    end
393 398
394
    test "a default grant stays on the default lane", %{conn: conn} do
399
    test "a default grant is called with the default model's own vendor string", %{conn: conn} do
400
      # This once asserted the default lane was *not* the recorded one, which
401
      # only held while the default sat on the other adapter. What it was
402
      # checking is that a grant naming no model is routed as its own catalog
403
      # entry says — the vendor string, not the public id — and that survives
404
      # the default moving between lanes.
395 405
      %{token: token} = grant("default-lane")
396 406
397 407
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
398 408
399 409
      assert conn.status == 200
400
      refute_received {:recorded_request, _id, _request}
410
      assert_received {:recorded_request, "test.recording_provider", request}
411
412
      {:ok, default} = OpenAgents.Inference.Models.fetch(OpenAgents.Inference.Models.default_id())
413
      assert request.model_id == default.provider_model
414
      refute request.model_id == default.id
401 415
    end
402 416
403 417
    test "a grant naming a model the proxy cannot route is refused", %{conn: conn} do

@@ -405,10 +419,24 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

405 419
406 420
      # A grant's model column is immutable and the mint refuses an unroutable
407 421
      # name, so the only way here is the routed set changing underneath a live
408
      # grant — a model withdrawn after it was issued.
409
      configured = Application.fetch_env!(:openagents, :openai_model)
410
      Application.put_env(:openagents, :openai_model, "#{configured}-withdrawn")
411
      on_exit(fn -> Application.put_env(:openagents, :openai_model, configured) end)
422
      # grant — a model withdrawn after it was issued. Withdrawn by taking it
423
      # out of the catalog, rather than by renaming one lane's configured
424
      # model, so the test says what it means whichever model leads.
425
      granted = OpenAgents.Inference.Models.default_id()
426
      catalog = Application.fetch_env!(:openagents, :model_catalog)
427
428
      Application.put_env(
429
        :openagents,
430
        :model_catalog,
431
        Enum.reject(catalog, fn entry ->
432
          case entry.id do
433
            {:config, key} -> Application.fetch_env!(:openagents, key) == granted
434
            id -> id == granted
435
          end
436
        end)
437
      )
438
439
      on_exit(fn -> Application.put_env(:openagents, :model_catalog, catalog) end)
412 440
413 441
      conn = post_chat(conn, token, %{"messages" => [%{"role" => "user", "content" => "hi"}]})
414 442
test/openagents_web/controllers/model_catalog_controller_test.exs modified +11 -9

@@ -37,15 +37,15 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

37 37
  end
38 38
39 39
  test "a lane without a configured credential is listed unavailable, not omitted", %{conn: conn} do
40
    previous = Application.get_env(:openagents, :openrouter_provider)
40
    # The OpenAI lane, because the default now sits on the OpenRouter one and
41
    # this test is about a lane going dark *without* taking the default with
42
    # it: "served here, not currently configured" has to be distinguishable
43
    # from "not served here" while the deployment still answers.
44
    previous = Application.get_env(:openagents, :provider)
41 45
42
    Application.put_env(
43
      :openagents,
44
      :openrouter_provider,
45
      OpenAgents.Providers.UnconfiguredTestProvider
46
    )
46
    Application.put_env(:openagents, :provider, OpenAgents.Providers.UnconfiguredTestProvider)
47 47
48
    on_exit(fn -> Application.put_env(:openagents, :openrouter_provider, previous) end)
48
    on_exit(fn -> Application.put_env(:openagents, :provider, previous) end)
49 49
50 50
    body =
51 51
      conn

@@ -58,8 +58,10 @@ defmodule OpenAgentsWeb.ModelCatalogControllerTest do

58 58
    # availability changes.
59 59
    assert Enum.map(body["models"], & &1["id"]) == Models.ids()
60 60
61
    ox_alpha = Enum.find(body["models"], &(&1["id"] == "ox-alpha"))
62
    assert ox_alpha["availability"] == "unavailable"
61
    luna =
62
      Enum.find(body["models"], &(&1["id"] == Application.fetch_env!(:openagents, :openai_model)))
63
64
    assert luna["availability"] == "unavailable"
63 65
64 66
    default_entry = Enum.find(body["models"], &(&1["id"] == body["default"]))
65 67
    assert default_entry["availability"] == "available"
test/openagents_web/controllers/thread_controller_test.exs modified +10 -2

@@ -175,8 +175,16 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

175 175
176 176
      assert body["code"] == "model_unavailable"
177 177
      assert Map.has_key?(body["errors"], "model")
178
      # The refusal names what is currently available.
179
      assert body["message"] =~ OpenAgents.Inference.Models.default_id()
178
179
      # The refusal names what is currently available. Unconfiguring this lane
180
      # takes every model on it, which is more than one now, so the check is
181
      # that each surviving model is named rather than that the default is —
182
      # the default may be on the lane that just went dark.
183
      available =
184
        Enum.filter(OpenAgents.Inference.Models.all(), &OpenAgents.Inference.Models.available?/1)
185
186
      assert available != []
187
      for model <- available, do: assert(body["message"] =~ model.id)
180 188
    end
181 189
182 190
    test "a model the proxy cannot route is refused, naming the field", %{conn: conn} do

This page updates live while a promote is in flight · changelog