feat(sse): add HyperAgent (hyperagent.com) unofficial web provider (#7994)

* 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 <diegosouza.pw@gmail.com>
This commit is contained in:
NOXX - Commiter
2026-07-22 14:08:46 +03:00
committed by GitHub
parent ce3f2445a6
commit 1b010f6c40
16 changed files with 1607 additions and 1 deletions

View File

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

View File

@@ -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.",

View File

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

View File

@@ -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<string, RegistryEntry> = {
aimlapi: aimlapiProvider,
@@ -420,4 +421,5 @@ export const REGISTRY: Record<string, RegistryEntry> = {
digitalocean: digitaloceanProvider,
hcnsec: hcnsecProvider,
promptql: promptqlProvider,
hyperagent: hyperagentProvider,
};

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown> | undefined)?.cookie) ||
readStr((credentials as Record<string, unknown> | 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<string, unknown>;
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<string, ThreadBinding>();
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<string, ThreadBinding> {
const p = threadCachePath();
if (!p || !existsSync(p)) return {};
try {
return JSON.parse(readFileSync(p, "utf8")) as Record<string, ThreadBinding>;
} catch {
return {};
}
}
function saveThreadDisk(map: Record<string, ThreadBinding>) {
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<string, string>
): { 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<string, string> = {};
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<string, string>): Record<string, string> {
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<string> {
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<string, unknown>;
const id =
readStr(j.id) ||
readStr(j.threadId) ||
readStr(j.thread_id) ||
(j.thread && typeof j.thread === "object"
? readStr((j.thread as Record<string, unknown>).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<void> {
const body: Record<string, unknown> = {
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<string, unknown> {
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<string, unknown>;
try {
obj = JSON.parse(trimmed) as Record<string, unknown>;
} 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<string, string> | null | undefined) ??
((input as { headers?: Record<string, string> }).headers as
Record<string, string> | 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 };

View File

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

View File

@@ -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<string, string> = {
// 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";
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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). */

View File

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

View File

@@ -2483,6 +2483,29 @@
"stream": "https://router.huggingface.co/v1/chat/completions"
}
},
"hyperagent": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://hyperagent.com/api/threads",
"stream": "https://hyperagent.com/api/threads"
}
},
"hyperbolic": {
"format": "openai",
"headers": {

View File

@@ -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<string, Record<string, unknown>>)["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);
});
});