From 5b7bfac80858ae24355c4c328c2bafaa2837a2f5 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 12:52:28 +0500 Subject: [PATCH 1/4] feat(llm): add aimlapi.com as an LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fincept already speaks to a dozen OpenAI-compatible backends, but every aggregator it ships (AIHubMix, AstraFlow) publishes a different slice of the model market. aimlapi.com routes 353 chat models — OpenAI, Anthropic, Google, DeepSeek, Qwen, xAI, Mistral — through one /v1/chat/completions endpoint, so a user with one key reaches all of them from the existing provider combo without a per-vendor account. Follows the AIHubMix shape exactly, which is the last provider added: one row per catalogue function in ProviderCatalog, a models-URL fallback for when the prefilled base_url has been cleared, a wildcard output cap, and an entry in the streaming allow-list. The provider id stays `aimlapi`; the label users see is the vendor's own name for itself, the bare domain. Two things are not copies of the AIHubMix row and are worth the reviewer's eye: * The catalogue is one list for every modality — 936 rows for 353 chat models — so parse_models_response filters on `type`. Without it the model combo fills with image, video, TTS and speech-to-text ids that answer /chat/completions with a 404. That field is in the default response, so the Fetch button does not pay for ?include=all. * Call attribution. OpenRouter's HTTP-Referer/X-Title pair was hardcoded inline in LlmService::get_headers; this adds ProviderCatalog::attribution_headers() so the same idea is table-driven, and wires it into all three places the app originates a request (chat, the Fetch button, and Alpha Arena, which builds its own QNetworkRequest and never goes through LlmService). It is merged into the caller's header map, never assigned over it, returns a fresh map per call, and returns nothing at all unless base_url still resolves to the provider's own host — a row left on this provider but repointed at a proxy must not carry the identifiers off-site. The starter model list is deliberately short and every id in it was verified with a live POST to /v1/chat/completions, not just looked up in /v1/models: that catalogue omits ids that serve traffic and publishes at least one that 404s, so membership in it is not evidence either way. New unit suite tst_provider_catalog covers the seam. The partner id assertion in it exists because a malformed id is dropped silently by the gateway — the request still succeeds and nothing anywhere reports the loss, so a regex in a test is the only place a typo can ever be caught. --- .../services/alpha_arena/ArenaLlmClient.cpp | 12 ++ fincept-qt/src/services/llm/LlmModelsApi.cpp | 37 +++- .../src/services/llm/LlmRequestBuilders.cpp | 5 + fincept-qt/src/services/llm/LlmService.cpp | 8 + fincept-qt/src/services/llm/LlmService.h | 6 +- fincept-qt/src/services/llm/ModelCatalog.cpp | 9 + .../src/services/llm/ProviderCatalog.cpp | 64 +++++- fincept-qt/src/services/llm/ProviderCatalog.h | 7 + fincept-qt/tests/CMakeLists.txt | 11 +- fincept-qt/tests/tst_provider_catalog.cpp | 195 ++++++++++++++++++ 10 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 fincept-qt/tests/tst_provider_catalog.cpp diff --git a/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp b/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp index 39e40d80b..cdcd7f706 100644 --- a/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp +++ b/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,17 @@ void ArenaLlmClient::complete(const ArenaLlmRequest& req, std::function 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); diff --git a/fincept-qt/src/services/llm/LlmModelsApi.cpp b/fincept-qt/src/services/llm/LlmModelsApi.cpp index 3c2c919e4..9019e2295 100644 --- a/fincept-qt/src/services/llm/LlmModelsApi.cpp +++ b/fincept-qt/src/services/llm/LlmModelsApi.cpp @@ -82,6 +82,8 @@ 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. @@ -89,7 +91,8 @@ QString LlmService::get_models_url(const QString& provider, const QString& api_k return {}; } -QMap LlmService::get_models_headers(const QString& provider, const QString& api_key) { +QMap LlmService::get_models_headers(const QString& provider, const QString& api_key, + const QString& base_url) { QMap h; const QString p = provider.toLower(); @@ -115,6 +118,15 @@ QMap 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 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; } @@ -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(); @@ -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()); diff --git a/fincept-qt/src/services/llm/LlmRequestBuilders.cpp b/fincept-qt/src/services/llm/LlmRequestBuilders.cpp index f75a710c6..756a19021 100644 --- a/fincept-qt/src/services/llm/LlmRequestBuilders.cpp +++ b/fincept-qt/src/services/llm/LlmRequestBuilders.cpp @@ -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 diff --git a/fincept-qt/src/services/llm/LlmService.cpp b/fincept-qt/src/services/llm/LlmService.cpp index d0fbf15ec..5fd584a5b 100644 --- a/fincept-qt/src/services/llm/LlmService.cpp +++ b/fincept-qt/src/services/llm/LlmService.cpp @@ -392,6 +392,14 @@ QMap 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 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; } diff --git a/fincept-qt/src/services/llm/LlmService.h b/fincept-qt/src/services/llm/LlmService.h index 653082000..0b720eca2 100644 --- a/fincept-qt/src/services/llm/LlmService.h +++ b/fincept-qt/src/services/llm/LlmService.h @@ -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) { @@ -225,7 +226,8 @@ class LlmService : public QObject { const QMap& headers); static QString get_models_url(const QString& provider, const QString& api_key, const QString& base_url); - static QMap get_models_headers(const QString& provider, const QString& api_key); + static QMap 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 diff --git a/fincept-qt/src/services/llm/ModelCatalog.cpp b/fincept-qt/src/services/llm/ModelCatalog.cpp index 05c71f1ac..af85c5dbc 100644 --- a/fincept-qt/src/services/llm/ModelCatalog.cpp +++ b/fincept-qt/src/services/llm/ModelCatalog.cpp @@ -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 diff --git a/fincept-qt/src/services/llm/ProviderCatalog.cpp b/fincept-qt/src/services/llm/ProviderCatalog.cpp index 3d7a86504..b39b59145 100644 --- a/fincept-qt/src/services/llm/ProviderCatalog.cpp +++ b/fincept-qt/src/services/llm/ProviderCatalog.cpp @@ -6,13 +6,14 @@ #include #include +#include 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"}; + static const QStringList kProviders = {"openai", "anthropic", "gemini", "groq", "deepseek", + "openrouter", "minimax", "kimi", "ollama", "xai", + "fincept", "astraflow", "astraflow_cn", "aihubmix", "aimlapi"}; return kProviders; } @@ -42,6 +43,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); @@ -85,9 +87,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 ProviderCatalog::attribution_headers(const QString& provider, const QString& base_url) { + QMap 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_finceptterminal"; + h["X-AIMLAPI-Source"] = "agent/finceptterminal"; + return h; +} + QStringList ProviderCatalog::fallback_models(const QString& provider) { const QString p = provider.toLower(); if (p == "openai") @@ -161,6 +194,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 {}; } @@ -179,7 +235,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()) diff --git a/fincept-qt/src/services/llm/ProviderCatalog.h b/fincept-qt/src/services/llm/ProviderCatalog.h index ca64a4106..31fb939c7 100644 --- a/fincept-qt/src/services/llm/ProviderCatalog.h +++ b/fincept-qt/src/services/llm/ProviderCatalog.h @@ -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 #include #include @@ -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 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. diff --git a/fincept-qt/tests/CMakeLists.txt b/fincept-qt/tests/CMakeLists.txt index 8dbb8c4af..eb55200da 100644 --- a/fincept-qt/tests/CMakeLists.txt +++ b/fincept-qt/tests/CMakeLists.txt @@ -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. @@ -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") diff --git a/fincept-qt/tests/tst_provider_catalog.cpp b/fincept-qt/tests/tst_provider_catalog.cpp new file mode 100644 index 000000000..d71e136ec --- /dev/null +++ b/fincept-qt/tests/tst_provider_catalog.cpp @@ -0,0 +1,195 @@ +// Unit tests for src/services/llm/ProviderCatalog.{h,cpp} — the attribution-header +// seam added for aimlapi.com, plus the catalogue entries that feed it. +// +// Why this file exists at all: the partner id is a *silent* contract. A malformed +// `X-AIMLAPI-Partner-ID` is not rejected by the gateway — the request succeeds, the +// header is dropped, and the attribution simply never happens. There is no runtime +// signal, in a log or on screen, that would ever tell anyone. A regex assertion is +// the only place a typo can be caught, so it lives here rather than nowhere. +// +// The other half is scoping. attribution_headers() takes the configured base_url and +// not just the provider id, because the LLM Config screen lets a user retype that +// field: a row still labelled "aimlapi" but pointed at a proxy, or at a different +// vendor entirely, must not carry the partner id along with it. +// +// ProviderCatalog is pure Qt Core (no network, no widgets, no singletons), so this +// suite costs exactly one extra translation unit — see the HARD RULE in +// tests/CMakeLists.txt. + +#include "services/llm/ProviderCatalog.h" + +#include +#include +#include +#include +#include + +using fincept::ai_chat::ProviderCatalog; + +namespace { + +// apps/api gateway contract: /^part_[A-Za-z0-9]{1,64}$/ — alphanumerics only after +// the prefix, no dashes and no underscores. +const QRegularExpression& partner_id_pattern() { + static const QRegularExpression re(QStringLiteral("^part_[A-Za-z0-9]{1,64}$")); + return re; +} + +// Signup-source contract: "/", channel from a closed enum, client +// lowercase alphanumeric-and-dash. An unrecognised channel means the whole value is +// discarded, which is just as silent as a bad partner id. +const QRegularExpression& source_pattern() { + static const QRegularExpression re(QStringLiteral("^(web|agent|mcp)/[a-z0-9-]{1,32}$")); + return re; +} + +} // namespace + +class TstProviderCatalog : public QObject { + Q_OBJECT + + private slots: + void aimlapi_is_a_known_provider(); + void aimlapi_display_name_is_the_domain(); + void aimlapi_resolves_a_chat_completions_endpoint(); + void aimlapi_never_resolves_a_bare_completions_path(); + void attribution_partner_id_matches_the_gateway_pattern(); + void attribution_source_matches_the_signup_source_pattern(); + void attribution_referer_and_title_identify_the_host_app(); + void attribution_is_empty_for_providers_without_it(); + void attribution_is_scoped_to_our_own_host(); + void attribution_survives_a_scheme_less_base_url(); + void attribution_returns_an_independent_map_each_call(); + void aimlapi_fallback_models_are_prefixed_ids(); + void aimlapi_fallback_models_have_no_duplicates(); +}; + +void TstProviderCatalog::aimlapi_is_a_known_provider() { + QVERIFY(ProviderCatalog::known_providers().contains(QStringLiteral("aimlapi"))); + QVERIFY(ProviderCatalog::requires_api_key(QStringLiteral("aimlapi"))); + QVERIFY(ProviderCatalog::is_openai_compatible(QStringLiteral("aimlapi"))); + QVERIFY(!ProviderCatalog::is_blocked(QStringLiteral("aimlapi"), + ProviderCatalog::default_base_url(QStringLiteral("aimlapi")))); +} + +void TstProviderCatalog::aimlapi_display_name_is_the_domain() { + // The vendor's own name for itself is the bare domain — not "AIMLAPI", not + // "AI/ML API". display_name() otherwise capitalises an unknown id, which would + // silently produce "Aimlapi" if this row were ever dropped. + QCOMPARE(ProviderCatalog::display_name(QStringLiteral("aimlapi")), QStringLiteral("aimlapi.com")); +} + +void TstProviderCatalog::aimlapi_resolves_a_chat_completions_endpoint() { + // With the base_url field cleared — the case that broke AstraFlow, where the + // host lived only in default_base_url and the screen's prefill was the only + // thing making the provider reachable. + QCOMPARE(ProviderCatalog::chat_endpoint(QStringLiteral("aimlapi"), QString(), QStringLiteral("openai/gpt-4o-mini")), + QStringLiteral("https://api.aimlapi.com/v1/chat/completions")); + // And with the prefilled value present. + QCOMPARE(ProviderCatalog::chat_endpoint(QStringLiteral("aimlapi"), QStringLiteral("https://api.aimlapi.com/v1"), + QStringLiteral("openai/gpt-4o-mini")), + QStringLiteral("https://api.aimlapi.com/v1/chat/completions")); +} + +void TstProviderCatalog::aimlapi_never_resolves_a_bare_completions_path() { + // https://api.aimlapi.com/v1/completions does not exist and returns 404. Nothing + // should ever compose it, including from a trailing-slash base_url. + const QString url = ProviderCatalog::chat_endpoint(QStringLiteral("aimlapi"), + QStringLiteral("https://api.aimlapi.com/v1/"), + QStringLiteral("openai/gpt-4o-mini")); + QCOMPARE(url, QStringLiteral("https://api.aimlapi.com/v1/chat/completions")); + QVERIFY(!url.endsWith(QStringLiteral("/v1/completions"))); +} + +void TstProviderCatalog::attribution_partner_id_matches_the_gateway_pattern() { + const auto h = ProviderCatalog::attribution_headers(QStringLiteral("aimlapi")); + QVERIFY(h.contains(QStringLiteral("X-AIMLAPI-Partner-ID"))); + const QString id = h.value(QStringLiteral("X-AIMLAPI-Partner-ID")); + QVERIFY2(partner_id_pattern().match(id).hasMatch(), + qPrintable(QStringLiteral("partner id '%1' does not match ^part_[A-Za-z0-9]{1,64}$ — the gateway " + "drops it silently and the request still succeeds") + .arg(id))); +} + +void TstProviderCatalog::attribution_source_matches_the_signup_source_pattern() { + const auto h = ProviderCatalog::attribution_headers(QStringLiteral("aimlapi")); + const QString src = h.value(QStringLiteral("X-AIMLAPI-Source")); + QVERIFY2(source_pattern().match(src).hasMatch(), qPrintable(QStringLiteral("source '%1' is not /") + .arg(src))); +} + +void TstProviderCatalog::attribution_referer_and_title_identify_the_host_app() { + // These two are the OpenRouter convention and name the CALLING application, not + // the gateway — same values the openrouter arm in LlmService::get_headers sends. + const auto h = ProviderCatalog::attribution_headers(QStringLiteral("aimlapi")); + QCOMPARE(h.value(QStringLiteral("HTTP-Referer")), QStringLiteral("https://fincept.in")); + QCOMPARE(h.value(QStringLiteral("X-Title")), QStringLiteral("Fincept Terminal")); +} + +void TstProviderCatalog::attribution_is_empty_for_providers_without_it() { + for (const QString& p : {QStringLiteral("openai"), QStringLiteral("openrouter"), QStringLiteral("aihubmix"), + QStringLiteral("ollama"), QStringLiteral("fincept")}) { + QVERIFY2(ProviderCatalog::attribution_headers(p).isEmpty(), qPrintable(p)); + } +} + +void TstProviderCatalog::attribution_is_scoped_to_our_own_host() { + // A row left on "aimlapi" but repointed at a proxy, an unrelated vendor, or a + // look-alike domain must not carry the partner id off-site. + for (const QString& base : {QStringLiteral("https://gw.example.com/v1"), QStringLiteral("http://localhost:8080/v1"), + QStringLiteral("https://api.aimlapi.com.evil.test/v1"), + QStringLiteral("https://aihubmix.com/v1")}) { + QVERIFY2(ProviderCatalog::attribution_headers(QStringLiteral("aimlapi"), base).isEmpty(), qPrintable(base)); + } + // Host match is case-insensitive, and the path beyond the host is irrelevant. + QCOMPARE(ProviderCatalog::attribution_headers(QStringLiteral("aimlapi"), QStringLiteral("https://API.AIMLAPI.COM/v1")) + .size(), + 4); +} + +void TstProviderCatalog::attribution_survives_a_scheme_less_base_url() { + // The base_url field is free text. "api.aimlapi.com/v1" parses as a QUrl with an + // empty host, so a naive host comparison drops the partner id — and a dropped + // partner id has NO runtime symptom at all: the request still succeeds. + for (const QString& base : {QStringLiteral("api.aimlapi.com/v1"), QStringLiteral("api.aimlapi.com"), + QStringLiteral(" https://api.aimlapi.com/v1 ")}) { + QCOMPARE(ProviderCatalog::attribution_headers(QStringLiteral("aimlapi"), base).size(), 4); + } + // Normalising a scheme-less value must not turn a foreign host into ours. + QVERIFY(ProviderCatalog::attribution_headers(QStringLiteral("aimlapi"), QStringLiteral("gw.example.com/v1")) + .isEmpty()); +} + +void TstProviderCatalog::attribution_returns_an_independent_map_each_call() { + // Callers merge this into their own header map; if it ever became a reference to + // a shared static, one caller's edit would leak into every later request. + auto first = ProviderCatalog::attribution_headers(QStringLiteral("aimlapi")); + first["X-AIMLAPI-Partner-ID"] = QStringLiteral("part_tampered"); + first.remove(QStringLiteral("X-Title")); + + const auto second = ProviderCatalog::attribution_headers(QStringLiteral("aimlapi")); + QCOMPARE(second.size(), 4); + QVERIFY(second.value(QStringLiteral("X-AIMLAPI-Partner-ID")) != QStringLiteral("part_tampered")); + QVERIFY(second.contains(QStringLiteral("X-Title"))); +} + +void TstProviderCatalog::aimlapi_fallback_models_are_prefixed_ids() { + // aimlapi ids carry a vendor prefix ("openai/gpt-4o", not "gpt-4o"); an + // unprefixed id from a neighbouring provider's list would 404 at request time. + const QStringList models = ProviderCatalog::fallback_models(QStringLiteral("aimlapi")); + QVERIFY(!models.isEmpty()); + for (const QString& m : models) { + QVERIFY2(m.contains('/'), qPrintable(m)); + QVERIFY2(!m.startsWith('/') && !m.endsWith('/'), qPrintable(m)); + } +} + +void TstProviderCatalog::aimlapi_fallback_models_have_no_duplicates() { + // The starter list is hand-maintained. A duplicated id shows up twice in the + // model combo and is the kind of thing nobody notices in review. + const QStringList models = ProviderCatalog::fallback_models(QStringLiteral("aimlapi")); + QCOMPARE(QSet(models.cbegin(), models.cend()).size(), models.size()); +} + +QTEST_GUILESS_MAIN(TstProviderCatalog) +#include "tst_provider_catalog.moc" From e9b1d883b03d8f68aab7a312e60f8fe063beae0f Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 12:52:40 +0500 Subject: [PATCH 2/4] fix(arena): stop under-counting reasoning tokens in arena decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArenaStore::token_totals and the decision log both bill a round as prompt_tokens + completion_tokens. That assumes completion_tokens covers everything the model generated, and on reasoning models it does not — the split is per-route, not per-vendor. Measured against one gateway with one request shape: a one-word reply from gemini-2.5-pro came back as completion_tokens=1, reasoning_tokens=57, total_tokens=69, and grok-4.6 as completion_tokens=1, reasoning_tokens=94, total_tokens=745 — while gpt-5.5 and deepseek-v4 fold reasoning into completion_tokens and reconcile exactly. So the arena was reporting a reasoning agent's usage as a small fraction of the work actually billed for it, with no error and nothing on screen to suggest the number was wrong. Taking the shortfall from total_tokens is provider-agnostic and a no-op wherever the two already agree, so it needs no per-model table to keep up to date. Separable from the provider change it ships with, if the reviewer would rather see it on its own; it is here because that route is where the discrepancy showed up. --- .../src/services/alpha_arena/ArenaLlmClient.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp b/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp index cdcd7f706..99ce0a606 100644 --- a/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp +++ b/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp @@ -95,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"; From e7aaaa0285fb6f51f63af73a11a8f3d837cf7886 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 12:53:21 +0500 Subject: [PATCH 3/4] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves aimlapi.com to the head of ProviderCatalog::known_providers(), the one provider list in the repo that is ordered by hand rather than sorted. Deliberately narrow. The two lists a user actually sees — the Settings provider combo via LlmConfigSection::providers_sorted(), and the Alpha Arena wizard's provider pane — both sort by display name at render time, and that sorting is left untouched: reordering a provider there would mean special-casing one vendor inside a general comparator, which is not a change this project should carry. The repo has no featured/badge mechanism to hook into either (the only thing resembling one is the literal "(recommended)" suffix inside Fincept's own display name, and this provider's label has to stay the bare domain), so none is invented here. Separated into its own commit so it can be dropped with a single revert before this work is offered anywhere outside our fork. --- fincept-qt/src/services/llm/ProviderCatalog.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/fincept-qt/src/services/llm/ProviderCatalog.cpp b/fincept-qt/src/services/llm/ProviderCatalog.cpp index b39b59145..d855a5519 100644 --- a/fincept-qt/src/services/llm/ProviderCatalog.cpp +++ b/fincept-qt/src/services/llm/ProviderCatalog.cpp @@ -11,9 +11,13 @@ 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", "aimlapi"}; + // 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; } From e02ef4a6b604fe45dca1436746ad80b2ac8b30dc Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:12:11 +0500 Subject: [PATCH 4/4] fix(aimlapi): use the registered partner id The placeholder part_finceptterminal was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_7BsLIBzelgOXobyArFlFtmup. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- fincept-qt/src/services/llm/ProviderCatalog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fincept-qt/src/services/llm/ProviderCatalog.cpp b/fincept-qt/src/services/llm/ProviderCatalog.cpp index d855a5519..1cb77a3bc 100644 --- a/fincept-qt/src/services/llm/ProviderCatalog.cpp +++ b/fincept-qt/src/services/llm/ProviderCatalog.cpp @@ -120,7 +120,7 @@ QMap ProviderCatalog::attribution_headers(const QString& provi return h; h["HTTP-Referer"] = "https://fincept.in"; h["X-Title"] = "Fincept Terminal"; - h["X-AIMLAPI-Partner-ID"] = "part_finceptterminal"; + h["X-AIMLAPI-Partner-ID"] = "part_7BsLIBzelgOXobyArFlFtmup"; h["X-AIMLAPI-Source"] = "agent/finceptterminal"; return h; }