Current section

Files

Jump to
erlang_adk src models openai request adk_openai_responses_codec.erl
Raw

src/models/openai/request/adk_openai_responses_codec.erl

%% @doc Bounded request/response codec for OpenAI's Responses API.
%%
%% Transport and credentials intentionally live outside this module. All wire
%% maps are binary-keyed. Provider error messages are not retained because
%% they can contain request/model content; callers receive only a bounded code.
-module(adk_openai_responses_codec).
-export([encode_request/4, decode_response/2, decode_api_error/1,
content_limits/1]).
-define(MAX_MODEL_BYTES, 256).
-define(MAX_FORMAT_NAME_BYTES, 64).
-define(MAX_OUTPUT_TOKENS, 1000000).
-spec encode_request(map(), term(), term(), boolean()) ->
{ok, map()} | {error, term()}.
encode_request(Config, History, Tools, Stream)
when is_map(Config), is_boolean(Stream) ->
Limits = content_limits(Config),
case {validate_model(maps:get(model, Config, undefined)),
adk_openai_responses_content:encode_history(History, Limits),
adk_openai_responses_content:encode_tools(Tools)} of
{{ok, Model}, {ok, Instructions, Input}, {ok, EncodedTools}} ->
case Input of
[] -> {error, openai_input_required};
_ ->
Base0 = #{<<"model">> => Model,
<<"input">> => Input,
<<"stream">> => Stream},
Base1 = maybe_put(<<"instructions">>, Instructions,
<<>>, Base0),
Base2 = maybe_put(<<"tools">>, EncodedTools, [], Base1),
case add_request_options(Config, Base2) of
{ok, Payload} -> {ok, Payload};
{error, _} = Error -> Error
end
end;
{{error, _} = Error, _, _} -> Error;
{_, {error, _} = Error, _} -> Error;
{_, _, {error, _} = Error} -> Error
end;
encode_request(_Config, _History, _Tools, _Stream) ->
{error, invalid_openai_request}.
-spec decode_response(term(), map()) ->
{ok, adk_provider_result:result()} | {error, term()}.
decode_response(Response, ConfigOrLimits) when is_map(Response),
is_map(ConfigOrLimits) ->
Limits = content_limits(ConfigOrLimits),
case maps:get(<<"status">>, Response, undefined) of
<<"completed">> -> decode_completed_response(Response, Limits);
<<"incomplete">> ->
{error, {openai_response_incomplete,
incomplete_reason(Response)}};
<<"failed">> ->
decode_api_error(maps:get(<<"error">>, Response, #{}));
<<"cancelled">> -> {error, openai_response_cancelled};
_ -> {error, invalid_openai_response_status}
end;
decode_response(_Response, _ConfigOrLimits) ->
{error, invalid_openai_response}.
%% @doc Sanitize either a top-level API error body or an error object. Only a
%% conservative code/type token is retained; `message', `param' and request
%% values are deliberately discarded.
-spec decode_api_error(term()) -> {error, term()}.
decode_api_error(#{<<"error">> := Error}) ->
decode_api_error(Error);
decode_api_error(Error) when is_map(Error) ->
Candidate = case maps:get(<<"code">>, Error, undefined) of
undefined -> maps:get(<<"type">>, Error, undefined);
Code -> Code
end,
{error, {openai_api_error, safe_code(Candidate)}};
decode_api_error(_) ->
{error, {openai_api_error, <<"unknown">>}}.
%% @doc Accept either a full provider config or a raw ADK content-limit map.
-spec content_limits(map()) -> map().
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.
add_request_options(Config, Payload0) ->
case validate_store(maps:get(store, Config, false)) of
{ok, Store} ->
Payload1 = Payload0#{<<"store">> => Store},
case add_numeric_options(Config, Payload1) of
{ok, Payload2} ->
case add_parallel_tool_calls(Config, Payload2) of
{ok, Payload3} -> add_text_format(Config, Payload3);
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end.
add_numeric_options(Config, Payload0) ->
case normalized_max_tokens(Config) of
{ok, MaxTokens} ->
Payload1 = maybe_put(<<"max_output_tokens">>, MaxTokens,
undefined, Payload0),
case validate_optional_number(
temperature, maps:get(temperature, Config, undefined),
0.0, 2.0) of
{ok, Temperature} ->
Payload2 = maybe_put(<<"temperature">>, Temperature,
undefined, Payload1),
case validate_optional_number(
top_p, maps:get(top_p, Config, undefined),
0.0, 1.0) of
{ok, TopP} ->
{ok, maybe_put(<<"top_p">>, TopP,
undefined, Payload2)};
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end.
normalized_max_tokens(Config) ->
A = maps:get(max_tokens, Config, undefined),
B = maps:get(max_output_tokens, Config, undefined),
case {A, B} of
{undefined, undefined} -> {ok, undefined};
{Value, undefined} -> validate_max_tokens(Value);
{undefined, Value} -> validate_max_tokens(Value);
{Value, Value} -> validate_max_tokens(Value);
{_, _} -> {error, conflicting_openai_max_tokens}
end.
validate_max_tokens(Value) when is_integer(Value), Value > 0,
Value =< ?MAX_OUTPUT_TOKENS ->
{ok, Value};
validate_max_tokens(_) -> {error, invalid_openai_max_output_tokens}.
validate_optional_number(_Key, undefined, _Min, _Max) ->
{ok, undefined};
validate_optional_number(_Key, Value, Min, Max)
when is_number(Value), Value >= Min, Value =< Max ->
{ok, Value};
validate_optional_number(Key, _Value, _Min, _Max) ->
{error, {invalid_openai_option, Key}}.
add_parallel_tool_calls(Config, Payload) ->
case maps:get(parallel_tool_calls, Config, undefined) of
undefined -> {ok, Payload};
Value when is_boolean(Value) ->
{ok, Payload#{<<"parallel_tool_calls">> => Value}};
_ -> {error, {invalid_openai_option, parallel_tool_calls}}
end.
add_text_format(Config, Payload) ->
Schema = maps:get(response_schema, Config, undefined),
Mime = maps:get(response_mime_type, Config, undefined),
case {Schema, Mime} of
{undefined, undefined} -> {ok, Payload};
{undefined, <<"text/plain">>} -> {ok, Payload};
{undefined, <<"application/json">>} ->
{ok, Payload#{<<"text">> =>
#{<<"format">> =>
#{<<"type">> => <<"json_object">>}}}};
{undefined, _} ->
{error, unsupported_openai_response_mime_type};
{SchemaMap, MimeValue}
when is_map(SchemaMap),
(MimeValue =:= undefined orelse
MimeValue =:= <<"application/json">>) ->
case structured_format(SchemaMap, Config) of
{ok, Format} ->
{ok, Payload#{<<"text">> =>
#{<<"format">> => Format}}};
{error, _} = Error -> Error
end;
{_SchemaValue, _MimeValue} ->
{error, invalid_openai_response_schema}
end.
structured_format(Schema, Config) ->
Name = maps:get(response_schema_name, Config, <<"adk_response">>),
Normalized = normalize_json_map(Schema),
Compiled = case Normalized of
{ok, Canonical} -> adk_json_schema:compile(Canonical);
{error, _} = Error -> Error
end,
case {validate_format_name(Name), Normalized, Compiled} of
{{ok, FormatName}, {ok, _Canonical}, {ok, CompiledSchema}} ->
{ok, #{<<"type">> => <<"json_schema">>,
<<"name">> => FormatName,
<<"schema">> => CompiledSchema,
<<"strict">> => true}};
{{error, _}, _, _} -> {error, invalid_openai_schema_name};
{_, {error, _}, _} -> {error, invalid_openai_response_schema};
{_, _, {error, _}} -> {error, invalid_openai_response_schema}
end.
decode_completed_response(Response, Limits) ->
case maps:get(<<"error">>, Response, null) of
null -> decode_completed_output(Response, Limits);
undefined -> decode_completed_output(Response, Limits);
Error -> decode_api_error(Error)
end.
decode_completed_output(Response, Limits) ->
case {bounded_utf8_field(Response, <<"id">>, 256),
bounded_utf8_field(Response, <<"model">>, ?MAX_MODEL_BYTES),
maps:find(<<"output">>, Response),
normalize_usage(maps:get(<<"usage">>, Response, undefined))} of
{{ok, ResponseId}, {ok, Model}, {ok, Output}, {ok, Usage}} ->
case adk_openai_responses_content:decode_output(Output, Limits) of
{ok, Content, Calls} ->
Outcome = case Calls of
[_ | _] -> {tool_calls, Calls};
[] ->
Text = iolist_to_binary(
adk_openai_responses_content:text_parts(
Content)),
case Text of
<<>> -> {ok, Content};
_ -> {ok, Text}
end
end,
Metadata0 = #{<<"response_id">> => ResponseId,
<<"response_model">> => Model,
<<"status">> => <<"completed">>},
Metadata = case map_size(Usage) of
0 -> Metadata0;
_ -> Metadata0#{<<"usage">> => Usage}
end,
case adk_provider_result:new(
<<"openai">>, <<"responses">>,
Outcome, Metadata) of
{ok, ProviderResult} -> {ok, ProviderResult};
{error, _} -> {error, invalid_openai_metadata}
end;
{error, _} = Error -> Error
end;
{{error, _}, _, _, _} -> {error, invalid_openai_response_id};
{_, {error, _}, _, _} -> {error, invalid_openai_response_model};
{_, _, error, _} -> {error, invalid_openai_response_output};
{_, _, _, {error, _}} -> {error, invalid_openai_usage}
end.
normalize_usage(undefined) -> {ok, #{}};
normalize_usage(null) -> {ok, #{}};
normalize_usage(Usage) when is_map(Usage) ->
Fields = [{<<"input_tokens">>, <<"input_tokens">>},
{<<"output_tokens">>, <<"output_tokens">>},
{<<"total_tokens">>, <<"total_tokens">>}],
case copy_nonnegative_integers(Fields, Usage, #{}) of
{ok, Basic0} ->
case nested_usage_integer(
Usage, <<"input_tokens_details">>, <<"cached_tokens">>) of
{ok, Cached} ->
Basic1 = maybe_put(<<"cached_input_tokens">>, Cached,
undefined, Basic0),
case nested_usage_integer(
Usage, <<"output_tokens_details">>,
<<"reasoning_tokens">>) of
{ok, Reasoning} ->
{ok, maybe_put(<<"reasoning_tokens">>, Reasoning,
undefined, Basic1)};
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
normalize_usage(_) -> {error, invalid_usage}.
copy_nonnegative_integers([], _Source, Acc) -> {ok, Acc};
copy_nonnegative_integers([{SourceKey, TargetKey} | Rest], Source, Acc) ->
case maps:find(SourceKey, Source) of
error -> copy_nonnegative_integers(Rest, Source, Acc);
{ok, Value} when is_integer(Value), Value >= 0 ->
copy_nonnegative_integers(
Rest, Source, Acc#{TargetKey => Value});
{ok, _} -> {error, invalid_usage_integer}
end.
nested_usage_integer(Source, ParentKey, ChildKey) ->
case maps:find(ParentKey, Source) of
error -> {ok, undefined};
{ok, null} -> {ok, undefined};
{ok, Parent} when is_map(Parent) ->
case maps:find(ChildKey, Parent) of
error -> {ok, undefined};
{ok, Value} when is_integer(Value), Value >= 0 ->
{ok, Value};
{ok, _} -> {error, invalid_usage_integer}
end;
{ok, _} -> {error, invalid_usage_details}
end.
incomplete_reason(Response) ->
case maps:get(<<"incomplete_details">>, Response, undefined) of
Details when is_map(Details) ->
safe_code(maps:get(<<"reason">>, Details, undefined));
_ -> <<"unknown">>
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_openai_model}
end;
validate_model(_) -> {error, invalid_openai_model}.
validate_store(Value) when is_boolean(Value) -> {ok, Value};
validate_store(_) -> {error, {invalid_openai_option, store}}.
validate_format_name(Name) when is_binary(Name), byte_size(Name) > 0,
byte_size(Name) =< ?MAX_FORMAT_NAME_BYTES ->
case re:run(Name, <<"^[A-Za-z0-9_-]+$">>, [{capture, none}]) of
match -> {ok, Name};
nomatch -> {error, invalid_name}
end;
validate_format_name(_) -> {error, invalid_name}.
normalize_json_map(Value) when is_map(Value) ->
case adk_json:normalize(Value) of
{ok, Normalized} when is_map(Normalized) -> {ok, Normalized};
_ -> {error, invalid_json}
end.
bounded_utf8_field(Map, Key, Max) ->
case maps:find(Key, Map) of
{ok, Value} when is_binary(Value), byte_size(Value) > 0,
byte_size(Value) =< Max ->
case valid_utf8(Value) of
true -> {ok, Value};
false -> {error, invalid_utf8}
end;
_ -> {error, invalid_field}
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) ->
case unicode:characters_to_binary(Value, utf8, utf8) of
Value -> true;
_ -> false
end.
maybe_put(_Key, Value, Value, Map) -> Map;
maybe_put(Key, Value, _Missing, Map) -> Map#{Key => Value}.