From 10111aef1b4c2fd860b95f2ed26e4468da1481b0 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Fri, 29 May 2026 19:02:44 +0700 Subject: [PATCH] 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 --- docs/research/DISCOVERY_TOOL_DESIGN.md | 136 ++++ docs/research/UNLIMITED_LLM_ACCESS.md | 322 ++++++++++ open-sse/config/providerRegistry.ts | 57 +- open-sse/executors/doubao-web.ts | 145 +++++ open-sse/executors/huggingchat.ts | 594 +++++++++++++++++ open-sse/executors/index.ts | 23 +- open-sse/executors/inner-ai.ts | 4 + open-sse/executors/kimi-web.ts | 145 +++++ open-sse/executors/phind.ts | 251 ++++++++ open-sse/executors/poe-web.ts | 158 +++++ open-sse/executors/v0-vercel-web.ts | 164 +++++ open-sse/executors/venice-web.ts | 165 +++++ open-sse/utils/error.ts | 34 + .../providers/[id]/webSessionCredentials.ts | 42 ++ .../db/migrations/073_discovery_results.sql | 20 + src/lib/discovery/index.ts | 105 +++ src/shared/constants/providers.ts | 86 +++ tests/unit/web-cookie-providers-new.test.ts | 602 ++++++++++++++++++ 18 files changed, 3041 insertions(+), 12 deletions(-) create mode 100644 docs/research/DISCOVERY_TOOL_DESIGN.md create mode 100644 docs/research/UNLIMITED_LLM_ACCESS.md create mode 100644 open-sse/executors/doubao-web.ts create mode 100644 open-sse/executors/huggingchat.ts create mode 100644 open-sse/executors/kimi-web.ts create mode 100644 open-sse/executors/phind.ts create mode 100644 open-sse/executors/poe-web.ts create mode 100644 open-sse/executors/v0-vercel-web.ts create mode 100644 open-sse/executors/venice-web.ts create mode 100644 src/lib/db/migrations/073_discovery_results.sql create mode 100644 src/lib/discovery/index.ts create mode 100644 tests/unit/web-cookie-providers-new.test.ts diff --git a/docs/research/DISCOVERY_TOOL_DESIGN.md b/docs/research/DISCOVERY_TOOL_DESIGN.md new file mode 100644 index 0000000000..234390fe17 --- /dev/null +++ b/docs/research/DISCOVERY_TOOL_DESIGN.md @@ -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) diff --git a/docs/research/UNLIMITED_LLM_ACCESS.md b/docs/research/UNLIMITED_LLM_ACCESS.md new file mode 100644 index 0000000000..a047ad982b --- /dev/null +++ b/docs/research/UNLIMITED_LLM_ACCESS.md @@ -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` diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index fddcefbd8b..fb2b670494 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -2344,13 +2344,17 @@ export const REGISTRY: Record = { 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 = { 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 = { 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", diff --git a/open-sse/executors/doubao-web.ts b/open-sse/executors/doubao-web.ts new file mode 100644 index 0000000000..3315b39809 --- /dev/null +++ b/open-sse/executors/doubao-web.ts @@ -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; + 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 = { + "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; + 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, + }; + } +} diff --git a/open-sse/executors/huggingchat.ts b/open-sse/executors/huggingchat.ts new file mode 100644 index 0000000000..0a587ef020 --- /dev/null +++ b/open-sse/executors/huggingchat.ts @@ -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; + 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> +): { 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, + model: string, + id: string, + created: number, + signal?: AbortSignal | null +): AsyncGenerator { + 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, + signal?: AbortSignal | null +): Promise { + 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; + transformedBody: unknown; + }> { + const { model, body, stream, credentials, signal, log, upstreamExtraHeaders } = input; + const messages = (body as Record).messages as + | Array> + | 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 = { + 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 = { 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; + 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, + }; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 0d78b88987..36d0f0eb7f 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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(); diff --git a/open-sse/executors/inner-ai.ts b/open-sse/executors/inner-ai.ts index 09d5f0f3b1..f1bb86105b 100644 --- a/open-sse/executors/inner-ai.ts +++ b/open-sse/executors/inner-ai.ts @@ -531,6 +531,10 @@ async function collectContent(upstream: ReadableStream): Promise { // ── 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; diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts new file mode 100644 index 0000000000..755561e9fb --- /dev/null +++ b/open-sse/executors/kimi-web.ts @@ -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; + 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 = { + "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; + 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, + }; + } +} diff --git a/open-sse/executors/phind.ts b/open-sse/executors/phind.ts new file mode 100644 index 0000000000..ac91b1798f --- /dev/null +++ b/open-sse/executors/phind.ts @@ -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; + 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 = { + "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, + }; + } +} diff --git a/open-sse/executors/poe-web.ts b/open-sse/executors/poe-web.ts new file mode 100644 index 0000000000..73d1cd5bf1 --- /dev/null +++ b/open-sse/executors/poe-web.ts @@ -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 = { + "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; + 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 = { + "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; + const inner = (data.data ?? {}) as Record; + const chatData = (inner.chatWithBot ?? {}) as Record; + 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, + }; + } +} diff --git a/open-sse/executors/v0-vercel-web.ts b/open-sse/executors/v0-vercel-web.ts new file mode 100644 index 0000000000..9064503673 --- /dev/null +++ b/open-sse/executors/v0-vercel-web.ts @@ -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; + 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 = { + "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; + 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, + }; + } +} diff --git a/open-sse/executors/venice-web.ts b/open-sse/executors/venice-web.ts new file mode 100644 index 0000000000..4007815f5e --- /dev/null +++ b/open-sse/executors/venice-web.ts @@ -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; + 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 = { + "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; + 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, + }; + } +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 42a42a0ebf..3927f84724 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -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, + 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 diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index 7b355d90a8..079dbff0c7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -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; export function getWebSessionCredentialRequirement( diff --git a/src/lib/db/migrations/073_discovery_results.sql b/src/lib/db/migrations/073_discovery_results.sql new file mode 100644 index 0000000000..f72b46c5cc --- /dev/null +++ b/src/lib/db/migrations/073_discovery_results.sql @@ -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); diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts new file mode 100644 index 0000000000..369a67a9f4 --- /dev/null +++ b/src/lib/discovery/index.ts @@ -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 = {} +): Promise { + // 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 }; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index e55ab7a95f..9c3a804b84 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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 diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts new file mode 100644 index 0000000000..e30f003aab --- /dev/null +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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; + } +});