7#include <unordered_set>
14#include <core/value.h>
15#include <neograph/provider_outcome_codec.h>
47 bool tool_retryable =
false;
48 bool tool_effect_uncertain =
false;
78 std::string mime_type;
82 json metadata = json::object();
87class NEOGRAPH_API UsageAccumulator {
89 struct AuthoritySnapshot {
90 std::uint64_t charged = 0, reserved = 0;
91 std::vector<std::string> provider_effects;
93 bool has_report =
false;
95 AuthoritySnapshot authority_snapshot()
const {
96 std::lock_guard lock(mutex_);
97 AuthoritySnapshot result;
98 result.charged = charged_;
99 result.reserved = reserved_;
100 result.provider_effects.assign(provider_effects_.begin(), provider_effects_.end());
101 std::sort(result.provider_effects.begin(), result.provider_effects.end());
102 result.reports = reports_;
103 result.has_report = has_report_;
107 void seal_observation() {
108 std::lock_guard lock(mutex_);
109 observation_only_ =
true;
113 void restore_authority(AuthoritySnapshot snapshot) {
114 std::lock_guard lock(mutex_);
115 require_live_authority_locked();
116 if (authority_restored_ || charged_ != 0 || reserved_ != 0 || has_report_ || !provider_effects_.empty())
117 throw std::logic_error(
"Spending authority may only restore into a pristine bank");
118 std::unordered_set<std::string> effects;
119 effects.reserve(snapshot.provider_effects.size());
120 for (
auto& key : snapshot.provider_effects)
121 if (!effects.insert(std::move(key)).second)
122 throw std::invalid_argument(
"Spending authority snapshot has duplicate effect identities");
123 charged_ = snapshot.charged;
124 reserved_ = snapshot.reserved;
125 reports_ = std::move(snapshot.reports);
126 has_report_ = snapshot.has_report;
127 provider_effects_ = std::move(effects);
128 authority_restored_ =
true;
130 bool remember_provider_effect(std::string key) {
131 std::lock_guard lock(mutex_);
132 require_live_authority_locked();
133 return provider_effects_.insert(std::move(key)).second;
135 void observe(
const sp::Usage& usage) {
136 std::lock_guard lock(mutex_);
137 require_live_authority_locked();
140 void add(
const sp::Usage& usage) {
141 std::lock_guard lock(mutex_);
142 require_live_authority_locked();
144 if (
const auto charge = final_charge(usage)) charged_ = sum(charged_, *charge);
146 bool try_reserve(std::uint64_t tokens, std::uint64_t ceiling) {
147 std::lock_guard lock(mutex_);
148 require_live_authority_locked();
149 if (tokens == 0 || ceiling == 0)
return false;
150 const auto committed = sum(charged_, reserved_);
151 if (committed > ceiling || tokens > ceiling - committed)
return false;
152 reserved_ = sum(reserved_, tokens);
156 void release_reservation(std::uint64_t tokens) {
157 std::lock_guard lock(mutex_);
158 require_live_authority_locked();
159 reserved_ -= std::min(reserved_, tokens);
161 void settle_reservation(std::uint64_t reserved,
const sp::Usage& usage) {
162 std::lock_guard lock(mutex_);
163 require_live_authority_locked();
165 const auto charge = final_charge(usage);
167 const auto held = std::min(reserved_, reserved);
169 charged_ = sum(charged_, *charge);
171 sp::Usage snapshot()
const {
172 std::lock_guard lock(mutex_);
175 std::uint64_t total_tokens_wide() const noexcept {
176 std::lock_guard lock(mutex_);
177 return sum(charged_, reserved_);
181 void restore_charge(std::uint64_t amount) {
182 std::lock_guard lock(mutex_);
183 require_live_authority_locked();
184 charged_ = sum(charged_, amount);
186 void restore_reservation(std::uint64_t amount) {
187 std::lock_guard lock(mutex_);
188 require_live_authority_locked();
189 reserved_ = sum(reserved_, amount);
191 static std::optional<std::uint64_t> conservative_final_charge(
const sp::Usage& usage) {
192 return final_charge(usage);
195 void require_live_authority_locked()
const {
196 if (observation_only_)
197 throw std::logic_error(
"Recorded replay bank is observation-only");
199 static std::uint64_t sum(std::uint64_t a, std::uint64_t b)
noexcept {
200 return b > std::numeric_limits<std::uint64_t>::max() - a
201 ? std::numeric_limits<std::uint64_t>::max() : a + b;
203 static std::optional<std::uint64_t> final_charge(
const sp::Usage& usage) {
204 if (usage.stage != sp::UsageStage::Final || usage.quality != sp::UsageQuality::Consistent ||
205 !usage.input_total || !usage.output_total)
return {};
206 const auto input = usage.input_total->value, output = usage.output_total->value;
207 if (output > std::numeric_limits<std::uint64_t>::max() - input)
return {};
208 const auto total = input + output;
209 if ((usage.total && usage.total->value < total) ||
210 (usage.provider_reported_total && usage.provider_reported_total->value < total))
return {};
211 return std::max({total, usage.total ? usage.total->value : total,
212 usage.provider_reported_total ? usage.provider_reported_total->value : total});
214 void add_report(
const sp::Usage& usage) {
215 if (!has_report_) { reports_ = usage; has_report_ =
true;
return; }
219 if (!provider_codec::provider_cost_absent(reports_.provider_cost))
220 reports_.provider_cost = {};
221 auto fold = [&](std::string_view counter, std::optional<sp::Count>& target,
222 const std::optional<sp::Count>& value) {
223 if (!target || !value) { target.reset();
return; }
224 if (value->value > std::numeric_limits<std::uint64_t>::max() - target->value) {
226 reports_.quality = sp::UsageQuality::Inconsistent;
227 reports_.conflicts.push_back({std::string(counter),
"aggregate counter overflow"});
230 target->value += value->value;
231 target->evidence = sp::Evidence::Derived;
233 fold(
"input_total", reports_.input_total, usage.input_total);
234 fold(
"output_total", reports_.output_total, usage.output_total);
235 fold(
"total", reports_.total, usage.total);
236 fold(
"provider_reported_total", reports_.provider_reported_total, usage.provider_reported_total);
237 fold(
"input_uncached", reports_.input_uncached, usage.input_uncached);
238 fold(
"cache_read", reports_.cache_read, usage.cache_read);
239 fold(
"cache_write", reports_.cache_write, usage.cache_write);
240 fold(
"reasoning", reports_.reasoning, usage.reasoning);
241 for (
auto entry = reports_.extra.begin(); entry != reports_.extra.end();) {
242 const auto value = usage.extra.find(entry->first);
243 if (value == usage.extra.end()) { entry = reports_.extra.erase(entry);
continue; }
244 if (value->second.value > std::numeric_limits<std::uint64_t>::max() - entry->second.value) {
245 reports_.quality = sp::UsageQuality::Inconsistent;
246 reports_.conflicts.push_back({entry->first,
"aggregate counter overflow"});
247 entry = reports_.extra.erase(entry);
250 entry->second.value += value->second.value;
251 entry->second.evidence = sp::Evidence::Derived;
254 if (reports_.stage == sp::UsageStage::Missing && usage.stage == sp::UsageStage::Missing)
255 reports_.stage = sp::UsageStage::Missing;
256 else if (reports_.stage != sp::UsageStage::Final || usage.stage != sp::UsageStage::Final)
257 reports_.stage = sp::UsageStage::Partial;
258 if (usage.quality == sp::UsageQuality::Inconsistent) reports_.quality = usage.quality;
259 reports_.conflicts.insert(reports_.conflicts.end(), usage.conflicts.begin(), usage.conflicts.end());
261 mutable std::mutex mutex_;
263 bool has_report_ =
false;
264 bool authority_restored_ =
false;
265 bool observation_only_ =
false;
266 std::uint64_t charged_ = 0, reserved_ = 0;
267 std::unordered_set<std::string> provider_effects_;
276inline void to_json(json& j,
const ToolCall& tc) {
277 j = json{{
"id", tc.id}, {
"name", tc.name}, {
"arguments", tc.arguments}};
283inline void from_json(
const json& j, ToolCall& tc) {
284 tc.id = j.value(
"id",
"");
285 tc.name = j.value(
"name",
"");
286 tc.arguments = j.value(
"arguments",
"");
292inline void to_json(json& j,
const ChatMessage& msg) {
293 j[
"role"] = msg.role;
294 j[
"content"] = msg.content;
295 if (!msg.tool_calls.empty()) {
296 j[
"tool_calls"] = json::array();
297 for (
const auto& tc : msg.tool_calls) {
300 j[
"tool_calls"].push_back(tc_j);
303 if (!msg.tool_call_id.empty()) j[
"tool_call_id"] = msg.tool_call_id;
304 if (!msg.tool_name.empty()) j[
"tool_name"] = msg.tool_name;
305 if (!msg.tool_status.empty()) j[
"tool_status"] = msg.tool_status;
306 if (msg.tool_retryable) j[
"tool_retryable"] =
true;
307 if (msg.tool_effect_uncertain) j[
"tool_effect_uncertain"] =
true;
308 if (!msg.image_urls.empty()) j[
"image_urls"] = msg.image_urls;
309 if (!msg.reasoning.empty()) j[
"reasoning"] = msg.reasoning;
310 if (!msg.reasoning_details.empty()) j[
"reasoning_details"] = msg.reasoning_details;
316inline void from_json(
const json& j, ChatMessage& msg) {
317 msg.role = j.value(
"role",
"");
318 msg.content = j.value(
"content",
"");
319 if (j.contains(
"tool_calls") && j[
"tool_calls"].is_array()) {
320 for (
const auto& tc_j : j[
"tool_calls"]) {
323 msg.tool_calls.push_back(tc);
326 msg.tool_call_id = j.value(
"tool_call_id",
"");
327 msg.tool_name = j.value(
"tool_name",
"");
328 msg.tool_status = j.value(
"tool_status",
"");
329 msg.tool_retryable = j.value(
"tool_retryable",
false);
330 msg.tool_effect_uncertain = j.value(
"tool_effect_uncertain",
false);
331 if (j.contains(
"image_urls") && j[
"image_urls"].is_array()) {
332 msg.image_urls = j[
"image_urls"].get<std::vector<std::string>>();
334 msg.reasoning = j.value(
"reasoning",
"");
335 if (j.contains(
"reasoning_details") && !j[
"reasoning_details"].is_array()) {
336 throw std::invalid_argument(
"ChatMessage reasoning_details must be an array");
338 msg.reasoning_details = j.contains(
"reasoning_details")
339 ? j[
"reasoning_details"]
394 : std::runtime_error(std::get<sp::Failure>(*outcome).error.safe_message),
395 outcome_(std::move(outcome)) {}
396 const std::shared_ptr<const sp::Outcome>& outcome()
const noexcept {
return outcome_; }
398 std::shared_ptr<const sp::Outcome> outcome_;
401inline std::shared_ptr<const sp::Outcome> outcome_or_throw(std::shared_ptr<const sp::Outcome> result) {
402 if (!result)
throw std::invalid_argument(
"Provider returned no owned outcome");
403 if (std::holds_alternative<sp::Failure>(*result))
throw ProviderFailure(result);
406inline const std::vector<sp::Message>& outcome_messages(
const sp::Outcome& outcome)
noexcept {
407 if (
const auto* completion = std::get_if<sp::Completion>(&outcome))
return completion->messages;
408 return std::get<sp::Failure>(outcome).partial.messages;
410inline const sp::Usage& outcome_usage(
const sp::Outcome& outcome)
noexcept {
411 if (
const auto* completion = std::get_if<sp::Completion>(&outcome))
return completion->usage;
412 return std::get<sp::Failure>(outcome).partial.usage;
417inline bool usage_holds_nothing(
const sp::Usage& usage)
noexcept {
418 return usage.stage == sp::UsageStage::Missing &&
419 usage.quality == sp::UsageQuality::Consistent &&
420 !usage.input_total && !usage.output_total && !usage.total && !usage.provider_reported_total &&
421 !usage.input_uncached && !usage.cache_read && !usage.cache_write && !usage.reasoning &&
422 usage.extra.empty() && usage.conflicts.empty() &&
423 provider_codec::provider_cost_absent(usage.provider_cost);
428inline bool http_status_declines_request(
int status)
noexcept {
429 return status >= 400 && status < 500 && status != 408 && status != 425 && status != 429;
436inline bool provider_failure_proves_not_sent(
const sp::Failure& failure)
noexcept {
437 const auto& attempt = failure.error.attempt;
438 const auto& partial = failure.partial;
439 return failure.error.retry_safety == sp::RetrySafety::NotSent &&
440 !attempt.request_may_have_left && attempt.request_body_bytes == 0 &&
441 !attempt.response_head_seen && attempt.transport_internal_resends == 0 && !attempt.prior_usage_unknown &&
442 partial.messages.empty() && !partial.stop && !partial.wire_envelope &&
443 partial.raw_events.empty() && detail::usage_holds_nothing(partial.usage);
451inline bool provider_failure_proves_rejected_before_output(
const sp::Failure& failure)
noexcept {
452 const auto& attempt = failure.error.attempt;
453 const auto& partial = failure.partial;
454 return failure.error.retry_safety == sp::RetrySafety::RejectedBeforeOutput &&
455 detail::http_status_declines_request(failure.error.http_status) &&
456 attempt.request_may_have_left && attempt.response_head_seen &&
457 attempt.transport_internal_resends == 0 && !attempt.prior_usage_unknown &&
458 partial.messages.empty() && !partial.stop && !partial.wire_envelope &&
459 std::all_of(partial.raw_events.begin(), partial.raw_events.end(),
460 [](
const sp::RawWire& event) { return event.type ==
"http.error"; }) &&
461 detail::usage_holds_nothing(partial.usage);
472inline bool provider_failure_proves_not_accepted(
const sp::Failure& failure)
noexcept {
473 return provider_failure_proves_not_sent(failure) ||
474 provider_failure_proves_rejected_before_output(failure);
476inline std::string outcome_text(
const sp::Outcome& outcome) {
478 for (
const auto& message : outcome_messages(outcome))
479 for (const auto& part : message.parts)
480 if (const auto* value = std::get_if<sp::Text>(&part)) text += value->value;
483inline std::vector<ToolCall> client_tool_calls(
const sp::Message& message) {
484 std::vector<ToolCall> result;
485 for (
const auto& part : message.parts) if (const auto* call = std::get_if<sp::ToolCall>(&part);
486 call && call->kind == sp::ToolCallKind::ClientExecuted && call->input && call->input->root().is_object())
487 result.push_back({call->id, call->name, call->input->root().dump()});
492inline bool paused_turn(
const sp::Outcome& outcome)
noexcept {
493 const auto* completion = std::get_if<sp::Completion>(&outcome);
494 return completion && completion->stop.kind == sp::StopKind::PauseTurn;
496inline std::vector<ToolCall> pending_client_tool_calls(
const std::vector<sp::Message>& history) {
497 std::map<std::string_view, const sp::ToolCall*> pending;
498 std::vector<const sp::ToolCall*> order;
499 for (
const auto& message : history) for (const auto& part : message.parts) {
500 if (
const auto* call = std::get_if<sp::ToolCall>(&part);
501 call && call->kind == sp::ToolCallKind::ClientExecuted && call->input &&
502 call->input->root().is_object()) {
503 if (!pending.emplace(call->id, call).second)
504 throw std::invalid_argument(
"Client tool call history has duplicate unresolved identities");
505 order.push_back(call);
506 }
else if (
const auto* result = std::get_if<sp::ToolResult>(&part)) {
507 pending.erase(result->tool_use_id);
510 std::vector<ToolCall> result;
511 result.reserve(pending.size());
512 for (
const auto* call : order) {
513 const auto found = pending.find(call->id);
514 if (found != pending.end() && found->second == call)
515 result.push_back({call->id, call->name, call->input->root().dump()});
519inline sp::Role portable_role(std::string_view role) {
520 if (role ==
"system")
return sp::Role::System;
521 if (role ==
"developer")
return sp::Role::Developer;
522 if (role ==
"user")
return sp::Role::User;
523 if (role ==
"assistant")
return sp::Role::Assistant;
524 if (role ==
"tool")
return sp::Role::Tool;
525 throw std::invalid_argument(
"Invalid portable message role");
527inline std::string_view portable_role_name(sp::Role role) {
529 case sp::Role::System:
return "system";
530 case sp::Role::Developer:
return "developer";
531 case sp::Role::User:
return "user";
532 case sp::Role::Assistant:
return "assistant";
533 case sp::Role::Tool:
return "tool";
535 throw std::invalid_argument(
"Invalid typed message role");
537inline sp::Message portable_message(
const ChatMessage& source) {
538 if (!source.reasoning.empty() || !source.reasoning_details.empty())
539 throw std::invalid_argument(
"Portable projection cannot import native reasoning authority");
541 message.role = portable_role(source.role);
542 if (message.role == sp::Role::Tool) {
543 if (!source.tool_calls.empty() || !source.image_urls.empty())
544 throw std::invalid_argument(
"Portable tool result cannot contain calls or images");
545 message.parts.emplace_back(sp::ToolResult{source.tool_call_id, source.content,
546 source.tool_status !=
"succeeded" && !source.tool_status.empty(),
547 sp::ToolResultHostMetadata{source.tool_name, source.tool_status,
548 source.tool_retryable, source.tool_effect_uncertain}});
551 if (!source.content.empty()) message.parts.emplace_back(sp::Text{source.content});
552 for (
const auto& url : source.image_urls) {
555 if (url.starts_with(
"data:")) {
556 const auto separator = url.find(
";base64,");
557 if (separator == std::string::npos)
558 throw std::invalid_argument(
"Portable inline image requires a base64 data URL");
559 image = sp::Media::image(url.substr(5, separator - 5),
560 std::make_shared<const std::string>(url.substr(separator + 8)));
562 image = sp::Media::url(sp::MediaKind::Image, url);
564 if (sp::check_media(image, sp::config_defaults::codec_resources_image_decoded_bytes) != sp::MediaFault::None)
565 throw std::invalid_argument(
"Invalid portable image");
566 message.parts.emplace_back(std::move(image));
568 for (
const auto& call : source.tool_calls) {
569 auto parsed = sp::json::parse(call.arguments);
570 auto* document = std::get_if<sp::json::Document>(&parsed);
571 if (!document || !document->root().is_object())
572 throw std::invalid_argument(
"Portable tool call requires admitted object arguments");
575 typed.name = call.name;
576 typed.input = std::make_shared<const sp::json::Document>(std::move(*document));
577 message.parts.emplace_back(std::move(typed));
582inline ChatMessage project_message(
const sp::Message& message) {
584 result.
role = portable_role_name(message.role);
585 result.tool_calls = client_tool_calls(message);
586 for (
const auto& part : message.parts) {
587 if (
const auto* text = std::get_if<sp::Text>(&part)) result.content += text->value;
588 else if (
const auto* thinking = std::get_if<sp::Thinking>(&part)) result.reasoning += thinking->text;
589 else if (
const auto* tool = std::get_if<sp::ToolResult>(&part)) {
590 result.tool_call_id = tool->tool_use_id;
591 result.content += tool->content;
592 result.tool_status = tool->is_error ?
"failed" :
"succeeded";
594 result.tool_name = tool->host->name;
595 result.tool_status = tool->host->status;
596 result.tool_retryable = tool->host->retryable;
597 result.tool_effect_uncertain = tool->host->effect_uncertain;
599 }
else if (
const auto* media = std::get_if<sp::Media>(&part);
600 media && media->kind == sp::MediaKind::Image) {
601 if (media->source == sp::MediaSource::Inline && media->data)
602 result.image_urls.push_back(
"data:" + media->mime +
";base64," + *media->data);
603 else if (media->source == sp::MediaSource::Url)
604 result.image_urls.push_back(media->reference);
609inline json message_projection_json(
const sp::Message& message) {
610 return provider_codec::encode_message(message);
612inline json usage_to_json(
const sp::Usage& usage) {
return provider_codec::encode_usage(usage); }
613inline json outcome_projection_json(
const sp::Outcome& outcome) {
614 return provider_codec::observe_outcome(outcome);
616NEOGRAPH_API std::string message_digest(
const sp::Message& message);
How provider stop reasons reach callers.
Thin C++ RAII wrapper around yyjson with nlohmann-compatible API.
A message in the conversation history.
std::string reasoning
Provider-returned reasoning text. Keep separate from user-visible content.
std::string tool_call_id
ID of the tool call being responded to (role == "tool").
json reasoning_details
Opaque provider-native continuation blocks replayed only on a compatible route.
std::string tool_name
Name of the tool being called.
std::string content
Text content of the message.
std::vector< std::string > image_urls
Base64 data URLs or HTTP URLs for vision support.
std::string role
Message role: "user", "assistant", "tool", or "system".
std::string tool_status
Typed terminal status emitted by the shared ToolExecutionController.
std::vector< ToolCall > tool_calls
Tool calls made by the assistant (if any).
Generated provider media.
std::string kind
"image", "video", or "file".
std::string base64_data
Encoded bytes, not decoded or copied into a second buffer.