yams/plugins/glint/plugin.cpp
trvon cf02477125
fix(glint): load ONNX Runtime at runtime instead of linking it
On Linux the Glint plugin always built in mock mode: meson found the ONNX
Runtime headers but its find_library('onnxruntime') looked in a libdir
taken from the already-normalized dependency, found nothing, and dropped
YAMS_USE_ONNX_RUNTIME, so GLiNER never ran (the v0.20.3 deb's glint has
no ONNX Runtime dependency either). On macOS it linked Homebrew's copy
directly.

Glint now uses the shared ONNX Runtime loader like the ONNX plugin: it
compiles against the headers only (ORT_API_MANUAL_INIT through
ort_cxx_api_wrapper.h), and before creating the session it resolves a
runtime with the same system-first, bundled-fallback policy. If none is
available the session reports "ONNX Runtime unavailable: ..." and entity
extraction is disabled instead of silently running the mock. Plugin
health gains an onnx_runtime object (available, source, version, path or
error), and [plugins.glint] runtime_library pins a copy.

Verified on grim (Linux, release, staged tree in a clean debian:testing
container): "Glint plugin: ONNX Runtime enabled (loaded at runtime)" at
configure, "[Glint] Loaded ONNX model with 6 inputs, 5 outputs" at run
time, and the daemon's title+NL stage extracts entities ("nl=5" for a
document naming people, companies, a place and a date).

Signed-off-by: trvon <me@trevon.dev>
2026-10-01 22:42:15 -06:00

413 lines
14 KiB
C++

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <yams/daemon/resource/OnnxConcurrencyRegistry.h>
#include <yams/plugins/abi.h>
#include <yams/plugins/entity_extractor_v2.h>
#include "gliner_session.h"
#ifdef YAMS_USE_ONNX_RUNTIME
#include "ort_runtime_loader.h"
#endif
namespace {
// Global plugin state
struct GlintPluginContext {
std::mutex mutex;
std::unique_ptr<yams::glint::GlinerSession> session;
yams::glint::GlinerConfig config;
bool initialized = false;
std::string last_error;
// Default entity labels for NL text
// GLiNER is zero-shot and can extract any entity type. Labels should be lower/title case.
// See: https://github.com/urchade/GLiNER, https://arxiv.org/html/2504.00676v1
std::vector<std::string> default_labels = {
// General entities (standard NER)
"person", "organization", "location", "date", "time", "event", "money", "percent",
// Products and technology
"product", "technology", "software", "hardware", "programming language", "framework",
// Scientific/biomedical entities (from GLiNER-biomed research)
"gene", "protein", "disease", "chemical", "drug", "species", "cell type", "method",
// Abstract concepts
"concept", "topic", "field of study", "theory", "algorithm"};
};
// Use leaky singleton pattern to avoid static destruction order issues
// The context will be allocated once and never freed - this is intentional
// to prevent crashes during program exit when other statics may still reference it
static GlintPluginContext& get_ctx() {
static GlintPluginContext* ctx = new GlintPluginContext();
return *ctx;
}
void ensure_plugin_logger() {
try {
if (spdlog::default_logger()) {
return;
}
auto sink = std::make_shared<spdlog::sinks::stderr_color_sink_mt>();
auto logger = std::make_shared<spdlog::logger>("yams_glint", sink);
logger->set_level(spdlog::level::info);
spdlog::set_default_logger(std::move(logger));
} catch (const spdlog::spdlog_ex&) {
if (auto logger = spdlog::get("yams_glint")) {
spdlog::set_default_logger(std::move(logger));
}
} catch (const std::exception& e) {
std::fprintf(stderr, "[Glint] Failed to initialize plugin logger: %s\n", e.what());
} catch (...) {
std::fprintf(stderr, "[Glint] Failed to initialize plugin logger: unknown error\n");
}
}
// C-ABI ownership: the returned string is owned by the result struct handed to the
// host and released via free_result_abi; do not free here.
char* dup_cstr(const std::string& s) {
char* p = static_cast<char*>(std::malloc(s.size() + 1));
if (p)
std::memcpy(p, s.c_str(), s.size() + 1);
return p;
}
void free_result_abi(void*, yams_entity_extraction_result_v2* res);
// Initialize session lazily
bool ensure_session() {
std::lock_guard<std::mutex> lock(get_ctx().mutex);
if (get_ctx().session && get_ctx().session->is_ready()) {
return true;
}
// Create session with config
get_ctx().config.entity_labels = get_ctx().default_labels;
get_ctx().session = std::make_unique<yams::glint::GlinerSession>(get_ctx().config);
if (!get_ctx().session->initialize()) {
get_ctx().last_error = std::string(get_ctx().session->last_error());
std::fprintf(stderr, "[Glint] Failed to initialize session: %s\n",
get_ctx().last_error.c_str());
return false;
}
get_ctx().initialized = true;
std::fprintf(stderr, "[Glint] Session initialized successfully\n");
return true;
}
bool supports_abi(void*, const char* content_type) {
if (!content_type)
return false;
std::string ct(content_type);
return ct == "text/plain" || ct == "text/markdown" || ct == "application/json";
}
int extract_abi(void*, const char* content, size_t content_len,
const yams_entity_extraction_options_v2* options,
yams_entity_extraction_result_v2** out) {
if (!out)
return YAMS_PLUGIN_ERR_INVALID;
*out = nullptr;
auto* r = static_cast<yams_entity_extraction_result_v2*>(
std::calloc(1, sizeof(yams_entity_extraction_result_v2)));
if (!r)
return YAMS_PLUGIN_ERR_INVALID;
r->entities = nullptr;
r->entity_count = 0;
r->relations = nullptr;
r->relation_count = 0;
r->error = nullptr;
// Check for empty input
if (!content || content_len == 0) {
*out = r;
return YAMS_PLUGIN_OK;
}
// Ensure session is initialized
if (!ensure_session()) {
r->error = dup_cstr(get_ctx().last_error);
if (!r->error) {
free_result_abi(nullptr, r);
return YAMS_PLUGIN_ERR_INVALID;
}
*out = r;
return YAMS_PLUGIN_OK; // Return OK but with error message
}
// Parse custom entity labels from options if provided
std::vector<std::string> labels;
if (options && options->entity_types && options->entity_type_count > 0) {
labels.reserve(options->entity_type_count);
for (size_t i = 0; i < options->entity_type_count; ++i) {
if (options->entity_types[i]) {
labels.emplace_back(options->entity_types[i]);
}
}
}
// Extract entities (acquire ONNX slot for fairness with embeddings/reranker)
std::string_view text(content, content_len);
std::vector<yams::glint::EntitySpan> spans;
{
yams::daemon::OnnxConcurrencyRegistry::SlotGuard slotGuard(
yams::daemon::OnnxConcurrencyRegistry::instance(), yams::daemon::OnnxLane::Gliner);
if (!slotGuard.acquired()) {
r->error = dup_cstr("ONNX slot timeout for GLiNER");
if (!r->error) {
free_result_abi(nullptr, r);
return YAMS_PLUGIN_ERR_INVALID;
}
*out = r;
return YAMS_PLUGIN_OK;
}
std::lock_guard<std::mutex> lock(get_ctx().mutex);
if (labels.empty()) {
spans = get_ctx().session->extract(text);
} else {
spans = get_ctx().session->extract(text, labels);
}
}
// Convert to ABI format
if (!spans.empty()) {
r->entities =
static_cast<yams_entity_v2*>(std::calloc(spans.size(), sizeof(yams_entity_v2)));
if (!r->entities) {
std::free(r);
return YAMS_PLUGIN_ERR_INVALID;
}
r->entity_count = spans.size();
for (size_t i = 0; i < spans.size(); ++i) {
const auto& span = spans[i];
r->entities[i].text = dup_cstr(span.text);
r->entities[i].type = dup_cstr(span.label);
r->entities[i].start_offset = static_cast<uint32_t>(span.start_char);
r->entities[i].end_offset = static_cast<uint32_t>(span.end_char);
r->entities[i].confidence = span.score;
r->entities[i].qualified_name = nullptr;
r->entities[i].scope = nullptr;
// Add properties JSON with word indices
nlohmann::json props;
props["start_word"] = span.start_word;
props["end_word"] = span.end_word;
r->entities[i].properties_json = dup_cstr(props.dump());
if (!r->entities[i].text || !r->entities[i].type || !r->entities[i].properties_json) {
free_result_abi(nullptr, r);
return YAMS_PLUGIN_ERR_INVALID;
}
}
}
*out = r;
return YAMS_PLUGIN_OK;
}
void free_result_abi(void*, yams_entity_extraction_result_v2* res) {
if (!res)
return;
if (res->entities) {
for (size_t i = 0; i < res->entity_count; ++i) {
std::free(res->entities[i].text);
std::free(res->entities[i].type);
std::free(res->entities[i].qualified_name);
std::free(res->entities[i].scope);
std::free(res->entities[i].properties_json);
}
std::free(res->entities);
}
if (res->relations) {
for (size_t i = 0; i < res->relation_count; ++i) {
std::free(res->relations[i].src_entity);
std::free(res->relations[i].dst_entity);
std::free(res->relations[i].relation_type);
}
std::free(res->relations);
}
std::free(res->error);
std::free(res);
}
int get_capabilities_json_abi(void*, char** out_json) {
if (!out_json)
return YAMS_PLUGIN_ERR_INVALID;
nlohmann::json caps;
caps["content_types"] = {"text/plain", "text/markdown", "application/json"};
caps["entity_types"] = get_ctx().default_labels;
caps["model"] = "gliner";
caps["version"] = "0.1.0";
caps["supports_custom_labels"] = true;
caps["max_width"] = get_ctx().config.max_width;
caps["threshold"] = get_ctx().config.threshold;
*out_json = dup_cstr(caps.dump(2));
return *out_json ? YAMS_PLUGIN_OK : YAMS_PLUGIN_ERR_INVALID;
}
void free_string_abi(void*, char* s) {
std::free(s);
}
} // namespace
extern "C" {
YAMS_PLUGIN_API int yams_plugin_get_abi_version(void) {
return YAMS_PLUGIN_ABI_VERSION;
}
YAMS_PLUGIN_API const char* yams_plugin_get_name(void) {
return "glint";
}
YAMS_PLUGIN_API const char* yams_plugin_get_version(void) {
return "0.1.0";
}
YAMS_PLUGIN_API const char* yams_plugin_get_manifest_json(void) {
return R"({
"name": "glint",
"version": "0.1.0",
"interfaces": [
{"id": "entity_extractor_v2", "version": 1}
],
"description": "Glint - GLiNER-based named entity extraction for natural language text",
"requires": ["onnx"]
})";
}
YAMS_PLUGIN_API int yams_plugin_init(const char* config_json, const void* host_context) {
(void)host_context;
ensure_plugin_logger();
// Parse configuration
if (config_json && *config_json) {
try {
auto cfg = nlohmann::json::parse(config_json, nullptr, false);
if (!cfg.is_discarded()) {
if (cfg.contains("model_path") && cfg["model_path"].is_string()) {
get_ctx().config.model_path = cfg["model_path"].get<std::string>();
}
if (cfg.contains("tokenizer_path") && cfg["tokenizer_path"].is_string()) {
get_ctx().config.tokenizer_path = cfg["tokenizer_path"].get<std::string>();
}
#ifdef YAMS_USE_ONNX_RUNTIME
// Optional pin for the ONNX Runtime copy ([plugins.glint] runtime_library).
if (cfg.contains("runtime_library") && cfg["runtime_library"].is_string()) {
yams::onnx_util::OrtRuntimeLoader::instance().setConfiguredLibrary(
cfg["runtime_library"].get<std::string>());
}
#endif
if (cfg.contains("threshold") && cfg["threshold"].is_number()) {
get_ctx().config.threshold = cfg["threshold"].get<float>();
}
if (cfg.contains("max_width") && cfg["max_width"].is_number()) {
get_ctx().config.max_width = cfg["max_width"].get<size_t>();
}
if (cfg.contains("entity_labels") && cfg["entity_labels"].is_array()) {
get_ctx().default_labels.clear();
for (const auto& label : cfg["entity_labels"]) {
if (label.is_string()) {
get_ctx().default_labels.push_back(label.get<std::string>());
}
}
}
}
} catch (const std::exception& e) {
std::fprintf(stderr, "[Glint] Failed to parse config: %s\n", e.what());
}
}
std::fprintf(stderr, "[Glint] Plugin initialized\n");
return YAMS_PLUGIN_OK;
}
YAMS_PLUGIN_API void yams_plugin_shutdown(void) {
std::lock_guard<std::mutex> lock(get_ctx().mutex);
get_ctx().session.reset();
get_ctx().initialized = false;
std::fprintf(stderr, "[Glint] Plugin shutdown\n");
}
YAMS_PLUGIN_API int yams_plugin_get_interface(const char* iface_id, uint32_t version,
void** out_iface) {
if (!iface_id || !out_iface)
return YAMS_PLUGIN_ERR_INVALID;
*out_iface = nullptr;
if (std::strcmp(iface_id, YAMS_IFACE_ENTITY_EXTRACTOR_V2) == 0 &&
version == YAMS_IFACE_ENTITY_EXTRACTOR_V2_VERSION) {
static yams_entity_extractor_v2 g_iface = {
.abi_version = YAMS_IFACE_ENTITY_EXTRACTOR_V2_VERSION,
.self = nullptr,
.supports = supports_abi,
.extract = extract_abi,
.free_result = free_result_abi,
.get_capabilities_json = get_capabilities_json_abi,
.free_string = free_string_abi,
};
*out_iface = &g_iface;
return YAMS_PLUGIN_OK;
}
return YAMS_PLUGIN_ERR_NOT_FOUND;
}
YAMS_PLUGIN_API int yams_plugin_get_health_json(char** out_json) {
if (!out_json)
return YAMS_PLUGIN_ERR_INVALID;
nlohmann::json health;
health["status"] = get_ctx().initialized ? "ready" : "not_initialized";
health["model_path"] = get_ctx().config.model_path;
health["threshold"] = get_ctx().config.threshold;
#ifdef YAMS_USE_ONNX_RUNTIME
{
const auto& rt = yams::onnx_util::OrtRuntimeLoader::instance().ensureLoaded();
nlohmann::json runtime;
runtime["available"] = rt.available;
runtime["reason"] = rt.reason;
if (rt.available) {
runtime["source"] = rt.source;
runtime["version"] = rt.version;
runtime["path"] = rt.libraryPath;
} else {
runtime["error"] = rt.errorMessage;
health["status"] = "degraded";
}
health["onnx_runtime"] = std::move(runtime);
}
#else
health["onnx_runtime"] = {{"available", false}, {"reason", "built_without_onnx_runtime"}};
#endif
if (get_ctx().session) {
health["session_ready"] = get_ctx().session->is_ready();
if (!get_ctx().session->last_error().empty()) {
health["last_error"] = std::string(get_ctx().session->last_error());
}
}
*out_json = dup_cstr(health.dump(2));
return *out_json ? YAMS_PLUGIN_OK : YAMS_PLUGIN_ERR_INVALID;
}
} // extern "C"