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 0000000..9cbd38d Binary files /dev/null and b/public/providers/azure.png differ diff --git a/public/providers/blackbox.png b/public/providers/blackbox.png new file mode 100644 index 0000000..3dc5d90 Binary files /dev/null and b/public/providers/blackbox.png differ diff --git a/public/providers/brave-search.png b/public/providers/brave-search.png new file mode 100644 index 0000000..a70acda Binary files /dev/null and b/public/providers/brave-search.png differ diff --git a/public/providers/exa.png b/public/providers/exa.png new file mode 100644 index 0000000..03eff2a Binary files /dev/null and b/public/providers/exa.png differ diff --git a/public/providers/firecrawl.png b/public/providers/firecrawl.png new file mode 100644 index 0000000..623235e Binary files /dev/null and b/public/providers/firecrawl.png differ diff --git a/public/providers/google-pse.png b/public/providers/google-pse.png new file mode 100644 index 0000000..465357f Binary files /dev/null and b/public/providers/google-pse.png differ diff --git a/public/providers/grok-web.png b/public/providers/grok-web.png new file mode 100644 index 0000000..ef9d7ab Binary files /dev/null and b/public/providers/grok-web.png differ diff --git a/public/providers/jina-reader.png b/public/providers/jina-reader.png new file mode 100644 index 0000000..388d65d Binary files /dev/null and b/public/providers/jina-reader.png differ diff --git a/public/providers/linkup.png b/public/providers/linkup.png new file mode 100644 index 0000000..166b1a5 Binary files /dev/null and b/public/providers/linkup.png differ diff --git a/public/providers/perplexity-web.png b/public/providers/perplexity-web.png new file mode 100644 index 0000000..0b5851e Binary files /dev/null and b/public/providers/perplexity-web.png differ diff --git a/public/providers/searchapi.png b/public/providers/searchapi.png new file mode 100644 index 0000000..23630ec Binary files /dev/null and b/public/providers/searchapi.png differ diff --git a/public/providers/searxng.png b/public/providers/searxng.png new file mode 100644 index 0000000..3487f02 Binary files /dev/null and b/public/providers/searxng.png differ diff --git a/public/providers/serper.png b/public/providers/serper.png new file mode 100644 index 0000000..6a8063c Binary files /dev/null and b/public/providers/serper.png differ diff --git a/public/providers/tavily.png b/public/providers/tavily.png new file mode 100644 index 0000000..b51f3c5 Binary files /dev/null and b/public/providers/tavily.png differ diff --git a/public/providers/youcom.png b/public/providers/youcom.png new file mode 100644 index 0000000..4f7d429 Binary files /dev/null and b/public/providers/youcom.png differ diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 3acc5f1..dfaa466 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -339,7 +339,7 @@ function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown ); } -function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { +function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { // Initialize state with combo values - key prop on parent handles reset on remount const [name, setName] = useState(combo?.name || ""); const [models, setModels] = useState(combo?.models || []); @@ -504,6 +504,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { activeProviders={activeProviders} modelAliases={modelAliases} title="Add Model to Combo" + kindFilter={kindFilter} /> ); 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}

+
+

{title}

+ {signupUrl && ( + + open_in_new + Get API Key + + )} +
{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; + } +}