Coverage Report

Created: 2026-10-30 11:52

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
be/src/exprs/function/ai/ai_adapter.h
Line
Count
Source
1
// Licensed to the Apache Software Foundation (ASF) under one
2
// or more contributor license agreements.  See the NOTICE file
3
// distributed with this work for additional information
4
// regarding copyright ownership.  The ASF licenses this file
5
// to you under the Apache License, Version 2.0 (the
6
// "License"); you may not use this file except in compliance
7
// with the License.  You may obtain a copy of the License at
8
//
9
//   http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing,
12
// software distributed under the License is distributed on an
13
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
// KIND, either express or implied.  See the License for the
15
// specific language governing permissions and limitations
16
// under the License.
17
18
#pragma once
19
20
#include <gen_cpp/PaloInternalService_types.h>
21
#include <rapidjson/rapidjson.h>
22
23
#include <cctype>
24
#include <cstdint>
25
#include <memory>
26
#include <optional>
27
#include <string>
28
#include <string_view>
29
#include <unordered_map>
30
#include <vector>
31
32
#include "common/status.h"
33
#include "core/string_buffer.hpp"
34
#include "rapidjson/document.h"
35
#include "rapidjson/stringbuffer.h"
36
#include "rapidjson/writer.h"
37
#include "service/http/http_client.h"
38
#include "service/http/http_headers.h"
39
#include "util/security.h"
40
41
namespace doris {
42
43
// Missing counts remain unknown; totals are never inferred from other fields.
44
struct AIUsage {
45
    std::optional<int64_t> prompt_tokens;
46
    std::optional<int64_t> completion_tokens;
47
    std::optional<int64_t> total_tokens;
48
};
49
50
struct AIResource {
51
161
    AIResource() = default;
52
    AIResource(const TAIResource& tai)
53
220
            : AIResource(tai, tai.endpoint, tai.provider_type, tai.model_name, tai.api_key) {}
54
55
4
    static AIResource from_embed(const TAIResource& tai) {
56
4
        return AIResource(tai, tai.embed_endpoint, tai.embed_provider_type, tai.embed_model_name,
57
4
                          tai.embed_api_key);
58
4
    }
59
60
1
    static AIResource from_multimodal_embed(const TAIResource& tai) {
61
1
        return AIResource(tai, tai.embed_mm_endpoint, tai.embed_mm_provider_type,
62
1
                          tai.embed_mm_model_name, tai.embed_mm_api_key);
63
1
    }
64
65
    std::string endpoint;
66
    std::string provider_type;
67
    std::string model_name;
68
    std::string api_key;
69
    double temperature;
70
    int64_t max_tokens;
71
    int32_t max_retries;
72
    int32_t retry_delay_second;
73
    std::string anthropic_version;
74
    int32_t dimensions;
75
    std::string effort;
76
    // Optional provider output constraints for scalar batches; not serialized in AI_AGG state.
77
    bool enable_structured_output = true;
78
79
3
    void serialize(BufferWritable& buf) const {
80
3
        buf.write_binary(endpoint);
81
3
        buf.write_binary(provider_type);
82
3
        buf.write_binary(model_name);
83
3
        buf.write_binary(api_key);
84
3
        buf.write_binary(temperature);
85
3
        buf.write_binary(max_tokens);
86
3
        buf.write_binary(max_retries);
87
3
        buf.write_binary(retry_delay_second);
88
3
        buf.write_binary(anthropic_version);
89
3
        buf.write_binary(dimensions);
90
3
        if (!effort.empty()) {
91
0
            buf.write_binary(effort);
92
0
        }
93
3
    }
94
95
3
    void deserialize(BufferReadable& buf) {
96
3
        buf.read_binary(endpoint);
97
3
        buf.read_binary(provider_type);
98
3
        buf.read_binary(model_name);
99
3
        buf.read_binary(api_key);
100
3
        buf.read_binary(temperature);
101
3
        buf.read_binary(max_tokens);
102
3
        buf.read_binary(max_retries);
103
3
        buf.read_binary(retry_delay_second);
104
3
        buf.read_binary(anthropic_version);
105
3
        buf.read_binary(dimensions);
106
3
        if (buf.has_remaining()) {
107
0
            buf.read_binary(effort);
108
0
        }
109
3
    }
110
111
private:
112
    AIResource(const TAIResource& tai, const std::string& selected_endpoint,
113
               const std::string& selected_provider_type, const std::string& selected_model_name,
114
               const std::string& selected_api_key)
115
225
            : endpoint(selected_endpoint),
116
225
              provider_type(selected_provider_type),
117
225
              model_name(selected_model_name),
118
225
              api_key(selected_api_key),
119
225
              temperature(tai.temperature),
120
225
              max_tokens(tai.max_tokens),
121
225
              max_retries(tai.max_retries),
122
225
              retry_delay_second(tai.retry_delay_second),
123
225
              anthropic_version(tai.anthropic_version),
124
225
              dimensions(tai.dimensions),
125
225
              effort(tai.effort),
126
225
              enable_structured_output(!tai.__isset.enable_structured_output ||
127
225
                                       tai.enable_structured_output) {}
128
};
129
130
enum class MultimodalType { IMAGE, VIDEO, AUDIO };
131
132
3
inline const char* multimodal_type_to_string(MultimodalType type) {
133
3
    switch (type) {
134
1
    case MultimodalType::IMAGE:
135
1
        return "image";
136
1
    case MultimodalType::VIDEO:
137
1
        return "video";
138
1
    case MultimodalType::AUDIO:
139
1
        return "audio";
140
3
    }
141
0
    return "unknown";
142
3
}
143
144
class AIAdapter {
145
public:
146
381
    explicit AIAdapter(bool structured_batch = false) : _structured_batch(structured_batch) {}
147
148
381
    virtual ~AIAdapter() = default;
149
150
    // Usage is independent of result parsing and may also be present in error responses.
151
    // OpenAI-compatible providers use either chat or Responses API field names.
152
10.1k
    virtual AIUsage parse_usage(const std::string& response_body) const {
153
10.1k
        return parse_usage_fields(response_body, "usage", "prompt_tokens", "completion_tokens",
154
10.1k
                                  "total_tokens", "input_tokens", "output_tokens");
155
10.1k
    }
156
157
    // Set authentication headers for the HTTP client
158
    virtual Status set_authentication(HttpClient* client) const = 0;
159
160
64
    virtual void init(const TAIResource& config) { _config = config; }
161
220
    virtual void init(const AIResource& config) {
162
220
        _config.endpoint = config.endpoint;
163
220
        _config.provider_type = config.provider_type;
164
220
        _config.model_name = config.model_name;
165
220
        _config.api_key = config.api_key;
166
220
        _config.temperature = config.temperature;
167
220
        _config.max_tokens = config.max_tokens;
168
220
        _config.max_retries = config.max_retries;
169
220
        _config.retry_delay_second = config.retry_delay_second;
170
220
        _config.anthropic_version = config.anthropic_version;
171
220
        _config.dimensions = config.dimensions;
172
220
        _config.effort = config.effort;
173
220
    }
174
175
    // Build request payload based on input text strings. The format fields in provider
176
    // examples apply only to structured batches; AI_AGG uses the same transport without them.
177
    virtual Status build_request_payload(const std::vector<std::string>& inputs,
178
                                         const char* const system_prompt, std::string& request_body,
179
1
                                         bool structured_batch = false) const {
180
1
        return Status::NotSupported("{} don't support text generation", _config.provider_type);
181
1
    }
182
183
157
    bool supports_structured_batch() const { return _structured_batch; }
184
185
    Status parse_batch_response(const std::string& response_body,
186
84
                                std::vector<std::string>& results) const {
187
84
        if (!supports_structured_batch()) {
188
1
            return parse_response(response_body, results);
189
1
        }
190
83
        std::vector<std::string> texts;
191
83
        RETURN_IF_ERROR(parse_response(response_body, texts, false));
192
        // Multiple choices are alternatives, not additional rows of the same batch.
193
82
        if (texts.size() != 1) {
194
0
            return Status::InternalError("Expected one structured batch response, got {}",
195
0
                                         texts.size());
196
0
        }
197
82
        rapidjson::Document doc;
198
82
        doc.Parse(texts[0].data(), texts[0].size());
199
        // A bare array, fenced JSON, or a missing/wrong results field violates our schema,
200
        // even for a batch of one row; never return that envelope as a successful SQL value.
201
82
        if (doc.HasParseError() || !doc.IsObject() || doc.MemberCount() != 1 ||
202
82
            !doc.HasMember("results") || !doc["results"].IsArray()) {
203
28
            return Status::InternalError("Invalid structured batch result: expected results array");
204
28
        }
205
54
        const auto& rows = doc["results"];
206
116
        for (rapidjson::SizeType i = 0; i < rows.Size(); ++i) {
207
79
            if (!rows[i].IsString()) {
208
17
                return Status::InternalError(
209
17
                        "Invalid structured batch result: element {} is not a string", i);
210
17
            }
211
79
        }
212
46
        for (const auto& row : rows.GetArray()) {
213
46
            results.emplace_back(row.GetString(), row.GetStringLength());
214
46
        }
215
37
        return Status::OK();
216
54
    }
217
218
    // Serialize the request while appending the provider's immutable result-format fragment.
219
    // RawValue avoids constructing/parsing a schema DOM; nested fields retain siblings like effort.
220
    static void append_result_format(const rapidjson::Document& doc, std::string& request_body,
221
                                     const char* key, std::string_view fragment,
222
38
                                     const char* parent = nullptr) {
223
38
        rapidjson::StringBuffer buffer;
224
38
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
225
38
        auto write_format = [&]() {
226
38
            writer.Key(key);
227
38
            writer.RawValue(fragment.data(), fragment.size(), rapidjson::kObjectType);
228
38
        };
229
38
        writer.StartObject();
230
145
        for (const auto& member : doc.GetObject()) {
231
145
            writer.Key(member.name.GetString(), member.name.GetStringLength());
232
145
            if (parent && member.name == parent) {
233
4
                writer.StartObject();
234
8
                for (const auto& nested : member.value.GetObject()) {
235
8
                    writer.Key(nested.name.GetString(), nested.name.GetStringLength());
236
8
                    nested.value.Accept(writer);
237
8
                }
238
4
                write_format();
239
4
                writer.EndObject();
240
141
            } else {
241
141
                member.value.Accept(writer);
242
141
            }
243
145
        }
244
38
        if (!parent) {
245
30
            write_format();
246
30
        } else if (parent && !doc.HasMember(parent)) {
247
4
            writer.Key(parent);
248
4
            writer.StartObject();
249
4
            write_format();
250
4
            writer.EndObject();
251
4
        }
252
38
        writer.EndObject();
253
38
        request_body.assign(buffer.GetString(), buffer.GetSize());
254
38
    }
255
256
    // Parse response from AI service and extract generated text results
257
    virtual Status parse_response(const std::string& response_body,
258
                                  std::vector<std::string>& results,
259
1
                                  bool /* expand_batch */ = true) const {
260
1
        return Status::NotSupported("{} don't support text generation", _config.provider_type);
261
1
    }
262
263
    virtual Status build_embedding_request(const std::vector<std::string>& inputs,
264
0
                                           std::string& request_body) const {
265
0
        return embed_not_supported_status();
266
0
    }
267
268
    virtual Status build_multimodal_embedding_request(
269
            const std::vector<MultimodalType>& /*media_types*/,
270
            const std::vector<std::string>& /*media_urls*/,
271
            const std::vector<std::string>& /*media_content_types*/,
272
0
            std::string& /*request_body*/) const {
273
0
        return Status::NotSupported("{} does not support multimodal Embed feature.",
274
0
                                    _config.provider_type);
275
0
    }
276
277
    virtual Status parse_embedding_response(const std::string& response_body,
278
0
                                            std::vector<std::vector<float>>& results) const {
279
0
        return embed_not_supported_status();
280
0
    }
281
282
protected:
283
    static std::optional<int64_t> usage_count(const rapidjson::Value& object, const char* field,
284
30.2k
                                              const char* fallback = nullptr) {
285
30.2k
        if (field == nullptr) {
286
31
            return std::nullopt;
287
31
        }
288
30.2k
        auto member = object.FindMember(field);
289
30.2k
        if (member == object.MemberEnd() && fallback != nullptr) {
290
56
            member = object.FindMember(fallback);
291
56
        }
292
30.2k
        if (member == object.MemberEnd() || !member->value.IsInt64() ||
293
30.2k
            member->value.GetInt64() < 0) {
294
80
            return std::nullopt;
295
80
        }
296
30.1k
        return member->value.GetInt64();
297
30.2k
    }
298
299
    static AIUsage parse_usage_fields(const std::string& response_body, const char* envelope,
300
                                      const char* prompt, const char* completion, const char* total,
301
                                      const char* prompt_fallback = nullptr,
302
10.1k
                                      const char* completion_fallback = nullptr) {
303
10.1k
        rapidjson::Document doc;
304
10.1k
        doc.Parse(response_body.data(), response_body.size());
305
10.1k
        if (doc.HasParseError() || !doc.IsObject()) {
306
80
            return {};
307
80
        }
308
10.1k
        const auto member = doc.FindMember(envelope);
309
10.1k
        if (member == doc.MemberEnd() || !member->value.IsObject()) {
310
28
            return {};
311
28
        }
312
10.0k
        const auto& usage = member->value;
313
10.0k
        return {.prompt_tokens = usage_count(usage, prompt, prompt_fallback),
314
10.0k
                .completion_tokens = usage_count(usage, completion, completion_fallback),
315
10.0k
                .total_tokens = usage_count(usage, total)};
316
10.1k
    }
317
318
    TAIResource _config;
319
320
4
    Status embed_not_supported_status() const {
321
4
        return Status::NotSupported(
322
4
                "{} does not support the Embed feature. Currently supported providers are "
323
4
                "OpenAI, Gemini, Voyage, Jina, Qwen, and Minimax.",
324
4
                _config.provider_type);
325
4
    }
326
327
    // Appends one provider-parsed text result to `results`.
328
    // The adapter has already parsed the provider's outer response envelope before calling here.
329
    // Example:
330
    // provider response -> choices[0].message.content = "[\"1\",\"0\",\"1\"]"
331
    // this helper       -> appends "1", "0", "1" into `results`
332
    // Set expand_batch to false when AI_AGG needs the complete generated text as one result.
333
    static Status append_parsed_text_result(std::string_view text,
334
                                            std::vector<std::string>& results,
335
227
                                            bool expand_batch = true) {
336
227
        if (!expand_batch) {
337
135
            results.emplace_back(text.data(), text.size());
338
135
            return Status::OK();
339
135
        }
340
341
92
        size_t begin = 0;
342
92
        size_t end = text.size();
343
122
        while (begin < end && std::isspace(static_cast<unsigned char>(text[begin]))) {
344
30
            ++begin;
345
30
        }
346
116
        while (begin < end && std::isspace(static_cast<unsigned char>(text[end - 1]))) {
347
24
            --end;
348
24
        }
349
350
92
        if (begin < end && text[begin] == '[' && text[end - 1] == ']') {
351
73
            rapidjson::Document doc;
352
73
            doc.Parse(text.data() + begin, end - begin);
353
73
            if (!doc.HasParseError() && doc.IsArray()) {
354
160
                for (rapidjson::SizeType i = 0; i < doc.Size(); ++i) {
355
90
                    if (!doc[i].IsString()) {
356
1
                        return Status::InternalError(
357
1
                                "Invalid batch result format, array element {} is not a string", i);
358
1
                    }
359
89
                    results.emplace_back(doc[i].GetString(), doc[i].GetStringLength());
360
89
                }
361
70
                return Status::OK();
362
71
            }
363
73
        }
364
365
21
        results.emplace_back(text.data(), text.size());
366
21
        return Status::OK();
367
92
    }
368
369
    Status append_parsed_embedding_result(const rapidjson::Value& embedding,
370
                                          std::vector<std::vector<float>>& results,
371
307
                                          const std::string& response_body) const {
372
307
        if (!embedding.IsArray()) {
373
1
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
374
1
                                         response_body);
375
1
        }
376
377
306
        std::vector<float> parsed_embedding;
378
306
        parsed_embedding.reserve(embedding.Size());
379
393k
        for (const auto& value : embedding.GetArray()) {
380
393k
            if (!value.IsNumber()) {
381
8
                return Status::InternalError("Invalid {} response format: {}",
382
8
                                             _config.provider_type, response_body);
383
8
            }
384
393k
            parsed_embedding.emplace_back(value.GetFloat());
385
393k
        }
386
298
        results.emplace_back(std::move(parsed_embedding));
387
298
        return Status::OK();
388
306
    }
389
390
    // return true if the model support dimension parameter
391
1
    virtual bool supports_dimension_param(const std::string& model_name) const { return false; }
392
393
    // Different providers may have different dimension parameter names.
394
0
    virtual std::string get_dimension_param_name() const { return "dimensions"; }
395
396
    virtual void add_dimension_params(rapidjson::Value& doc,
397
30
                                      rapidjson::Document::AllocatorType& allocator) const {
398
30
        if (_config.dimensions != -1 && supports_dimension_param(_config.model_name)) {
399
23
            std::string param_name = get_dimension_param_name();
400
23
            rapidjson::Value name(param_name.c_str(), allocator);
401
23
            doc.AddMember(name, _config.dimensions, allocator);
402
23
        }
403
30
    }
404
405
    // Validates common multimodal embedding request invariants shared by providers.
406
    Status validate_multimodal_embedding_inputs(
407
            std::string_view provider_name, const std::vector<MultimodalType>& media_types,
408
            const std::vector<std::string>& media_urls,
409
26
            std::initializer_list<MultimodalType> supported_types) const {
410
26
        if (media_urls.empty()) {
411
1
            return Status::InvalidArgument("{} multimodal embed inputs can not be empty",
412
1
                                           provider_name);
413
1
        }
414
25
        if (media_types.size() != media_urls.size()) {
415
1
            return Status::InvalidArgument(
416
1
                    "{} multimodal embed input size mismatch, media_types={}, media_urls={}",
417
1
                    provider_name, media_types.size(), media_urls.size());
418
1
        }
419
29
        for (MultimodalType media_type : media_types) {
420
29
            bool supported = false;
421
46
            for (MultimodalType supported_type : supported_types) {
422
46
                if (media_type == supported_type) {
423
28
                    supported = true;
424
28
                    break;
425
28
                }
426
46
            }
427
29
            if (!supported) [[unlikely]] {
428
1
                return Status::InvalidArgument(
429
1
                        "{} only supports {} multimodal embed, got {}", provider_name,
430
1
                        supported_multimodal_types_to_string(supported_types),
431
1
                        multimodal_type_to_string(media_type));
432
1
            }
433
29
        }
434
23
        return Status::OK();
435
24
    }
436
437
    static std::string supported_multimodal_types_to_string(
438
1
            std::initializer_list<MultimodalType> supported_types) {
439
1
        std::string result;
440
2
        for (MultimodalType type : supported_types) {
441
2
            if (!result.empty()) {
442
1
                result += "/";
443
1
            }
444
2
            result += multimodal_type_to_string(type);
445
2
        }
446
1
        return result;
447
1
    }
448
449
private:
450
    // The batch protocol is fixed by the concrete adapter, including inherited transports.
451
    const bool _structured_batch;
452
};
453
454
// Most LLM-providers' Embedding formats are based on VoyageAI.
455
// The following adapters inherit from VoyageAIAdapter to directly reuse its embedding logic.
456
class VoyageAIAdapter : public AIAdapter {
457
public:
458
    using AIAdapter::AIAdapter;
459
460
21
    AIUsage parse_usage(const std::string& response_body) const override {
461
21
        return parse_usage_fields(response_body, "usage", nullptr, nullptr, "total_tokens");
462
21
    }
463
464
12
    Status set_authentication(HttpClient* client) const override {
465
12
        client->set_header(HttpHeaders::AUTHORIZATION, "Bearer " + _config.api_key);
466
12
        client->set_content_type("application/json");
467
468
12
        return Status::OK();
469
12
    }
470
471
    Status build_embedding_request(const std::vector<std::string>& inputs,
472
18
                                   std::string& request_body) const override {
473
18
        rapidjson::Document doc;
474
18
        doc.SetObject();
475
18
        auto& allocator = doc.GetAllocator();
476
477
        /*{
478
            "model": "xxx",
479
            "input": [
480
              "xxx",
481
              "xxx",
482
              ...
483
            ],
484
            "output_dimensions": 512
485
        }*/
486
18
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
487
18
        add_dimension_params(doc, allocator);
488
489
18
        rapidjson::Value input(rapidjson::kArrayType);
490
18
        for (const auto& msg : inputs) {
491
18
            input.PushBack(rapidjson::Value(msg.c_str(), allocator), allocator);
492
18
        }
493
18
        doc.AddMember("input", input, allocator);
494
495
18
        rapidjson::StringBuffer buffer;
496
18
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
497
18
        doc.Accept(writer);
498
18
        request_body = buffer.GetString();
499
500
18
        return Status::OK();
501
18
    }
502
503
    Status build_multimodal_embedding_request(
504
            const std::vector<MultimodalType>& media_types,
505
            const std::vector<std::string>& media_urls,
506
            const std::vector<std::string>& /*media_content_types*/,
507
12
            std::string& request_body) const override {
508
12
        RETURN_IF_ERROR(validate_multimodal_embedding_inputs(
509
12
                "VoyageAI", media_types, media_urls,
510
12
                {MultimodalType::IMAGE, MultimodalType::VIDEO}));
511
12
        if (_config.dimensions != -1) {
512
12
            LOG(WARNING) << "VoyageAI multimodal embedding currently ignores dimensions parameter, "
513
12
                         << "model=" << _config.model_name << ", dimensions=" << _config.dimensions;
514
12
        }
515
516
12
        rapidjson::Document doc;
517
12
        doc.SetObject();
518
12
        auto& allocator = doc.GetAllocator();
519
520
        /*{
521
            "inputs": [
522
              {
523
                "content": [
524
                  {"type": "image_url", "image_url": "<url>"}
525
                ]
526
              },
527
              {
528
                "content": [
529
                  {"type": "video_url", "video_url": "<url>"}
530
                ]
531
              }
532
            ],
533
            "model": "voyage-multimodal-3.5"
534
        }*/
535
12
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
536
537
12
        rapidjson::Value request_inputs(rapidjson::kArrayType);
538
25
        for (size_t i = 0; i < media_urls.size(); ++i) {
539
13
            rapidjson::Value input(rapidjson::kObjectType);
540
13
            rapidjson::Value content(rapidjson::kArrayType);
541
13
            rapidjson::Value media_item(rapidjson::kObjectType);
542
13
            if (media_types[i] == MultimodalType::IMAGE) {
543
6
                media_item.AddMember("type", "image_url", allocator);
544
6
                media_item.AddMember("image_url",
545
6
                                     rapidjson::Value(media_urls[i].c_str(), allocator), allocator);
546
7
            } else {
547
7
                media_item.AddMember("type", "video_url", allocator);
548
7
                media_item.AddMember("video_url",
549
7
                                     rapidjson::Value(media_urls[i].c_str(), allocator), allocator);
550
7
            }
551
13
            content.PushBack(media_item, allocator);
552
13
            input.AddMember("content", content, allocator);
553
13
            request_inputs.PushBack(input, allocator);
554
13
        }
555
556
12
        doc.AddMember("inputs", request_inputs, allocator);
557
558
12
        rapidjson::StringBuffer buffer;
559
12
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
560
12
        doc.Accept(writer);
561
12
        request_body = buffer.GetString();
562
12
        return Status::OK();
563
12
    }
564
565
    Status parse_embedding_response(const std::string& response_body,
566
39
                                    std::vector<std::vector<float>>& results) const override {
567
39
        rapidjson::Document doc;
568
39
        doc.Parse(response_body.c_str());
569
570
39
        if (doc.HasParseError() || !doc.IsObject()) {
571
3
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
572
3
                                         response_body);
573
3
        }
574
36
        if (!doc.HasMember("data") || !doc["data"].IsArray()) {
575
1
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
576
1
                                         response_body);
577
1
        }
578
579
        /*{
580
            "data":[
581
              {
582
                "object": "embedding",
583
                "embedding": [...], <- only need this
584
                "index": 0
585
              },
586
              {
587
                "object": "embedding",
588
                "embedding": [...],
589
                "index": 1
590
              }, ...
591
            ],
592
            "model"....
593
        }*/
594
35
        const auto& data = doc["data"];
595
35
        results.reserve(data.Size());
596
324
        for (rapidjson::SizeType i = 0; i < data.Size(); i++) {
597
292
            if (!data[i].IsObject() || !data[i].HasMember("embedding")) {
598
2
                return Status::InternalError("Invalid {} response format: {}",
599
2
                                             _config.provider_type, response_body);
600
2
            }
601
290
            RETURN_IF_ERROR(
602
290
                    append_parsed_embedding_result(data[i]["embedding"], results, response_body));
603
290
        }
604
605
32
        return Status::OK();
606
35
    }
607
608
protected:
609
4
    bool supports_dimension_param(const std::string& model_name) const override {
610
4
        static const std::unordered_set<std::string> no_dimension_models = {
611
4
                "voyage-law-2", "voyage-2", "voyage-code-2", "voyage-finance-2",
612
4
                "voyage-multimodal-3"};
613
4
        return !no_dimension_models.contains(model_name);
614
4
    }
615
616
1
    std::string get_dimension_param_name() const override { return "output_dimension"; }
617
};
618
619
// Local AI adapter for locally hosted models (Ollama, LLaMA, etc.)
620
class LocalAdapter : public AIAdapter {
621
public:
622
11
    AIUsage parse_usage(const std::string& response_body) const override {
623
11
        rapidjson::Document doc;
624
11
        doc.Parse(response_body.data(), response_body.size());
625
11
        if (doc.HasParseError() || !doc.IsObject()) {
626
4
            return {};
627
4
        }
628
7
        if (doc.HasMember("prompt_eval_count") || doc.HasMember("eval_count")) {
629
2
            return {.prompt_tokens = usage_count(doc, "prompt_eval_count"),
630
2
                    .completion_tokens = usage_count(doc, "eval_count"),
631
2
                    .total_tokens = std::nullopt};
632
2
        }
633
5
        const auto member = doc.FindMember("usage");
634
5
        if (member == doc.MemberEnd() || !member->value.IsObject()) {
635
3
            return {};
636
3
        }
637
2
        const auto& usage = member->value;
638
2
        return {.prompt_tokens = usage_count(usage, "prompt_tokens", "input_tokens"),
639
2
                .completion_tokens = usage_count(usage, "completion_tokens", "output_tokens"),
640
2
                .total_tokens = usage_count(usage, "total_tokens")};
641
5
    }
642
643
    // Local deployments typically don't need authentication
644
4
    Status set_authentication(HttpClient* client) const override {
645
4
        client->set_content_type("application/json");
646
4
        return Status::OK();
647
4
    }
648
649
    Status build_request_payload(const std::vector<std::string>& inputs,
650
                                 const char* const system_prompt, std::string& request_body,
651
4
                                 bool structured_batch = false) const override {
652
4
        rapidjson::Document doc;
653
4
        doc.SetObject();
654
4
        auto& allocator = doc.GetAllocator();
655
656
4
        std::string end_point = _config.endpoint;
657
4
        if (end_point.ends_with("chat") || end_point.ends_with("generate")) {
658
2
            RETURN_IF_ERROR(
659
2
                    build_ollama_request(doc, allocator, inputs, system_prompt, request_body));
660
2
        } else {
661
2
            RETURN_IF_ERROR(
662
2
                    build_default_request(doc, allocator, inputs, system_prompt, request_body));
663
2
        }
664
665
4
        rapidjson::StringBuffer buffer;
666
4
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
667
4
        doc.Accept(writer);
668
4
        request_body = buffer.GetString();
669
670
4
        return Status::OK();
671
4
    }
672
673
    Status parse_response(const std::string& response_body, std::vector<std::string>& results,
674
17
                          bool expand_batch = true) const override {
675
17
        rapidjson::Document doc;
676
17
        doc.Parse(response_body.c_str());
677
678
17
        if (doc.HasParseError() || !doc.IsObject()) {
679
1
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
680
1
                                         response_body);
681
1
        }
682
683
        // Handle various response formats from local LLMs
684
        // Format 1: OpenAI-compatible format with choices/message/content
685
16
        if (doc.HasMember("choices") && doc["choices"].IsArray()) {
686
7
            const auto& choices = doc["choices"];
687
7
            results.reserve(choices.Size());
688
689
12
            for (rapidjson::SizeType i = 0; i < choices.Size(); i++) {
690
7
                if (!choices[i].IsObject()) {
691
1
                    return Status::InternalError("Invalid {} response format: {}",
692
1
                                                 _config.provider_type, response_body);
693
1
                }
694
6
                if (choices[i].HasMember("message") && !choices[i]["message"].IsObject()) {
695
1
                    return Status::InternalError("Invalid {} response format: {}",
696
1
                                                 _config.provider_type, response_body);
697
1
                }
698
5
                if (choices[i].HasMember("message") && choices[i]["message"].HasMember("content") &&
699
5
                    choices[i]["message"]["content"].IsString()) {
700
4
                    RETURN_IF_ERROR(append_parsed_text_result(
701
4
                            std::string_view(choices[i]["message"]["content"].GetString(),
702
4
                                             choices[i]["message"]["content"].GetStringLength()),
703
4
                            results, expand_batch));
704
4
                } else if (choices[i].HasMember("text") && choices[i]["text"].IsString()) {
705
                    // Some local LLMs use a simpler format
706
1
                    RETURN_IF_ERROR(append_parsed_text_result(
707
1
                            std::string_view(choices[i]["text"].GetString(),
708
1
                                             choices[i]["text"].GetStringLength()),
709
1
                            results, expand_batch));
710
1
                }
711
5
            }
712
9
        } else if (doc.HasMember("text") && doc["text"].IsString()) {
713
            // Format 2: Simple response with just "text" or "content" field
714
2
            RETURN_IF_ERROR(append_parsed_text_result(
715
2
                    std::string_view(doc["text"].GetString(), doc["text"].GetStringLength()),
716
2
                    results, expand_batch));
717
7
        } else if (doc.HasMember("content") && doc["content"].IsString()) {
718
2
            RETURN_IF_ERROR(append_parsed_text_result(
719
2
                    std::string_view(doc["content"].GetString(), doc["content"].GetStringLength()),
720
2
                    results, expand_batch));
721
5
        } else if (doc.HasMember("response") && doc["response"].IsString()) {
722
            // Format 3: Response field (Ollama `generate` format)
723
2
            RETURN_IF_ERROR(
724
2
                    append_parsed_text_result(std::string_view(doc["response"].GetString(),
725
2
                                                               doc["response"].GetStringLength()),
726
2
                                              results, expand_batch));
727
3
        } else if (doc.HasMember("message") && doc["message"].IsObject() &&
728
3
                   doc["message"].HasMember("content") && doc["message"]["content"].IsString()) {
729
            // Format 4: message/content field (Ollama `chat` format)
730
2
            RETURN_IF_ERROR(append_parsed_text_result(
731
2
                    std::string_view(doc["message"]["content"].GetString(),
732
2
                                     doc["message"]["content"].GetStringLength()),
733
2
                    results, expand_batch));
734
2
        } else {
735
1
            return Status::NotSupported("Unsupported response format from local AI.");
736
1
        }
737
13
        return Status::OK();
738
16
    }
739
740
    Status build_embedding_request(const std::vector<std::string>& inputs,
741
1
                                   std::string& request_body) const override {
742
1
        rapidjson::Document doc;
743
1
        doc.SetObject();
744
1
        auto& allocator = doc.GetAllocator();
745
746
1
        if (!_config.model_name.empty()) {
747
1
            doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator),
748
1
                          allocator);
749
1
        }
750
751
1
        add_dimension_params(doc, allocator);
752
753
1
        rapidjson::Value input(rapidjson::kArrayType);
754
1
        for (const auto& msg : inputs) {
755
1
            input.PushBack(rapidjson::Value(msg.c_str(), allocator), allocator);
756
1
        }
757
1
        doc.AddMember("input", input, allocator);
758
759
1
        rapidjson::StringBuffer buffer;
760
1
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
761
1
        doc.Accept(writer);
762
1
        request_body = buffer.GetString();
763
764
1
        return Status::OK();
765
1
    }
766
767
    Status build_multimodal_embedding_request(
768
            const std::vector<MultimodalType>& /*media_types*/,
769
            const std::vector<std::string>& /*media_urls*/,
770
            const std::vector<std::string>& /*media_content_types*/,
771
0
            std::string& /*request_body*/) const override {
772
0
        return Status::NotSupported("{} does not support multimodal Embed feature.",
773
0
                                    _config.provider_type);
774
0
    }
775
776
    Status parse_embedding_response(const std::string& response_body,
777
8
                                    std::vector<std::vector<float>>& results) const override {
778
8
        rapidjson::Document doc;
779
8
        doc.Parse(response_body.c_str());
780
781
8
        if (doc.HasParseError() || !doc.IsObject()) {
782
0
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
783
0
                                         response_body);
784
0
        }
785
786
        // parse different response format
787
8
        if (doc.HasMember("data") && doc["data"].IsArray()) {
788
            // "data":["object":"embedding", "embedding":[0.1, 0.2...], "index":0]
789
3
            const auto& data = doc["data"];
790
3
            results.reserve(data.Size());
791
5
            for (rapidjson::SizeType i = 0; i < data.Size(); i++) {
792
4
                if (!data[i].IsObject() || !data[i].HasMember("embedding")) {
793
1
                    return Status::InternalError("Invalid {} response format",
794
1
                                                 _config.provider_type);
795
1
                }
796
3
                RETURN_IF_ERROR(append_parsed_embedding_result(data[i]["embedding"], results,
797
3
                                                               response_body));
798
3
            }
799
5
        } else if (doc.HasMember("embeddings") && doc["embeddings"].IsArray()) {
800
            // "embeddings":[[0.1, 0.2, ...]]
801
3
            const auto& embeddings = doc["embeddings"];
802
3
            results.reserve(embeddings.Size());
803
4
            for (rapidjson::SizeType i = 0; i < embeddings.Size(); i++) {
804
3
                RETURN_IF_ERROR(
805
3
                        append_parsed_embedding_result(embeddings[i], results, response_body));
806
3
            }
807
3
        } else if (doc.HasMember("embedding") && doc["embedding"].IsArray()) {
808
            // "embedding":[0.1, 0.2, ...]
809
2
            results.reserve(1);
810
2
            RETURN_IF_ERROR(
811
2
                    append_parsed_embedding_result(doc["embedding"], results, response_body));
812
2
        } else {
813
0
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
814
0
                                         response_body);
815
0
        }
816
817
3
        return Status::OK();
818
8
    }
819
820
private:
821
    Status build_ollama_request(rapidjson::Document& doc,
822
                                rapidjson::Document::AllocatorType& allocator,
823
                                const std::vector<std::string>& inputs,
824
2
                                const char* const system_prompt, std::string& request_body) const {
825
        /*
826
        for endpoints end_with `/chat` like 'http://localhost:11434/api/chat':
827
        {
828
            "model": <model_name>,
829
            "stream": false,
830
            "think": false,
831
            "options": {
832
                "temperature": <temperature>,
833
                "max_token": <max_token>
834
            },
835
            "messages": [
836
                {"role": "system", "content": <system_prompt>},
837
                {"role": "user", "content": <user_prompt>}
838
            ]
839
        }
840
        
841
        for endpoints end_with `/generate` like 'http://localhost:11434/api/generate':
842
        {
843
            "model": <model_name>,
844
            "stream": false,
845
            "think": false
846
            "options": {
847
                "temperature": <temperature>,
848
                "max_token": <max_token>
849
            },
850
            "system": <system_prompt>,
851
            "prompt": <user_prompt>
852
        }
853
        */
854
855
        // For Ollama, only the prompt section ("system" + "prompt" or "role" + "content") is affected by the endpoint;
856
        // The rest remains identical.
857
2
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
858
2
        doc.AddMember("stream", false, allocator);
859
2
        doc.AddMember("think", false, allocator);
860
861
        // option section
862
2
        rapidjson::Value options(rapidjson::kObjectType);
863
2
        if (_config.temperature != -1) {
864
2
            options.AddMember("temperature", _config.temperature, allocator);
865
2
        }
866
2
        if (_config.max_tokens != -1) {
867
2
            options.AddMember("max_token", _config.max_tokens, allocator);
868
2
        }
869
2
        doc.AddMember("options", options, allocator);
870
871
        // prompt section
872
2
        if (_config.endpoint.ends_with("chat")) {
873
1
            rapidjson::Value messages(rapidjson::kArrayType);
874
1
            if (system_prompt && *system_prompt) {
875
1
                rapidjson::Value sys_msg(rapidjson::kObjectType);
876
1
                sys_msg.AddMember("role", "system", allocator);
877
1
                sys_msg.AddMember("content", rapidjson::Value(system_prompt, allocator), allocator);
878
1
                messages.PushBack(sys_msg, allocator);
879
1
            }
880
1
            for (const auto& input : inputs) {
881
1
                rapidjson::Value message(rapidjson::kObjectType);
882
1
                message.AddMember("role", "user", allocator);
883
1
                message.AddMember("content", rapidjson::Value(input.c_str(), allocator), allocator);
884
1
                messages.PushBack(message, allocator);
885
1
            }
886
1
            doc.AddMember("messages", messages, allocator);
887
1
        } else {
888
1
            if (system_prompt && *system_prompt) {
889
1
                doc.AddMember("system", rapidjson::Value(system_prompt, allocator), allocator);
890
1
            }
891
1
            doc.AddMember("prompt", rapidjson::Value(inputs[0].c_str(), allocator), allocator);
892
1
        }
893
894
2
        return Status::OK();
895
2
    }
896
897
    Status build_default_request(rapidjson::Document& doc,
898
                                 rapidjson::Document::AllocatorType& allocator,
899
                                 const std::vector<std::string>& inputs,
900
2
                                 const char* const system_prompt, std::string& request_body) const {
901
        /*
902
        Default format(OpenAI-compatible):
903
        {
904
            "model": <model_name>,
905
            "temperature": <temperature>,
906
            "max_tokens": <max_tokens>,
907
            "messages": [
908
                {"role": "system", "content": <system_prompt>},
909
                {"role": "user", "content": <user_prompt>}
910
            ]
911
        }
912
        */
913
914
2
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
915
916
        // If 'temperature' and 'max_tokens' are set, add them to the request body.
917
2
        if (_config.temperature != -1) {
918
2
            doc.AddMember("temperature", _config.temperature, allocator);
919
2
        }
920
2
        if (_config.max_tokens != -1) {
921
2
            doc.AddMember("max_tokens", _config.max_tokens, allocator);
922
2
        }
923
924
2
        rapidjson::Value messages(rapidjson::kArrayType);
925
2
        if (system_prompt && *system_prompt) {
926
2
            rapidjson::Value sys_msg(rapidjson::kObjectType);
927
2
            sys_msg.AddMember("role", "system", allocator);
928
2
            sys_msg.AddMember("content", rapidjson::Value(system_prompt, allocator), allocator);
929
2
            messages.PushBack(sys_msg, allocator);
930
2
        }
931
2
        for (const auto& input : inputs) {
932
2
            rapidjson::Value message(rapidjson::kObjectType);
933
2
            message.AddMember("role", "user", allocator);
934
2
            message.AddMember("content", rapidjson::Value(input.c_str(), allocator), allocator);
935
2
            messages.PushBack(message, allocator);
936
2
        }
937
2
        doc.AddMember("messages", messages, allocator);
938
2
        return Status::OK();
939
2
    }
940
};
941
942
// The OpenAI API format can be reused with some compatible AIs.
943
class OpenAIAdapter : public VoyageAIAdapter {
944
public:
945
    explicit OpenAIAdapter(bool chat_json_schema = true)
946
181
            : VoyageAIAdapter(true), _chat_json_schema(chat_json_schema) {}
947
948
10.1k
    AIUsage parse_usage(const std::string& response_body) const override {
949
10.1k
        return AIAdapter::parse_usage(response_body);
950
10.1k
    }
951
952
289
    Status set_authentication(HttpClient* client) const override {
953
289
        client->set_header(HttpHeaders::AUTHORIZATION, "Bearer " + _config.api_key);
954
289
        client->set_content_type("application/json");
955
956
289
        return Status::OK();
957
289
    }
958
959
    Status build_request_payload(const std::vector<std::string>& inputs,
960
                                 const char* const system_prompt, std::string& request_body,
961
78
                                 bool structured_batch = false) const override {
962
78
        rapidjson::Document doc;
963
78
        doc.SetObject();
964
78
        auto& allocator = doc.GetAllocator();
965
966
78
        if (_config.endpoint.ends_with("responses")) {
967
            /*{
968
              "model": "gpt-4.1-mini",
969
              "input": [
970
                {"role": "system", "content": "system_prompt here"},
971
                {"role": "user", "content": "xxx"}
972
              ],
973
              "temperature": 0.7,
974
              "max_output_tokens": 150,
975
              "reasoning": {"effort": "max"},
976
              "text": {"format": <responses_format>}
977
            }*/
978
10
            doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator),
979
10
                          allocator);
980
981
            // If 'temperature' and 'max_tokens' are set, add them to the request body.
982
10
            if (_config.temperature != -1) {
983
10
                doc.AddMember("temperature", _config.temperature, allocator);
984
10
            }
985
10
            if (_config.max_tokens != -1) {
986
10
                doc.AddMember("max_output_tokens", _config.max_tokens, allocator);
987
10
            }
988
10
            if (!_config.effort.empty()) {
989
4
                rapidjson::Value reasoning(rapidjson::kObjectType);
990
4
                reasoning.AddMember("effort", rapidjson::Value(_config.effort.c_str(), allocator),
991
4
                                    allocator);
992
4
                doc.AddMember("reasoning", reasoning, allocator);
993
4
            }
994
995
            // input
996
10
            rapidjson::Value input(rapidjson::kArrayType);
997
10
            if (system_prompt && *system_prompt) {
998
10
                rapidjson::Value sys_msg(rapidjson::kObjectType);
999
10
                sys_msg.AddMember("role", "system", allocator);
1000
10
                sys_msg.AddMember("content", rapidjson::Value(system_prompt, allocator), allocator);
1001
10
                input.PushBack(sys_msg, allocator);
1002
10
            }
1003
10
            for (const auto& msg : inputs) {
1004
10
                rapidjson::Value message(rapidjson::kObjectType);
1005
10
                message.AddMember("role", "user", allocator);
1006
10
                message.AddMember("content", rapidjson::Value(msg.c_str(), allocator), allocator);
1007
10
                input.PushBack(message, allocator);
1008
10
            }
1009
10
            doc.AddMember("input", input, allocator);
1010
68
        } else {
1011
            /*{
1012
              "model": "gpt-4",
1013
              "messages": [
1014
                {"role": "system", "content": "system_prompt here"},
1015
                {"role": "user", "content": "xxx"}
1016
              ],
1017
              "temperature": x,
1018
              "max_tokens": x,
1019
              "reasoning_effort": "low",
1020
              "response_format": <chat_format>
1021
            }*/
1022
68
            doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator),
1023
68
                          allocator);
1024
1025
            // If 'temperature' and 'max_tokens' are set, add them to the request body.
1026
68
            if (_config.temperature != -1) {
1027
54
                doc.AddMember("temperature", _config.temperature, allocator);
1028
54
            }
1029
68
            if (_config.max_tokens != -1) {
1030
54
                doc.AddMember("max_tokens", _config.max_tokens, allocator);
1031
54
            }
1032
68
            if (!_config.effort.empty()) {
1033
4
                doc.AddMember("reasoning_effort",
1034
4
                              rapidjson::Value(_config.effort.c_str(), allocator), allocator);
1035
4
            }
1036
1037
68
            rapidjson::Value messages(rapidjson::kArrayType);
1038
68
            if (system_prompt && *system_prompt) {
1039
68
                rapidjson::Value sys_msg(rapidjson::kObjectType);
1040
68
                sys_msg.AddMember("role", "system", allocator);
1041
68
                sys_msg.AddMember("content", rapidjson::Value(system_prompt, allocator), allocator);
1042
68
                messages.PushBack(sys_msg, allocator);
1043
68
            }
1044
68
            for (const auto& input : inputs) {
1045
68
                rapidjson::Value message(rapidjson::kObjectType);
1046
68
                message.AddMember("role", "user", allocator);
1047
68
                message.AddMember("content", rapidjson::Value(input.c_str(), allocator), allocator);
1048
68
                messages.PushBack(message, allocator);
1049
68
            }
1050
68
            doc.AddMember("messages", messages, allocator);
1051
68
        }
1052
1053
78
        if (structured_batch) {
1054
            // These complete format fragments are compile-time constants. RawValue writes
1055
            // them unchanged on every request; no schema construction or parsing is needed.
1056
            /* {
1057
              "type": "json_schema", "name": "ai_batch_result", "strict": true,
1058
              "schema": {
1059
                  "type": "object",
1060
                  "properties": {"results": {"type": "array", "items": {"type": "string"}}},
1061
                  "required": ["results"],
1062
                  "additionalProperties": false
1063
                }
1064
            } */
1065
33
            static constexpr std::string_view responses_format =
1066
33
                    R"({"type":"json_schema","name":"ai_batch_result","strict":true,"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"string"}}},"required":["results"],"additionalProperties":false}})";
1067
            /* {
1068
              "type": "json_schema",
1069
              "json_schema": {
1070
                "name": "ai_batch_result", "strict": true,
1071
                "schema": {
1072
                  "type": "object",
1073
                  "properties": {"results": {"type": "array", "items": {"type": "string"}}},
1074
                  "required": ["results"],
1075
                  "additionalProperties": false
1076
                }
1077
              }
1078
            } */
1079
33
            static constexpr std::string_view chat_format =
1080
33
                    R"({"type":"json_schema","json_schema":{"name":"ai_batch_result","strict":true,"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"string"}}},"required":["results"],"additionalProperties":false}}})";
1081
33
            if (_config.endpoint.ends_with("responses")) {
1082
3
                append_result_format(doc, request_body, "format", responses_format, "text");
1083
30
            } else if (_chat_json_schema) {
1084
24
                append_result_format(doc, request_body, "response_format", chat_format);
1085
24
            } else {
1086
                // DeepSeek, Zhipu and Baichuan Chat use JSON mode. Our prompt specifies the
1087
                // results envelope, which parse_batch_response validates.
1088
6
                static constexpr std::string_view json_format = R"({"type":"json_object"})";
1089
6
                append_result_format(doc, request_body, "response_format", json_format);
1090
6
            }
1091
45
        } else {
1092
45
            rapidjson::StringBuffer buffer;
1093
45
            rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1094
45
            doc.Accept(writer);
1095
45
            request_body.assign(buffer.GetString(), buffer.GetSize());
1096
45
        }
1097
1098
78
        return Status::OK();
1099
78
    }
1100
1101
    Status parse_response(const std::string& response_body, std::vector<std::string>& results,
1102
107
                          bool expand_batch = true) const override {
1103
        // For structured batches, the text field below contains a JSON-encoded string.
1104
        // Its decoded value is parsed by parse_batch_response:
1105
        // {
1106
        //   "results": [
1107
        //     "row1",
1108
        //     "row2"
1109
        //   ]
1110
        // }
1111
107
        rapidjson::Document doc;
1112
107
        doc.Parse(response_body.c_str());
1113
1114
107
        if (doc.HasParseError() || !doc.IsObject()) {
1115
3
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
1116
3
                                         response_body);
1117
3
        }
1118
1119
104
        const bool is_responses_response =
1120
104
                doc.HasMember("output") ||
1121
104
                (doc.HasMember("object") && doc["object"].IsString() &&
1122
78
                 std::string_view(doc["object"].GetString(), doc["object"].GetStringLength()) ==
1123
0
                         "response");
1124
104
        if (is_responses_response) {
1125
            /// for responses endpoint
1126
            /*{
1127
              "output": [
1128
                {
1129
                  "id": "rs_123",
1130
                  "type": "reasoning",
1131
                  "content": [],
1132
                  "summary": []
1133
                },
1134
                {
1135
                  "id": "msg_123",
1136
                  "type": "message",
1137
                  "role": "assistant",
1138
                  "content": [
1139
                    {
1140
                      "type": "output_text",
1141
                      "text": "<result JSON string>"
1142
                    }
1143
                  ]
1144
                }
1145
              ]
1146
            }*/
1147
26
            if (doc.HasMember("status")) {
1148
26
                if (!doc["status"].IsString()) {
1149
0
                    return Status::InternalError("Invalid status in {} response: {}",
1150
0
                                                 _config.provider_type, response_body);
1151
0
                }
1152
26
                if (std::string_view(doc["status"].GetString(), doc["status"].GetStringLength()) !=
1153
26
                    "completed") {
1154
2
                    return Status::InternalError("{} response is not completed: {}",
1155
2
                                                 _config.provider_type, response_body);
1156
2
                }
1157
26
            }
1158
1159
24
            if (!doc.HasMember("output") || !doc["output"].IsArray()) {
1160
0
                return Status::InternalError("Invalid output format in {} response: {}",
1161
0
                                             _config.provider_type, response_body);
1162
0
            }
1163
1164
24
            const auto& output = doc["output"];
1165
24
            std::string response_text;
1166
24
            bool has_output_text = false;
1167
1168
68
            for (rapidjson::SizeType i = 0; i < output.Size(); i++) {
1169
44
                const auto& item = output[i];
1170
44
                if (!item.IsObject() || !item.HasMember("type") || !item["type"].IsString()) {
1171
0
                    return Status::InternalError("Invalid output format in {} response: {}",
1172
0
                                                 _config.provider_type, response_body);
1173
0
                }
1174
1175
                // Responses output is heterogeneous. Reasoning and tool items are not final text.
1176
44
                if (std::string_view(item["type"].GetString(), item["type"].GetStringLength()) !=
1177
44
                    "message") {
1178
20
                    continue;
1179
20
                }
1180
1181
24
                if (!item.HasMember("content") || !item["content"].IsArray()) {
1182
0
                    return Status::InternalError("Invalid output format in {} response: {}",
1183
0
                                                 _config.provider_type, response_body);
1184
0
                }
1185
1186
24
                const auto& content = item["content"];
1187
50
                for (rapidjson::SizeType j = 0; j < content.Size(); j++) {
1188
26
                    const auto& part = content[j];
1189
26
                    if (!part.IsObject() || !part.HasMember("type") || !part["type"].IsString()) {
1190
0
                        return Status::InternalError("Invalid output format in {} response: {}",
1191
0
                                                     _config.provider_type, response_body);
1192
0
                    }
1193
1194
26
                    if (std::string_view(part["type"].GetString(),
1195
26
                                         part["type"].GetStringLength()) != "output_text") {
1196
0
                        continue;
1197
0
                    }
1198
1199
26
                    if (!part.HasMember("text") || !part["text"].IsString()) {
1200
0
                        return Status::InternalError("Invalid output format in {} response: {}",
1201
0
                                                     _config.provider_type, response_body);
1202
0
                    }
1203
1204
26
                    has_output_text = true;
1205
26
                    response_text.append(part["text"].GetString(), part["text"].GetStringLength());
1206
26
                }
1207
24
            }
1208
1209
24
            if (!has_output_text) {
1210
1
                return Status::InternalError("No output text in {} response: {}",
1211
1
                                             _config.provider_type, response_body);
1212
1
            }
1213
23
            RETURN_IF_ERROR(append_parsed_text_result(response_text, results, expand_batch));
1214
78
        } else if (doc.HasMember("choices") && doc["choices"].IsArray()) {
1215
            /// for completions endpoint
1216
            /*{
1217
              "object": "chat.completion",
1218
              "model": "gpt-4",
1219
              "choices": [
1220
                {
1221
                  ...
1222
                  "message": {
1223
                    "role": "assistant",
1224
                    "content": "<result JSON string>"
1225
                  },
1226
                  ...
1227
                }
1228
              ],
1229
              ...
1230
            }*/
1231
77
            const auto& choices = doc["choices"];
1232
77
            results.reserve(choices.Size());
1233
1234
150
            for (rapidjson::SizeType i = 0; i < choices.Size(); i++) {
1235
77
                if (!choices[i].IsObject() || !choices[i].HasMember("message") ||
1236
77
                    !choices[i]["message"].IsObject() ||
1237
77
                    !choices[i]["message"].HasMember("content") ||
1238
77
                    !choices[i]["message"]["content"].IsString()) {
1239
4
                    return Status::InternalError("Invalid choice format in {} response: {}",
1240
4
                                                 _config.provider_type, response_body);
1241
4
                }
1242
1243
73
                RETURN_IF_ERROR(append_parsed_text_result(
1244
73
                        std::string_view(choices[i]["message"]["content"].GetString(),
1245
73
                                         choices[i]["message"]["content"].GetStringLength()),
1246
73
                        results, expand_batch));
1247
73
            }
1248
77
        } else {
1249
1
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
1250
1
                                         response_body);
1251
1
        }
1252
1253
96
        return Status::OK();
1254
104
    }
1255
1256
    Status build_multimodal_embedding_request(
1257
            const std::vector<MultimodalType>& /*media_types*/,
1258
            const std::vector<std::string>& /*media_urls*/,
1259
            const std::vector<std::string>& /*media_content_types*/,
1260
1
            std::string& /*request_body*/) const override {
1261
1
        return Status::NotSupported("{} does not support multimodal Embed feature.",
1262
1
                                    _config.provider_type);
1263
1
    }
1264
1265
protected:
1266
12
    bool supports_dimension_param(const std::string& model_name) const override {
1267
12
        return !(model_name == "text-embedding-ada-002");
1268
12
    }
1269
1270
12
    std::string get_dimension_param_name() const override { return "dimensions"; }
1271
1272
private:
1273
    const bool _chat_json_schema;
1274
};
1275
1276
class DeepSeekAdapter : public OpenAIAdapter {
1277
public:
1278
    // Chat Completions supports JSON mode, while Responses supports JSON Schema.
1279
7
    DeepSeekAdapter() : OpenAIAdapter(false) {}
1280
1281
    Status build_embedding_request(const std::vector<std::string>& inputs,
1282
1
                                   std::string& request_body) const override {
1283
1
        return embed_not_supported_status();
1284
1
    }
1285
1286
    Status parse_embedding_response(const std::string& response_body,
1287
1
                                    std::vector<std::vector<float>>& results) const override {
1288
1
        return embed_not_supported_status();
1289
1
    }
1290
};
1291
1292
class MoonShotAdapter : public OpenAIAdapter {
1293
public:
1294
    Status build_embedding_request(const std::vector<std::string>& inputs,
1295
1
                                   std::string& request_body) const override {
1296
1
        return embed_not_supported_status();
1297
1
    }
1298
1299
    Status parse_embedding_response(const std::string& response_body,
1300
1
                                    std::vector<std::vector<float>>& results) const override {
1301
1
        return embed_not_supported_status();
1302
1
    }
1303
};
1304
1305
class MinimaxAdapter : public OpenAIAdapter {
1306
public:
1307
    Status build_request_payload(const std::vector<std::string>& inputs,
1308
                                 const char* const system_prompt, std::string& request_body,
1309
4
                                 bool structured_batch = false) const override {
1310
4
        return OpenAIAdapter::build_request_payload(inputs, system_prompt, request_body, false);
1311
4
    }
1312
1313
    Status build_embedding_request(const std::vector<std::string>& inputs,
1314
1
                                   std::string& request_body) const override {
1315
1
        rapidjson::Document doc;
1316
1
        doc.SetObject();
1317
1
        auto& allocator = doc.GetAllocator();
1318
1319
        /*{
1320
          "text": ["xxx", "xxx", ...],
1321
          "model": "embo-1",
1322
          "type": "db"
1323
        }*/
1324
1
        rapidjson::Value texts(rapidjson::kArrayType);
1325
1
        for (const auto& input : inputs) {
1326
1
            texts.PushBack(rapidjson::Value(input.c_str(), allocator), allocator);
1327
1
        }
1328
1
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
1329
1
        doc.AddMember("texts", texts, allocator);
1330
1
        doc.AddMember("type", rapidjson::Value("db", allocator), allocator);
1331
1332
1
        rapidjson::StringBuffer buffer;
1333
1
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1334
1
        doc.Accept(writer);
1335
1
        request_body = buffer.GetString();
1336
1337
1
        return Status::OK();
1338
1
    }
1339
};
1340
1341
class ZhipuAdapter : public OpenAIAdapter {
1342
public:
1343
    // Zhipu Chat accepts JSON mode, but not response_format.type=json_schema.
1344
5
    ZhipuAdapter() : OpenAIAdapter(false) {}
1345
1346
protected:
1347
2
    bool supports_dimension_param(const std::string& model_name) const override {
1348
2
        return !(model_name == "embedding-2");
1349
2
    }
1350
};
1351
1352
class QwenAdapter : public OpenAIAdapter {
1353
public:
1354
    Status build_multimodal_embedding_request(
1355
            const std::vector<MultimodalType>& media_types,
1356
            const std::vector<std::string>& media_urls,
1357
            const std::vector<std::string>& /*media_content_types*/,
1358
4
            std::string& request_body) const override {
1359
4
        RETURN_IF_ERROR(validate_multimodal_embedding_inputs(
1360
4
                "QWEN", media_types, media_urls, {MultimodalType::IMAGE, MultimodalType::VIDEO}));
1361
1362
3
        rapidjson::Document doc;
1363
3
        doc.SetObject();
1364
3
        auto& allocator = doc.GetAllocator();
1365
1366
        /*{
1367
            "model": "tongyi-embedding-vision-plus",
1368
            "input": {
1369
              "contents": [
1370
                {"image": "<url>"},
1371
                {"video": "<url>"}
1372
              ]
1373
            }
1374
            "parameters": {
1375
              "dimension": 512
1376
            }
1377
        }*/
1378
3
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
1379
3
        rapidjson::Value input(rapidjson::kObjectType);
1380
3
        rapidjson::Value contents(rapidjson::kArrayType);
1381
1382
7
        for (size_t i = 0; i < media_urls.size(); ++i) {
1383
4
            rapidjson::Value media_item(rapidjson::kObjectType);
1384
4
            if (media_types[i] == MultimodalType::IMAGE) {
1385
2
                media_item.AddMember("image", rapidjson::Value(media_urls[i].c_str(), allocator),
1386
2
                                     allocator);
1387
2
            } else {
1388
2
                media_item.AddMember("video", rapidjson::Value(media_urls[i].c_str(), allocator),
1389
2
                                     allocator);
1390
2
            }
1391
4
            contents.PushBack(media_item, allocator);
1392
4
        }
1393
1394
3
        input.AddMember("contents", contents, allocator);
1395
3
        doc.AddMember("input", input, allocator);
1396
3
        if (_config.dimensions != -1 && supports_dimension_param(_config.model_name)) {
1397
3
            rapidjson::Value parameters(rapidjson::kObjectType);
1398
3
            std::string param_name = get_dimension_param_name();
1399
3
            rapidjson::Value dimension_name(param_name.c_str(), allocator);
1400
3
            parameters.AddMember(dimension_name, _config.dimensions, allocator);
1401
3
            doc.AddMember("parameters", parameters, allocator);
1402
3
        }
1403
1404
3
        rapidjson::StringBuffer buffer;
1405
3
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1406
3
        doc.Accept(writer);
1407
3
        request_body = buffer.GetString();
1408
3
        return Status::OK();
1409
4
    }
1410
1411
    Status parse_embedding_response(const std::string& response_body,
1412
2
                                    std::vector<std::vector<float>>& results) const override {
1413
2
        rapidjson::Document doc;
1414
2
        doc.Parse(response_body.c_str());
1415
1416
2
        if (doc.HasParseError() || !doc.IsObject()) [[unlikely]] {
1417
0
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
1418
0
                                         response_body);
1419
0
        }
1420
        // Qwen multimodal embedding usually returns:
1421
        // {
1422
        //   "output": {
1423
        //     "embeddings": [
1424
        //       {"index":0, "embedding":[...], "type":"image|video|text"},
1425
        //       ...
1426
        //     ]
1427
        //   }
1428
        // }
1429
        //
1430
        // In text-only or compatibility endpoints, Qwen may also return OpenAI-style
1431
        // "data":[{"embedding":[...]}]. For compatibility we first parse native
1432
        // output.embeddings and then fallback to OpenAIAdapter parser.
1433
2
        if (doc.HasMember("output") && doc["output"].IsObject() &&
1434
2
            doc["output"].HasMember("embeddings") && doc["output"]["embeddings"].IsArray()) {
1435
2
            const auto& embeddings = doc["output"]["embeddings"];
1436
2
            results.reserve(embeddings.Size());
1437
2
            for (rapidjson::SizeType i = 0; i < embeddings.Size(); i++) {
1438
2
                if (!embeddings[i].IsObject() || !embeddings[i].HasMember("embedding")) {
1439
1
                    return Status::InternalError("Invalid {} response format: {}",
1440
1
                                                 _config.provider_type, response_body);
1441
1
                }
1442
1
                RETURN_IF_ERROR(append_parsed_embedding_result(embeddings[i]["embedding"], results,
1443
1
                                                               response_body));
1444
1
            }
1445
0
            return Status::OK();
1446
2
        }
1447
0
        return OpenAIAdapter::parse_embedding_response(response_body, results);
1448
2
    }
1449
1450
protected:
1451
5
    bool supports_dimension_param(const std::string& model_name) const override {
1452
5
        static const std::unordered_set<std::string> no_dimension_models = {
1453
5
                "text-embedding-v1", "text-embedding-v2", "text2vec", "m3e-base", "m3e-small"};
1454
5
        return !no_dimension_models.contains(model_name);
1455
5
    }
1456
1457
4
    std::string get_dimension_param_name() const override { return "dimension"; }
1458
};
1459
1460
class JinaAdapter : public VoyageAIAdapter {
1461
public:
1462
    Status build_multimodal_embedding_request(
1463
            const std::vector<MultimodalType>& media_types,
1464
            const std::vector<std::string>& media_urls,
1465
            const std::vector<std::string>& /*media_content_types*/,
1466
2
            std::string& request_body) const override {
1467
2
        RETURN_IF_ERROR(validate_multimodal_embedding_inputs(
1468
2
                "JINA", media_types, media_urls, {MultimodalType::IMAGE, MultimodalType::VIDEO}));
1469
1470
2
        rapidjson::Document doc;
1471
2
        doc.SetObject();
1472
2
        auto& allocator = doc.GetAllocator();
1473
1474
        /*{
1475
            "model": "jina-embeddings-v4",
1476
            "task": "text-matching",
1477
            "input": [
1478
              {"image": "<url>"},
1479
              {"video": "<url>"}
1480
            ]
1481
        }*/
1482
2
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
1483
2
        doc.AddMember("task", "text-matching", allocator);
1484
1485
2
        rapidjson::Value input(rapidjson::kArrayType);
1486
5
        for (size_t i = 0; i < media_urls.size(); ++i) {
1487
3
            rapidjson::Value media_item(rapidjson::kObjectType);
1488
3
            if (media_types[i] == MultimodalType::IMAGE) {
1489
2
                media_item.AddMember("image", rapidjson::Value(media_urls[i].c_str(), allocator),
1490
2
                                     allocator);
1491
2
            } else {
1492
1
                media_item.AddMember("video", rapidjson::Value(media_urls[i].c_str(), allocator),
1493
1
                                     allocator);
1494
1
            }
1495
3
            input.PushBack(media_item, allocator);
1496
3
        }
1497
2
        if (_config.dimensions != -1 && supports_dimension_param(_config.model_name)) {
1498
2
            doc.AddMember("dimensions", _config.dimensions, allocator);
1499
2
        }
1500
2
        doc.AddMember("input", input, allocator);
1501
1502
2
        rapidjson::StringBuffer buffer;
1503
2
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1504
2
        doc.Accept(writer);
1505
2
        request_body = buffer.GetString();
1506
2
        return Status::OK();
1507
2
    }
1508
};
1509
1510
class BaichuanAdapter : public OpenAIAdapter {
1511
public:
1512
    // Baichuan Chat supports JSON mode, not response_format.type=json_schema.
1513
8
    BaichuanAdapter() : OpenAIAdapter(false) {}
1514
1515
protected:
1516
0
    bool supports_dimension_param(const std::string& model_name) const override { return false; }
1517
};
1518
1519
// Gemini's embedding format is different from VoyageAI, so it requires a separate adapter
1520
class GeminiAdapter : public AIAdapter {
1521
public:
1522
27
    GeminiAdapter() : AIAdapter(true) {}
1523
1524
10
    AIUsage parse_usage(const std::string& response_body) const override {
1525
10
        return parse_usage_fields(response_body, "usageMetadata", "promptTokenCount",
1526
10
                                  "candidatesTokenCount", "totalTokenCount");
1527
10
    }
1528
1529
4
    Status set_authentication(HttpClient* client) const override {
1530
4
        client->set_header("x-goog-api-key", _config.api_key);
1531
4
        client->set_content_type("application/json");
1532
4
        return Status::OK();
1533
4
    }
1534
1535
    Status build_request_payload(const std::vector<std::string>& inputs,
1536
                                 const char* const system_prompt, std::string& request_body,
1537
4
                                 bool structured_batch = false) const override {
1538
4
        rapidjson::Document doc;
1539
4
        doc.SetObject();
1540
4
        auto& allocator = doc.GetAllocator();
1541
1542
        /*{
1543
          "systemInstruction": {
1544
              "parts": [
1545
                {
1546
                  "text": "system_prompt here"
1547
                }
1548
              ]
1549
            }
1550
          ],
1551
          "contents": [
1552
            {
1553
              "parts": [
1554
                {
1555
                  "text": "xxx"
1556
                }
1557
              ]
1558
            }
1559
          ],
1560
          "generationConfig": {
1561
          "temperature": 0.7,
1562
          "maxOutputTokens": 1024,
1563
          "thinkingConfig": {"thinkingLevel": "high"},
1564
          "responseFormat": <format>
1565
          }
1566
1567
        }*/
1568
4
        if (system_prompt && *system_prompt) {
1569
4
            rapidjson::Value system_instruction(rapidjson::kObjectType);
1570
4
            rapidjson::Value parts(rapidjson::kArrayType);
1571
1572
4
            rapidjson::Value part(rapidjson::kObjectType);
1573
4
            part.AddMember("text", rapidjson::Value(system_prompt, allocator), allocator);
1574
4
            parts.PushBack(part, allocator);
1575
            // system_instruction.PushBack(content, allocator);
1576
4
            system_instruction.AddMember("parts", parts, allocator);
1577
4
            doc.AddMember("systemInstruction", system_instruction, allocator);
1578
4
        }
1579
1580
4
        rapidjson::Value contents(rapidjson::kArrayType);
1581
4
        for (const auto& input : inputs) {
1582
4
            rapidjson::Value content(rapidjson::kObjectType);
1583
4
            rapidjson::Value parts(rapidjson::kArrayType);
1584
1585
4
            rapidjson::Value part(rapidjson::kObjectType);
1586
4
            part.AddMember("text", rapidjson::Value(input.c_str(), allocator), allocator);
1587
1588
4
            parts.PushBack(part, allocator);
1589
4
            content.AddMember("parts", parts, allocator);
1590
4
            contents.PushBack(content, allocator);
1591
4
        }
1592
4
        doc.AddMember("contents", contents, allocator);
1593
1594
        // If 'temperature' and 'max_tokens' are set, add them to the request body.
1595
4
        rapidjson::Value generationConfig(rapidjson::kObjectType);
1596
4
        if (_config.temperature != -1) {
1597
4
            generationConfig.AddMember("temperature", _config.temperature, allocator);
1598
4
        }
1599
4
        if (_config.max_tokens != -1) {
1600
4
            generationConfig.AddMember("maxOutputTokens", _config.max_tokens, allocator);
1601
4
        }
1602
4
        if (!_config.effort.empty()) {
1603
4
            rapidjson::Value thinking_config(rapidjson::kObjectType);
1604
4
            thinking_config.AddMember("thinkingLevel",
1605
4
                                      rapidjson::Value(_config.effort.c_str(), allocator),
1606
4
                                      allocator);
1607
4
            generationConfig.AddMember("thinkingConfig", thinking_config, allocator);
1608
4
        }
1609
4
        doc.AddMember("generationConfig", generationConfig, allocator);
1610
1611
4
        if (structured_batch) {
1612
            // Compile-time JSON fragment: reuse it without building or parsing a schema DOM.
1613
            /* {
1614
              "text": {"mimeType": "APPLICATION_JSON", "schema": {
1615
                  "type": "object",
1616
                  "properties": {"results": {"type": "array", "items": {"type": "string"}}},
1617
                  "required": ["results"],
1618
                  "additionalProperties": false
1619
                }}
1620
            } */
1621
2
            static constexpr std::string_view format =
1622
2
                    R"({"text":{"mimeType":"APPLICATION_JSON","schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"string"}}},"required":["results"],"additionalProperties":false}}})";
1623
2
            append_result_format(doc, request_body, "responseFormat", format, "generationConfig");
1624
2
        } else {
1625
2
            rapidjson::StringBuffer buffer;
1626
2
            rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1627
2
            doc.Accept(writer);
1628
2
            request_body.assign(buffer.GetString(), buffer.GetSize());
1629
2
        }
1630
1631
4
        return Status::OK();
1632
4
    }
1633
1634
    Status parse_response(const std::string& response_body, std::vector<std::string>& results,
1635
23
                          bool expand_batch = true) const override {
1636
        // For structured batches, the text field below contains a JSON-encoded string.
1637
        // Its decoded value is parsed by parse_batch_response:
1638
        // {
1639
        //   "results": [
1640
        //     "row1",
1641
        //     "row2"
1642
        //   ]
1643
        // }
1644
23
        rapidjson::Document doc;
1645
23
        doc.Parse(response_body.c_str());
1646
1647
23
        if (doc.HasParseError() || !doc.IsObject()) {
1648
1
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
1649
1
                                         response_body);
1650
1
        }
1651
22
        if (!doc.HasMember("candidates") || !doc["candidates"].IsArray()) {
1652
1
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
1653
1
                                         response_body);
1654
1
        }
1655
1656
        /*{
1657
          "candidates":[
1658
            {
1659
              "content": {
1660
                "parts": [
1661
                  {
1662
                    "text": "<result JSON string>"
1663
                  }
1664
                ]
1665
              }
1666
            }
1667
          ]
1668
        }*/
1669
21
        const auto& candidates = doc["candidates"];
1670
21
        results.reserve(candidates.Size());
1671
1672
39
        for (rapidjson::SizeType i = 0; i < candidates.Size(); i++) {
1673
21
            if (!candidates[i].IsObject() || !candidates[i].HasMember("content") ||
1674
21
                !candidates[i]["content"].IsObject() ||
1675
21
                !candidates[i]["content"].HasMember("parts") ||
1676
21
                !candidates[i]["content"]["parts"].IsArray() ||
1677
21
                candidates[i]["content"]["parts"].Empty() ||
1678
21
                !candidates[i]["content"]["parts"][0].IsObject() ||
1679
21
                !candidates[i]["content"]["parts"][0].HasMember("text") ||
1680
21
                !candidates[i]["content"]["parts"][0]["text"].IsString()) {
1681
3
                return Status::InternalError("Invalid candidate format in {} response",
1682
3
                                             _config.provider_type);
1683
3
            }
1684
1685
18
            RETURN_IF_ERROR(append_parsed_text_result(
1686
18
                    std::string_view(
1687
18
                            candidates[i]["content"]["parts"][0]["text"].GetString(),
1688
18
                            candidates[i]["content"]["parts"][0]["text"].GetStringLength()),
1689
18
                    results, expand_batch));
1690
18
        }
1691
18
        return Status::OK();
1692
21
    }
1693
1694
    Status build_embedding_request(const std::vector<std::string>& inputs,
1695
2
                                   std::string& request_body) const override {
1696
2
        rapidjson::Document doc;
1697
2
        doc.SetObject();
1698
2
        auto& allocator = doc.GetAllocator();
1699
1700
        /*{
1701
          "requests": [
1702
            {
1703
              "model": "models/gemini-embedding-001",
1704
              "content": {
1705
                "parts": [
1706
                  {
1707
                    "text": "xxx"
1708
                  }
1709
                ]
1710
              },
1711
              "outputDimensionality": 1024
1712
            },
1713
            {
1714
              "model": "models/gemini-embedding-001",
1715
              "content": {
1716
                "parts": [
1717
                  {
1718
                    "text": "yyy"
1719
                  }
1720
                ]
1721
              },
1722
              "outputDimensionality": 1024
1723
            }
1724
          ]
1725
        }*/
1726
1727
        // gemini requires the model format as `models/{model}`
1728
2
        std::string model_name = _config.model_name;
1729
2
        if (!model_name.starts_with("models/")) {
1730
2
            model_name = "models/" + model_name;
1731
2
        }
1732
1733
2
        rapidjson::Value requests(rapidjson::kArrayType);
1734
4
        for (const auto& input : inputs) {
1735
4
            rapidjson::Value request(rapidjson::kObjectType);
1736
4
            request.AddMember("model", rapidjson::Value(model_name.c_str(), allocator), allocator);
1737
4
            add_dimension_params(request, allocator);
1738
1739
4
            rapidjson::Value content(rapidjson::kObjectType);
1740
4
            rapidjson::Value parts(rapidjson::kArrayType);
1741
4
            rapidjson::Value part(rapidjson::kObjectType);
1742
4
            part.AddMember("text", rapidjson::Value(input.c_str(), allocator), allocator);
1743
4
            parts.PushBack(part, allocator);
1744
4
            content.AddMember("parts", parts, allocator);
1745
4
            request.AddMember("content", content, allocator);
1746
4
            requests.PushBack(request, allocator);
1747
4
        }
1748
2
        doc.AddMember("requests", requests, allocator);
1749
1750
2
        rapidjson::StringBuffer buffer;
1751
2
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1752
2
        doc.Accept(writer);
1753
2
        request_body = buffer.GetString();
1754
1755
2
        return Status::OK();
1756
2
    }
1757
1758
    Status build_multimodal_embedding_request(const std::vector<MultimodalType>& media_types,
1759
                                              const std::vector<std::string>& media_urls,
1760
                                              const std::vector<std::string>& media_content_types,
1761
8
                                              std::string& request_body) const override {
1762
8
        RETURN_IF_ERROR(validate_multimodal_embedding_inputs(
1763
8
                "Gemini", media_types, media_urls,
1764
8
                {MultimodalType::IMAGE, MultimodalType::AUDIO, MultimodalType::VIDEO}));
1765
6
        if (media_content_types.size() != media_urls.size()) {
1766
1
            return Status::InvalidArgument(
1767
1
                    "Gemini multimodal embed input size mismatch, media_content_types={}, "
1768
1
                    "media_urls={}",
1769
1
                    media_content_types.size(), media_urls.size());
1770
1
        }
1771
1772
5
        rapidjson::Document doc;
1773
5
        doc.SetObject();
1774
5
        auto& allocator = doc.GetAllocator();
1775
1776
        /*{
1777
          "requests": [
1778
            {
1779
              "model": "models/gemini-embedding-2-preview",
1780
              "content": {
1781
                "parts": [
1782
                  {"file_data": {"mime_type": "<original content_type>", "file_uri": "<url>"}}
1783
                ]
1784
              },
1785
              "outputDimensionality": 768
1786
            },
1787
            {
1788
              "model": "models/gemini-embedding-2-preview",
1789
              "content": {
1790
                "parts": [
1791
                  {"file_data": {"mime_type": "<original content_type>", "file_uri": "<url>"}}
1792
                ]
1793
              },
1794
              "outputDimensionality": 768
1795
            }
1796
          ]
1797
        }*/
1798
5
        std::string model_name = _config.model_name;
1799
5
        if (!model_name.starts_with("models/")) {
1800
5
            model_name = "models/" + model_name;
1801
5
        }
1802
1803
5
        rapidjson::Value requests(rapidjson::kArrayType);
1804
12
        for (size_t i = 0; i < media_urls.size(); ++i) {
1805
7
            rapidjson::Value request(rapidjson::kObjectType);
1806
7
            request.AddMember("model", rapidjson::Value(model_name.c_str(), allocator), allocator);
1807
7
            add_dimension_params(request, allocator);
1808
1809
7
            rapidjson::Value content(rapidjson::kObjectType);
1810
7
            rapidjson::Value parts(rapidjson::kArrayType);
1811
7
            rapidjson::Value part(rapidjson::kObjectType);
1812
7
            rapidjson::Value file_data(rapidjson::kObjectType);
1813
7
            file_data.AddMember("mime_type",
1814
7
                                rapidjson::Value(media_content_types[i].c_str(), allocator),
1815
7
                                allocator);
1816
7
            file_data.AddMember("file_uri", rapidjson::Value(media_urls[i].c_str(), allocator),
1817
7
                                allocator);
1818
7
            part.AddMember("file_data", file_data, allocator);
1819
7
            parts.PushBack(part, allocator);
1820
7
            content.AddMember("parts", parts, allocator);
1821
7
            request.AddMember("content", content, allocator);
1822
7
            requests.PushBack(request, allocator);
1823
7
        }
1824
5
        doc.AddMember("requests", requests, allocator);
1825
1826
5
        rapidjson::StringBuffer buffer;
1827
5
        rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1828
5
        doc.Accept(writer);
1829
5
        request_body = buffer.GetString();
1830
5
        return Status::OK();
1831
6
    }
1832
1833
    Status parse_embedding_response(const std::string& response_body,
1834
6
                                    std::vector<std::vector<float>>& results) const override {
1835
6
        rapidjson::Document doc;
1836
6
        doc.Parse(response_body.c_str());
1837
1838
6
        if (doc.HasParseError() || !doc.IsObject()) {
1839
0
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
1840
0
                                         response_body);
1841
0
        }
1842
6
        if (doc.HasMember("embeddings") && doc["embeddings"].IsArray()) {
1843
            /*{
1844
              "embeddings": [
1845
                {"values": [0.1, 0.2, 0.3]},
1846
                {"values": [0.4, 0.5, 0.6]}
1847
              ]
1848
            }*/
1849
4
            const auto& embeddings = doc["embeddings"];
1850
4
            results.reserve(embeddings.Size());
1851
8
            for (rapidjson::SizeType i = 0; i < embeddings.Size(); i++) {
1852
6
                if (!embeddings[i].IsObject() || !embeddings[i].HasMember("values")) {
1853
1
                    return Status::InternalError("Invalid {} response format: {}",
1854
1
                                                 _config.provider_type, response_body);
1855
1
                }
1856
5
                RETURN_IF_ERROR(append_parsed_embedding_result(embeddings[i]["values"], results,
1857
5
                                                               response_body));
1858
5
            }
1859
2
            return Status::OK();
1860
4
        }
1861
2
        if (!doc.HasMember("embedding") || !doc["embedding"].IsObject()) {
1862
0
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
1863
0
                                         response_body);
1864
0
        }
1865
1866
        /*{
1867
          "embedding":{
1868
            "values": [0.1, 0.2, 0.3]
1869
          }
1870
        }*/
1871
2
        const auto& embedding = doc["embedding"];
1872
2
        if (!embedding.HasMember("values")) {
1873
0
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
1874
0
                                         response_body);
1875
0
        }
1876
2
        RETURN_IF_ERROR(
1877
2
                append_parsed_embedding_result(embedding["values"], results, response_body));
1878
1879
1
        return Status::OK();
1880
2
    }
1881
1882
protected:
1883
11
    bool supports_dimension_param(const std::string& model_name) const override {
1884
11
        static const std::unordered_set<std::string> no_dimension_models = {"models/embedding-001",
1885
11
                                                                            "embedding-001"};
1886
11
        return !no_dimension_models.contains(model_name);
1887
11
    }
1888
1889
9
    std::string get_dimension_param_name() const override { return "outputDimensionality"; }
1890
};
1891
1892
class AnthropicAdapter : public VoyageAIAdapter {
1893
public:
1894
13
    AnthropicAdapter() : VoyageAIAdapter(true) {}
1895
1896
10
    AIUsage parse_usage(const std::string& response_body) const override {
1897
10
        return parse_usage_fields(response_body, "usage", "input_tokens", "output_tokens", nullptr);
1898
10
    }
1899
1900
2
    Status set_authentication(HttpClient* client) const override {
1901
2
        client->set_header("x-api-key", _config.api_key);
1902
2
        client->set_header("anthropic-version", _config.anthropic_version);
1903
2
        client->set_content_type("application/json");
1904
1905
2
        return Status::OK();
1906
2
    }
1907
1908
    Status build_request_payload(const std::vector<std::string>& inputs,
1909
                                 const char* const system_prompt, std::string& request_body,
1910
5
                                 bool structured_batch = false) const override {
1911
5
        rapidjson::Document doc;
1912
5
        doc.SetObject();
1913
5
        auto& allocator = doc.GetAllocator();
1914
1915
        /*
1916
            "model": "claude-opus-4-1-20250805",
1917
            "max_tokens": 1024,
1918
            "output_config": {"effort": "medium", "format": <format>},
1919
            "system": "system_prompt here",
1920
            "messages": [
1921
              {"role": "user", "content": "xxx"}
1922
            ],
1923
            "temperature": 0.7
1924
        */
1925
1926
        // If 'temperature' and 'max_tokens' are set, add them to the request body.
1927
5
        doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
1928
5
        if (_config.temperature != -1) {
1929
5
            doc.AddMember("temperature", _config.temperature, allocator);
1930
5
        }
1931
5
        if (_config.max_tokens != -1) {
1932
5
            doc.AddMember("max_tokens", _config.max_tokens, allocator);
1933
5
        } else {
1934
            // Keep the default value, Anthropic requires this parameter
1935
0
            doc.AddMember("max_tokens", 2048, allocator);
1936
0
        }
1937
5
        if (!_config.effort.empty()) {
1938
3
            rapidjson::Value output_config(rapidjson::kObjectType);
1939
3
            output_config.AddMember("effort", rapidjson::Value(_config.effort.c_str(), allocator),
1940
3
                                    allocator);
1941
3
            doc.AddMember("output_config", output_config, allocator);
1942
3
        }
1943
5
        if (system_prompt && *system_prompt) {
1944
5
            doc.AddMember("system", rapidjson::Value(system_prompt, allocator), allocator);
1945
5
        }
1946
1947
5
        rapidjson::Value messages(rapidjson::kArrayType);
1948
5
        for (const auto& input : inputs) {
1949
5
            rapidjson::Value message(rapidjson::kObjectType);
1950
5
            message.AddMember("role", "user", allocator);
1951
5
            message.AddMember("content", rapidjson::Value(input.c_str(), allocator), allocator);
1952
5
            messages.PushBack(message, allocator);
1953
5
        }
1954
5
        doc.AddMember("messages", messages, allocator);
1955
1956
5
        if (structured_batch) {
1957
            // Compile-time JSON fragment: reuse it without building or parsing a schema DOM.
1958
            /* {
1959
              "type": "json_schema", "schema": {
1960
                  "type": "object",
1961
                  "properties": {"results": {"type": "array", "items": {"type": "string"}}},
1962
                  "required": ["results"],
1963
                  "additionalProperties": false
1964
                }
1965
            } */
1966
3
            static constexpr std::string_view format =
1967
3
                    R"({"type":"json_schema","schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"string"}}},"required":["results"],"additionalProperties":false}})";
1968
3
            append_result_format(doc, request_body, "format", format, "output_config");
1969
3
        } else {
1970
2
            rapidjson::StringBuffer buffer;
1971
2
            rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
1972
2
            doc.Accept(writer);
1973
2
            request_body.assign(buffer.GetString(), buffer.GetSize());
1974
2
        }
1975
1976
5
        return Status::OK();
1977
5
    }
1978
1979
    Status parse_response(const std::string& response_body, std::vector<std::string>& results,
1980
21
                          bool expand_batch = true) const override {
1981
        // For structured batches, the text field below contains a JSON-encoded string.
1982
        // Its decoded value is parsed by parse_batch_response:
1983
        // {
1984
        //   "results": [
1985
        //     "row1",
1986
        //     "row2"
1987
        //   ]
1988
        // }
1989
21
        rapidjson::Document doc;
1990
21
        doc.Parse(response_body.c_str());
1991
21
        if (doc.HasParseError() || !doc.IsObject()) {
1992
1
            return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
1993
1
                                         response_body);
1994
1
        }
1995
20
        if (!doc.HasMember("content") || !doc["content"].IsArray()) {
1996
1
            return Status::InternalError("Invalid {} response format: {}", _config.provider_type,
1997
1
                                         response_body);
1998
1
        }
1999
2000
        /*{
2001
            "content": [
2002
              {
2003
                "text": "<result JSON string>",
2004
                "type": "text"
2005
              }
2006
            ]
2007
        }*/
2008
19
        const auto& content = doc["content"];
2009
19
        results.reserve(1);
2010
2011
19
        std::string result;
2012
37
        for (rapidjson::SizeType i = 0; i < content.Size(); i++) {
2013
19
            if (!content[i].IsObject()) {
2014
1
                return Status::InternalError("Invalid {} response format: {}",
2015
1
                                             _config.provider_type, response_body);
2016
1
            }
2017
18
            if (!content[i].HasMember("type") || !content[i]["type"].IsString() ||
2018
18
                !content[i].HasMember("text") || !content[i]["text"].IsString()) {
2019
0
                continue;
2020
0
            }
2021
2022
18
            if (std::string(content[i]["type"].GetString()) == "text") {
2023
18
                if (!result.empty()) {
2024
0
                    result += "\n";
2025
0
                }
2026
18
                result.append(content[i]["text"].GetString(), content[i]["text"].GetStringLength());
2027
18
            }
2028
18
        }
2029
2030
18
        return append_parsed_text_result(result, results, expand_batch);
2031
19
    }
2032
};
2033
2034
// Mock adapter used only for UT to bypass real HTTP calls and return deterministic data.
2035
class MockAdapter : public AIAdapter {
2036
public:
2037
#ifdef BE_TEST
2038
    static void clear_embedding_inputs_for_test() { _embedding_inputs_for_test().clear(); }
2039
2040
    static const std::vector<std::string>& get_embedding_inputs_for_test() {
2041
        return _embedding_inputs_for_test();
2042
    }
2043
#endif
2044
2045
0
    Status set_authentication(HttpClient* client) const override { return Status::OK(); }
2046
2047
    Status build_request_payload(const std::vector<std::string>& inputs,
2048
                                 const char* const system_prompt, std::string& request_body,
2049
3
                                 bool structured_batch = false) const override {
2050
3
        return Status::OK();
2051
3
    }
2052
2053
    Status parse_response(const std::string& response_body, std::vector<std::string>& results,
2054
82
                          bool expand_batch = true) const override {
2055
82
        return append_parsed_text_result(response_body, results, expand_batch);
2056
82
    }
2057
2058
    Status build_embedding_request(const std::vector<std::string>& inputs,
2059
7
                                   std::string& request_body) const override {
2060
#ifdef BE_TEST
2061
        auto& embedding_inputs = _embedding_inputs_for_test();
2062
        embedding_inputs.insert(embedding_inputs.end(), inputs.begin(), inputs.end());
2063
#endif
2064
7
        return Status::OK();
2065
7
    }
2066
2067
    Status build_multimodal_embedding_request(
2068
            const std::vector<MultimodalType>& /*media_types*/,
2069
            const std::vector<std::string>& /*media_urls*/,
2070
            const std::vector<std::string>& /*media_content_types*/,
2071
3
            std::string& /*request_body*/) const override {
2072
3
        return Status::OK();
2073
3
    }
2074
2075
    Status parse_embedding_response(const std::string& response_body,
2076
1
                                    std::vector<std::vector<float>>& results) const override {
2077
1
        rapidjson::Document doc;
2078
1
        doc.SetObject();
2079
1
        doc.Parse(response_body.c_str());
2080
1
        if (doc.HasParseError() || !doc.IsObject()) {
2081
0
            return Status::InternalError("Failed to parse embedding response");
2082
0
        }
2083
1
        if (!doc.HasMember("embedding") || !doc["embedding"].IsArray()) {
2084
0
            return Status::InternalError("Invalid embedding response format");
2085
0
        }
2086
2087
1
        results.reserve(1);
2088
1
        return append_parsed_embedding_result(doc["embedding"], results, response_body);
2089
1
    }
2090
2091
private:
2092
#ifdef BE_TEST
2093
    static std::vector<std::string>& _embedding_inputs_for_test() {
2094
        static thread_local std::vector<std::string> embedding_inputs;
2095
        return embedding_inputs;
2096
    }
2097
#endif
2098
};
2099
2100
class AIAdapterFactory {
2101
public:
2102
189
    static std::shared_ptr<AIAdapter> create_adapter(const std::string& provider_type) {
2103
189
        static const std::unordered_map<std::string, std::function<std::shared_ptr<AIAdapter>()>>
2104
189
                adapters = {{"LOCAL", []() { return std::make_shared<LocalAdapter>(); }},
2105
189
                            {"OPENAI", []() { return std::make_shared<OpenAIAdapter>(); }},
2106
189
                            {"MOONSHOT", []() { return std::make_shared<MoonShotAdapter>(); }},
2107
189
                            {"DEEPSEEK", []() { return std::make_shared<DeepSeekAdapter>(); }},
2108
189
                            {"MINIMAX", []() { return std::make_shared<MinimaxAdapter>(); }},
2109
189
                            {"ZHIPU", []() { return std::make_shared<ZhipuAdapter>(); }},
2110
189
                            {"QWEN", []() { return std::make_shared<QwenAdapter>(); }},
2111
189
                            {"JINA", []() { return std::make_shared<JinaAdapter>(); }},
2112
189
                            {"BAICHUAN", []() { return std::make_shared<BaichuanAdapter>(); }},
2113
189
                            {"ANTHROPIC", []() { return std::make_shared<AnthropicAdapter>(); }},
2114
189
                            {"GEMINI", []() { return std::make_shared<GeminiAdapter>(); }},
2115
189
                            {"VOYAGEAI", []() { return std::make_shared<VoyageAIAdapter>(); }},
2116
189
                            {"MOCK", []() { return std::make_shared<MockAdapter>(); }}};
2117
2118
189
        auto it = adapters.find(provider_type);
2119
189
        return (it != adapters.end()) ? it->second() : nullptr;
2120
189
    }
2121
};
2122
2123
} // namespace doris