Current section
Files
Jump to
Current section
Files
src/models/compatible/request/adk_llm_compatible_request.erl
%% @doc Bounded OpenAI-compatible Chat Completions request/response codec.
%%
%% Transport, endpoint selection, authentication, and retries intentionally
%% live elsewhere. The runtime config is never merged into the wire payload;
%% only explicitly validated generation options are copied.
-module(adk_llm_compatible_request).
-export([build/3, build/4, decode_response/2, decode_error/1,
max_request_bytes/0]).
-define(MAX_REQUEST_BYTES, 33554432).
-define(MAX_RESPONSE_BYTES, 33554432).
-define(MAX_ERROR_BYTES, 1048576).
-define(MAX_MODEL_BYTES, 512).
-define(MAX_TOKENS, 1000000).
-define(MAX_STOP_SEQUENCES, 16).
-define(MAX_STOP_BYTES, 4096).
-define(MAX_ALL_STOP_BYTES, 32768).
-define(MAX_SCHEMA_NAME_BYTES, 64).
-define(MAX_ID_BYTES, 512).
-define(MAX_MODEL_RESPONSE_BYTES, 512).
-spec max_request_bytes() -> pos_integer().
max_request_bytes() -> ?MAX_REQUEST_BYTES.
-spec build(map(), term(), term()) -> {ok, map()} | {error, term()}.
build(Config, Memory, Tools) ->
build(Config, Memory, Tools, false).
-spec build(map(), term(), term(), boolean()) ->
{ok, map()} | {error, term()}.
build(Config, Memory, Tools, Stream)
when is_map(Config), is_boolean(Stream) ->
Limits0 = maps:get(content_limits, Config, #{}),
case {validate_model(maps:get(model, Config, undefined)),
adk_content:normalize_limits(Limits0)} of
{{ok, Model}, {ok, Limits}} ->
case {adk_llm_compatible_content:encode_history(Memory, Limits),
adk_llm_compatible_content:encode_tools(Tools)} of
{{ok, []}, _} -> {error, compatible_messages_required};
{{ok, Messages}, {ok, EncodedTools}} ->
build_payload(Config, Model, Messages,
EncodedTools, Stream);
{{error, _} = Error, _} -> Error;
{_, {error, _} = Error} -> Error
end;
{{error, _} = Error, _} -> Error;
{_, {error, Reason}} ->
{error, {invalid_compatible_content_limits, Reason}}
end;
build(_Config, _Memory, _Tools, Stream) when not is_boolean(Stream) ->
{error, invalid_compatible_stream_option};
build(_Config, _Memory, _Tools, _Stream) ->
{error, invalid_compatible_request_config}.
-spec decode_response(binary() | map(), map()) ->
{ok, adk_provider_result:result()} | {error, term()}.
decode_response(Body, ConfigOrLimits) when is_binary(Body),
is_map(ConfigOrLimits) ->
case byte_size(Body) =< ?MAX_RESPONSE_BYTES of
false -> {error, compatible_response_too_large};
true ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) -> decode_response(Map, ConfigOrLimits);
_ -> {error, invalid_compatible_response_json}
catch
_:_ -> {error, invalid_compatible_response_json}
end
end;
decode_response(#{<<"error">> := _} = Response, _ConfigOrLimits) ->
decode_error(Response);
decode_response(Response, ConfigOrLimits)
when is_map(Response), is_map(ConfigOrLimits) ->
Limits0 = content_limits(ConfigOrLimits),
case adk_content:normalize_limits(Limits0) of
{ok, Limits} -> decode_success_response(Response, Limits);
{error, Reason} ->
{error, {invalid_compatible_content_limits, Reason}}
end;
decode_response(_Response, _ConfigOrLimits) ->
{error, invalid_compatible_response}.
%% @doc Sanitize a compatible API error. Remote messages and parameter values
%% are deliberately discarded because providers may quote request content.
-spec decode_error(binary() | map()) -> {error, term()}.
decode_error(Body) when is_binary(Body) ->
case byte_size(Body) =< ?MAX_ERROR_BYTES of
false -> {error, compatible_error_response_too_large};
true ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) -> decode_error(Map);
_ -> {error, invalid_compatible_error_response}
catch
_:_ -> {error, invalid_compatible_error_response}
end
end;
decode_error(#{<<"error">> := Error}) when is_map(Error) ->
Candidate = case maps:get(<<"code">>, Error, undefined) of
undefined -> maps:get(<<"type">>, Error, undefined);
Code -> Code
end,
{error, {compatible_api_error, safe_code(Candidate)}};
decode_error(Error) when is_map(Error) ->
Candidate = case maps:get(<<"code">>, Error, undefined) of
undefined -> maps:get(<<"type">>, Error, undefined);
Code -> Code
end,
{error, {compatible_api_error, safe_code(Candidate)}};
decode_error(_Body) ->
{error, invalid_compatible_error_response}.
build_payload(Config, Model, Messages, Tools, Stream) ->
Base0 = #{<<"model">> => Model,
<<"messages">> => Messages,
<<"stream">> => Stream},
Base1 = case Tools of
[] -> Base0;
_ -> Base0#{<<"tools">> => Tools}
end,
case add_generation_options(Config, Base1) of
{ok, Payload1} ->
case add_tool_options(Config, Tools, Payload1) of
{ok, Payload2} ->
case add_response_format(Config, Payload2) of
{ok, Payload3} ->
case add_stream_options(Config, Stream,
Payload3) of
{ok, Payload} -> check_request_size(Payload);
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end.
add_generation_options(Config, Payload0) ->
case {optional_number(temperature, Config, 0.0, 2.0),
optional_number(top_p, Config, 0.0, 1.0),
max_tokens(Config), stop_sequences(Config)} of
{{ok, Temperature}, {ok, TopP},
{ok, MaxTokens}, {ok, Stops}} ->
Payload1 = put_optional(<<"temperature">>, Temperature,
Payload0),
Payload2 = put_optional(<<"top_p">>, TopP, Payload1),
Payload3 = put_optional(<<"max_tokens">>, MaxTokens, Payload2),
{ok, put_optional(<<"stop">>, Stops, Payload3)};
{{error, _} = Error, _, _, _} -> Error;
{_, {error, _} = Error, _, _} -> Error;
{_, _, {error, _} = Error, _} -> Error;
{_, _, _, {error, _} = Error} -> Error
end.
optional_number(Key, Config, Min, Max) ->
case maps:find(Key, Config) of
error -> {ok, undefined};
{ok, Value} when (is_integer(Value) orelse is_float(Value)),
Value >= Min, Value =< Max -> {ok, Value};
{ok, _} -> {error, {invalid_compatible_option, Key}}
end.
max_tokens(Config) ->
Legacy = maps:get(max_tokens, Config, undefined),
Completion = maps:get(max_completion_tokens, Config, undefined),
case {Legacy, Completion} of
{undefined, undefined} -> {ok, undefined};
{Value, undefined} -> valid_max_tokens(Value);
{undefined, Value} -> valid_max_tokens(Value);
{Value, Value} -> valid_max_tokens(Value);
_ -> {error, conflicting_compatible_max_tokens}
end.
valid_max_tokens(Value) when is_integer(Value), Value > 0,
Value =< ?MAX_TOKENS -> {ok, Value};
valid_max_tokens(_Value) -> {error, invalid_compatible_max_tokens}.
stop_sequences(Config) ->
case maps:find(stop_sequences, Config) of
error -> {ok, undefined};
{ok, Stops} -> validate_stop_sequences(Stops)
end.
validate_stop_sequences(Stops) ->
case bounded_list_length(Stops, ?MAX_STOP_SEQUENCES) of
{ok, Count} when Count > 0 -> validate_stops(Stops, 0, []);
{ok, 0} -> {error, invalid_compatible_stop_sequences};
too_many -> {error, compatible_stop_sequence_limit_exceeded};
improper -> {error, invalid_compatible_stop_sequences}
end.
validate_stops([], _Total, Acc) -> {ok, lists:reverse(Acc)};
validate_stops([Stop | Rest], Total, Acc) ->
case bounded_utf8(Stop, ?MAX_STOP_BYTES) of
true ->
NewTotal = Total + byte_size(Stop),
case NewTotal =< ?MAX_ALL_STOP_BYTES of
true -> validate_stops(Rest, NewTotal, [Stop | Acc]);
false -> {error, compatible_stop_sequences_too_large}
end;
false -> {error, invalid_compatible_stop_sequence}
end.
add_tool_options(Config, Tools, Payload0) ->
case add_parallel_tool_calls(Config, Tools, Payload0) of
{ok, Payload1} -> add_tool_choice(Config, Tools, Payload1);
{error, _} = Error -> Error
end.
add_parallel_tool_calls(Config, Tools, Payload) ->
case maps:find(parallel_tool_calls, Config) of
error -> {ok, Payload};
{ok, _Value} when Tools =:= [] ->
{error, compatible_parallel_tool_calls_requires_tools};
{ok, Value} when is_boolean(Value) ->
{ok, Payload#{<<"parallel_tool_calls">> => Value}};
{ok, _} -> {error, {invalid_compatible_option,
parallel_tool_calls}}
end.
add_tool_choice(Config, Tools, Payload) ->
case maps:find(tool_choice, Config) of
error -> {ok, Payload};
{ok, _Choice} when Tools =:= [] ->
{error, compatible_tool_choice_requires_tools};
{ok, Choice} ->
case normalize_tool_choice(Choice, Tools) of
{ok, Encoded} ->
{ok, Payload#{<<"tool_choice">> => Encoded}};
{error, _} = Error -> Error
end
end.
normalize_tool_choice(auto, _Tools) -> {ok, <<"auto">>};
normalize_tool_choice(required, _Tools) -> {ok, <<"required">>};
normalize_tool_choice(none, _Tools) -> {ok, <<"none">>};
normalize_tool_choice(<<"auto">>, Tools) ->
normalize_tool_choice(auto, Tools);
normalize_tool_choice(<<"required">>, Tools) ->
normalize_tool_choice(required, Tools);
normalize_tool_choice(<<"none">>, Tools) ->
normalize_tool_choice(none, Tools);
normalize_tool_choice({tool, Name}, Tools) ->
named_tool_choice(Name, Tools);
normalize_tool_choice(#{type := tool, name := Name}, Tools) ->
named_tool_choice(Name, Tools);
normalize_tool_choice(_Choice, _Tools) ->
{error, invalid_compatible_tool_choice}.
named_tool_choice(Name, Tools) ->
case valid_tool_name(Name) andalso
lists:any(
fun(#{<<"function">> := #{<<"name">> := ToolName}}) ->
ToolName =:= Name
end, Tools) of
true ->
{ok, #{<<"type">> => <<"function">>,
<<"function">> => #{<<"name">> => Name}}};
false -> {error, invalid_compatible_tool_choice}
end.
%% `response_format' is a capability gate, not a raw wire-map escape hatch.
%% `auto' uses json_schema when a schema exists and json_object for a JSON MIME
%% request. Profiles for vendors without structured output set `unsupported'.
add_response_format(Config, Payload) ->
Mode = maps:get(response_format, Config, auto),
Schema = maps:get(response_schema, Config, undefined),
Mime = maps:get(response_mime_type, Config, undefined),
case structured_request(Mode, Schema, Mime, Config) of
{ok, undefined} -> {ok, Payload};
{ok, Format} -> {ok, Payload#{<<"response_format">> => Format}};
{error, _} = Error -> Error
end.
structured_request(unsupported, undefined, undefined, _Config) ->
{ok, undefined};
structured_request(unsupported, _Schema, _Mime, _Config) ->
{error, compatible_structured_output_unsupported};
structured_request(text, undefined, undefined, _Config) -> {ok, undefined};
structured_request(text, undefined, <<"text/plain">>, _Config) ->
{ok, undefined};
structured_request(json_object, undefined, Mime, _Config)
when Mime =:= undefined; Mime =:= <<"application/json">> ->
{ok, #{<<"type">> => <<"json_object">>}};
structured_request(auto, undefined, <<"application/json">>, _Config) ->
{ok, #{<<"type">> => <<"json_object">>}};
structured_request(auto, undefined, Mime, _Config)
when Mime =:= undefined; Mime =:= <<"text/plain">> ->
{ok, undefined};
structured_request(Mode, Schema, Mime, Config)
when is_map(Schema),
(Mode =:= auto orelse Mode =:= json_schema),
(Mime =:= undefined orelse Mime =:= <<"application/json">>) ->
json_schema_format(Schema, Config);
structured_request(json_schema, undefined, _Mime, _Config) ->
{error, compatible_response_schema_required};
structured_request(Mode, _Schema, _Mime, _Config)
when Mode =:= auto; Mode =:= text; Mode =:= json_object;
Mode =:= json_schema ->
{error, invalid_compatible_response_format};
structured_request(_Mode, _Schema, _Mime, _Config) ->
{error, invalid_compatible_response_format}.
json_schema_format(Schema, Config) ->
Name = maps:get(response_schema_name, Config, <<"adk_response">>),
case {valid_schema_name(Name), strict_json_map(Schema),
adk_json_schema:compile(Schema)} of
{true, ok, {ok, Compiled}} ->
{ok, #{<<"type">> => <<"json_schema">>,
<<"json_schema">> =>
#{<<"name">> => Name,
<<"schema">> => Compiled,
<<"strict">> => true}}};
_ -> {error, invalid_compatible_response_schema}
end.
add_stream_options(Config, false, Payload) ->
case maps:find(stream_include_usage, Config) of
error -> {ok, Payload};
{ok, false} -> {ok, Payload};
{ok, _} -> {error, compatible_stream_options_require_streaming}
end;
add_stream_options(Config, true, Payload) ->
case maps:find(stream_include_usage, Config) of
error -> {ok, Payload};
{ok, false} -> {ok, Payload};
{ok, true} ->
{ok, Payload#{<<"stream_options">> =>
#{<<"include_usage">> => true}}};
{ok, _} ->
{error, {invalid_compatible_option, stream_include_usage}}
end.
check_request_size(Payload) ->
try iolist_size(jsx:encode(Payload)) of
Size when Size =< ?MAX_REQUEST_BYTES -> {ok, Payload};
_ -> {error, compatible_request_too_large}
catch
_:_ -> {error, invalid_compatible_request_json}
end.
decode_success_response(Response, Limits) ->
case {bounded_field(Response, <<"id">>, ?MAX_ID_BYTES),
bounded_field(Response, <<"model">>, ?MAX_MODEL_RESPONSE_BYTES),
single_choice(maps:get(<<"choices">>, Response, undefined)),
normalize_usage(maps:get(<<"usage">>, Response, undefined))} of
{{ok, Id}, {ok, Model}, {ok, Choice}, {ok, Usage}} ->
decode_choice(Response, Choice, Id, Model, Usage, Limits);
{{error, _}, _, _, _} ->
{error, invalid_compatible_response_id};
{_, {error, _}, _, _} ->
{error, invalid_compatible_response_model};
{_, _, {error, _}, _} ->
{error, invalid_compatible_response_choices};
{_, _, _, {error, _}} ->
{error, invalid_compatible_usage}
end.
single_choice([#{<<"index">> := 0} = Choice]) -> {ok, Choice};
single_choice(_Choices) -> {error, invalid_choices}.
decode_choice(Response, Choice, Id, Model, Usage, Limits) ->
FinishReason = maps:get(<<"finish_reason">>, Choice, undefined),
Message = maps:get(<<"message">>, Choice, undefined),
case adk_llm_compatible_content:decode_message(Message, Limits) of
{ok, Content, Calls} ->
case validate_finish(FinishReason, Calls) of
ok ->
case adk_llm_compatible_content:outcome(Content) of
{error, _} = Error -> Error;
Outcome ->
Metadata0 = #{<<"response_id">> => Id,
<<"response_model">> => Model,
<<"finish_reason">> =>
FinishReason},
Metadata1 = maybe_metadata(
<<"usage">>, Usage, #{},
Metadata0),
Fingerprint = maps:get(
<<"system_fingerprint">>,
Response, undefined),
case optional_bounded_field(
Fingerprint, ?MAX_ID_BYTES) of
{ok, SafeFingerprint} ->
Metadata = maybe_metadata(
<<"system_fingerprint">>,
SafeFingerprint,
undefined, Metadata1),
provider_result(Outcome, Metadata);
error ->
{error,
invalid_compatible_system_fingerprint}
end
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end.
validate_finish(<<"stop">>, []) -> ok;
validate_finish(<<"tool_calls">>, [_ | _]) -> ok;
validate_finish(<<"function_call">>, [_ | _]) -> ok;
validate_finish(<<"length">>, _Calls) ->
{error, compatible_response_incomplete};
validate_finish(<<"content_filter">>, _Calls) ->
{error, compatible_response_filtered};
validate_finish(_Reason, _Calls) ->
{error, invalid_compatible_finish_reason}.
provider_result(Outcome, Metadata) ->
case adk_provider_result:new(
<<"openai_compatible">>, <<"chat_completions">>,
Outcome, Metadata) of
{ok, Result} -> {ok, Result};
{error, _} -> {error, invalid_compatible_metadata}
end.
normalize_usage(undefined) -> {ok, #{}};
normalize_usage(null) -> {ok, #{}};
normalize_usage(Usage) when is_map(Usage) ->
Fields = [{<<"prompt_tokens">>, <<"input_tokens">>},
{<<"completion_tokens">>, <<"output_tokens">>},
{<<"total_tokens">>, <<"total_tokens">>}],
copy_usage(Fields, Usage, #{});
normalize_usage(_Usage) -> {error, invalid_usage}.
copy_usage([], _Usage, Acc) -> {ok, Acc};
copy_usage([{Source, Target} | Rest], Usage, Acc) ->
case maps:find(Source, Usage) of
error -> copy_usage(Rest, Usage, Acc);
{ok, Value} when is_integer(Value), Value >= 0 ->
copy_usage(Rest, Usage, Acc#{Target => Value});
{ok, _} -> {error, invalid_usage_integer}
end.
content_limits(#{content_limits := Limits}) when is_map(Limits) -> Limits;
content_limits(Map) when is_map(Map) ->
Allowed = maps:keys(adk_content:default_limits()),
case lists:all(fun(Key) -> lists:member(Key, Allowed) end,
maps:keys(Map)) of
true -> Map;
false -> #{}
end.
validate_model(Model) when is_binary(Model), byte_size(Model) > 0,
byte_size(Model) =< ?MAX_MODEL_BYTES ->
case valid_utf8(Model) of
true -> {ok, Model};
false -> {error, invalid_compatible_model}
end;
validate_model(_Model) -> {error, invalid_compatible_model}.
bounded_field(Map, Key, Max) ->
case maps:find(Key, Map) of
{ok, Value} ->
case bounded_utf8(Value, Max) of
true -> {ok, Value};
false -> {error, invalid_field}
end;
error -> {error, missing_field}
end.
optional_bounded_field(undefined, _Max) -> {ok, undefined};
optional_bounded_field(null, _Max) -> {ok, undefined};
optional_bounded_field(Value, Max) ->
case bounded_utf8(Value, Max) of
true -> {ok, Value};
false -> error
end.
bounded_utf8(Value, Max) when is_binary(Value), byte_size(Value) > 0,
byte_size(Value) =< Max -> valid_utf8(Value);
bounded_utf8(_Value, _Max) -> false.
valid_tool_name(Name) when is_binary(Name), byte_size(Name) > 0,
byte_size(Name) =< 64 ->
re:run(Name, <<"^[A-Za-z0-9_-]+$">>, [{capture, none}]) =:= match;
valid_tool_name(_) -> false.
valid_schema_name(Name) when is_binary(Name), byte_size(Name) > 0,
byte_size(Name) =< ?MAX_SCHEMA_NAME_BYTES ->
re:run(Name, <<"^[A-Za-z0-9_-]+$">>, [{capture, none}]) =:= match;
valid_schema_name(_) -> false.
strict_json_map(Value) when is_map(Value) ->
case adk_json:normalize(Value) of
{ok, Value} -> ok;
_ -> {error, invalid_json}
end.
safe_code(Value) when is_binary(Value), byte_size(Value) > 0,
byte_size(Value) =< 64 ->
case re:run(Value, <<"^[A-Za-z0-9_.-]+$">>, [{capture, none}]) of
match -> Value;
nomatch -> <<"unknown">>
end;
safe_code(_) -> <<"unknown">>.
valid_utf8(Value) when is_binary(Value) ->
try unicode:characters_to_binary(Value, utf8, utf8) of
Value -> true;
_ -> false
catch _:_ -> false
end.
bounded_list_length(Value, Max) ->
bounded_list_length(Value, Max, 0).
bounded_list_length([], _Max, Count) -> {ok, Count};
bounded_list_length([_ | _], Max, Count) when Count >= Max -> too_many;
bounded_list_length([_ | Rest], Max, Count) ->
bounded_list_length(Rest, Max, Count + 1);
bounded_list_length(_, _Max, _Count) -> improper.
put_optional(_Key, undefined, Map) -> Map;
put_optional(Key, Value, Map) -> Map#{Key => Value}.
maybe_metadata(_Key, Value, Value, Map) -> Map;
maybe_metadata(Key, Value, _Missing, Map) -> Map#{Key => Value}.