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.
354
README.md
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
237
open-sse/handlers/fetch/index.js
Normal 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
|
||||
})
|
||||
};
|
||||
}
|
||||
371
open-sse/handlers/search/callers.js
Normal 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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
409
open-sse/handlers/search/chatSearch.js
Normal 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 };
|
||||
201
open-sse/handlers/search/index.js
Normal 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");
|
||||
}
|
||||
223
open-sse/handlers/search/normalizers.js
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/providers/blackbox.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/providers/brave-search.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
public/providers/exa.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/providers/firecrawl.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/providers/google-pse.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/providers/grok-web.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/providers/jina-reader.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/providers/linkup.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/providers/perplexity-web.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/providers/searchapi.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/providers/searxng.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/providers/serper.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/providers/tavily.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/providers/youcom.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
208
src/app/(dashboard)/dashboard/media-providers/web/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
21
src/app/api/v1/search/route.js
Normal 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);
|
||||
}
|
||||
21
src/app/api/v1/web/fetch/route.js
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||