From 8f813636752a9dd25057afecbc80efb5f9585e44 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 28 Apr 2026 17:28:57 +0700 Subject: [PATCH] Enhance token refresh functionality across multiple executors - Updated refreshCredentials methods in various executors (Antigravity, Base, Default, Github, Kiro) to accept optional proxyOptions for improved proxy handling. - Modified token refresh logic to utilize proxy-aware fetch for better network management. - Enhanced usage retrieval functions to support proxy options, ensuring seamless integration with proxy configurations. - Updated ModelSelectModal and ProviderInfoCard components to incorporate kind filtering for improved user experience in model selection. - Added validation for API keys in the provider validation route, including support for webSearch/webFetch providers. --- README.md | 354 ++++++++------- open-sse/executors/antigravity.js | 6 +- open-sse/executors/base.js | 2 +- open-sse/executors/default.js | 65 +-- open-sse/executors/github.js | 20 +- open-sse/executors/kiro.js | 5 +- open-sse/handlers/fetch/index.js | 237 ++++++++++ open-sse/handlers/search/callers.js | 371 ++++++++++++++++ open-sse/handlers/search/chatSearch.js | 409 ++++++++++++++++++ open-sse/handlers/search/index.js | 201 +++++++++ open-sse/handlers/search/normalizers.js | 223 ++++++++++ open-sse/services/tokenRefresh.js | 11 +- open-sse/services/usage.js | 80 ++-- public/providers/azure.png | Bin 0 -> 7167 bytes public/providers/blackbox.png | Bin 0 -> 3162 bytes public/providers/brave-search.png | Bin 0 -> 9853 bytes public/providers/exa.png | Bin 0 -> 10724 bytes public/providers/firecrawl.png | Bin 0 -> 3211 bytes public/providers/google-pse.png | Bin 0 -> 1981 bytes public/providers/grok-web.png | Bin 0 -> 3168 bytes public/providers/jina-reader.png | Bin 0 -> 1620 bytes public/providers/linkup.png | Bin 0 -> 2216 bytes public/providers/perplexity-web.png | Bin 0 -> 10678 bytes public/providers/searchapi.png | Bin 0 -> 7311 bytes public/providers/searxng.png | Bin 0 -> 7142 bytes public/providers/serper.png | Bin 0 -> 7148 bytes public/providers/tavily.png | Bin 0 -> 3987 bytes public/providers/youcom.png | Bin 0 -> 7580 bytes src/app/(dashboard)/dashboard/combos/page.js | 3 +- .../media-providers/[kind]/[id]/page.js | 11 +- .../dashboard/media-providers/[kind]/page.js | 10 +- .../media-providers/web/combo/[id]/page.js | 346 +++++++++++++++ .../dashboard/media-providers/web/page.js | 208 +++++++++ src/app/api/combos/route.js | 4 +- src/app/api/providers/validate/route.js | 56 ++- src/app/api/usage/[connectionId]/route.js | 25 +- src/app/api/v1/search/route.js | 21 + src/app/api/v1/web/fetch/route.js | 21 + src/lib/localDb.js | 1 + src/shared/components/ModelSelectModal.js | 31 +- src/shared/components/ProviderInfoCard.js | 29 +- src/shared/components/Sidebar.js | 18 +- src/shared/constants/providers.js | 30 +- src/sse/handlers/fetch.js | 211 +++++++++ src/sse/handlers/search.js | 204 +++++++++ 45 files changed, 2924 insertions(+), 289 deletions(-) create mode 100644 open-sse/handlers/fetch/index.js create mode 100644 open-sse/handlers/search/callers.js create mode 100644 open-sse/handlers/search/chatSearch.js create mode 100644 open-sse/handlers/search/index.js create mode 100644 open-sse/handlers/search/normalizers.js create mode 100644 public/providers/azure.png create mode 100644 public/providers/blackbox.png create mode 100644 public/providers/brave-search.png create mode 100644 public/providers/exa.png create mode 100644 public/providers/firecrawl.png create mode 100644 public/providers/google-pse.png create mode 100644 public/providers/grok-web.png create mode 100644 public/providers/jina-reader.png create mode 100644 public/providers/linkup.png create mode 100644 public/providers/perplexity-web.png create mode 100644 public/providers/searchapi.png create mode 100644 public/providers/searxng.png create mode 100644 public/providers/serper.png create mode 100644 public/providers/tavily.png create mode 100644 public/providers/youcom.png create mode 100644 src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/web/page.js create mode 100644 src/app/api/v1/search/route.js create mode 100644 src/app/api/v1/web/fetch/route.js create mode 100644 src/sse/handlers/fetch.js create mode 100644 src/sse/handlers/search.js diff --git a/README.md b/README.md index 533e3cb..c5c959c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@
9Router Dashboard - # 9Router - Free AI Router + # 9Router - FREE AI Router & Token Saver - **Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.** + **Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** @@ -20,19 +20,21 @@ ## ๐Ÿค” Why 9Router? -**Stop wasting money and hitting limits:** +**Stop wasting money, tokens and hitting limits:** - โŒ Subscription quota expires unused every month - โŒ Rate limits stop you mid-coding +- โŒ Tool outputs (git diff, grep, ls...) burn tokens fast - โŒ Expensive APIs ($20-50/month per provider) - โŒ Manual switching between providers **9Router solves this:** +- โœ… **RTK Token Saver** - Auto-compress tool_result content, save 20-40% tokens per request - โœ… **Maximize subscriptions** - Track quota, use every bit before reset - โœ… **Auto fallback** - Subscription โ†’ Cheap โ†’ Free, zero downtime - โœ… **Multi-account** - Round-robin between accounts per provider -- โœ… **Universal** - Works with Claude Code, Codex, Gemini CLI, Cursor, Cline, any CLI tool +- โœ… **Universal** - Works with Claude Code, Codex, Cursor, Cline, any CLI tool --- @@ -40,25 +42,26 @@ ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Your CLI โ”‚ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +โ”‚ Your CLI โ”‚ (Claude Code, Codex, OpenClaw, Cursor, Cline...) โ”‚ Tool โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ http://localhost:20128/v1 โ†“ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ 9Router (Smart Router) โ”‚ -โ”‚ โ€ข Format translation (OpenAI โ†” Claude) โ”‚ -โ”‚ โ€ข Quota tracking โ”‚ -โ”‚ โ€ข Auto token refresh โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 9Router (Smart Router) โ”‚ +โ”‚ โ€ข RTK Token Saver (cut tool_result tokens) โ”‚ +โ”‚ โ€ข Format translation (OpenAI โ†” Claude) โ”‚ +โ”‚ โ€ข Quota tracking โ”‚ +โ”‚ โ€ข Auto token refresh โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ”œโ”€โ†’ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI + โ”œโ”€โ†’ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot โ”‚ โ†“ quota exhausted โ”œโ”€โ†’ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) โ”‚ โ†“ budget limit - โ””โ”€โ†’ [Tier 3: FREE] iFlow, Qwen, Kiro (unlimited) + โ””โ”€โ†’ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) -Result: Never stop coding, minimal cost +Result: Never stop coding, minimal cost + 20-40% token savings via RTK ``` --- @@ -76,15 +79,15 @@ npm install -g 9router **2. Connect a FREE provider (no signup needed):** -Dashboard โ†’ Providers โ†’ Connect **Claude Code** or **Antigravity** โ†’ OAuth login โ†’ Done! +Dashboard โ†’ Providers โ†’ Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) โ†’ Done! **3. Use in your CLI tool:** ``` -Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Settings: +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: Endpoint: http://localhost:20128/v1 API Key: [copy from dashboard] - Model: if/kimi-k2-thinking + Model: kr/claude-sonnet-4.5 ``` **That's it!** Start coding with FREE AI models. @@ -233,30 +236,27 @@ Default URLs:
- - - + +
- iFlow
- iFlow AI
- 8+ models โ€ข Unlimited -
- Qwen
- Qwen Code
- 3+ models โ€ข Unlimited -
- Gemini CLI
- Gemini CLI
- 180K/month FREE -
Kiro
Kiro AI
- Claude โ€ข Unlimited + Claude 4.5 + GLM-5 + MiniMax
Unlimited FREE
+
+ OpenCode Free
+ OpenCode Free
+ No auth โ€ข Auto-fetch models
Unlimited FREE
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 credits free
+> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. + ### ๐Ÿ”‘ API Key Providers (40+)
@@ -349,9 +349,10 @@ Default URLs: | Feature | What It Does | Why It Matters | |---------|--------------|----------------| +| ๐Ÿš€ **RTK Token Saver** | Auto-compress tool_result content (git-diff, grep, find, ls, tree...) before sending to LLM | Save 20-40% tokens per request, keep more context window | | ๐ŸŽฏ **Smart 3-Tier Fallback** | Auto-route: Subscription โ†’ Cheap โ†’ Free | Never stop coding, zero downtime | | ๐Ÿ“Š **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | -| ๐Ÿ”„ **Format Translation** | OpenAI โ†” Claude โ†” Gemini seamless | Works with any CLI tool | +| ๐Ÿ”„ **Format Translation** | OpenAI โ†” Claude โ†” Gemini โ†” Cursor โ†” Kiro โ†” Vertex | Works with any CLI tool | | ๐Ÿ‘ฅ **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | | ๐Ÿ”„ **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | | ๐ŸŽจ **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | @@ -363,6 +364,21 @@ Default URLs:
๐Ÿ“– Feature Details +### ๐Ÿš€ RTK Token Saver + +Tool outputs (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) often eat 30-50% of your prompt budget. RTK detects them and applies smart, lossless compression **before** the request hits the LLM: + +- **Filters:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Auto-detect:** No config needed โ€” RTK peeks the first 1KB of each `tool_result` and picks the right filter. +- **Safe by design:** If a filter fails, throws, or makes output bigger, RTK silently keeps the original text. Errors never break your request. +- **Universal:** Works across all formats (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) because it runs **before** any format translation. +- **Default ON:** Toggle anytime in Dashboard โ†’ Endpoint settings. + +``` +Without RTK: 47K tokens sent to LLM +With RTK: 28K tokens sent to LLM (40% saved ยท same context ยท same answer) +``` + ### ๐ŸŽฏ Smart 3-Tier Fallback Create combos with automatic fallback: @@ -386,7 +402,7 @@ Combo: "my-coding-stack" ### ๐Ÿ”„ Format Translation Seamless translation between formats: -- **OpenAI** โ†” **Claude** โ†” **Gemini** โ†” **OpenAI Responses** +- **OpenAI** โ†” **Claude** โ†” **Gemini** โ†” **Cursor** โ†” **Kiro** โ†” **Vertex** โ†” **Antigravity** โ†” **Ollama** โ†” **OpenAI Responses** - Your CLI tool sends OpenAI format โ†’ 9Router translates โ†’ Provider receives native format - Works with any tool that supports custom OpenAI endpoints @@ -464,18 +480,19 @@ Seamless translation between formats: | Tier | Provider | Cost | Quota Reset | Best For | |------|----------|------|-------------|----------| -| **๐Ÿ’ณ SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed | +| **๐Ÿš€ TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | +| **๐Ÿ’ณ SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | | | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | -| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! | | | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| **๐Ÿ’ฐ CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | -| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **๐Ÿ†“ FREE** | iFlow | $0 | Unlimited | 8 models free | -| | Qwen | $0 | Unlimited | 3 models free | -| | Kiro | $0 | Unlimited | Claude free | +| | Cursor IDE | $20/mo | Monthly | Cursor users | +| **๐Ÿ’ฐ CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | +| **๐Ÿ†“ FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | +| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | +| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | -**๐Ÿ’ก Pro Tip:** Start with Gemini CLI (180K free/month) + iFlow (unlimited free) combo = $0 cost! +**๐Ÿ’ก Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! --- @@ -523,9 +540,9 @@ Reality Check: **Solution:** ``` Combo: "maximize-claude" - 1. cc/claude-opus-4-6 (use subscription fully) - 2. glm/glm-4.7 (cheap backup when quota out) - 3. if/kimi-k2-thinking (free emergency fallback) + 1. cc/claude-opus-4-7 (use subscription fully) + 2. glm/glm-5.1 (cheap backup when quota out) + 3. kr/claude-sonnet-4.5 (free emergency fallback) Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total vs. $20 + hitting limits = frustration @@ -538,12 +555,12 @@ vs. $20 + hitting limits = frustration **Solution:** ``` Combo: "free-forever" - 1. gc/gemini-3-flash (180K free/month) - 2. if/kimi-k2-thinking (unlimited free) - 3. qw/qwen3-coder-plus (unlimited free) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. oc/ (OpenCode Free, no auth) Monthly cost: $0 -Quality: Production-ready models +Quality: Production-ready models + RTK saves 20-40% tokens ``` ### Case 3: "I need 24/7 coding, no interruptions" @@ -553,11 +570,11 @@ Quality: Production-ready models **Solution:** ``` Combo: "always-on" - 1. cc/claude-opus-4-6 (best quality) - 2. cx/gpt-5.2-codex (second subscription) - 3. glm/glm-4.7 (cheap, resets daily) - 4. minimax/MiniMax-M2.1 (cheapest, 5h reset) - 5. if/kimi-k2-thinking (free unlimited) + 1. cc/claude-opus-4-7 (best quality) + 2. cx/gpt-5.5 (second subscription) + 3. glm/glm-5.1 (cheap, resets daily) + 4. minimax/MiniMax-M2.7 (cheapest, 5h reset) + 5. kr/claude-sonnet-4.5 (free unlimited) Result: 5 layers of fallback = zero downtime Monthly cost: $20-200 (subscriptions) + $10-20 (backup) @@ -570,9 +587,9 @@ Monthly cost: $20-200 (subscriptions) + $10-20 (backup) **Solution:** ``` Combo: "openclaw-free" - 1. if/glm-4.7 (unlimited free) - 2. if/minimax-m2.1 (unlimited free) - 3. if/kimi-k2-thinking (unlimited free) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free) + 2. kr/glm-5 (GLM-5 free) + 3. kr/MiniMax-M2.5 (MiniMax free) Monthly cost: $0 Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... @@ -614,16 +631,19 @@ The cost display is a "savings tracker" to help you understand your usage patter
๐Ÿ†“ Are FREE providers really unlimited? -**Yes!** Providers marked as FREE (iFlow, Kiro, Qwen) are genuinely unlimited with **no hidden charges**. +**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. These are free services offered by those respective companies: -- **iFlow**: Free unlimited access to 8+ models via OAuth -- **Kiro**: Free unlimited Claude models via AWS Builder ID -- **Qwen**: Free unlimited access to Qwen models via device auth +- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` +- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) 9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. -**Note:** Some subscription providers (Antigravity, GitHub Copilot) may have free preview periods that could become paid later, but this would be clearly announced by those providers, not 9Router. +**Discontinued free tiers (no longer recommended):** +- โŒ **iFlow**: Was free unlimited, now changed to paid (2026) +- โŒ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 +- โŒ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans โ€” only use if you stick to Gemini CLI itself
@@ -689,8 +709,9 @@ Dashboard โ†’ Providers โ†’ Connect Claude Code โ†’ 5-hour + weekly quota tracking Models: + cc/claude-opus-4-7 cc/claude-opus-4-6 - cc/claude-sonnet-4-5-20250929 + cc/claude-sonnet-4-6 cc/claude-haiku-4-5-20251001 ``` @@ -704,24 +725,12 @@ Dashboard โ†’ Providers โ†’ Connect Codex โ†’ 5-hour + weekly reset Models: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex cx/gpt-5.2-codex - cx/gpt-5.1-codex-max ``` -### Gemini CLI (FREE 180K/month!) - -```bash -Dashboard โ†’ Providers โ†’ Connect Gemini CLI -โ†’ Google OAuth -โ†’ 180K completions/month + 1K/day - -Models: - gc/gemini-3-flash-preview - gc/gemini-2.5-pro -``` - -**Best Value:** Huge free tier! Use this before paid tiers. - ### GitHub Copilot ```bash @@ -730,9 +739,24 @@ Dashboard โ†’ Providers โ†’ Connect GitHub โ†’ Monthly reset (1st of month) Models: - gh/gpt-5 - gh/claude-4.5-sonnet - gh/gemini-3-pro + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Dashboard โ†’ Providers โ†’ Connect Cursor +โ†’ OAuth login +โ†’ Monthly subscription + +Models: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex ```
@@ -740,7 +764,7 @@ Models:
๐Ÿ’ฐ Cheap Providers (Backup) -### GLM-4.7 (Daily reset, $0.6/1M) +### GLM-5.1 / GLM-4.7 (Daily reset, $0.6/1M) 1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) 2. Get API key from Coding Plan @@ -748,74 +772,83 @@ Models: - Provider: `glm` - API Key: `your-key` -**Use:** `glm/glm-4.7` +**Use:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` **Pro Tip:** Coding Plan offers 3ร— quota at 1/7 cost! Reset daily 10:00 AM. -### MiniMax M2.1 (5h reset, $0.20/1M) +### MiniMax M2.7 (5h reset, $0.20/1M) 1. Sign up: [MiniMax](https://www.minimax.io/) 2. Get API key 3. Dashboard โ†’ Add API Key -**Use:** `minimax/MiniMax-M2.1` +**Use:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` **Pro Tip:** Cheapest option for long context (1M tokens)! -### Kimi K2 ($9/month flat) +### Kimi K2.5 ($9/month flat) 1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) 2. Get API key 3. Dashboard โ†’ Add API Key -**Use:** `kimi/kimi-latest` +**Use:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` **Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost!
-๐Ÿ†“ FREE Providers (Emergency Backup) +๐Ÿ†“ FREE Providers (Recommended) -### iFlow (8 FREE models) - -```bash -Dashboard โ†’ Connect iFlow -โ†’ iFlow OAuth login -โ†’ Unlimited usage - -Models: - if/kimi-k2-thinking - if/qwen3-coder-plus - if/glm-4.7 - if/minimax-m2 - if/deepseek-r1 -``` - -### Qwen (3 FREE models) - -```bash -Dashboard โ†’ Connect Qwen -โ†’ Device code authorization -โ†’ Unlimited usage - -Models: - qw/qwen3-coder-plus - qw/qwen3-coder-flash -``` - -### Kiro (Claude FREE) +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax FREE) ```bash Dashboard โ†’ Connect Kiro -โ†’ AWS Builder ID, AWS IAM Identity Center, Google, GitHub +โ†’ AWS Builder ID, AWS IAM Identity Center, Google, or GitHub โ†’ Unlimited usage Models: kr/claude-sonnet-4.5 kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 ``` +**Pro Tip:** Best free option for Claude. No API key, no payment, fully unlimited. + +### OpenCode Free (No auth, auto-fetch models) + +```bash +Dashboard โ†’ Connect OpenCode Free +โ†’ No login required (passthrough proxy) +โ†’ Models auto-fetched from opencode.ai/zen/v1/models +``` + +**Pro Tip:** Fastest setup. Just connect and start coding. + +### Vertex AI ($300 free credits for new GCP accounts) + +```bash +Dashboard โ†’ Connect Vertex AI +โ†’ Upload Google Cloud Service Account JSON +โ†’ Enable Vertex AI API in your GCP project + +Models: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen via Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Pro Tip:** New Google Cloud accounts get $300 credits free for 90 days. Plenty for daily coding. +
@@ -828,9 +861,9 @@ Dashboard โ†’ Combos โ†’ Create New Name: premium-coding Models: - 1. cc/claude-opus-4-6 (Subscription primary) - 2. glm/glm-4.7 (Cheap backup, $0.6/1M) - 3. minimax/MiniMax-M2.1 (Cheapest fallback, $0.20/1M) + 1. cc/claude-opus-4-7 (Subscription primary) + 2. glm/glm-5.1 (Cheap backup, $0.6/1M) + 3. minimax/MiniMax-M2.7 (Cheapest fallback, $0.20/1M) Use in CLI: premium-coding @@ -846,11 +879,11 @@ Monthly cost example (100M tokens): ``` Name: free-combo Models: - 1. gc/gemini-3-flash-preview (180K free/month) - 2. if/kimi-k2-thinking (unlimited) - 3. qw/qwen3-coder-plus (unlimited) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 free credits) -Cost: $0 forever! +Cost: $0 forever (+ 20-40% token savings via RTK)! ```
@@ -864,7 +897,7 @@ Cost: $0 forever! Settings โ†’ Models โ†’ Advanced: OpenAI API Base URL: http://localhost:20128/v1 OpenAI API Key: [from 9router dashboard] - Model: cc/claude-opus-4-6 + Model: cc/claude-opus-4-7 ``` Or use combo: `premium-coding` @@ -904,7 +937,7 @@ Dashboard โ†’ CLI Tools โ†’ OpenClaw โ†’ Select Model โ†’ Apply "agents": { "defaults": { "model": { - "primary": "9router/if/glm-4.7" + "primary": "9router/kr/claude-sonnet-4.5" } } }, @@ -916,8 +949,8 @@ Dashboard โ†’ CLI Tools โ†’ OpenClaw โ†’ Select Model โ†’ Apply "api": "openai-completions", "models": [ { - "id": "if/glm-4.7", - "name": "glm-4.7" + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" } ] } @@ -934,7 +967,7 @@ Dashboard โ†’ CLI Tools โ†’ OpenClaw โ†’ Select Model โ†’ Apply Provider: OpenAI Compatible Base URL: http://localhost:20128/v1 API Key: [from dashboard] -Model: cc/claude-opus-4-6 +Model: cc/claude-opus-4-7 ``` @@ -1057,40 +1090,62 @@ Notes: View all available models **Claude Code (`cc/`)** - Pro/Max: +- `cc/claude-opus-4-7` - `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` - `cc/claude-sonnet-4-5-20250929` - `cc/claude-haiku-4-5-20251001` **Codex (`cx/`)** - Plus/Pro: +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` - `cx/gpt-5.2-codex` - `cx/gpt-5.1-codex-max` -**Gemini CLI (`gc/`)** - FREE: -- `gc/gemini-3-flash-preview` -- `gc/gemini-2.5-pro` - **GitHub Copilot (`gh/`)**: -- `gh/gpt-5` -- `gh/claude-4.5-sonnet` +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Subscription: +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` **GLM (`glm/`)** - $0.6/1M: +- `glm/glm-5.1` +- `glm/glm-5` - `glm/glm-4.7` **MiniMax (`minimax/`)** - $0.2/1M: -- `minimax/MiniMax-M2.1` +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` -**iFlow (`if/`)** - FREE: -- `if/kimi-k2-thinking` -- `if/qwen3-coder-plus` -- `if/deepseek-r1` +**Kimi (`kimi/`)** - $9/mo flat: +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` -**Qwen (`qw/`)** - FREE: -- `qw/qwen3-coder-plus` -- `qw/qwen3-coder-flash` - -**Kiro (`kr/`)** - FREE: +**Kiro (`kr/`)** - FREE unlimited: - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - FREE no-auth: +- Auto-fetched from `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - $300 free credits: +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` @@ -1104,16 +1159,17 @@ Notes: **Rate limiting** - Subscription quota out โ†’ Fallback to GLM/MiniMax -- Add combo: `cc/claude-opus-4-6 โ†’ glm/glm-4.7 โ†’ if/kimi-k2-thinking` +- Add combo: `cc/claude-opus-4-7 โ†’ glm/glm-5.1 โ†’ kr/claude-sonnet-4.5` **OAuth token expired** - Auto-refreshed by 9Router - If issues persist: Dashboard โ†’ Provider โ†’ Reconnect **High costs** +- Enable RTK in Dashboard โ†’ Endpoint settings (default ON, saves 20-40% tokens) - Check usage stats in Dashboard - Switch primary model to GLM/MiniMax -- Use free tier (Gemini CLI, iFlow) for non-critical tasks +- Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks **Dashboard opens on wrong port** - Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 0fcbab8..36c38f1 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -114,11 +114,11 @@ export class AntigravityExecutor extends BaseExecutor { }; } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log, proxyOptions = null) { if (!credentials.refreshToken) return null; try { - const response = await fetch(OAUTH_ENDPOINTS.google.token, { + const response = await proxyAwareFetch(OAUTH_ENDPOINTS.google.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body: new URLSearchParams({ @@ -127,7 +127,7 @@ export class AntigravityExecutor extends BaseExecutor { client_id: this.config.clientId, client_secret: this.config.clientSecret }) - }); + }, proxyOptions); if (!response.ok) return null; diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 2ba2219..39f89bf 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -85,7 +85,7 @@ export class BaseExecutor { } // Override in subclass for provider-specific refresh - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log, proxyOptions = null) { return null; } diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index c5fce14..1db029a 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -3,6 +3,7 @@ import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; export class DefaultExecutor extends BaseExecutor { constructor(provider) { @@ -154,19 +155,19 @@ export class DefaultExecutor extends BaseExecutor { return headers; } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log, proxyOptions = null) { if (!credentials.refreshToken) return null; const refreshers = { - claude: () => this.refreshWithJSON(OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.claude.clientId }), - codex: () => this.refreshWithForm(OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access" }), - qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }), - iflow: () => this.refreshIflow(credentials.refreshToken), - gemini: () => this.refreshGoogle(credentials.refreshToken), - kiro: () => this.refreshKiro(credentials.refreshToken), - cline: () => this.refreshCline(credentials.refreshToken), - "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken), - kilocode: () => this.refreshKilocode(credentials.refreshToken) + claude: () => this.refreshWithJSON(OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.claude.clientId }, proxyOptions), + codex: () => this.refreshWithForm(OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access" }, proxyOptions), + qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions), + iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions), + gemini: () => this.refreshGoogle(credentials.refreshToken, proxyOptions), + kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), + cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), + "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), + kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions) }; const refresher = refreshers[this.provider]; @@ -182,69 +183,69 @@ export class DefaultExecutor extends BaseExecutor { } } - async refreshWithJSON(url, body) { - const response = await fetch(url, { + async refreshWithJSON(url, body, proxyOptions = null) { + const response = await proxyAwareFetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(body) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || body.refresh_token, expiresIn: tokens.expires_in }; } - async refreshWithForm(url, params) { - const response = await fetch(url, { + async refreshWithForm(url, params, proxyOptions = null) { + const response = await proxyAwareFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body: new URLSearchParams(params) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || params.refresh_token, expiresIn: tokens.expires_in }; } - async refreshIflow(refreshToken) { + async refreshIflow(refreshToken, proxyOptions = null) { const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { + const response = await proxyAwareFetch(OAUTH_ENDPOINTS.iflow.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "Authorization": `Basic ${basicAuth}` }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.iflow.clientId, client_secret: PROVIDERS.iflow.clientSecret }) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; } - async refreshGoogle(refreshToken) { - const response = await fetch(OAUTH_ENDPOINTS.google.token, { + async refreshGoogle(refreshToken, proxyOptions = null) { + const response = await proxyAwareFetch(OAUTH_ENDPOINTS.google.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.config.clientId, client_secret: this.config.clientSecret }) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; } - async refreshKiro(refreshToken) { - const response = await fetch(PROVIDERS.kiro.tokenUrl, { + async refreshKiro(refreshToken, proxyOptions = null) { + const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "kiro-cli/1.0.0" }, body: JSON.stringify({ refreshToken }) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn }; } - async refreshCline(refreshToken) { + async refreshCline(refreshToken, proxyOptions = null) { console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length); - const response = await fetch("https://api.cline.bot/api/v1/auth/refresh", { + const response = await proxyAwareFetch("https://api.cline.bot/api/v1/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" }) - }); + }, proxyOptions); console.log('[DEBUG] Cline refresh response status:', response.status); if (!response.ok) { const errorText = await response.text(); @@ -260,9 +261,9 @@ export class DefaultExecutor extends BaseExecutor { return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; } - async refreshKimiCoding(refreshToken) { + async refreshKimiCoding(refreshToken, proxyOptions = null) { const kimiHeaders = buildKimiHeaders(); - const response = await fetch("https://auth.kimi.com/api/oauth/token", { + const response = await proxyAwareFetch("https://auth.kimi.com/api/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -270,13 +271,13 @@ export class DefaultExecutor extends BaseExecutor { ...kimiHeaders }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" }) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; } - async refreshKilocode(refreshToken) { + async refreshKilocode(refreshToken, proxyOptions = null) { // Kilocode uses device code flow, no refresh token support return null; } diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index ea0905e..d0c6e33 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -271,9 +271,9 @@ export class GithubExecutor extends BaseExecutor { }; } - async refreshCopilotToken(githubAccessToken, log) { + async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) { try { - const response = await fetch("https://api.github.com/copilot_internal/v2/token", { + const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", { headers: { "Authorization": `token ${githubAccessToken}`, "User-Agent": GITHUB_COPILOT.USER_AGENT, @@ -282,7 +282,7 @@ export class GithubExecutor extends BaseExecutor { "Accept": "application/json", "x-github-api-version": GITHUB_COPILOT.API_VERSION } - }); + }, proxyOptions); if (!response.ok) { const errorText = await response.text(); log?.error?.("TOKEN", `Copilot token refresh failed: ${response.status} ${errorText}`); @@ -297,7 +297,7 @@ export class GithubExecutor extends BaseExecutor { } } - async refreshGitHubToken(refreshToken, log) { + async refreshGitHubToken(refreshToken, log, proxyOptions = null) { try { const params = { grant_type: "refresh_token", @@ -308,11 +308,11 @@ export class GithubExecutor extends BaseExecutor { params.client_secret = this.config.clientSecret; } - const response = await fetch(OAUTH_ENDPOINTS.github.token, { + const response = await proxyAwareFetch(OAUTH_ENDPOINTS.github.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body: new URLSearchParams(params) - }); + }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); log?.info?.("TOKEN", "GitHub token refreshed"); @@ -323,13 +323,13 @@ export class GithubExecutor extends BaseExecutor { } } - async refreshCredentials(credentials, log) { - let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log); + async refreshCredentials(credentials, log, proxyOptions = null) { + let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log, proxyOptions); if (!copilotResult && credentials.refreshToken) { - const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log); + const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log, proxyOptions); if (githubTokens?.accessToken) { - copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log); + copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log, proxyOptions); if (copilotResult) { return { ...githubTokens, copilotToken: copilotResult.token, copilotTokenExpiresAt: copilotResult.expiresAt }; } diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index bcb28f6..120f440 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -378,7 +378,7 @@ export class KiroExecutor extends BaseExecutor { }); } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log, proxyOptions = null) { if (!credentials.refreshToken) return null; try { @@ -386,7 +386,8 @@ export class KiroExecutor extends BaseExecutor { const result = await refreshKiroToken( credentials.refreshToken, credentials.providerSpecificData, - log + log, + proxyOptions ); return result; diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js new file mode 100644 index 0000000..da1c230 --- /dev/null +++ b/open-sse/handlers/fetch/index.js @@ -0,0 +1,237 @@ +// Web Fetch handler โ€” dispatches to firecrawl, jina-reader, tavily, exa +// Returns normalized shape across all providers + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_FORMAT = "markdown"; + +/** + * @typedef {Object} FetchResult + * @property {boolean} success + * @property {number} [status] + * @property {string} [error] + * @property {Object} [data] + */ + +/** + * Fetch with timeout abort. + * @param {string} url + * @param {RequestInit} init + * @param {number} timeoutMs + */ +// Strip non-ASCII chars from header values (HTTP headers must be ByteString). +function sanitizeHeaders(headers) { + if (!headers) return headers; + const out = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + } + return out; +} + +async function tryFetch(url, init, timeoutMs) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: ctrl.signal }); + return { ok: true, res }; + } catch (err) { + const isAbort = err?.name === "AbortError"; + return { ok: false, timeout: isAbort, error: err?.message || String(err) }; + } finally { + clearTimeout(timer); + } +} + +function truncate(text, max) { + if (!text || typeof text !== "string") return text || ""; + if (!max || max <= 0) return text; + return text.length > max ? text.slice(0, max) : text; +} + +function parseJinaTitle(text) { + const m = String(text || "").match(/^\s*#\s+(.+)$/m); + return m ? m[1].trim() : null; +} + +function buildData({ provider, url, title, format, text, costUsd, responseMs, upstreamMs }) { + return { + provider, + url, + title: title || null, + content: { format, text: text || "", length: (text || "").length }, + metadata: { author: null, published_at: null, language: null }, + usage: { fetch_cost_usd: costUsd ?? null }, + metrics: { response_time_ms: responseMs, upstream_latency_ms: upstreamMs } + }; +} + +async function readJsonOrText(res) { + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + try { return { json: await res.json() }; } catch { return { text: "" }; } + } + return { text: await res.text() }; +} + +/** + * Main handler. + * @param {Object} params + * @param {string} params.url + * @param {string} [params.format] + * @param {number} [params.maxCharacters] + * @param {string} params.provider + * @param {Object} [params.providerConfig] + * @param {Object} [params.credentials] + * @param {Function} [params.log] + * @returns {Promise} + */ +export async function handleFetchCore({ url, format, maxCharacters, provider, providerConfig, credentials, log }) { + if (!url || typeof url !== "string") { + return { success: false, status: 400, error: "url is required" }; + } + if (!provider) { + return { success: false, status: 400, error: "provider is required" }; + } + + const fmt = format || DEFAULT_FORMAT; + const timeoutMs = providerConfig?.timeoutMs || DEFAULT_TIMEOUT_MS; + const apiKey = credentials?.apiKey || credentials?.key || credentials?.token || ""; + const costPerQuery = providerConfig?.costPerQuery ?? null; + const startedAt = Date.now(); + + try { + if (provider === "firecrawl") { + return await runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "jina-reader") { + return await runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "tavily") { + return await runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "exa") { + return await runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + return { success: false, status: 400, error: `Unsupported provider: ${provider}` }; + } catch (err) { + log?.("fetch handler error:", err?.message || err); + return { success: false, status: 502, error: err?.message || "Internal fetch error" }; + } +} + +async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.firecrawl.dev/v1/scrape", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url, formats: [fmt] }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Firecrawl error: ${r.res.status}` }; + } + const d = json?.data || {}; + const text = truncate(d.markdown || d.html || d.text || "", maxCharacters); + const title = d.metadata?.title || null; + return { + success: true, + data: buildData({ + provider: "firecrawl", url, title, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const target = `https://r.jina.ai/${encodeURIComponent(url)}`; + const upstreamStart = Date.now(); + const r = await tryFetch(target, { + method: "GET", + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const body = await r.res.text(); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: body?.slice(0, 500) || `Jina error: ${r.res.status}` }; + } + const text = truncate(body, maxCharacters); + return { + success: true, + data: buildData({ + provider: "jina-reader", url, title: parseJinaTitle(body), format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.tavily.com/extract", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ urls: [url], extract_depth: "basic" }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Tavily error: ${r.res.status}` }; + } + const first = json?.results?.[0] || {}; + const text = truncate(first.raw_content || "", maxCharacters); + return { + success: true, + data: buildData({ + provider: "tavily", url, title: null, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.exa.ai/contents", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { "x-api-key": apiKey } : {}) + }, + body: JSON.stringify({ ids: [url], text: true }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Exa error: ${r.res.status}` }; + } + const first = json?.results?.[0] || {}; + const text = truncate(first.text || "", maxCharacters); + return { + success: true, + data: buildData({ + provider: "exa", url, title: first.title || null, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js new file mode 100644 index 0000000..64f045c --- /dev/null +++ b/open-sse/handlers/search/callers.js @@ -0,0 +1,371 @@ +/** + * Search Provider Request Builders + * + * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). + * Builds HTTP request `{ url, init }` for 10 search providers. + * + * @typedef {Object} SearchProviderConfig + * @property {string} id + * @property {string} baseUrl + * @property {string} [method] + * + * @typedef {Object} ContentOptions + * @property {boolean} [snippet] + * @property {boolean} [full_page] + * @property {string} [format] + * @property {number} [max_characters] + * + * @typedef {Object} SearchRequestParams + * @property {string} query + * @property {string} searchType + * @property {number} maxResults + * @property {string} [token] + * @property {string} [country] + * @property {string} [language] + * @property {string} [timeRange] + * @property {number} [offset] + * @property {string[]} [domainFilter] + * @property {ContentOptions} [contentOptions] + * @property {Record} [providerOptions] + * @property {Record} [providerSpecificData] + */ + +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Split domain filter into includes / excludes (excludes prefixed with "-"). + * @param {string[]} [domainFilter] + * @returns {{includes: string[], excludes: string[]}} + */ +export function parseDomainFilter(domainFilter) { + if (!domainFilter?.length) return { includes: [], excludes: [] }; + const includes = domainFilter.filter((d) => !d.startsWith("-")); + const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1)); + return { includes, excludes }; +} + +/** + * Read string setting from providerOptions first, then providerSpecificData. + * @param {SearchRequestParams} params + * @param {string} key + * @returns {string|undefined} + */ +export function getProviderSetting(params, key) { + const fromOptions = params.providerOptions?.[key]; + if (typeof fromOptions === "string" && fromOptions.trim().length > 0) { + return fromOptions.trim(); + } + const fromProviderData = params.providerSpecificData?.[key]; + if (typeof fromProviderData === "string" && fromProviderData.trim().length > 0) { + return fromProviderData.trim(); + } + return undefined; +} + +/** + * Resolve base URL with optional override from providerOptions.baseUrl. + * @param {SearchProviderConfig} config + * @param {SearchRequestParams} params + * @returns {string} + */ +export function resolveBaseUrl(config, params) { + const override = getProviderSetting(params, "baseUrl"); + return (override || config.baseUrl).replace(/\/+$/, ""); +} + +/** + * Convert offset+maxResults to 1-indexed page number. + * @param {number|undefined} offset + * @param {number} maxResults + * @returns {number|undefined} + */ +export function toPageNumber(offset, maxResults) { + if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined; + return Math.floor(offset / maxResults) + 1; +} + +// โ”€โ”€ Provider Request Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildSerperRequest(config, params) { + const endpoint = params.searchType === "news" ? "/news" : "/search"; + const body = { q: params.query, num: params.maxResults }; + if (params.country) body.gl = params.country.toLowerCase(); + if (params.language) body.hl = params.language; + return { + url: `${resolveBaseUrl(config, params)}${endpoint}`, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildBraveRequest(config, params) { + const endpoint = params.searchType === "news" ? "/news/search" : "/web/search"; + const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) }); + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("search_lang", params.language); + return { + url: `${resolveBaseUrl(config, params)}${endpoint}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": params.token }, + }, + }; +} + +function buildPerplexityRequest(config, params) { + const body = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.search_language_filter = [params.language]; + if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildExaRequest(config, params) { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body = { + query: params.query, + numResults: params.maxResults, + type: "auto", + text: true, + highlights: true, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.searchType === "news") body.category = "news"; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildTavilyRequest(config, params) { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body = { + query: params.query, + max_results: params.maxResults, + topic: params.searchType === "news" ? "news" : "general", + }; + if (includes.length) body.include_domains = includes; + if (excludes.length) body.exclude_domains = excludes; + if (params.country) body.country = params.country; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildGooglePseRequest(config, params) { + const apiKey = params.token; + const cx = getProviderSetting(params, "cx"); + if (!apiKey || !cx) { + throw new Error("Google Programmable Search requires both apiKey and cx"); + } + const qp = new URLSearchParams({ + key: apiKey, + cx, + q: params.query, + num: String(Math.min(params.maxResults, 10)), + }); + if (params.country) qp.set("gl", params.country.toLowerCase()); + if (params.language) qp.set("hl", params.language); + if (params.timeRange && params.timeRange !== "any") { + const dateRestrictMap = { day: "d1", week: "w1", month: "m1", year: "y1" }; + const dateRestrict = dateRestrictMap[params.timeRange]; + if (dateRestrict) qp.set("dateRestrict", dateRestrict); + } + if (typeof params.offset === "number" && params.offset > 0) { + qp.set("start", String(Math.min(params.offset + 1, 91))); + } + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +function buildLinkupRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("Linkup Search requires an API key"); + + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const requestedDepth = getProviderSetting(params, "depth"); + const depth = + requestedDepth && ["fast", "standard", "deep"].includes(requestedDepth) + ? requestedDepth + : "standard"; + + const body = { + q: params.query, + depth, + outputType: "searchResults", + maxResults: params.maxResults, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.timeRange && params.timeRange !== "any") { + const today = new Date(); + const toDate = today.toISOString().slice(0, 10); + const from = new Date(today); + if (params.timeRange === "day") from.setUTCDate(from.getUTCDate() - 1); + if (params.timeRange === "week") from.setUTCDate(from.getUTCDate() - 7); + if (params.timeRange === "month") from.setUTCMonth(from.getUTCMonth() - 1); + if (params.timeRange === "year") from.setUTCFullYear(from.getUTCFullYear() - 1); + body.fromDate = from.toISOString().slice(0, 10); + body.toDate = toDate; + } + + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildSearchApiRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("SearchAPI requires an API key"); + + const qp = new URLSearchParams({ + engine: params.searchType === "news" ? "google_news" : "google", + q: params.query, + api_key: apiKey, + }); + if (params.country) qp.set("gl", params.country.toLowerCase()); + if (params.language) qp.set("hl", params.language); + + const page = toPageNumber(params.offset, params.maxResults); + if (page) qp.set("page", String(page)); + + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +function buildYouComRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("You.com Search requires an API key"); + + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const qp = new URLSearchParams({ + query: params.query, + count: String(Math.min(params.maxResults, 100)), + }); + + if (params.timeRange && params.timeRange !== "any") qp.set("freshness", params.timeRange); + if (typeof params.offset === "number" && params.offset > 0 && params.maxResults > 0) { + qp.set("offset", String(Math.min(Math.floor(params.offset / params.maxResults), 9))); + } + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("language", params.language); + if (includes.length) qp.set("include_domains", includes.join(",")); + if (excludes.length) qp.set("exclude_domains", excludes.join(",")); + + if (params.contentOptions?.full_page) { + qp.set("livecrawl", params.searchType === "news" ? "news" : "web"); + qp.append( + "livecrawl_formats", + params.contentOptions.format === "markdown" ? "markdown" : "html" + ); + } + + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-API-Key": apiKey }, + }, + }; +} + +function buildSearxngRequest(config, params) { + const baseUrl = resolveBaseUrl(config, params); + const url = baseUrl.endsWith("/search") ? baseUrl : `${baseUrl}/search`; + const qp = new URLSearchParams({ + q: params.query, + format: "json", + categories: params.searchType === "news" ? "news" : "general", + }); + if (params.language) qp.set("language", params.language); + if (params.timeRange && params.timeRange !== "any") qp.set("time_range", params.timeRange); + + const page = toPageNumber(params.offset, params.maxResults); + if (page) qp.set("pageno", String(page)); + + return { + url: `${url}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +// โ”€โ”€ Dispatcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const BUILDERS = { + "serper": buildSerperRequest, + "brave-search": buildBraveRequest, + "perplexity": buildPerplexityRequest, + "exa": buildExaRequest, + "tavily": buildTavilyRequest, + "google-pse": buildGooglePseRequest, + "linkup": buildLinkupRequest, + "searchapi": buildSearchApiRequest, + "youcom": buildYouComRequest, + "searxng": buildSearxngRequest, +}; + +/** + * Dispatch to the correct provider builder by `provider.id`. + * Falls back to generic POST + bearer auth for unknown providers. + * @param {SearchProviderConfig} provider + * @param {SearchRequestParams} params + * @returns {{url: string, init: RequestInit}} + */ +export function buildSearchRequest(provider, params) { + const builder = BUILDERS[provider.id]; + if (builder) return builder(provider, params); + + return { + url: resolveBaseUrl(provider, params), + init: { + method: provider.method || "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify({ + query: params.query, + max_results: params.maxResults, + search_type: params.searchType, + }), + }, + }; +} diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js new file mode 100644 index 0000000..c089b99 --- /dev/null +++ b/open-sse/handlers/search/chatSearch.js @@ -0,0 +1,409 @@ +/** + * Wrap chat-completions endpoints (with built-in web search) into the unified + * /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity. + */ + +const REQUEST_TIMEOUT_MS = 15000; +const DEFAULT_MAX_RESULTS = 10; + +/** + * Normalize a citation entry into the unified result shape. + * @param {{url:string, title?:string, snippet?:string}} c + * @param {number} index + * @param {string} provider + * @param {string} retrievedAt + */ +function toResult(c, index, provider, retrievedAt) { + return { + title: c.title || "", + url: c.url, + snippet: c.snippet || "", + position: index + 1, + score: null, + published_at: null, + favicon_url: null, + content: null, + metadata: {}, + citation: { provider, retrieved_at: retrievedAt, rank: index + 1 }, + provider_raw: null + }; +} + +/** Coerce a citation that might be a raw URL string or an object. */ +function normalizeCitation(c) { + if (!c) return null; + if (typeof c === "string") return { url: c }; + if (typeof c === "object" && c.url) return c; + return null; +} + +/** + * Provider-specific configuration map. All providers must implement: + * { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer } + */ +const CHAT_SEARCH_CONFIG = { + gemini: { + endpoint: (model) => + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, + defaultModel: "gemini-2.5-flash", + buildBody: (query) => ({ + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ google_search: {} }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + "x-goog-api-key": token + }), + extractAnswer: (data) => { + const candidate = data?.candidates?.[0]; + const parts = candidate?.content?.parts || []; + const text = parts.map((p) => p?.text || "").filter(Boolean).join(""); + const chunks = candidate?.groundingMetadata?.groundingChunks || []; + const citations = chunks + .map((ch) => ch?.web) + .filter(Boolean) + .map((w) => ({ url: w.uri || w.url, title: w.title || "" })) + .filter((c) => c.url); + const tokens = data?.usageMetadata?.totalTokenCount || 0; + return { text, citations, tokens }; + } + }, + + openai: { + endpoint: () => "https://api.openai.com/v1/chat/completions", + defaultModel: "gpt-4o-mini", + buildBody: (query, model) => { + const body = { + model, + messages: [{ role: "user", content: query }] + }; + // Non-search-preview models need explicit web_search tool + if (!/search/i.test(model)) { + body.tools = [{ type: "web_search" }]; + } + return body; + }, + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const annotations = Array.isArray(msg.annotations) ? msg.annotations : []; + const fromAnn = annotations + .map((a) => a?.url_citation) + .filter(Boolean) + .map((u) => ({ url: u.url, title: u.title || "" })); + const fromTop = Array.isArray(data?.citations) + ? data.citations.map(normalizeCitation).filter(Boolean) + : []; + const citations = fromAnn.length ? fromAnn : fromTop; + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + xai: { + endpoint: () => "https://api.x.ai/v1/responses", + defaultModel: "grok-4.20-reasoning", + buildBody: (query, model) => ({ + model, + input: [{ role: "user", content: query }], + tools: [{ type: "web_search" }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + // /v1/responses returns output[] array of message/tool blocks + const output = Array.isArray(data?.output) ? data.output : []; + let text = ""; + const citations = []; + for (const item of output) { + const parts = Array.isArray(item?.content) ? item.content : []; + for (const p of parts) { + if (typeof p?.text === "string") text += p.text; + const anns = Array.isArray(p?.annotations) ? p.annotations : []; + for (const a of anns) { + const c = normalizeCitation(a?.url ? a : a?.url_citation); + if (c) citations.push(c); + } + } + } + // Fallback: top-level citations array (some response variants) + if (!citations.length && Array.isArray(data?.citations)) { + for (const c of data.citations) { + const n = normalizeCitation(c); + if (n) citations.push(n); + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + kimi: { + endpoint: () => "https://api.moonshot.cn/v1/chat/completions", + defaultModel: "kimi-k2.5", + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }], + tools: [ + { type: "builtin_function", function: { name: "$web_search" } } + ] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + const citations = []; + for (const call of calls) { + const argStr = call?.function?.arguments; + if (!argStr) continue; + let parsed; + try { + parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; + } catch { + continue; + } + const items = + parsed?.search_results || + parsed?.results || + parsed?.references || + []; + if (Array.isArray(items)) { + for (const it of items) { + const url = it?.url || it?.link; + if (!url) continue; + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || it.summary || "" + }); + } + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + minimax: { + endpoint: () => "https://api.minimaxi.com/v1/text/chatcompletion_v2", + defaultModel: "MiniMax-M2.7", + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }], + tools: [{ type: "web_search" }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const citations = []; + const direct = Array.isArray(data?.web_search_results) + ? data.web_search_results + : []; + for (const it of direct) { + const url = it?.url || it?.link; + if (url) { + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || it.summary || "" + }); + } + } + if (!citations.length) { + const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + for (const call of calls) { + const argStr = call?.function?.arguments; + if (!argStr) continue; + let parsed; + try { + parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; + } catch { + continue; + } + const items = parsed?.results || parsed?.search_results || []; + if (Array.isArray(items)) { + for (const it of items) { + const url = it?.url || it?.link; + if (!url) continue; + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || "" + }); + } + } + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + perplexity: { + endpoint: () => "https://api.perplexity.ai/chat/completions", + defaultModel: "sonar", + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const raw = data?.citations || []; + const citations = Array.isArray(raw) + ? raw.map(normalizeCitation).filter(Boolean) + : []; + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + } +}; + +/** + * Execute a chat-search request against the chosen provider. + * @param {object} params + * @param {string} params.provider + * @param {string} params.query + * @param {number} [params.maxResults] + * @param {string} [params.model] + * @param {{apiKey?:string, accessToken?:string}} params.credentials + * @param {{info?:Function, warn?:Function, error?:Function}} [params.log] + * @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>} + */ +export async function handleChatSearch({ + provider, + query, + maxResults, + model, + credentials, + log +}) { + const startTime = Date.now(); + const cfg = CHAT_SEARCH_CONFIG[provider]; + + if (!cfg) { + return { + success: false, + status: 400, + error: `Unsupported chat-search provider: ${provider}` + }; + } + + if (!query || typeof query !== "string") { + return { success: false, status: 400, error: "Missing query" }; + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { + success: false, + status: 401, + error: "Missing credentials (apiKey or accessToken)" + }; + } + + const limit = + Number.isFinite(maxResults) && maxResults > 0 + ? Math.floor(maxResults) + : DEFAULT_MAX_RESULTS; + const useModel = model || cfg.defaultModel; + const url = cfg.endpoint(useModel); + const body = cfg.buildBody(query, useModel); + const headers = cfg.buildHeaders(token); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let upstreamStart = Date.now(); + let resp; + try { + resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal + }); + } catch (err) { + clearTimeout(timer); + if (err?.name === "AbortError") { + log?.warn?.(`[chatSearch] timeout provider=${provider}`); + return { success: false, status: 504, error: "Upstream timeout" }; + } + log?.error?.(`[chatSearch] network error provider=${provider}: ${err?.message}`); + return { + success: false, + status: 502, + error: `Network error: ${err?.message || "unknown"}` + }; + } + clearTimeout(timer); + const upstreamLatency = Date.now() - upstreamStart; + + let data; + try { + data = await resp.json(); + } catch { + return { + success: false, + status: 502, + error: `Invalid upstream response (status ${resp.status})` + }; + } + + if (!resp.ok) { + const errMsg = + data?.error?.message || + data?.error || + data?.message || + `Upstream HTTP ${resp.status}`; + log?.warn?.(`[chatSearch] upstream error provider=${provider} status=${resp.status}`); + return { + success: false, + status: resp.status, + error: typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg) + }; + } + + const { text, citations, tokens } = cfg.extractAnswer(data); + const retrievedAt = new Date().toISOString(); + const limited = (citations || []).slice(0, limit); + const results = limited.map((c, i) => toResult(c, i, provider, retrievedAt)); + + return { + success: true, + status: 200, + data: { + provider, + query, + results, + answer: { source: provider, text: text || "", model: useModel }, + usage: { queries_used: 1, search_cost_usd: 0, llm_tokens: tokens || 0 }, + metrics: { + response_time_ms: Date.now() - startTime, + upstream_latency_ms: upstreamLatency, + total_results_available: null + }, + errors: [] + } + }; +} + +export { CHAT_SEARCH_CONFIG }; diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js new file mode 100644 index 0000000..f581547 --- /dev/null +++ b/open-sse/handlers/search/index.js @@ -0,0 +1,201 @@ +/** + * Search Dispatcher โ€” routes /v1/search requests to dedicated search APIs + * or chat-based LLM search wrappers, with retry-friendly error envelope. + * + * Dependency map: + * provider.searchConfig โ†’ dedicated search API (callers + normalizers) + * provider.searchViaChat โ†’ wrap chat-completions (chatSearch.js) + */ + +import { buildSearchRequest } from "./callers.js"; +import { normalizeSearchResponse } from "./normalizers.js"; +import { handleChatSearch } from "./chatSearch.js"; + +const GLOBAL_TIMEOUT_MS = 15000; +const NON_RETRIABLE = new Set([400, 401, 403, 404]); + +const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + +/** Normalize and validate query string. */ +function sanitizeQuery(query) { + if (CONTROL_CHAR_RE.test(query)) return { error: "Query contains invalid control characters" }; + const clean = query.normalize("NFKC").trim().replace(/\s+/g, " "); + if (!clean) return { error: "Query is empty after normalization" }; + return { clean }; +} + +// Strip non-ASCII chars from header values (HTTP headers must be ByteString). +function sanitizeHeaders(headers) { + if (!headers) return headers; + const out = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + } + return out; +} + +/** Build a JSON Response wrapper used by the auth layer. */ +function jsonResponse(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }); +} + +/** Wrap an error result with a Response object so the auth wrapper can return it directly. */ +function errorResult(status, error) { + return { + success: false, + status, + error, + response: jsonResponse({ error: { message: error, code: status } }, status) + }; +} + +/** Wrap a success payload. */ +function successResult(data) { + return { success: true, data, response: jsonResponse(data, 200) }; +} + +/** + * Run a single dedicated search provider attempt. + * @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>} + */ +async function tryDedicatedProvider({ provider, providerConfig, body, credentials, log, globalStartTime }) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || undefined; + + if (providerConfig.authType !== "none" && !token) { + return { success: false, status: 401, error: `No credentials for provider: ${provider.id}` }; + } + + const params = { + query: body.query, + searchType: body.search_type || (providerConfig.searchTypes?.[0] || "web"), + maxResults: Math.min(body.max_results || providerConfig.defaultMaxResults || 5, providerConfig.maxMaxResults || 100), + token, + country: body.country, + language: body.language, + timeRange: body.time_range, + offset: body.offset, + domainFilter: body.domain_filter, + contentOptions: body.content_options, + providerOptions: body.provider_options, + providerSpecificData: credentials?.providerSpecificData + }; + + let url, init; + try { + ({ url, init } = buildSearchRequest({ id: provider.id, ...providerConfig }, params)); + } catch (err) { + return { success: false, status: 400, error: err?.message || `Invalid request for ${provider.id}` }; + } + + // Timeout = min(provider timeout, remaining global) + const remaining = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); + const timeout = Math.min(providerConfig.timeoutMs || 10000, Math.max(remaining, 1000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`); + + try { + const resp = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal }); + clearTimeout(timer); + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + log?.error?.("SEARCH", `${provider.id} ${resp.status}: ${errText.slice(0, 200)}`); + return { success: false, status: resp.status, error: `${provider.id} returned ${resp.status}: ${errText.slice(0, 200)}` }; + } + const data = await resp.json(); + const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType); + const results = normalized.results.slice(0, params.maxResults); + const duration = Date.now() - startTime; + + return { + success: true, + data: { + provider: provider.id, + query: params.query, + results, + answer: null, + usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 }, + metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults }, + errors: [] + } + }; + } catch (err) { + clearTimeout(timer); + const isTimeout = err.name === "AbortError"; + const status = isTimeout ? 504 : 502; + log?.error?.("SEARCH", `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}`); + return { success: false, status, error: `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}` }; + } +} + +/** + * Core search handler. Dispatches to dedicated API or chat-based LLM. + * Same calling convention as handleEmbeddingsCore: returns `{success, response, status?, error?}`. + * + * @param {object} options + * @param {object} options.body Sanitized body from auth wrapper + * @param {object} options.provider Provider entry from AI_PROVIDERS + * @param {object} [options.providerConfig] Provider's searchConfig (if dedicated) + * @param {object|null} options.credentials Provider credentials + * @param {object} [options.log] Logger + */ +export async function handleSearchCore({ body, provider, providerConfig, credentials, log }) { + const globalStartTime = Date.now(); + + // 1. Sanitize query + const { clean, error: sanitizeError } = sanitizeQuery(body.query || ""); + if (sanitizeError) return errorResult(400, sanitizeError); + const normalizedBody = { ...body, query: clean }; + + // 2. Route: dedicated search API takes priority over chat-based + let result; + if (providerConfig) { + result = await tryDedicatedProvider({ + provider, + providerConfig, + body: normalizedBody, + credentials, + log, + globalStartTime + }); + } else if (provider.searchViaChat) { + result = await handleChatSearch({ + provider: provider.id, + query: clean, + maxResults: normalizedBody.max_results, + model: provider.searchViaChat.defaultModel, + credentials, + log + }); + } else { + return errorResult(400, `Provider ${provider.id} does not support web search`); + } + + if (result.success) return successResult(result.data); + + // 3. Failover within global timeout for retriable errors + if ( + !NON_RETRIABLE.has(result.status || 0) && + Date.now() - globalStartTime < GLOBAL_TIMEOUT_MS && + provider.searchViaChat && + providerConfig + ) { + log?.warn?.("SEARCH", `${provider.id} dedicated failed (${result.status}), falling back to chat-based search`); + const fallback = await handleChatSearch({ + provider: provider.id, + query: clean, + maxResults: normalizedBody.max_results, + model: provider.searchViaChat.defaultModel, + credentials, + log + }); + if (fallback.success) return successResult(fallback.data); + } + + return errorResult(result.status || 502, result.error || "Search failed"); +} diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js new file mode 100644 index 0000000..da008bf --- /dev/null +++ b/open-sse/handlers/search/normalizers.js @@ -0,0 +1,223 @@ +/** + * Search Response Normalizers + * + * Ported from OmniRoute open-sse/handlers/search.ts. + * Each normalizer maps a provider-specific response into the unified SearchResult shape. + */ + +/** Build a unified SearchResult object. */ +function makeResult(providerId, item, idx, now) { + const url = item.url || ""; + return { + title: item.title || "", + url, + display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined, + snippet: item.snippet || "", + position: idx + 1, + score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null, + published_at: item.published_at || null, + favicon_url: item.favicon_url || null, + content: item.full_text + ? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length } + : null, + metadata: { + author: item.author || null, + language: null, + source_type: item.source_type || null, + image_url: item.image_url || null, + }, + citation: { provider: providerId, retrieved_at: now, rank: idx + 1 }, + provider_raw: null, + }; +} + +function normalizeSerper(data, _query, searchType) { + const now = new Date().toISOString(); + const items = searchType === "news" ? data.news : data.organic; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("serper", { title: item.title, url: item.link, snippet: item.snippet || item.description, published_at: item.date }, idx, now) + ); + const total = data.searchParameters?.totalResults; + return { results, totalResults: typeof total === "number" ? total : null }; +} + +function normalizeBrave(data, _query, searchType) { + const now = new Date().toISOString(); + const container = searchType === "news" ? data.news || data : data.web; + const items = container?.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("brave-search", { + title: item.title, + url: item.url, + snippet: item.description, + published_at: item.page_age || item.age, + favicon_url: item.meta_url?.favicon || item.favicon, + }, idx, now) + ); + return { results, totalResults: container?.totalCount ?? null }; +} + +function normalizePerplexity(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("perplexity", { title: item.title, url: item.url, snippet: item.snippet, published_at: item.date || item.last_updated }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeExa(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("exa", { + title: item.title, + url: item.url, + snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "", + score: item.score, + published_at: item.publishedDate, + favicon_url: item.favicon, + author: item.author, + image_url: item.image, + full_text: item.text, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeTavily(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("tavily", { + title: item.title, + url: item.url, + snippet: item.content || "", + score: item.score, + published_at: item.published_date, + full_text: item.raw_content, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeGooglePse(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.items) ? data.items : []; + const results = items.map((item, idx) => + makeResult("google-pse", { + title: item.title, + url: item.link, + snippet: item.snippet, + image_url: item.pagemap?.cse_image?.[0]?.src || item.pagemap?.cse_thumbnail?.[0]?.src || item.pagemap?.metatags?.[0]?.["og:image"], + }, idx, now) + ); + const raw = data.searchInformation?.totalResults ?? data.queries?.request?.[0]?.totalResults ?? null; + const total = typeof raw === "string" ? Number(raw) : raw; + return { results, totalResults: Number.isFinite(total) ? total : null }; +} + +function normalizeLinkup(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.results) ? data.results : []; + const results = items.map((item, idx) => + makeResult("linkup", { + title: item.name || item.title, + url: item.url, + snippet: item.content || item.snippet || "", + source_type: item.type || "web", + image_url: item.image_url || item.imageUrl || null, + full_text: item.content, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeSearchApi(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.organic_results) ? data.organic_results : Array.isArray(data.top_stories) ? data.top_stories : []; + const results = items.map((item, idx) => + makeResult("searchapi", { + title: item.title, + url: item.link, + snippet: item.snippet || item.description || "", + published_at: item.date || item.published_at, + favicon_url: item.favicon, + author: item.source || null, + image_url: item.thumbnail || null, + }, idx, now) + ); + const raw = data.search_information?.total_results; + const total = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : null; + return { results, totalResults: Number.isFinite(total) ? total : results.length }; +} + +function normalizeYouCom(data, _query, searchType) { + const now = new Date().toISOString(); + const container = data?.results && typeof data.results === "object" ? data.results : undefined; + const section = searchType === "news" ? container?.news || [] : container?.web || []; + const items = Array.isArray(section) ? section : []; + const results = items.map((item, idx) => { + const firstSnippet = Array.isArray(item.snippets) ? item.snippets.find((v) => typeof v === "string") : null; + const livecrawlText = typeof item.markdown === "string" ? item.markdown : typeof item.html === "string" ? item.html : undefined; + const livecrawlFormat = typeof item.markdown === "string" ? "markdown" : "html"; + return makeResult("youcom", { + title: item.title, + url: item.url, + snippet: typeof firstSnippet === "string" ? firstSnippet : typeof item.description === "string" ? item.description : "", + published_at: item.page_age, + favicon_url: item.favicon_url, + image_url: item.thumbnail_url, + source_type: searchType, + full_text: livecrawlText, + text_format: livecrawlText ? livecrawlFormat : undefined, + }, idx, now); + }); + return { results, totalResults: results.length }; +} + +function normalizeSearxng(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.results) ? data.results : []; + const results = items.map((item, idx) => + makeResult("searxng", { + title: item.title, + url: item.url, + snippet: item.content || item.snippet || "", + published_at: item.publishedDate || item.published_date || null, + source_type: Array.isArray(item.engines) ? item.engines.join(", ") : item.engine || item.category || null, + image_url: item.thumbnail || item.img_src || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +const NORMALIZERS = { + "serper": normalizeSerper, + "brave-search": normalizeBrave, + "perplexity": normalizePerplexity, + "exa": normalizeExa, + "tavily": normalizeTavily, + "google-pse": normalizeGooglePse, + "linkup": normalizeLinkup, + "searchapi": normalizeSearchApi, + "youcom": normalizeYouCom, + "searxng": normalizeSearxng, +}; + +/** + * Dispatch to the appropriate normalizer based on providerId. + * @returns {{results: Array, totalResults: number|null}} + */ +export function normalizeSearchResponse(providerId, data, query, searchType) { + const fn = NORMALIZERS[providerId]; + return fn ? fn(data, query, searchType) : { results: [], totalResults: null }; +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 040b6e0..bec36ba 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -1,5 +1,6 @@ import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; // Default token expiry buffer (refresh if expires within 5 minutes) export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; @@ -242,7 +243,7 @@ export async function refreshCodexToken(refreshToken, log) { * Specialized refresh for Kiro (AWS CodeWhisperer) tokens * Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub) */ -export async function refreshKiroToken(refreshToken, providerSpecificData, log) { +export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) { const authMethod = providerSpecificData?.authMethod; const clientId = providerSpecificData?.clientId; const clientSecret = providerSpecificData?.clientSecret; @@ -256,7 +257,7 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log) ? `https://oidc.${region}.amazonaws.com/token` : "https://oidc.us-east-1.amazonaws.com/token"; - const response = await fetch(endpoint, { + const response = await proxyAwareFetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", @@ -268,7 +269,7 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log) refreshToken: refreshToken, grantType: "refresh_token", }), - }); + }, proxyOptions); if (!response.ok) { const errorText = await response.text(); @@ -294,7 +295,7 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log) } // Social Auth (Google/GitHub) - use Kiro's refresh endpoint - const response = await fetch(PROVIDERS.kiro.tokenUrl, { + const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -304,7 +305,7 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log) body: JSON.stringify({ refreshToken: refreshToken, }), - }); + }, proxyOptions); if (!response.ok) { const errorText = await response.text(); diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 3fcb0e4..53181c1 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -3,6 +3,7 @@ */ import { CLIENT_METADATA, getPlatformUserAgent } from "../config/appConstants.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; // GitHub API config const GITHUB_CONFIG = { @@ -38,22 +39,22 @@ const CLAUDE_CONFIG = { * @param {Object} connection - Provider connection with accessToken * @returns {Object} Usage data with quotas */ -export async function getUsageForProvider(connection) { +export async function getUsageForProvider(connection, proxyOptions = null) { const { provider, accessToken, providerSpecificData } = connection; switch (provider) { case "github": - return await getGitHubUsage(accessToken, providerSpecificData); + return await getGitHubUsage(accessToken, providerSpecificData, proxyOptions); case "gemini-cli": - return await getGeminiUsage(accessToken); + return await getGeminiUsage(accessToken, proxyOptions); case "antigravity": - return await getAntigravityUsage(accessToken); + return await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions); case "claude": - return await getClaudeUsage(accessToken); + return await getClaudeUsage(accessToken, proxyOptions); case "codex": - return await getCodexUsage(accessToken); + return await getCodexUsage(accessToken, proxyOptions); case "kiro": - return await getKiroUsage(accessToken, providerSpecificData); + return await getKiroUsage(accessToken, providerSpecificData, proxyOptions); case "qwen": return await getQwenUsage(accessToken, providerSpecificData); case "iflow": @@ -103,14 +104,14 @@ function parseResetTime(resetValue) { * GitHub Copilot Usage * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API */ -async function getGitHubUsage(accessToken, providerSpecificData) { +async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) { try { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); } // copilot_internal/user API requires GitHub OAuth token, not copilotToken - const response = await fetch("https://api.github.com/copilot_internal/user", { + const response = await proxyAwareFetch("https://api.github.com/copilot_internal/user", { headers: { "Authorization": `token ${accessToken}`, "Accept": "application/json", @@ -119,7 +120,7 @@ async function getGitHubUsage(accessToken, providerSpecificData) { "Editor-Version": "vscode/1.100.0", "Editor-Plugin-Version": "copilot-chat/0.26.7", }, - }); + }, proxyOptions); if (!response.ok) { const error = await response.text(); @@ -189,18 +190,19 @@ function formatGitHubQuotaSnapshot(quota) { /** * Gemini CLI Usage (Google Cloud) */ -async function getGeminiUsage(accessToken) { +async function getGeminiUsage(accessToken, proxyOptions = null) { try { // Gemini CLI uses Google Cloud quotas // Try to get quota info from Cloud Resource Manager - const response = await fetch( + const response = await proxyAwareFetch( "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, - } + }, + proxyOptions ); if (!response.ok) { @@ -217,10 +219,10 @@ async function getGeminiUsage(accessToken) { /** * Antigravity Usage - Fetch quota from Google Cloud Code API */ -async function getAntigravityUsage(accessToken, providerSpecificData) { +async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) { try { // Fetch subscription info once โ€” reuse for both projectId and plan - const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken); + const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions); const projectId = subscriptionInfo?.cloudaicompanionProject || null; // Fetch quota data with timeout @@ -229,7 +231,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { let response; try { - response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { + response = await proxyAwareFetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, @@ -243,7 +245,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { ...(projectId ? { project: projectId } : {}) }), signal: controller.signal, - }); + }, proxyOptions); } finally { clearTimeout(timeoutId); } @@ -338,11 +340,11 @@ async function getAntigravityProjectId(accessToken) { /** * Get Antigravity subscription info */ -async function getAntigravitySubscriptionInfo(accessToken) { +async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout try { - const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { + const response = await proxyAwareFetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, @@ -352,7 +354,7 @@ async function getAntigravitySubscriptionInfo(accessToken) { }, body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), signal: controller.signal, - }); + }, proxyOptions); if (!response.ok) return null; return await response.json(); @@ -367,17 +369,17 @@ async function getAntigravitySubscriptionInfo(accessToken) { /** * Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint */ -async function getClaudeUsage(accessToken) { +async function getClaudeUsage(accessToken, proxyOptions = null) { try { // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) - const oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { + const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20", "anthropic-version": CLAUDE_CONFIG.apiVersion, }, - }); + }, proxyOptions); if (oauthResponse.ok) { const data = await oauthResponse.json(); @@ -425,7 +427,7 @@ async function getClaudeUsage(accessToken) { // Fallback: legacy settings + org usage endpoint console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`); - return await getClaudeUsageLegacy(accessToken); + return await getClaudeUsageLegacy(accessToken, proxyOptions); } catch (error) { return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; } @@ -434,21 +436,21 @@ async function getClaudeUsage(accessToken) { /** * Legacy Claude usage for API key / org admin users */ -async function getClaudeUsageLegacy(accessToken) { +async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { try { - const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { + const settingsResponse = await proxyAwareFetch(CLAUDE_CONFIG.settingsUrl, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, "anthropic-version": CLAUDE_CONFIG.apiVersion, }, - }); + }, proxyOptions); if (settingsResponse.ok) { const settings = await settingsResponse.json(); if (settings.organization_id) { - const usageResponse = await fetch( + const usageResponse = await proxyAwareFetch( CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id), { method: "GET", @@ -456,7 +458,8 @@ async function getClaudeUsageLegacy(accessToken) { "Authorization": `Bearer ${accessToken}`, "anthropic-version": CLAUDE_CONFIG.apiVersion, }, - } + }, + proxyOptions ); if (usageResponse.ok) { @@ -485,15 +488,15 @@ async function getClaudeUsageLegacy(accessToken) { /** * Codex (OpenAI) Usage - Fetch from ChatGPT backend API */ -async function getCodexUsage(accessToken) { +async function getCodexUsage(accessToken, proxyOptions = null) { try { - const response = await fetch(CODEX_CONFIG.usageUrl, { + const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, "Accept": "application/json", }, - }); + }, proxyOptions); if (!response.ok) { return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` }; @@ -577,7 +580,7 @@ function parseKiroQuotaData(data) { }; } -async function getKiroUsage(accessToken, providerSpecificData) { +async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) { // Default profileArn fallback const DEFAULT_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX"; const profileArn = providerSpecificData?.profileArn || DEFAULT_PROFILE_ARN; @@ -593,7 +596,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { const attempts = [ { name: "codewhisperer-get", - run: async () => fetch( + run: async () => proxyAwareFetch( `https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?${getUsageParams.toString()}`, { method: "GET", @@ -604,11 +607,12 @@ async function getKiroUsage(accessToken, providerSpecificData) { "user-agent": "aws-sdk-js/1.0.0 KiroIDE", }, }, + proxyOptions ), }, { name: "codewhisperer-post", - run: async () => fetch("https://codewhisperer.us-east-1.amazonaws.com", { + run: async () => proxyAwareFetch("https://codewhisperer.us-east-1.amazonaws.com", { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, @@ -621,7 +625,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { profileArn, resourceType: "AGENTIC_REQUEST", }), - }), + }, proxyOptions), }, { name: "q-get", @@ -631,13 +635,13 @@ async function getKiroUsage(accessToken, providerSpecificData) { profileArn, resourceType: "AGENTIC_REQUEST", }); - return fetch(`https://q.us-east-1.amazonaws.com/getUsageLimits?${params}`, { + return proxyAwareFetch(`https://q.us-east-1.amazonaws.com/getUsageLimits?${params}`, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, "Accept": "application/json", }, - }); + }, proxyOptions); }, }, ]; diff --git a/public/providers/azure.png b/public/providers/azure.png new file mode 100644 index 0000000000000000000000000000000000000000..9cbd38d0fc9252adfe3e90a406850b3300f715a6 GIT binary patch literal 7167 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 bU(v!%o<$;-h^^qZGJvA&dzngzY4HC6axLms literal 0 HcmV?d00001 diff --git a/public/providers/exa.png b/public/providers/exa.png new file mode 100644 index 0000000000000000000000000000000000000000..03eff2af67f372d2112da426d92dc604e2e4357b GIT binary patch literal 10724 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 Wr<)m_7XYg_5fo%pr7J&~1pW_f1{!Vv literal 0 HcmV?d00001 diff --git a/public/providers/firecrawl.png b/public/providers/firecrawl.png new file mode 100644 index 0000000000000000000000000000000000000000..623235e9e4d98b98b98f64f16f2f0dd35298e5bd GIT binary patch literal 3211 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 literal 0 HcmV?d00001 diff --git a/public/providers/google-pse.png b/public/providers/google-pse.png new file mode 100644 index 0000000000000000000000000000000000000000..465357f2340eec971dad9ee5eba60259d71ca908 GIT binary patch literal 1981 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 LDPJ#V8Tx+!Sh%H` literal 0 HcmV?d00001 diff --git a/public/providers/searchapi.png b/public/providers/searchapi.png new file mode 100644 index 0000000000000000000000000000000000000000..23630ec3efbf1d878fdf3e928afb326507f37319 GIT binary patch literal 7311 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 |DwmEOmPvrfVJ> zfX}6r`*MN~l}uu_Q8LKr(v{eCI0Ng79jVKjfp=MNa zMU=)|tz2HM_yBw*C>*Pf?;?Dgfn9vCxxYY!n-nIu2XO2};~5MldKI_Y=qNy#B9<7r zRA=FKtT@#7U4|EYny%UaO@mQJ?kp94rOK5Ee-5*tDR_PmG^YKwa6*H21;dN+1#TmLL8*Pg1J48VR9T4f zqPWNiW9Wq*Px^Xa#0x>7l6-kFmO6n8TC~9f4?ae7NcAtuqxxsqBhXlQlFk) z*I>!<+d*oCPl}z9V+Wu=uVIYbMZhx}@|pxD3|-N<0aSkpGPlCgAMH_owowrWJ}elV zK=a8^_y3s$9FK4&RvTeDr725WYCNXOj4cG%lI7K>V* zgw+h3j0x#bsJb@htJdzKLHidKk zYPS`6Xq^PJF6h72u-_tdVx#iT5T3GI`8ePr5w5~6VIwFl)&W-^as=}>{Mi;S-2qSc z!+%hc!(`ShW$Q!;JOqM-$}HduSj^l|yn&Z9UI#v35pZwTqoUx`&^#~RQkL{eIU^!c zcmU5tah#8tS>smPE(dgA^zQ^~d+miyz!y`(UAd4D4`GGT`ie!#70Y*hNNF1l=`2*` zpd-z3z{OY)=}^ijvKx3z;dZg4QCCo2mcy;U^*-ds&XL1G=!>u4Fkm*u>5WS0ARR(Z z2vSNN!ao7^^qhmI9Iy+s#Qjv2hj45Ms!_fm7AhVm570oNRgHUjt5dRAW%FL+8u!G2 zyPIkoj9KddHEZ(m2RQ+dk2u@Ew$I zA{Kbk7vDjmWW2g=#t{Hs37k1{4nwHL$f$>7X6A#iQ0)ATJfJ&meUz;gk2!~OSjwBN zm;>o$%$nAs%8LRU#L*sw#S=tq(53sav*)?|IVDI5|184)C0=^ifkH-=TM)hi?2tYL zpp*?QcSgP9Bc@KiSE(#y)6@V5h8?$mW(L75n6$53LC|8@a_^} zhvt(#MbB@`f_PZVsTS&ZG$=*vgez}oixzvX>FU86a~e2BOd9}@#Ic2l!1paz& z0u7X@01O4>FNTwV%`hAIci^z3`&9ss6~n$(m5u40&_2>N|VF|cADoU})b=Ex4vPt?|{6`63L${E0SiAutk5f-9+R^gQ( zfULsu=qMc70s4uS2Lz(1Y7u@7Ttd83@&k!Rgj+>;hkC$4-JFObpLEZL3-a1Ql=kI4 z!1pklMmgcvz!fMfHBx;?c7PJ0<$me1NO<8%l#78UlkV3ZI0N_r!Za6IO-6Qr5@SZa z#g%&iVVYQA?I}t3YY#k$aw!2#BLXl0sB1K%mb1)j$BOVh;Pj;XwFj00SBxA11Ayj* zR)jiZ>10=;Tmt-e(*4>42LfY91Ym%$e1Q-Eo$EnijVhO7i8#vv>lLnQuO{Kl3~vF< zoUP=1mv%4(vlz}Lx%<~fRjvvG?(?3cYE`|pFzp;AowAFeDu+a=XU=2rwXiL~=W*PL zx5&zoBVZ7){6Qg%ypO15P6K{I&n4;y0^5P>1n$D4`3z+v0x(EuStQg{0S-jitje{( ztr&M;d{GUcxPA6g^?p*kTH3xa_~`6a&8za!oOgQ zBa-OX)dXRmSz1SSfCSNUzvOp4p4^3YX|A=kQ>ij;5)#fJ$^x%k8maMm;Igjdtzhw zmU$>wV9`zc!mogZPQ1m3ksV-2pf*wwz2#Z>0B|{QqXt4^$!0JD;+mkN^Mx07*qoM6N<$f+H?4=l}o! literal 0 HcmV?d00001 diff --git a/public/providers/serper.png b/public/providers/serper.png new file mode 100644 index 0000000000000000000000000000000000000000..6a8063c9393c02258fdeee2eccb5d06a63b309bc GIT binary patch literal 7148 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 NKv&yPt4;$E`G3r=m#6>$ literal 0 HcmV?d00001 diff --git a/public/providers/youcom.png b/public/providers/youcom.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7d4296a0fb80c94af7e6f25e1c040ec54d2305 GIT binary patch literal 7580 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 ); diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index f0c3ab0..3cc8c56 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -1446,10 +1446,15 @@ export default function MediaProviderDetailPage() { /> )} - {/* Provider Info โ€” config-driven, only for providers with searchConfig/fetchConfig */} - {!isCustom && (provider.searchConfig || provider.fetchConfig) && ( + {/* Provider Info โ€” config-driven, supports searchConfig, fetchConfig, searchViaChat */} + {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.searchViaChat) && ( )} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js index 842c11e..9da1470 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -1,6 +1,6 @@ "use client"; -import { useParams, notFound } from "next/navigation"; +import { useParams, notFound, useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState } from "react"; import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components"; @@ -72,10 +72,18 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) { export default function MediaProviderKindPage() { const { kind } = useParams(); + const router = useRouter(); const [connections, setConnections] = useState([]); const [customNodes, setCustomNodes] = useState([]); const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false); + // webSearch/webFetch listing pages are merged into /web + useEffect(() => { + if (kind === "webSearch" || kind === "webFetch") { + router.replace("/dashboard/media-providers/web"); + } + }, [kind, router]); + const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); const isEmbedding = kind === "embedding"; diff --git a/src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js new file mode 100644 index 0000000..262c9a0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/web/combo/[id]/page.js @@ -0,0 +1,346 @@ +"use client"; + +import { useParams, notFound, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Card, Button, Input, Toggle, Modal } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers"; + +const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; + +const KIND_LABELS = { + webSearch: "Web Search", + webFetch: "Web Fetch", +}; + +const EXAMPLE_PATHS = { + webSearch: "/v1/search", + webFetch: "/v1/web/fetch", +}; + +const EXAMPLE_BODIES = { + webSearch: (comboName) => ({ model: comboName, query: "What is the latest news about AI?", search_type: "web", max_results: 5 }), + webFetch: (comboName) => ({ model: comboName, url: "https://example.com", format: "markdown" }), +}; + +function ProviderPickerModal({ isOpen, onClose, onPick, kind, currentIds, connections }) { + // Only show providers with at least one usable connection (active/success) or noAuth + const usableIds = new Set( + (connections || []) + .filter((c) => { + if (c.isActive === false) return false; + const s = c.testStatus; + return s === "active" || s === "success" || s === "unavailable"; + }) + .map((c) => c.provider) + ); + const all = kind ? getProvidersByKind(kind) : []; + const providers = all.filter((p) => p.noAuth || usableIds.has(p.id)); + return ( + + {providers.length === 0 ? ( +
+ No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section. +
+ ) : ( +
+ {providers.map((p) => { + const already = currentIds.includes(p.id); + return ( + + ); + })} +
+ )} +
+ ); +} + +export default function ComboDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const [combo, setCombo] = useState(null); + const [loading, setLoading] = useState(true); + const [name, setName] = useState(""); + const [nameError, setNameError] = useState(""); + const [providers, setProviders] = useState([]); + const [roundRobin, setRoundRobin] = useState(false); + const [showPicker, setShowPicker] = useState(false); + const [logs, setLogs] = useState([]); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [connections, setConnections] = useState([]); + + const fetchAll = async () => { + try { + const [comboRes, settingsRes, logsRes, keysRes, connsRes] = await Promise.all([ + fetch(`/api/combos/${id}`, { cache: "no-store" }), + fetch("/api/settings", { cache: "no-store" }), + fetch("/api/usage/logs", { cache: "no-store" }), + fetch("/api/keys", { cache: "no-store" }), + fetch("/api/providers", { cache: "no-store" }), + ]); + if (keysRes.ok) { + const k = await keysRes.json(); + setApiKey((k.keys || []).find((x) => x.isActive !== false)?.key || ""); + } + if (connsRes.ok) setConnections((await connsRes.json()).connections || []); + if (!comboRes.ok) { setCombo(null); setLoading(false); return; } + const c = await comboRes.json(); + setCombo(c); + setName(c.name); + setProviders(c.models || []); + const s = settingsRes.ok ? await settingsRes.json() : {}; + setRoundRobin(s.comboStrategies?.[c.name]?.fallbackStrategy === "round-robin"); + const allLogs = logsRes.ok ? await logsRes.json() : []; + setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50)); + } catch { /* noop */ } + setLoading(false); + }; + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { fetchAll(); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + + const validateName = (v) => { + if (!v.trim()) { setNameError("Name is required"); return false; } + if (!VALID_NAME_REGEX.test(v)) { setNameError("Only letters, numbers, -, _ and ."); return false; } + setNameError(""); + return true; + }; + + const saveCombo = async (patch) => { + const res = await fetch(`/api/combos/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { const err = await res.json(); alert(err.error || "Failed to save"); return false; } + return true; + }; + + const handleSaveName = async () => { + if (!validateName(name)) return; + if (name === combo.name) return; + const ok = await saveCombo({ name }); + if (ok) await fetchAll(); + }; + + const handleAddProvider = async (providerId) => { + const next = [...providers, providerId]; + setProviders(next); + await saveCombo({ models: next }); + }; + + const handleRemoveProvider = async (idx) => { + const next = providers.filter((_, i) => i !== idx); + setProviders(next); + await saveCombo({ models: next }); + }; + + const handleMove = async (idx, dir) => { + const next = [...providers]; + const swap = idx + dir; + if (swap < 0 || swap >= next.length) return; + [next[idx], next[swap]] = [next[swap], next[idx]]; + setProviders(next); + await saveCombo({ models: next }); + }; + + const handleToggleRoundRobin = async (enabled) => { + setRoundRobin(enabled); + const settingsRes = await fetch("/api/settings", { cache: "no-store" }); + const s = settingsRes.ok ? await settingsRes.json() : {}; + const updated = { ...(s.comboStrategies || {}) }; + if (enabled) updated[combo.name] = { fallbackStrategy: "round-robin" }; + else delete updated[combo.name]; + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStrategies: updated }), + }); + }; + + const handleDelete = async () => { + if (!confirm(`Delete combo "${combo.name}"?`)) return; + const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); + if (res.ok) router.push("/dashboard/media-providers/web"); + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(""); + try { + const path = EXAMPLE_PATHS[combo.kind]; + const body = EXAMPLE_BODIES[combo.kind](combo.name); + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`/api${path}`, { method: "POST", headers, body: JSON.stringify(body) }); + const data = await res.json().catch(() => ({})); + setTestResult(JSON.stringify(data, null, 2)); + } catch (e) { + setTestResult(`Error: ${e.message}`); + } + setTesting(false); + }; + + if (loading) return
Loading...
; + if (!combo) return notFound(); + + const kindLabel = KIND_LABELS[combo.kind] || "Web"; + const examplePath = EXAMPLE_PATHS[combo.kind]; + const exampleBody = combo.kind ? EXAMPLE_BODIES[combo.kind](combo.name) : null; + const curlExample = examplePath + ? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'` + : ""; + + return ( +
+ {/* Header */} +
+
+ + arrow_back + +
+ layers +
+
+

{kindLabel} Combo

+ {combo.name} +
+
+ +
+ + {/* Settings Card */} + +

Settings

+
+
+ { setName(e.target.value); validateName(e.target.value); }} onBlur={handleSaveName} error={nameError} /> +

Only letters, numbers, -, _ and .

+
+
+
+

Round Robin

+

Rotate providers across requests instead of strict fallback order.

+
+ +
+
+
+ + {/* Providers Card */} + +
+
+

Providers

+

Tried in order (top-down) or rotated when round-robin is on.

+
+ +
+ {providers.length === 0 ? ( +
+ No providers yet. +
+ ) : ( +
+ {providers.map((pid, idx) => { + const p = AI_PROVIDERS[pid]; + return ( +
+ {idx + 1} + + {p?.name || pid} +
+ + + +
+
+ ); + })} +
+ )} +
+ + {/* Test Example Card */} + {combo.kind && ( + +
+

Test Example

+ +
+
+            {curlExample}
+          
+ {testResult && ( +
+              {testResult}
+            
+ )} +
+ )} + + {/* Usage Logs Card */} + +

Usage Logs

+ {logs.length === 0 ? ( +

No usage yet.

+ ) : ( +
+            {logs.join("\n")}
+          
+ )} +
+ + setShowPicker(false)} + onPick={handleAddProvider} + kind={combo.kind} + currentIds={providers} + connections={connections} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/web/page.js b/src/app/(dashboard)/dashboard/media-providers/web/page.js new file mode 100644 index 0000000..5e5fc64 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/web/page.js @@ -0,0 +1,208 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Card, Badge, Button } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers"; + +function getEffectiveStatus(conn) { + const isCooldown = Object.entries(conn).some( + ([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now() + ); + return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; +} + +function ProviderCard({ provider, kind, connections }) { + const providerInfo = AI_PROVIDERS[provider.id]; + const isNoAuth = !!providerInfo?.noAuth; + const providerConns = connections.filter((c) => c.provider === provider.id); + const connected = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "active" || s === "success"; }).length; + const error = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "error" || s === "expired" || s === "unavailable"; }).length; + const total = providerConns.length; + const allDisabled = total > 0 && providerConns.every((c) => c.isActive === false); + + const renderStatus = () => { + if (isNoAuth) return Ready; + if (allDisabled) return Disabled; + if (total === 0) return No connections; + return ( + <> + {connected > 0 && {connected} Connected} + {error > 0 && {error} Error} + {connected === 0 && error === 0 && {total} Added} + + ); + }; + + return ( + + +
+
7 ? provider.color : (provider.color ?? "#888") + "15"}` }} + > + +
+
+

{provider.name}

+
{renderStatus()}
+
+
+
+ + ); +} + +function ComboList({ combos }) { + if (combos.length === 0) { + return

No combos yet.

; + } + return ( +
+ {combos.map((combo) => ( + + +
+ layers + {combo.name} + {/* Provider icons preview */} +
+ {combo.models.slice(0, 6).map((pid, i) => { + const p = AI_PROVIDERS[pid]; + return ( +
+ +
+ ); + })} + {combo.models.length > 6 && ( + +{combo.models.length - 6} + )} +
+ {combo.models.length} + chevron_right +
+
+ + ))} +
+ ); +} + +function Section({ title, icon, kind, providers, connections, combos, onCreateCombo }) { + return ( +
+ {/* Header โ€” title left, Create Combo right */} +
+
+ {icon} +

{title}

+ ({providers.length} providers ยท {combos.length} combos) +
+ +
+ + {/* Combos โ€” top */} + {combos.length > 0 && ( +
+ +
+ )} + + {/* Providers grid โ€” bottom */} + {providers.length === 0 ? ( +
+ No providers. +
+ ) : ( +
+ {providers.map((p) => ( + + ))} +
+ )} +
+ ); +} + +export default function WebProvidersPage() { + const router = useRouter(); + const [connections, setConnections] = useState([]); + const [combos, setCombos] = useState([]); + + const fetchAll = async () => { + try { + const [connsRes, combosRes] = await Promise.all([ + fetch("/api/providers", { cache: "no-store" }), + fetch("/api/combos", { cache: "no-store" }), + ]); + if (connsRes.ok) setConnections((await connsRes.json()).connections || []); + if (combosRes.ok) setCombos((await combosRes.json()).combos || []); + } catch { /* noop */ } + }; + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { fetchAll(); }, []); + + const searchProviders = getProvidersByKind("webSearch"); + const fetchProviders = getProvidersByKind("webFetch"); + const searchCombos = combos.filter((c) => c.kind === "webSearch"); + const fetchCombos = combos.filter((c) => c.kind === "webFetch"); + + const handleCreateCombo = async (kind) => { + // Generate unique default name + const base = kind === "webSearch" ? "search-combo" : "fetch-combo"; + let name = base; + let i = 1; + const existing = new Set(combos.map((c) => c.name)); + while (existing.has(name)) { name = `${base}-${i++}`; } + const res = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, models: [], kind }), + }); + if (res.ok) { + const created = await res.json(); + router.push(`/dashboard/media-providers/web/combo/${created.id}`); + } else { + const err = await res.json(); + alert(err.error || "Failed to create combo"); + } + }; + + return ( +
+
handleCreateCombo("webSearch")} + /> + + {/* Divider between sections */} +
+ +
handleCreateCombo("webFetch")} + /> +
+ ); +} diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js index 6b7ca24..db9f02d 100644 --- a/src/app/api/combos/route.js +++ b/src/app/api/combos/route.js @@ -21,7 +21,7 @@ export async function GET() { export async function POST(request) { try { const body = await request.json(); - const { name, models } = body; + const { name, models, kind } = body; if (!name) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); @@ -38,7 +38,7 @@ export async function POST(request) { return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); } - const combo = await createCombo({ name, models: models || [] }); + const combo = await createCombo({ name, models: models || [], kind: kind || null }); return NextResponse.json(combo, { status: 201 }); } catch (error) { diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 4d3a890..db9cbb5 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -1,17 +1,53 @@ import { NextResponse } from "next/server"; import { getProviderNodeById } from "@/models"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; import { PROVIDER_ENDPOINTS } from "@/shared/constants/config"; +// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig. +// Returns true if API key is accepted (status !== 401 && !== 403). +async function probeWebProvider(provider, apiKey) { + const p = AI_PROVIDERS[provider]; + if (!p) return null; + // Skip if provider has dual-purpose (LLM + search), let LLM validate handle it + const kinds = p.serviceKinds || ["llm"]; + const isWebOnly = kinds.every((k) => k === "webSearch" || k === "webFetch"); + if (!isWebOnly) return null; + const cfg = p.searchConfig || p.fetchConfig; + if (!cfg) return null; + if (cfg.authType === "none") return true; // no-auth (e.g. searxng) + + let url = cfg.baseUrl; + const headers = { "Content-Type": "application/json" }; + let body; + + // Apply auth based on authHeader + switch (cfg.authHeader) { + case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; break; + case "x-api-key": headers["x-api-key"] = apiKey; break; + case "x-subscription-token":headers["x-subscription-token"] = apiKey; break; + case "key": url += `?key=${encodeURIComponent(apiKey)}&q=ping&cx=test`; break; // google-pse + case "api_key": url += `?api_key=${encodeURIComponent(apiKey)}&q=ping&engine=google`; break; // searchapi + } + + // Minimal body for POST endpoints; GET sends nothing + if (cfg.method === "POST") { + body = JSON.stringify({ query: "ping", q: "ping", url: "https://example.com" }); + } + + const res = await fetch(url, { method: cfg.method, headers, body, signal: AbortSignal.timeout(8000) }); + return res.status !== 401 && res.status !== 403; +} + // POST /api/providers/validate - Validate API key with provider export async function POST(request) { try { const body = await request.json(); const { provider, apiKey, providerSpecificData } = body; - if (!provider || (!apiKey && provider !== "ollama-local")) { + const isNoAuth = AI_PROVIDERS[provider]?.noAuth === true; + if (!provider || (!apiKey && provider !== "ollama-local" && !isNoAuth)) { return NextResponse.json({ error: "Provider and API key required" }, { status: 400 }); } @@ -147,6 +183,15 @@ export async function POST(request) { }); } + // Generic probe for webSearch/webFetch providers (config-driven) + const webResult = await probeWebProvider(provider, apiKey); + if (webResult !== null) { + return NextResponse.json({ + valid: webResult, + error: webResult ? null : "Invalid API key", + }); + } + switch (provider) { case "openai": const openaiRes = await fetch("https://api.openai.com/v1/models", { @@ -294,7 +339,12 @@ export async function POST(request) { const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(endpoints[provider], { headers }); - isValid = res.ok; + // xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401. + if (provider === "xai") { + isValid = res.status === 200 || res.status === 403; + } else { + isValid = res.ok; + } break; } diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index dd749f3..95762ec 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -4,6 +4,7 @@ import "open-sse/index.js"; import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; import { getUsageForProvider } from "open-sse/services/usage.js"; import { getExecutor } from "open-sse/executors/index.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; // Detect auth-expired messages returned by usage providers instead of throwing const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"]; @@ -18,7 +19,7 @@ function isAuthExpiredMessage(usage) { * @param {boolean} force - Skip needsRefresh check and always attempt refresh * @returns Promise<{ connection, refreshed: boolean }> */ -async function refreshAndUpdateCredentials(connection, force = false) { +async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) { const executor = getExecutor(connection.provider); // Build credentials object from connection @@ -39,8 +40,8 @@ async function refreshAndUpdateCredentials(connection, force = false) { return { connection, refreshed: false }; } - // Use executor's refreshCredentials method - const refreshResult = await executor.refreshCredentials(credentials, console); + // Use executor's refreshCredentials method (with optional proxy) + const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions); if (!refreshResult) { // Refresh failed but we still have an accessToken โ€” try with existing token @@ -117,9 +118,19 @@ export async function GET(request, { params }) { return Response.json({ message: "Usage not available for API key connections" }); } + // Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; + // Refresh credentials if needed using executor try { - const result = await refreshAndUpdateCredentials(connection); + const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); connection = result.connection; } catch (refreshError) { console.error("[Usage API] Credential refresh failed:", refreshError); @@ -129,15 +140,15 @@ export async function GET(request, { params }) { } // Fetch usage from provider API - let usage = await getUsageForProvider(connection); + let usage = await getUsageForProvider(connection, proxyOptions); // If provider returned an auth-expired message instead of throwing, // force-refresh token and retry once if (isAuthExpiredMessage(usage) && connection.refreshToken) { try { - const retryResult = await refreshAndUpdateCredentials(connection, true); + const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions); connection = retryResult.connection; - usage = await getUsageForProvider(connection); + usage = await getUsageForProvider(connection, proxyOptions); } catch (retryError) { console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`); } diff --git a/src/app/api/v1/search/route.js b/src/app/api/v1/search/route.js new file mode 100644 index 0000000..279b46a --- /dev/null +++ b/src/app/api/v1/search/route.js @@ -0,0 +1,21 @@ +import { handleSearch } from "@/sse/handlers/search.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1/search - Web search endpoint + */ +export async function POST(request) { + return await handleSearch(request); +} diff --git a/src/app/api/v1/web/fetch/route.js b/src/app/api/v1/web/fetch/route.js new file mode 100644 index 0000000..332600e --- /dev/null +++ b/src/app/api/v1/web/fetch/route.js @@ -0,0 +1,21 @@ +import { handleFetch } from "@/sse/handlers/fetch.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1/web/fetch - Web URL fetch/extract endpoint + */ +export async function POST(request) { + return await handleFetch(request); +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 7387ee3..1e90d6b 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -582,6 +582,7 @@ export async function createCombo(data) { id: uuidv4(), name: data.name, models: data.models || [], + kind: data.kind || null, createdAt: now, updatedAt: now, }; diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 258f121..a50e91d 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -4,7 +4,7 @@ import { useState, useMemo, useEffect } from "react"; import PropTypes from "prop-types"; import Modal from "./Modal"; import { getModelsByProviderId } from "@/shared/constants/models"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers"; // Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ @@ -25,7 +25,17 @@ export default function ModelSelectModal({ activeProviders = [], title = "Select Model", modelAliases = {}, + kindFilter = null, }) { + // Filter activeProviders by serviceKinds when kindFilter set (e.g. "webSearch", "webFetch") + const filteredActiveProviders = useMemo(() => { + if (!kindFilter) return activeProviders; + return activeProviders.filter((p) => { + const info = AI_PROVIDERS[p.provider]; + const kinds = info?.serviceKinds || ["llm"]; + return kinds.includes(kindFilter); + }); + }, [activeProviders, kindFilter]); const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); @@ -85,13 +95,18 @@ export default function ModelSelectModal({ const groupedModels = useMemo(() => { const groups = {}; - // Get all active provider IDs from connections - const activeConnectionIds = activeProviders.map(p => p.provider); + // Get all active provider IDs from connections (filtered by kindFilter if set) + const activeConnectionIds = filteredActiveProviders.map(p => p.provider); + + // No-auth providers: filter by kindFilter as well + const noAuthIds = kindFilter + ? NO_AUTH_PROVIDER_IDS.filter((id) => (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter)) + : NO_AUTH_PROVIDER_IDS; // Only show connected providers (including both standard and custom) const providerIdsToShow = new Set([ ...activeConnectionIds, // Only connected providers - ...NO_AUTH_PROVIDER_IDS, // No-auth providers always visible + ...noAuthIds, // No-auth providers (kind-filtered) ]); // Sort by PROVIDER_ORDER @@ -203,14 +218,15 @@ export default function ModelSelectModal({ }); return groups; - }, [activeProviders, modelAliases, allProviders, providerNodes, customModels]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, kindFilter]); - // Filter combos by search query + // Filter combos by search query (and hide combos when kindFilter is set โ€” combos are LLM-only by design) const filteredCombos = useMemo(() => { + if (kindFilter) return []; if (!searchQuery.trim()) return combos; const query = searchQuery.toLowerCase(); return combos.filter(c => c.name.toLowerCase().includes(query)); - }, [combos, searchQuery]); + }, [combos, searchQuery, kindFilter]); // Filter models by search query const filteredGroups = useMemo(() => { @@ -384,5 +400,6 @@ ModelSelectModal.propTypes = { ), title: PropTypes.string, modelAliases: PropTypes.object, + kindFilter: PropTypes.string, }; diff --git a/src/shared/components/ProviderInfoCard.js b/src/shared/components/ProviderInfoCard.js index a4588a1..6150fdd 100644 --- a/src/shared/components/ProviderInfoCard.js +++ b/src/shared/components/ProviderInfoCard.js @@ -2,24 +2,20 @@ import Card from "./Card"; -// Field schema โ€” config-driven, used for both searchConfig and fetchConfig +// Only show fields user actually cares about const FIELD_SCHEMA = { + mode: { label: "Mode", format: (v) => v }, + defaultModel: { label: "Model", format: (v) => v, mono: true }, baseUrl: { label: "Endpoint", format: (v) => v, isLink: true, mono: true }, - method: { label: "Method", format: (v) => v }, - authType: { label: "Auth", format: (v) => v }, - authHeader: { label: "Auth Header", format: (v) => v, mono: true }, costPerQuery: { label: "Cost / call", format: (v) => v === 0 ? "Free" : `$${v.toFixed(4)}` }, freeMonthlyQuota: { label: "Free quota", format: (v) => v === 0 ? "โ€”" : v >= 999999 ? "Unlimited" : `${v.toLocaleString()} / mo` }, searchTypes: { label: "Types", format: (v) => v.join(", ") }, formats: { label: "Formats", format: (v) => v.join(", ") }, - defaultMaxResults: { label: "Default results", format: (v) => v }, maxMaxResults: { label: "Max results", format: (v) => v }, maxCharacters: { label: "Max chars", format: (v) => v.toLocaleString() }, - timeoutMs: { label: "Timeout", format: (v) => `${v / 1000}s` }, - cacheTTLMs: { label: "Cache TTL", format: (v) => `${v / 60000}m` }, }; -export default function ProviderInfoCard({ config, title = "Provider Info" }) { +export default function ProviderInfoCard({ config, provider, title = "Provider Info" }) { if (!config) return null; const rows = Object.entries(FIELD_SCHEMA) @@ -33,9 +29,24 @@ export default function ProviderInfoCard({ config, title = "Provider Info" }) { raw: config[key], })); + const signupUrl = provider?.notice?.apiKeyUrl || provider?.website; + return ( -

{title}

+
{rows.map((r) => (
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index b44db86..1f2b8dc 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -12,7 +12,9 @@ import Button from "./Button"; import { ConfirmModal } from "./Modal"; // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; -const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "webSearch", "webFetch"]; +const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts"]; +// Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web +const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" }; const navItems = [ { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, @@ -234,6 +236,20 @@ export default function Sidebar({ onClose }) { {kind.label} ))} + + {COMBINED_WEB_ITEM.icon} + {COMBINED_WEB_ITEM.label} +
)} diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index ba674c8..54f0bf5 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -18,7 +18,7 @@ export const FREE_TIER_PROVIDERS = { nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } }, ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo ยท Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } }, vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://generativelanguage.googleapis.com", method: "POST", authType: "apikey", authHeader: "x-goog-api-key", costPerQuery: 0, freeMonthlyQuota: 1500, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "gemini-2.5-flash" } }, byteplus: { id: "byteplus", alias: "bpm", name: "BytePlus ModelArk", icon: "cloud", color: "#2563EB", textIcon: "BP", website: "https://console.byteplus.com/ark", notice: { text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.", apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey" }, serviceKinds: ["llm"] }, }; @@ -55,20 +55,20 @@ export const OAUTH_PROVIDERS = { export const APIKEY_PROVIDERS = { glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" }, "glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM (China)", icon: "code", color: "#DC2626", textIcon: "GC", website: "https://open.bigmodel.cn" }, - kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.moonshot.cn", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } }, - minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://api.minimaxi.com", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } }, + kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"], searchViaChat: { defaultModel: "kimi-k2.5" } }, + minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "MiniMax-M2.7" } }, "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" }, alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com" }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchConfig: { baseUrl: "https://api.openai.com", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.025, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini" } }, anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm", "imageToText"] }, "opencode-go": { id: "opencode-go", alias: "ocg", name: "OpenCode Go", icon: "terminal", color: "#E87040", textIcon: "OC", website: "https://opencode.ai/auth", notice: { text: "OpenCode Go subscription: $5/mo (then $10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth" } }, azure: { id: "azure", alias: "azure", name: "Azure OpenAI", icon: "cloud", color: "#0078D4", textIcon: "AZ", website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service", hasProviderSpecificData: true }, deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", serviceKinds: ["llm", "imageToText"] }, - xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://api.x.ai", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.025, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 30, timeoutMs: 15000, cacheTTLMs: 300000 } }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning" } }, mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", serviceKinds: ["llm", "imageToText"] }, perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" }, @@ -94,17 +94,17 @@ export const APIKEY_PROVIDERS = { chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" }, "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, "vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" }, - tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } }, - "brave-search": { id: "brave-search", alias: "brave", name: "Brave Search", icon: "travel_explore", color: "#FB542B", textIcon: "BR", website: "https://brave.com/search/api", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.search.brave.com/res/v1", method: "GET", authType: "apikey", authHeader: "x-subscription-token", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, - serper: { id: "serper", alias: "serper", name: "Serper", icon: "search", color: "#4F46E5", textIcon: "SP", website: "https://serper.dev", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://google.serper.dev", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 2500, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, - exa: { id: "exa", alias: "exa", name: "Exa", icon: "manage_search", color: "#2563EB", textIcon: "EX", website: "https://exa.ai", serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.exa.ai/search", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.007, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.exa.ai/contents", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 1000, formats: ["text", "markdown"], maxCharacters: 100000, timeoutMs: 15000 } }, + tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", notice: { apiKeyUrl: "https://app.tavily.com/home" }, serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } }, + "brave-search": { id: "brave-search", alias: "brave", name: "Brave Search", icon: "travel_explore", color: "#FB542B", textIcon: "BR", website: "https://brave.com/search/api", notice: { apiKeyUrl: "https://api-dashboard.search.brave.com/app/keys" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.search.brave.com/res/v1", method: "GET", authType: "apikey", authHeader: "x-subscription-token", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, + serper: { id: "serper", alias: "serper", name: "Serper", icon: "search", color: "#4F46E5", textIcon: "SP", website: "https://serper.dev", notice: { apiKeyUrl: "https://serper.dev/api-key" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://google.serper.dev", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 2500, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, + exa: { id: "exa", alias: "exa", name: "Exa", icon: "manage_search", color: "#2563EB", textIcon: "EX", website: "https://exa.ai", notice: { apiKeyUrl: "https://dashboard.exa.ai/api-keys" }, serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.exa.ai/search", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.007, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.exa.ai/contents", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 1000, formats: ["text", "markdown"], maxCharacters: 100000, timeoutMs: 15000 } }, searxng: { id: "searxng", alias: "searxng", name: "SearXNG", icon: "saved_search", color: "#3B82F6", textIcon: "SX", website: "https://docs.searxng.org", serviceKinds: ["webSearch"], noAuth: true, searchConfig: { baseUrl: "http://localhost:8888/search", method: "GET", authType: "none", authHeader: "none", costPerQuery: 0, freeMonthlyQuota: 999999, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10000, cacheTTLMs: 180000 } }, - "google-pse": { id: "google-pse", alias: "gpse", name: "Google PSE", icon: "search", color: "#4285F4", textIcon: "GP", website: "https://programmablesearchengine.google.com", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.googleapis.com/customsearch/v1", method: "GET", authType: "apikey", authHeader: "key", costPerQuery: 0.005, freeMonthlyQuota: 3000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 10000, cacheTTLMs: 300000 } }, - linkup: { id: "linkup", alias: "linkup", name: "Linkup", icon: "link", color: "#0EA5E9", textIcon: "LK", website: "https://linkup.so", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.linkup.so/v1/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10000, cacheTTLMs: 300000 } }, - searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, - youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, - firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } }, - "jina-reader": { id: "jina-reader", alias: "jina", name: "Jina Reader", icon: "menu_book", color: "#000000", textIcon: "JR", website: "https://jina.ai/reader", serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://r.jina.ai", method: "GET", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 1000000, formats: ["markdown", "text", "html"], maxCharacters: 200000, timeoutMs: 30000 } }, + "google-pse": { id: "google-pse", alias: "gpse", name: "Google PSE", icon: "search", color: "#4285F4", textIcon: "GP", website: "https://programmablesearchengine.google.com", notice: { apiKeyUrl: "https://programmablesearchengine.google.com/controlpanel/create" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.googleapis.com/customsearch/v1", method: "GET", authType: "apikey", authHeader: "key", costPerQuery: 0.005, freeMonthlyQuota: 3000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 10000, cacheTTLMs: 300000 } }, + linkup: { id: "linkup", alias: "linkup", name: "Linkup", icon: "link", color: "#0EA5E9", textIcon: "LK", website: "https://linkup.so", notice: { apiKeyUrl: "https://app.linkup.so/api-keys" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.linkup.so/v1/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10000, cacheTTLMs: 300000 } }, + searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", notice: { apiKeyUrl: "https://www.searchapi.io/dashboard" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, + youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", notice: { apiKeyUrl: "https://api.you.com" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } }, + firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", notice: { apiKeyUrl: "https://www.firecrawl.dev/app/api-keys" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } }, + "jina-reader": { id: "jina-reader", alias: "jina", name: "Jina Reader", icon: "menu_book", color: "#000000", textIcon: "JR", website: "https://jina.ai/reader", notice: { apiKeyUrl: "https://jina.ai/?sui=apikey" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://r.jina.ai", method: "GET", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 1000000, formats: ["markdown", "text", "html"], maxCharacters: 200000, timeoutMs: 30000 } }, }; // Web Cookie Providers (use browser session cookie instead of API key) diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js new file mode 100644 index 0000000..543eb58 --- /dev/null +++ b/src/sse/handlers/fetch.js @@ -0,0 +1,211 @@ +import { + getProviderCredentials, + markAccountUnavailable, + clearAccountError, + extractApiKey, + isValidApiKey, +} from "../services/auth.js"; +import { getSettings, getCombos } from "@/lib/localDb"; +import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js"; +import { handleFetchCore } from "open-sse/handlers/fetch/index.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; +import * as log from "../utils/logger.js"; +import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; +import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js"; + +/** + * Handle web fetch (URL extraction) request for the SSE/Next.js server. + * Provider IS the model. Mirrors handleEmbeddings auth + fallback flow. + * + * @param {Request} request + */ +export async function handleFetch(request) { + let body; + try { + body = await request.json(); + } catch { + log.warn("FETCH", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const reqUrl = new URL(request.url); + // Accept either `provider` or `model` (UI sends `model` since provider IS the model for webFetch) + const providerInput = body.provider || body.model; + const targetUrl = body.url; + const format = body.format; + const maxCharacters = body.max_characters; + + log.request("POST", `${reqUrl.pathname} | ${providerInput}`); + + // Log API key (masked) + const apiKey = extractApiKey(request); + if (apiKey) { + log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); + } else { + log.debug("AUTH", "No API key provided (local mode)"); + } + + // Enforce API key if enabled in settings + const settings = await getSettings(); + if (settings.requireApiKey) { + if (!apiKey) { + log.warn("AUTH", "Missing API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + log.warn("AUTH", "Invalid API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!providerInput || typeof providerInput !== "string") { + log.warn("FETCH", "Missing provider/model"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)"); + } + + if (!targetUrl || typeof targetUrl !== "string") { + log.warn("FETCH", "Missing url"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: url"); + } + + // Validate URL format + try { + new URL(targetUrl); + } catch { + log.warn("FETCH", "Invalid URL", { url: targetUrl }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format"); + } + + // Combo expansion: providerInput may be a combo name โ†’ run fallback/round-robin across providers + const combos = await getCombos(); + const comboModels = getComboModelsFromData(providerInput, combos); + if (comboModels) { + const comboStrategies = settings.comboStrategies || {}; + const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback"; + log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy})`); + return handleComboChat({ + body, + models: comboModels, + handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings), + log, + comboName: providerInput, + comboStrategy + }); + } + + return handleSingleProviderFetch(body, providerInput, request, apiKey, settings); +} + +async function handleSingleProviderFetch(body, providerInput, request, apiKey, settings) { + const targetUrl = body.url; + const format = body.format; + const maxCharacters = body.max_characters; + const providerId = resolveProviderId(providerInput); + const resolvedProvider = AI_PROVIDERS[providerId]; + + if (!resolvedProvider) { + log.warn("FETCH", "Unknown provider", { provider: providerInput }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`); + } + + const providerConfig = resolvedProvider.fetchConfig; + if (!providerConfig) { + log.warn("FETCH", "Provider does not support web fetch", { provider: providerId }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web fetch`); + } + + if (providerInput !== providerId) { + log.info("ROUTING", `${providerInput} โ†’ ${providerId}`); + } else { + log.info("ROUTING", `Provider: ${providerId}`); + } + + // No-auth fetch path (kept for parity though no current fetch provider sets noAuth) + if (resolvedProvider.noAuth) { + log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`); + const result = await handleFetchCore({ + url: targetUrl, + format, + maxCharacters, + provider: resolvedProvider.id, + providerConfig, + credentials: null, + log + }); + if (result.success) { + return new Response(JSON.stringify(result.data), { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }); + } + return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Fetch failed"); + } + + // Credential + fallback loop + const excludeConnectionIds = new Set(); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials(providerId, excludeConnectionIds); + + if (!credentials || credentials.allRateLimited) { + if (credentials?.allRateLimited) { + const errorMsg = lastError || credentials.lastError || "Unavailable"; + const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + log.warn("FETCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`); + return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); + } + if (excludeConnectionIds.size === 0) { + log.error("AUTH", `No credentials for provider: ${providerId}`); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`); + } + log.warn("FETCH", "No more accounts available", { provider: providerId }); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + } + + log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`); + + const refreshedCredentials = await checkAndRefreshToken(providerId, credentials); + + const result = await handleFetchCore({ + url: targetUrl, + format, + maxCharacters, + provider: resolvedProvider.id, + providerConfig, + credentials: refreshedCredentials, + log, + onCredentialsRefreshed: async (newCreds) => { + await updateProviderCredentials(credentials.connectionId, { + accessToken: newCreds.accessToken, + refreshToken: newCreds.refreshToken, + providerSpecificData: newCreds.providerSpecificData, + testStatus: "active" + }); + }, + onRequestSuccess: async () => { + await clearAccountError(credentials.connectionId, credentials); + } + }); + + if (result.success) { + return new Response(JSON.stringify(result.data), { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }); + } + + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId); + + if (shouldFallback) { + log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); + excludeConnectionIds.add(credentials.connectionId); + lastError = result.error; + lastStatus = result.status; + continue; + } + + return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Fetch failed"); + } +} diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js new file mode 100644 index 0000000..2d06281 --- /dev/null +++ b/src/sse/handlers/search.js @@ -0,0 +1,204 @@ +import { + getProviderCredentials, + markAccountUnavailable, + clearAccountError, + extractApiKey, + isValidApiKey, +} from "../services/auth.js"; +import { getSettings, getCombos } from "@/lib/localDb"; +import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js"; +import { handleSearchCore } from "open-sse/handlers/search/index.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; +import * as log from "../utils/logger.js"; +import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; +import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js"; + +/** + * Handle web search request for the SSE/Next.js server. + * Provider IS the model (no model field). Mirrors handleEmbeddings auth + fallback flow. + * + * @param {Request} request + */ +export async function handleSearch(request) { + let body; + try { + body = await request.json(); + } catch { + log.warn("SEARCH", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const url = new URL(request.url); + // Accept either `provider` or `model` (UI sends `model` since provider IS the model for webSearch) + const providerInput = body.provider || body.model; + const query = body.query; + + log.request("POST", `${url.pathname} | ${providerInput}`); + + // Log API key (masked) + const apiKey = extractApiKey(request); + if (apiKey) { + log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); + } else { + log.debug("AUTH", "No API key provided (local mode)"); + } + + // Enforce API key if enabled in settings + const settings = await getSettings(); + if (settings.requireApiKey) { + if (!apiKey) { + log.warn("AUTH", "Missing API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + log.warn("AUTH", "Invalid API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!providerInput || typeof providerInput !== "string") { + log.warn("SEARCH", "Missing provider/model"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)"); + } + + if (!query || typeof query !== "string" || !query.trim()) { + log.warn("SEARCH", "Missing query"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: query"); + } + + // Combo expansion: providerInput may be a combo name โ†’ run fallback/round-robin across providers + const combos = await getCombos(); + const comboModels = getComboModelsFromData(providerInput, combos); + if (comboModels) { + const comboStrategies = settings.comboStrategies || {}; + const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback"; + log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy})`); + return handleComboChat({ + body, + models: comboModels, + handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings), + log, + comboName: providerInput, + comboStrategy + }); + } + + return handleSingleProviderSearch(body, providerInput, request, apiKey, settings); +} + +async function handleSingleProviderSearch(body, providerInput, request, apiKey, settings) { + const query = body.query; + const providerId = resolveProviderId(providerInput); + const resolvedProvider = AI_PROVIDERS[providerId]; + + if (!resolvedProvider) { + log.warn("SEARCH", "Unknown provider", { provider: providerInput }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`); + } + + const providerConfig = resolvedProvider.searchConfig; + const supportsSearch = !!providerConfig || !!resolvedProvider.searchViaChat; + + if (!supportsSearch) { + log.warn("SEARCH", "Provider does not support web search", { provider: providerId }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web search`); + } + + if (providerInput !== providerId) { + log.info("ROUTING", `${providerInput} โ†’ ${providerId}`); + } else { + log.info("ROUTING", `Provider: ${providerId}`); + } + + // Sanitized body forwarded to core + const coreBody = { + query: query.trim(), + provider: providerId, + max_results: body.max_results, + search_type: body.search_type, + country: body.country, + language: body.language, + time_range: body.time_range, + offset: body.offset, + domain_filter: body.domain_filter, + content_options: body.content_options, + provider_options: body.provider_options + }; + + // No-auth providers (e.g. searxng) bypass credential lookup + if (resolvedProvider.noAuth) { + log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`); + const result = await handleSearchCore({ + body: coreBody, + provider: resolvedProvider, + providerConfig, + credentials: null, + log + }); + if (result.success) return result.response; + return result.response; + } + + // Credential + fallback loop + const excludeConnectionIds = new Set(); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials(providerId, excludeConnectionIds); + + if (!credentials || credentials.allRateLimited) { + if (credentials?.allRateLimited) { + const errorMsg = lastError || credentials.lastError || "Unavailable"; + const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + log.warn("SEARCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`); + return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); + } + if (excludeConnectionIds.size === 0) { + log.error("AUTH", `No credentials for provider: ${providerId}`); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`); + } + log.warn("SEARCH", "No more accounts available", { provider: providerId }); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + } + + log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`); + + const refreshedCredentials = await checkAndRefreshToken(providerId, credentials); + + const result = await handleSearchCore({ + body: coreBody, + provider: resolvedProvider, + providerConfig, + credentials: refreshedCredentials, + log, + onCredentialsRefreshed: async (newCreds) => { + await updateProviderCredentials(credentials.connectionId, { + accessToken: newCreds.accessToken, + refreshToken: newCreds.refreshToken, + providerSpecificData: newCreds.providerSpecificData, + testStatus: "active" + }); + }, + onRequestSuccess: async () => { + await clearAccountError(credentials.connectionId, credentials); + } + }); + + if (result.success) return result.response; + + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId); + + if (shouldFallback) { + log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); + excludeConnectionIds.add(credentials.connectionId); + lastError = result.error; + lastStatus = result.status; + continue; + } + + return result.response; + } +}