Enhance token refresh functionality across multiple executors

- Updated refreshCredentials methods in various executors (Antigravity, Base, Default, Github, Kiro) to accept optional proxyOptions for improved proxy handling.
- Modified token refresh logic to utilize proxy-aware fetch for better network management.
- Enhanced usage retrieval functions to support proxy options, ensuring seamless integration with proxy configurations.
- Updated ModelSelectModal and ProviderInfoCard components to incorporate kind filtering for improved user experience in model selection.
- Added validation for API keys in the provider validation route, including support for webSearch/webFetch providers.
This commit is contained in:
decolua
2026-04-28 17:28:57 +07:00
parent 1bb621317d
commit 8f81363675
45 changed files with 2924 additions and 289 deletions

354
README.md
View File

@@ -1,9 +1,9 @@
<div align="center">
<img src="./images/9router.png?1" alt="9Router Dashboard" width="800"/>
# 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:
<div align="center">
<table>
<tr>
<td align="center" width="150">
<img src="./public/providers/iflow.png" width="70" alt="iFlow"/><br/>
<b>iFlow AI</b><br/>
<sub>8+ models • Unlimited</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/qwen.png" width="70" alt="Qwen"/><br/>
<b>Qwen Code</b><br/>
<sub>3+ models • Unlimited</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/gemini-cli.png" width="70" alt="Gemini CLI"/><br/>
<b>Gemini CLI</b><br/>
<sub>180K/month FREE</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude Unlimited</sub>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>Unlimited FREE</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/opencode.png" width="70" alt="OpenCode Free"/><br/>
<b>OpenCode Free</b><br/>
<sub>No auth • Auto-fetch models<br/>Unlimited FREE</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
<b>Vertex AI</b><br/>
<sub>Gemini 3 Pro + GLM-5 + DeepSeek<br/>$300 credits free</sub>
</td>
</tr>
</table>
</div>
> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead.
### 🔑 API Key Providers (40+)
<div align="center">
@@ -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:
<details>
<summary><b>📖 Feature Details</b></summary>
### 🚀 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/<auto> (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
<details>
<summary><b>🆓 Are FREE providers really unlimited?</b></summary>
**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
</details>
@@ -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
```
</details>
@@ -740,7 +764,7 @@ Models:
<details>
<summary><b>💰 Cheap Providers (Backup)</b></summary>
### 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!
</details>
<details>
<summary><b>🆓 FREE Providers (Emergency Backup)</b></summary>
<summary><b>🆓 FREE Providers (Recommended)</b></summary>
### 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.
</details>
<details>
@@ -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)!
```
</details>
@@ -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
```
</details>
@@ -1057,40 +1090,62 @@ Notes:
<summary><b>View all available models</b></summary>
**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`
</details>
@@ -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`

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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<FetchResult>}
*/
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
})
};
}

View File

@@ -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<string,unknown>} [providerOptions]
* @property {Record<string,unknown>} [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,
}),
},
};
}

View File

@@ -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 };

View File

@@ -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");
}

View File

@@ -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 };
}

View File

@@ -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();

View File

@@ -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);
},
},
];

BIN
public/providers/azure.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
public/providers/exa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/providers/linkup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
public/providers/serper.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
public/providers/tavily.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/providers/youcom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -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}
/>
</>
);

View File

@@ -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) && (
<ProviderInfoCard
config={kind === "webFetch" ? provider.fetchConfig : provider.searchConfig}
config={
kind === "webFetch"
? provider.fetchConfig
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, costPerQuery: 0 }
}
provider={provider}
title={`${kindConfig.label} Config`}
/>
)}

View File

@@ -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";

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={`Add ${KIND_LABELS[kind] || ""} Provider`} size="md">
{providers.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No connected providers available. Add a connection first in the {KIND_LABELS[kind]} section.
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-[400px] overflow-y-auto">
{providers.map((p) => {
const already = currentIds.includes(p.id);
return (
<button
key={p.id}
disabled={already}
onClick={() => { onPick(p.id); onClose(); }}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
already
? "border-border opacity-40 cursor-not-allowed"
: "border-border hover:border-primary/50 hover:bg-primary/5 cursor-pointer"
}`}
>
<ProviderIcon
src={`/providers/${p.id}.png`}
alt={p.name}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p.textIcon || p.id.slice(0, 2).toUpperCase()}
fallbackColor={p.color}
/>
<span className="text-xs font-medium truncate text-left">{p.name}</span>
{already && <span className="text-[9px] text-text-muted ml-auto">added</span>}
</button>
);
})}
</div>
)}
</Modal>
);
}
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 <div className="text-text-muted text-sm">Loading...</div>;
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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Link href="/dashboard/media-providers/web" className="text-text-muted hover:text-primary">
<span className="material-symbols-outlined">arrow_back</span>
</Link>
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-primary">layers</span>
</div>
<div className="min-w-0">
<p className="text-xs text-text-muted">{kindLabel} Combo</p>
<code className="text-lg font-semibold font-mono">{combo.name}</code>
</div>
</div>
<Button variant="outline" icon="delete" onClick={handleDelete} className="text-red-500 border-red-200 hover:bg-red-50">
Delete
</Button>
</div>
{/* Settings Card */}
<Card>
<h2 className="text-lg font-semibold mb-3">Settings</h2>
<div className="flex flex-col gap-4">
<div>
<Input label="Combo Name" value={name} onChange={(e) => { setName(e.target.value); validateName(e.target.value); }} onBlur={handleSaveName} error={nameError} />
<p className="text-[10px] text-text-muted mt-0.5">Only letters, numbers, -, _ and .</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Round Robin</p>
<p className="text-xs text-text-muted">Rotate providers across requests instead of strict fallback order.</p>
</div>
<Toggle checked={roundRobin} onChange={handleToggleRoundRobin} />
</div>
</div>
</Card>
{/* Providers Card */}
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-semibold">Providers</h2>
<p className="text-xs text-text-muted">Tried in order (top-down) or rotated when round-robin is on.</p>
</div>
<Button size="sm" icon="add" onClick={() => setShowPicker(true)}>Add Provider</Button>
</div>
{providers.length === 0 ? (
<div className="text-center py-6 border border-dashed border-border rounded-lg text-text-muted text-sm">
No providers yet.
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((pid, idx) => {
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${idx}`} className="flex items-center gap-3 p-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
<span className="text-xs text-text-muted w-5 text-center">{idx + 1}</span>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={24}
className="object-contain rounded shrink-0"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
<span className="text-sm font-medium flex-1 truncate">{p?.name || pid}</span>
<div className="flex items-center gap-0.5">
<button onClick={() => handleMove(idx, -1)} disabled={idx === 0} className={`p-1 rounded ${idx === 0 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move up">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
</button>
<button onClick={() => handleMove(idx, 1)} disabled={idx === providers.length - 1} className={`p-1 rounded ${idx === providers.length - 1 ? "text-text-muted/20" : "text-text-muted hover:text-primary hover:bg-black/5"}`} title="Move down">
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
</button>
<button onClick={() => handleRemoveProvider(idx)} className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10" title="Remove">
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
</div>
);
})}
</div>
)}
</Card>
{/* Test Example Card */}
{combo.kind && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Test Example</h2>
<Button size="sm" icon="play_arrow" onClick={handleTest} disabled={testing || providers.length === 0}>
{testing ? "Running..." : "Run"}
</Button>
</div>
<pre className="text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all">
{curlExample}
</pre>
{testResult && (
<pre className="mt-3 text-xs font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[300px]">
{testResult}
</pre>
)}
</Card>
)}
{/* Usage Logs Card */}
<Card>
<h2 className="text-lg font-semibold mb-3">Usage Logs</h2>
{logs.length === 0 ? (
<p className="text-xs text-text-muted italic">No usage yet.</p>
) : (
<pre className="text-[11px] font-mono bg-black/[0.03] dark:bg-white/[0.03] p-3 rounded-lg overflow-auto max-h-[400px] whitespace-pre-wrap">
{logs.join("\n")}
</pre>
)}
</Card>
<ProviderPickerModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onPick={handleAddProvider}
kind={combo.kind}
currentIds={providers}
connections={connections}
/>
</div>
);
}

View File

@@ -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 <Badge variant="success" size="sm">Ready</Badge>;
if (allDisabled) return <Badge variant="default" size="sm">Disabled</Badge>;
if (total === 0) return <span className="text-xs text-text-muted">No connections</span>;
return (
<>
{connected > 0 && <Badge variant="success" size="sm" dot>{connected} Connected</Badge>}
{error > 0 && <Badge variant="error" size="sm" dot>{error} Error</Badge>}
{connected === 0 && error === 0 && <Badge variant="default" size="sm">{total} Added</Badge>}
</>
);
};
return (
<Link href={`/dashboard/media-providers/${kind}/${provider.id}`} className="group">
<Card padding="xs" className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}>
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
</div>
<div>
<h3 className="font-semibold text-sm">{provider.name}</h3>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">{renderStatus()}</div>
</div>
</div>
</Card>
</Link>
);
}
function ComboList({ combos }) {
if (combos.length === 0) {
return <p className="text-xs text-text-muted italic">No combos yet.</p>;
}
return (
<div className="flex flex-col gap-2">
{combos.map((combo) => (
<Link key={combo.id} href={`/dashboard/media-providers/web/combo/${combo.id}`}>
<Card padding="xs" className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
<code className="text-sm font-mono font-medium flex-1 truncate">{combo.name}</code>
{/* Provider icons preview */}
<div className="flex items-center gap-1 shrink-0">
{combo.models.slice(0, 6).map((pid, i) => {
const p = AI_PROVIDERS[pid];
return (
<div key={`${pid}-${i}`} title={p?.name || pid} className="size-5 rounded flex items-center justify-center" style={{ backgroundColor: `${(p?.color ?? "#888")}15` }}>
<ProviderIcon
src={`/providers/${pid}.png`}
alt={p?.name || pid}
size={18}
className="object-contain rounded max-w-[18px] max-h-[18px]"
fallbackText={p?.textIcon || pid.slice(0, 2).toUpperCase()}
fallbackColor={p?.color}
/>
</div>
);
})}
{combo.models.length > 6 && (
<span className="text-[10px] text-text-muted ml-1">+{combo.models.length - 6}</span>
)}
</div>
<span className="text-[11px] text-text-muted shrink-0">{combo.models.length}</span>
<span className="material-symbols-outlined text-text-muted text-[16px]">chevron_right</span>
</div>
</Card>
</Link>
))}
</div>
);
}
function Section({ title, icon, kind, providers, connections, combos, onCreateCombo }) {
return (
<div>
{/* Header — title left, Create Combo right */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary">{icon}</span>
<h2 className="text-base font-semibold">{title}</h2>
<span className="text-xs text-text-muted">({providers.length} providers · {combos.length} combos)</span>
</div>
<Button size="sm" icon="add" onClick={onCreateCombo}>Create Combo</Button>
</div>
{/* Combos — top */}
{combos.length > 0 && (
<div className="mb-4">
<ComboList combos={combos} />
</div>
)}
{/* Providers grid — bottom */}
{providers.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{providers.map((p) => (
<ProviderCard key={p.id} provider={p} kind={kind} connections={connections} />
))}
</div>
)}
</div>
);
}
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 (
<div className="flex flex-col gap-8">
<Section
title="Web Search" icon="search" kind="webSearch"
providers={searchProviders} connections={connections} combos={searchCombos}
onCreateCombo={() => handleCreateCombo("webSearch")}
/>
{/* Divider between sections */}
<div className="border-t border-border" />
<Section
title="Web Fetch" icon="travel_explore" kind="webFetch"
providers={fetchProviders} connections={connections} combos={fetchCombos}
onCreateCombo={() => handleCreateCombo("webFetch")}
/>
</div>
);
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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}`);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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 (
<Card>
<h2 className="text-lg font-semibold mb-3">{title}</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">{title}</h2>
{signupUrl && (
<a
href={signupUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">open_in_new</span>
Get API Key
</a>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-3 min-w-0">

View File

@@ -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 }) {
<span className="text-sm">{kind.label}</span>
</Link>
))}
<Link
key={COMBINED_WEB_ITEM.id}
href={COMBINED_WEB_ITEM.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
pathname.startsWith(COMBINED_WEB_ITEM.href)
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[16px]">{COMBINED_WEB_ITEM.icon}</span>
<span className="text-sm">{COMBINED_WEB_ITEM.label}</span>
</Link>
</div>
)}

View File

@@ -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)

211
src/sse/handlers/fetch.js Normal file
View File

@@ -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");
}
}

204
src/sse/handlers/search.js Normal file
View File

@@ -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;
}
}