feat: Add ImageUrl struct and image_url support to ContentPart (#152)

- Add ImageUrl struct with url and detail fields
- Extend ContentPart with optional text and image_url fields
- Add std::optional serialization/deserialization support
- Custom to_json for ContentPart that only serializes relevant fields
- Custom json_to<ContentPart> for proper deserialization
- Add test_image_url.sh with comprehensive tests
- All 30 tests pass
This commit is contained in:
2026-06-11 21:40:30 +00:00
committed by Jacob Gamble
parent 068557b06c
commit 0ca0305ebf
3 changed files with 180 additions and 2 deletions
+103 -2
View File
@@ -3,6 +3,7 @@
#include <string>
#include <vector>
#include <expected>
#include <optional>
#include <iostream>
#include <cstdlib>
#include <regex>
@@ -93,6 +94,50 @@ std::expected<T, std::string> json_to(const nlohmann::json& j) {
return json_to_impl<T>::apply(j);
}
// ============================================================================
// std::optional serialization/deserialization support
// ============================================================================
// Partial specialization for std::optional<T> - always true since we handle it
template<typename T>
struct has_json_to<std::optional<T>> : std::true_type {};
// to_json for std::optional - serializes as null when empty, value when present
template<typename T>
void to_json(nlohmann::json& j, const std::optional<T>& opt) {
if (opt.has_value()) {
j = opt.value();
} else {
j = nullptr;
}
}
// json_to_impl specialization for std::optional<T>
// Deserializes null as std::nullopt, otherwise deserializes the inner type
template<typename T>
struct json_to_impl<std::optional<T>> {
static std::expected<std::optional<T>, std::string> apply(const nlohmann::json& j) {
if (j.is_null()) {
return std::optional<T>{};
}
if constexpr (has_json_to_v<T>) {
auto result = json_to<T>(j);
if (!result) {
return std::unexpected(result.error());
}
return std::optional<T>{std::move(result.value())};
} else {
try {
T value;
j.get_to(value);
return std::optional<T>{std::move(value)};
} catch (const nlohmann::json::exception& e) {
return std::unexpected(e.what());
}
}
}
};
// ============================================================================
// std::variant serialization/deserialization support
// ============================================================================
@@ -340,11 +385,67 @@ struct ToolCall {
};
DRH_DEFINE_TYPE_MIXED(ToolCall, (index), (id, type, function))
struct ImageUrl {
std::string url = "";
std::string detail = "auto";
};
DRH_DEFINE_TYPE_OPTIONAL(ImageUrl, url, detail)
struct ContentPart {
std::string type = "text";
std::string text = "";
std::optional<std::string> text;
std::optional<ImageUrl> image_url;
};
DRH_DEFINE_TYPE_OPTIONAL(ContentPart, type, text)
// Custom to_json for ContentPart - only serializes relevant fields based on type
inline void to_json(nlohmann::json& j, const ContentPart& cp) {
j = nlohmann::json{};
j["type"] = cp.type;
if (cp.type == "text" && cp.text.has_value()) {
j["text"] = cp.text.value();
} else if (cp.type == "image_url" && cp.image_url.has_value()) {
j["image_url"] = cp.image_url.value();
}
}
// Custom json_to for ContentPart
template<>
inline std::expected<ContentPart, std::string> json_to<ContentPart>(const nlohmann::json& j) {
ContentPart cp;
if (j.contains("type")) {
auto type_result = json_to_field<std::string>(j.at("type"), "type");
if (!type_result) return std::unexpected(type_result.error());
cp.type = std::move(type_result.value());
}
if (j.contains("text") && !j["text"].is_null()) {
auto text_result = json_to_field<std::string>(j.at("text"), "text");
if (!text_result) return std::unexpected(text_result.error());
cp.text = std::move(text_result.value());
}
if (j.contains("image_url") && !j["image_url"].is_null()) {
auto url_result = json_to<ImageUrl>(j.at("image_url"));
if (!url_result) return std::unexpected("field 'image_url': " + url_result.error());
cp.image_url = std::move(url_result.value());
}
return cp;
}
// Register has_json_to<ContentPart> trait
template<> struct has_json_to<ContentPart> : std::true_type {};
// Register ContentPart in json_dispatch_map for -R debug flag
inline const auto drh_register_ContentPart = []() {
json_dispatch_map["ContentPart"] = [](const nlohmann::json& j) {
auto result = json_to<ContentPart>(j);
if (result) std::cout << "success" << std::endl;
else std::cout << "error: " << result.error() << std::endl;
};
return 0u;
}();
struct Message {
std::string role = "";
+3
View File
@@ -59,3 +59,6 @@ add_dr_test(test_base64_encode)
# Add image file reading tests
add_dr_test(test_image_flag)
# Add ImageUrl and ContentPart tests
add_dr_test(test_image_url)
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# test_image_url.sh - Tests for ImageUrl and ContentPart with image_url support
# Test ImageUrl deserialization with url and detail fields
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{"url": "data:image/png;base64,abc123", "detail": "auto"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl with url and detail - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ImageUrl deserialization with only url field (detail should default to "auto")
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{"url": "https://example.com/image.png"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl with only url - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ImageUrl deserialization with empty object (should use defaults)
OUTPUT=$("${DRHARNESS}" -R ImageUrl '{}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ImageUrl empty object - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with text type
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "text", "text": "Hello world"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with text type - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with text type and only type field (text should be optional)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "text"}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with only type text - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123", "detail": "auto"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url type - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and minimal image_url (only url)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with minimal image_url - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with empty object (should use defaults)
OUTPUT=$("${DRHARNESS}" -R ContentPart '{}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart empty object - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and detail "low"
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc", "detail": "low"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url detail low - expected 'success', got '${OUTPUT}'"
exit 1
fi
# Test ContentPart with image_url type and detail "high"
OUTPUT=$("${DRHARNESS}" -R ContentPart '{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc", "detail": "high"}}')
if [ "${OUTPUT}" != "success" ]; then
echo "FAIL: ContentPart with image_url detail high - expected 'success', got '${OUTPUT}'"
exit 1
fi
echo "All ImageUrl and ContentPart tests passed!"