diff --git a/.env.example b/.env.example index d108de911..774cb77a0 100644 --- a/.env.example +++ b/.env.example @@ -350,6 +350,13 @@ MINIMAX_API_KEY= MINIMAX_MODEL=MiniMax-M2.7 MINIMAX_BASE_URL=https://api.minimax.io/v1 +# 88API (set LLM_PROVIDER=api88) +# OpenAI-compatible multi-model gateway: one key for GPT, Claude, Gemini, +# DeepSeek, Grok, MiniMax and more. Get a key at https://88api.ai +API88_API_KEY= +API88_MODEL=claude-sonnet-5-5 +API88_BASE_URL=https://88api.ai/v1 + # ========================= # Common background jobs # ========================= diff --git a/README.md b/README.md index aa7f609fd..2a40aa123 100644 --- a/README.md +++ b/README.md @@ -512,7 +512,7 @@ not include credentials, account data, or exploitable details in public issues. | Strategies | Strategy API V2 intents, sizing, risk, backtests, and live runtime. | | Crypto | Binance, OKX, Bitget, Bybit, Gate, HTX, and adapter extensions. | | Traditional brokers | IBKR and Alpaca workflows. | -| AI providers | OpenRouter, OpenAI-compatible APIs, Google, DeepSeek, Grok, MiniMax, and custom endpoints. | +| AI providers | OpenRouter, OpenAI-compatible APIs, Google, DeepSeek, Grok, MiniMax, 88API, and custom endpoints. | | Automation | Human API, Agent Gateway, MCP server, Celery jobs, schedules, and notifications. | Start with the [Indicator guide](docs/trading/INDICATOR_DEV_GUIDE.md), diff --git a/README_CN.md b/README_CN.md index 12b0315b2..e55149418 100644 --- a/README_CN.md +++ b/README_CN.md @@ -456,7 +456,7 @@ docker compose \ | 策略 | Strategy API V2 交易意图、仓位计算、风险控制、回测和实盘运行时。 | | 加密货币 | Binance、OKX、Bitget、Bybit、Gate、HTX 及可扩展适配器。 | | 传统券商 | IBKR 和 Alpaca 工作流。 | -| AI 提供商 | OpenRouter、OpenAI 兼容 API、Google、DeepSeek、Grok、MiniMax 和自定义端点。 | +| AI 提供商 | OpenRouter、OpenAI 兼容 API、Google、DeepSeek、Grok、MiniMax、88API 和自定义端点。 | | 自动化 | Human API、Agent Gateway、MCP Server、Celery 任务、调度和通知。 | 建议从[指标开发指南](docs/trading/INDICATOR_DEV_GUIDE.md)、 diff --git a/backend_api_python/app/config/api_keys.py b/backend_api_python/app/config/api_keys.py index ccdf5fdb8..357fe3797 100644 --- a/backend_api_python/app/config/api_keys.py +++ b/backend_api_python/app/config/api_keys.py @@ -202,6 +202,16 @@ def LITELLM_API_KEY(cls): from app.utils.config_loader import load_addon_config val = load_addon_config().get('litellm', {}).get('api_key') return val if val else '' + + @property + def API88_API_KEY(cls): + """88API API key""" + env_val = os.getenv('API88_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('api88', {}).get('api_key') + return val if val else '' @property def TAVILY_API_KEYS(cls): diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 6bf44c667..6fa1824df 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -99,7 +99,7 @@ 'AI_CODE_GEN_MODEL', 'LLM_PROXY_URL', 'LLM_USE_SYSTEM_PROXY', 'JEV_BASE_URL', 'JEV_MODEL', 'JEV_TIMEOUT_SECONDS', 'JEV_MIN_CONFIDENCE', 'AI_DECISION_TIMEOUT_SECONDS', 'SEC_EDGAR_USER_AGENT', - 'OPENAI_BASE_URL', 'DEEPSEEK_BASE_URL', 'GROK_BASE_URL', 'ATLASCLOUD_BASE_URL', 'MINIMAX_BASE_URL', + 'OPENAI_BASE_URL', 'DEEPSEEK_BASE_URL', 'GROK_BASE_URL', 'ATLASCLOUD_BASE_URL', 'MINIMAX_BASE_URL', 'API88_BASE_URL', # Trading internals 'ORDER_MODE', 'MAKER_WAIT_SEC', 'FINNHUB_API_KEY', 'FINNHUB_FREE_ONLY', @@ -468,6 +468,7 @@ {'value': 'custom', 'label': 'Custom API (OpenAI-compatible)'}, {'value': 'minimax', 'label': 'MiniMax'}, {'value': 'litellm', 'label': 'LiteLLM (100+ providers)'}, + {'value': 'api88', 'label': '88API (Multi-model gateway)'}, ], 'description': 'Select your preferred LLM provider' }, @@ -720,6 +721,35 @@ 'description': 'Optional. Override provider base URL (e.g. Azure endpoint)', 'group': 'litellm' }, + # 88API + { + 'key': 'API88_API_KEY', + 'label': '88API API Key', + 'type': 'password', + 'required': False, + 'link': 'https://88api.ai/console/token', + 'link_text': 'settings.link.getApiKey', + 'description': '88API API key. One key calls GPT, Claude, Gemini, DeepSeek, Grok, MiniMax and more through an OpenAI-compatible endpoint.', + 'group': 'api88' + }, + { + 'key': 'API88_MODEL', + 'label': '88API Model', + 'type': 'text', + 'default': 'claude-sonnet-5-5', + 'link': 'https://88api.ai/pricing', + 'link_text': 'settings.link.viewModels', + 'description': '88API model ID without provider prefix, e.g. claude-sonnet-5-5, gpt-5.5, gemini-2.5-pro, deepseek-v4-pro. The API key group must include the model.', + 'group': 'api88' + }, + { + 'key': 'API88_BASE_URL', + 'label': '88API Base URL', + 'type': 'text', + 'default': 'https://88api.ai/v1', + 'description': '88API OpenAI-compatible API endpoint. Must include /v1.', + 'group': 'api88' + }, # Common settings { 'key': 'OPENROUTER_TEMPERATURE', diff --git a/backend_api_python/app/services/llm.py b/backend_api_python/app/services/llm.py index 3563b4800..a11c89907 100644 --- a/backend_api_python/app/services/llm.py +++ b/backend_api_python/app/services/llm.py @@ -1,7 +1,7 @@ """ LLM service. Supports multiple providers: OpenRouter, OpenAI, Google Gemini, DeepSeek, Grok, -AtlasCloud, Custom (OpenAI-compatible), MiniMax. +AtlasCloud, Custom (OpenAI-compatible), MiniMax, 88API. Kept separate from AnalysisService to avoid circular imports. """ import json @@ -115,6 +115,7 @@ class LLMProvider(Enum): CUSTOM = "custom" MINIMAX = "minimax" LITELLM = "litellm" + API88 = "api88" # 88API; enum and env names cannot start with a digit # Provider configurations @@ -164,6 +165,11 @@ class LLMProvider(Enum): "default_model": "openai/gpt-5.4", "fallback_model": "gpt-4o-mini", }, + LLMProvider.API88: { + "base_url": "https://88api.ai/v1", + "default_model": "claude-sonnet-5-5", + "fallback_model": "claude-haiku-5-5", + }, } @@ -175,7 +181,7 @@ def __init__(self, provider: str = None): Initialize LLM service. Args: - provider: Override the default provider (openrouter, openai, google, deepseek, grok, atlascloud, custom, minimax) + provider: Override the default provider (openrouter, openai, google, deepseek, grok, atlascloud, custom, minimax, api88) """ self._provider_override = provider # Populated by call_llm_api() so callers can report what actually answered; @@ -207,7 +213,7 @@ def provider(self) -> LLMProvider: pass # Auto-detect: find any provider with a configured API key - # Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > OpenRouter + # Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > 88API > OpenRouter # (LiteLLM excluded from auto-detect; must be set explicitly via LLM_PROVIDER=litellm) priority_order = [ LLMProvider.DEEPSEEK, @@ -216,6 +222,7 @@ def provider(self) -> LLMProvider: LLMProvider.MINIMAX, LLMProvider.OPENAI, LLMProvider.GOOGLE, + LLMProvider.API88, LLMProvider.OPENROUTER, ] @@ -241,6 +248,7 @@ def get_api_key(self, provider: LLMProvider = None) -> str: LLMProvider.CUSTOM: APIKeys.CUSTOM_API_KEY, LLMProvider.MINIMAX: APIKeys.MINIMAX_API_KEY, LLMProvider.LITELLM: APIKeys.LITELLM_API_KEY, + LLMProvider.API88: APIKeys.API88_API_KEY, } return key_map.get(p, "") or "" @@ -447,7 +455,7 @@ def _llm_post(self, url: str, *, headers: dict, json_payload: dict, timeout: int def _call_openai_compatible(self, messages: list, model: str, temperature: float, api_key: str, base_url: str, timeout: int, use_json_mode: bool = True) -> str: - """Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, AtlasCloud, OpenRouter).""" + """Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, AtlasCloud, OpenRouter, 88API).""" url = f"{base_url}/chat/completions" headers = {"Content-Type": "application/json"} @@ -470,18 +478,25 @@ def _call_openai_compatible(self, messages: list, model: str, temperature: float # its public parameter table currently lists model/messages/temperature/ # max_tokens/stream/top_p and not response_format. Keep prompts JSON- # oriented while avoiding a provider-side 400 from an unsupported knob. - if use_json_mode and "atlascloud" not in (base_url or "").lower(): + # 88API routes claude-* models to Anthropic, which rejects + # {"type": "json_object"} (only text or json_schema), so skip it there too. + normalized_base_url = (base_url or "").lower() + skip_json_mode = "atlascloud" in normalized_base_url or ( + "88api.ai" in normalized_base_url and (model or "").lower().startswith("claude") + ) + if use_json_mode and not skip_json_mode: data["response_format"] = {"type": "json_object"} response = self._llm_post(url, headers=headers, json_payload=data, timeout=timeout) # Handle non-2xx with provider/model-aware details if response.status_code >= 400: - normalized_base_url = (base_url or "").lower() if "atlascloud" in normalized_base_url: provider_name = "AtlasCloud" elif "openrouter" in normalized_base_url: provider_name = "OpenRouter" + elif "88api.ai" in normalized_base_url: + provider_name = "88API" else: provider_name = "LLM" err_text = self._extract_provider_error(response) @@ -1137,6 +1152,8 @@ def _normalize_model_for_provider(self, model: str, provider: LLMProvider) -> st 'atlascloud': LLMProvider.ATLASCLOUD, 'atlas': LLMProvider.ATLASCLOUD, 'minimax': LLMProvider.MINIMAX, + 'api88': LLMProvider.API88, + '88api': LLMProvider.API88, } # If the model prefix matches the current provider, use the extracted model name @@ -1171,6 +1188,8 @@ def _detect_provider_from_model(self, model: str) -> Optional[LLMProvider]: 'atlascloud': LLMProvider.ATLASCLOUD, 'atlas': LLMProvider.ATLASCLOUD, 'minimax': LLMProvider.MINIMAX, + 'api88': LLMProvider.API88, + '88api': LLMProvider.API88, 'anthropic': LLMProvider.OPENROUTER, # Anthropic only via OpenRouter 'meta': LLMProvider.OPENROUTER, # Meta/Llama only via OpenRouter 'mistral': LLMProvider.OPENROUTER, # Mistral only via OpenRouter @@ -1247,7 +1266,7 @@ def call_llm_api(self, messages: list, model: str = None, temperature: float = 0 ) # If no API key for current provider, try to find any available provider if try_alternative_providers: - for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.ATLASCLOUD, LLMProvider.GROK, LLMProvider.MINIMAX, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]: + for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.ATLASCLOUD, LLMProvider.GROK, LLMProvider.MINIMAX, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.API88, LLMProvider.OPENROUTER]: if alt_provider != p and self.get_api_key(alt_provider): logger.warning(f"No API key for {p.value}, switching to {alt_provider.value}") p = alt_provider @@ -1460,7 +1479,7 @@ def _try_alternative_providers(self, messages: list, model: str, temperature: fl """ Try alternative providers when current provider fails. - Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > OpenRouter + Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > 88API > OpenRouter """ priority_order = [ LLMProvider.DEEPSEEK, @@ -1469,6 +1488,7 @@ def _try_alternative_providers(self, messages: list, model: str, temperature: fl LLMProvider.MINIMAX, LLMProvider.OPENAI, LLMProvider.GOOGLE, + LLMProvider.API88, LLMProvider.OPENROUTER, ] diff --git a/backend_api_python/app/utils/config_loader.py b/backend_api_python/app/utils/config_loader.py index fc1aa0891..f0be6903b 100644 --- a/backend_api_python/app/utils/config_loader.py +++ b/backend_api_python/app/utils/config_loader.py @@ -138,6 +138,11 @@ def env_get(name: str) -> Optional[str]: ('LITELLM_BASE_URL', 'litellm.base_url', 'string'), ('LITELLM_MODEL', 'litellm.model', 'string'), + # 88API + ('API88_API_KEY', 'api88.api_key', 'string'), + ('API88_BASE_URL', 'api88.base_url', 'string'), + ('API88_MODEL', 'api88.model', 'string'), + # LLM Provider Selection ('LLM_PROVIDER', 'llm.provider', 'string'), ('LLM_PROXY_URL', 'llm.proxy_url', 'string'), diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 6202579ec..99173118d 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -229,6 +229,13 @@ MINIMAX_API_KEY= MINIMAX_MODEL=MiniMax-M2.7 MINIMAX_BASE_URL=https://api.minimax.io/v1 +# 88API (set LLM_PROVIDER=api88) +# OpenAI-compatible multi-model gateway: one key for GPT, Claude, Gemini, +# DeepSeek, Grok, MiniMax and more. Get a key at https://88api.ai +API88_API_KEY= +API88_MODEL=claude-sonnet-5-5 +API88_BASE_URL=https://88api.ai/v1 + # ========================= # Common background jobs # ========================= diff --git a/backend_api_python/tests/test_llm_litellm_provider.py b/backend_api_python/tests/test_llm_litellm_provider.py index d6fdedfcf..1eea6a7a0 100644 --- a/backend_api_python/tests/test_llm_litellm_provider.py +++ b/backend_api_python/tests/test_llm_litellm_provider.py @@ -107,6 +107,144 @@ def fake_post(url, headers, json, timeout): assert "response_format" not in captured["json"] +def test_api88_env_mapping(monkeypatch): + monkeypatch.setenv("API88_API_KEY", "api88-key") + monkeypatch.setenv("API88_MODEL", "claude-sonnet-5-5") + monkeypatch.setenv("API88_BASE_URL", "https://88api.ai/v1") + _reset_config_cache() + + cfg = load_addon_config() + + assert cfg["api88"]["api_key"] == "api88-key" + assert cfg["api88"]["model"] == "claude-sonnet-5-5" + assert cfg["api88"]["base_url"] == "https://88api.ai/v1" + + +def test_api88_provider_defaults_and_model_prefix(monkeypatch): + monkeypatch.setenv("LLM_PROVIDER", "api88") + monkeypatch.setenv("API88_API_KEY", "api88-key") + monkeypatch.delenv("API88_MODEL", raising=False) + monkeypatch.delenv("API88_BASE_URL", raising=False) + _reset_config_cache() + + service = LLMService() + + assert service.provider == LLMProvider.API88 + assert service.get_api_key() == "api88-key" + assert service.get_default_model() == "claude-sonnet-5-5" + assert service.get_base_url() == "https://88api.ai/v1" + assert ( + service._normalize_model_for_provider("api88/gpt-5.5", LLMProvider.API88) + == "gpt-5.5" + ) + assert ( + service._normalize_model_for_provider("88api/claude-haiku-5-5", LLMProvider.API88) + == "claude-haiku-5-5" + ) + assert ( + service._normalize_model_for_provider("claude-opus-5-5", LLMProvider.API88) + == "claude-opus-5-5" + ) + assert service._detect_provider_from_model("88api/claude-sonnet-5-5") == LLMProvider.API88 + + +def test_api88_base_url_env_override(monkeypatch): + monkeypatch.setenv("API88_BASE_URL", "https://88api.example/v1/") + _reset_config_cache() + + assert LLMService().get_base_url(LLMProvider.API88) == "https://88api.example/v1" + + +def test_api88_is_auto_detected_when_it_is_the_only_configured_key(monkeypatch): + monkeypatch.delenv("LLM_PROVIDER", raising=False) + _reset_config_cache() + service = LLMService() + monkeypatch.setattr( + service, + "get_api_key", + lambda provider=None: "api88-key" if provider == LLMProvider.API88 else "", + ) + + assert service.provider == LLMProvider.API88 + + +def test_default_provider_stays_openrouter_without_any_key(monkeypatch): + monkeypatch.delenv("LLM_PROVIDER", raising=False) + _reset_config_cache() + service = LLMService() + monkeypatch.setattr(service, "get_api_key", lambda provider=None: "") + + assert service.provider == LLMProvider.OPENROUTER + + +@pytest.mark.parametrize( + "model, expects_json_mode", + [ + ("gpt-5.5", True), + # Anthropic rejects response_format json_object behind 88API. + ("claude-sonnet-5-5", False), + ], +) +def test_api88_openai_compatible_call(monkeypatch, model, expects_json_mode): + captured = {} + + class FakeResponse: + status_code = 200 + + def json(self): + return {"choices": [{"message": {"content": "{\"ok\": true}"}}]} + + def fake_post(url, headers, json, timeout): + captured.update({"url": url, "headers": headers, "json": json}) + return FakeResponse() + + monkeypatch.setattr("app.services.llm.requests.post", fake_post) + service = LLMService(provider="api88") + + out = service._call_openai_compatible( + [{"role": "user", "content": "hello"}], + model, + 0.7, + "api88-key", + "https://88api.ai/v1", + 30, + use_json_mode=True, + ) + + assert out == "{\"ok\": true}" + assert captured["url"] == "https://88api.ai/v1/chat/completions" + assert captured["headers"]["Authorization"] == "Bearer api88-key" + assert captured["json"]["model"] == model + assert ("response_format" in captured["json"]) is expects_json_mode + + +def test_api88_http_error_names_the_provider(monkeypatch): + class FakeResponse: + status_code = 400 + headers = {} + text = "" + + def json(self): + return {"error": {"message": "model not found"}} + + service = LLMService(provider="api88") + monkeypatch.setattr(service, "_llm_post", lambda *args, **kwargs: FakeResponse()) + + with pytest.raises(LLMAPIError) as exc_info: + service._call_openai_compatible( + [{"role": "user", "content": "hello"}], + "claude-sonnet-5-5", + 0.4, + "api88-key", + "https://88api.ai/v1", + 30, + use_json_mode=False, + ) + + assert "88API API 400" in str(exc_info.value) + assert "model=claude-sonnet-5-5" in str(exc_info.value) + + def test_google_gemini_uses_uniform_max_tokens(monkeypatch): captured = {}