diff --git a/.env.example b/.env.example index b7470e61fb1..62facdc2234 100644 --- a/.env.example +++ b/.env.example @@ -258,6 +258,16 @@ GEMINI_API_KEY= # # 常用厂商模板(任选其一复制到 .env;完整说明见 docs/llm-providers.md) # +# aimlapi.com(OpenAI Compatible 聚合平台) +# 来源:官方模型库 https://docs.aimlapi.com/api-references/model-database +# 模型名填 /v1/models 返回的 alias;带 anthropic/、gemini/、xai/ 前缀的 id 会被 LiteLLM 当作直连 provider,不再走本渠道 Base URL。 +# LLM_CHANNELS=aimlapi +# LLM_AIMLAPI_PROTOCOL=openai +# LLM_AIMLAPI_BASE_URL=https://api.aimlapi.com/v1 +# LLM_AIMLAPI_API_KEY=sk-xxx +# LLM_AIMLAPI_MODELS=gpt-5.5-2026-04-23,claude-sonnet-4.6,deepseek-v4-flash +# LITELLM_MODEL=openai/claude-sonnet-4.6 +# # Anspire Open(共享搜索与 LLM Key,OpenAI Compatible) # LLM_CHANNELS=anspire # LLM_ANSPIRE_PROTOCOL=openai diff --git a/.github/workflows/00-daily-analysis.yml b/.github/workflows/00-daily-analysis.yml index 2a8e173bcf8..c1d38e70f93 100644 --- a/.github/workflows/00-daily-analysis.yml +++ b/.github/workflows/00-daily-analysis.yml @@ -169,6 +169,15 @@ jobs: LLM_AIHUBMIX_ENABLED: ${{ vars.LLM_AIHUBMIX_ENABLED || secrets.LLM_AIHUBMIX_ENABLED }} LLM_AIHUBMIX_EXTRA_HEADERS: ${{ vars.LLM_AIHUBMIX_EXTRA_HEADERS || secrets.LLM_AIHUBMIX_EXTRA_HEADERS }} + LLM_AIMLAPI_PROTOCOL: ${{ vars.LLM_AIMLAPI_PROTOCOL || secrets.LLM_AIMLAPI_PROTOCOL }} + LLM_AIMLAPI_API_SURFACE: ${{ vars.LLM_AIMLAPI_API_SURFACE || secrets.LLM_AIMLAPI_API_SURFACE }} + LLM_AIMLAPI_BASE_URL: ${{ vars.LLM_AIMLAPI_BASE_URL || secrets.LLM_AIMLAPI_BASE_URL }} + LLM_AIMLAPI_API_KEY: ${{ secrets.LLM_AIMLAPI_API_KEY }} + LLM_AIMLAPI_API_KEYS: ${{ secrets.LLM_AIMLAPI_API_KEYS }} + LLM_AIMLAPI_MODELS: ${{ vars.LLM_AIMLAPI_MODELS || secrets.LLM_AIMLAPI_MODELS }} + LLM_AIMLAPI_ENABLED: ${{ vars.LLM_AIMLAPI_ENABLED || secrets.LLM_AIMLAPI_ENABLED }} + LLM_AIMLAPI_EXTRA_HEADERS: ${{ vars.LLM_AIMLAPI_EXTRA_HEADERS || secrets.LLM_AIMLAPI_EXTRA_HEADERS }} + LLM_ANSPIRE_PROTOCOL: ${{ vars.LLM_ANSPIRE_PROTOCOL || secrets.LLM_ANSPIRE_PROTOCOL }} LLM_ANSPIRE_API_SURFACE: ${{ vars.LLM_ANSPIRE_API_SURFACE || secrets.LLM_ANSPIRE_API_SURFACE }} LLM_ANSPIRE_BASE_URL: ${{ vars.LLM_ANSPIRE_BASE_URL || secrets.LLM_ANSPIRE_BASE_URL }} diff --git a/apps/dsa-web/src/components/settings/LLMChannelEditor.tsx b/apps/dsa-web/src/components/settings/LLMChannelEditor.tsx index 9f3d0d6288b..1366c9b04dc 100644 --- a/apps/dsa-web/src/components/settings/LLMChannelEditor.tsx +++ b/apps/dsa-web/src/components/settings/LLMChannelEditor.tsx @@ -1666,7 +1666,7 @@ export const LLMChannelEditor: React.FC = ({ const [capabilityStates, setCapabilityStates] = useState>({}); const [expandedRows, setExpandedRows] = useState>({}); const [isCollapsed, setIsCollapsed] = useState(false); - const [addPreset, setAddPreset] = useState('aihubmix'); + const [addPreset, setAddPreset] = useState(LLM_PROVIDER_TEMPLATES[0].channelId); const addChannelIdRef = useRef(0); const lastDraftFingerprintRef = useRef(null); const onDraftItemsChangeRef = useRef(onDraftItemsChange); diff --git a/apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts b/apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts index 04b14119be4..7a069d07d04 100644 --- a/apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts +++ b/apps/dsa-web/src/components/settings/__tests__/llmProviderTemplates.test.ts @@ -11,6 +11,7 @@ import { describe('llmProviderTemplates', () => { it('keeps provider template order aligned with the existing preset dropdown order', () => { expect(LLM_PROVIDER_TEMPLATES.map((template) => template.channelId)).toEqual([ + 'aimlapi', 'aihubmix', 'anspire', 'deepseek', @@ -91,6 +92,18 @@ describe('llmProviderTemplates', () => { }); }); + it('keeps the aimlapi preset on the OpenAI-compatible chat completions entry', () => { + expect(LLM_PROVIDER_TEMPLATE_BY_ID.aimlapi).toMatchObject({ + label: 'aimlapi.com', + protocol: 'openai', + baseUrl: 'https://api.aimlapi.com/v1', + placeholderModels: 'gpt-5.5-2026-04-23,claude-sonnet-4.6,deepseek-v4-flash', + }); + // /v1/completions does not exist on this gateway; the Base URL must stop at /v1. + expect(LLM_PROVIDER_TEMPLATE_BY_ID.aimlapi.baseUrl.endsWith('/v1')).toBe(true); + expect(LLM_PROVIDER_TEMPLATE_BY_ID.aimlapi.configHint).toContain('anthropic/'); + }); + it('keeps basic metadata on non-custom provider templates', () => { for (const template of LLM_PROVIDER_TEMPLATES.filter((item) => item.channelId !== 'custom')) { expect(template.capabilities.length).toBeGreaterThan(0); diff --git a/apps/dsa-web/src/components/settings/llmProviderTemplates.ts b/apps/dsa-web/src/components/settings/llmProviderTemplates.ts index fbd093be0ab..b91e2d27bb6 100644 --- a/apps/dsa-web/src/components/settings/llmProviderTemplates.ts +++ b/apps/dsa-web/src/components/settings/llmProviderTemplates.ts @@ -49,6 +49,20 @@ export const LLM_PROVIDER_CAPABILITY_LABELS: Record For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page. ## [Unreleased] +- [新功能] 新增 aimlapi.com 渠道预设(渠道名 `aimlapi`,OpenAI Compatible,Base URL `https://api.aimlapi.com/v1`),同步补齐 Web 设置页预设、`.env.example` 模板、`00-daily-analysis.yml` 的 `LLM_AIMLAPI_*` 映射与中英文档;请求发往 `api.aimlapi.com` 时按既有 AIHubmix 赞助头的同一机制附加合作归因请求头,渠道自身 `LLM__EXTRA_HEADERS` 同名字段仍然优先 - [修复] 将 litellm 依赖窗口上界收敛到 `<1.99.0`:1.99.0 起把 `prompt_cache_key` 透传给 OpenAI provider,破坏 provider 缓存测试对不透传行为的既有断言(CI backend-tests 3/3 与 backend-gate 失败);保留历史最低版本与 `!=1.82.7`/`!=1.82.8` 事故排除,同时同步更新各 LLM 兼容文档中写死的依赖约束表述,避免文档与 requirements.txt 漂移 - [新功能] 新增 `SEARXNG_TIMEOUT_SECONDS` 配置自建 SearXNG 单次搜索超时(默认 10 秒),已接线全部 SearchService 构造入口(含题材搜索子进程重建)与默认 GitHub Actions 工作流 diff --git a/docs/LLM_CONFIG_GUIDE.md b/docs/LLM_CONFIG_GUIDE.md index 358bf1e748d..4ec89772242 100644 --- a/docs/LLM_CONFIG_GUIDE.md +++ b/docs/LLM_CONFIG_GUIDE.md @@ -467,7 +467,7 @@ model_list: - 运行时选择:`GENERATION_BACKEND`、`GENERATION_FALLBACK_BACKEND`、`GENERATION_BACKEND_TIMEOUT_SECONDS`、`GENERATION_BACKEND_MAX_OUTPUT_BYTES`、`GENERATION_BACKEND_MAX_CONCURRENCY`、`LOCAL_CLI_BACKEND_MAX_CONCURRENCY`、`AGENT_GENERATION_BACKEND`、`LLM_CHANNELS`、`LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`VISION_PROVIDER_PRIORITY`、`LLM_TEMPERATURE`、`LLM_USAGE_HMAC_SECRET`、`LLM_USAGE_HMAC_KEY_VERSION`、`LLM_PROMPT_CACHE_TELEMETRY_ENABLED`、`LLM_PROMPT_CACHE_HINTS_ENABLED`、`LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL` - 多 Key:`GEMINI_API_KEYS`、`ANTHROPIC_API_KEYS`、`OPENAI_API_KEYS`、`DEEPSEEK_API_KEYS`(当前 workflow 仅从 repository secrets 导入,不会读取同名 Variables) -- 常用渠道名:`primary`、`secondary`、`aihubmix`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama` +- 常用渠道名:`primary`、`secondary`、`aihubmix`、`aimlapi`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama` 例如在 GitHub Actions 中配置 `LLM_CHANNELS=primary,deepseek` 时,需同步配置 `LLM_PRIMARY_*` / `LLM_DEEPSEEK_*`。其中 `LLM__API_KEY` / `LLM__API_KEYS` 当前也仅从 repository secrets 导入;如果你把这些值放在 Variables,运行时不会生效。若使用自定义渠道名(如 `my_proxy`),GitHub Actions 还必须在 workflow `env:` 中显式新增对应的 `LLM_MY_PROXY_*` 映射;本地 `.env` 和 Docker 不受这个限制。 diff --git a/docs/LLM_CONFIG_GUIDE_EN.md b/docs/LLM_CONFIG_GUIDE_EN.md index a3ea46034fa..2fc109389ab 100644 --- a/docs/LLM_CONFIG_GUIDE_EN.md +++ b/docs/LLM_CONFIG_GUIDE_EN.md @@ -435,7 +435,7 @@ The bundled `00-daily-analysis.yml` explicitly passes the common LLM runtime fie - Runtime selection: `GENERATION_BACKEND`, `GENERATION_FALLBACK_BACKEND`, `GENERATION_BACKEND_TIMEOUT_SECONDS`, `GENERATION_BACKEND_MAX_OUTPUT_BYTES`, `GENERATION_BACKEND_MAX_CONCURRENCY`, `LOCAL_CLI_BACKEND_MAX_CONCURRENCY`, `AGENT_GENERATION_BACKEND`, `LLM_CHANNELS`, `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `VISION_PROVIDER_PRIORITY`, `LLM_TEMPERATURE`, `LLM_USAGE_HMAC_SECRET`, `LLM_USAGE_HMAC_KEY_VERSION`, `LLM_PROMPT_CACHE_TELEMETRY_ENABLED`, `LLM_PROMPT_CACHE_HINTS_ENABLED`, `LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL` - Multiple keys: `GEMINI_API_KEYS`, `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `DEEPSEEK_API_KEYS` (the current workflow imports these from repository Secrets only, not from same-named Variables) -- Common channel names: `primary`, `secondary`, `aihubmix`, `deepseek`, `dashscope`, `zhipu`, `moonshot`, `minimax`, `volcengine`, `siliconflow`, `openrouter`, `gemini`, `anthropic`, `openai`, `ollama` +- Common channel names: `primary`, `secondary`, `aihubmix`, `aimlapi`, `deepseek`, `dashscope`, `zhipu`, `moonshot`, `minimax`, `volcengine`, `siliconflow`, `openrouter`, `gemini`, `anthropic`, `openai`, `ollama` For example, if you set `LLM_CHANNELS=primary,deepseek` in GitHub Actions, also configure the corresponding `LLM_PRIMARY_*` and `LLM_DEEPSEEK_*` entries. The `LLM__API_KEY` / `LLM__API_KEYS` fields are also imported from repository Secrets only right now, so storing them in Variables will not work at runtime. If you use a custom channel name such as `my_proxy`, GitHub Actions must explicitly add matching `LLM_MY_PROXY_*` mappings in the workflow `env:` block. Local `.env` and Docker runs do not have this limitation. diff --git a/docs/llm-providers.md b/docs/llm-providers.md index 4beedd91857..f750a99aead 100644 --- a/docs/llm-providers.md +++ b/docs/llm-providers.md @@ -106,6 +106,7 @@ LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna | 服务商 | 渠道名 | 协议 | Base URL | 模型示例 | | --- | --- | --- | --- | --- | +| aimlapi.com | `aimlapi` | `openai` | `https://api.aimlapi.com/v1` | `gpt-5.5-2026-04-23,claude-sonnet-4.6,deepseek-v4-flash` | | AIHubmix | `aihubmix` | `openai` | `https://aihubmix.com/v1` | `gpt-5.5,claude-sonnet-4-6,gemini-3.1-pro-preview` | | Anspire Open | `anspire` | `openai` | `https://open-gateway.anspire.cn/v6`(示例) | `Doubao-Seed-2.0-lite,Doubao-Seed-2.0-pro,qwen3.5-flash,MiniMax-M2.7`(示例) | | OpenAI | `openai` | `openai` | `https://api.openai.com/v1` | `gpt-5.5,gpt-5.4-mini` | @@ -139,6 +140,7 @@ LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna | 火山方舟 / 豆包 | [在线推理(常规)](https://www.volcengine.com/docs/82379/2121998)、[模型列表](https://www.volcengine.com/docs/82379/1949118) | 官方示例使用 `https://ark.cn-beijing.volces.com/api/v3` 与 `doubao-seed-1-6-251015`;如使用 Coding Plan,请改用其专用 Base URL 和模型名,不要套用本表的在线推理模板。 | | SiliconFlow | [模型列表](https://docs.siliconflow.cn/quickstart/models)、[获取模型列表 API](https://docs.siliconflow.cn/cn/api-reference/models/get-model-list) | 平台模型实时更新且 `/models` 需要 API Key;模板只给常见新模型示例,保存前建议在 Web 设置页点击「获取模型」确认账号可见性。 | | OpenRouter | [Models API](https://openrouter.ai/docs/api/api-reference/models/get-models) | OpenRouter 支持 `~anthropic/claude-sonnet-latest`、`~openai/gpt-latest` 等 latest router alias;2026-05-03 的一次手动 live smoke 以 Claude Sonnet latest 作为默认示例通过,GPT latest 保留为可按账号权限切换的备选。 | +| aimlapi.com | [Model Database](https://docs.aimlapi.com/api-references/model-database)、[Quickstart](https://docs.aimlapi.com/quickstart/setting-up) | OpenAI-compatible Base URL 为 `https://api.aimlapi.com/v1`,只提供 `/chat/completions` 与 `/responses`,没有 `/v1/completions`。`/v1/models` 返回 `{"object":"list","data":[...]}`,可以用「获取模型」拉取;但它对任意 Key 都返回 200,不能作为 Key 有效性判据,请以「测试连接」的实际推理结果为准。模型请填返回的 alias(例如 `claude-sonnet-4.6`——该网关同时提供点号与横杠两种拼写,只有点号形式在 `?include=all` 中声明了 `tools` / `vision` / `structured_output` 等能力,横杠形式只声明 `streaming`),不要填带 `anthropic/`、`gemini/`、`xai/` 前缀的 id,否则 LiteLLM 会按直连 provider 路由而不再走本渠道 Base URL。该网关还对 `temperature` / `top_p` / `seed` / `tools` / `tool_choice` / `response_format` / `stream` / `max_tokens` 等字段的 JSON `null` 返回 400(未设置的可选参数正好会被序列化成 `null`),因此请求参数应整键省略而不是传 `None`;本仓库现有实现已满足,回归见 `tests/test_llm_channel_config.py::AimlapiRequestParamsTestCase`。与 AIHubmix 的赞助头同一机制:请求发往 `api.aimlapi.com` 时会附加合作归因请求头(`X-AIMLAPI-Partner-ID` / `X-AIMLAPI-Source` / `HTTP-Referer` / `X-Title`),渠道自身的 `LLM_AIMLAPI_EXTRA_HEADERS` 同名字段优先,其他 Base URL 不会被注入。 | | LiteLLM | [OpenAI-Compatible Endpoints](https://docs.litellm.ai/docs/providers/openai_compatible) | OpenAI-compatible 端点需要把运行时模型写成 `openai/`,Base URL 只填到服务商兼容入口,不额外拼接 `/chat/completions`。 | 本页预设只保证配置形状与当前依赖的 OpenAI-compatible 路由规则一致;实际连通性仍取决于服务商账号权限、地域、额度和模型开通状态。当前 LiteLLM 版本约束为 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<1.99.0`(见 `requirements.txt`),保留历史最低版本、显式排除 PyPI 事故版本,并将上界收敛到已验证的 `<1.99.0`,避免未来大版本自动进入。 @@ -172,7 +174,7 @@ LITELLM_FALLBACK_MODELS=openai/gpt-5.6-terra,openai/gpt-5.6-luna | `LLM_USAGE_HMAC_SECRET` | Secrets | 可选;只有需要跨部署比较 usage message HMAC 时才配置同一个高熵随机密钥,例如 `openssl rand -hex 32`;不要放 Variables 或提交到版本控制。 | | `LLM_USAGE_HMAC_KEY_VERSION` | Variables 或 Secrets | 可选;轮换 `LLM_USAGE_HMAC_SECRET` 时同步更新版本标签,避免误比较不同密钥生成的 HMAC。 | -默认 workflow 已显式映射 `primary`、`secondary`、`aihubmix`、`anspire`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama`、`hermes`;`mimo` 未在默认 workflow 中映射。若使用 `mimo`(或任何未列渠道名),除了在 Variables/Secrets 配置同名 `LLM__*` 外,还需在 workflow 中同步补齐对应 env 映射;本地 `.env`、Docker 和自托管脚本不受这个限制。 +默认 workflow 已显式映射 `primary`、`secondary`、`aihubmix`、`aimlapi`、`anspire`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama`、`hermes`;`mimo` 未在默认 workflow 中映射。若使用 `mimo`(或任何未列渠道名),除了在 Variables/Secrets 配置同名 `LLM__*` 外,还需在 workflow 中同步补齐对应 env 映射;本地 `.env`、Docker 和自托管脚本不受这个限制。 回滚 HMAC 遥测显式配置时,可移除 `LLM_USAGE_HMAC_SECRET` 并恢复或删除 `LLM_USAGE_HMAC_KEY_VERSION`;留空后系统会回到本地生成 `.llm_usage_hmac_secret` 的默认行为。 diff --git a/src/config.py b/src/config.py index e40f4ec76c7..06dd4428933 100644 --- a/src/config.py +++ b/src/config.py @@ -128,6 +128,41 @@ def __str__(self) -> str: # noqa: D105 ANSPIRE_LLM_BASE_URL_DEFAULT = "https://open-gateway.anspire.cn/v6" ANSPIRE_LLM_MODEL_DEFAULT = "Doubao-Seed-2.0-lite" +# 聚合平台的赞助 / 归因请求头。 +# 仅在请求确实发往对应服务商时注入,避免被带到其他渠道或中转代理上; +# 渠道自身的 LLM__EXTRA_HEADERS 始终优先,不会被这里覆盖。 +AIHUBMIX_SPONSORED_HEADERS: Dict[str, str] = {"APP-Code": "GPIJ3886"} +AIMLAPI_API_HOST = "api.aimlapi.com" +AIMLAPI_ATTRIBUTION_HEADERS: Dict[str, str] = { + "HTTP-Referer": "https://github.com/ZhuLinsen/daily_stock_analysis", + "X-Title": "Daily Stock Analysis", + "X-AIMLAPI-Partner-ID": "part_ZBzsnhYW8aO7HueEfvKZM4je", + "X-AIMLAPI-Source": "agent/daily-stock-analysis", +} + + +def build_provider_extra_headers( + base_url: Optional[str], + extra_headers: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """按 base_url 合并服务商赞助 / 归因请求头。 + + 每次调用都返回新的 dict:既不修改模块级常量,也不覆盖调用方已经配置的同名 + header。aihubmix 沿用既有的 base_url 子串匹配;aimlapi 按主机名精确匹配, + 确保归因头不会跟着请求发到其他服务商或中转代理。 + """ + headers: Dict[str, Any] = dict(extra_headers or {}) + raw_base_url = (base_url or "").strip() + if not raw_base_url: + return headers + if "aihubmix.com" in raw_base_url: + for header_name, header_value in AIHUBMIX_SPONSORED_HEADERS.items(): + headers.setdefault(header_name, header_value) + if (urlparse(raw_base_url).hostname or "").lower() == AIMLAPI_API_HOST: + for header_name, header_value in AIMLAPI_ATTRIBUTION_HEADERS.items(): + headers.setdefault(header_name, header_value) + return headers + def _has_ntfy_topic_endpoint(value: Optional[str]) -> bool: """Return whether an ntfy URL points at a concrete topic endpoint.""" @@ -2624,10 +2659,8 @@ def _channels_to_model_list(cls, channels: List[Dict[str, Any]]) -> List[Dict[st litellm_params['api_key'] = api_key if ch['base_url']: litellm_params['api_base'] = ch['base_url'] - # Auto-inject aihubmix sponsored header - headers = dict(ch.get('extra_headers') or {}) - if ch['base_url'] and 'aihubmix.com' in ch['base_url']: - headers.setdefault('APP-Code', 'GPIJ3886') + # Auto-inject provider sponsored / attribution headers + headers = build_provider_extra_headers(ch['base_url'], ch.get('extra_headers')) if headers: litellm_params['extra_headers'] = headers @@ -2689,8 +2722,9 @@ def _legacy_keys_to_model_list( params: Dict[str, Any] = {'model': '__legacy_openai__', 'api_key': k} if openai_base_url: params['api_base'] = openai_base_url - if openai_base_url and 'aihubmix.com' in openai_base_url: - params['extra_headers'] = {'APP-Code': 'GPIJ3886'} + legacy_headers = build_provider_extra_headers(openai_base_url) + if legacy_headers: + params['extra_headers'] = legacy_headers model_list.append({ 'model_name': '__legacy_openai__', 'litellm_params': params, @@ -3733,8 +3767,9 @@ def extra_litellm_params(model: str, config: Config) -> Dict[str, Any]: if model.startswith("openai/") or "/" not in model: if config.openai_base_url: params["api_base"] = config.openai_base_url - if config.openai_base_url and "aihubmix.com" in config.openai_base_url: - params["extra_headers"] = {"APP-Code": "GPIJ3886"} + provider_headers = build_provider_extra_headers(config.openai_base_url) + if provider_headers: + params["extra_headers"] = provider_headers return params diff --git a/src/services/image_stock_extractor.py b/src/services/image_stock_extractor.py index 95f352058fe..4e618a45a90 100644 --- a/src/services/image_stock_extractor.py +++ b/src/services/image_stock_extractor.py @@ -19,7 +19,7 @@ import time from typing import Any, Dict, List, Optional, Tuple -from src.config import Config, channel_allows_empty_api_key, get_config +from src.config import Config, build_provider_extra_headers, channel_allows_empty_api_key, get_config from src.llm.hermes import route_has_hermes logger = logging.getLogger(__name__) @@ -330,8 +330,9 @@ def _call_litellm_vision(image_b64: str, mime_type: str, api_key: Optional[str] if not deployment_params and not model.startswith("gemini/") and not model.startswith("anthropic/") and not model.startswith("vertex_ai/"): if cfg.openai_base_url: call_kwargs["api_base"] = cfg.openai_base_url - if cfg.openai_base_url and "aihubmix.com" in cfg.openai_base_url: - call_kwargs["extra_headers"] = {"APP-Code": "GPIJ3886"} + provider_headers = build_provider_extra_headers(cfg.openai_base_url) + if provider_headers: + call_kwargs["extra_headers"] = provider_headers if getattr(litellm, "completion", None) is None: import litellm as litellm_module diff --git a/tests/test_llm_channel_config.py b/tests/test_llm_channel_config.py index f876478aa4e..1cf16916e19 100644 --- a/tests/test_llm_channel_config.py +++ b/tests/test_llm_channel_config.py @@ -10,10 +10,12 @@ ensure_litellm_stub() from src.config import ( + AIMLAPI_ATTRIBUTION_HEADERS, ANSPIRE_LLM_BASE_URL_DEFAULT, ANSPIRE_LLM_MODEL_DEFAULT, Config, apply_litellm_api_surface, + build_provider_extra_headers, get_configured_llm_models, get_effective_agent_models_to_try, get_effective_agent_primary_model, @@ -1690,5 +1692,163 @@ def test_llm_models_url_rechecks_restricted_and_valid_urls(self) -> None: ) +class ProviderExtraHeadersTestCase(unittest.TestCase): + """Sponsored / attribution headers must stay scoped to their own provider.""" + + def test_aimlapi_partner_id_matches_backend_contract(self) -> None: + # A malformed partner id is silently dropped by the gateway, so the shape + # has to be asserted here rather than discovered in production. + self.assertRegex( + AIMLAPI_ATTRIBUTION_HEADERS["X-AIMLAPI-Partner-ID"], + r"^part_[A-Za-z0-9]{1,64}$", + ) + self.assertRegex( + AIMLAPI_ATTRIBUTION_HEADERS["X-AIMLAPI-Source"], + r"^(web|agent|mcp)/[a-z0-9-]{1,32}$", + ) + # HTTP-Referer / X-Title identify the calling project, not the gateway. + self.assertEqual( + AIMLAPI_ATTRIBUTION_HEADERS["HTTP-Referer"], + "https://github.com/ZhuLinsen/daily_stock_analysis", + ) + self.assertEqual(AIMLAPI_ATTRIBUTION_HEADERS["X-Title"], "Daily Stock Analysis") + + def test_headers_are_injected_only_for_the_matching_provider(self) -> None: + self.assertEqual( + build_provider_extra_headers("https://api.aimlapi.com/v1"), + dict(AIMLAPI_ATTRIBUTION_HEADERS), + ) + self.assertEqual( + build_provider_extra_headers("https://aihubmix.com/v1"), + {"APP-Code": "GPIJ3886"}, + ) + for base_url in ( + "", + None, + "https://api.openai.com/v1", + "https://openrouter.ai/api/v1", + # A proxy that merely fronts the gateway must not inherit attribution. + "https://api.aimlapi.com.example.com/v1", + "https://proxy.example.com/api.aimlapi.com/v1", + ): + with self.subTest(base_url=base_url): + self.assertEqual(build_provider_extra_headers(base_url), {}) + + def test_channel_extra_headers_win_and_constants_are_not_mutated(self) -> None: + original = dict(AIMLAPI_ATTRIBUTION_HEADERS) + merged = build_provider_extra_headers( + "https://api.aimlapi.com/v1", + {"X-Title": "custom", "X-Tenant": "team-a"}, + ) + + self.assertEqual(merged["X-Title"], "custom") + self.assertEqual(merged["X-Tenant"], "team-a") + self.assertEqual(merged["X-AIMLAPI-Partner-ID"], original["X-AIMLAPI-Partner-ID"]) + self.assertEqual(AIMLAPI_ATTRIBUTION_HEADERS, original) + + merged["X-AIMLAPI-Partner-ID"] = "part_mutated" + self.assertEqual(AIMLAPI_ATTRIBUTION_HEADERS, original) + + @patch("src.config.setup_env") + @patch.object(Config, "_parse_litellm_yaml", return_value=[]) + def test_aimlapi_channel_deployments_carry_attribution_headers( + self, + _mock_parse_yaml, + _mock_setup_env, + ) -> None: + env = { + "LLM_CHANNELS": "aimlapi,openai", + "LLM_AIMLAPI_PROTOCOL": "openai", + "LLM_AIMLAPI_BASE_URL": "https://api.aimlapi.com/v1", + "LLM_AIMLAPI_API_KEY": "sk-aimlapi-test-value", + "LLM_AIMLAPI_MODELS": "claude-sonnet-4.6", + "LLM_OPENAI_PROTOCOL": "openai", + "LLM_OPENAI_BASE_URL": "https://api.openai.com/v1", + "LLM_OPENAI_API_KEY": "sk-openai-test-value", + "LLM_OPENAI_MODELS": "gpt-5.4-mini", + } + + with patch.dict(os.environ, env, clear=True): + config = Config._load_from_env() + + by_model = { + entry["model_name"]: entry["litellm_params"] for entry in config.llm_model_list + } + self.assertEqual( + by_model["openai/claude-sonnet-4.6"]["extra_headers"], + dict(AIMLAPI_ATTRIBUTION_HEADERS), + ) + self.assertNotIn("extra_headers", by_model["openai/gpt-5.4-mini"]) + + +#: Request fields the aimlapi gateway answers with HTTP 400 when they are sent +#: as JSON ``null`` (verified against POST /v1/chat/completions on 2026-09-03). +#: ``stop`` / ``presence_penalty`` / ``frequency_penalty`` / ``n`` / ``user`` / +#: ``logprobs`` / ``logit_bias`` / ``reasoning_effort`` accept ``null`` there. +AIMLAPI_NULL_REJECTING_REQUEST_FIELDS = ( + "temperature", + "top_p", + "seed", + "tools", + "tool_choice", + "response_format", + "stream", + "stream_options", + "parallel_tool_calls", + "max_tokens", + "max_completion_tokens", +) + + +class AimlapiRequestParamsTestCase(unittest.TestCase): + """Unset optionals must be omitted, not sent as JSON null. + + An unset optional is exactly what an OpenAI-style client serializes as + ``null``, and this gateway answers 400 for the fields listed above rather + than treating them as absent. ``tools`` is the one that bites hardest: a + caller that clears tools between turns by setting the field to ``None`` + succeeds on turn 1 and fails on turn 2 of every agent loop. Mocked + transports hide all of this, so the omission is pinned here. + + (On a 400 the offending field is named in ``error.details[].path`` / + ``.reason``; the top-level ``message`` is generic.) + """ + + def test_unset_temperature_never_reaches_the_wire_as_null(self) -> None: + applied = apply_litellm_generation_params({}, "openai/claude-sonnet-4.6", None) + + self.assertEqual(applied.get("temperature"), 0.7) + for field in AIMLAPI_NULL_REJECTING_REQUEST_FIELDS: + with self.subTest(field=field): + self.assertIsNotNone(applied.get(field, "absent")) + + def test_channel_deployment_params_are_never_null(self) -> None: + channels = [ + { + "name": "aimlapi", + "protocol": "openai", + "api_surface": "chat_completions", + "base_url": "https://api.aimlapi.com/v1", + "api_keys": ["sk-aimlapi-test-value"], + "models": ["openai/claude-sonnet-4.6"], + }, + { + # No base_url and no key: the builder must omit those keys + # rather than writing None into litellm_params. + "name": "bare", + "protocol": "openai", + "api_surface": "chat_completions", + "base_url": "", + "api_keys": [""], + "models": ["openai/gpt-5.4-mini"], + }, + ] + + for entry in Config._channels_to_model_list(channels): + params = entry["litellm_params"] + with self.subTest(model_name=entry["model_name"]): + self.assertEqual([key for key, value in params.items() if value is None], []) + + if __name__ == "__main__": unittest.main()