|
|
@@ -1,7 +1,93 @@
|
|
|
#include "embeddings.hpp"
|
|
|
#include "errors.hpp"
|
|
|
#include <httplib.h>
|
|
|
+#include <condition_variable>
|
|
|
+#include <map>
|
|
|
+#include <memory>
|
|
|
+#include <mutex>
|
|
|
+#include <vector>
|
|
|
namespace svapi {
|
|
|
+namespace {
|
|
|
+
|
|
|
+// A pool of N independent keep-alive httplib::Clients for a single upstream origin.
|
|
|
+// Each Client owns one TCP/TLS connection; a thread must have EXCLUSIVE use of a
|
|
|
+// Client between checkout and checkin (httplib::Client is not safe for concurrent
|
|
|
+// use by multiple threads). Checkout blocks until a free Client is available.
|
|
|
+class ClientPool {
|
|
|
+public:
|
|
|
+ ClientPool(const std::string& origin, const std::string& apiKey,
|
|
|
+ uint32_t connectTimeoutSec, uint32_t readTimeoutSec, uint32_t poolSize) {
|
|
|
+ uint32_t n = poolSize < 1 ? 1 : poolSize;
|
|
|
+ clients_.reserve(n);
|
|
|
+ free_.reserve(n);
|
|
|
+ for (uint32_t i = 0; i < n; ++i) {
|
|
|
+ auto cli = std::make_unique<httplib::Client>(origin);
|
|
|
+ cli->set_keep_alive(true);
|
|
|
+ cli->set_connection_timeout(connectTimeoutSec);
|
|
|
+ cli->set_read_timeout(readTimeoutSec);
|
|
|
+ cli->enable_server_certificate_verification(true);
|
|
|
+ if (!apiKey.empty()) cli->set_bearer_token_auth(apiKey);
|
|
|
+ free_.push_back(cli.get());
|
|
|
+ clients_.push_back(std::move(cli));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // RAII lease: borrows one Client on construction (blocking) and returns it on
|
|
|
+ // destruction, so the Client is freed even if the POST throws.
|
|
|
+ class Lease {
|
|
|
+ public:
|
|
|
+ explicit Lease(ClientPool& pool) : pool_(pool) {
|
|
|
+ std::unique_lock<std::mutex> lk(pool_.mu_);
|
|
|
+ pool_.cv_.wait(lk, [&] { return !pool_.free_.empty(); });
|
|
|
+ cli_ = pool_.free_.back();
|
|
|
+ pool_.free_.pop_back();
|
|
|
+ }
|
|
|
+ ~Lease() {
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lk(pool_.mu_);
|
|
|
+ pool_.free_.push_back(cli_);
|
|
|
+ }
|
|
|
+ pool_.cv_.notify_one();
|
|
|
+ }
|
|
|
+ Lease(const Lease&) = delete;
|
|
|
+ Lease& operator=(const Lease&) = delete;
|
|
|
+ httplib::Client& client() { return *cli_; }
|
|
|
+ private:
|
|
|
+ ClientPool& pool_;
|
|
|
+ httplib::Client* cli_;
|
|
|
+ };
|
|
|
+
|
|
|
+private:
|
|
|
+ std::mutex mu_;
|
|
|
+ std::condition_variable cv_;
|
|
|
+ std::vector<std::unique_ptr<httplib::Client>> clients_; // owns the Clients
|
|
|
+ std::vector<httplib::Client*> free_; // currently-idle Clients
|
|
|
+};
|
|
|
+
|
|
|
+// Process-wide registry keyed on everything that affects a connection's behavior.
|
|
|
+// A settings hot-reload that changes any of these fields naturally routes to a
|
|
|
+// freshly-built pool; the stale pool simply ages out of use (no eviction — YAGNI).
|
|
|
+ClientPool& poolFor(const std::string& origin, const std::string& apiKey,
|
|
|
+ uint32_t connectTimeoutSec, uint32_t readTimeoutSec, uint32_t poolSize) {
|
|
|
+ static std::mutex regMu;
|
|
|
+ static std::map<std::string, std::unique_ptr<ClientPool>> registry;
|
|
|
+ // '\x1f' (unit separator) cannot appear in origins/keys/decimals → unambiguous key.
|
|
|
+ std::string key = origin + '\x1f' + apiKey + '\x1f' +
|
|
|
+ std::to_string(connectTimeoutSec) + '\x1f' +
|
|
|
+ std::to_string(readTimeoutSec) + '\x1f' +
|
|
|
+ std::to_string(poolSize);
|
|
|
+ std::lock_guard<std::mutex> lk(regMu);
|
|
|
+ auto it = registry.find(key);
|
|
|
+ if (it == registry.end()) {
|
|
|
+ it = registry.emplace(key, std::make_unique<ClientPool>(
|
|
|
+ origin, apiKey, connectTimeoutSec, readTimeoutSec, poolSize))
|
|
|
+ .first;
|
|
|
+ }
|
|
|
+ return *it->second;
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
std::vector<float> parseEmbeddingResponse(const nlohmann::json& body) {
|
|
|
if (!body.contains("data") || !body["data"].is_array() || body["data"].empty())
|
|
|
throw ApiError(ErrCode::Unprocessable, "embedding_failed", "embedding response missing data[]");
|
|
|
@@ -23,17 +109,17 @@ EmbeddingEndpoint splitEmbeddingBase(const std::string& apiBase) {
|
|
|
}
|
|
|
|
|
|
EmbeddingClient::EmbeddingClient(std::string apiBase, std::string apiKey,
|
|
|
- uint32_t connectTimeoutSec, uint32_t readTimeoutSec)
|
|
|
+ uint32_t connectTimeoutSec, uint32_t readTimeoutSec,
|
|
|
+ uint32_t poolSize)
|
|
|
: apiBase_(std::move(apiBase)), apiKey_(std::move(apiKey)),
|
|
|
- connectTimeoutSec_(connectTimeoutSec), readTimeoutSec_(readTimeoutSec) {}
|
|
|
+ connectTimeoutSec_(connectTimeoutSec), readTimeoutSec_(readTimeoutSec),
|
|
|
+ poolSize_(poolSize) {}
|
|
|
std::vector<float> EmbeddingClient::embed(const std::string& model, const std::string& text) {
|
|
|
EmbeddingEndpoint ep = splitEmbeddingBase(apiBase_);
|
|
|
- httplib::Client cli(ep.origin);
|
|
|
- cli.set_connection_timeout(connectTimeoutSec_); cli.set_read_timeout(readTimeoutSec_);
|
|
|
- cli.enable_server_certificate_verification(true);
|
|
|
- if (!apiKey_.empty()) cli.set_bearer_token_auth(apiKey_);
|
|
|
+ ClientPool& pool = poolFor(ep.origin, apiKey_, connectTimeoutSec_, readTimeoutSec_, poolSize_);
|
|
|
+ ClientPool::Lease lease(pool); // EXCLUSIVE use of one keep-alive Client until scope exit
|
|
|
nlohmann::json req{{"model", model}, {"input", text}};
|
|
|
- auto res = cli.Post(ep.path + "/v1/embeddings", req.dump(), "application/json");
|
|
|
+ auto res = lease.client().Post(ep.path + "/v1/embeddings", req.dump(), "application/json");
|
|
|
if (!res) throw ApiError(ErrCode::Unavailable, "embedding_unreachable", "could not reach embedding endpoint");
|
|
|
if (res->status != 200)
|
|
|
throw ApiError(ErrCode::Unprocessable, "embedding_failed",
|