okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Dylan Shepardfix(providers): harden cloud credentials and Vertex wire semantics794bda6

main
17.1 KiB484 linesraw
1//// Vertex's foreign wire options, raw tool schemas, malformed SSE fields,
2//// terminal-call safety and replay catch protocol regressions that the E2E
3//// OpenAI-shaped fake provider cannot exercise. These tests need neither
4//// Google credentials nor network calls; ADC tests cover shape selection only.
5
6import albedo/harness/extensions/vertex/auth
7import albedo/harness/extensions/vertex/stream
8import albedo/harness/extensions/vertex/wire
9import albedo/openai_api
10import albedo/openai_api/types
11import gleam/dynamic.{type Dynamic}
12import gleam/dynamic/decode
13import gleam/json
14import gleam/list
15import gleam/option.{None, Some}
16import gleam/result
17import gleam/string
18import gleam/string_tree
19
20const project = "my-project"
21
22const location = "us-central1"
23
24fn request(model: String, input: List(types.Input)) -> types.Request {
25  types.Request(model, None, input, [], Some(100), types.defaults)
26}
27
28fn gemini_request(input: List(types.Input)) -> types.Request {
29  request("gemini-2.5-flash", input)
30}
31
32fn body(input: List(types.Input)) -> Dynamic {
33  let assert Ok(openai_api.Exchange(body: body, ..)) =
34    wire.encode("token", project, location, gemini_request(input))
35  let assert Ok(value) = json.parse(string_tree.to_string(body), decode.dynamic)
36  value
37}
38
39fn at(value: Dynamic, path: List(String), decoder: decode.Decoder(a)) -> a {
40  let assert Ok(found) = decode.run(value, decode.at(path, decoder))
41  found
42}
43
44pub fn encode_rejects_models_outside_the_gemini_family_test() -> Nil {
45  let assert Error(types.InvalidRequest(message)) =
46    wire.encode(
47      "token",
48      project,
49      location,
50      request("claude-opus-5@default", [types.User("hi")]),
51    )
52  assert string.contains(message, "Gemini")
53}
54
55pub fn encode_builds_the_project_and_location_scoped_url_and_bearer_header_test() -> Nil {
56  let assert Ok(openai_api.Exchange(url: url, headers: headers, ..)) =
57    wire.encode(
58      "my-token",
59      project,
60      location,
61      gemini_request([types.User("hi")]),
62    )
63  assert url
64    == "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
65  let assert Ok(#(_, authorization)) =
66    list.find(headers, fn(h) { h.0 == "authorization" })
67  assert authorization == "Bearer my-token"
68}
69
70pub fn encode_uses_the_bare_host_for_the_global_location_test() -> Nil {
71  let assert Ok(openai_api.Exchange(url: url, ..)) =
72    wire.encode("t", project, "global", gemini_request([types.User("hi")]))
73  assert string.starts_with(url, "https://aiplatform.googleapis.com/")
74}
75
76pub fn tool_output_looks_up_its_calls_name_from_the_replayed_history_test() -> Nil {
77  let assert Ok(item) =
78    json.parse(
79      "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"{}\"}}]}",
80      types.replay_decoder(types.ChatCompletions),
81    )
82  let value =
83    body([
84      types.User("a"),
85      types.Replay(item),
86      types.ToolOutput("c1", "done", []),
87    ])
88  let assert [user, model, result] =
89    at(value, ["contents"], decode.list(decode.dynamic))
90  assert at(user, ["role"], decode.string) == "user"
91  assert at(model, ["role"], decode.string) == "model"
92  assert at(result, ["role"], decode.string) == "user"
93  let assert [part] = at(result, ["parts"], decode.list(decode.dynamic))
94  assert at(part, ["functionResponse", "name"], decode.string) == "bash"
95  assert at(part, ["functionResponse", "response", "output"], decode.string)
96    == "done"
97}
98
99pub fn tool_output_without_a_preceding_call_is_refused_test() -> Nil {
100  let assert Error(types.InvalidRequest(message)) =
101    wire.encode(
102      "t",
103      project,
104      location,
105      gemini_request([types.ToolOutput("missing", "x", [])]),
106    )
107  assert string.contains(message, "no preceding call")
108}
109
110pub fn replayed_calls_always_carry_the_foreign_signature_placeholder_test() -> Nil {
111  let assert Ok(item) =
112    json.parse(
113      "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"{}\"}}]}",
114      types.replay_decoder(types.ChatCompletions),
115    )
116  let value = body([types.User("a"), types.Replay(item)])
117  let assert [_, model] = at(value, ["contents"], decode.list(decode.dynamic))
118  let assert [call] = at(model, ["parts"], decode.list(decode.dynamic))
119  assert at(call, ["thoughtSignature"], decode.string)
120    == "skip_thought_signature_validator"
121}
122
123pub fn tool_choice_modes_map_to_function_calling_config_test() -> Nil {
124  let tool = types.Tool("bash", "run", json.object([]), False)
125  let named =
126    types.Request(
127      "gemini-2.5-flash",
128      None,
129      [types.User("hi")],
130      [tool],
131      Some(100),
132      types.Options(
133        ..types.defaults,
134        tool_choice: Some(types.NamedTool("bash")),
135      ),
136    )
137  let assert Ok(openai_api.Exchange(body: raw, ..)) =
138    wire.encode("t", project, location, named)
139  let assert Ok(value) = json.parse(string_tree.to_string(raw), decode.dynamic)
140  assert at(
141      value,
142      ["toolConfig", "functionCallingConfig", "mode"],
143      decode.string,
144    )
145    == "ANY"
146  assert at(
147      value,
148      ["toolConfig", "functionCallingConfig", "allowedFunctionNames"],
149      decode.list(decode.string),
150    )
151    == ["bash"]
152}
153
154/// Feeds every chunk, then ends the body: Gemini chunks carry no explicit
155/// terminal event, so `finish` (not `feed`) settles the turn.
156fn reduce(chunks: List(String)) -> Result(types.Turn, types.Error) {
157  let final =
158    list.fold(chunks, stream.reducer(), fn(state, data) {
159      let assert Ok(#(next, _, None)) = state.feed(data)
160      next
161    })
162  final.finish()
163}
164
165pub fn stream_decodes_text_thought_and_usage_test() -> Nil {
166  let chunks = [
167    "{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"thinking\",\"thought\":true}]}}]}",
168    "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"pong\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":3,\"candidatesTokenCount\":1,\"thoughtsTokenCount\":2}}",
169  ]
170  let assert Ok(turn) = reduce(chunks)
171  assert turn.finish == types.Complete
172  let assert [item] = turn.output
173  let assert Ok(value) =
174    json.parse(json.to_string(types.replay_json(item)), decode.dynamic)
175  assert at(value, ["content"], decode.string) == "pong"
176  assert at(value, ["reasoning_content"], decode.string) == "thinking"
177  let assert Some(usage) = turn.usage
178  assert usage.input_tokens == 3
179  assert usage.output_tokens == 3
180  assert usage.reasoning_tokens == Some(2)
181}
182
183pub fn stream_decodes_a_function_call_with_an_indexed_id_test() -> Nil {
184  let chunks = [
185    "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"bash\",\"args\":{\"cmd\":\"ls\"}}}]},\"finishReason\":\"STOP\"}]}",
186  ]
187  let assert Ok(turn) = reduce(chunks)
188  assert turn.finish == types.ToolCalls
189  let assert [call] = turn.tool_calls
190  assert call.id == "call_0"
191  assert call.name == "bash"
192  assert call.arguments == "{\"cmd\":\"ls\"}"
193}
194
195pub fn stream_maps_finish_reasons_to_albedo_finish_values_test() -> Nil {
196  let finish = fn(reason) {
197    let assert Ok(turn) =
198      reduce([
199        "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"x\"}]},\"finishReason\":\""
200        <> reason
201        <> "\"}]}",
202      ])
203    turn.finish
204  }
205  assert finish("MAX_TOKENS") == types.LengthLimit
206  assert finish("SAFETY") == types.ContentFiltered
207  assert finish("WEIRD") == types.OtherFinish("WEIRD")
208}
209
210pub fn stream_errors_surface_the_provider_message_test() -> Nil {
211  let assert Error(types.ProviderError(message)) =
212    stream.reducer().feed("{\"error\":{\"message\":\"quota\"}}")
213  assert message == "quota"
214}
215
216pub fn stream_reports_blocked_prompts_test() -> Nil {
217  let assert Error(types.ProviderError(message)) =
218    stream.reducer().feed("{\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}")
219  assert string.contains(message, "SAFETY")
220}
221
222pub fn from_text_recognizes_a_service_account_key_test() -> Nil {
223  assert auth.from_text(
224      "{\"type\":\"service_account\",\"client_email\":\"sa@example.com\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----\\n\"}",
225    )
226    == Ok(auth.ServiceAccount(
227      "sa@example.com",
228      "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----\n",
229      "https://oauth2.googleapis.com/token",
230    ))
231}
232
233pub fn from_text_recognizes_a_plain_refresh_token_and_defaults_its_endpoint_test() -> Nil {
234  assert auth.from_text(
235      "{\"type\":\"authorized_user\",\"client_id\":\"client-id\",\"client_secret\":\"client-secret\",\"refresh_token\":\"refresh\"}",
236    )
237    == Ok(auth.RefreshToken(
238      "client-id",
239      "client-secret",
240      "refresh",
241      "https://oauth2.googleapis.com/token",
242      False,
243    ))
244}
245
246pub fn from_text_uses_basic_auth_only_when_the_file_names_its_own_token_url_test() -> Nil {
247  assert auth.from_text(
248      "{\"type\":\"external_account_authorized_user\",\"client_id\":\"id\",\"client_secret\":\"secret\",\"refresh_token\":\"r\",\"token_url\":\"https://sts.example.com/token\"}",
249    )
250    == Ok(auth.RefreshToken(
251      "id",
252      "secret",
253      "r",
254      "https://sts.example.com/token",
255      True,
256    ))
257}
258
259pub fn from_text_rejects_a_credential_with_neither_shape_test() -> Nil {
260  let assert Error(message) = auth.from_text("{\"type\":\"authorized_user\"}")
261  assert string.contains(
262    message,
263    "neither a service-account key nor a refresh-token credential",
264  )
265}
266
267pub fn from_text_rejects_malformed_json_test() -> Nil {
268  assert result.is_error(auth.from_text("not json"))
269}
270
271fn encoded(request: types.Request) -> Dynamic {
272  let assert Ok(openai_api.Exchange(body: raw, ..)) =
273    wire.encode("t", project, location, request)
274  let assert Ok(value) = json.parse(string_tree.to_string(raw), decode.dynamic)
275  value
276}
277
278pub fn raw_json_schema_keeps_refs_unions_and_constraints_test() -> Nil {
279  // A realistic MCP tool input: named reusable definition, nullable union,
280  // required properties and constraints must survive without normalization.
281  let schema_text =
282    "{\"type\":\"object\",\"$defs\":{\"path\":{\"type\":\"string\",\"minLength\":1}},\"properties\":{\"path\":{\"$ref\":\"#/$defs/path\"},\"limit\":{\"anyOf\":[{\"type\":\"integer\",\"minimum\":1},{\"type\":\"null\"}]}},\"required\":[\"path\"],\"additionalProperties\":false}"
283  let assert Ok(schema_value) = json.parse(schema_text, decode.dynamic)
284  let schema = types.encode_value(schema_value)
285  let req =
286    types.Request(..gemini_request([types.User("list")]), tools: [
287      types.Tool("list_files", "List files", schema, False),
288    ])
289  let value = encoded(req)
290  let assert [tool] = at(value, ["tools"], decode.list(decode.dynamic))
291  let assert [declaration] =
292    at(tool, ["functionDeclarations"], decode.list(decode.dynamic))
293  assert at(declaration, ["parametersJsonSchema"], decode.dynamic)
294    == schema_value
295  assert result.is_error(decode.run(
296    declaration,
297    decode.at(["parameters"], decode.dynamic),
298  ))
299  let formatted =
300    encoded(
301      types.Request(
302        ..req,
303        tools: [],
304        options: types.Options(
305          ..types.defaults,
306          format: Some(types.JsonSchema("files", schema, True)),
307        ),
308      ),
309    )
310  assert at(
311      formatted,
312      ["generationConfig", "responseJsonSchema"],
313      decode.dynamic,
314    )
315    == schema_value
316  assert at(formatted, ["generationConfig", "responseMimeType"], decode.string)
317    == "application/json"
318}
319
320pub fn json_object_format_requests_json_mime_type_test() -> Nil {
321  let value =
322    encoded(
323      types.Request(
324        ..gemini_request([types.User("hi")]),
325        options: types.Options(..types.defaults, format: Some(types.JsonObject)),
326      ),
327    )
328  assert at(value, ["generationConfig", "responseMimeType"], decode.string)
329    == "application/json"
330}
331
332fn with_effort(model: String, effort: String) -> types.Request {
333  types.Request(
334    ..request(model, [types.User("hi")]),
335    options: types.Options(..types.defaults, effort: Some(effort)),
336  )
337}
338
339pub fn effort_uses_budgets_for_25_and_levels_for_3_test() -> Nil {
340  list.each(
341    [
342      #("gemini-2.5-pro", "low", 1024),
343      #("gemini-2.5-pro", "high", 32_768),
344      #("gemini-2.5-flash", "medium", 8192),
345      #("gemini-2.5-flash", "minimal", 0),
346      #("gemini-2.5-flash-lite", "minimal", 0),
347    ],
348    fn(case_) {
349      let value = encoded(with_effort(case_.0, case_.1))
350      assert at(
351          value,
352          ["generationConfig", "thinkingConfig", "thinkingBudget"],
353          decode.int,
354        )
355        == case_.2
356      assert at(value, ["generationConfig", "maxOutputTokens"], decode.int)
357        == 100
358    },
359  )
360  list.each(
361    [
362      #("gemini-3-pro-preview", "low", "LOW"),
363      #("gemini-3-flash-preview", "minimal", "MINIMAL"),
364      #("gemini-3-flash-preview", "medium", "MEDIUM"),
365      #("gemini-3.1-pro-preview", "medium", "MEDIUM"),
366      #("gemini-3.1-flash-lite", "minimal", "MINIMAL"),
367      #("gemini-3.5-flash", "minimal", "MINIMAL"),
368      #("gemini-3.8-flash", "medium", "MEDIUM"),
369    ],
370    fn(case_) {
371      let value = encoded(with_effort(case_.0, case_.1))
372      assert at(
373          value,
374          ["generationConfig", "thinkingConfig", "thinkingLevel"],
375          decode.string,
376        )
377        == case_.2
378    },
379  )
380}
381
382pub fn unsupported_effort_is_not_silently_ignored_test() -> Nil {
383  list.each(
384    [
385      #("gemini-2.0-flash", "high"),
386      #("gemini-2.5-pro", "minimal"),
387      #("gemini-3-pro-preview", "medium"),
388      #("gemini-3-flash-preview", "xhigh"),
389      #("gemini-30-pro", "high"),
390      #("gemini-3.1-flash-image", "medium"),
391      #("gemini-3.8-flash", "minimal"),
392    ],
393    fn(case_) {
394      let assert Error(types.InvalidRequest(_)) =
395        wire.encode("t", project, location, with_effort(case_.0, case_.1))
396    },
397  )
398}
399
400pub fn malformed_present_fields_fail_instead_of_becoming_empty_output_test() -> Nil {
401  list.each(
402    [
403      "null", "[]", "{\"candidates\":null}", "{\"candidates\":{}}",
404      "{\"candidates\":[null]}", "{\"candidates\":[{\"content\":null}]}",
405      "{\"candidates\":[{\"content\":{\"parts\":{}}}]}",
406      "{\"candidates\":[{\"content\":{\"parts\":[null]}}]}",
407      "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":1}]}}]}",
408      "{\"candidates\":[{\"content\":{\"parts\":[{\"thought\":\"yes\"}]}}]}",
409      "{\"candidates\":[{\"finishReason\":null}]}",
410      "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":null}]}}]}",
411      "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"args\":{}}}]}}]}",
412      "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"run\",\"args\":[]}}]}}]}",
413      "{\"usageMetadata\":null}",
414      "{\"usageMetadata\":{\"thoughtsTokenCount\":\"2\"}}",
415      "{\"promptFeedback\":false}", "{\"error\":{\"message\":12}}",
416    ],
417    fn(data) {
418      let assert Error(types.InvalidEvent(_)) = stream.reducer().feed(data)
419    },
420  )
421}
422
423pub fn eof_without_finish_reason_rejects_text_and_calls_test() -> Nil {
424  list.each(
425    [
426      "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"partial\"}]}}]}",
427      "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"run\",\"args\":{}}}]}}]}",
428    ],
429    fn(data) {
430      assert reduce([data]) == Error(types.UnexpectedEnd)
431    },
432  )
433}
434
435pub fn terminal_limits_and_errors_never_publish_executable_calls_test() -> Nil {
436  list.each(
437    [
438      #("MAX_TOKENS", types.LengthLimit),
439      #("SAFETY", types.ContentFiltered),
440      #("MALFORMED_FUNCTION_CALL", types.OtherFinish("MALFORMED_FUNCTION_CALL")),
441    ],
442    fn(case_) {
443      let assert Ok(turn) =
444        reduce([
445          "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"run\",\"args\":{}}}]}}]}",
446          "{\"candidates\":[{\"finishReason\":\"" <> case_.0 <> "\"}]}",
447        ])
448      assert turn.finish == case_.1
449      assert turn.tool_calls == []
450      assert turn.call_indices == []
451    },
452  )
453}
454
455pub fn streamed_parallel_calls_replay_into_second_step_test() -> Nil {
456  let assert Ok(turn) =
457    reduce([
458      "{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"first\",\"args\":{\"path\":\"a\"}}},{\"functionCall\":{\"name\":\"second\",\"args\":{}}}]},\"finishReason\":\"STOP\"}]}",
459      "{\"usageMetadata\":{\"candidatesTokenCount\":2,\"thoughtsTokenCount\":3}}",
460    ])
461  let assert [item] = turn.output
462  let assert [first, second] = turn.tool_calls
463  let value =
464    body([
465      types.User("do both"),
466      types.Replay(item),
467      types.ToolOutput(first.id, "one", []),
468      types.ToolOutput(second.id, "two", []),
469    ])
470  let assert [_, model, results] =
471    at(value, ["contents"], decode.list(decode.dynamic))
472  let assert [a, b] = at(model, ["parts"], decode.list(decode.dynamic))
473  assert at(a, ["functionCall", "name"], decode.string) == "first"
474  assert at(b, ["functionCall", "name"], decode.string) == "second"
475  assert at(a, ["functionCall", "args", "path"], decode.string) == "a"
476  assert at(b, ["thoughtSignature"], decode.string)
477    == "skip_thought_signature_validator"
478  let assert [a, b] = at(results, ["parts"], decode.list(decode.dynamic))
479  assert at(a, ["functionResponse", "name"], decode.string) == "first"
480  assert at(b, ["functionResponse", "response", "output"], decode.string)
481    == "two"
482  let assert Some(usage) = turn.usage
483  assert usage.output_tokens == 5
484}