From 1b010f6c4020db5897c9469e079a6a7dd17a406e Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Wed, 22 Jul 2026 14:08:46 +0300 Subject: [PATCH] feat(sse): add HyperAgent (hyperagent.com) unofficial web provider (#7994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): add HyperAgent (hyperagent.com) unofficial web provider Reverse-engineered from live SPA captures (hyperagent/*.txt): Chat: - Cookie session auth (full Cookie header) - New thread via GET /threads/new (or POST /api/threads) - POST /api/threads/{id}/chat with SPA feature flags + content - SSE parse of text/session_start/session_end/done events - Multi-turn sticky threadId + sessionId cache (history prefix + last assistant) Models: - Hardcoded catalog from SPA pricing map - Pretty display names (Claude Fable 5) while wire modelId stays fable etc. - /v1/models exposes pretty name; chat uses modelId Limits: - GET /api/settings/billing/usage → creditBlocks initialUsd/remainingUsd/usedUsd - USD Credits quota for Limits page Tests: 15/15 unit/executor-hyperagent * fix(sse): HyperAgent execution mode + fable-latest wire model (no plan mode) * fix(sse): document HyperAgent env vars + regenerate golden snapshot Addresses pre-merge review feedback on #7994: - Documents HYPERAGENT_USAGE_URL in .env.example and ENVIRONMENT.md (OMNIROUTE_DATA_DIR was already documented via the sibling PromptQL provider) so check-env-doc-sync.test.ts passes. - Regenerates the provider-translate-path golden snapshot to include the new hyperagent/ha registry entries. - Swaps the local toNumber() helper in usage/hyperagent.ts for the canonical @/shared/utils/numeric import (#7879 no-restricted-syntax rule landed on the release branch after this PR was opened). - Freezes file-size baseline entries for the new open-sse/executors/hyperagent.ts (937 LOC, new-file cap 800) and the +3 LOC growth in src/lib/usage/providerLimits.ts (1000->1003), both irreducible to this PR's own provider-registration wiring. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- .env.example | 9 + config/quality/file-size-baseline.json | 4 +- docs/reference/ENVIRONMENT.md | 10 + open-sse/config/providers/index.ts | 2 + .../providers/registry/hyperagent/index.ts | 20 + open-sse/executors/hyperagent.ts | 936 ++++++++++++++++++ open-sse/executors/index.ts | 4 + open-sse/services/hyperagentModels.ts | 162 +++ open-sse/services/usage.ts | 7 + open-sse/services/usage/hyperagent.ts | 163 +++ src/lib/usage/providerLimits.ts | 3 + src/shared/constants/providers.ts | 2 + src/shared/constants/providers/web-cookie.ts | 13 + src/shared/providers/webSessionCredentials.ts | 7 + tests/snapshots/provider/translate-path.json | 23 + tests/unit/executor-hyperagent.test.ts | 243 +++++ 16 files changed, 1607 insertions(+), 1 deletion(-) create mode 100644 open-sse/config/providers/registry/hyperagent/index.ts create mode 100644 open-sse/executors/hyperagent.ts create mode 100644 open-sse/services/hyperagentModels.ts create mode 100644 open-sse/services/usage/hyperagent.ts create mode 100644 tests/unit/executor-hyperagent.test.ts diff --git a/.env.example b/.env.example index d0fc4022ec..9e0ae47260 100644 --- a/.env.example +++ b/.env.example @@ -2222,3 +2222,12 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # PROMPTQL_CREDITS_ENDPOINT=https://data.pro.ql.app/v1/graphql # PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token # PROMPTQL_POLL_TIMEOUT_MS=180000 + +# ───────────────────────────────────────────────────────────────────────────── +# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts) +# Reverse-engineered session bridge for hyperagent.com. Optional — defaults +# point at the public billing/usage endpoint; override only for a +# self-hosted/alternate HyperAgent deployment. +# Used by: open-sse/services/usage/hyperagent.ts +# ───────────────────────────────────────────────────────────────────────────── +# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f10f472352..ebba1936b0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.", "_rebaseline_2026_07_21_7301_universal_cooldown_retry": "PR #7301 (ViFigueiredo, feat/universal-cooldown-retry) own growth, surfaced during rebase-onto-tip reconciliation (fast-gates PR->release do not run check:file-size): open-sse/services/combo.ts 3388->3479 (+91) generalizes the existing quota-share-only cooldown-aware retry (dispatchWithCooldownRetry) to ALL combo strategies (priority/weighted/round-robin/etc), gates it on the model lockout's REAL reason (not a hardcoded \"rate_limit\") via the existing getModelLockoutInfo/resolveComboCooldownWaitDecision chokepoint, and adds a global comboTimeoutMs guard + aggregated per-target error diagnostics on exhaustion. Companion leaves open-sse/services/combo/comboCooldownRetry.ts (+29), combo/autoStrategy.ts (+9, auto-strategy combo-ref guard so a combo cannot recursively reference itself as a candidate), combo/comboSetup.ts (+3), comboConfig.ts (+6) all stay under cap. Irreducible orchestration wiring at the existing dispatch chokepoint (mirrors the quota-share-only precedent this PR generalizes); not extractable without hiding the retry loop. Covered by tests/unit/combo-auto-candidate-expansion.test.ts (+61, combo-ref guard), tests/unit/combo-routing-engine.test.ts (+68, universal retry across strategies + comboTimeoutMs, no-explicit-any clean), tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts (+136, quota_exhausted vs rate_limit reason gating, disabled-flag passthrough). Structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_07_21_7935_vi_locale_residual_ui": "PR #7935 (nguyenha935, fix/vietnamese-locale-residual) own growth: 9 dashboard components gained `useTranslations()` wiring (import + hook call + a handful of `t(\"key\")` call-sites replacing hardcoded English strings) as part of restoring i18n coverage — ComboHealthTab.tsx 1028->1031 (+3), cloud-agents/page.tsx 922->931 (+9), PoolWizard.tsx 1007->1022 (+15), EndpointPageClient.tsx 2612->2615 (+3), health/page.tsx 1091->1095 (+4), ProviderOnboardingWizard.tsx 912->948 (+36, largest — several previously-hardcoded wizard step labels/descriptions), PricingTab.tsx 1012->1017 (+5), ProxyRegistryManager.tsx 1461->1464 (+3), BudgetTab.tsx 1016->1028 (+12). All additions are literal `t(...)`/`tc(...)` call-site swaps for existing UI text, verified byte-identical in intent against the corresponding new `src/i18n/messages/{en,vi}.json` keys (see tests/unit/dashboard-localization-contract.test.ts, tests/unit/i18n-vi-completeness.test.ts, tests/unit/gamification-display-contract.test.ts, tests/unit/cli-catalog-display-contract.test.ts added by the same PR). Fast-gates PR->release do not run check:file-size, so this surfaced only during rebase-onto-tip reconciliation.", "_rebaseline_2026_07_21_7908_chathelpers_abort_guard": "PR #7908 (insoln, don't cool down accounts or trip the breaker on client-side stream aborts, #7907) own growth: src/sse/handlers/chatHelpers.ts 876->877 (+1 = the single `isLocalStreamLifecycleError(failure?.message ?? failure)` clause added to executeChatWithBreaker's onStreamFailure connection-disable check, verified working by the existing #4602 test + the PR's own circuit-breaker-client-abort.test.ts, no regressions). Irreducible call-site wiring at the existing failure-classification chokepoint. Fast-gates PR->release do not run check:file-size, so this surfaced only during the /green-prs pre-merge pass.", @@ -169,6 +170,7 @@ "_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.", "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1873, + "open-sse/executors/hyperagent.ts": 937, "open-sse/executors/muse-spark-web.ts": 1394, "open-sse/executors/perplexity-web.ts": 1032, "open-sse/handlers/audioSpeech.ts": 1061, @@ -268,7 +270,7 @@ "src/lib/resilience/settings.ts": 841, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 1000, + "src/lib/usage/providerLimits.ts": 1003, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 66c08a73b1..2f1780353d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -803,6 +803,16 @@ Reverse-engineered GraphQL session bridge for prompt.ql.app (`src/shared/constan --- +## HyperAgent Web Provider (Unofficial/Experimental) + +Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/providers/web-cookie.ts`). Optional — the default points at the public billing/usage endpoint; override only for a self-hosted/alternate HyperAgent deployment. + +| Variable | Default | Source File | Description | +| ------------------------ | ------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------- | +| `HYPERAGENT_USAGE_URL` | `https://hyperagent.com/api/settings/billing/usage` | `open-sse/services/usage/hyperagent.ts` | Endpoint used to fetch billing/usage credit blocks. | + +--- + ## 19. Model Sync (Dev) | Variable | Default | Source File | Description | diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 3e09509dd1..3fb8d957a4 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -209,6 +209,7 @@ import { routewayProvider } from "./registry/routeway/index.ts"; import { digitaloceanProvider } from "./registry/digitalocean/index.ts"; import { hcnsecProvider } from "./registry/hcnsec/index.ts"; import { promptqlProvider } from "./registry/promptql/index.ts"; +import { hyperagentProvider } from "./registry/hyperagent/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -420,4 +421,5 @@ export const REGISTRY: Record = { digitalocean: digitaloceanProvider, hcnsec: hcnsecProvider, promptql: promptqlProvider, + hyperagent: hyperagentProvider, }; diff --git a/open-sse/config/providers/registry/hyperagent/index.ts b/open-sse/config/providers/registry/hyperagent/index.ts new file mode 100644 index 0000000000..b271205729 --- /dev/null +++ b/open-sse/config/providers/registry/hyperagent/index.ts @@ -0,0 +1,20 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { HYPERAGENT_FALLBACK_MODELS } from "../../../../services/hyperagentModels.ts"; + +// HyperAgent (hyperagent.com) — unofficial reverse-engineered web session. +// Auth: browser Cookie header. Chat: POST /api/threads/{id}/chat (SSE). +// Credits: GET /api/settings/billing/usage → creditData.creditBlocks. +export const hyperagentProvider: RegistryEntry = { + id: "hyperagent", + alias: "ha", + format: "openai", + executor: "hyperagent", + baseUrl: "https://hyperagent.com/api/threads", + authType: "apikey", + authHeader: "cookie", + passthroughModels: true, + models: HYPERAGENT_FALLBACK_MODELS.map((m) => ({ + id: m.id, + name: m.name, + })), +}; diff --git a/open-sse/executors/hyperagent.ts b/open-sse/executors/hyperagent.ts new file mode 100644 index 0000000000..dd4a3c60fa --- /dev/null +++ b/open-sse/executors/hyperagent.ts @@ -0,0 +1,936 @@ +/** + * HyperAgentExecutor — hyperagent.com agent chat (Unofficial/Experimental) + * + * Reverse-engineered from SPA captures (2026-07-21, hyperagent/*.txt): + * - New thread: GET /threads/new (Next.js) → redirect /thread/{cuid} + * - Chat: POST /api/threads/{threadId}/chat (SSE data: lines) + * - First turn: sessionId=null → session_start event yields sessionId + * - Follow-up: same threadId + sessionId in body + * - Stream events: text / thinking / session_start / session_end / done / [DONE] + * - Auth: browser Cookie header (credentials:include) + * - Credits: GET /api/settings/billing/usage (usage/hyperagent.ts) + * + * OpenAI multi-turn is preserved via sticky thread+session cache (PromptQL-style). + */ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { + HYPERAGENT_FALLBACK_MODELS, + clientFacingHyperAgentModelId, + resolveHyperAgentModel, + wireHyperAgentModelId, + wireHyperAgentRuntimeId, + wireHyperAgentSubagentModelId, +} from "../services/hyperagentModels.ts"; + +const ORIGIN = "https://hyperagent.com"; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +const THREAD_CACHE_MAX = 200; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ChatMessage { + role: string; + content: unknown; +} + +interface HyperAgentRequestBody { + messages?: ChatMessage[]; + model?: string; + hyperagent_thread_id?: string; + thread_id?: string; + session_id?: string; + hyperagent_session_id?: string; +} + +type ThreadBinding = { + threadId: string; + sessionId: string; + projectKey: string; + updatedAt: number; +}; + +// ─── Credential helpers ───────────────────────────────────────────────────── + +function readStr(v: unknown): string { + if (typeof v !== "string") return ""; + const t = v.trim(); + return t.length ? t : ""; +} + +function readPs(data: unknown, keys: readonly string[]): string { + if (!data || typeof data !== "object" || Array.isArray(data)) return ""; + const rec = data as Record; + for (const k of keys) { + const v = readStr(rec[k]); + if (v) return v; + } + return ""; +} + +/** Normalize pasted cookie: full Cookie header or bare value. */ +export function normalizeHyperAgentCookie(raw: string): string { + const t = (raw || "").trim(); + if (!t) return ""; + // Strip accidental "Cookie: " prefix + return t.replace(/^Cookie:\s*/i, "").trim(); +} + +export function resolveHyperAgentCredentials(credentials: ExecuteInput["credentials"]): { + cookie: string; +} { + const direct = + readStr(credentials?.apiKey) || + readStr((credentials as Record | undefined)?.cookie) || + readStr((credentials as Record | undefined)?.accessToken); + const ps = credentials?.providerSpecificData; + const cookie = normalizeHyperAgentCookie( + direct || readPs(ps, ["cookie", "sessionCookie", "authCookie", "Cookie"]) + ); + return { cookie }; +} + +// ─── Message helpers ──────────────────────────────────────────────────────── + +export function extractMessageText(content: unknown): string { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object") { + const p = part as Record; + if (typeof p.text === "string") return p.text; + if (typeof p.content === "string") return p.content; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + } + if ( + content && + typeof content === "object" && + typeof (content as { text?: string }).text === "string" + ) { + return (content as { text: string }).text; + } + return ""; +} + +function lastUserText(messages: ChatMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const role = (messages[i]?.role || "").toLowerCase(); + if (role === "user" || role === "human" || role === "tool" || role === "function") { + return extractMessageText(messages[i]!.content).trim(); + } + } + return ""; +} + +// ─── Sticky thread cache ──────────────────────────────────────────────────── + +const memoryThreads = new Map(); + +function threadCachePath(): string | null { + const dataDir = process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR; + if (!dataDir) return null; + return join(dataDir, "hyperagent-thread-sessions.json"); +} + +function loadThreadDisk(): Record { + const p = threadCachePath(); + if (!p || !existsSync(p)) return {}; + try { + return JSON.parse(readFileSync(p, "utf8")) as Record; + } catch { + return {}; + } +} + +function saveThreadDisk(map: Record) { + const p = threadCachePath(); + if (!p) return; + try { + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(map), "utf8"); + } catch { + /* best-effort */ + } +} + +function getThreadBinding(key: string): ThreadBinding | null { + if (!key) return null; + const mem = memoryThreads.get(key); + if (mem) return mem; + const disk = loadThreadDisk()[key]; + if (disk) { + memoryThreads.set(key, disk); + return disk; + } + return null; +} + +function setThreadBinding(key: string, binding: ThreadBinding) { + if (!key) return; + memoryThreads.set(key, binding); + const disk = loadThreadDisk(); + disk[key] = binding; + const keys = Object.keys(disk); + if (keys.length > THREAD_CACHE_MAX) { + keys + .sort((a, b) => (disk[a]!.updatedAt || 0) - (disk[b]!.updatedAt || 0)) + .slice(0, keys.length - THREAD_CACHE_MAX) + .forEach((k) => { + delete disk[k]; + memoryThreads.delete(k); + }); + } + saveThreadDisk(disk); +} + +export function clearHyperAgentThreadBindingsForTests(opts?: { disk?: boolean }): void { + memoryThreads.clear(); + if (opts?.disk) { + const p = threadCachePath(); + if (p && existsSync(p)) { + try { + writeFileSync(p, "{}", "utf8"); + } catch { + /* ignore */ + } + } + } +} + +export function normalizeForFingerprint(text: string): string { + let t = (text || "").replace(/\r\n/g, "\n"); + t = t.replace(/^@\S+\s+/gm, ""); + t = t.replace(/^[\s\S]*?\bUser request:\s*/i, ""); + t = t.replace(/^[\s\S]*?\bCurrent request:\s*/i, ""); + t = t.replace(/\n{3,}/g, "\n\n"); + return t.trim().slice(0, 2000); +} + +function isFingerprintRole(role: string): boolean { + const r = (role || "").toLowerCase(); + if (!r || r === "system" || r === "developer") return false; + return true; +} + +export function conversationFingerprint(cookieKey: string, messages: ChatMessage[]): string { + const parts: string[] = [`ck:${cookieKey}`]; + for (const m of messages) { + const roleRaw = (m?.role || "").toLowerCase(); + if (!isFingerprintRole(roleRaw)) continue; + const role = + roleRaw === "tool" || roleRaw === "function" || roleRaw === "human" ? "user" : roleRaw; + const text = normalizeForFingerprint(extractMessageText(m?.content)); + if (!text) continue; + parts.push(`${role}:${text}`); + } + const h = createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 32); + return `ha:${cookieKey}:${h}`; +} + +export function historyPrefixBeforeLastUser(messages: ChatMessage[]): ChatMessage[] { + let lastUser = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const role = (messages[i]?.role || "").toLowerCase(); + if (role === "user" || role === "human" || role === "tool" || role === "function") { + lastUser = i; + break; + } + } + if (lastUser <= 0) return []; + return messages.slice(0, lastUser); +} + +export function hasAssistantMessage(messages: ChatMessage[]): boolean { + return messages.some((m) => { + const r = (m?.role || "").toLowerCase(); + return r === "assistant" || r === "ai" || r === "model"; + }); +} + +export function lastAssistantFingerprint( + cookieKey: string, + messages: ChatMessage[] +): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const role = (messages[i]?.role || "").toLowerCase(); + if (role !== "assistant" && role !== "ai" && role !== "model") continue; + const text = normalizeForFingerprint(extractMessageText(messages[i]?.content)); + if (!text) continue; + const h = createHash("sha256").update(text).digest("hex").slice(0, 24); + return `ha:${cookieKey}:asst:${h}`; + } + return null; +} + +/** Short stable key from cookie for cache isolation (not the full secret). */ +export function cookieFingerprint(cookie: string): string { + return createHash("sha256") + .update(cookie || "") + .digest("hex") + .slice(0, 16); +} + +export function readClientThreadIds( + body: HyperAgentRequestBody, + headers?: Record +): { threadId: string; sessionId: string } { + const fromBodyThread = readStr(body.hyperagent_thread_id) || readStr(body.thread_id); + const fromBodySession = readStr(body.hyperagent_session_id) || readStr(body.session_id); + if (!headers) return { threadId: fromBodyThread, sessionId: fromBodySession }; + const lower: Record = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = String(v ?? ""); + const threadId = + fromBodyThread || + readStr(lower["x-hyperagent-thread-id"]) || + readStr(lower["x-thread-id"]) || + ""; + const sessionId = + fromBodySession || + readStr(lower["x-hyperagent-session-id"]) || + readStr(lower["x-session-id"]) || + ""; + return { threadId, sessionId }; +} + +export type HyperAgentThreadResolve = { + threadId: string; + sessionId: string; + isFollowUp: boolean; + prefixKey: string | null; +}; + +export function resolveHyperAgentThreadBinding( + cookieKey: string, + messages: ChatMessage[], + clientThreadId?: string, + clientSessionId?: string +): HyperAgentThreadResolve { + const clientId = (clientThreadId || "").trim(); + const clientSess = (clientSessionId || "").trim(); + const prefix = historyPrefixBeforeLastUser(messages); + const prefixKey = + prefix.length > 0 && hasAssistantMessage(prefix) + ? conversationFingerprint(cookieKey, prefix) + : null; + + if (clientId) { + return { + threadId: clientId, + sessionId: clientSess, + isFollowUp: true, + prefixKey, + }; + } + + if (prefixKey) { + const cached = getThreadBinding(prefixKey); + if (cached?.threadId && cached.projectKey === cookieKey) { + return { + threadId: cached.threadId, + sessionId: cached.sessionId || clientSess, + isFollowUp: true, + prefixKey, + }; + } + } + + if (hasAssistantMessage(messages)) { + const asstKey = lastAssistantFingerprint(cookieKey, prefix.length ? prefix : messages); + if (asstKey) { + const cached = getThreadBinding(asstKey); + if (cached?.threadId && cached.projectKey === cookieKey) { + return { + threadId: cached.threadId, + sessionId: cached.sessionId || clientSess, + isFollowUp: true, + prefixKey: asstKey, + }; + } + } + } + + return { threadId: "", sessionId: "", isFollowUp: false, prefixKey: null }; +} + +export function storeHyperAgentThreadAfterTurn( + cookieKey: string, + messages: ChatMessage[], + assistantText: string, + threadId: string, + sessionId: string +): string | null { + if (!cookieKey || !threadId) return null; + const full: ChatMessage[] = [...messages, { role: "assistant", content: assistantText || "" }]; + if ( + !hasAssistantMessage(full) || + !messages.some((m) => { + const r = (m.role || "").toLowerCase(); + return r === "user" || r === "human" || r === "tool" || r === "function"; + }) + ) { + return null; + } + const binding: ThreadBinding = { + threadId, + sessionId: sessionId || "", + projectKey: cookieKey, + updatedAt: Date.now(), + }; + const key = conversationFingerprint(cookieKey, full); + setThreadBinding(key, binding); + const prefix = historyPrefixBeforeLastUser(messages); + if (prefix.length > 0 && hasAssistantMessage(prefix)) { + setThreadBinding(conversationFingerprint(cookieKey, prefix), binding); + } + const asstKey = lastAssistantFingerprint(cookieKey, full); + if (asstKey) setThreadBinding(asstKey, binding); + return key; +} + +// ─── HTTP helpers ─────────────────────────────────────────────────────────── + +function browserHeaders(cookie: string, extra?: Record): Record { + return { + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + cookie, + origin: ORIGIN, + referer: `${ORIGIN}/`, + "user-agent": USER_AGENT, + ...extra, + }; +} + +/** + * Create a new HyperAgent thread id. + * Primary (live-validated): POST /api/threads → { id }. + * Fallback: GET /threads/new (Next RSC) and parse Location / body. + */ +export async function createHyperAgentThread( + cookie: string, + signal?: AbortSignal | null +): Promise { + try { + const res = await fetch(`${ORIGIN}/api/threads`, { + method: "POST", + headers: browserHeaders(cookie, { + "content-type": "application/json", + "x-request-id": randomUUID(), + }), + body: JSON.stringify({}), + signal: signal ?? undefined, + redirect: "manual", + }); + const loc = res.headers.get("location") || res.headers.get("Location") || ""; + const fromLoc = extractThreadIdFromUrl(loc); + if (fromLoc) return fromLoc; + if (res.ok) { + const text = await res.text(); + try { + const j = JSON.parse(text) as Record; + const id = + readStr(j.id) || + readStr(j.threadId) || + readStr(j.thread_id) || + (j.thread && typeof j.thread === "object" + ? readStr((j.thread as Record).id) + : ""); + if (id) return id; + } catch { + const m = text.match(/cm[a-z0-9]{20,}/i); + if (m) return m[0]!; + } + } + } catch { + /* fall through */ + } + + const res2 = await fetch(`${ORIGIN}/threads/new`, { + method: "GET", + headers: browserHeaders(cookie, { + rsc: "1", + "next-url": "/", + "x-request-id": randomUUID(), + }), + signal: signal ?? undefined, + redirect: "manual", + }); + const loc2 = + res2.headers.get("location") || + res2.headers.get("Location") || + res2.headers.get("x-middleware-rewrite") || + ""; + const fromLoc2 = extractThreadIdFromUrl(loc2); + if (fromLoc2) return fromLoc2; + + if (res2.status >= 200 && res2.status < 400) { + const text = await res2.text().catch(() => ""); + const m = text.match(/\/thread\/(cm[a-z0-9]{20,})/i) || text.match(/"(cm[a-z0-9]{20,})"/i); + if (m) return m[1]!; + } + + throw new Error( + `Could not create HyperAgent thread (HTTP ${res2.status}). Ensure the session Cookie is valid and not expired.` + ); +} + +/** + * Apply model + execution settings on a thread (live SPA does this before chat). + * + * - modelId: wire id (e.g. fable-latest — NOT bare "fable") + * - defaultSubagentModel: short family (fable|opus|sonnet|haiku) matching selected model + * - executionMode: "auto" (only non-null value accepted live) + * - runtimeId: claude-agents-sdk for Claude family + * + * Chat body must NOT carry modelId (API returns model_unknown for bare pricing keys). + */ +export async function configureHyperAgentThread( + cookie: string, + threadId: string, + opts: { + modelId: string; + subagentModelId: string; + runtimeId?: string; + executionMode?: "auto" | null; + }, + signal?: AbortSignal | null +): Promise { + const body: Record = { + modelId: opts.modelId, + defaultSubagentModel: opts.subagentModelId, + runtimeId: opts.runtimeId || "claude-agents-sdk", + }; + // "auto" = execution-style agent loop (validated). null clears. Never "plan". + if (opts.executionMode === "auto") body.executionMode = "auto"; + else if (opts.executionMode === null) body.executionMode = null; + + const res = await fetch(`${ORIGIN}/api/threads/${encodeURIComponent(threadId)}`, { + method: "PATCH", + headers: browserHeaders(cookie, { + "content-type": "application/json", + "x-request-id": randomUUID(), + referer: `${ORIGIN}/thread/${threadId}`, + }), + body: JSON.stringify(body), + signal: signal ?? undefined, + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error( + `HyperAgent configure thread HTTP ${res.status}: ${errText.slice(0, 300) || res.statusText}` + ); + } +} + +export function extractThreadIdFromUrl(url: string): string { + if (!url) return ""; + const m = url.match(/\/thread\/([A-Za-z0-9_-]{10,})/i) || url.match(/(cm[a-z0-9]{20,})/i); + return m ? m[1]! : ""; +} + +/** + * Default feature flags from live SPA **execution-mode** chat body. + * + * Important differences from older plan-mode captures: + * - Do NOT set injectPlanMode (plan mode). Execution omits the field entirely. + * - Do NOT put modelId/model here — model is PATCH'd onto the thread first. + * - enabledIntegrations: [] — no connectors; integrationMode stays "open" like SPA. + */ +export function buildHyperAgentChatBody(opts: { + content: string; + sessionId: string | null; + /** @deprecated Model is configured on the thread via PATCH — ignored. */ + modelId?: string; +}): Record { + return { + sessionId: opts.sessionId, + unifiedStream: true, + searchMode: "exa", + enableExecuteScript: false, + enablePersistentSandbox: true, + enableWebpage: true, + enableSlides: true, + tablesEnabled: true, + enableWebSearch: true, + enableBrowser: true, + enableImageGeneration: true, + enableVideoGeneration: true, + enableAudioGeneration: true, + enableTranscription: true, + enableAvatarVideo: true, + enableExaFindSimilar: true, + enableExaAnswer: true, + enableExaResearch: true, + enableExaWebsets: true, + enableGeoTools: true, + hyperAppsEnabled: false, + documentsEnabled: true, + enableThreadSearch: true, + residentialProxyEnabled: false, + solveCaptchasEnabled: true, + content: opts.content, + debug: false, + // No connectors — empty list (SPA execution capture). + enabledIntegrations: [], + integrationMode: "open", + globalTablesEnabled: true, + // NO injectPlanMode → execution mode (plan mode was injectPlanMode:true). + // NO modelId / model → set via configureHyperAgentThread PATCH. + }; +} + +/** + * Parse HyperAgent SSE stream into assistant text (+ sessionId). + * Accumulates `type:"text"` content; ignores thinking for the OpenAI body. + */ +export async function parseHyperAgentSseStream( + response: Response +): Promise<{ text: string; sessionId: string; modelId: string; events: number }> { + if (!response.body) { + throw new Error("Empty HyperAgent stream body"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let text = ""; + let sessionId = ""; + let modelId = ""; + let events = 0; + + const handleData = (payload: string) => { + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return; + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + return; + } + events += 1; + const type = readStr(obj.type); + if (type === "text") { + text += typeof obj.content === "string" ? obj.content : ""; + } else if (type === "session_start") { + const sid = readStr(obj.sessionId); + if (sid) sessionId = sid; + } else if (type === "thread_runtime_latched") { + const mid = readStr(obj.modelId); + if (mid) modelId = mid; + } else if (type === "error" || type === "stream_error") { + const msg = + readStr(obj.content) || + readStr(obj.message) || + readStr(obj.error) || + "HyperAgent stream error"; + throw new Error(msg); + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + // Split SSE frames + const parts = buffer.split("\n"); + buffer = parts.pop() || ""; + for (const line of parts) { + const t = line.trimEnd(); + if (t.startsWith("data:")) { + handleData(t.slice(5).trimStart()); + } + } + } + if (buffer.trim()) { + const t = buffer.trim(); + if (t.startsWith("data:")) handleData(t.slice(5).trimStart()); + } + + return { text, sessionId, modelId, events }; +} + +// ─── OpenAI response helpers ──────────────────────────────────────────────── + +function estimateUsage(messages: ChatMessage[] | undefined, content: string) { + const prompt = (messages || []).map((m) => extractMessageText(m.content)).join("\n"); + const prompt_tokens = Math.max(1, Math.ceil(prompt.length / 4)); + const completion_tokens = Math.max(1, Math.ceil(content.length / 4)); + return { + prompt_tokens, + completion_tokens, + total_tokens: prompt_tokens + completion_tokens, + estimated: true, + }; +} + +function chatCompletionResponse( + content: string, + model: string, + messages: ChatMessage[] | undefined, + threadId?: string, + sessionId?: string +) { + const id = threadId ? `chatcmpl-ha-${threadId}` : `chatcmpl-ha-${Date.now()}`; + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: estimateUsage(messages, content), + hyperagent_thread_id: threadId || undefined, + hyperagent_session_id: sessionId || undefined, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + ...(threadId ? { "X-HyperAgent-Thread-Id": threadId } : {}), + ...(sessionId ? { "X-HyperAgent-Session-Id": sessionId } : {}), + }, + } + ); +} + +function pseudoStreamResponse( + content: string, + model: string, + threadId?: string, + sessionId?: string +) { + const encoder = new TextEncoder(); + const id = threadId ? `chatcmpl-ha-${threadId}` : `chatcmpl-ha-${Date.now()}`; + const chunk = (delta: string, finishReason: string | null) => ({ + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }], + }); + const readable = new ReadableStream({ + start(controller) { + const parts = content.match(/\S+\s*/g) || [content]; + let buf = ""; + for (const p of parts) { + buf += p; + if (buf.length >= 40) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(buf, null))}\n\n`)); + buf = ""; + } + } + if (buf) controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(buf, null))}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(readable, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...(threadId ? { "X-HyperAgent-Thread-Id": threadId } : {}), + ...(sessionId ? { "X-HyperAgent-Session-Id": sessionId } : {}), + }, + }); +} + +// ─── Executor ─────────────────────────────────────────────────────────────── + +export class HyperAgentExecutor extends BaseExecutor { + constructor() { + super("hyperagent", { + id: "hyperagent", + baseUrl: `${ORIGIN}/api/threads`, + }); + } + + async execute(input: ExecuteInput) { + const { model, body, stream: wantStream, credentials, signal } = input; + const requestBody = (body || {}) as HyperAgentRequestBody; + const { cookie } = resolveHyperAgentCredentials(credentials); + + if (!cookie) { + return makeErrorResult( + 401, + "Missing HyperAgent session cookie — paste the full Cookie header from hyperagent.com (DevTools → Network → any document request → Request Headers → Cookie)", + body, + `${ORIGIN}/api/threads` + ); + } + + const messages = requestBody.messages || []; + const userText = lastUserText(messages); + if (!userText) { + return makeErrorResult(400, "No user message found", body, `${ORIGIN}/api/threads`); + } + + const clientFacing = clientFacingHyperAgentModelId(model || requestBody.model); + const wireModel = wireHyperAgentModelId(model || requestBody.model); + const subagentModel = wireHyperAgentSubagentModelId(model || requestBody.model); + const runtimeId = wireHyperAgentRuntimeId(model || requestBody.model); + const cookieKey = cookieFingerprint(cookie); + + const inboundHeaders = + (input.clientHeaders as Record | null | undefined) ?? + ((input as { headers?: Record }).headers as + Record | undefined); + const clientIds = readClientThreadIds(requestBody, inboundHeaders ?? undefined); + const binding = resolveHyperAgentThreadBinding( + cookieKey, + messages, + clientIds.threadId, + clientIds.sessionId + ); + + let threadId = binding.threadId; + let sessionId = binding.sessionId || null; + + try { + if (!binding.isFollowUp || !threadId) { + threadId = await createHyperAgentThread(cookie, signal); + sessionId = null; + } + + // Always apply model + execution settings on the thread (SPA does this + // before /chat). Chat body must not carry modelId. + await configureHyperAgentThread( + cookie, + threadId, + { + modelId: wireModel, + subagentModelId: subagentModel, + runtimeId, + executionMode: "auto", + }, + signal + ); + + const chatUrl = `${ORIGIN}/api/threads/${encodeURIComponent(threadId)}/chat`; + const chatBody = buildHyperAgentChatBody({ + content: userText, + sessionId, + }); + + const res = await fetch(chatUrl, { + method: "POST", + headers: browserHeaders(cookie, { + "content-type": "application/json", + referer: `${ORIGIN}/thread/${threadId}`, + "x-request-id": randomUUID(), + }), + body: JSON.stringify(chatBody), + signal: signal ?? undefined, + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + // Stale thread → create once, reconfigure, retry + if (res.status === 404 || /not found|unknown thread/i.test(errText)) { + threadId = await createHyperAgentThread(cookie, signal); + sessionId = null; + await configureHyperAgentThread( + cookie, + threadId, + { + modelId: wireModel, + subagentModelId: subagentModel, + runtimeId, + executionMode: "auto", + }, + signal + ); + const retryUrl = `${ORIGIN}/api/threads/${encodeURIComponent(threadId)}/chat`; + const retryBody = buildHyperAgentChatBody({ + content: userText, + sessionId: null, + }); + const res2 = await fetch(retryUrl, { + method: "POST", + headers: browserHeaders(cookie, { + "content-type": "application/json", + referer: `${ORIGIN}/thread/${threadId}`, + "x-request-id": randomUUID(), + }), + body: JSON.stringify(retryBody), + signal: signal ?? undefined, + }); + if (!res2.ok) { + const t2 = await res2.text().catch(() => ""); + return makeErrorResult( + res2.status >= 400 && res2.status < 600 ? res2.status : 502, + `HyperAgent chat HTTP ${res2.status}: ${t2.slice(0, 300)}`, + body, + retryUrl + ); + } + const parsed2 = await parseHyperAgentSseStream(res2); + return finalize(parsed2, messages, clientFacing, threadId, cookieKey, wantStream); + } + return makeErrorResult( + res.status >= 400 && res.status < 600 ? res.status : 502, + `HyperAgent chat HTTP ${res.status}: ${errText.slice(0, 300)}`, + body, + chatUrl + ); + } + + const parsed = await parseHyperAgentSseStream(res); + // Prefer session from stream; keep prior if stream omitted on follow-up + if (!parsed.sessionId && sessionId) parsed.sessionId = sessionId; + return finalize(parsed, messages, clientFacing, threadId, cookieKey, wantStream); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const status = /cookie|401|unauthor/i.test(msg) ? 401 : /timeout/i.test(msg) ? 504 : 502; + return makeErrorResult(status, `HyperAgent: ${msg}`, body, `${ORIGIN}/api/threads`); + } + } +} + +function finalize( + parsed: { text: string; sessionId: string; modelId: string; events: number }, + messages: ChatMessage[], + clientFacing: string, + threadId: string, + cookieKey: string, + wantStream?: boolean +) { + const text = (parsed.text || "").trim(); + if (!text) { + return makeErrorResult( + 502, + `HyperAgent returned empty content (events=${parsed.events})`, + undefined, + `${ORIGIN}/api/threads` + ); + } + storeHyperAgentThreadAfterTurn(cookieKey, messages, text, threadId, parsed.sessionId || ""); + const modelOut = parsed.modelId || clientFacing; + const response = wantStream + ? pseudoStreamResponse(text, modelOut, threadId, parsed.sessionId) + : chatCompletionResponse(text, modelOut, messages, threadId, parsed.sessionId); + return { + response, + url: `${ORIGIN}/api/threads/${threadId}/chat`, + headers: { Cookie: "***" }, + transformedBody: { + threadId, + sessionId: parsed.sessionId || null, + model: modelOut, + }, + }; +} + +export { HYPERAGENT_FALLBACK_MODELS, ORIGIN as HYPERAGENT_ORIGIN }; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index c891133415..7c5305e1f3 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -64,6 +64,7 @@ import { MimocodeExecutor } from "./mimocode.ts"; import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +import { HyperAgentExecutor } from "./hyperagent.ts"; import { XaiExecutor } from "./xai.ts"; import { PromptQlExecutor } from "./promptql.ts"; @@ -181,6 +182,8 @@ const executors = { "codebuddy-cn": new CodeBuddyCnExecutor(), cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), + hyperagent: new HyperAgentExecutor(), + ha: new HyperAgentExecutor(), // Alias zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free auggie: new AuggieExecutor(), xai: new XaiExecutor(), @@ -273,6 +276,7 @@ export { MimocodeExecutor } from "./mimocode.ts"; export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +export { HyperAgentExecutor } from "./hyperagent.ts"; export { XaiExecutor } from "./xai.ts"; export { MoonshotExecutor } from "./moonshot.ts"; export { PromptQlExecutor } from "./promptql.ts"; diff --git a/open-sse/services/hyperagentModels.ts b/open-sse/services/hyperagentModels.ts new file mode 100644 index 0000000000..b1bbbf9a55 --- /dev/null +++ b/open-sse/services/hyperagentModels.ts @@ -0,0 +1,162 @@ +/** + * HyperAgent (hyperagent.com) model catalog. + * + * Wire IDs were validated live against PATCH /api/threads/{id} (2026-07-21): + * Main modelId: fable-latest | opus-latest | sonnet-latest | claude-fable-5 | + * claude-opus-4-8 | claude-sonnet-5 + * Subagent (defaultSubagentModel): short family names only — fable | opus | sonnet | haiku + * + * Pricing keys like bare "fable" are NOT valid chat modelIds (API returns model_unknown). + * Model is applied on the THREAD via PATCH, not in the /chat body. + */ + +export interface HyperAgentModel { + /** Wire modelId for PATCH /api/threads/{id}. */ + id: string; + /** Pretty picker / /v1/models name. */ + name: string; + /** Short subagent override (defaultSubagentModel). */ + subagent: "fable" | "opus" | "sonnet" | "haiku"; + /** Agent runtime for Claude family models. */ + runtimeId?: string; +} + +/** Valid selectable models (live-validated). */ +export const HYPERAGENT_FALLBACK_MODELS: HyperAgentModel[] = [ + { id: "fable-latest", name: "Fable 5", subagent: "fable", runtimeId: "claude-agents-sdk" }, + { + id: "claude-fable-5", + name: "Claude Fable 5", + subagent: "fable", + runtimeId: "claude-agents-sdk", + }, + { + id: "opus-latest", + name: "Claude Opus Latest", + subagent: "opus", + runtimeId: "claude-agents-sdk", + }, + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + subagent: "opus", + runtimeId: "claude-agents-sdk", + }, + { + id: "sonnet-latest", + name: "Claude Sonnet Latest", + subagent: "sonnet", + runtimeId: "claude-agents-sdk", + }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + subagent: "sonnet", + runtimeId: "claude-agents-sdk", + }, +]; + +export function stripHyperAgentModelPrefix(model: string): string { + let m = (model || "").trim(); + if (m.startsWith("hyperagent/")) m = m.slice("hyperagent/".length); + else if (m.startsWith("ha/")) m = m.slice(3); + else if (m.startsWith("hyper/")) m = m.slice("hyper/".length); + return m; +} + +/** Alias / pretty-name / legacy pricing-key → catalog wire id. */ +const ALIASES: Record = { + // Fable + fable: "fable-latest", + "fable-5": "fable-latest", + fable5: "fable-latest", + "claude-fable-5": "claude-fable-5", + "claude-fable": "fable-latest", + "fable-latest": "fable-latest", + // Opus + opus: "opus-latest", + "opus-latest": "opus-latest", + "opus-4-8": "claude-opus-4-8", + "opus-4.8": "claude-opus-4-8", + "claude-opus-4-8": "claude-opus-4-8", + "claude-opus-4.8": "claude-opus-4-8", + "claude-opus-latest": "opus-latest", + // Sonnet + sonnet: "sonnet-latest", + "sonnet-latest": "sonnet-latest", + "sonnet-5": "claude-sonnet-5", + "claude-sonnet-5": "claude-sonnet-5", + "claude-sonnet-latest": "sonnet-latest", + // Haiku subagent only — map main requests to sonnet-latest as closest chat model + haiku: "sonnet-latest", + "haiku-4": "sonnet-latest", + "claude-haiku-4": "sonnet-latest", +}; + +export function resolveHyperAgentModel(model: unknown): HyperAgentModel | null { + const raw = typeof model === "string" ? stripHyperAgentModelPrefix(model) : ""; + if (!raw) return null; + const lower = raw.toLowerCase().trim(); + const compact = lower.replace(/[\s_]+/g, "-"); + const catalog = HYPERAGENT_FALLBACK_MODELS; + + // Exact wire id + const byId = catalog.find((m) => m.id.toLowerCase() === lower || m.id.toLowerCase() === compact); + if (byId) return byId; + + // Pretty name + const byName = catalog.find((m) => m.name.toLowerCase() === lower); + if (byName) return byName; + + // Alias table + const aliasId = ALIASES[compact] || ALIASES[lower]; + if (aliasId) { + const hit = catalog.find((m) => m.id === aliasId); + if (hit) return hit; + } + + // Partial contains + return ( + catalog.find((m) => compact.includes(m.id.toLowerCase())) || + catalog.find((m) => m.name.toLowerCase().includes(compact.replace(/-/g, " "))) || + null + ); +} + +/** Client-facing / OpenAI response model id (wire id). */ +export function clientFacingHyperAgentModelId(model: unknown): string { + const resolved = resolveHyperAgentModel(model); + if (resolved) return resolved.id; + const stripped = typeof model === "string" ? stripHyperAgentModelPrefix(model) : ""; + return stripped || "opus-latest"; +} + +/** + * Wire modelId for PATCH /api/threads/{id}. + * Never returns bare "fable" (invalid) — maps to fable-latest. + */ +export function wireHyperAgentModelId(model: unknown): string { + return clientFacingHyperAgentModelId(model); +} + +/** + * Short subagent family for defaultSubagentModel. + * Live API accepts only: fable | opus | sonnet | haiku (not *-latest). + * User request: subagent matches the selected model family. + */ +export function wireHyperAgentSubagentModelId(model: unknown): string { + const resolved = resolveHyperAgentModel(model); + if (resolved?.subagent) return resolved.subagent; + + const wire = wireHyperAgentModelId(model).toLowerCase(); + if (wire.includes("fable")) return "fable"; + if (wire.includes("sonnet")) return "sonnet"; + if (wire.includes("haiku")) return "haiku"; + if (wire.includes("opus")) return "opus"; + return "opus"; +} + +export function wireHyperAgentRuntimeId(model: unknown): string { + const resolved = resolveHyperAgentModel(model); + return resolved?.runtimeId || "claude-agents-sdk"; +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index a81f5c95bc..7682784f04 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -11,6 +11,7 @@ import { getOpenrouterUsage } from "./usage/openrouter.ts"; import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; import { getPromptQlUsage } from "./usage/promptql.ts"; +import { getHyperAgentUsage } from "./usage/hyperagent.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, @@ -544,6 +545,9 @@ export const USAGE_FETCHER_PROVIDERS = [ // PromptQL playground credits (data.pro.ql.app getCreditSummary) "promptql", "pql", + // HyperAgent billing usage (creditBlocks USD) + "hyperagent", + "ha", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -633,6 +637,9 @@ export async function getUsageForProvider( providerSpecificData, projectId ); + case "hyperagent": + case "ha": + return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/open-sse/services/usage/hyperagent.ts b/open-sse/services/usage/hyperagent.ts new file mode 100644 index 0000000000..30d8910d56 --- /dev/null +++ b/open-sse/services/usage/hyperagent.ts @@ -0,0 +1,163 @@ +/** + * HyperAgent billing usage → UsageQuota for Limits page. + * + * Live capture (hyperagent/get_balance.txt): + * GET https://hyperagent.com/api/settings/billing/usage + * credentials: include (session Cookie) + * + * creditData.creditBlocks[]: + * initialUsd → total ($500) + * remainingUsd → remaining ($499.56) + * usedUsd → used + * expiryDate → optional resetAt + * + * plan.name → "Pay As You Go" etc. + */ +import { type UsageQuota } from "./quota.ts"; +import { toNumber } from "@/shared/utils/numeric"; + +const USAGE_URL = + process.env.HYPERAGENT_USAGE_URL || "https://hyperagent.com/api/settings/billing/usage"; + +function readStr(v: unknown): string { + if (typeof v !== "string") return ""; + const t = v.trim(); + return t.length ? t : ""; +} + +function readPs(data: unknown, keys: string[]): string { + if (!data || typeof data !== "object" || Array.isArray(data)) return ""; + const rec = data as Record; + for (const k of keys) { + const v = readStr(rec[k]); + if (v) return v; + } + return ""; +} + +/** Round money to 2 decimals for UI. */ +export function roundUsd(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.round(n * 100) / 100; +} + +export function buildHyperAgentCreditsQuota(block: { + initialUsd?: number | null; + remainingUsd?: number | null; + usedUsd?: number | null; + expiryDate?: string | null; +}): UsageQuota { + const total = roundUsd(toNumber(block.initialUsd)); + const remaining = roundUsd(toNumber(block.remainingUsd)); + let used = roundUsd(toNumber(block.usedUsd)); + if (used <= 0 && total > 0) used = roundUsd(Math.max(0, total - remaining)); + const rem = remaining > 0 || total <= 0 ? remaining : Math.max(0, total - used); + const remainingPercentage = total > 0 ? Math.round((rem / total) * 1000) / 10 : rem > 0 ? 100 : 0; + const expiry = readStr(block.expiryDate) || null; + return { + used, + total: total > 0 ? total : used + rem, + remaining: rem, + remainingPercentage, + resetAt: expiry, + unlimited: false, + currency: "USD", + displayName: "Credits (USD)", + }; +} + +function normalizeCookie(raw: string): string { + const t = (raw || "").trim(); + if (!t) return ""; + // Accept bare session value — pass through; full Cookie header preferred. + return t; +} + +export function resolveHyperAgentCookie( + apiKey?: string, + providerSpecificData?: Record | null +): string { + const direct = normalizeCookie(apiKey || ""); + if (direct) return direct; + return normalizeCookie( + readPs(providerSpecificData, ["cookie", "sessionCookie", "authCookie", "Cookie"]) + ); +} + +export async function getHyperAgentUsage( + apiKey?: string, + providerSpecificData?: Record | null +) { + const cookie = resolveHyperAgentCookie(apiKey, providerSpecificData); + if (!cookie) { + return { + message: + "HyperAgent session cookie not available. Paste the full Cookie header from hyperagent.com (DevTools → Network → any request → Cookie).", + }; + } + + try { + const res = await fetch(USAGE_URL, { + method: "GET", + headers: { + accept: "application/json", + cookie, + origin: "https://hyperagent.com", + referer: "https://hyperagent.com/settings/billing", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + }, + }); + if (!res.ok) { + const bodyText = await res.text().catch(() => ""); + return { + message: `HyperAgent usage HTTP ${res.status}${bodyText ? `: ${bodyText.slice(0, 200)}` : ""}`, + plan: "HyperAgent", + }; + } + const json = (await res.json()) as { + plan?: { id?: string; name?: string; monthlyPriceUsd?: number; includedUsageUsd?: number }; + orbCostsAndUsage?: { totalCost?: string | number; subtotalCost?: string | number }; + creditData?: { + subscriptionCredits?: unknown; + creditBlocks?: Array<{ + id?: string; + initialUsd?: number; + remainingUsd?: number; + usedUsd?: number; + expiryDate?: string | null; + status?: string; + }>; + }; + openInvoiceTotalUsd?: number; + bonusCreditsUsedThisPeriodUsd?: number; + }; + + const blocks = json.creditData?.creditBlocks || []; + const active = blocks.find((b) => (b.status || "").toLowerCase() === "active") || blocks[0]; + if (!active) { + return { + message: "No active HyperAgent credit block (creditData.creditBlocks empty).", + plan: json.plan?.name || "HyperAgent", + }; + } + + const credits = buildHyperAgentCreditsQuota(active); + const planName = readStr(json.plan?.name) || "HyperAgent"; + return { + plan: planName, + quotas: { + credits, + }, + remainingUsd: credits.remaining, + drawnUsd: credits.used, + availableUsd: credits.total, + totalCost: toNumber(json.orbCostsAndUsage?.totalCost), + }; + } catch (err) { + return { + message: `HyperAgent usage failed: ${err instanceof Error ? err.message : String(err)}`, + plan: "HyperAgent", + }; + } +} diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 8499215e4d..2abea5bfba 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -82,6 +82,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "qoder", "promptql", // PromptQL playground JWT → getCreditSummary USD credits "pql", + // HyperAgent session cookie → billing/usage creditBlocks + "hyperagent", + "ha", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index c82108ae2e..81e9fd5502 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -444,6 +444,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ // PromptQL playground credits (getCreditSummary → USD micros) "promptql", "pql", + "hyperagent", + "ha", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index ba16bcbf33..fe3def5b00 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -423,6 +423,19 @@ export const WEB_COOKIE_PROVIDERS = { "Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). " + "Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional.", }, + hyperagent: { + id: "hyperagent", + alias: "ha", + name: "HyperAgent (Unofficial/Experimental)", + icon: "auto_awesome", + color: "#6C5CE7", + textIcon: "HA", + website: "https://hyperagent.com", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + authHint: + "Paste the full Cookie header from hyperagent.com (DevTools → Network → any request → Request Headers → Cookie). Session cookies power chat + billing usage.", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index e8f896e31d..bef2a791d7 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -82,6 +82,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], }, + hyperagent: { + kind: "cookie", + credentialName: "Session Cookie", + placeholder: "Paste full Cookie header from hyperagent.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionCookie", "authCookie"], + }, "blackbox-web": { kind: "cookie", credentialName: "__Secure-authjs.session-token", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index de1e271d8f..929dbd02cc 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2483,6 +2483,29 @@ "stream": "https://router.huggingface.co/v1/chat/completions" } }, + "hyperagent": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://hyperagent.com/api/threads", + "stream": "https://hyperagent.com/api/threads" + } + }, "hyperbolic": { "format": "openai", "headers": { diff --git a/tests/unit/executor-hyperagent.test.ts b/tests/unit/executor-hyperagent.test.ts new file mode 100644 index 0000000000..a8ab478fef --- /dev/null +++ b/tests/unit/executor-hyperagent.test.ts @@ -0,0 +1,243 @@ +// Unit tests for HyperAgent (hyperagent.com) unofficial session bridge. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../open-sse/executors/hyperagent.ts"); +const usage = await import("../../open-sse/services/usage/hyperagent.ts"); +const models = await import("../../open-sse/services/hyperagentModels.ts"); +const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts"); +const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers/web-cookie.ts"); + +describe("HyperAgent — registry consistency", () => { + it("is present in WEB_COOKIE_PROVIDERS", () => { + const entry = (WEB_COOKIE_PROVIDERS as Record>)["hyperagent"]; + assert.ok(entry, "hyperagent missing from WEB_COOKIE_PROVIDERS"); + assert.equal(entry.id, "hyperagent"); + assert.equal(entry.alias, "ha"); + assert.equal(entry.subscriptionRisk, true); + }); + + it("registers a model catalog via getModelsByProviderId", () => { + const catalog = getModelsByProviderId("hyperagent"); + assert.ok(catalog.length >= 4, `expected validated models, got ${catalog.length}`); + // Wire ids are live-validated (fable-latest, not bare "fable") + assert.ok(catalog.some((m) => m.id === "fable-latest")); + assert.ok(catalog.some((m) => m.id === "opus-latest")); + assert.ok(catalog.some((m) => m.id === "sonnet-latest")); + // Pretty names in registry + const fable = catalog.find((m) => m.id === "fable-latest"); + assert.ok(fable?.name?.toLowerCase().includes("fable")); + assert.notEqual(fable?.name?.toLowerCase(), "fable"); + }); + + it("registers hyperagent on usage-fetcher + limits allowlists", async () => { + const usageMain = await import("../../open-sse/services/usage.ts"); + assert.ok( + (usageMain.USAGE_FETCHER_PROVIDERS as readonly string[]).includes("hyperagent"), + "USAGE_FETCHER_PROVIDERS must list hyperagent" + ); + assert.ok((usageMain.USAGE_FETCHER_PROVIDERS as readonly string[]).includes("ha")); + const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + assert.ok( + (USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("hyperagent"), + "USAGE_SUPPORTED_PROVIDERS must list hyperagent" + ); + }); +}); + +describe("HyperAgent — models", () => { + it("maps pretty names / legacy keys to live wire modelId (not bare fable)", () => { + // Bare "fable" is INVALID on the API — must become fable-latest + assert.equal(models.wireHyperAgentModelId("fable"), "fable-latest"); + assert.equal(models.wireHyperAgentModelId("fable-5"), "fable-latest"); + assert.equal(models.wireHyperAgentModelId("Fable 5"), "fable-latest"); + assert.equal(models.wireHyperAgentModelId("hyperagent/fable"), "fable-latest"); + assert.equal(models.wireHyperAgentModelId("ha/opus-latest"), "opus-latest"); + assert.equal(models.wireHyperAgentModelId("claude-opus-4-8"), "claude-opus-4-8"); + assert.equal(models.clientFacingHyperAgentModelId("fable"), "fable-latest"); + }); + + it("maps subagent to short family matching selected model", () => { + assert.equal(models.wireHyperAgentSubagentModelId("fable"), "fable"); + assert.equal(models.wireHyperAgentSubagentModelId("fable-latest"), "fable"); + assert.equal(models.wireHyperAgentSubagentModelId("opus-latest"), "opus"); + assert.equal(models.wireHyperAgentSubagentModelId("sonnet-latest"), "sonnet"); + // Subagent must NOT be *-latest (API rejects those) + assert.notEqual(models.wireHyperAgentSubagentModelId("fable-latest"), "fable-latest"); + }); + + it("resolveHyperAgentModel finds by id and pretty name", () => { + const a = models.resolveHyperAgentModel("fable"); + assert.ok(a); + assert.equal(a!.id, "fable-latest"); + assert.match(a!.name, /Fable/i); + const b = models.resolveHyperAgentModel("Fable 5"); + assert.ok(b); + assert.equal(b!.id, "fable-latest"); + }); +}); + +describe("HyperAgent — helpers", () => { + it("normalizes cookie input", () => { + assert.equal(mod.normalizeHyperAgentCookie("Cookie: a=1; b=2"), "a=1; b=2"); + assert.equal(mod.normalizeHyperAgentCookie(" sess=xyz "), "sess=xyz"); + }); + + it("extracts thread id from SPA URLs", () => { + assert.equal( + mod.extractThreadIdFromUrl("https://hyperagent.com/thread/cmrujkys70aiu07addcodbsj3"), + "cmrujkys70aiu07addcodbsj3" + ); + assert.equal( + mod.extractThreadIdFromUrl("/thread/cmabc123def456ghi789jkl"), + "cmabc123def456ghi789jkl" + ); + }); + + it("buildHyperAgentChatBody is execution-mode (no plan / no modelId)", () => { + const b = mod.buildHyperAgentChatBody({ + content: "hello", + sessionId: null, + modelId: "fable-latest", // ignored — model is PATCH'd on thread + }); + assert.equal(b.content, "hello"); + assert.equal(b.sessionId, null); + assert.equal(b.unifiedStream, true); + // Execution mode: never inject plan mode + assert.equal(b.injectPlanMode, undefined); + // Model is NOT in chat body (would 400 with bare pricing keys) + assert.equal(b.modelId, undefined); + assert.equal(b.model, undefined); + // No connectors + assert.deepEqual(b.enabledIntegrations, []); + const b2 = mod.buildHyperAgentChatBody({ + content: "hello2", + sessionId: "dd6d5eee-5c1c-449f-8dee-abb09eabd338", + }); + assert.equal(b2.sessionId, "dd6d5eee-5c1c-449f-8dee-abb09eabd338"); + assert.equal(b2.injectPlanMode, undefined); + }); + + it("buildHyperAgentCreditsQuota maps live creditBlocks", () => { + const q = usage.buildHyperAgentCreditsQuota({ + initialUsd: 500, + remainingUsd: 499.568805, + usedUsd: 0.431195, + expiryDate: "2027-01-21T00:00:00+00:00", + }); + assert.equal(q.currency, "USD"); + assert.equal(q.total, 500); + assert.equal(q.remaining, 499.57); + assert.equal(q.used, 0.43); + assert.ok((q.remainingPercentage ?? 0) > 99); + assert.equal(q.displayName, "Credits (USD)"); + assert.ok(q.resetAt); + }); +}); + +describe("HyperAgent — thread continuity", () => { + const cookieKey = "testck1234567890"; + + it("two chats with different histories stay isolated; follow-up sticks", () => { + mod.clearHyperAgentThreadBindingsForTests(); + const a1 = [{ role: "user", content: "topic A" }]; + const b1 = [{ role: "user", content: "topic B" }]; + assert.equal(mod.resolveHyperAgentThreadBinding(cookieKey, a1).isFollowUp, false); + mod.storeHyperAgentThreadAfterTurn(cookieKey, a1, "reply-A", "thread-A", "sess-A"); + mod.storeHyperAgentThreadAfterTurn(cookieKey, b1, "reply-B", "thread-B", "sess-B"); + + const a2 = [ + { role: "user", content: "topic A" }, + { role: "assistant", content: "reply-A" }, + { role: "user", content: "follow A" }, + ]; + const b2 = [ + { role: "user", content: "topic B" }, + { role: "assistant", content: "reply-B" }, + { role: "user", content: "follow B" }, + ]; + const ra = mod.resolveHyperAgentThreadBinding(cookieKey, a2); + const rb = mod.resolveHyperAgentThreadBinding(cookieKey, b2); + assert.equal(ra.isFollowUp, true); + assert.equal(ra.threadId, "thread-A"); + assert.equal(ra.sessionId, "sess-A"); + assert.equal(rb.threadId, "thread-B"); + assert.notEqual(ra.threadId, rb.threadId); + }); + + it("honors explicit client thread id", () => { + mod.clearHyperAgentThreadBindingsForTests(); + const r = mod.resolveHyperAgentThreadBinding( + cookieKey, + [{ role: "user", content: "x" }], + "client-thread-99", + "client-sess" + ); + assert.equal(r.isFollowUp, true); + assert.equal(r.threadId, "client-thread-99"); + assert.equal(r.sessionId, "client-sess"); + }); +}); + +describe("HyperAgentExecutor — auth / validation", () => { + it("can be instantiated", () => { + const executor = new mod.HyperAgentExecutor(); + assert.ok(executor); + assert.equal(executor.getProvider(), "hyperagent"); + }); + + it("returns 401 when no cookie is supplied", async () => { + const executor = new mod.HyperAgentExecutor(); + const result = await executor.execute({ + model: "fable", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {}, + signal: null, + } as never); + assert.equal(result.response.status, 401); + const errBody = (await result.response.json()) as { error: { message: string } }; + assert.match(errBody.error.message, /cookie|Cookie/i); + }); + + it("returns 400 when no user message is present", async () => { + const executor = new mod.HyperAgentExecutor(); + const result = await executor.execute({ + model: "fable", + body: { messages: [{ role: "assistant", content: "hi" }] }, + stream: false, + credentials: { apiKey: "session=abc" }, + signal: null, + } as never); + assert.equal(result.response.status, 400); + }); + + it("parseHyperAgentSseStream accumulates text + sessionId", async () => { + const sse = [ + 'data: {"type":"thread_runtime_latched","runtimeId":"claude-agents-sdk","modelId":"opus-latest"}', + "", + 'data: {"type":"session_start","content":"Session initialized","sessionId":"sess-1"}', + "", + 'data: {"type":"thinking","content":"hmm"}', + "", + 'data: {"type":"text","content":"Hello"}', + "", + 'data: {"type":"text","content":" world"}', + "", + 'data: {"type":"session_end","content":"Completed","sessionId":"sess-1"}', + "", + "data: [DONE]", + "", + ].join("\n"); + const res = new Response(sse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + const parsed = await mod.parseHyperAgentSseStream(res); + assert.equal(parsed.text, "Hello world"); + assert.equal(parsed.sessionId, "sess-1"); + assert.equal(parsed.modelId, "opus-latest"); + assert.ok(parsed.events >= 4); + }); +});