diff --git a/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp b/fincept-qt/src/services/alpha_arena/ArenaLlmClient.cpp index 39e40d80b..99ce0a606 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 @@ -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"; @@ -133,6 +147,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..1cb77a3bc 100644 --- a/fincept-qt/src/services/llm/ProviderCatalog.cpp +++ b/fincept-qt/src/services/llm/ProviderCatalog.cpp @@ -6,13 +6,18 @@ #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"}; + // 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; } @@ -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); @@ -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 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_7BsLIBzelgOXobyArFlFtmup"; + 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 +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 {}; } @@ -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()) 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"