import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode.{type DecodeError, type Decoder} import gleam/json import gleam/option.{None} import claude/types/content.{ type Citation, type ContentBlock, CharLocationCitation, ContentBlockLocationCitation, PageLocationCitation, RedactedThinking, ServerToolUse, Text, Thinking, ToolUse, WebSearchResult, WebSearchResultLocationCitation, } import claude/types/message.{ type Message, type Role, type StopReason, type Usage, Assistant, EndTurn, MaxTokens, Message, PauseTurn, Refusal, StopSequence, ToolUseStop, Usage, User, } /// Re-serialize a dynamic value (arbitrary JSON) back to a JSON string. @external(erlang, "claude_json_ffi", "dynamic_to_json_string") fn dynamic_to_json_string(term: Dynamic) -> String /// Decode a full API response message from a JSON string. pub fn message( json_string: String, ) -> Result(Message, List(DecodeError)) { json.parse(json_string, message_decoder()) |> map_json_error } /// Map gleam_json's DecodeError to a list of dynamic DecodeErrors. fn map_json_error( result: Result(a, json.DecodeError), ) -> Result(a, List(DecodeError)) { case result { Ok(v) -> Ok(v) Error(json.UnexpectedEndOfInput) -> Error([ decode.DecodeError( expected: "valid JSON", found: "unexpected end of input", path: [], ), ]) Error(json.UnexpectedByte(b)) -> Error([ decode.DecodeError( expected: "valid JSON", found: "unexpected byte: " <> b, path: [], ), ]) Error(json.UnexpectedSequence(s)) -> Error([ decode.DecodeError( expected: "valid JSON", found: "unexpected sequence: " <> s, path: [], ), ]) Error(json.UnableToDecode(errors)) -> Error(errors) } } /// Decoder for the top-level Message type. pub fn message_decoder() -> Decoder(Message) { use id <- decode.field("id", decode.string) use role <- decode.field("role", role_decoder()) use content <- decode.field("content", decode.list(content_block_decoder())) use model <- decode.field("model", decode.string) use stop_reason <- decode.field( "stop_reason", decode.optional(stop_reason_decoder()), ) use stop_sequence <- decode.field( "stop_sequence", decode.optional(decode.string), ) use usage <- decode.field("usage", usage_decoder()) decode.success(Message( id: id, role: role, content: content, model: model, stop_reason: stop_reason, stop_sequence: stop_sequence, usage: usage, )) } /// Decoder for Role. pub fn role_decoder() -> Decoder(Role) { use s <- decode.then(decode.string) case s { "user" -> decode.success(User) "assistant" -> decode.success(Assistant) _ -> decode.failure(User, "Role") } } /// Decoder for StopReason. pub fn stop_reason_decoder() -> Decoder(StopReason) { use s <- decode.then(decode.string) case s { "end_turn" -> decode.success(EndTurn) "tool_use" -> decode.success(ToolUseStop) "max_tokens" -> decode.success(MaxTokens) "stop_sequence" -> decode.success(StopSequence) "pause_turn" -> decode.success(PauseTurn) "refusal" -> decode.success(Refusal) _ -> decode.failure(EndTurn, "StopReason") } } /// Decoder for Usage. pub fn usage_decoder() -> Decoder(Usage) { use input_tokens <- decode.field("input_tokens", decode.int) use output_tokens <- decode.field("output_tokens", decode.int) use cache_creation <- decode.optional_field( "cache_creation_input_tokens", None, decode.optional(decode.int), ) use cache_read <- decode.optional_field( "cache_read_input_tokens", None, decode.optional(decode.int), ) decode.success(Usage( input_tokens: input_tokens, output_tokens: output_tokens, cache_creation_input_tokens: cache_creation, cache_read_input_tokens: cache_read, )) } /// Decoder for ContentBlock — discriminates on the "type" field. fn content_block_decoder() -> Decoder(ContentBlock) { use block_type <- decode.field("type", decode.string) case block_type { "text" -> text_block_decoder() "thinking" -> thinking_block_decoder() "redacted_thinking" -> redacted_thinking_block_decoder() "tool_use" -> tool_use_block_decoder() "server_tool_use" -> server_tool_use_block_decoder() "web_search_result" -> web_search_result_block_decoder() _ -> decode.failure(Text(text: "", citations: []), "ContentBlock") } } /// Decoder for Text content block. fn text_block_decoder() -> Decoder(ContentBlock) { use text <- decode.field("text", decode.string) use citations <- decode.optional_field( "citations", [], decode.list(citation_decoder()), ) decode.success(Text(text: text, citations: citations)) } /// Decoder for Citation — discriminates on the "type" field. pub fn citation_decoder() -> Decoder(Citation) { use citation_type <- decode.field("type", decode.string) case citation_type { "char_location" -> { use cited_text <- decode.field("cited_text", decode.string) use document_index <- decode.field("document_index", decode.int) use start_char_index <- decode.field("start_char_index", decode.int) use end_char_index <- decode.field("end_char_index", decode.int) decode.success(CharLocationCitation( cited_text: cited_text, document_index: document_index, start_char_index: start_char_index, end_char_index: end_char_index, )) } "page_location" -> { use cited_text <- decode.field("cited_text", decode.string) use document_index <- decode.field("document_index", decode.int) use start_page <- decode.field("start_page", decode.int) use end_page <- decode.field("end_page", decode.int) decode.success(PageLocationCitation( cited_text: cited_text, document_index: document_index, start_page: start_page, end_page: end_page, )) } "content_block_location" -> { use cited_text <- decode.field("cited_text", decode.string) use document_index <- decode.field("document_index", decode.int) use start_block_index <- decode.field("start_block_index", decode.int) use end_block_index <- decode.field("end_block_index", decode.int) decode.success(ContentBlockLocationCitation( cited_text: cited_text, document_index: document_index, start_block_index: start_block_index, end_block_index: end_block_index, )) } "web_search_result_location" -> { use cited_text <- decode.field("cited_text", decode.string) use url <- decode.field("url", decode.string) use title <- decode.field("title", decode.string) decode.success(WebSearchResultLocationCitation( cited_text: cited_text, url: url, title: title, )) } _ -> decode.failure( CharLocationCitation( cited_text: "", document_index: 0, start_char_index: 0, end_char_index: 0, ), "Citation", ) } } /// Decoder for Thinking content block. fn thinking_block_decoder() -> Decoder(ContentBlock) { use thinking <- decode.field("thinking", decode.string) use signature <- decode.field("signature", decode.string) decode.success(Thinking(thinking: thinking, signature: signature)) } /// Decoder for RedactedThinking content block. fn redacted_thinking_block_decoder() -> Decoder(ContentBlock) { use data <- decode.field("data", decode.string) decode.success(RedactedThinking(data: data)) } /// Decoder for ToolUse content block. /// The "input" field is an arbitrary JSON object which we re-serialize to a string. fn tool_use_block_decoder() -> Decoder(ContentBlock) { use id <- decode.field("id", decode.string) use name <- decode.field("name", decode.string) use input_dynamic <- decode.field("input", dynamic_decoder()) // Re-serializing the already-parsed Dynamic value back to a JSON string for // storage in the ContentBlock. This string will be parsed again when dispatched // to a tool handler via tool.dispatch. This round-trip is a known trade-off of // the String-based ToolUse interface — it simplifies conversation history // encoding at the cost of an extra serialize-parse cycle. let input_string = dynamic_to_json_string(input_dynamic) decode.success(ToolUse(id: id, name: name, input: input_string)) } /// Decoder for ServerToolUse content block. fn server_tool_use_block_decoder() -> Decoder(ContentBlock) { use id <- decode.field("id", decode.string) use name <- decode.field("name", decode.string) use input_dynamic <- decode.field("input", dynamic_decoder()) let input_string = dynamic_to_json_string(input_dynamic) decode.success(ServerToolUse(id: id, name: name, input: input_string)) } /// Decoder for WebSearchResult content block. fn web_search_result_block_decoder() -> Decoder(ContentBlock) { use id <- decode.field("id", decode.string) use title <- decode.field("title", decode.string) use url <- decode.field("url", decode.string) use encrypted_content <- decode.field("encrypted_content", decode.string) decode.success(WebSearchResult( id: id, title: title, url: url, encrypted_content: encrypted_content, )) } /// A decoder that returns the raw Dynamic value without transforming it. /// This is used to capture the raw JSON value for ToolUse input. fn dynamic_decoder() -> Decoder(Dynamic) { decode.new_primitive_decoder("Dynamic", fn(dyn) { Ok(dyn) }) }