Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
Expand Down Expand Up @@ -94,6 +95,19 @@ ArenaLlmResult ArenaLlmClient::parse_response(const QString& provider, const QBy
const auto u = o.value("usage").toObject();
r.prompt_tokens = u.value("prompt_tokens").toInt();
r.completion_tokens = u.value("completion_tokens").toInt();
// Reasoning tokens are billed but are NOT inside completion_tokens on every
// route. Measured on the same gateway, same request shape: gemini-2.5-pro
// answered a one-word reply with completion_tokens=1,
// completion_tokens_details.reasoning_tokens=57 and total_tokens=69, while
// gpt-5.5 and deepseek-v4 already fold reasoning into completion_tokens.
// ArenaStore::token_totals and the decision log both sum
// prompt_tokens + completion_tokens, so without this the arena silently
// under-reports a reasoning agent's usage by an order of magnitude.
// Trusting total_tokens for the shortfall is provider-agnostic and a no-op
// wherever the two already agree.
const int total = u.value("total_tokens").toInt();
if (total > r.prompt_tokens + r.completion_tokens)
r.completion_tokens = total - r.prompt_tokens;
}
if (r.content.isEmpty()) {
r.error = "empty completion";
Expand Down Expand Up @@ -133,6 +147,17 @@ void ArenaLlmClient::complete(const ArenaLlmRequest& req, std::function<void(Are
nr.setRawHeader("Authorization", ("Bearer " + req.api_key).toUtf8());
}

// Arena rounds are real billed traffic on a second code path — LlmService is not
// involved here — so they carry the same call attribution a chat request does.
// Fresh map per request, and empty unless base_url still resolves to the
// provider's own host.
const QMap<QString, QString> attribution = ProviderCatalog::attribution_headers(p, req.base_url);
for (auto it = attribution.constBegin(); it != attribution.constEnd(); ++it) {
const QByteArray name = it.key().toUtf8();
if (!nr.hasRawHeader(name)) // merge, never assign over an auth header set above
nr.setRawHeader(name, it.value().toUtf8());
}

const qint64 t0 = QDateTime::currentMSecsSinceEpoch();
QNetworkReply* reply = nam_->post(nr, build_body(req));
auto* timeout = new QTimer(reply);
Expand Down
37 changes: 35 additions & 2 deletions fincept-qt/src/services/llm/LlmModelsApi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,17 @@ QString LlmService::get_models_url(const QString& provider, const QString& api_k
return "https://api.moonshot.ai/v1/models";
if (p == "aihubmix")
return "https://aihubmix.com/v1/models"; // fallback if prefilled base_url was cleared
if (p == "aimlapi")
return "https://api.aimlapi.com/v1/models"; // fallback if prefilled base_url was cleared
// fincept publishes no models endpoint — /research/llm/models is a 404, and
// /research/llm/async takes no `model` field at all (the backend picks).
// fetch_models() short-circuits to the known list before reaching here.
// minimax has no public /v1/models — caller falls back to known models.
return {};
}

QMap<QString, QString> LlmService::get_models_headers(const QString& provider, const QString& api_key) {
QMap<QString, QString> LlmService::get_models_headers(const QString& provider, const QString& api_key,
const QString& base_url) {
QMap<QString, QString> h;
const QString p = provider.toLower();

Expand All @@ -115,6 +118,15 @@ QMap<QString, QString> LlmService::get_models_headers(const QString& provider, c
if (!api_key.isEmpty())
h["Authorization"] = "Bearer " + api_key;
}
// The Fetch button is a request we send too, so it carries the same attribution
// as a chat call. Merged, never assigned over — the auth header above wins any
// collision — and ProviderCatalog returns an empty map unless base_url still
// resolves to that provider's own host.
const QMap<QString, QString> attribution = ProviderCatalog::attribution_headers(p, base_url);
for (auto it = attribution.constBegin(); it != attribution.constEnd(); ++it) {
if (!h.contains(it.key()))
h.insert(it.key(), it.value());
}
return h;
}

Expand Down Expand Up @@ -170,6 +182,27 @@ QStringList LlmService::parse_models_response(const QString& provider, const QBy
}
if (models.isEmpty())
models = {"MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"};
} else if (p == "aimlapi") {
// Same {"data":[{"id":...}]} envelope as everyone else, but the catalogue is
// one list for every modality: 936 rows, of which only 353 are chat models.
// Unfiltered, the model combo fills up with image/video/embedding/STT ids
// that answer /chat/completions with a 404. `type` carries the endpoint the
// row belongs to and is present in the default response, so the Fetch button
// does not need ?include=all (that flag adds pricing/capabilities/modalities
// and triples the payload to ~1.6 MB for fields the combo never reads).
//
// 71 ids appear on more than one row under different `type`s — the same id is
// both a chat model and, say, a /responses or /batches target. Filtering on
// `type` also de-duplicates those: the 353 chat rows carry 353 distinct ids.
QJsonArray arr = root["data"].toArray();
for (const auto& v : arr) {
QJsonObject m = v.toObject();
if (m["type"].toString() != QLatin1String("openai/chat-completions"))
continue;
QString id = m["id"].toString();
if (!id.isEmpty())
models.append(id);
}
} else {
// OpenAI-compatible: {"data": [{"id": ...}]}.
QJsonArray arr = root["data"].toArray();
Expand Down Expand Up @@ -204,7 +237,7 @@ void LlmService::fetch_models(const QString& provider, const QString& api_key, c

QNetworkRequest req{QUrl(url)};
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
auto headers = get_models_headers(provider, api_key);
auto headers = get_models_headers(provider, api_key, base_url);
for (auto it = headers.constBegin(); it != headers.constEnd(); ++it)
req.setRawHeader(it.key().toUtf8(), it.value().toUtf8());

Expand Down
5 changes: 5 additions & 0 deletions fincept-qt/src/services/llm/LlmRequestBuilders.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ void LlmService::apply_openai_token_limit(QJsonObject& body) const {
// parameter: 'max_tokens' ... Use 'max_completion_tokens' instead").
// Non-OpenAI models (claude/gemini/deepseek/qwen/…) keep max_tokens.
//
// aimlapi.com is deliberately NOT in this list even though it also fronts the
// gpt-5/o-series: it translates the parameter itself. Verified live —
// openai/gpt-5-5 with max_tokens returns 200 (model echo gpt-5.5-2026-04-23),
// so adding it here would swap in a parameter it does not need.
//
// Centralised because the tool-loop follow-ups did NOT do this and sent
// max_tokens unconditionally: on a reasoning model the opening turn was
// built correctly and every follow-up 400'd, so the model would call one
Expand Down
8 changes: 8 additions & 0 deletions fincept-qt/src/services/llm/LlmService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,14 @@ QMap<QString, QString> LlmService::get_headers() const {
h["HTTP-Referer"] = "https://fincept.in";
h["X-Title"] = "Fincept Terminal";
}
// Same idea, table-driven: a provider that publishes attribution headers
// gets them merged in, never assigned over what is already here, and only
// when base_url still resolves to that provider's own host.
const QMap<QString, QString> attribution = ProviderCatalog::attribution_headers(p, base_url_);
for (auto it = attribution.constBegin(); it != attribution.constEnd(); ++it) {
if (!h.contains(it.key()))
h.insert(it.key(), it.value());
}
}
return h;
}
Expand Down
6 changes: 4 additions & 2 deletions fincept-qt/src/services/llm/LlmService.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ inline bool provider_supports_streaming(const QString& provider) {
return provider == "openai" || provider == "anthropic" || provider == "gemini" || provider == "google" ||
provider == "groq" || provider == "deepseek" || provider == "openrouter" || provider == "minimax" ||
provider == "kimi" || provider == "ollama" || provider == "xai" || provider == "fincept" ||
provider == "astraflow" || provider == "astraflow_cn" || provider == "aihubmix";
provider == "astraflow" || provider == "astraflow_cn" || provider == "aihubmix" ||
provider == "aimlapi";
}

inline bool provider_requires_api_key(const QString& provider) {
Expand Down Expand Up @@ -225,7 +226,8 @@ class LlmService : public QObject {
const QMap<QString, QString>& headers);

static QString get_models_url(const QString& provider, const QString& api_key, const QString& base_url);
static QMap<QString, QString> get_models_headers(const QString& provider, const QString& api_key);
static QMap<QString, QString> get_models_headers(const QString& provider, const QString& api_key,
const QString& base_url = {});
static QStringList parse_models_response(const QString& provider, const QByteArray& body);

/// One parsed SSE delta. `is_reasoning` is true for chain-of-thought text
Expand Down
9 changes: 9 additions & 0 deletions fincept-qt/src/services/llm/ModelCatalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ const CatalogEntry kCatalog[] = {
// (user max_tokens still applies).
{"aihubmix", "*", kNoPublishedCap},

// ── aimlapi.com (OpenAI-compatible aggregator) ─────────────────────────
// https://docs.aimlapi.com — routes 353 chat models; the per-model cap is the
// upstream model's and is not derivable from the id, so use the generous
// default. Note max_tokens is a REQUEST cap, not a spend cap here: on some
// reasoning models the billed reasoning tokens run well past the value and the
// response still reports finish_reason "stop", i.e. no signal at all. Do not
// present this control to the user as a cost ceiling.
{"aimlapi", "*", kNoPublishedCap},

// ── Fincept (proxies upstream) ──────────────────────────────────────
// Fincept's /research/llm/async wraps various upstream models. We
// don't know which one is selected server-side, so go with a generous
Expand Down
68 changes: 64 additions & 4 deletions fincept-qt/src/services/llm/ProviderCatalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@

#include <QHash>
#include <QRegularExpression>
#include <QUrl>

namespace fincept::ai_chat {

const QStringList& ProviderCatalog::known_providers() {
static const QStringList kProviders = {"openai", "anthropic", "gemini", "groq", "deepseek",
"openrouter", "minimax", "kimi", "ollama", "xai",
"fincept", "astraflow", "astraflow_cn", "aihubmix"};
// Hand-ordered. This is the only provider list in the repo that is not sorted
// for display: LlmConfigSection::providers_sorted() and the Alpha Arena wizard
// both re-sort by display name, so what this order actually controls is
// registry insertion order in ArenaModelRegistry's catalogue-browse pass.
static const QStringList kProviders = {"aimlapi", "openai", "anthropic", "gemini", "groq",
"deepseek", "openrouter", "minimax", "kimi", "ollama",
"xai", "fincept", "astraflow", "astraflow_cn", "aihubmix"};
return kProviders;
}

Expand Down Expand Up @@ -42,6 +47,7 @@ QString ProviderCatalog::display_name(const QString& provider_id) {
{"astraflow", "AstraFlow"},
{"astraflow_cn", "AstraFlow CN"},
{"aihubmix", "AIHubMix"},
{"aimlapi", "aimlapi.com"},
};
const QString id = provider_id.toLower();
const auto it = kNames.find(id);
Expand Down Expand Up @@ -85,9 +91,40 @@ QString ProviderCatalog::default_base_url(const QString& provider) {
return "https://api.modelverse.cn/v1"; // Astraflow China endpoint (UCloud)
if (p == "aihubmix")
return "https://aihubmix.com/v1"; // AIHubMix — OpenAI-compatible aggregator (500+ models)
if (p == "aimlapi")
return "https://api.aimlapi.com/v1"; // aimlapi.com — OpenAI-compatible aggregator (350+ chat models)
return {};
}

// Attribution headers for aimlapi.com, mirroring the OpenRouter block in
// LlmService::get_headers (HTTP-Referer / X-Title identify the CALLING app, not
// the gateway). Returned by value so callers get a fresh map they may merge
// into — there is no shared mutable constant to clobber.
//
// Scoped to our own host on purpose: the Settings screen lets a user retype
// base_url, and a provider row left on "aimlapi" while pointed at a proxy or a
// different vendor must not carry the partner id with it.
QMap<QString, QString> ProviderCatalog::attribution_headers(const QString& provider, const QString& base_url) {
QMap<QString, QString> h;
if (provider.toLower() != QLatin1String("aimlapi"))
return h;
QString effective = base_url.trimmed();
if (effective.isEmpty())
effective = default_base_url(QStringLiteral("aimlapi"));
// A scheme-less base_url ("api.aimlapi.com/v1") parses with an EMPTY host and
// would fail the check below, dropping attribution with no visible symptom.
// The Settings field accepts whatever is typed, so normalise before comparing.
if (!effective.contains(QLatin1String("://")))
effective.prepend(QLatin1String("https://"));
if (QUrl(effective).host().compare(QLatin1String("api.aimlapi.com"), Qt::CaseInsensitive) != 0)
return h;
h["HTTP-Referer"] = "https://fincept.in";
h["X-Title"] = "Fincept Terminal";
h["X-AIMLAPI-Partner-ID"] = "part_7BsLIBzelgOXobyArFlFtmup";
h["X-AIMLAPI-Source"] = "agent/finceptterminal";
return h;
}

QStringList ProviderCatalog::fallback_models(const QString& provider) {
const QString p = provider.toLower();
if (p == "openai")
Expand Down Expand Up @@ -161,6 +198,29 @@ QStringList ProviderCatalog::fallback_models(const QString& provider) {
"qwen-max",
"qwen-plus",
"grok-4"};
if (p == "aimlapi")
// aimlapi.com — OpenAI-compatible aggregator; 353 of its 936 catalogue rows are
// chat models, all reachable through one /v1/chat/completions endpoint. Ids keep
// a vendor prefix and are NOT interchangeable with the upstream vendor's own
// spelling. Starter list only; full list via the Fetch button.
//
// Every id below was verified on 2026-09-03 with a live POST to
// /v1/chat/completions (HTTP 200, non-empty choices[0]), not just looked up in
// /v1/models: that catalogue both omits ids that serve traffic and lists at least
// one that 404s, so membership alone is not evidence. Dotted spellings are used
// where the vendor publishes both (e.g. claude-sonnet-4.6, not -4-6).
return {"openai/gpt-5-5",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-opus-5",
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"deepseek/deepseek-v4-flash",
"alibaba/qwen3-max",
"x-ai/grok-4-6",
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
"mistralai/mistral-large-2512"};
return {};
}

Expand All @@ -179,7 +239,7 @@ QString ProviderCatalog::brand_color(const QString& provider) {
{"openai", "#10A37F"}, {"anthropic", "#D97757"}, {"gemini", "#4285F4"}, {"groq", "#F55036"},
{"deepseek", "#4D6BFE"}, {"openrouter", "#8B5CF6"}, {"minimax", "#FF4D6A"}, {"kimi", "#16D9C4"},
{"ollama", "#9CA3AF"}, {"xai", "#E7E9EA"}, {"fincept", "#FF8800"}, {"astraflow", "#38BDF8"},
{"astraflow_cn", "#38BDF8"}, {"aihubmix", "#F59E0B"},
{"astraflow_cn", "#38BDF8"}, {"aihubmix", "#F59E0B"}, {"aimlapi", "#1C38FF"},
};
const auto it = kColors.find(provider.toLower());
if (it != kColors.end())
Expand Down
7 changes: 7 additions & 0 deletions fincept-qt/src/services/llm/ProviderCatalog.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// (LlmConfigSection) and the Alpha Arena model registry. Data moved verbatim
// from LlmConfigSection.cpp (2026-06-10); update HERE when adding a provider.

#include <QMap>
#include <QString>
#include <QStringList>

Expand All @@ -25,6 +26,12 @@ class ProviderCatalog {
static bool is_openai_compatible(const QString& provider); // everything except anthropic/gemini/fincept
/// Brand color used for arena agent identity (hex, e.g. "#10A37F").
static QString brand_color(const QString& provider);
/// Extra request headers a provider wants for call attribution (the same job the
/// hardcoded HTTP-Referer/X-Title pair does for OpenRouter). Empty for every
/// provider that has none, and empty when base_url points somewhere other than the
/// provider's own host, so attribution can never ride a request to a third party.
/// Returns a fresh map per call — merge it, do not assign over caller headers.
static QMap<QString, QString> attribution_headers(const QString& provider, const QString& base_url = {});
/// Full chat-completions endpoint. Mirrors LlmService::get_endpoint_url rules:
/// custom base_url wins (appends /v1 + suffix unless already versioned/full);
/// fincept is handled by the CALLER (needs AppConfig) — returns empty for it.
Expand Down
11 changes: 9 additions & 2 deletions fincept-qt/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
# signal about the unit, not about this file: extract the pure logic into a small
# header (or a leaf .cpp) and test that. Do not grow the link line.
#
# Current cost of the whole suite: 3 executables, 5 translation units.
# Current cost of the whole suite: 4 executables, 7 translation units.
# ─────────────────────────────────────────────────────────────────────────────

# Qt Test ships with every Qt installation, so this adds no new dependency.
Expand Down Expand Up @@ -119,4 +119,11 @@ fincept_add_test(tst_order_validator
tst_order_validator.cpp
"${PROJECT_SOURCE_DIR}/src/trading/OrderValidator.cpp")

message(STATUS "Fincept tests: tst_broker_modify_fields, tst_result, tst_order_validator")
# src/services/llm/ProviderCatalog.{h,cpp} — pure Qt Core (QHash/QString/QUrl/
# QRegularExpression). Its .cpp includes only its own header, so this is one extra
# TU with no service, network or repository linkage.
fincept_add_test(tst_provider_catalog
tst_provider_catalog.cpp
"${PROJECT_SOURCE_DIR}/src/services/llm/ProviderCatalog.cpp")

message(STATUS "Fincept tests: tst_broker_modify_fields, tst_result, tst_order_validator, tst_provider_catalog")
Loading