hyperagents 1.2.3rc5.post3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HyperClaw Model Router
|
|
3
|
+
Intelligent routing to optimize cost, speed, and quality.
|
|
4
|
+
Routes simple tasks to ChatJimmy, complex tasks to Claude.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from hyperclaw.api_utils import extract_text
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from hyperclaw import cost_guard
|
|
20
|
+
from hyperclaw.cost_guard import CostCapReached
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("hyperclaw.model_router")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelTier(Enum):
|
|
26
|
+
"""Model tiers by capability and cost."""
|
|
27
|
+
FAST = "fast" # ChatJimmy, Haiku - simple tasks, low cost
|
|
28
|
+
STANDARD = "standard" # Sonnet - balanced
|
|
29
|
+
PREMIUM = "premium" # Opus - complex reasoning
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ModelConfig:
|
|
34
|
+
"""Configuration for a model."""
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
tier: ModelTier
|
|
38
|
+
provider: str # anthropic, chatjimmy, openai
|
|
39
|
+
cost_per_1k_input: float
|
|
40
|
+
cost_per_1k_output: float
|
|
41
|
+
max_tokens: int
|
|
42
|
+
latency_ms: int # typical latency
|
|
43
|
+
capabilities: list[str] = field(default_factory=list)
|
|
44
|
+
base_url: Optional[str] = None
|
|
45
|
+
api_key_env: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Model ids come from models.yaml (providers.anthropic.models + the
|
|
49
|
+
# chatjimmy provider); the static strings below are only last-resort
|
|
50
|
+
# fallbacks when the registry is unavailable.
|
|
51
|
+
def _yaml_anthropic_models() -> dict:
|
|
52
|
+
try:
|
|
53
|
+
from hyperclaw.providers import registry
|
|
54
|
+
prov = registry().providers.get("anthropic")
|
|
55
|
+
return dict(prov.models) if prov else {}
|
|
56
|
+
except Exception:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _yaml_chatjimmy() -> tuple:
|
|
61
|
+
"""(model_id, base_url) for chatjimmy from the registry, or ("", "")."""
|
|
62
|
+
try:
|
|
63
|
+
from hyperclaw.providers import registry
|
|
64
|
+
prov = registry().providers.get("chatjimmy")
|
|
65
|
+
if prov:
|
|
66
|
+
return prov.model_for(), prov.base_url
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return "", ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_ANTH = _yaml_anthropic_models()
|
|
73
|
+
_CJ_MODEL, _CJ_URL = _yaml_chatjimmy()
|
|
74
|
+
|
|
75
|
+
# Model registry
|
|
76
|
+
MODELS = {
|
|
77
|
+
# ChatJimmy - Fast & cheap for simple tasks
|
|
78
|
+
"chatjimmy": ModelConfig(
|
|
79
|
+
id=_CJ_MODEL or "chatjimmy",
|
|
80
|
+
name="ChatJimmy (Llama 3.1 8B)",
|
|
81
|
+
tier=ModelTier.FAST,
|
|
82
|
+
provider="chatjimmy",
|
|
83
|
+
cost_per_1k_input=0.00001,
|
|
84
|
+
cost_per_1k_output=0.00001,
|
|
85
|
+
max_tokens=2048,
|
|
86
|
+
latency_ms=50,
|
|
87
|
+
capabilities=["chat", "simple_qa", "classification", "extraction"],
|
|
88
|
+
base_url=_CJ_URL or None,
|
|
89
|
+
api_key_env="CHATJIMMY_API_KEY",
|
|
90
|
+
),
|
|
91
|
+
|
|
92
|
+
# Claude Haiku - Fast Claude for moderate tasks
|
|
93
|
+
"claude-haiku": ModelConfig(
|
|
94
|
+
id=_ANTH.get("fast", "claude-haiku-4-5"),
|
|
95
|
+
name="Claude Haiku 4.5",
|
|
96
|
+
tier=ModelTier.FAST,
|
|
97
|
+
provider="anthropic",
|
|
98
|
+
cost_per_1k_input=0.001,
|
|
99
|
+
cost_per_1k_output=0.005,
|
|
100
|
+
max_tokens=4096,
|
|
101
|
+
latency_ms=500,
|
|
102
|
+
capabilities=["chat", "analysis", "coding", "writing"],
|
|
103
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
104
|
+
),
|
|
105
|
+
|
|
106
|
+
# Claude Sonnet - Balanced for most tasks
|
|
107
|
+
"claude-sonnet": ModelConfig(
|
|
108
|
+
id=_ANTH.get("standard", "claude-sonnet-5"),
|
|
109
|
+
name="Claude Sonnet 5",
|
|
110
|
+
tier=ModelTier.STANDARD,
|
|
111
|
+
provider="anthropic",
|
|
112
|
+
cost_per_1k_input=0.002,
|
|
113
|
+
cost_per_1k_output=0.010,
|
|
114
|
+
max_tokens=8192,
|
|
115
|
+
latency_ms=1500,
|
|
116
|
+
capabilities=["chat", "analysis", "coding", "writing", "reasoning", "planning"],
|
|
117
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
118
|
+
),
|
|
119
|
+
|
|
120
|
+
# Claude Opus - Premium for complex tasks
|
|
121
|
+
"claude-opus": ModelConfig(
|
|
122
|
+
id=_ANTH.get("premium", "claude-opus-5"),
|
|
123
|
+
name="Claude Opus 5",
|
|
124
|
+
tier=ModelTier.PREMIUM,
|
|
125
|
+
provider="anthropic",
|
|
126
|
+
cost_per_1k_input=0.005,
|
|
127
|
+
cost_per_1k_output=0.025,
|
|
128
|
+
max_tokens=8192,
|
|
129
|
+
latency_ms=3000,
|
|
130
|
+
capabilities=["chat", "analysis", "coding", "writing", "reasoning", "planning", "complex_reasoning", "research"],
|
|
131
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
132
|
+
),
|
|
133
|
+
"claude-fable": ModelConfig(
|
|
134
|
+
id=_ANTH.get("flagship", "claude-fable-5"),
|
|
135
|
+
name="Claude Fable 5",
|
|
136
|
+
tier=ModelTier.PREMIUM,
|
|
137
|
+
provider="anthropic",
|
|
138
|
+
cost_per_1k_input=0.010,
|
|
139
|
+
cost_per_1k_output=0.050,
|
|
140
|
+
max_tokens=8192,
|
|
141
|
+
latency_ms=3000,
|
|
142
|
+
capabilities=["chat", "analysis", "coding", "writing", "reasoning", "planning", "complex_reasoning", "research"],
|
|
143
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
144
|
+
),
|
|
145
|
+
|
|
146
|
+
# OpenRouter - Meta router for any model
|
|
147
|
+
"openrouter": ModelConfig(
|
|
148
|
+
id="openrouter/auto",
|
|
149
|
+
name="OpenRouter Auto",
|
|
150
|
+
tier=ModelTier.STANDARD,
|
|
151
|
+
provider="openrouter",
|
|
152
|
+
cost_per_1k_input=0.002,
|
|
153
|
+
cost_per_1k_output=0.006,
|
|
154
|
+
max_tokens=4096,
|
|
155
|
+
latency_ms=1500,
|
|
156
|
+
capabilities=["chat", "analysis", "coding", "writing"],
|
|
157
|
+
base_url="https://openrouter.ai/api/v1",
|
|
158
|
+
api_key_env="OPENROUTER_API_KEY",
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Task complexity patterns
|
|
163
|
+
SIMPLE_TASK_PATTERNS = [
|
|
164
|
+
r"^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure)[\s\.\!\?]*$",
|
|
165
|
+
r"^what('s| is) (the )?(time|date|day)",
|
|
166
|
+
r"^(list|show|get) (my )?(tasks|emails|events|calendar)",
|
|
167
|
+
r"^(send|forward) (a )?quick (message|note|reply)",
|
|
168
|
+
r"^(check|look up|find) (the )?(weather|status|price)",
|
|
169
|
+
r"^remind me",
|
|
170
|
+
r"^set (a )?(timer|alarm|reminder)",
|
|
171
|
+
r"^(open|launch|start) ",
|
|
172
|
+
r"^(yes|no|confirm|cancel|stop|done|finished)",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
COMPLEX_TASK_PATTERNS = [
|
|
176
|
+
r"(analyze|analysis|evaluate|assess|review)",
|
|
177
|
+
r"(strategy|strategic|plan|planning)",
|
|
178
|
+
r"(research|investigate|deep dive)",
|
|
179
|
+
r"(write|draft|compose) (a )?(report|proposal|document|article)",
|
|
180
|
+
r"(code|implement|build|develop|architect)",
|
|
181
|
+
r"(compare|contrast|difference|pros and cons)",
|
|
182
|
+
r"(explain|describe|elaborate) (in detail|thoroughly)",
|
|
183
|
+
r"(summarize|synthesize) (multiple|several|all)",
|
|
184
|
+
r"(debug|troubleshoot|diagnose)",
|
|
185
|
+
r"(optimize|improve|refactor)",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class UsageStats:
|
|
191
|
+
"""Track usage and costs."""
|
|
192
|
+
total_requests: int = 0
|
|
193
|
+
total_input_tokens: int = 0
|
|
194
|
+
total_output_tokens: int = 0
|
|
195
|
+
total_cost: float = 0.0
|
|
196
|
+
requests_by_model: dict = field(default_factory=dict)
|
|
197
|
+
cost_by_model: dict = field(default_factory=dict)
|
|
198
|
+
last_reset: datetime = field(default_factory=datetime.now)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ModelRouter:
|
|
202
|
+
"""
|
|
203
|
+
Intelligent model router that selects the best model based on:
|
|
204
|
+
- Task complexity
|
|
205
|
+
- Cost constraints
|
|
206
|
+
- Required capabilities
|
|
207
|
+
- Current load/latency
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self):
|
|
211
|
+
self.models = MODELS.copy()
|
|
212
|
+
self.stats = UsageStats()
|
|
213
|
+
self._clients: dict[str, Any] = {}
|
|
214
|
+
self._prefer_cheap = os.environ.get("PREFER_CHEAP_MODELS", "true").lower() == "true"
|
|
215
|
+
|
|
216
|
+
# The daily cap lives in the cost guard (hyperclaw/cost_guard.py); this
|
|
217
|
+
# attribute is kept for older callers. Setting it is an explicit cap
|
|
218
|
+
# change and is recorded in the activity log like `hyperclaw cost raise`.
|
|
219
|
+
@property
|
|
220
|
+
def _daily_budget(self) -> float:
|
|
221
|
+
return cost_guard.daily_cap()
|
|
222
|
+
|
|
223
|
+
@_daily_budget.setter
|
|
224
|
+
def _daily_budget(self, value: float) -> None:
|
|
225
|
+
cost_guard.raise_cap(cost_guard.DAILY, value, by="ModelRouter daily budget setting")
|
|
226
|
+
|
|
227
|
+
def _get_client(self, model_config: ModelConfig):
|
|
228
|
+
"""Get or create API client for a model."""
|
|
229
|
+
provider = model_config.provider
|
|
230
|
+
|
|
231
|
+
if provider not in self._clients:
|
|
232
|
+
if provider == "anthropic":
|
|
233
|
+
import anthropic
|
|
234
|
+
api_key = os.environ.get(model_config.api_key_env, "")
|
|
235
|
+
self._clients[provider] = cost_guard.guard_client(
|
|
236
|
+
anthropic.Anthropic(api_key=api_key), "anthropic")
|
|
237
|
+
|
|
238
|
+
elif provider == "chatjimmy":
|
|
239
|
+
# ChatJimmy uses OpenAI-compatible API
|
|
240
|
+
api_key = os.environ.get(model_config.api_key_env, "")
|
|
241
|
+
self._clients[provider] = {
|
|
242
|
+
"base_url": model_config.base_url,
|
|
243
|
+
"api_key": api_key,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
elif provider == "openrouter":
|
|
247
|
+
# OpenRouter uses OpenAI-compatible API
|
|
248
|
+
api_key = os.environ.get(model_config.api_key_env, "")
|
|
249
|
+
self._clients[provider] = {
|
|
250
|
+
"base_url": model_config.base_url,
|
|
251
|
+
"api_key": api_key,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return self._clients.get(provider)
|
|
255
|
+
|
|
256
|
+
def classify_complexity(self, message: str, context: dict = None) -> ModelTier:
|
|
257
|
+
"""Classify task complexity to determine model tier."""
|
|
258
|
+
message_lower = message.lower().strip()
|
|
259
|
+
|
|
260
|
+
# Check for simple patterns
|
|
261
|
+
for pattern in SIMPLE_TASK_PATTERNS:
|
|
262
|
+
if re.search(pattern, message_lower, re.IGNORECASE):
|
|
263
|
+
return ModelTier.FAST
|
|
264
|
+
|
|
265
|
+
# Check for complex patterns
|
|
266
|
+
for pattern in COMPLEX_TASK_PATTERNS:
|
|
267
|
+
if re.search(pattern, message_lower, re.IGNORECASE):
|
|
268
|
+
return ModelTier.PREMIUM if len(message) > 500 else ModelTier.STANDARD
|
|
269
|
+
|
|
270
|
+
# Check message length as proxy for complexity
|
|
271
|
+
if len(message) < 50:
|
|
272
|
+
return ModelTier.FAST
|
|
273
|
+
elif len(message) < 200:
|
|
274
|
+
return ModelTier.STANDARD
|
|
275
|
+
else:
|
|
276
|
+
return ModelTier.STANDARD # Default to standard, not premium
|
|
277
|
+
|
|
278
|
+
def select_model(
|
|
279
|
+
self,
|
|
280
|
+
message: str,
|
|
281
|
+
required_capabilities: list[str] = None,
|
|
282
|
+
preferred_tier: ModelTier = None,
|
|
283
|
+
max_cost: float = None,
|
|
284
|
+
context: dict = None
|
|
285
|
+
) -> ModelConfig:
|
|
286
|
+
"""
|
|
287
|
+
Select the best model for a task.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
message: The user message/task
|
|
291
|
+
required_capabilities: Capabilities the model must have
|
|
292
|
+
preferred_tier: Override automatic tier selection
|
|
293
|
+
max_cost: Maximum cost per request (approximate)
|
|
294
|
+
context: Additional context for routing
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Selected ModelConfig
|
|
298
|
+
"""
|
|
299
|
+
# Determine tier
|
|
300
|
+
if preferred_tier:
|
|
301
|
+
tier = preferred_tier
|
|
302
|
+
else:
|
|
303
|
+
tier = self.classify_complexity(message, context)
|
|
304
|
+
|
|
305
|
+
# Budget: no downgrade here. Over a cap the cost guard refuses the
|
|
306
|
+
# call itself (CostCapReached) — a cheaper tier never keeps spending.
|
|
307
|
+
|
|
308
|
+
# Filter models by tier and capabilities
|
|
309
|
+
candidates = []
|
|
310
|
+
for model_id, config in self.models.items():
|
|
311
|
+
# Check tier
|
|
312
|
+
if self._prefer_cheap:
|
|
313
|
+
# Allow same tier or cheaper
|
|
314
|
+
if config.tier.value > tier.value:
|
|
315
|
+
continue
|
|
316
|
+
else:
|
|
317
|
+
# Exact tier match
|
|
318
|
+
if config.tier != tier:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# Check capabilities
|
|
322
|
+
if required_capabilities:
|
|
323
|
+
if not all(cap in config.capabilities for cap in required_capabilities):
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Check API key available
|
|
327
|
+
if config.api_key_env and not os.environ.get(config.api_key_env):
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
candidates.append(config)
|
|
331
|
+
|
|
332
|
+
if not candidates:
|
|
333
|
+
# Fallback to any available model
|
|
334
|
+
for model_id, config in self.models.items():
|
|
335
|
+
if config.api_key_env and os.environ.get(config.api_key_env):
|
|
336
|
+
candidates.append(config)
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if not candidates:
|
|
340
|
+
raise RuntimeError("No models available - check API keys")
|
|
341
|
+
|
|
342
|
+
# Sort by cost (cheapest first if prefer_cheap)
|
|
343
|
+
if self._prefer_cheap:
|
|
344
|
+
candidates.sort(key=lambda m: m.cost_per_1k_input + m.cost_per_1k_output)
|
|
345
|
+
else:
|
|
346
|
+
# Sort by capability (most capable first)
|
|
347
|
+
tier_order = {ModelTier.PREMIUM: 0, ModelTier.STANDARD: 1, ModelTier.FAST: 2}
|
|
348
|
+
candidates.sort(key=lambda m: tier_order.get(m.tier, 2))
|
|
349
|
+
|
|
350
|
+
selected = candidates[0]
|
|
351
|
+
logger.debug(f"Selected model: {selected.name} (tier={tier.value}, cost=${selected.cost_per_1k_input}/1k)")
|
|
352
|
+
|
|
353
|
+
return selected
|
|
354
|
+
|
|
355
|
+
async def call(
|
|
356
|
+
self,
|
|
357
|
+
message: str,
|
|
358
|
+
system: str = "",
|
|
359
|
+
history: list[dict] = None,
|
|
360
|
+
model_override: str = None,
|
|
361
|
+
**kwargs
|
|
362
|
+
) -> tuple[str, dict]:
|
|
363
|
+
"""
|
|
364
|
+
Call the appropriate model.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Tuple of (response_text, metadata)
|
|
368
|
+
"""
|
|
369
|
+
# Select model
|
|
370
|
+
if model_override and model_override in self.models:
|
|
371
|
+
model = self.models[model_override]
|
|
372
|
+
else:
|
|
373
|
+
model = self.select_model(message, context=kwargs.get("context"))
|
|
374
|
+
|
|
375
|
+
start_time = time.time()
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
if model.provider == "anthropic":
|
|
379
|
+
response, metadata = await self._call_anthropic(model, message, system, history)
|
|
380
|
+
elif model.provider == "chatjimmy":
|
|
381
|
+
response, metadata = await self._call_chatjimmy(model, message, system, history)
|
|
382
|
+
elif model.provider == "openrouter":
|
|
383
|
+
response, metadata = await self._call_openrouter(model, message, system, history)
|
|
384
|
+
else:
|
|
385
|
+
raise ValueError(f"Unknown provider: {model.provider}")
|
|
386
|
+
|
|
387
|
+
# Track usage
|
|
388
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
389
|
+
self._track_usage(model, metadata.get("input_tokens", 0), metadata.get("output_tokens", 0))
|
|
390
|
+
|
|
391
|
+
metadata["model"] = model.id
|
|
392
|
+
metadata["model_name"] = model.name
|
|
393
|
+
metadata["tier"] = model.tier.value
|
|
394
|
+
metadata["latency_ms"] = latency_ms
|
|
395
|
+
|
|
396
|
+
return response, metadata
|
|
397
|
+
|
|
398
|
+
except CostCapReached:
|
|
399
|
+
raise # at the cost cap: stop, never a cheaper model
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.error(f"Model call failed ({model.name}): {e}")
|
|
402
|
+
|
|
403
|
+
# Try fallback to cheaper model (provider errors only, never the cost cap)
|
|
404
|
+
if model.tier != ModelTier.FAST:
|
|
405
|
+
logger.info("Attempting fallback to fast tier")
|
|
406
|
+
fallback = self.select_model(message, preferred_tier=ModelTier.FAST)
|
|
407
|
+
if fallback.id != model.id:
|
|
408
|
+
return await self.call(message, system, history, model_override=fallback.id, **kwargs)
|
|
409
|
+
|
|
410
|
+
raise
|
|
411
|
+
|
|
412
|
+
async def _call_anthropic(
|
|
413
|
+
self,
|
|
414
|
+
model: ModelConfig,
|
|
415
|
+
message: str,
|
|
416
|
+
system: str,
|
|
417
|
+
history: list[dict]
|
|
418
|
+
) -> tuple[str, dict]:
|
|
419
|
+
"""Call Anthropic API."""
|
|
420
|
+
import anthropic
|
|
421
|
+
import asyncio
|
|
422
|
+
|
|
423
|
+
client = self._get_client(model)
|
|
424
|
+
|
|
425
|
+
messages = []
|
|
426
|
+
if history:
|
|
427
|
+
messages.extend(history[-20:]) # Last 20 messages
|
|
428
|
+
messages.append({"role": "user", "content": message})
|
|
429
|
+
|
|
430
|
+
response = await asyncio.to_thread(
|
|
431
|
+
client.messages.create,
|
|
432
|
+
model=model.id,
|
|
433
|
+
max_tokens=_max_out(model),
|
|
434
|
+
system=system if system else None,
|
|
435
|
+
messages=messages,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return extract_text(response), {
|
|
439
|
+
"input_tokens": response.usage.input_tokens,
|
|
440
|
+
"output_tokens": response.usage.output_tokens,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async def _call_chatjimmy(
|
|
444
|
+
self,
|
|
445
|
+
model: ModelConfig,
|
|
446
|
+
message: str,
|
|
447
|
+
system: str,
|
|
448
|
+
history: list[dict]
|
|
449
|
+
) -> tuple[str, dict]:
|
|
450
|
+
"""Call ChatJimmy (OpenAI-compatible) API."""
|
|
451
|
+
config = self._get_client(model)
|
|
452
|
+
|
|
453
|
+
messages = []
|
|
454
|
+
if system:
|
|
455
|
+
messages.append({"role": "system", "content": system})
|
|
456
|
+
if history:
|
|
457
|
+
messages.extend(history[-10:]) # Smaller context for fast model
|
|
458
|
+
messages.append({"role": "user", "content": message})
|
|
459
|
+
|
|
460
|
+
with cost_guard.metered(model.provider, model.id, {"messages": messages},
|
|
461
|
+
_max_out(model)) as meter:
|
|
462
|
+
async with httpx.AsyncClient() as client:
|
|
463
|
+
response = await client.post(
|
|
464
|
+
f"{config['base_url']}/chat/completions",
|
|
465
|
+
headers={
|
|
466
|
+
"Authorization": f"Bearer {config['api_key']}",
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
},
|
|
469
|
+
json={
|
|
470
|
+
"model": "llama-3.1-8b-instant",
|
|
471
|
+
"messages": messages,
|
|
472
|
+
"max_tokens": _max_out(model),
|
|
473
|
+
"temperature": 0.7,
|
|
474
|
+
},
|
|
475
|
+
timeout=30.0,
|
|
476
|
+
)
|
|
477
|
+
response.raise_for_status()
|
|
478
|
+
data = response.json()
|
|
479
|
+
meter.usage_from(data)
|
|
480
|
+
|
|
481
|
+
choice = data["choices"][0]
|
|
482
|
+
usage = data.get("usage", {})
|
|
483
|
+
|
|
484
|
+
return choice["message"]["content"], {
|
|
485
|
+
"input_tokens": usage.get("prompt_tokens", 0),
|
|
486
|
+
"output_tokens": usage.get("completion_tokens", 0),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async def _call_openrouter(
|
|
490
|
+
self,
|
|
491
|
+
model: ModelConfig,
|
|
492
|
+
message: str,
|
|
493
|
+
system: str,
|
|
494
|
+
history: list[dict]
|
|
495
|
+
) -> tuple[str, dict]:
|
|
496
|
+
"""Call OpenRouter API."""
|
|
497
|
+
config = self._get_client(model)
|
|
498
|
+
|
|
499
|
+
messages = []
|
|
500
|
+
if system:
|
|
501
|
+
messages.append({"role": "system", "content": system})
|
|
502
|
+
if history:
|
|
503
|
+
messages.extend(history[-15:])
|
|
504
|
+
messages.append({"role": "user", "content": message})
|
|
505
|
+
|
|
506
|
+
with cost_guard.metered(model.provider, model.id, {"messages": messages},
|
|
507
|
+
_max_out(model)) as meter:
|
|
508
|
+
async with httpx.AsyncClient() as client:
|
|
509
|
+
response = await client.post(
|
|
510
|
+
f"{config['base_url']}/chat/completions",
|
|
511
|
+
headers={
|
|
512
|
+
"Authorization": f"Bearer {config['api_key']}",
|
|
513
|
+
"Content-Type": "application/json",
|
|
514
|
+
"HTTP-Referer": "https://hyperclaw.ai",
|
|
515
|
+
"X-Title": "HyperClaw",
|
|
516
|
+
},
|
|
517
|
+
json={
|
|
518
|
+
"model": model.id,
|
|
519
|
+
"messages": messages,
|
|
520
|
+
"max_tokens": _max_out(model),
|
|
521
|
+
"temperature": 0.7,
|
|
522
|
+
},
|
|
523
|
+
timeout=60.0,
|
|
524
|
+
)
|
|
525
|
+
response.raise_for_status()
|
|
526
|
+
data = response.json()
|
|
527
|
+
meter.usage_from(data)
|
|
528
|
+
|
|
529
|
+
choice = data["choices"][0]
|
|
530
|
+
usage = data.get("usage", {})
|
|
531
|
+
|
|
532
|
+
return choice["message"]["content"], {
|
|
533
|
+
"input_tokens": usage.get("prompt_tokens", 0),
|
|
534
|
+
"output_tokens": usage.get("completion_tokens", 0),
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
def _track_usage(self, model: ModelConfig, input_tokens: int, output_tokens: int):
|
|
538
|
+
"""Track usage statistics."""
|
|
539
|
+
cost = (
|
|
540
|
+
(input_tokens / 1000) * model.cost_per_1k_input +
|
|
541
|
+
(output_tokens / 1000) * model.cost_per_1k_output
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
self.stats.total_requests += 1
|
|
545
|
+
self.stats.total_input_tokens += input_tokens
|
|
546
|
+
self.stats.total_output_tokens += output_tokens
|
|
547
|
+
self.stats.total_cost += cost
|
|
548
|
+
|
|
549
|
+
if model.id not in self.stats.requests_by_model:
|
|
550
|
+
self.stats.requests_by_model[model.id] = 0
|
|
551
|
+
self.stats.cost_by_model[model.id] = 0.0
|
|
552
|
+
|
|
553
|
+
self.stats.requests_by_model[model.id] += 1
|
|
554
|
+
self.stats.cost_by_model[model.id] += cost
|
|
555
|
+
|
|
556
|
+
logger.debug(f"Usage: {model.name} - {input_tokens}+{output_tokens} tokens, ${cost:.4f}")
|
|
557
|
+
|
|
558
|
+
def get_stats(self) -> dict:
|
|
559
|
+
"""Get usage statistics."""
|
|
560
|
+
return {
|
|
561
|
+
"total_requests": self.stats.total_requests,
|
|
562
|
+
"total_tokens": self.stats.total_input_tokens + self.stats.total_output_tokens,
|
|
563
|
+
"total_cost_usd": round(self.stats.total_cost, 4),
|
|
564
|
+
"daily_budget_usd": self._daily_budget,
|
|
565
|
+
"spent_today_usd": round(cost_guard.get_store().day_total(), 4),
|
|
566
|
+
"budget_remaining_usd": round(self._daily_budget - cost_guard.get_store().day_total(), 4),
|
|
567
|
+
"requests_by_model": self.stats.requests_by_model,
|
|
568
|
+
"cost_by_model": {k: round(v, 4) for k, v in self.stats.cost_by_model.items()},
|
|
569
|
+
"last_reset": self.stats.last_reset.isoformat(),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
def reset_daily_stats(self):
|
|
573
|
+
"""Reset daily statistics (call at midnight)."""
|
|
574
|
+
logger.info(f"Resetting daily stats. Previous: ${self.stats.total_cost:.2f}")
|
|
575
|
+
self.stats = UsageStats()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# Singleton
|
|
579
|
+
_router: Optional[ModelRouter] = None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _max_out(model: "ModelConfig") -> int:
|
|
583
|
+
"""The explicit max output for a router call: the model's own max_tokens,
|
|
584
|
+
capped by the cost guard (path "router") so no reservation exceeds the
|
|
585
|
+
per-task cap."""
|
|
586
|
+
return cost_guard.max_output("router", model.provider, model.id,
|
|
587
|
+
requested=model.max_tokens)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def get_model_router() -> ModelRouter:
|
|
591
|
+
"""Get or create model router singleton."""
|
|
592
|
+
global _router
|
|
593
|
+
if _router is None:
|
|
594
|
+
_router = ModelRouter()
|
|
595
|
+
return _router
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model selector — tiered Claude routing.
|
|
3
|
+
|
|
4
|
+
Picks the right Claude model per request so the assistant switches across the whole ladder instead of
|
|
5
|
+
running one model for everything:
|
|
6
|
+
|
|
7
|
+
Routes by prompt complexity across THREE tiers (no Haiku, per policy):
|
|
8
|
+
SONNET (claude-sonnet-5) -> simple/everyday requests, quick questions, acks
|
|
9
|
+
OPUS (claude-opus-5) -> complex: analysis, planning, writing, research, judgement
|
|
10
|
+
FABLE (claude-fable-5, ACTIVE) -> hardest: code, architecture, quant, deep multi-step work; also the default backend model
|
|
11
|
+
|
|
12
|
+
Model IDs are env-overridable so the exact Fable id can be dropped in without code changes:
|
|
13
|
+
FABLE_MODEL the new model's API id (defaults to claude-fable-5)
|
|
14
|
+
HYPERCLAW_OPUS_MODEL / HYPERCLAW_SONNET_MODEL / HYPERCLAW_HAIKU_MODEL
|
|
15
|
+
Disable routing entirely with HYPERCLAW_MODEL_ROUTING=off (everything uses the passed default).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
DEFAULT_MODEL = "claude-fable-5"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tiers() -> dict:
|
|
27
|
+
return {
|
|
28
|
+
# FABLE: claude-fable-5 (activated 2026-08-20). FABLE_MODEL env still overrides.
|
|
29
|
+
"fable": (os.environ.get("FABLE_MODEL", "").strip() or DEFAULT_MODEL),
|
|
30
|
+
"opus": os.environ.get("HYPERCLAW_OPUS_MODEL", "claude-opus-5"),
|
|
31
|
+
"sonnet": os.environ.get("HYPERCLAW_SONNET_MODEL", "claude-sonnet-5"),
|
|
32
|
+
"haiku": os.environ.get("HYPERCLAW_HAIKU_MODEL", "claude-haiku-4-5"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Hardest tier (FABLE): deep technical / engineering / quantitative / heavy multi-step work.
|
|
37
|
+
_FABLE_RE = re.compile(
|
|
38
|
+
r"\b(code|coding|debug|refactor|architect|architecture|algorithm|implement|build (a|an|the|me|out)|"
|
|
39
|
+
r"design (a|an|the|system|schema)|migrat|optimi[sz]|prove|valuation|forecast|financial model|"
|
|
40
|
+
r"legal|contract|root cause|deep dive|end.to.end|whole (system|codebase)|"
|
|
41
|
+
r"think (hard|deeply|carefully|step)|step.?by.?step|trading strateg|portfolio)\b",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Complex tier (OPUS): analysis, planning, writing, research, judgement.
|
|
46
|
+
_OPUS_RE = re.compile(
|
|
47
|
+
r"\b(analy[sz]e|analysis|plan|planning|research|draft|write (a|an|the|me)|essay|memo|report|"
|
|
48
|
+
r"compar|evaluat|assess|recommend|strateg|decision|review|investor|proposal|negotiat|"
|
|
49
|
+
r"summari[sz]e|explain (why|how)|reason|brief|pitch|roadmap|tradeoff|pros and cons)\b",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify(message: str) -> str:
|
|
55
|
+
"""Return a tier by prompt complexity: 'sonnet' (simple) | 'opus' (complex) | 'fable' (hardest).
|
|
56
|
+
|
|
57
|
+
Haiku tier is available via env override; default routing uses Sonnet/Opus/Fable."""
|
|
58
|
+
msg = (message or "").strip()
|
|
59
|
+
words = len(msg.split())
|
|
60
|
+
if _FABLE_RE.search(msg) or words >= 120:
|
|
61
|
+
return "fable"
|
|
62
|
+
if _OPUS_RE.search(msg) or words >= 30:
|
|
63
|
+
return "opus"
|
|
64
|
+
return "sonnet"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pick_model(message: str, default: str = None) -> str:
|
|
68
|
+
"""Pick a concrete model id for this request. Falls back to `default` when routing is off."""
|
|
69
|
+
default = default or DEFAULT_MODEL
|
|
70
|
+
if os.environ.get("HYPERCLAW_MODEL_ROUTING", "on").lower() in ("off", "0", "false", "no"):
|
|
71
|
+
return default
|
|
72
|
+
return tiers()[classify(message)]
|