Skip to content

feat: add aimlapi.com LLM channel preset - #1

Open
Lookoff-AIMLAPI wants to merge 3 commits into
mainfrom
feat/aimlapi-provider
Open

feat: add aimlapi.com LLM channel preset#1
Lookoff-AIMLAPI wants to merge 3 commits into
mainfrom
feat/aimlapi-provider

Conversation

@Lookoff-AIMLAPI

Copy link
Copy Markdown
Member

PR Type

  • feat

Background And Problem

仓库的渠道模式(LLM_CHANNELS + LLM_<NAME>_*)本身是通用的,但「预设服务商」列表决定了用户在 Web 设置页能不能一步配好一个 OpenAI-compatible 聚合平台。目前想用 aimlapi.com 的用户只能走「自定义渠道」,并且必须自己知道三件事才不会踩坑:

  1. Base URL 必须停在 https://api.aimlapi.com/v1(该网关只有 /chat/completions/responses,没有 /v1/completions);
  2. 模型要写平台返回的 alias(例如 claude-sonnet-4.6);
  3. 如果照抄 /v1/models 里带 anthropic/gemini/xai/ 前缀的 id,normalize_llm_channel_model() 会把它识别为直连 provider 前缀并原样保留,LiteLLM 于是不再走本渠道 Base URL,请求会被发到 Anthropic/Google 官方端点。

本 PR 把这三件事收敛进一个预设条目 + .env.example 模板 + 文档,运行时不新增任何 Python 分支。

同时补齐渠道归因请求头。这里没有引入新机制:仓库已经有「按 base_url 注入服务商赞助头」的实现(AIHubmix 的 APP-Code),只是它被复制成了 4 份(src/config.py 三处 + src/services/image_stock_extractor.py 一处)。本 PR 把这 4 处收敛成一个 build_provider_extra_headers(),并在其中加入 aimlapi 的归因头。

Scope Of Change

BASE_REF=$(git merge-base HEAD origin/main)
git diff --stat "$BASE_REF"..HEAD
 .env.example                                       |  10 ++
 .github/workflows/00-daily-analysis.yml            |   9 ++
 .../src/components/settings/LLMChannelEditor.tsx   |   2 +-
 .../__tests__/llmProviderTemplates.test.ts         |  13 ++
 .../components/settings/llmProviderTemplates.ts    |  14 ++
 docs/CHANGELOG.md                                  |   1 +
 docs/LLM_CONFIG_GUIDE.md                           |   2 +-
 docs/LLM_CONFIG_GUIDE_EN.md                        |   2 +-
 docs/llm-providers.md                              |   4 +-
 src/config.py                                      |  51 +++++--
 src/services/image_stock_extractor.py              |   7 +-
 tests/test_llm_channel_config.py                   | 160 +++++++++++++++++++++
 12 files changed, 260 insertions(+), 15 deletions(-)
  • 文件总数:12
  • 文件清单:
    • .env.example
    • .github/workflows/00-daily-analysis.yml
    • apps/dsa-web/src/components/settings/LLMChannelEditor.tsx
    • apps/dsa-web/src/components/settings/llmProviderTemplates.ts
    • apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts
    • docs/CHANGELOG.md
    • docs/LLM_CONFIG_GUIDE.md
    • docs/LLM_CONFIG_GUIDE_EN.md
    • docs/llm-providers.md
    • src/config.py
    • src/services/image_stock_extractor.py
    • tests/test_llm_channel_config.py
  • 文档更新文件(docs/*):docs/CHANGELOG.mddocs/llm-providers.mddocs/LLM_CONFIG_GUIDE.mddocs/LLM_CONFIG_GUIDE_EN.md

两个 commit 是有意拆开的:

commit 内容
fa6f7e0 feat: add aimlapi.com LLM channel preset 预设条目、归因头收敛、.env.example、workflow 映射、文档、测试
ccd175d chore(aimlapi): fork-only placement — do not send upstream 只把该预设移到各手工排序列表的第一位,并把「新增渠道」下拉默认值从写死的 'aihubmix' 改为 LLM_PROVIDER_TEMPLATES[0].channelId

第二个 commit 是本 fork 的排序偏好,可以整条 drop 掉而不影响第一个 commit。仓库没有 “recommended / 精选” 徽章机制(capability 标签是事实性描述),因此没有新造一个。

Issue Link

无对应 issue。动机与验收标准:让 aimlapi.com 成为可一步配置的 OpenAI-compatible 渠道预设。验收标准 ——

  1. 设置页选中预设即可得到正确的 Base URL / protocol / 模型示例;
  2. .env 模板可直接复制运行;
  3. tests/test_daily_analysis_workflow_llm_env.py 对每个模板渠道要求的 workflow env 映射与 .env.example 示例契约通过;
  4. 归因请求头只在请求确实发往 api.aimlapi.com 时出现,且用户自定义的同名 header 优先。

Verification Commands And Results

基线(改动前,pristine origin/main checkout)与改动后使用同一环境:Python 3.12.14 venv + requirements.txt,Node 26.7.0 + npm ci

# 后端
python -m pytest -m "not network"
#   基线:6578 passed, 4 deselected
#   改动后:6583 passed, 4 deselected(+5 = 本 PR 新增的 5 条测试,0 failed)

python -m py_compile src/config.py src/services/image_stock_extractor.py tests/test_llm_channel_config.py   # OK
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics                                          # 0
./scripts/test.sh code       # exit 0
./scripts/test.sh yfinance   # exit 0
python scripts/check_ai_assets.py   # [ai-assets] OK

# Web
cd apps/dsa-web && npm ci && npm run lint && npm run build && npm run test
#   基线:lint pass / build pass / 1208 passed, 3 failed, 2 skipped (103 files)
#   改动后:lint pass / build pass / 1209 passed, 3 failed, 2 skipped (103 files)

Web 的 3 个失败在 pristine checkout 上完全一致,与本 PR 无关:

  • tests/ui_governance.test.ts > does not reintroduce native title attributes on common interactive elements
  • src/components/alerts/__tests__/AlertRuleForm.test.tsx > shows JP/KR options for market region in Chinese UI mode
  • src/components/settings/__tests__/SettingsField.test.tsx > localizes TickFlow field descriptions instead of falling back to backend English schema

关于环境噪声(如实说明):本机在验证期间同时跑着其他重负载任务,load average 长时间在 60–150。高负载下两侧都会出现 Test timed out 类失败,且每次失败集合都不一样——这是超时噪声的特征,不是回归:

  • 后端曾在不同轮次出现 test_blocked_scheduled_analysis_times_out_and_allows_next_runtest_three_production_tools_execute_through_spawned_workertest_cold_waiters_degrade_after_timeout_but_fetch_landstest_run_agent_loop_allows_choice_compare_stock_codes 失败,负载下降后单独重跑全部通过;其中 test_blocked_scheduled_analysis_times_out_and_allows_next_run 在 pristine origin/main 上同样会因负载失败。上面填报的 6583 passed / 0 failed 是完整一轮的结果。
  • 前端上面填报的 1209 passed / 3 failed 来自一轮低负载的完整跑,失败项与基线逐条一致。之后在高负载下重跑会额外出现 14–25 条 Test timed out in 5000ms(HomePage / DecisionSignalsPage / PortfolioPage 等与本 PR 无关的页面,每轮不同)。与本 PR 直接相关的文件在最终 head 上单独跑过:llmProviderTemplates.test.ts 10 passed、LLMChannelEditor.test.tsx 62 passed、ui_governance.test.ts 仅剩上述基线失败。

建议以 CI 结果为准复核这两组数字。

真实推理验证(非 mock)

通过本 PR 实际改动的代码路径跑了真实请求:Config._load_from_env() 构造 llm_model_list,原样交给 litellm.Router——即 src/analyzer.py::_init_llm() 的 channel 分支。API Key 经环境变量注入,未写入仓库、commit 或日志。

== model_list built by Config._load_from_env() (api_key redacted) ==
{"model_name": "openai/claude-sonnet-4.6", "litellm_params": {
  "model": "openai/claude-sonnet-4.6", "api_key": "<redacted>",
  "api_base": "https://api.aimlapi.com/v1",
  "extra_headers": {"HTTP-Referer": "https://github.com/ZhuLinsen/daily_stock_analysis",
                    "X-Title": "Daily Stock Analysis",
                    "X-AIMLAPI-Partner-ID": "part_dailystockanalysis",
                    "X-AIMLAPI-Source": "agent/daily-stock-analysis"}}}

== generation params for an unset temperature ==
{'temperature': 0.7}          # never None — see "请求参数 null" below

== 1) chat completion through the Router ==
model: anthropic/claude-sonnet-4.6
content: aimlapi channel ok

== 2) tool calling through the Router ==
tool_calls: [('get_stock_quote', '{"code": "600519"}')]

== 3) headers actually put on the wire ==
POST https://api.aimlapi.com/v1/chat/completions  user-agent=OpenAI/Python 2.54.0
  attribution: {"http-referer": "https://github.com/ZhuLinsen/daily_stock_analysis",
                "x-title": "Daily Stock Analysis",
                "x-aimlapi-partner-id": "part_dailystockanalysis",
                "x-aimlapi-source": "agent/daily-stock-analysis"}
requests observed: 2

模型 id 核对

预设 / .env.example / 文档中出现的三个示例模型,都对 GET https://api.aimlapi.com/v1/modelsid aliases 两个集合逐个核对(该目录 936 条记录 / 785 个唯一 id / 684 个 alias,chat 面为 type == "openai/chat-completions" 的 353 条),并各自实际跑通一次 completion:

示例模型 目录中的归属 通过 Router 实跑
gpt-5.5-2026-04-23 openai/gpt-5.5-2026-04-23 的 alias ✅ 返回 OK
claude-sonnet-4.6 anthropic/claude-sonnet-4.6 的 alias ✅ 返回 aimlapi channel ok,tool call 正常
deepseek-v4-flash deepseek/deepseek-v4-flash 的 alias ✅ 返回 OK

Anthropic 示例特意用点号拼写:该网关同时提供 claude-sonnet-4.6claude-sonnet-4-6,两者都能调用,但 GET /v1/models?include=all 里只有点号形式声明了 tools / vision / structured_output / parallel_tool_calls / reasoning 等能力,横杠形式只声明 streaming。同样的差异也出现在 claude-opus-4.7 / claude-opus-4-7claude-opus-4.8 / claude-opus-4-8 上。对依赖 Agent 工具调用与 Vision 的本仓库来说,写点号形式更安全。

顺带记录一个被换掉的候选:gemini-3.1-pro-preview 在目录中确实存在、正常调用也可用,但当 max_tokens 被 reasoning token 吃满、finish_reason == "length" 时,该网关的 Google 路由会返回一个 不含 choices[0].message、且 usagecompletion_tokens 的 200 响应,LiteLLM 直接抛 InternalServerError: Invalid response object;同样条件下 claude-sonnet-4.6 / gpt-5.5-2026-04-23 / deepseek-v4-flash 都返回合法结构。为免用户第一次试用就撞上一个看不懂的报错,示例模型改成了截断行为可预期的三个。

  • 当前 Head CI:ai-governance:not run / backend-gate:not run / docker-build:not run / web-gate:not run —— 本仓库为 fork,未启用 GitHub Actions(gh api repos/.../actions/workflows 返回 total_count: 0),因此没有 CI 运行链接。上面的本地命令是对四个 gate 的等价复现:ai-governancepython scripts/check_ai_assets.pybackend-gatepy_compile + flake8 E9,F63,F7,F82 + ./scripts/test.sh code|yfinance + pytest -m "not network"web-gatenpm run lint + npm run build。未本地复现的是 docker-build(未构建镜像),本 PR 不改 docker/、依赖清单或模块导入结构,风险按低评估。

Visual Evidence (if applicable)

本 PR 修改了 Web 设置页「AI 模型配置」的渠道预设下拉:新增一个 aimlapi.com 选项;在 fork-only commit 中该选项被移到列表首位,并成为「新增渠道」下拉的默认值。

未附截图,原因与替代证据如下。 本机执行 npx playwright install chromium 时被 ~/Library/Caches/ms-playwright/__dirlock 阻塞,chromium_headless_shell-1208 始终没有落盘,因此 e2e/smoke.spec.ts 只能报 browserType.launch: Executable doesn't exist。webServer 两段(python main.py --webui-onlyvite dev)都能正常起来,缺的只是浏览器二进制。

可复现命令(在能装浏览器的机器 / CI 上执行即可得到产物):

cd apps/dsa-web
npx playwright install chromium
DSA_WEB_SMOKE_PASSWORD=<任意密码> ADMIN_AUTH_ENABLED=true \
  npx playwright test e2e/smoke.spec.ts --grep "settings page"
# 产物:apps/dsa-web/test-results/**/smoke-settings-page-zh.png

在没有截图的前提下,本次改动的用户可见项由以下确定性证据锁定,且都能定位到具体变更项:

  • apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts
    • keeps provider template order aligned with the existing preset dropdown order —— 断言下拉的完整顺序,aimlapi 现在是第一项;
    • keeps the aimlapi preset on the OpenAI-compatible chat completions entry —— 断言下拉项展示名为 aimlapi.com、Base URL、模型示例与配置提示文案。
  • npm run test -- src/components/settings/__tests__/LLMChannelEditor.test.tsx 全绿(62 tests),覆盖「新增渠道」按预设默认值建渠道的行为。
  • tests/test_daily_analysis_workflow_llm_env.py 断言该预设在 .env.example 与 workflow 中都有对应示例/映射。

Compatibility And Risk

  • provider / model / Base URL 语义:本 PR 新增一个预设渠道,不修改任何既有渠道的 protocol、Base URL、模型名或路由前缀;normalize_llm_channel_model()resolve_llm_channel_protocol()、API Surface 判定逻辑均未改动。
  • 归因 / 赞助头build_provider_extra_headers() 对 AIHubmix 保留原来的 'aihubmix.com' in base_url 子串匹配与 setdefault 语义,行为等价;aimlapi 的头按 urlparse(base_url).hostname == "api.aimlapi.com" 精确匹配注入,因此不会跟着请求发到其他服务商,也不会命中仅前缀相似的域名(例如 api.aimlapi.com.example.com)。渠道自身 LLM_<NAME>_EXTRA_HEADERS 中的同名字段始终优先;模块级常量不会被就地修改,每次调用返回新 dict。以上四点都有回归测试(tests/test_llm_channel_config.py::ProviderExtraHeadersTestCase),其中包含对 partner id 形状 ^part_[A-Za-z0-9]{1,64}$ 的断言——该值写错时网关不会报错、只会静默丢弃归因,只能靠测试兜住。
  • 请求参数 null:该网关对以下字段的 JSON null 返回 400 —— temperaturetop_pseedtoolstool_choiceresponse_formatstreamstream_optionsparallel_tool_callsmax_tokensmax_completion_tokens;而 stoppresence_penaltyfrequency_penaltynuserlogprobslogit_biasreasoning_effort 接受 null。「未设置的可选参数」在 OpenAI 风格客户端里正好序列化成 null,所以这是一个容易让每次调用都 400、却被 mock 测试全绿掩盖的坑;tools 尤其危险——把 tools 置空写成 None 的调用方会在 agent 循环第 1 轮成功、第 2 轮失败。本仓库现有实现是安全的:apply_litellm_generation_params() 要么省略 temperature 要么写入 float(未配置时回落到 0.7);src/agent/llm_adapter.pyif tools: / if max_tokens is not None: 决定是否写键;top_p / seed / tool_choice 根本不会被主动设置(src/llm/errors.py 只在报错后移除它们);response_format / stream 只会被写成具体值。为防止以后有人把默认值改成 None,新增了 AimlapiRequestParamsTestCase(含渠道 litellm_params 不含 None 的断言)固定这一契约。排障提示:该网关 400 的顶层 message 是通用文案,真正的字段名在 error.details[].path / .reason 里。
  • 历史配置:不改写、不清空、不迁移任何既有 .env 或运行时配置;未配置 aimlapi 渠道的用户行为完全不变。
  • 依赖窗口:在 requirements.txt 现有约束 litellm>=1.80.10,!=1.82.7,!=1.82.8,<1.99.0 下验证,未引入新依赖。
  • 官方来源:Base URL、endpoint 形状与模型 id 依据 https://docs.aimlapi.com/quickstart/setting-uphttps://docs.aimlapi.com/api-references/model-database,并以 GET https://api.aimlapi.com/v1/models 的实时返回复核。这是当前运行时约束,不是临时兼容处理。
  • 风险:主要风险是示例模型随平台目录变动而过期——这是所有预设共有的;文档已注明以 /models 返回和「测试连接」结果为准。

Rollback Plan

revert this PR。无需额外配置或数据回滚:预设条目、.env.example 注释块、workflow env 映射与文档都是纯新增;build_provider_extra_headers() 被 revert 后会恢复成原来 4 处内联的 AIHubmix 判断。若只想去掉排序偏好而保留渠道,drop 掉 ccd175d 这一个 commit 即可。

Checklist

  • 本 PR 有明确动机和业务价值
  • 已提供可复现的验证命令与结果
  • 已评估兼容性与风险
  • 已提供回滚方案
  • 若涉及用户可见变更,已同步更新相关文档与 docs/CHANGELOG.md

Users who want to reach several vendors through one OpenAI-compatible key
currently have to hand-build a custom channel: they have to know the Base URL,
that models must be written as bare aliases, and that a provider-prefixed id
would be routed away from the gateway by LiteLLM. Adding the preset removes
those three failure modes from the settings page and from .env.

The channel itself needs no new runtime code — LLM_<NAME>_* is generic — so the
diff is the preset, the .env.example block, the workflow mapping that
tests/test_daily_analysis_workflow_llm_env.py requires for every template
channel, and the docs.

Attribution reuses the mechanism that already exists for the AIHubmix sponsored
header rather than adding a second one: the three copies of that base-URL check
plus the one in image_stock_extractor now share build_provider_extra_headers(),
which returns a fresh dict per deployment, leaves the module-level constants
untouched, and lets a channel's own LLM_<NAME>_EXTRA_HEADERS win on a key
clash. AIHubmix keeps its existing substring match; the aimlapi headers are
matched on hostname so they cannot ride a request to another vendor or to a
proxy that merely fronts the gateway.

Two traps in that gateway are pinned by tests because both fail invisibly. A
malformed partner id is dropped silently rather than rejected, so its shape is
asserted instead of being discovered in production. And the gateway answers 400
for a dozen request fields sent as JSON null — temperature, tools, max_tokens
among them — which is exactly what an unset optional serializes to; the repo
already omits those keys, and a regression test now says so.

The Anthropic example uses the dotted spelling because the gateway ships both:
claude-sonnet-4.6 declares tools, vision and structured_output in its catalog
capabilities while the otherwise identical claude-sonnet-4-6 declares only
streaming.
Moves the aimlapi.com preset to the front of every hand-ordered list (the
settings-page template array and its order assertion, the .env.example vendor
templates, the docs preset table) and makes the "add channel" dropdown default
to the first template instead of a hardcoded 'aihubmix'.

This is a placement preference for our fork, not a change any upstream reviewer
asked for, so it is isolated in one commit that can be dropped without touching
the preset itself. The repo has no "recommended"/featured badge concept — the
capability chips are factual labels — so none was invented.
The placeholder part_dailystockanalysis was a readable stand-in chosen before the
partner was registered. Registration mints the id server-side, so the
real value is part_ZBzsnhYW8aO7HueEfvKZM4je. A wrong or unknown partner id is accepted with a
200 and silently not attributed, so this would not have surfaced at runtime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant