diff --git a/docs/proxy.md b/docs/proxy.md index bf49a4d..3ddcc0a 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -18,7 +18,10 @@ Each request is translated into albedo's request types and projected onto the pr - Leading `system` and `developer` messages become the instructions. Later ones become user notes in place. - `user` text and base64 `data:` images become user input. Image urls are not fetched. - `assistant` messages with `tool_calls` become replayable tool calls. `tool` messages become their results. -- `tools` and `max_tokens`/`max_completion_tokens` are passed through. Sampling options, `tool_choice`, and response formats are ignored. +- `tools`, `max_tokens`/`max_completion_tokens`, `temperature`, `top_p`, `stop`, `tool_choice`, `parallel_tool_calls`, `reasoning_effort` (or `reasoning.effort`), and `response_format` (`json_object` or `json_schema`) become albedo's generation options. Each provider applies what its wire can express: + - OpenAI Chat Completions and Responses take all of them, except that Responses has no stop sequences. + - Codex keeps its fixed sampling. Effort, tool choice, parallel calls, and format override its defaults (medium effort, automatic parallel tools). + - Antigravity maps effort onto each model's thinking budget or level, and sampling, stop sequences, and format into `generationConfig`. Claude routes always run tools `VALIDATED`. On Gemini routes a forced tool choice is also restated as a final instruction, because Cloud Code Assist drops the tool mode there. Text and reasoning stream as `delta.content` and `delta.reasoning_content`. Each tool call is sent whole, with its id, name, and arguments, when the turn ends. Errors from the provider arrive as an `error` chunk while streaming, or as a JSON error otherwise. diff --git a/src/albedo/harness/extensions/antigravity/catalog.gleam b/src/albedo/harness/extensions/antigravity/catalog.gleam index 90cd734..5c479d2 100644 --- a/src/albedo/harness/extensions/antigravity/catalog.gleam +++ b/src/albedo/harness/extensions/antigravity/catalog.gleam @@ -18,11 +18,11 @@ pub type Family { Claude } -/// How a model is asked to think. Albedo has no effort selector, so each -/// model carries the budget or level of the client's highest supported effort. +/// How a model is asked to think: token budgets per effort, or Google's +/// named levels. Without a requested effort a model thinks at its high end. pub type Thinking { - Budget(tokens: Int) - Level(String) + Budget(low: Int, medium: Int, high: Int) + Level } pub type Model { @@ -62,11 +62,11 @@ pub fn images_in_tool_results(model: Model) -> Bool { } } -const flash_budget = Budget(10_000) +const flash_budget = Budget(1000, 4000, 10_000) -const pro_budget = Budget(10_001) +const pro_budget = Budget(1001, 8192, 10_001) -const default_budget = Budget(16_384) +const default_budget = Budget(4096, 8192, 16_384) /// What the backend last offered, with this table's thinking hints, or the /// table itself before the first discovery. @@ -137,13 +137,8 @@ pub fn known() -> List(Model) { default_budget, ), gemini("gemini-2.5-pro", "Gemini 2.5 Pro", 65_536, default_budget), - gemini("gemini-3-pro-low", "Gemini 3 Pro (Low)", 65_535, Level("HIGH")), - gemini( - "gemini-3.1-flash-lite", - "Gemini 3.1 Flash Lite", - 65_535, - Level("HIGH"), - ), + gemini("gemini-3-pro-low", "Gemini 3 Pro (Low)", 65_535, Level), + gemini("gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite", 65_535, Level), Model( ..gemini("gemini-3.1-pro-low", "Gemini 3.1 Pro (Low)", 65_535, pro_budget), model_enum: Some("MODEL_PLACEHOLDER_M36"), @@ -184,18 +179,8 @@ pub fn known() -> List(Model) { model_enum: Some("MODEL_PLACEHOLDER_M132"), pinned_output: True, ), - gemini( - "gemini-3.6-flash-low", - "Gemini 3.6 Flash (Low)", - 65_536, - Level("HIGH"), - ), - gemini( - "gemini-3.7-flash-low", - "Gemini 3.7 Flash (Low)", - 65_536, - Level("HIGH"), - ), + gemini("gemini-3.6-flash-low", "Gemini 3.6 Flash (Low)", 65_536, Level), + gemini("gemini-3.7-flash-low", "Gemini 3.7 Flash (Low)", 65_536, Level), Model( "gpt-oss-120b-medium", "GPT OSS 120B", @@ -203,7 +188,7 @@ pub fn known() -> List(Model) { 32_768, False, // The backend rejects a larger budget for this route. - Budget(8192), + Budget(4096, 8192, 8192), None, False, ), @@ -250,7 +235,7 @@ fn infer_thinking(id: String) -> Thinking { || string.starts_with(id, "gemini-3.7-flash") || id == "gemini-3.1-flash-lite" { - True -> Level("HIGH") + True -> Level False -> case string.contains(id, "flash") { True -> flash_budget diff --git a/src/albedo/harness/extensions/antigravity/wire.gleam b/src/albedo/harness/extensions/antigravity/wire.gleam index 80e441d..1cc7380 100644 --- a/src/albedo/harness/extensions/antigravity/wire.gleam +++ b/src/albedo/harness/extensions/antigravity/wire.gleam @@ -52,6 +52,10 @@ pub fn encode( add(history, input, model) }), ) + let history = case request.tools, forced(model, request.options.tool_choice) { + [_, ..], Some(directive) -> push(history, "user", [text_part(directive)]) + _, _ -> history + } let contents = history.contents |> list.reverse @@ -64,7 +68,10 @@ pub fn encode( let fields = [ #("contents", json.preprocessed_array(contents)), - #("generationConfig", generation(model, request.max_output_tokens)), + #( + "generationConfig", + generation(model, request.max_output_tokens, request.options), + ), #("labels", labels(context, request.input)), #("sessionId", json.string(session_number(context.session))), ] @@ -77,7 +84,7 @@ pub fn encode( let fields = case request.tools, catalog.family(model) { [], catalog.Gemini -> fields tools, _ -> [ - #("toolConfig", validated()), + #("toolConfig", tool_config(model, request.options.tool_choice)), ..case tools { [] -> fields tools -> [ @@ -309,10 +316,20 @@ fn inline(image: types.Image) -> Json { ]) } -fn generation(model: Model, requested: Option(Int)) -> Json { +fn generation( + model: Model, + requested: Option(Int), + options: types.Options, +) -> Json { let limit = model.max_output_tokens + let effort = option.unwrap(options.effort, "high") let #(output, thinking) = case model.thinking { - catalog.Budget(budget) -> { + catalog.Budget(low, medium, high) -> { + let budget = case effort { + "minimal" | "low" -> low + "medium" -> medium + _ -> high + } let output = case requested { Some(n) -> int.min(n + budget, limit) None -> limit @@ -323,33 +340,88 @@ fn generation(model: Model, requested: Option(Int)) -> Json { } #(output, #("thinkingBudget", json.int(budget))) } - catalog.Level(level) -> #( + catalog.Level -> #( option.unwrap(requested, limit), - #("thinkingLevel", json.string(level)), + #( + "thinkingLevel", + json.string(case effort { + "minimal" | "low" -> "LOW" + "medium" -> "MEDIUM" + _ -> "HIGH" + }), + ), ) } let output = case model.pinned_output { True -> limit False -> output } - json.object([ + [ #("maxOutputTokens", json.int(output)), #( "thinkingConfig", json.object([#("includeThoughts", json.bool(True)), thinking]), ), - ]) + ] + |> optional("temperature", options.temperature, json.float) + |> optional("topP", options.top_p, json.float) + |> optional( + "stopSequences", + case options.stop { + [] -> None + stop -> Some(stop) + }, + json.array(_, json.string), + ) + |> list.append(case options.format { + None -> [] + Some(types.JsonObject) -> [ + #("responseMimeType", json.string("application/json")), + ] + Some(types.JsonSchema(_, schema, _)) -> [ + #("responseMimeType", json.string("application/json")), + #("responseSchema", normalize_schema(schema)), + ] + }) + |> json.object } -fn validated() -> Json { +/// Claude routes always run tools VALIDATED. Gemini routes take the mode, +/// but Cloud Code Assist drops it there, so `forced` restates a forced +/// choice in the transcript. +fn tool_config(model: Model, choice: Option(types.ToolChoice)) -> Json { + let #(mode, names) = case catalog.family(model), choice { + catalog.Claude, _ | _, None -> #("VALIDATED", []) + _, Some(types.AutoTool) -> #("AUTO", []) + _, Some(types.NoTool) -> #("NONE", []) + _, Some(types.AnyTool) -> #("ANY", []) + _, Some(types.NamedTool(name)) -> #("ANY", [name]) + } json.object([ #( "functionCallingConfig", - json.object([#("mode", json.string("VALIDATED"))]), + json.object( + [#("mode", json.string(mode))] + |> when(names != [], #( + "allowedFunctionNames", + json.array(names, json.string), + )), + ), ), ]) } +fn forced(model: Model, choice: Option(types.ToolChoice)) -> Option(String) { + case catalog.family(model), choice { + catalog.Gemini, Some(types.AnyTool) -> Some(forced_directive) + catalog.Gemini, Some(types.NamedTool(name)) -> + Some(forced_directive <> "Call " <> name <> ".\n") + _, _ -> None + } +} + +const forced_directive = "TOOL-ONLY TURN. This turn accepts a tool call and nothing else; a text reply here is discarded unread and you will be re-prompted. Emit the tool call now.\n" + /// The antigravity/hub client numbers each agent step within a trajectory. /// Deriving it from history keeps it stable across daemon restarts. fn step(input: List(Input)) -> Int { diff --git a/src/albedo/harness/extensions/proxy/chat.gleam b/src/albedo/harness/extensions/proxy/chat.gleam index baacdb7..c27a9aa 100644 --- a/src/albedo/harness/extensions/proxy/chat.gleam +++ b/src/albedo/harness/extensions/proxy/chat.gleam @@ -43,7 +43,7 @@ type Message { } pub fn parse(body: BitArray) -> Result(Completion, String) { - use #(requested, messages, tools, stream, include_usage, limit) <- result.try( + use #(requested, messages, tools, stream, include_usage, limit, options) <- result.try( json.parse_bits(body, request_decoder()) |> result.map_error(fn(error) { "invalid chat completion request: " <> string.inspect(error) @@ -58,7 +58,7 @@ pub fn parse(body: BitArray) -> Result(Completion, String) { requested, profile, model, - types.Request(model, instructions, input, tools, limit), + types.Request(model, instructions, input, tools, limit, options), stream, include_usage, profile <> "\n" <> first_user_text(messages), @@ -116,6 +116,7 @@ fn request_decoder() { None, decode.optional(decode.int), ) + use options <- decode.then(options_decoder()) decode.success(#( model, messages, @@ -123,6 +124,88 @@ fn request_decoder() { stream, include_usage, option.or(completion, tokens), + options, + )) +} + +fn maybe( + name: String, + decoder: decode.Decoder(a), +) -> decode.Decoder(Option(a)) { + decode.optional_field(name, None, decode.optional(decoder), decode.success) +} + +fn options_decoder() -> decode.Decoder(types.Options) { + let number = + decode.one_of(decode.float, [decode.int |> decode.map(int.to_float)]) + let choice = + decode.one_of( + decode.string + |> decode.then(fn(choice) { + case choice { + "auto" -> decode.success(types.AutoTool) + "none" -> decode.success(types.NoTool) + "required" | "any" -> decode.success(types.AnyTool) + other -> + decode.failure(types.AutoTool, "tool choice, got " <> other) + } + }), + [ + decode.at(["function", "name"], decode.string) + |> decode.map(types.NamedTool), + ], + ) + let format = { + use kind <- decode.field("type", decode.string) + case kind { + "json_object" -> decode.success(Some(types.JsonObject)) + "json_schema" -> { + use name <- decode.subfield(["json_schema", "name"], decode.string) + use schema <- decode.subfield( + ["json_schema", "schema"], + decode.dynamic |> decode.map(encode), + ) + use strict <- decode.then(decode.optionally_at( + ["json_schema", "strict"], + False, + decode.bool, + )) + decode.success(Some(types.JsonSchema(name, schema, strict))) + } + _ -> decode.success(None) + } + } + use temperature <- decode.then(maybe("temperature", number)) + use top_p <- decode.then(maybe("top_p", number)) + use stop <- decode.optional_field( + "stop", + [], + decode.one_of(decode.list(decode.string), [ + decode.string |> decode.map(fn(stop) { [stop] }), + decode.success([]), + ]), + ) + use tool_choice <- decode.then(maybe("tool_choice", choice)) + use parallel <- decode.then(maybe("parallel_tool_calls", decode.bool)) + use effort <- decode.then(maybe("reasoning_effort", decode.string)) + use nested_effort <- decode.then(decode.optionally_at( + ["reasoning", "effort"], + None, + decode.optional(decode.string), + )) + use format <- decode.optional_field( + "response_format", + None, + decode.optional(format) |> decode.map(option.flatten), + ) + decode.success(types.Options( + temperature, + top_p, + stop, + tool_choice, + parallel, + option.or(effort, nested_effort), + format, )) } diff --git a/src/albedo/harness/loop.gleam b/src/albedo/harness/loop.gleam index 154b2c1..651fb81 100644 --- a/src/albedo/harness/loop.gleam +++ b/src/albedo/harness/loop.gleam @@ -72,6 +72,7 @@ pub fn run( history, runtime.tools(state.kernel), None, + types.defaults, ) state.record_context(request) use turn <- result.try( @@ -208,6 +209,7 @@ pub fn compact(state: Loop, inputs: List(types.Input)) -> Result(Nil, String) { history, runtime.tools(state.kernel), None, + types.defaults, )) // The projection keeps a verbatim tail, so everything before the shared // suffix is what the strategy's replacement stands in for. @@ -405,6 +407,7 @@ fn summarize( [types.User(prompt)], [], Some(max_output_tokens), + types.defaults, ) use turn <- result.try( stream_with_retries(state.upstream, summary_request, state.publish, fn(_) { diff --git a/src/albedo/openai_api.gleam b/src/albedo/openai_api.gleam index fe054e3..5168999 100644 --- a/src/albedo/openai_api.gleam +++ b/src/albedo/openai_api.gleam @@ -67,7 +67,7 @@ pub fn codex_client( } pub fn request(model: String, input: List(Input)) -> Request { - types.Request(model, None, input, [], None) + types.Request(model, None, input, [], None, types.defaults) } /// Returns final replayable items and tool-call proposals. The caller must validate diff --git a/src/albedo/openai_api/request.gleam b/src/albedo/openai_api/request.gleam index 8fa5b9d..d62fb30 100644 --- a/src/albedo/openai_api/request.gleam +++ b/src/albedo/openai_api/request.gleam @@ -72,22 +72,75 @@ pub fn encode_with_policy( ..fields ] } + let options = request.options + let tools = request.tools != [] let fields = case policy, protocol { + // The ChatGPT reasoning backend fixes sampling; only what it accepts + // overrides its defaults. Codex(_, session_id), Responses -> [ - #("tool_choice", json.string("auto")), - #("parallel_tool_calls", json.bool(True)), - #("text", json.object([#("verbosity", json.string("low"))])), + #( + "tool_choice", + option.map(options.tool_choice, tool_choice(protocol, _)) + |> option.unwrap(json.string("auto")), + ), + #( + "parallel_tool_calls", + json.bool(option.unwrap(options.parallel_tool_calls, True)), + ), + #( + "text", + json.object( + [#("verbosity", json.string("low"))] + |> optional("format", options.format, response_format(protocol, _)), + ), + ), #( "reasoning", json.object([ - #("effort", json.string("medium")), + #("effort", json.string(option.unwrap(options.effort, "medium"))), #("summary", json.string("auto")), ]), ), #("prompt_cache_key", json.string(session_id)), ..fields ] - _, _ -> fields + _, Responses -> + fields + |> sampling(options) + |> optional("tool_choice", options.tool_choice, tool_choice(protocol, _)) + |> optional( + "parallel_tool_calls", + when_tools(tools, options.parallel_tool_calls), + json.bool, + ) + |> optional("reasoning", options.effort, fn(effort) { + json.object([#("effort", json.string(effort))]) + }) + |> optional("text", options.format, fn(format) { + json.object([#("format", response_format(protocol, format))]) + }) + _, ChatCompletions -> + fields + |> sampling(options) + |> optional( + "stop", + case options.stop { + [] -> None + stop -> Some(stop) + }, + json.array(_, json.string), + ) + |> optional("tool_choice", options.tool_choice, tool_choice(protocol, _)) + |> optional( + "parallel_tool_calls", + when_tools(tools, options.parallel_tool_calls), + json.bool, + ) + |> optional("reasoning_effort", options.effort, json.string) + |> optional("response_format", options.format, response_format( + protocol, + _, + )) } Ok(json.to_string_tree(json.object(fields))) } @@ -288,6 +341,68 @@ fn encode_tool(protocol: Protocol, policy: ProviderPolicy, tool: Tool) -> Json { }) } +/// OpenAI rejects `parallel_tool_calls` on a request without tools. +fn when_tools(tools: Bool, value: Option(Bool)) -> Option(Bool) { + case tools { + True -> value + False -> None + } +} + +fn sampling( + fields: List(#(String, Json)), + options: types.Options, +) -> List(#(String, Json)) { + fields + |> optional("temperature", options.temperature, json.float) + |> optional("top_p", options.top_p, json.float) +} + +fn tool_choice(protocol: Protocol, choice: types.ToolChoice) -> Json { + case choice, protocol { + types.AutoTool, _ -> json.string("auto") + types.NoTool, _ -> json.string("none") + types.AnyTool, _ -> json.string("required") + types.NamedTool(name), Responses -> + json.object([ + #("type", json.string("function")), + #("name", json.string(name)), + ]) + types.NamedTool(name), ChatCompletions -> + json.object([ + #("type", json.string("function")), + #("function", json.object([#("name", json.string(name))])), + ]) + } +} + +/// Chat Completions nests a schema under `json_schema`; Responses flattens it +/// into `text.format`. +fn response_format(protocol: Protocol, format: types.Format) -> Json { + case format, protocol { + types.JsonObject, _ -> json.object([#("type", json.string("json_object"))]) + types.JsonSchema(name, schema, strict), Responses -> + json.object([ + #("type", json.string("json_schema")), + #("name", json.string(name)), + #("schema", schema), + #("strict", json.bool(strict)), + ]) + types.JsonSchema(name, schema, strict), ChatCompletions -> + json.object([ + #("type", json.string("json_schema")), + #( + "json_schema", + json.object([ + #("name", json.string(name)), + #("schema", schema), + #("strict", json.bool(strict)), + ]), + ), + ]) + } +} + fn optional( fields: List(#(String, Json)), key: String, diff --git a/src/albedo/openai_api/types.gleam b/src/albedo/openai_api/types.gleam index 770aac1..25a9175 100644 --- a/src/albedo/openai_api/types.gleam +++ b/src/albedo/openai_api/types.gleam @@ -1,7 +1,7 @@ import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/json.{type Json} -import gleam/option.{type Option} +import gleam/option.{type Option, None} import gleam/string pub type Protocol { @@ -88,9 +88,40 @@ pub type Request { input: List(Input), tools: List(Tool), max_output_tokens: Option(Int), + options: Options, ) } +/// Generation controls a caller may set; `None` and `[]` keep the provider's +/// default. A provider applies the ones its wire can express. +pub type Options { + Options( + temperature: Option(Float), + top_p: Option(Float), + stop: List(String), + tool_choice: Option(ToolChoice), + parallel_tool_calls: Option(Bool), + /// Reasoning effort: "minimal", "low", "medium", or "high". + effort: Option(String), + format: Option(Format), + ) +} + +pub const defaults = Options(None, None, [], None, None, None, None) + +pub type ToolChoice { + AnyTool + NoTool + AutoTool + NamedTool(String) +} + +/// Structured output: any JSON object, or one matching `schema`. +pub type Format { + JsonObject + JsonSchema(name: String, schema: Json, strict: Bool) +} + /// An unmodified provider output object, including opaque replay fields. pub opaque type ReplayItem { ReplayItem(protocol: Protocol, value: Dynamic) diff --git a/test/daemon/context_snapshot_test.gleam b/test/daemon/context_snapshot_test.gleam index 8c5ae66..9078c04 100644 --- a/test/daemon/context_snapshot_test.gleam +++ b/test/daemon/context_snapshot_test.gleam @@ -149,6 +149,7 @@ pub fn exact_request_builder_keeps_source_order_and_omits_payload_bodies_test() ), ], None, + types.defaults, ) let observation = rolling.Observation( diff --git a/test/daemon/projection_test.gleam b/test/daemon/projection_test.gleam index f929978..cd4e31e 100644 --- a/test/daemon/projection_test.gleam +++ b/test/daemon/projection_test.gleam @@ -21,7 +21,8 @@ fn entry(input: types.Input, provider: String) -> transcript.Entry { fn encode(protocol: types.Protocol, newest: List(transcript.Entry)) -> String { let assert Ok(inputs) = projection.for_model(newest, "target", protocol) - let request = types.Request("model", None, list.reverse(inputs), [], None) + let request = + types.Request("model", None, list.reverse(inputs), [], None, types.defaults) let assert Ok(body) = request.encode(protocol, request) string_tree.to_string(body) } diff --git a/test/harness/antigravity_test.gleam b/test/harness/antigravity_test.gleam index 587c209..25824af 100644 --- a/test/harness/antigravity_test.gleam +++ b/test/harness/antigravity_test.gleam @@ -199,7 +199,14 @@ pub fn foreign_calls_carry_the_skip_signature_on_gemini_test() { pub fn envelope_carries_project_identity_and_thinking_test() { let request = - types.Request(gemini, Some("be brief"), [types.User("hi")], [], None) + types.Request( + gemini, + Some("be brief"), + [types.User("hi")], + [], + None, + types.defaults, + ) let value = body(gemini, request) assert at(value, ["project"], decode.string) == "project-1" assert at(value, ["model"], decode.string) == gemini @@ -237,7 +244,17 @@ pub fn tool_schemas_are_flattened_for_cloud_code_assist_test() { ) let tool = types.Tool("t", "d", wire.encode_value(schema), False) let value = - body(gemini, types.Request(gemini, None, [types.User("hi")], [tool], None)) + body( + gemini, + types.Request( + gemini, + None, + [types.User("hi")], + [tool], + None, + types.defaults, + ), + ) let parameters = at( value, @@ -389,7 +406,7 @@ pub fn discovery_supplies_ids_and_model_enums_test() { == ["gemini-3-flash-agent", "gemini-9-flash-high"] let known = catalog.model(home, "gemini-3-flash-agent") assert known.model_enum == Some("MODEL_PLACEHOLDER_M84") - assert known.thinking == catalog.Budget(10_000) + assert known.thinking == catalog.Budget(1000, 4000, 10_000) let fresh = catalog.model(home, "gemini-9-flash-high") assert fresh.context_tokens == 1000 assert !fresh.images @@ -517,3 +534,73 @@ fn with_routes( @external(erlang, "albedo_antigravity_test_support", "seen") fn seen(log: dynamic.Dynamic) -> List(String) + +pub fn effort_and_forced_tools_reach_the_envelope_test() { + let tool = + types.Tool( + "bash", + "run", + json.object([#("type", json.string("object"))]), + False, + ) + let options = + types.Options( + ..types.defaults, + temperature: Some(0.5), + effort: Some("low"), + tool_choice: Some(types.NamedTool("bash")), + format: Some(types.JsonObject), + ) + let request = + types.Request(gemini, None, [types.User("hi")], [tool], None, options) + let value = body(gemini, request) + assert at( + value, + ["request", "generationConfig", "thinkingConfig", "thinkingBudget"], + decode.int, + ) + == 1001 + assert at(value, ["request", "generationConfig", "temperature"], decode.float) + == 0.5 + assert at( + value, + ["request", "generationConfig", "responseMimeType"], + decode.string, + ) + == "application/json" + assert at( + value, + ["request", "toolConfig", "functionCallingConfig", "allowedFunctionNames"], + decode.list(decode.string), + ) + == ["bash"] + // Gemini routes drop toolConfig, so the choice is restated last. + let contents = at(value, ["request", "contents"], decode.list(decode.dynamic)) + let assert Ok(last) = list.last(contents) + let assert [_, directive] = + at(last, ["parts"], decode.list(decode.at(["text"], decode.string))) + assert string.contains(directive, "Call bash.") + // Level models take a named level; Claude always runs VALIDATED. + let level = + body( + "gemini-3.7-flash-low", + types.Request( + ..request, + model: "gemini-3.7-flash-low", + options: types.Options(..options, effort: Some("medium")), + ), + ) + assert at( + level, + ["request", "generationConfig", "thinkingConfig", "thinkingLevel"], + decode.string, + ) + == "MEDIUM" + let claude_body = body(claude, types.Request(..request, model: claude)) + assert at( + claude_body, + ["request", "toolConfig", "functionCallingConfig", "mode"], + decode.string, + ) + == "VALIDATED" +} diff --git a/test/harness/proxy_chat_test.gleam b/test/harness/proxy_chat_test.gleam index 8d7cd6c..1ad4340 100644 --- a/test/harness/proxy_chat_test.gleam +++ b/test/harness/proxy_chat_test.gleam @@ -104,3 +104,25 @@ pub fn a_turn_becomes_a_completion_and_closing_chunks_test() { assert string.contains(usage, "\"choices\":[]") assert chat.delta(reply, types.ArgumentsDelta(0, "{")) == None } + +pub fn client_generation_options_are_kept_test() { + let assert Ok(completion) = + parse( + "{\"model\":\"p/m\",\"messages\":[],\"temperature\":1,\"top_p\":0.5,\"stop\":\"END\",\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"bash\"}},\"parallel_tool_calls\":false,\"reasoning\":{\"effort\":\"high\"},\"response_format\":{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"a\",\"schema\":{\"type\":\"object\"}}}}", + ) + let options = completion.request.options + assert options.temperature == Some(1.0) + assert options.top_p == Some(0.5) + assert options.stop == ["END"] + assert options.tool_choice == Some(types.NamedTool("bash")) + assert options.parallel_tool_calls == Some(False) + assert options.effort == Some("high") + let assert Some(types.JsonSchema("a", _, False)) = options.format + let assert Ok(plain) = + parse( + "{\"model\":\"p/m\",\"messages\":[],\"tool_choice\":\"required\",\"reasoning_effort\":\"low\",\"response_format\":{\"type\":\"text\"}}", + ) + assert plain.request.options.tool_choice == Some(types.AnyTool) + assert plain.request.options.effort == Some("low") + assert plain.request.options.format == None +} diff --git a/test/openai_api/request_test.gleam b/test/openai_api/request_test.gleam index 33322d7..ae8900d 100644 --- a/test/openai_api/request_test.gleam +++ b/test/openai_api/request_test.gleam @@ -269,3 +269,89 @@ pub fn chat_tool_images_follow_the_whole_run_of_results_test() { #("image_url", ""), ]) } + +fn options() -> types.Options { + types.Options( + Some(0.2), + Some(0.9), + ["END"], + Some(types.NamedTool("read_file")), + Some(False), + Some("low"), + Some(types.JsonSchema( + "answer", + json.object([#("type", json.string("object"))]), + True, + )), + ) +} + +fn with_options(tools) { + types.Request( + ..openai.request("model", [types.User("hi")]), + tools: tools, + options: options(), + ) +} + +fn field(encoded: String, path: List(String), decoder) { + json.parse(encoded, decode.at(path, decoder)) +} + +pub fn chat_encodes_generation_options_test() { + let encoded = body(types.ChatCompletions, with_options([tool()])) + assert field(encoded, ["temperature"], decode.float) == Ok(0.2) + assert field(encoded, ["top_p"], decode.float) == Ok(0.9) + assert field(encoded, ["stop"], decode.list(decode.string)) == Ok(["END"]) + assert field(encoded, ["tool_choice", "function", "name"], decode.string) + == Ok("read_file") + assert field(encoded, ["parallel_tool_calls"], decode.bool) == Ok(False) + assert field(encoded, ["reasoning_effort"], decode.string) == Ok("low") + assert field( + encoded, + ["response_format", "json_schema", "name"], + decode.string, + ) + == Ok("answer") + // Without tools OpenAI rejects parallel_tool_calls, so it is left out. + let bare = body(types.ChatCompletions, with_options([])) + let assert Error(_) = field(bare, ["parallel_tool_calls"], decode.bool) +} + +pub fn responses_encodes_generation_options_test() { + let encoded = body(types.Responses, with_options([tool()])) + assert field(encoded, ["reasoning", "effort"], decode.string) == Ok("low") + assert field(encoded, ["tool_choice", "name"], decode.string) + == Ok("read_file") + assert field(encoded, ["text", "format", "name"], decode.string) + == Ok("answer") + assert field(encoded, ["temperature"], decode.float) == Ok(0.2) + // Responses has no stop sequences. + let assert Error(_) = field(encoded, ["stop"], decode.dynamic) +} + +pub fn codex_overrides_only_what_its_backend_accepts_test() { + let assert Ok(encoded) = + request.encode_with_policy( + types.Responses, + types.Codex("account", "session"), + with_options([tool()]), + ) + let encoded = string_tree.to_string(encoded) + assert field(encoded, ["reasoning", "effort"], decode.string) == Ok("low") + assert field(encoded, ["reasoning", "summary"], decode.string) == Ok("auto") + assert field(encoded, ["parallel_tool_calls"], decode.bool) == Ok(False) + assert field(encoded, ["text", "verbosity"], decode.string) == Ok("low") + assert field(encoded, ["text", "format", "type"], decode.string) + == Ok("json_schema") + let assert Error(_) = field(encoded, ["temperature"], decode.float) + let assert Ok(defaults) = + request.encode_with_policy( + types.Responses, + types.Codex("account", "session"), + openai.request("model", [types.User("hi")]), + ) + let defaults = string_tree.to_string(defaults) + assert field(defaults, ["reasoning", "effort"], decode.string) == Ok("medium") + assert field(defaults, ["tool_choice"], decode.string) == Ok("auto") +}