mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
20
open-sse/config/providers/registry/hyperagent/index.ts
Normal file
20
open-sse/config/providers/registry/hyperagent/index.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
936
open-sse/executors/hyperagent.ts
Normal file
936
open-sse/executors/hyperagent.ts
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
162
open-sse/services/hyperagentModels.ts
Normal file
162
open-sse/services/hyperagentModels.ts
Normal 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";
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
163
open-sse/services/usage/hyperagent.ts
Normal file
163
open-sse/services/usage/hyperagent.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
243
tests/unit/executor-hyperagent.test.ts
Normal file
243
tests/unit/executor-hyperagent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user