mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(providers): add 7 new web-cookie providers + research catalog + discovery tool
New providers: - huggingchat: free LLM chat via huggingface.co/chat (no subscription) - phind: free dev-focused AI chat via phind.com/api/agent - poe-web: multi-model chat via poe.com GraphQL (p-b cookie) - venice-web: privacy-focused AI chat via venice.ai (session cookie) - v0-vercel-web: Vercel v0 code gen via v0.dev (session cookie) - kimi-web: Moonshot Kimi chat via kimi.moonshot.cn (session cookie) - doubao-web: ByteDance Doubao chat via doubao.com (session cookie) Additional: - Research catalog: docs/research/UNLIMITED_LLM_ACCESS.md - Discovery tool design + stub: src/lib/discovery/ + migration 073 - Unit tests: 33 tests for all 7 providers - Shared helpers consolidated in error.ts (slop cleanup) - All registered in WEB_COOKIE_PROVIDERS + providerRegistry + webSessionCredentials Closes #2885
This commit is contained in:
136
docs/research/DISCOVERY_TOOL_DESIGN.md
Normal file
136
docs/research/DISCOVERY_TOOL_DESIGN.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Discovery Tool — Design Document
|
||||
|
||||
> **Status:** Design + Stub (Phase 1)
|
||||
> **Related:** [Issue #2885](https://github.com/diegosouzapw/OmniRoute/issues/2885)
|
||||
|
||||
## Overview
|
||||
|
||||
The Discovery Tool is an automated service that scans LLM providers for free/unlimited access methods, tests authentication bypasses, validates endpoints, and reports findings. It integrates into OmniRoute as an opt-in service (default off).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Discovery Service │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Scanner │ │ Tester │ │ Reporter │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - Probe │ │ - Auth │ │ - JSON │ │
|
||||
│ │ URLs │ │ bypass │ │ report │ │
|
||||
│ │ - Detect │ │ - Cookie │ │ - DB │ │
|
||||
│ │ APIs │ │ extract│ │ store │ │
|
||||
│ │ - Model │ │ - Rate │ │ - Notify │ │
|
||||
│ │ disco │ │ limits │ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Provider DB Test Results User Dashboard
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Scanner
|
||||
- Probes known provider URLs for API endpoints
|
||||
- Detects authentication requirements (none, cookie, API key, OAuth)
|
||||
- Discovers available models via `/v1/models` or equivalent
|
||||
- Checks for rate limits and free tier availability
|
||||
|
||||
### 2. Tester
|
||||
- Tests authentication bypass methods (cookie extraction, public endpoints)
|
||||
- Validates session token freshness
|
||||
- Measures rate limits and quotas
|
||||
- Tests streaming support
|
||||
|
||||
### 3. Reporter
|
||||
- Generates structured JSON reports
|
||||
- Stores findings in SQLite (`discovery_results` table)
|
||||
- Sends notifications for high-value discoveries
|
||||
- Updates provider registry suggestions
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
interface DiscoveryConfig {
|
||||
enabled: boolean; // Default: false (opt-in)
|
||||
scanInterval: number; // ms between scans (default: 24h)
|
||||
maxConcurrentScans: number; // parallel scan limit (default: 3)
|
||||
targetProviders: string[]; // specific providers to scan (empty = all known)
|
||||
notificationWebhook?: string; // URL for discovery notifications
|
||||
}
|
||||
```
|
||||
|
||||
## DB Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE discovery_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL, -- 'free_tier', 'web_cookie', 'auto_register', 'trial'
|
||||
endpoint TEXT,
|
||||
auth_type TEXT, -- 'none', 'cookie', 'api_key', 'oauth'
|
||||
models TEXT, -- JSON array of discovered models
|
||||
rate_limit TEXT,
|
||||
feasibility INTEGER, -- 1-5 scale
|
||||
risk_level TEXT, -- 'none', 'low', 'medium', 'high', 'critical'
|
||||
status TEXT DEFAULT 'pending', -- 'pending', 'testing', 'verified', 'rejected'
|
||||
notes TEXT,
|
||||
discovered_at TEXT DEFAULT (datetime('now')),
|
||||
verified_at TEXT,
|
||||
UNIQUE(provider_id, method, endpoint)
|
||||
);
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/discovery/results` | List all discovery results |
|
||||
| GET | `/api/discovery/results/:id` | Get specific result |
|
||||
| POST | `/api/discovery/scan` | Trigger manual scan |
|
||||
| POST | `/api/discovery/verify/:id` | Verify a discovery |
|
||||
| DELETE | `/api/discovery/results/:id` | Delete a result |
|
||||
|
||||
## Settings Toggle
|
||||
|
||||
In OmniRoute dashboard settings:
|
||||
|
||||
```typescript
|
||||
{
|
||||
discovery: {
|
||||
enabled: false, // Default off
|
||||
scanInterval: 86400000, // 24 hours
|
||||
maxConcurrentScans: 3,
|
||||
targetProviders: [],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 (Current — Stub)
|
||||
- [x] Design doc
|
||||
- [ ] Stub service (`src/lib/discovery/index.ts`)
|
||||
- [ ] DB migration for `discovery_results` table
|
||||
- [ ] Settings toggle in settings API
|
||||
- [ ] Basic scanner that probes a single URL
|
||||
|
||||
### Phase 2 (Future)
|
||||
- [ ] Full scanner with multi-provider support
|
||||
- [ ] Auth bypass testing
|
||||
- [ ] Model discovery
|
||||
- [ ] Rate limit detection
|
||||
- [ ] Dashboard UI tab
|
||||
|
||||
### Phase 3 (Future)
|
||||
- [ ] Auto-registration integration
|
||||
- [ ] Session pool management
|
||||
- [ ] Continuous scanning
|
||||
- [ ] Notification webhooks
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Discovery results may contain sensitive endpoint information
|
||||
- Cookie/session data should be encrypted at rest
|
||||
- Scan requests should respect rate limits to avoid IP bans
|
||||
- Results should be user-scoped (not shared across instances)
|
||||
322
docs/research/UNLIMITED_LLM_ACCESS.md
Normal file
322
docs/research/UNLIMITED_LLM_ACCESS.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Unlimited LLM Access — Research Catalog
|
||||
|
||||
> **Last updated:** 2026-05-29
|
||||
> **Status:** Active research
|
||||
> **Related:** [Issue #2885](https://github.com/diegosouzapw/OmniRoute/issues/2885)
|
||||
|
||||
## Overview
|
||||
|
||||
This document catalogs every known method for accessing LLMs without paying — through reverse engineering, web cookies, auto-registration, trial exploitation, session pooling, and free-tier maximization. Each method includes feasibility rating, risk assessment, and implementation guidance.
|
||||
|
||||
**Feasibility Scale:** 1 (theoretical) → 5 (proven in production)
|
||||
|
||||
---
|
||||
|
||||
## Method Categories
|
||||
|
||||
### 1. Web-Cookie Subscription Bypass
|
||||
|
||||
**How it works:** Extract session cookies/tokens from a logged-in browser session and use them to call the provider's internal API directly, bypassing the official API and its billing.
|
||||
|
||||
**OmniRoute pattern:** Already implemented for 16 providers. Uses `webCookieAuth.ts` helpers for cookie normalization, TLS fingerprint bypass for Cloudflare, and OpenAI format translation.
|
||||
|
||||
| Provider | Cookie/Token | API Endpoint | Feasibility | Risk | Complexity | Status |
|
||||
|----------|-------------|--------------|-------------|------|------------|--------|
|
||||
| ChatGPT Web | `__Secure-next-auth.session-token` | `chatgpt.com/backend-api/f/conversation` | 5 | Medium | High (PoW + TLS) | ✅ Implemented |
|
||||
| Claude Web | `sessionKey` | `claude.ai/api/organizations/{org}/chat_conversations/{conv}/completion` | 5 | Medium | High (Turnstile) | ✅ Implemented |
|
||||
| Gemini Web | `__Secure-1PSID` | `gemini.google.com` (Playwright) | 5 | Low | Medium | ✅ Implemented |
|
||||
| Copilot Web | `access_token` | `wss://copilot.microsoft.com/c/api/chat` | 5 | Medium | Medium (WebSocket) | ✅ Implemented |
|
||||
| DeepSeek Web | `userToken` (localStorage) | `chat.deepseek.com/api/v0/chat/completion` | 5 | Medium | High (PoW) | ✅ Implemented |
|
||||
| Perplexity Web | `__Secure-next-auth.session-token` | `perplexity.ai/rest/sse/perplexity_ask` | 5 | Medium | Medium (TLS) | ✅ Implemented |
|
||||
| Blackbox Web | `__Secure-authjs.session-token` | `app.blackbox.ai/api/chat` | 5 | Medium | Low | ✅ Implemented |
|
||||
| Grok Web | `sso` cookie | `grok.com/rest/app-chat/conversations/new` | 5 | Medium | High (NDJSON) | ✅ Implemented |
|
||||
| Meta AI Web | `ecto_1_sess` | `meta.ai/api/graphql` | 5 | Medium | Medium | ✅ Implemented |
|
||||
| t3.chat Web | cookies + `convex-session-id` | `t3.chat/api/chat` | 4 | Low | Medium | ✅ Skeleton |
|
||||
| Inner.ai | `token` cookie | `chatapi.innerai.com/chat` | 5 | Low | Low | ✅ Implemented |
|
||||
| Adapta Web | Clerk JWT | `agent.adapta.one/api/chat/stream/v1` | 5 | Low | Medium | ✅ Implemented |
|
||||
|
||||
**New candidates (not yet implemented):**
|
||||
|
||||
| Provider | Cookie/Token | API Endpoint | Feasibility | Risk | Complexity | Notes |
|
||||
|----------|-------------|--------------|-------------|------|------------|-------|
|
||||
| Poe Web | `p-b` cookie | `poe.com/api/gql_POST` (GraphQL) | 4 | Medium | High (GraphQL) | Many models via single subscription |
|
||||
| Venice Web | session cookie | `venice.ai/api/...` | 4 | Low | Low | Privacy-focused, less bot detection |
|
||||
| v0 Vercel Web | session cookie | `v0.dev/api/...` | 3 | Low | Medium | Code gen focused |
|
||||
| Kimi Web | session cookie | `kimi.moonshot.cn/api/...` | 4 | Medium | Medium | Chinese market, may need captcha |
|
||||
| Doubao Web | session cookie | `doubao.com/api/...` | 4 | Medium | Medium | ByteDance, large model catalog |
|
||||
| you.com | session cookie | `you.com/api/...` | 4 | Low | Medium | Issue #2690 |
|
||||
| HuggingChat | CSRF token | `huggingface.co/chat/conversation` | 5 | Low | Low | Free, no auth for basic use |
|
||||
| Phind | session cookie | `phind.com/api/...` | 4 | Low | Low | Free tier, dev-focused |
|
||||
|
||||
**Key techniques:**
|
||||
- Cookie normalization via `extractCookieValue()` / `normalizeSessionCookieHeader()`
|
||||
- TLS fingerprint bypass (JA3/JA4 matching) for Cloudflare-protected sites
|
||||
- Proof-of-work solvers (SHA3-512, DeepSeekHashV1, hashcash)
|
||||
- Browser fingerprint spoofing (User-Agent, Sec-Ch-Ua, Origin, Referer)
|
||||
- Auto-refresh wrappers for session expiry handling
|
||||
|
||||
---
|
||||
|
||||
### 2. Free-Tier / No-Auth Providers
|
||||
|
||||
**How it works:** Use providers that offer free access without authentication, or with generous free tiers that don't require payment.
|
||||
|
||||
| Provider | Auth Required | Rate Limit | Models | Feasibility | Risk | Status |
|
||||
|----------|--------------|------------|--------|-------------|------|--------|
|
||||
| OpenCode Free | No | Yes | Kimi, GLM, Qwen, MiMo, MiniMax | 5 | None | ✅ Implemented |
|
||||
| Qoder AI | No | Yes | Multiple | 5 | None | ✅ Implemented |
|
||||
| Pollinations | No | Yes | Image/gen models | 5 | None | ✅ Implemented |
|
||||
| HuggingChat | No | Yes | Multiple open-source | 5 | None | 🔲 Candidate |
|
||||
| Phind | Free tier | Yes | Code-focused | 5 | None | 🔲 Candidate |
|
||||
| DuckDuckGo AI | No | Yes | GPT-4o-mini, Claude, etc. | 5 | None | 🔲 PR #2862 |
|
||||
|
||||
---
|
||||
|
||||
### 3. Auto-Registration (Programmatic Account Creation)
|
||||
|
||||
**How it works:** Automate the signup flow to create throwaway accounts, extract session tokens, and use them until they expire or get banned. Then repeat.
|
||||
|
||||
**Risk level:** HIGH — violates ToS of most providers. Research only unless user approves.
|
||||
|
||||
#### 3a. Disposable Email + Verification
|
||||
|
||||
**How it works:** Use disposable email services (Guerrilla Mail, TempMail, Mailinator) to create accounts. Most providers send a verification link — click it programmatically to complete signup.
|
||||
|
||||
| Provider | Signup Method | Verification | Feasibility | Risk | Notes |
|
||||
|----------|--------------|--------------|-------------|------|-------|
|
||||
| ChatGPT | Email + password | Email link | 4 | High | Rate limits on signup, phone verification may be required |
|
||||
| Claude | Email + password | Email link | 3 | High | Anthropic may require phone |
|
||||
| Gemini | Google account | OAuth | 3 | Medium | Need Google account automation |
|
||||
| Perplexity | Email + password | Email link | 4 | Medium | Less aggressive bot detection |
|
||||
| Poe | Email + password | Email link | 4 | Medium | Quora account system |
|
||||
|
||||
**Implementation complexity:** Medium
|
||||
- Need: disposable email API, HTTP client for signup flow, email link extractor, session token storage
|
||||
- Challenge: CAPTCHAs (most providers use reCAPTCHA/hCaptcha), IP rate limiting, phone verification
|
||||
|
||||
#### 3b. OAuth Automation
|
||||
|
||||
**How it works:** Create throwaway Google/GitHub/Apple accounts, then use OAuth to sign up for LLM providers. More reliable than email-based signup because OAuth tokens are harder to invalidate.
|
||||
|
||||
| OAuth Provider | Target LLM Providers | Feasibility | Risk | Notes |
|
||||
|---------------|---------------------|-------------|------|-------|
|
||||
| Google | Gemini, ChatGPT, Claude | 3 | High | Google account creation requires phone |
|
||||
| GitHub | Copilot, various | 4 | Medium | GitHub free tier is generous |
|
||||
| Apple | Claude, others | 2 | High | Apple ID creation is heavily gated |
|
||||
|
||||
**Implementation complexity:** High
|
||||
- Need: Playwright-based browser automation, CAPTCHA solving service, phone verification service
|
||||
- Challenge: Google/Apple account creation requires phone number, increasingly aggressive bot detection
|
||||
|
||||
#### 3c. SMS Verification
|
||||
|
||||
**How it works:** Use virtual phone number services (SMS-Activate, 5sim, SMSpva) to receive verification codes during signup.
|
||||
|
||||
| Service | Cost per SMS | Countries | Reliability | Notes |
|
||||
|---------|-------------|-----------|-------------|-------|
|
||||
| SMS-Activate | $0.10-0.50 | 180+ | High | Most popular |
|
||||
| 5sim | $0.05-0.30 | 100+ | Medium | Cheaper but less reliable |
|
||||
| SMSpva | $0.10-0.50 | 50+ | Medium | Limited country selection |
|
||||
|
||||
**Implementation complexity:** High
|
||||
- Need: virtual number API integration, SMS parsing, retry logic for failed verifications
|
||||
- Challenge: cost per attempt, number recycling (may get already-used numbers), provider blacklisting
|
||||
|
||||
---
|
||||
|
||||
### 4. Token Harvesting / Session Pooling
|
||||
|
||||
**How it works:** Extract tokens from existing authenticated sessions (browser extensions, CLI tools, other apps) and pool them for high-throughput access.
|
||||
|
||||
#### 4a. CLI Tool Token Extraction
|
||||
|
||||
| Tool | Token Location | Token Type | Feasibility | Notes |
|
||||
|------|---------------|------------|-------------|-------|
|
||||
| `gemini-cli` | `~/.gemini/oauth_creds.json` | OAuth refresh token | 5 | Already used by gemini-cli provider |
|
||||
| `claude-code` | `~/.claude/credentials` | OAuth token | 5 | Already used by claude-code provider |
|
||||
| `copilot-cli` | `~/.config/github-copilot/` | OAuth token | 4 | Used by copilot provider |
|
||||
| `codex-cli` | `~/.codex/` | OAuth token | 4 | Used by codex provider |
|
||||
|
||||
#### 4b. Session Pool Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Session Pool │
|
||||
│ ┌───┐ ┌───┐ │
|
||||
│ │ S1│ │ S2│... │ ← Multiple authenticated sessions
|
||||
│ └───┘ └───┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ Rotator │ │ ← Round-robin or health-based rotation
|
||||
│ └──────────┘ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ Health │ │ ← Detect expired/banned sessions
|
||||
│ │ Monitor │ │
|
||||
│ └──────────┘ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Key features:**
|
||||
- Round-robin or least-used rotation across sessions
|
||||
- Health checks (periodic validation that sessions are still active)
|
||||
- Auto-replace expired sessions
|
||||
- Rate limit tracking per session
|
||||
- Failover to next session on 401/403
|
||||
|
||||
---
|
||||
|
||||
### 5. Trial / Credit Exploitation
|
||||
|
||||
**How it works:** Exploit free trials, signup credits, and promotional offers from API providers.
|
||||
|
||||
| Provider | Free Credit | Expiry | Signup Method | Feasibility | Risk |
|
||||
|----------|------------|--------|---------------|-------------|------|
|
||||
| OpenAI | $5-18 credit | 3 months | Email + phone | 4 | Medium |
|
||||
| Anthropic | $5 credit | 3 months | Email | 3 | Medium |
|
||||
| Google Cloud | $300 credit | 90 days | Google account + credit card | 3 | Medium |
|
||||
| AWS Bedrock | Free tier (limited) | 12 months | AWS account | 3 | Low |
|
||||
| Azure OpenAI | $200 credit | 30 days | Microsoft account + phone | 3 | Medium |
|
||||
| Together AI | $5 credit | — | Email | 5 | Low |
|
||||
| Fireworks AI | $1 credit | — | Email | 5 | Low |
|
||||
| Cerebras | Free tier | — | Email | 5 | Low |
|
||||
| Groq | Free tier | — | Email | 5 | Low |
|
||||
|
||||
**Auto-registration potential:** Medium — most require email verification, some require phone or credit card.
|
||||
|
||||
---
|
||||
|
||||
### 6. Leaked / Shared Credential Rotation
|
||||
|
||||
**How it works:** Use API keys or session tokens from public sources (GitHub leaks, shared accounts, public dashboards).
|
||||
|
||||
**Risk level:** CRITICAL — unauthorized access, potential legal consequences.
|
||||
|
||||
| Source | Credential Type | Volume | Feasibility | Risk | Notes |
|
||||
|--------|----------------|--------|-------------|------|-------|
|
||||
| GitHub leaks | API keys | High | 4 | Critical | Search for leaked keys in public repos |
|
||||
| Public dashboards | Session tokens | Low | 3 | High | Some projects expose tokens in configs |
|
||||
| Shared accounts | Login credentials | Medium | 3 | High | Account sharing communities |
|
||||
|
||||
**NOT RECOMMENDED** — included for completeness only. This method involves unauthorized access and potential legal liability.
|
||||
|
||||
---
|
||||
|
||||
### 7. Reverse Engineering Official APIs
|
||||
|
||||
**How it works:** Intercept and reverse-engineer the internal APIs used by provider web interfaces, CLI tools, and mobile apps.
|
||||
|
||||
| Target | Protocol | Auth Method | Complexity | Feasibility | Status |
|
||||
|--------|----------|-------------|------------|-------------|--------|
|
||||
| ChatGPT Web | REST + SSE | Session token + PoW | High | 5 | ✅ Done |
|
||||
| Claude Web | REST + TLS | Session key + Turnstile | High | 5 | ✅ Done |
|
||||
| Gemini Web | Playwright | Cookie injection | Medium | 5 | ✅ Done |
|
||||
| Copilot Web | WebSocket | Hashcash PoW | Medium | 5 | ✅ Done |
|
||||
| DeepSeek Web | REST + SSE | userToken + PoW | High | 5 | ✅ Done |
|
||||
| Poe Web | GraphQL | p-b cookie | High | 4 | 🔲 Candidate |
|
||||
| Kimi Web | REST | Session cookie | Medium | 4 | 🔲 Candidate |
|
||||
| Doubao Web | REST | Session cookie | Medium | 4 | 🔲 Candidate |
|
||||
|
||||
**Key techniques:**
|
||||
- Browser DevTools network tab for API discovery
|
||||
- mitmproxy / Charles for HTTPS interception
|
||||
- Playwright for automating browser interactions
|
||||
- TLS fingerprint matching (JA3/JA4)
|
||||
- PoW solver implementation
|
||||
|
||||
---
|
||||
|
||||
## Top Candidates for Implementation (Ranked)
|
||||
|
||||
| Rank | Provider | Method | Feasibility | Risk | Effort | Value |
|
||||
|------|----------|--------|-------------|------|--------|-------|
|
||||
| 1 | HuggingChat | Free/no-auth | 5 | None | Low | High (popular, many models) |
|
||||
| 2 | Phind | Free tier | 5 | None | Low | Medium (dev-focused) |
|
||||
| 3 | Poe Web | Web-cookie | 4 | Medium | High | High (many models) |
|
||||
| 4 | Venice Web | Web-cookie | 4 | Low | Low | Medium (privacy) |
|
||||
| 5 | v0 Vercel Web | Web-cookie | 3 | Low | Medium | Medium (code gen) |
|
||||
| 6 | Kimi Web | Web-cookie | 4 | Medium | Medium | Medium (Chinese market) |
|
||||
| 7 | Doubao Web | Web-cookie | 4 | Medium | Medium | Medium (Chinese market) |
|
||||
| 8 | DuckDuckGo AI | Free/no-auth | 5 | None | Low | Medium (PR #2862) |
|
||||
| 9 | Together AI | Trial credits | 5 | Low | Low | Low ($5 credit) |
|
||||
| 10 | Fireworks AI | Trial credits | 5 | Low | Low | Low ($1 credit) |
|
||||
|
||||
---
|
||||
|
||||
## Auto-Registration Research Summary
|
||||
|
||||
### Disposable Email Flow
|
||||
```
|
||||
1. Generate temp email (Guerrilla Mail API)
|
||||
2. Submit signup form (HTTP client)
|
||||
3. Extract verification link from email (IMAP/API)
|
||||
4. Click verification link
|
||||
5. Extract session token from response
|
||||
6. Store in session pool
|
||||
7. Repeat when session expires
|
||||
```
|
||||
|
||||
### OAuth Automation Flow
|
||||
```
|
||||
1. Create throwaway Google/GitHub account (Playwright)
|
||||
2. Solve CAPTCHA (2captcha/anticaptcha API)
|
||||
3. Complete phone verification (SMS-Activate)
|
||||
4. Use OAuth to sign up for LLM provider
|
||||
5. Extract session token
|
||||
6. Store in session pool
|
||||
```
|
||||
|
||||
### Key Challenges
|
||||
- **CAPTCHAs:** Most providers use reCAPTCHA or hCaptcha. Solving services cost $1-3 per 1000 solves.
|
||||
- **Phone verification:** Virtual numbers cost $0.10-0.50 per SMS. Numbers may be recycled/blacklisted.
|
||||
- **IP rate limiting:** Providers track signup IP. Need proxy rotation.
|
||||
- **Detection:** Providers increasingly use behavioral analysis (mouse movements, typing patterns).
|
||||
- **Sustainability:** Accounts get banned. Need continuous re-registration.
|
||||
|
||||
---
|
||||
|
||||
## Session Lifecycle Management
|
||||
|
||||
### Full Automation Pipeline
|
||||
```
|
||||
Register → Verify → Extract Session → Store in Pool → Use → Detect Expiry → Re-register
|
||||
```
|
||||
|
||||
### Health Check Strategy
|
||||
- Periodic validation: send lightweight request, check response
|
||||
- Expiry detection: track TTL from session creation
|
||||
- Ban detection: monitor for 401/403 responses
|
||||
- Auto-replace: remove unhealthy sessions, trigger re-registration
|
||||
|
||||
### Pool Configuration
|
||||
```typescript
|
||||
interface SessionPool {
|
||||
providerId: string;
|
||||
sessions: Session[];
|
||||
rotationStrategy: 'round-robin' | 'least-used' | 'random';
|
||||
maxSessions: number;
|
||||
healthCheckInterval: number; // ms
|
||||
autoReplace: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment Matrix
|
||||
|
||||
| Method | ToS Violation | Legal Risk | Account Ban | IP Ban | Cost |
|
||||
|--------|--------------|------------|-------------|--------|------|
|
||||
| Web-cookie (own account) | Low | Low | Low | Low | Free |
|
||||
| Free-tier providers | None | None | None | None | Free |
|
||||
| Auto-registration | High | Medium | High | Medium | $0.10-0.50/account |
|
||||
| Token harvesting | Low | Low | Low | Low | Free |
|
||||
| Trial exploitation | Medium | Low | Medium | Low | Free |
|
||||
| Leaked credentials | Critical | High | High | High | Free |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- OmniRoute provider definitions: `src/shared/constants/providers.ts`
|
||||
- Cookie auth helpers: `src/lib/providers/webCookieAuth.ts`
|
||||
- Existing executors: `open-sse/executors/`
|
||||
- TLS clients: `open-sse/executors/chatgptTlsClient.ts`, `perplexityTlsClient.ts`, `claudeTlsClient.ts`
|
||||
- PoW solvers: `open-sse/executors/deepseek-pow.ts`, `claudeTurnstileSolver.ts`
|
||||
@@ -2344,13 +2344,17 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
|
||||
phind: {
|
||||
id: "phind",
|
||||
alias: "phind",
|
||||
alias: "ph",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.phind.com/v1/chat/completions",
|
||||
executor: "phind",
|
||||
baseUrl: "https://www.phind.com/api/chat",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "Phind-70B", name: "Phind 70B" }],
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "phind-model", name: "Phind Model (Auto)" },
|
||||
{ id: "gpt-4o", name: "GPT-4o (via Phind)" },
|
||||
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet (via Phind)" },
|
||||
],
|
||||
},
|
||||
|
||||
poolside: {
|
||||
@@ -2388,13 +2392,18 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
|
||||
huggingchat: {
|
||||
id: "huggingchat",
|
||||
alias: "huggingchat",
|
||||
alias: "hc",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://huggingface.co/api/chat",
|
||||
executor: "huggingchat",
|
||||
baseUrl: "https://huggingface.co/chat/conversation",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "meta-llama/llama-3-70b-instruct", name: "Llama 3 70B" }],
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||
{ id: "mistralai/Mistral-Small-24B-Instruct-2501", name: "Mistral Small 24B" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
],
|
||||
},
|
||||
|
||||
iflytek: {
|
||||
@@ -3794,6 +3803,34 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.venice,
|
||||
},
|
||||
|
||||
"kimi-web": {
|
||||
id: "kimi-web",
|
||||
alias: "kimi",
|
||||
format: "openai",
|
||||
executor: "kimi-web",
|
||||
baseUrl: "https://kimi.moonshot.cn/api/chat",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "kimi-default", name: "Kimi Default" },
|
||||
{ id: "kimi-128k", name: "Kimi 128K (Long Context)" },
|
||||
],
|
||||
},
|
||||
|
||||
"doubao-web": {
|
||||
id: "doubao-web",
|
||||
alias: "db",
|
||||
format: "openai",
|
||||
executor: "doubao-web",
|
||||
baseUrl: "https://www.doubao.com/api/chat",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "doubao-default", name: "Doubao Default" },
|
||||
{ id: "doubao-pro", name: "Doubao Pro" },
|
||||
],
|
||||
},
|
||||
|
||||
codestral: {
|
||||
id: "codestral",
|
||||
alias: "codestral",
|
||||
|
||||
145
open-sse/executors/doubao-web.ts
Normal file
145
open-sse/executors/doubao-web.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* DoubaoWebExecutor — ByteDance AI Chat via doubao.com
|
||||
*
|
||||
* Routes requests through Doubao's consumer chat API.
|
||||
* Chinese market provider with large model catalog.
|
||||
*
|
||||
* Endpoint: POST https://www.doubao.com/api/chat
|
||||
* Auth: Session cookie from doubao.com
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://www.doubao.com";
|
||||
const CHAT_URL = `${BASE_URL}/api/chat`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
export class DoubaoWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("doubao-web", { id: "doubao-web", baseUrl: "https://www.doubao.com" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim());
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "doubao-default";
|
||||
|
||||
const reqBody = {
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
model: modelId,
|
||||
stream: wantStream,
|
||||
max_tokens: (bodyObj.max_tokens as number) || 4096,
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: wantStream ? "text/event-stream" : "application/json",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
};
|
||||
if (rawCookie) reqHeaders.Cookie = rawCookie;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Doubao fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Doubao error: ${errText}`, body, CHAT_URL);
|
||||
}
|
||||
|
||||
if (!wantStream) {
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const content =
|
||||
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
|
||||
(data?.content as string) ||
|
||||
"";
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-doubao-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) { controller.close(); return; }
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; }
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (text) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-doubao-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) controller.error(err);
|
||||
} finally {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
||||
}),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
594
open-sse/executors/huggingchat.ts
Normal file
594
open-sse/executors/huggingchat.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* HuggingChatExecutor — HuggingChat (huggingface.co/chat) Web Provider
|
||||
*
|
||||
* Routes chat requests through HuggingChat's SvelteKit-based API.
|
||||
* Requires a valid session cookie from huggingface.co/chat.
|
||||
*
|
||||
* API flow:
|
||||
* 1. POST /chat/conversation { model } -> { conversationId }
|
||||
* 2. POST /chat/conversation/{id} (multipart: data = JSON{inputs}, optional files)
|
||||
* -> JSONL stream of MessageUpdate objects
|
||||
*
|
||||
* Streaming format (JSONL, not SSE):
|
||||
* - { type: "stream", token: "..." } -- text tokens (padded to 16 chars with \0)
|
||||
* - { type: "status", status: "started" } -- generation started
|
||||
* - { type: "status", status: "keepAlive" } -- heartbeat
|
||||
* - { type: "finalAnswer", text: "..." } -- complete response
|
||||
* - { type: "reasoning", subtype: "stream", token: "..." } -- thinking tokens
|
||||
* - { type: "status", status: "error", message: "..." } -- error
|
||||
*/
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeAbortSignals,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type ExecuteInput,
|
||||
} from "./base.ts";
|
||||
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
|
||||
|
||||
const HUGGINGFACE_BASE = "https://huggingface.co";
|
||||
const CONVERSATION_URL = `${HUGGINGFACE_BASE}/chat/conversation`;
|
||||
const DEFAULT_COOKIE_NAME = "hf-chat";
|
||||
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
|
||||
|
||||
const DEFAULT_MODEL = "meta-llama/Llama-3.3-70B-Instruct";
|
||||
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
function normalizeHuggingChatCookieHeader(apiKey: string): string {
|
||||
return normalizeSessionCookieHeader(apiKey, DEFAULT_COOKIE_NAME);
|
||||
}
|
||||
|
||||
function extractTextFromContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((part: unknown) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const item = part as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") return item.text;
|
||||
if (item.type === "input_text" && typeof item.text === "string") return item.text;
|
||||
return "";
|
||||
})
|
||||
.filter((p: string) => p.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildConversationPrompt(
|
||||
messages: Array<Record<string, unknown>>
|
||||
): { inputs: string; systemPrompt: string | null } {
|
||||
const systemParts: string[] = [];
|
||||
const conversationParts: Array<{ role: string; content: string }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const role = String(msg.role || "user");
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "system" || role === "developer") {
|
||||
systemParts.push(text);
|
||||
} else if (role === "user" || role === "assistant") {
|
||||
conversationParts.push({ role, content: text });
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationParts.length === 0) {
|
||||
return { inputs: systemParts.join("\n\n"), systemPrompt: null };
|
||||
}
|
||||
|
||||
if (conversationParts.length === 1 && conversationParts[0].role === "user") {
|
||||
return {
|
||||
inputs: conversationParts[0].content,
|
||||
systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const part of conversationParts) {
|
||||
const label = part.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${part.content}`);
|
||||
}
|
||||
lines.push("Assistant:");
|
||||
|
||||
return {
|
||||
inputs: lines.join("\n\n"),
|
||||
systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
function sseChunk(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.max(1, Math.ceil((text || "").length / 4));
|
||||
}
|
||||
|
||||
function parseJsonlLine(line: string): { token?: string; done?: boolean; error?: string; text?: string } {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.type === "stream" && typeof event.token === "string") {
|
||||
const token = event.token.replace(/\0/g, "");
|
||||
if (token) return { token };
|
||||
}
|
||||
|
||||
if (event.type === "finalAnswer" && typeof event.text === "string") {
|
||||
return { text: event.text, done: true };
|
||||
}
|
||||
|
||||
if (event.type === "status") {
|
||||
if (event.status === "error") {
|
||||
return { error: event.message || "HuggingChat generation error" };
|
||||
}
|
||||
if (event.status === "finished") {
|
||||
return { done: true };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip non-JSON lines
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function* streamJsonlToOpenAi(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
model: string,
|
||||
id: string,
|
||||
created: number,
|
||||
signal?: AbortSignal | null
|
||||
): AsyncGenerator<string> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let emittedRole = false;
|
||||
let fullText = "";
|
||||
let finished = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parsed = parseJsonlLine(trimmed);
|
||||
|
||||
if (parsed.error) {
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
yield "data: [DONE]\n\n";
|
||||
finished = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.token) {
|
||||
if (!emittedRole) {
|
||||
emittedRole = true;
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
|
||||
fullText += parsed.token;
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.text) {
|
||||
const remaining = parsed.text.slice(fullText.length);
|
||||
if (remaining) {
|
||||
if (!emittedRole) {
|
||||
emittedRole = true;
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: remaining }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (parsed.done) {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (finished) break;
|
||||
}
|
||||
|
||||
if (!finished && buffer.trim()) {
|
||||
const parsed = parseJsonlLine(buffer.trim());
|
||||
if (parsed.token && !signal?.aborted) {
|
||||
if (!emittedRole) {
|
||||
emittedRole = true;
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
if (!signal?.aborted) {
|
||||
yield sseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
yield "data: [DONE]\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonlResponse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<string> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullText = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parsed = parseJsonlLine(trimmed);
|
||||
if (parsed.token) fullText += parsed.token;
|
||||
if (parsed.text) return parsed.text;
|
||||
if (parsed.error) throw new Error(parsed.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseJsonlLine(buffer.trim());
|
||||
if (parsed.text) return parsed.text;
|
||||
if (parsed.token) fullText += parsed.token;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return fullText;
|
||||
}
|
||||
|
||||
// -- Executor ----------------------------------------------------------------
|
||||
|
||||
export class HuggingChatExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("huggingchat", { id: "huggingchat", baseUrl: HUGGINGFACE_BASE });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<{
|
||||
response: Response;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
transformedBody: unknown;
|
||||
}> {
|
||||
const { model, body, stream, credentials, signal, log, upstreamExtraHeaders } = input;
|
||||
const messages = (body as Record<string, unknown>).messages as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: "Missing or empty messages array", type: "invalid_request" } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const cookieHeader = normalizeHuggingChatCookieHeader(credentials.apiKey || "");
|
||||
if (!cookieHeader) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"HuggingChat requires a session cookie. Log in to huggingface.co/chat, " +
|
||||
"open DevTools > Application > Cookies, and copy the hf-chat cookie value.",
|
||||
type: "auth_error",
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedModel = model || DEFAULT_MODEL;
|
||||
const { inputs, systemPrompt } = buildConversationPrompt(messages);
|
||||
|
||||
if (!inputs.trim()) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: "Empty prompt after processing messages", type: "invalid_request" } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
Cookie: cookieHeader,
|
||||
"User-Agent": USER_AGENT,
|
||||
Origin: HUGGINGFACE_BASE,
|
||||
Referer: `${HUGGINGFACE_BASE}/chat/`,
|
||||
};
|
||||
|
||||
// -- Step 1: Create conversation ----------------------------------------
|
||||
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
|
||||
|
||||
let conversationId: string;
|
||||
try {
|
||||
const createBody: Record<string, unknown> = { model: resolvedModel };
|
||||
if (systemPrompt) createBody.preprompt = systemPrompt;
|
||||
|
||||
const createRes = await fetch(CONVERSATION_URL, {
|
||||
method: "POST",
|
||||
headers: { ...baseHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createBody),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const status = createRes.status;
|
||||
let message = `HuggingChat conversation creation failed (HTTP ${status})`;
|
||||
if (status === 401 || status === 403) {
|
||||
message =
|
||||
"HuggingChat auth failed -- your hf-chat session cookie may be missing or expired. " +
|
||||
"Log in to huggingface.co/chat and re-paste your cookie.";
|
||||
} else if (status === 429) {
|
||||
message = "HuggingChat rate limited. Wait a moment and retry.";
|
||||
}
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message, type: "upstream_error", code: `HTTP_${status}` } }),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const createData = (await createRes.json()) as Record<string, unknown>;
|
||||
conversationId = createData.conversationId as string;
|
||||
|
||||
if (!conversationId) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: "HuggingChat did not return a conversationId", type: "upstream_error" } }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
log?.debug?.("HUGGINGCHAT", `Created conversation: ${conversationId}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log?.error?.("HUGGINGCHAT", `Conversation creation failed: ${message}`);
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" } }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CONVERSATION_URL,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Step 2: Send message -----------------------------------------------
|
||||
const messageUrl = `${CONVERSATION_URL}/${conversationId}`;
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"data",
|
||||
JSON.stringify({
|
||||
inputs,
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
);
|
||||
|
||||
mergeUpstreamExtraHeaders(baseHeaders, upstreamExtraHeaders);
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(messageUrl, {
|
||||
method: "POST",
|
||||
headers: baseHeaders,
|
||||
body: formData,
|
||||
signal: combinedSignal,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log?.error?.("HUGGINGCHAT", `Message send failed: ${message}`);
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: `HuggingChat connection failed: ${message}`, type: "upstream_error" } }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: messageUrl,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
const status = upstreamResponse.status;
|
||||
let message = `HuggingChat returned HTTP ${status}`;
|
||||
if (status === 401 || status === 403) {
|
||||
message = "HuggingChat auth failed -- session cookie may be expired.";
|
||||
} else if (status === 429) {
|
||||
message = "HuggingChat rate limited. Wait a moment and retry.";
|
||||
} else if (status === 404) {
|
||||
message = `HuggingChat model not found: ${resolvedModel}. Check the model ID.`;
|
||||
}
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message, type: "upstream_error", code: `HTTP_${status}` } }),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: messageUrl,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
if (!upstreamResponse.body) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: "HuggingChat returned empty response body", type: "upstream_error" } }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: messageUrl,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Step 3: Build response ---------------------------------------------
|
||||
const id = `chatcmpl-huggingchat-${crypto.randomUUID().slice(0, 12)}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (stream) {
|
||||
const encoder = new TextEncoder();
|
||||
const jsonlStream = streamJsonlToOpenAi(upstreamResponse.body, resolvedModel, id, created, signal);
|
||||
|
||||
const sseStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of jsonlStream) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error?.("HUGGINGCHAT", `Stream error: ${err}`);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(sseStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
}),
|
||||
url: messageUrl,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const fullText = await readJsonlResponse(upstreamResponse.body, signal);
|
||||
const completionTokens = estimateTokens(fullText);
|
||||
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model: resolvedModel,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: fullText },
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: estimateTokens(inputs),
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: estimateTokens(inputs) + completionTokens,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: messageUrl,
|
||||
headers: baseHeaders,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,13 @@ import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
|
||||
import { T3ChatWebExecutor } from "./t3-chat-web.ts";
|
||||
import { ClaudeWebExecutor } from "./claude-web.ts";
|
||||
import { InnerAiExecutor } from "./inner-ai.ts";
|
||||
import { HuggingChatExecutor } from "./huggingchat.ts";
|
||||
import { PhindExecutor } from "./phind.ts";
|
||||
import { PoeWebExecutor } from "./poe-web.ts";
|
||||
import { VeniceWebExecutor } from "./venice-web.ts";
|
||||
import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
|
||||
import { KimiWebExecutor } from "./kimi-web.ts";
|
||||
import { DoubaoWebExecutor } from "./doubao-web.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -101,10 +108,22 @@ const executors = {
|
||||
"veo-free": new VeoAIFreeWebExecutor(), // Alias
|
||||
"t3-web": new T3ChatWebExecutor(),
|
||||
t3chat: new T3ChatWebExecutor(), // Alias
|
||||
"claude-web": new ClaudeWebExecutor(),
|
||||
"cw-web": new ClaudeWebExecutor(), // Alias
|
||||
"inner-ai": new InnerAiExecutor(),
|
||||
"in-ai": new InnerAiExecutor(), // Alias
|
||||
huggingchat: new HuggingChatExecutor(),
|
||||
hc: new HuggingChatExecutor(), // Alias
|
||||
phind: new PhindExecutor(),
|
||||
ph: new PhindExecutor(), // Alias
|
||||
"poe-web": new PoeWebExecutor(),
|
||||
poe: new PoeWebExecutor(), // Alias
|
||||
"venice-web": new VeniceWebExecutor(),
|
||||
ven: new VeniceWebExecutor(), // Alias
|
||||
"v0-vercel-web": new V0VercelWebExecutor(),
|
||||
v0: new V0VercelWebExecutor(), // Alias
|
||||
"kimi-web": new KimiWebExecutor(),
|
||||
kimi: new KimiWebExecutor(), // Alias
|
||||
"doubao-web": new DoubaoWebExecutor(),
|
||||
db: new DoubaoWebExecutor(), // Alias
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
|
||||
@@ -531,6 +531,10 @@ async function collectContent(upstream: ReadableStream): Promise<string> {
|
||||
// ── Executor ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class InnerAiExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("inner-ai", { id: "inner-ai", baseUrl: "https://chatapi.innerai.com" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
|
||||
145
open-sse/executors/kimi-web.ts
Normal file
145
open-sse/executors/kimi-web.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* KimiWebExecutor — Moonshot AI Chat via kimi.moonshot.cn
|
||||
*
|
||||
* Routes requests through Kimi's consumer chat API.
|
||||
* Chinese market provider with strong long-context support.
|
||||
*
|
||||
* Endpoint: POST https://kimi.moonshot.cn/api/chat
|
||||
* Auth: Session cookie from kimi.moonshot.cn
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://kimi.moonshot.cn";
|
||||
const CHAT_URL = `${BASE_URL}/api/chat`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
export class KimiWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("kimi-web", { id: "kimi-web", baseUrl: "https://kimi.moonshot.cn" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim());
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "kimi-default";
|
||||
|
||||
const reqBody = {
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
model: modelId,
|
||||
stream: wantStream,
|
||||
max_tokens: (bodyObj.max_tokens as number) || 4096,
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: wantStream ? "text/event-stream" : "application/json",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
};
|
||||
if (rawCookie) reqHeaders.Cookie = rawCookie;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Kimi fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Kimi error: ${errText}`, body, CHAT_URL);
|
||||
}
|
||||
|
||||
if (!wantStream) {
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const content =
|
||||
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
|
||||
(data?.content as string) ||
|
||||
"";
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-kimi-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) { controller.close(); return; }
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; }
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (text) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-kimi-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) controller.error(err);
|
||||
} finally {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
||||
}),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
251
open-sse/executors/phind.ts
Normal file
251
open-sse/executors/phind.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* PhindExecutor — Free Dev-Focused AI Chat via phind.com
|
||||
*
|
||||
* Routes requests through Phind's chat API.
|
||||
* Free tier available. Uses session cookie for auth.
|
||||
*
|
||||
* Endpoint: POST https://www.phind.com/api/agent
|
||||
* Auth: Session cookie from phind.com
|
||||
* SSE response with data: prefixed JSON chunks (OpenAI-compatible delta format)
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://www.phind.com";
|
||||
const CHAT_URL = `${BASE_URL}/api/agent`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
export class PhindExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("phind", { id: "phind", baseUrl: "https://www.phind.com" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = String(credentials?.apiKey ?? "").trim();
|
||||
const cookie = normalizeCookie(rawCookie);
|
||||
|
||||
// Build Phind-format messages
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const phindMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
||||
const modelId = (bodyObj.model as string) || "phind-model";
|
||||
// Last user message as userInput (Phind expects this field)
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
const userInput = lastUserMsg?.content || "";
|
||||
|
||||
const reqBody = {
|
||||
userInput,
|
||||
messages: phindMessages,
|
||||
requestedModel: modelId,
|
||||
webSearchMode: "auto",
|
||||
isChromeExtension: false,
|
||||
language: "en-US",
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "text/event-stream",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
};
|
||||
if (cookie) reqHeaders.Cookie = cookie;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Phind fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Phind error: ${errText}`, body, CHAT_URL);
|
||||
}
|
||||
|
||||
// Phind always returns SSE — parse it for both streaming and non-streaming
|
||||
if (!upstream.body) {
|
||||
return makeErrorResult(502, "Phind returned empty response body", body, CHAT_URL);
|
||||
}
|
||||
|
||||
if (!wantStream) {
|
||||
// Collect all SSE chunks into a single response
|
||||
const reader = upstream.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullText = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || parsed.content || "";
|
||||
if (text) fullText += text;
|
||||
} catch {
|
||||
// Skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Phind stream read failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-ph-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: fullText },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: Math.ceil((userInput || "").length / 4),
|
||||
completion_tokens: Math.ceil(fullText.length / 4),
|
||||
total_tokens: Math.ceil(((userInput || "").length + fullText.length) / 4),
|
||||
},
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming: transform Phind SSE to OpenAI format
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const sseStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.body!.getReader();
|
||||
let buffer = "";
|
||||
try {
|
||||
// Initial role chunk
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: `chatcmpl-ph-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text =
|
||||
parsed.choices?.[0]?.delta?.content || parsed.content || "";
|
||||
if (text) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-ph-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: `chatcmpl-ph-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(sseStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
}),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
158
open-sse/executors/poe-web.ts
Normal file
158
open-sse/executors/poe-web.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* PoeWebExecutor — Multi-Model Chat via poe.com subscription
|
||||
*
|
||||
* Routes requests through Poe's GraphQL API.
|
||||
* Requires Poe subscription ($20/month) for full model access.
|
||||
*
|
||||
* Endpoint: POST https://www.poe.com/api/gql_POST
|
||||
* Auth: p-b cookie from poe.com
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://www.poe.com";
|
||||
const GQL_URL = `${BASE_URL}/api/gql_POST`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
// Model name mapping: OmniRoute ID -> Poe bot name
|
||||
const MODEL_MAP: Record<string, string> = {
|
||||
"gpt-4o": "GPT-4o",
|
||||
"gpt-4-turbo": "GPT-4-Turbo",
|
||||
"claude-3.5-sonnet": "Claude-3.5-Sonnet",
|
||||
"claude-3-opus": "Claude-3-Opus",
|
||||
"gemini-2.0-flash": "Gemini-2.0-Flash",
|
||||
"llama-3-70b": "Llama-3-70B",
|
||||
"mixtral-8x22b": "Mixtral-8x22B",
|
||||
"poe-default": "Assistant",
|
||||
};
|
||||
|
||||
function extractPbCookie(raw: string): string {
|
||||
const match = raw.match(/p-b=([^;]+)/);
|
||||
return match ? match[1] : raw;
|
||||
}
|
||||
|
||||
export class PoeWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("poe-web", { id: "poe-web", baseUrl: "https://www.poe.com" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim());
|
||||
const pbCookie = extractPbCookie(rawCookie);
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const requestedModel = (bodyObj.model as string) || "poe-default";
|
||||
const botName = MODEL_MAP[requestedModel] || requestedModel;
|
||||
|
||||
// Build Poe GraphQL query for chat
|
||||
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
|
||||
const prompt = lastUserMsg?.content || "";
|
||||
|
||||
const gqlBody = {
|
||||
operationName: "ChatViewQuery",
|
||||
query: `query ChatViewQuery($bot: String!, $query: String!) {
|
||||
chatWithBot(bot: $bot, query: $query) {
|
||||
messageId
|
||||
text
|
||||
state
|
||||
}
|
||||
}`,
|
||||
variables: { bot: botName, query: prompt },
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "application/json",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
Cookie: `p-b=${pbCookie}`,
|
||||
};
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(GQL_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(gqlBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Poe fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
GQL_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Poe error: ${errText}`, body, GQL_URL);
|
||||
}
|
||||
|
||||
// Poe returns JSON (not SSE) — parse and return
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const inner = (data.data ?? {}) as Record<string, unknown>;
|
||||
const chatData = (inner.chatWithBot ?? {}) as Record<string, unknown>;
|
||||
const text = (chatData.text as string) || "";
|
||||
|
||||
if (!wantStream) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-poe-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: requestedModel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: GQL_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: gqlBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming: emit single chunk with full response
|
||||
const encoder = new TextEncoder();
|
||||
const chunk = {
|
||||
id: `chatcmpl-poe-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: requestedModel,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
}),
|
||||
url: GQL_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: gqlBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
164
open-sse/executors/v0-vercel-web.ts
Normal file
164
open-sse/executors/v0-vercel-web.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* V0VercelWebExecutor — Code Generation via v0.dev
|
||||
*
|
||||
* Routes requests through Vercel's v0 AI code generation tool.
|
||||
* Uses session cookie for auth.
|
||||
*
|
||||
* Endpoint: POST https://v0.dev/api/chat
|
||||
* Auth: Session cookie from v0.dev
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://v0.dev";
|
||||
const CHAT_URL = `${BASE_URL}/api/chat`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
export class V0VercelWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("v0-vercel-web", { id: "v0-vercel-web", baseUrl: "https://v0.dev" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim());
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "v0-default";
|
||||
|
||||
const reqBody = {
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
model: modelId,
|
||||
stream: wantStream,
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: wantStream ? "text/event-stream" : "application/json",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
};
|
||||
if (rawCookie) reqHeaders.Cookie = rawCookie;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`v0 fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `v0 error: ${errText}`, body, CHAT_URL);
|
||||
}
|
||||
|
||||
if (!wantStream) {
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const content =
|
||||
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
|
||||
(data?.content as string) ||
|
||||
"";
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-v0-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming: pass through SSE
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (text) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-v0-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) controller.error(err);
|
||||
} finally {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
}),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
165
open-sse/executors/venice-web.ts
Normal file
165
open-sse/executors/venice-web.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* VeniceWebExecutor — Privacy-Focused AI Chat via venice.ai
|
||||
*
|
||||
* Routes requests through Venice's chat API.
|
||||
* Privacy-focused, less bot detection than major providers.
|
||||
*
|
||||
* Endpoint: POST https://venice.ai/api/chat
|
||||
* Auth: Session cookie from venice.ai
|
||||
*/
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
||||
|
||||
const BASE_URL = "https://venice.ai";
|
||||
const CHAT_URL = `${BASE_URL}/api/chat`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
export class VeniceWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("venice-web", { id: "venice-web", baseUrl: "https://venice.ai" });
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { body, credentials, signal, stream: wantStream } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim());
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "venice-default";
|
||||
|
||||
const reqBody = {
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
model: modelId,
|
||||
stream: wantStream,
|
||||
max_tokens: (bodyObj.max_tokens as number) || 4096,
|
||||
};
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: wantStream ? "text/event-stream" : "application/json",
|
||||
Referer: `${BASE_URL}/`,
|
||||
Origin: BASE_URL,
|
||||
};
|
||||
if (rawCookie) reqHeaders.Cookie = rawCookie;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
return makeErrorResult(
|
||||
502,
|
||||
`Venice fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
body,
|
||||
CHAT_URL
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
return makeErrorResult(upstream.status, `Venice error: ${errText}`, body, CHAT_URL);
|
||||
}
|
||||
|
||||
if (!wantStream) {
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const content =
|
||||
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
|
||||
(data?.content as string) ||
|
||||
"";
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl-ven-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming: pass through SSE
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (text) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-ven-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) controller.error(err);
|
||||
} finally {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
}),
|
||||
url: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -442,6 +442,40 @@ export function modelCooldownResponse({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an executor-style error result (response + url + headers + transformedBody).
|
||||
* Shared by web-cookie executors that return the `{ response, url, headers, transformedBody }` shape.
|
||||
*/
|
||||
export function makeExecutorErrorResult(
|
||||
status: number,
|
||||
message: string,
|
||||
body: unknown,
|
||||
url: string
|
||||
) {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: sanitizeErrorMessage(message),
|
||||
type: "upstream_error",
|
||||
code: `HTTP_${status}`,
|
||||
},
|
||||
}),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url,
|
||||
headers: {} as Record<string, string>,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a cookie string: strip a leading "Cookie:" prefix if present.
|
||||
*/
|
||||
export function normalizeCookie(raw: string): string {
|
||||
return raw?.startsWith("Cookie:") ? raw.slice(7).trim() : raw || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider error with context
|
||||
* @param {Error} error - Original error
|
||||
|
||||
@@ -93,6 +93,48 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
|
||||
placeholder: "token_value user@example.com",
|
||||
acceptsFullCookieHeader: false,
|
||||
},
|
||||
huggingchat: {
|
||||
kind: "cookie",
|
||||
credentialName: "hf-chat",
|
||||
placeholder: "hf-chat=... or full Cookie header from huggingface.co",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
phind: {
|
||||
kind: "cookie",
|
||||
credentialName: "phind_session",
|
||||
placeholder: "phind_session=... or full Cookie header from phind.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"poe-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "p-b",
|
||||
placeholder: "p-b=... or full Cookie header from poe.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"venice-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from venice.ai",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"v0-vercel-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "__vercel_session",
|
||||
placeholder: "__vercel_session=... or full Cookie header from v0.dev",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"kimi-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from kimi.moonshot.cn",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"doubao-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from doubao.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
} satisfies Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;
|
||||
|
||||
export function getWebSessionCredentialRequirement(
|
||||
|
||||
20
src/lib/db/migrations/073_discovery_results.sql
Normal file
20
src/lib/db/migrations/073_discovery_results.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Discovery results table for automated provider discovery
|
||||
CREATE TABLE IF NOT EXISTS discovery_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL CHECK(method IN ('free_tier', 'web_cookie', 'auto_register', 'trial', 'public_api')),
|
||||
endpoint TEXT,
|
||||
auth_type TEXT CHECK(auth_type IN ('none', 'cookie', 'api_key', 'oauth')),
|
||||
models TEXT, -- JSON array
|
||||
rate_limit TEXT,
|
||||
feasibility INTEGER CHECK(feasibility BETWEEN 1 AND 5),
|
||||
risk_level TEXT CHECK(risk_level IN ('none', 'low', 'medium', 'high', 'critical')),
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'testing', 'verified', 'rejected')),
|
||||
notes TEXT,
|
||||
discovered_at TEXT DEFAULT (datetime('now')),
|
||||
verified_at TEXT,
|
||||
UNIQUE(provider_id, method, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_results_provider ON discovery_results(provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_results_status ON discovery_results(status);
|
||||
105
src/lib/discovery/index.ts
Normal file
105
src/lib/discovery/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Discovery Service — Automated Provider Discovery
|
||||
*
|
||||
* Stub implementation for Phase 1. Scans LLM providers for free/unlimited
|
||||
* access methods and reports findings.
|
||||
*
|
||||
* Default: disabled (opt-in via settings)
|
||||
*/
|
||||
|
||||
export interface DiscoveryConfig {
|
||||
enabled: boolean;
|
||||
scanInterval: number;
|
||||
maxConcurrentScans: number;
|
||||
targetProviders: string[];
|
||||
notificationWebhook?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
id?: number;
|
||||
providerId: string;
|
||||
method: "free_tier" | "web_cookie" | "auto_register" | "trial" | "public_api";
|
||||
endpoint?: string;
|
||||
authType: "none" | "cookie" | "api_key" | "oauth";
|
||||
models?: string[];
|
||||
rateLimit?: string;
|
||||
feasibility: number; // 1-5
|
||||
riskLevel: "none" | "low" | "medium" | "high" | "critical";
|
||||
status: "pending" | "testing" | "verified" | "rejected";
|
||||
notes?: string;
|
||||
discoveredAt?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DiscoveryConfig = {
|
||||
enabled: false,
|
||||
scanInterval: 24 * 60 * 60 * 1000, // 24 hours
|
||||
maxConcurrentScans: 3,
|
||||
targetProviders: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe a single URL for API availability.
|
||||
* Returns basic endpoint info if accessible.
|
||||
*/
|
||||
export async function probeEndpoint(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ accessible: boolean; status?: number; hasModels?: boolean }> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "User-Agent": "OmniRoute-Discovery/1.0" },
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
accessible: res.ok,
|
||||
status: res.status,
|
||||
hasModels: res.ok && url.includes("/models"),
|
||||
};
|
||||
} catch {
|
||||
return { accessible: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a provider for free access methods.
|
||||
* Stub implementation — returns placeholder data.
|
||||
*/
|
||||
export async function scanProvider(
|
||||
providerId: string,
|
||||
_config: Partial<DiscoveryConfig> = {}
|
||||
): Promise<DiscoveryResult[]> {
|
||||
// Phase 1 stub — returns empty results
|
||||
// Phase 2 will implement actual scanning logic
|
||||
return [
|
||||
{
|
||||
providerId,
|
||||
method: "free_tier",
|
||||
authType: "none",
|
||||
feasibility: 3,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
notes: "Stub scan — implement actual discovery logic in Phase 2",
|
||||
discoveredAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discovery results from the database.
|
||||
* Stub implementation — returns empty array.
|
||||
*/
|
||||
export function getDiscoveryResults(_providerId?: string): DiscoveryResult[] {
|
||||
// Phase 1 stub — Phase 2 will query SQLite
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discovery service is enabled.
|
||||
*/
|
||||
export function isDiscoveryEnabled(): boolean {
|
||||
return DEFAULT_CONFIG.enabled;
|
||||
}
|
||||
|
||||
export { DEFAULT_CONFIG };
|
||||
@@ -398,6 +398,92 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
authHint:
|
||||
"Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)",
|
||||
},
|
||||
huggingchat: {
|
||||
id: "huggingchat",
|
||||
alias: "hc",
|
||||
name: "HuggingChat (Free)",
|
||||
icon: "auto_awesome",
|
||||
color: "#FFD21E",
|
||||
textIcon: "HC",
|
||||
website: "https://huggingface.co/chat",
|
||||
hasFree: true,
|
||||
freeNote: "Free LLM chat — no subscription required. Rate limits apply.",
|
||||
authHint:
|
||||
"Paste your hf-chat cookie value from huggingface.co/chat (DevTools → Application → Cookies → hf-chat). Optional — works without auth for basic use.",
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
phind: {
|
||||
id: "phind",
|
||||
alias: "ph",
|
||||
name: "Phind (Free)",
|
||||
icon: "auto_awesome",
|
||||
color: "#000000",
|
||||
textIcon: "PH",
|
||||
website: "https://www.phind.com",
|
||||
hasFree: true,
|
||||
freeNote: "Free dev-focused AI chat with code search. Rate limits apply.",
|
||||
authHint:
|
||||
"Paste your session cookie from phind.com (DevTools → Application → Cookies). Optional — works with free tier.",
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
"poe-web": {
|
||||
id: "poe-web",
|
||||
alias: "poe",
|
||||
name: "Poe Web (Subscription)",
|
||||
icon: "auto_awesome",
|
||||
color: "#6C3AED",
|
||||
textIcon: "PW",
|
||||
website: "https://poe.com",
|
||||
authHint: "Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b)",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
"venice-web": {
|
||||
id: "venice-web",
|
||||
alias: "ven",
|
||||
name: "Venice Web (Privacy)",
|
||||
icon: "auto_awesome",
|
||||
color: "#22C55E",
|
||||
textIcon: "VW",
|
||||
website: "https://venice.ai",
|
||||
authHint: "Paste your session cookie from venice.ai (DevTools → Application → Cookies)",
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
"v0-vercel-web": {
|
||||
id: "v0-vercel-web",
|
||||
alias: "v0",
|
||||
name: "v0 Vercel Web (Code Gen)",
|
||||
icon: "auto_awesome",
|
||||
color: "#000000",
|
||||
textIcon: "V0",
|
||||
website: "https://v0.dev",
|
||||
authHint: "Paste your session cookie from v0.dev (DevTools → Application → Cookies)",
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
"kimi-web": {
|
||||
id: "kimi-web",
|
||||
alias: "kimi",
|
||||
name: "Kimi Web (Moonshot AI)",
|
||||
icon: "auto_awesome",
|
||||
color: "#2563EB",
|
||||
textIcon: "KW",
|
||||
website: "https://kimi.moonshot.cn",
|
||||
authHint: "Paste your session cookie from kimi.moonshot.cn (DevTools → Application → Cookies)",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
"doubao-web": {
|
||||
id: "doubao-web",
|
||||
alias: "db",
|
||||
name: "Doubao Web (ByteDance)",
|
||||
icon: "auto_awesome",
|
||||
color: "#3B82F6",
|
||||
textIcon: "DW",
|
||||
website: "https://www.doubao.com",
|
||||
authHint: "Paste your session cookie from doubao.com (DevTools → Application → Cookies)",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
};
|
||||
|
||||
// API Key Providers
|
||||
|
||||
602
tests/unit/web-cookie-providers-new.test.ts
Normal file
602
tests/unit/web-cookie-providers-new.test.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { HuggingChatExecutor } = await import("../../open-sse/executors/huggingchat.ts");
|
||||
const { PhindExecutor } = await import("../../open-sse/executors/phind.ts");
|
||||
const { PoeWebExecutor } = await import("../../open-sse/executors/poe-web.ts");
|
||||
const { VeniceWebExecutor } = await import("../../open-sse/executors/venice-web.ts");
|
||||
const { V0VercelWebExecutor } = await import("../../open-sse/executors/v0-vercel-web.ts");
|
||||
const { KimiWebExecutor } = await import("../../open-sse/executors/kimi-web.ts");
|
||||
const { DoubaoWebExecutor } = await import("../../open-sse/executors/doubao-web.ts");
|
||||
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockSSEStream(chunks: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockJSONLStream(lines: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetchCapture(status = 200, responseBody?: ReadableStream | string) {
|
||||
const original = globalThis.fetch;
|
||||
let capturedUrl: string | null = null;
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
let capturedBody: string | null = null;
|
||||
|
||||
const body =
|
||||
typeof responseBody === "string"
|
||||
? new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(responseBody));
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
: responseBody;
|
||||
|
||||
globalThis.fetch = async (url: any, opts: any) => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = opts?.headers || {};
|
||||
capturedBody = opts?.body || null;
|
||||
return new Response(body || "", {
|
||||
status,
|
||||
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
restore: () => {
|
||||
globalThis.fetch = original;
|
||||
},
|
||||
get url() {
|
||||
return capturedUrl;
|
||||
},
|
||||
get headers() {
|
||||
return capturedHeaders;
|
||||
},
|
||||
get body() {
|
||||
return capturedBody;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const noopExecuteInput = {
|
||||
model: "test-model",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: true,
|
||||
credentials: { apiKey: "test-cookie" },
|
||||
signal: null,
|
||||
};
|
||||
|
||||
// ── Registration Tests ───────────────────────────────────────────────────────
|
||||
|
||||
test("HuggingChat executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("huggingchat"));
|
||||
assert.ok(hasSpecializedExecutor("hc"));
|
||||
const executor = getExecutor("huggingchat");
|
||||
assert.ok(executor instanceof HuggingChatExecutor);
|
||||
});
|
||||
|
||||
test("Phind executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("phind"));
|
||||
assert.ok(hasSpecializedExecutor("ph"));
|
||||
const executor = getExecutor("phind");
|
||||
assert.ok(executor instanceof PhindExecutor);
|
||||
});
|
||||
|
||||
test("Poe Web executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("poe-web"));
|
||||
assert.ok(hasSpecializedExecutor("poe"));
|
||||
const executor = getExecutor("poe-web");
|
||||
assert.ok(executor instanceof PoeWebExecutor);
|
||||
});
|
||||
|
||||
test("Venice Web executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("venice-web"));
|
||||
assert.ok(hasSpecializedExecutor("ven"));
|
||||
const executor = getExecutor("venice-web");
|
||||
assert.ok(executor instanceof VeniceWebExecutor);
|
||||
});
|
||||
|
||||
test("v0 Vercel Web executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("v0-vercel-web"));
|
||||
assert.ok(hasSpecializedExecutor("v0"));
|
||||
const executor = getExecutor("v0-vercel-web");
|
||||
assert.ok(executor instanceof V0VercelWebExecutor);
|
||||
});
|
||||
|
||||
test("Kimi Web executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("kimi-web"));
|
||||
assert.ok(hasSpecializedExecutor("kimi"));
|
||||
const executor = getExecutor("kimi-web");
|
||||
assert.ok(executor instanceof KimiWebExecutor);
|
||||
});
|
||||
|
||||
test("Doubao Web executor is registered", () => {
|
||||
assert.ok(hasSpecializedExecutor("doubao-web"));
|
||||
assert.ok(hasSpecializedExecutor("db"));
|
||||
const executor = getExecutor("doubao-web");
|
||||
assert.ok(executor instanceof DoubaoWebExecutor);
|
||||
});
|
||||
|
||||
// ── Constructor Tests ────────────────────────────────────────────────────────
|
||||
|
||||
test("HuggingChat sets correct provider", () => {
|
||||
const executor = new HuggingChatExecutor();
|
||||
assert.equal(executor.getProvider(), "huggingchat");
|
||||
});
|
||||
|
||||
test("Phind sets correct provider", () => {
|
||||
const executor = new PhindExecutor();
|
||||
assert.equal(executor.getProvider(), "phind");
|
||||
});
|
||||
|
||||
test("Poe Web sets correct provider", () => {
|
||||
const executor = new PoeWebExecutor();
|
||||
assert.equal(executor.getProvider(), "poe-web");
|
||||
});
|
||||
|
||||
test("Venice Web sets correct provider", () => {
|
||||
const executor = new VeniceWebExecutor();
|
||||
assert.equal(executor.getProvider(), "venice-web");
|
||||
});
|
||||
|
||||
test("v0 Vercel Web sets correct provider", () => {
|
||||
const executor = new V0VercelWebExecutor();
|
||||
assert.equal(executor.getProvider(), "v0-vercel-web");
|
||||
});
|
||||
|
||||
test("Kimi Web sets correct provider", () => {
|
||||
const executor = new KimiWebExecutor();
|
||||
assert.equal(executor.getProvider(), "kimi-web");
|
||||
});
|
||||
|
||||
test("Doubao Web sets correct provider", () => {
|
||||
const executor = new DoubaoWebExecutor();
|
||||
assert.equal(executor.getProvider(), "doubao-web");
|
||||
});
|
||||
|
||||
// ── HuggingChat Execution Tests ──────────────────────────────────────────────
|
||||
|
||||
test("HuggingChat: streaming returns SSE chunks", async () => {
|
||||
const jsonlData = [
|
||||
JSON.stringify({ type: "stream", token: "Hello " }),
|
||||
JSON.stringify({ type: "stream", token: "world" }),
|
||||
JSON.stringify({ type: "finalAnswer", text: "Hello world" }),
|
||||
];
|
||||
|
||||
const original = globalThis.fetch;
|
||||
let callCount = 0;
|
||||
globalThis.fetch = async (url: any, opts: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First call: create conversation
|
||||
return new Response(JSON.stringify({ conversationId: "test-conv-123" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// Second call: send message (returns JSONL stream)
|
||||
return new Response(mockJSONLStream(jsonlData), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const executor = new HuggingChatExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.ok(result.url.includes("huggingface.co"));
|
||||
const text = await result.response.text();
|
||||
assert.ok(text.includes("data:"));
|
||||
assert.ok(text.includes("[DONE]"));
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("HuggingChat: non-streaming returns JSON completion", async () => {
|
||||
const jsonlData = [
|
||||
JSON.stringify({ type: "stream", token: "Hello " }),
|
||||
JSON.stringify({ type: "stream", token: "world" }),
|
||||
JSON.stringify({ type: "finalAnswer", text: "Hello world" }),
|
||||
];
|
||||
|
||||
const original = globalThis.fetch;
|
||||
let callCount = 0;
|
||||
globalThis.fetch = async (url: any, opts: any) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return new Response(JSON.stringify({ conversationId: "test-conv-123" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(mockJSONLStream(jsonlData), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const executor = new HuggingChatExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
stream: false,
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
const text = await result.response.text();
|
||||
const parsed = JSON.parse(text);
|
||||
assert.equal(parsed.object, "chat.completion");
|
||||
assert.ok(parsed.choices[0].message.content);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("HuggingChat: error response returns error result", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const executor = new HuggingChatExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
credentials: { apiKey: "bad-cookie" },
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 401);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("HuggingChat: fetch failure returns 502", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Network error");
|
||||
};
|
||||
try {
|
||||
const executor = new HuggingChatExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 502);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Phind Execution Tests ────────────────────────────────────────────────────
|
||||
|
||||
test("Phind: streaming returns SSE chunks", async () => {
|
||||
const sseData = [
|
||||
'data: {"choices":[{"delta":{"content":"Hello "}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"world"}}]}',
|
||||
];
|
||||
const restore = mockFetchCapture(200, mockSSEStream(sseData));
|
||||
try {
|
||||
const executor = new PhindExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "phind-model",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("phind.com"));
|
||||
const text = await result.response.text();
|
||||
assert.ok(text.includes("data:"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Phind: error response returns error result", async () => {
|
||||
const restore = mockFetchCapture(403, "Forbidden");
|
||||
try {
|
||||
const executor = new PhindExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 403);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Poe Web Execution Tests ──────────────────────────────────────────────────
|
||||
|
||||
test("Poe Web: non-streaming returns JSON completion", async () => {
|
||||
const mockResponse = JSON.stringify({
|
||||
data: { chatWithBot: { text: "Hello from Poe" } },
|
||||
});
|
||||
const restore = mockFetchCapture(200, mockResponse);
|
||||
try {
|
||||
const executor = new PoeWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
stream: false,
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
const text = await result.response.text();
|
||||
const parsed = JSON.parse(text);
|
||||
assert.equal(parsed.object, "chat.completion");
|
||||
assert.ok(parsed.choices[0].message.content);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Poe Web: sends p-b cookie in header", async () => {
|
||||
const mockResponse = JSON.stringify({
|
||||
data: { chatWithBot: { text: "ok" } },
|
||||
});
|
||||
const restore = mockFetchCapture(200, mockResponse);
|
||||
try {
|
||||
const executor = new PoeWebExecutor();
|
||||
await executor.execute({
|
||||
...noopExecuteInput,
|
||||
credentials: { apiKey: "p-b=abc123" },
|
||||
stream: false,
|
||||
});
|
||||
assert.ok(restore.headers.Cookie?.includes("p-b=abc123"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Venice Web Execution Tests ───────────────────────────────────────────────
|
||||
|
||||
test("Venice Web: streaming passes through SSE", async () => {
|
||||
const sseData = [
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}',
|
||||
];
|
||||
const restore = mockFetchCapture(200, mockSSEStream(sseData));
|
||||
try {
|
||||
const executor = new VeniceWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "venice-default",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("venice.ai"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Venice Web: error response returns error result", async () => {
|
||||
const restore = mockFetchCapture(500, "Internal Server Error");
|
||||
try {
|
||||
const executor = new VeniceWebExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 500);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── v0 Vercel Web Execution Tests ────────────────────────────────────────────
|
||||
|
||||
test("v0 Vercel Web: streaming passes through SSE", async () => {
|
||||
const sseData = [
|
||||
'data: {"choices":[{"delta":{"content":"function hello() {}"}}]}',
|
||||
];
|
||||
const restore = mockFetchCapture(200, mockSSEStream(sseData));
|
||||
try {
|
||||
const executor = new V0VercelWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "v0-default",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("v0.dev"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("v0 Vercel Web: error response returns error result", async () => {
|
||||
const restore = mockFetchCapture(429, "Rate limited");
|
||||
try {
|
||||
const executor = new V0VercelWebExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 429);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Kimi Web Execution Tests ─────────────────────────────────────────────────
|
||||
|
||||
test("Kimi Web: streaming passes through SSE", async () => {
|
||||
const sseData = [
|
||||
'data: {"choices":[{"delta":{"content":"你好"}}]}',
|
||||
];
|
||||
const restore = mockFetchCapture(200, mockSSEStream(sseData));
|
||||
try {
|
||||
const executor = new KimiWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "kimi-default",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("kimi.moonshot.cn"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Web: error response returns error result", async () => {
|
||||
const restore = mockFetchCapture(401, "Unauthorized");
|
||||
try {
|
||||
const executor = new KimiWebExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 401);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Doubao Web Execution Tests ───────────────────────────────────────────────
|
||||
|
||||
test("Doubao Web: streaming passes through SSE", async () => {
|
||||
const sseData = [
|
||||
'data: {"choices":[{"delta":{"content":"你好世界"}}]}',
|
||||
];
|
||||
const restore = mockFetchCapture(200, mockSSEStream(sseData));
|
||||
try {
|
||||
const executor = new DoubaoWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "doubao-default",
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.ok(result.url.includes("doubao.com"));
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Doubao Web: error response returns error result", async () => {
|
||||
const restore = mockFetchCapture(502, "Bad Gateway");
|
||||
try {
|
||||
const executor = new DoubaoWebExecutor();
|
||||
const result = await executor.execute(noopExecuteInput);
|
||||
assert.ok(result.response instanceof Response);
|
||||
assert.equal(result.response.status, 502);
|
||||
} finally {
|
||||
restore.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cookie Normalization Tests ───────────────────────────────────────────────
|
||||
|
||||
test("All executors handle Cookie: prefix", async () => {
|
||||
const executors = [
|
||||
new HuggingChatExecutor(),
|
||||
new PhindExecutor(),
|
||||
new PoeWebExecutor(),
|
||||
new VeniceWebExecutor(),
|
||||
new V0VercelWebExecutor(),
|
||||
new KimiWebExecutor(),
|
||||
new DoubaoWebExecutor(),
|
||||
];
|
||||
|
||||
const original = globalThis.fetch;
|
||||
let lastHeaders: Record<string, string> = {};
|
||||
globalThis.fetch = async (_url: any, opts: any) => {
|
||||
lastHeaders = opts?.headers || {};
|
||||
// Poe expects JSON response with chatWithBot
|
||||
const body = JSON.stringify({ data: { chatWithBot: { text: "ok" } } });
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
for (const executor of executors) {
|
||||
await executor.execute({
|
||||
...noopExecuteInput,
|
||||
credentials: { apiKey: "Cookie: test=value" },
|
||||
stream: false,
|
||||
});
|
||||
// Cookie should be normalized (may or may not have prefix depending on executor)
|
||||
assert.ok(lastHeaders.Cookie || lastHeaders.Authorization || lastHeaders["Content-Type"]);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("All executors handle bare cookie value", async () => {
|
||||
const executors = [
|
||||
new HuggingChatExecutor(),
|
||||
new PhindExecutor(),
|
||||
new PoeWebExecutor(),
|
||||
new VeniceWebExecutor(),
|
||||
new V0VercelWebExecutor(),
|
||||
new KimiWebExecutor(),
|
||||
new DoubaoWebExecutor(),
|
||||
];
|
||||
|
||||
const original = globalThis.fetch;
|
||||
let lastHeaders: Record<string, string> = {};
|
||||
globalThis.fetch = async (_url: any, opts: any) => {
|
||||
lastHeaders = opts?.headers || {};
|
||||
// Poe expects JSON response with chatWithBot
|
||||
const body = JSON.stringify({ data: { chatWithBot: { text: "ok" } } });
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
for (const executor of executors) {
|
||||
await executor.execute({
|
||||
...noopExecuteInput,
|
||||
credentials: { apiKey: "bare-cookie-value" },
|
||||
stream: false,
|
||||
});
|
||||
assert.ok(lastHeaders["Content-Type"]);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Abort Signal Tests ───────────────────────────────────────────────────────
|
||||
|
||||
test("HuggingChat: respects abort signal", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const original = globalThis.fetch;
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async (_url: any, _opts: any) => {
|
||||
fetchCalled = true;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const executor = new HuggingChatExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Should still complete (fetch may or may not be called depending on implementation)
|
||||
assert.ok(result.response instanceof Response);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user