Просмотр исходного кода

fix(embeddings): honor a base-path prefix so OpenAI-compatible providers under /api (e.g. OpenRouter) work

cpp-httplib's Client drops the path from the base URL, so the client always
posted to {host}/v1/embeddings. Split the configured base into origin + path
prefix and post to {prefix}/v1/embeddings. Backward-compatible (empty prefix).
Bump VERSION 0.1.0 -> 0.1.1.
Fszontagh 1 месяц назад
Родитель
Сommit
f6995f0430
4 измененных файлов с 46 добавлено и 3 удалено
  1. 1 1
      VERSION
  2. 13 2
      src/embeddings.cpp
  3. 8 0
      src/embeddings.hpp
  4. 24 0
      tests/test_embeddings.cpp

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.1.0
+0.1.1

+ 13 - 2
src/embeddings.cpp

@@ -12,15 +12,26 @@ std::vector<float> parseEmbeddingResponse(const nlohmann::json& body) {
     for (const auto& v : emb) out.push_back(v.get<float>());
     return out;
 }
+EmbeddingEndpoint splitEmbeddingBase(const std::string& apiBase) {
+    std::string base = apiBase;
+    while (!base.empty() && base.back() == '/') base.pop_back();  // strip trailing slashes
+    auto schemeEnd = base.find("://");
+    std::size_t from = (schemeEnd == std::string::npos) ? 0 : schemeEnd + 3;
+    auto slash = base.find('/', from);
+    if (slash == std::string::npos) return {base, ""};
+    return {base.substr(0, slash), base.substr(slash)};  // path keeps its leading '/', no trailing '/'
+}
+
 EmbeddingClient::EmbeddingClient(std::string apiBase, std::string apiKey)
     : apiBase_(std::move(apiBase)), apiKey_(std::move(apiKey)) {}
 std::vector<float> EmbeddingClient::embed(const std::string& model, const std::string& text) {
-    httplib::Client cli(apiBase_);
+    EmbeddingEndpoint ep = splitEmbeddingBase(apiBase_);
+    httplib::Client cli(ep.origin);
     cli.set_connection_timeout(10); cli.set_read_timeout(30);
     cli.enable_server_certificate_verification(true);
     if (!apiKey_.empty()) cli.set_bearer_token_auth(apiKey_);
     nlohmann::json req{{"model", model}, {"input", text}};
-    auto res = cli.Post("/v1/embeddings", req.dump(), "application/json");
+    auto res = cli.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",

+ 8 - 0
src/embeddings.hpp

@@ -4,6 +4,14 @@
 #include <nlohmann/json.hpp>
 namespace svapi {
 std::vector<float> parseEmbeddingResponse(const nlohmann::json& body);
+
+// Split an OpenAI-compatible base URL into the origin (scheme://host[:port] —
+// httplib::Client ignores any path) and a path prefix to prepend to
+// "/v1/embeddings". e.g. "https://api.openai.com" -> {"https://api.openai.com", ""};
+// "https://openrouter.ai/api" -> {"https://openrouter.ai", "/api"}.
+struct EmbeddingEndpoint { std::string origin; std::string path; };
+EmbeddingEndpoint splitEmbeddingBase(const std::string& apiBase);
+
 class EmbeddingClient {
 public:
     EmbeddingClient(std::string apiBase, std::string apiKey);

+ 24 - 0
tests/test_embeddings.cpp

@@ -13,6 +13,17 @@ TEST(Embeddings, MalformedThrows) {
     EXPECT_THROW(parseEmbeddingResponse(nlohmann::json::parse(R"({"data":[]})")), ApiError);
     EXPECT_THROW(parseEmbeddingResponse(nlohmann::json::object()), ApiError);
 }
+TEST(Embeddings, SplitBaseHandlesPathPrefix) {
+    EXPECT_EQ(splitEmbeddingBase("https://api.openai.com").origin, "https://api.openai.com");
+    EXPECT_EQ(splitEmbeddingBase("https://api.openai.com").path, "");
+    EXPECT_EQ(splitEmbeddingBase("https://api.openai.com/").path, "");
+    auto orr = splitEmbeddingBase("https://openrouter.ai/api");
+    EXPECT_EQ(orr.origin, "https://openrouter.ai");
+    EXPECT_EQ(orr.path, "/api");
+    EXPECT_EQ(splitEmbeddingBase("https://openrouter.ai/api/").path, "/api");
+    EXPECT_EQ(splitEmbeddingBase("http://h:8080/a/b").origin, "http://h:8080");
+    EXPECT_EQ(splitEmbeddingBase("http://h:8080/a/b").path, "/a/b");
+}
 TEST(Embeddings, ClientHitsMockServer) {
     httplib::Server mock;
     mock.Post("/v1/embeddings", [](const httplib::Request& req, httplib::Response& res) {
@@ -27,3 +38,16 @@ TEST(Embeddings, ClientHitsMockServer) {
     EXPECT_EQ(v.size(), 4u); EXPECT_FLOAT_EQ(v[3], 4.0f);
     mock.stop(); t.join();
 }
+TEST(Embeddings, ClientHonorsBasePathPrefix) {
+    // Provider whose embeddings live under a path prefix (e.g. OpenRouter /api/v1/embeddings)
+    httplib::Server mock;
+    mock.Post("/api/v1/embeddings", [](const httplib::Request&, httplib::Response& res) {
+        res.set_content(R"({"data":[{"embedding":[9,9]}]})", "application/json");
+    });
+    int port = mock.bind_to_any_port("127.0.0.1");
+    std::thread t([&]{ mock.listen_after_bind(); });
+    EmbeddingClient cli("http://127.0.0.1:" + std::to_string(port) + "/api", "k");
+    auto v = cli.embed("qwen/qwen3-embedding-8b", "hello");
+    EXPECT_EQ(v.size(), 2u); EXPECT_FLOAT_EQ(v[0], 9.0f);
+    mock.stop(); t.join();
+}