ReUseX
0.0.5
3D Point Cloud Processing for Building Reuse
Toggle main menu visibility
Loading...
Searching...
No Matches
vlm_protocol.hpp
Go to the documentation of this file.
1
// SPDX-FileCopyrightText: 2026 Povl Filip Sonne-Frederiksen
2
//
3
// SPDX-License-Identifier: GPL-3.0-or-later
4
5
#pragma once
6
7
// Provider-agnostic OpenAI-compatible chat/completions protocol for the VLM
8
// attributes pass (#373).
9
//
10
// Deliberately header-only and network-free: the two functions here โ building
11
// the request body and parsing the response body โ are pure string/JSON
12
// transforms with NO dependency on libcurl or reusex_vision. That lets them be
13
// unit-tested directly in the LIGHT test binary (which does not link
14
// reusex_vision), which is the whole point: the transport is a thin wrapper
15
// nobody tests in CI, but the request shape and the (defensive) parser are
16
// where the bugs live, so those must be testable without a server.
17
18
#include <nlohmann/json.hpp>
19
20
#include <optional>
21
#include <string>
22
#include <utility>
23
#include <vector>
24
25
namespace
reusex::vision
{
26
31
struct
VlmResult
{
32
std::string
description
;
33
std::vector<std::pair<std::string, std::string>>
34
attributes
;
35
std::string
raw_json
;
36
bool
ok
=
false
;
38
};
39
51
inline
nlohmann::json
build_chat_request_json
(
const
std::string &model,
52
const
std::string &b64_jpeg,
53
const
std::string &prompt) {
54
nlohmann::json image_url;
55
image_url[
"url"
] =
"data:image/jpeg;base64,"
+ b64_jpeg;
56
57
nlohmann::json content = nlohmann::json::array();
58
content.push_back({{
"type"
,
"text"
}, {
"text"
, prompt}});
59
content.push_back({{
"type"
,
"image_url"
}, {
"image_url"
, image_url}});
60
61
nlohmann::json message;
62
message[
"role"
] =
"user"
;
63
message[
"content"
] = content;
64
65
nlohmann::json req;
66
req[
"model"
] = model;
67
req[
"messages"
] = nlohmann::json::array({message});
68
req[
"temperature"
] = 0;
69
req[
"stream"
] =
false
;
70
// Ollama's native key.
71
req[
"format"
] =
"json"
;
72
// OpenAI's key (ignored by servers that don't support it).
73
req[
"response_format"
] = {{
"type"
,
"json_object"
}};
74
return
req;
75
}
76
77
namespace
detail
{
78
82
inline
std::string
strip_json_fence
(std::string s) {
83
auto
trim = [](std::string &t) {
84
const
char
*ws =
" \t\r\n"
;
85
t.erase(0, t.find_first_not_of(ws));
86
const
auto
end = t.find_last_not_of(ws);
87
t.erase(end == std::string::npos ? 0 : end + 1);
88
};
89
trim(s);
90
if
(s.rfind(
"```"
, 0) == 0) {
91
// Drop the opening fence line (```json or ```), then the closing fence.
92
const
auto
nl = s.find(
'\n'
);
93
if
(nl != std::string::npos)
94
s.erase(0, nl + 1);
95
else
96
s.erase(0, 3);
97
const
auto
close = s.rfind(
"```"
);
98
if
(close != std::string::npos)
99
s.erase(close);
100
trim(s);
101
}
102
return
s;
103
}
104
108
inline
bool
is_chat_envelope
(
const
nlohmann::json &body) {
109
if
(!body.is_object())
110
return
false
;
111
if
(body.contains(
"choices"
) && body[
"choices"
].is_array() &&
112
!body[
"choices"
].empty()) {
113
const
auto
&first = body[
"choices"
][0];
114
if
(first.is_object() && first.contains(
"message"
) &&
115
first[
"message"
].is_object() && first[
"message"
].contains(
"content"
))
116
return
true
;
117
}
118
// Ollama's /api/chat shape: { "message": { "content": "..." } }
119
if
(body.contains(
"message"
) && body[
"message"
].is_object() &&
120
body[
"message"
].contains(
"content"
))
121
return
true
;
122
return
false
;
123
}
124
131
inline
std::string
extract_message_content
(
const
nlohmann::json &body) {
132
if
(body.is_object() && body.contains(
"choices"
) &&
133
body[
"choices"
].is_array() && !body[
"choices"
].empty()) {
134
const
auto
&first = body[
"choices"
][0];
135
if
(first.contains(
"message"
) && first[
"message"
].contains(
"content"
) &&
136
first[
"message"
][
"content"
].is_string())
137
return
first[
"message"
][
"content"
].get<std::string>();
138
}
139
// Ollama's /api/chat shape: { "message": { "content": "..." } }
140
if
(body.is_object() && body.contains(
"message"
) &&
141
body[
"message"
].contains(
"content"
) &&
142
body[
"message"
][
"content"
].is_string())
143
return
body[
"message"
][
"content"
].get<std::string>();
144
return
{};
145
}
146
149
inline
std::optional<std::string>
scalar_to_string
(
const
nlohmann::json &v) {
150
if
(v.is_string())
151
return
v.get<std::string>();
152
if
(v.is_boolean())
153
return
v.get<
bool
>() ? std::string(
"true"
) : std::string(
"false"
);
154
if
(v.is_number_integer())
155
return
std::to_string(v.get<
long
long
>());
156
if
(v.is_number_unsigned())
157
return
std::to_string(v.get<
unsigned
long
long
>());
158
if
(v.is_number_float())
159
return
v.dump();
// shortest round-trippable form
160
return
std::nullopt;
161
}
162
164
inline
bool
is_description_key
(
const
std::string &k) {
165
return
k ==
"description"
|| k ==
"desc"
|| k ==
"summary"
;
166
}
167
168
}
// namespace detail
169
194
inline
VlmResult
parse_vlm_response
(
const
std::string &body) {
195
VlmResult
result;
196
result.
raw_json
= body;
197
198
nlohmann::json envelope = nlohmann::json::parse(body,
nullptr
,
false
);
199
200
std::string content;
201
if
(!envelope.is_discarded() &&
detail::is_chat_envelope
(envelope)) {
202
// A chat envelope was returned: the payload is its assistant content, and
203
// ONLY that. If the content is empty the model returned nothing usable โ
204
// do not fall back to re-parsing the envelope object itself.
205
content =
detail::extract_message_content
(envelope);
206
if
(content.empty())
207
return
result;
// ok stays false
208
}
else
{
209
// No chat envelope (or the body did not parse): treat the whole body as the
210
// candidate payload, so a bare attributes JSON object still parses.
211
content = body;
212
}
213
214
const
std::string inner =
detail::strip_json_fence
(content);
215
nlohmann::json obj = nlohmann::json::parse(inner,
nullptr
,
false
);
216
if
(obj.is_discarded() || !obj.is_object())
217
return
result;
// ok stays false
218
219
// Collect attributes de-duplicated (last value wins), preserving first-seen
220
// key order so a caller sees a stable list even before the DB re-sorts it.
221
std::vector<std::pair<std::string, std::string>> attrs;
222
auto
put_attr = [&attrs](
const
std::string &key,
const
std::string &value) {
223
for
(
auto
&kv : attrs) {
224
if
(kv.first == key) {
225
kv.second = value;
226
return
;
227
}
228
}
229
attrs.emplace_back(key, value);
230
};
231
232
// Description: a description/desc/summary key at the top level.
233
for
(
auto
it = obj.begin(); it != obj.end(); ++it) {
234
if
(
detail::is_description_key
(it.key()) && it.value().is_string()) {
235
result.
description
= it.value().get<std::string>();
236
break
;
237
}
238
}
239
240
// Nested attributes object, if present.
241
if
(obj.contains(
"attributes"
) && obj[
"attributes"
].is_object()) {
242
for
(
auto
it = obj[
"attributes"
].begin(); it != obj[
"attributes"
].end();
243
++it) {
244
if
(
auto
v =
detail::scalar_to_string
(it.value()))
245
put_attr(it.key(), *v);
246
}
247
}
248
249
// Flat attributes: every OTHER top-level scalar field. Skips the description
250
// key(s) and the nested `attributes` object handled above.
251
for
(
auto
it = obj.begin(); it != obj.end(); ++it) {
252
if
(
detail::is_description_key
(it.key()))
253
continue
;
254
if
(it.key() ==
"attributes"
&& it.value().is_object())
255
continue
;
256
if
(
auto
v =
detail::scalar_to_string
(it.value()))
257
put_attr(it.key(), *v);
258
}
259
260
result.
attributes
= std::move(attrs);
261
262
// Success requires a non-empty description OR at least one attribute: never
263
// report ok on an object that carried neither (STANDARDS ยง5, no empty row).
264
result.
ok
= !result.
description
.empty() || !result.
attributes
.empty();
265
return
result;
266
}
267
268
}
// namespace reusex::vision
reusex::vision::detail
Definition
vlm_protocol.hpp:77
reusex::vision::detail::strip_json_fence
std::string strip_json_fence(std::string s)
Strip a leading/trailing markdown json ... fence if present, and trim surrounding whitespace.
Definition
vlm_protocol.hpp:82
reusex::vision::detail::scalar_to_string
std::optional< std::string > scalar_to_string(const nlohmann::json &v)
Stringify a JSON scalar (string / number / bool).
Definition
vlm_protocol.hpp:149
reusex::vision::detail::is_chat_envelope
bool is_chat_envelope(const nlohmann::json &body)
Whether a parsed body is an OpenAI-/Ollama-style chat envelope, i.e.
Definition
vlm_protocol.hpp:108
reusex::vision::detail::extract_message_content
std::string extract_message_content(const nlohmann::json &body)
Pull the assistant message text out of an OpenAI-style chat response.
Definition
vlm_protocol.hpp:131
reusex::vision::detail::is_description_key
bool is_description_key(const std::string &k)
True for any of the keys treated as the free-text description slot.
Definition
vlm_protocol.hpp:164
reusex::vision
Definition
annotate.hpp:12
reusex::vision::build_chat_request_json
nlohmann::json build_chat_request_json(const std::string &model, const std::string &b64_jpeg, const std::string &prompt)
Build an OpenAI-compatible chat/completions request body for one image.
Definition
vlm_protocol.hpp:51
reusex::vision::parse_vlm_response
VlmResult parse_vlm_response(const std::string &body)
Parse an OpenAI-compatible chat/completions response into a VlmResult.
Definition
vlm_protocol.hpp:194
reusex::vision::VlmResult
The description + arbitrary attributes a vision-language model returned for one crop.
Definition
vlm_protocol.hpp:31
reusex::vision::VlmResult::raw_json
std::string raw_json
The raw response body, verbatim.
Definition
vlm_protocol.hpp:35
reusex::vision::VlmResult::ok
bool ok
True only when a non-empty description OR at least one attribute was recovered (never fabricated).
Definition
vlm_protocol.hpp:36
reusex::vision::VlmResult::attributes
std::vector< std::pair< std::string, std::string > > attributes
Arbitrary key/value pairs the model returned.
Definition
vlm_protocol.hpp:34
reusex::vision::VlmResult::description
std::string description
Free-text description of the object/material.
Definition
vlm_protocol.hpp:32
libs
reusex
include
vision
vlm_protocol.hpp
Generated by
1.18.0