multi-forge 0.2.0__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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""PID-sharded JSONL cost log writer.
|
|
2
|
+
|
|
3
|
+
Each proxy process writes to its own shard file to avoid interprocess
|
|
4
|
+
locking. The CLI aggregates across shards at query time.
|
|
5
|
+
|
|
6
|
+
Location: ~/.forge/costs/requests/YYYY-MM_<pid>.jsonl
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forge.core.paths import get_forge_home
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pid_suffix() -> str:
|
|
27
|
+
return str(os.getpid())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _costs_dir() -> Path:
|
|
31
|
+
return get_forge_home() / "costs" / "requests"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _current_log_path() -> Path:
|
|
35
|
+
month = datetime.now(timezone.utc).strftime("%Y-%m")
|
|
36
|
+
return _costs_dir() / f"{month}_{_pid_suffix()}.jsonl"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def log_request_cost(
|
|
40
|
+
*,
|
|
41
|
+
proxy_id: str,
|
|
42
|
+
model: str,
|
|
43
|
+
tier: str,
|
|
44
|
+
input_tokens: int,
|
|
45
|
+
output_tokens: int,
|
|
46
|
+
cached_tokens: int,
|
|
47
|
+
cost_micros: int,
|
|
48
|
+
latency_ms: float,
|
|
49
|
+
failed: bool,
|
|
50
|
+
request_id: str,
|
|
51
|
+
pricing_source: str = "catalog",
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Append a cost record to the PID-sharded JSONL log.
|
|
54
|
+
|
|
55
|
+
Best-effort: write failures are logged but never block the request.
|
|
56
|
+
"""
|
|
57
|
+
record: dict[str, Any] = {
|
|
58
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
59
|
+
"proxy_id": proxy_id,
|
|
60
|
+
"model": model,
|
|
61
|
+
"tier": tier,
|
|
62
|
+
"input_tokens": input_tokens,
|
|
63
|
+
"output_tokens": output_tokens,
|
|
64
|
+
"cached_tokens": cached_tokens,
|
|
65
|
+
"cost_micros": cost_micros,
|
|
66
|
+
"estimated": True,
|
|
67
|
+
"pricing_source": pricing_source,
|
|
68
|
+
"latency_ms": round(latency_ms, 1),
|
|
69
|
+
"failed": failed,
|
|
70
|
+
"request_id": request_id,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from forge.core.state import open_secure_append
|
|
75
|
+
|
|
76
|
+
log_path = _current_log_path()
|
|
77
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
with _lock:
|
|
80
|
+
with open_secure_append(log_path) as f:
|
|
81
|
+
f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.warning("Failed to write cost log: %s", e)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_cost_logs(
|
|
87
|
+
period_start: datetime | None = None,
|
|
88
|
+
period_end: datetime | None = None,
|
|
89
|
+
) -> list[dict[str, Any]]:
|
|
90
|
+
"""Read and aggregate cost records from all PID shards.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
period_start: Only include records at or after this time (UTC).
|
|
94
|
+
period_end: Only include records before this time (UTC).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of cost record dicts, sorted by timestamp.
|
|
98
|
+
"""
|
|
99
|
+
costs_dir = _costs_dir()
|
|
100
|
+
if not costs_dir.is_dir():
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
records: list[dict[str, Any]] = []
|
|
104
|
+
for path in sorted(costs_dir.glob("*.jsonl")):
|
|
105
|
+
try:
|
|
106
|
+
with open(path) as f:
|
|
107
|
+
for line in f:
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if not line:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
record = json.loads(line)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if period_start or period_end:
|
|
117
|
+
ts_str = record.get("ts", "")
|
|
118
|
+
try:
|
|
119
|
+
ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
|
|
120
|
+
except (ValueError, TypeError):
|
|
121
|
+
continue
|
|
122
|
+
if period_start and ts < period_start:
|
|
123
|
+
continue
|
|
124
|
+
if period_end and ts >= period_end:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
records.append(record)
|
|
128
|
+
except OSError as e:
|
|
129
|
+
logger.warning("Failed to read cost log %s: %s", path, e)
|
|
130
|
+
|
|
131
|
+
records.sort(key=lambda r: r.get("ts", ""))
|
|
132
|
+
return records
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Spend cap enforcement with JSONL-bootstrapped tracking.
|
|
2
|
+
|
|
3
|
+
On proxy startup, reads the current (and previous) month's cost JSONL
|
|
4
|
+
logs to initialize in-memory spend counters. Caps are enforced per
|
|
5
|
+
request via check_cap().
|
|
6
|
+
|
|
7
|
+
Two enforcement modes:
|
|
8
|
+
post -- block once accumulated spend already exceeds the cap
|
|
9
|
+
strict -- estimate incoming request cost and block if projected total exceeds
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_MICROS_PER_DOLLAR = 1_000_000
|
|
24
|
+
_24H_SECONDS = 86400
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CapResult:
|
|
29
|
+
"""Result of a spend cap check."""
|
|
30
|
+
|
|
31
|
+
exceeded: bool
|
|
32
|
+
cap_type: str | None = None # "daily" or "monthly"
|
|
33
|
+
current_micros: int = 0
|
|
34
|
+
limit_micros: int = 0
|
|
35
|
+
projected: bool = False # True if this is a pre-flight estimate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CostTracker:
|
|
39
|
+
"""In-memory spend tracking with cap enforcement.
|
|
40
|
+
|
|
41
|
+
Thread-safe via the proxy's single-threaded async event loop
|
|
42
|
+
(all calls happen on the main thread in FastAPI/uvicorn).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
daily_cap_usd: float | None = None,
|
|
49
|
+
monthly_cap_usd: float | None = None,
|
|
50
|
+
cap_mode: str = "post",
|
|
51
|
+
on_cap_hit: str = "reject",
|
|
52
|
+
) -> None:
|
|
53
|
+
self.daily_cap_micros = int(daily_cap_usd * _MICROS_PER_DOLLAR) if daily_cap_usd is not None else None
|
|
54
|
+
self.monthly_cap_micros = int(monthly_cap_usd * _MICROS_PER_DOLLAR) if monthly_cap_usd is not None else None
|
|
55
|
+
self.cap_mode = cap_mode
|
|
56
|
+
self.on_cap_hit = on_cap_hit
|
|
57
|
+
|
|
58
|
+
self._daily_window: deque[tuple[float, int]] = deque()
|
|
59
|
+
self._monthly_total: int = 0
|
|
60
|
+
self._monthly_key: str = ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def has_caps(self) -> bool:
|
|
64
|
+
return self.daily_cap_micros is not None or self.monthly_cap_micros is not None
|
|
65
|
+
|
|
66
|
+
def bootstrap_from_logs(self, log_dir: Path, *, proxy_id: str | None = None) -> None:
|
|
67
|
+
"""Read existing cost logs to initialize spend counters.
|
|
68
|
+
|
|
69
|
+
Reads current month + previous month (for rolling 24h window
|
|
70
|
+
at month boundaries). Scans all PID shards.
|
|
71
|
+
|
|
72
|
+
When proxy_id is set, only records matching that proxy are counted.
|
|
73
|
+
Records without a proxy_id field are skipped (pre-proxy-id logs).
|
|
74
|
+
When proxy_id is None, all records are counted (backward compat).
|
|
75
|
+
"""
|
|
76
|
+
if not log_dir.is_dir():
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
now = datetime.now(timezone.utc)
|
|
80
|
+
current_month = now.strftime("%Y-%m")
|
|
81
|
+
self._monthly_key = current_month
|
|
82
|
+
|
|
83
|
+
if now.month == 1:
|
|
84
|
+
prev_month = f"{now.year - 1}-12"
|
|
85
|
+
else:
|
|
86
|
+
prev_month = f"{now.year}-{now.month - 1:02d}"
|
|
87
|
+
|
|
88
|
+
cutoff = time.time() - _24H_SECONDS
|
|
89
|
+
|
|
90
|
+
for path in sorted(log_dir.glob("*.jsonl")):
|
|
91
|
+
fname = path.stem # e.g., "2026-05_12345"
|
|
92
|
+
file_month = fname.split("_")[0] if "_" in fname else fname
|
|
93
|
+
|
|
94
|
+
if file_month not in (current_month, prev_month):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with open(path) as f:
|
|
99
|
+
for line in f:
|
|
100
|
+
line = line.strip()
|
|
101
|
+
if not line:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
record = self._parse_record(line)
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
if record is None:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
ts_unix, cost_micros, record_month, record_proxy_id = record
|
|
111
|
+
|
|
112
|
+
if proxy_id is not None and record_proxy_id != proxy_id:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if record_month == current_month:
|
|
116
|
+
self._monthly_total += cost_micros
|
|
117
|
+
|
|
118
|
+
if ts_unix >= cutoff:
|
|
119
|
+
self._daily_window.append((ts_unix, cost_micros))
|
|
120
|
+
except OSError as e:
|
|
121
|
+
logger.warning("Failed to read cost log %s: %s", path, e)
|
|
122
|
+
|
|
123
|
+
daily_total = sum(c for _, c in self._daily_window)
|
|
124
|
+
logger.info(
|
|
125
|
+
"Cost tracker bootstrapped: daily=$%.2f, monthly=$%.2f (%d records in window)",
|
|
126
|
+
daily_total / _MICROS_PER_DOLLAR,
|
|
127
|
+
self._monthly_total / _MICROS_PER_DOLLAR,
|
|
128
|
+
len(self._daily_window),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _parse_record(line: str) -> tuple[float, int, str, str | None] | None:
|
|
133
|
+
"""Parse a JSONL line into (unix_timestamp, cost_micros, month_key, record_proxy_id)."""
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
data = json.loads(line)
|
|
137
|
+
ts_str = data.get("ts", "")
|
|
138
|
+
cost_micros = int(data.get("cost_micros", 0))
|
|
139
|
+
if cost_micros <= 0:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
|
|
144
|
+
except (ValueError, TypeError):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
month_key = ts.strftime("%Y-%m")
|
|
148
|
+
record_proxy_id = data.get("proxy_id")
|
|
149
|
+
return ts.timestamp(), cost_micros, month_key, record_proxy_id
|
|
150
|
+
|
|
151
|
+
def record(self, cost_micros: int) -> None:
|
|
152
|
+
"""Record a completed request's cost."""
|
|
153
|
+
if cost_micros <= 0:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
now = time.time()
|
|
157
|
+
self._roll_month_if_needed()
|
|
158
|
+
|
|
159
|
+
self._monthly_total += cost_micros
|
|
160
|
+
self._daily_window.append((now, cost_micros))
|
|
161
|
+
|
|
162
|
+
def _roll_month_if_needed(self) -> None:
|
|
163
|
+
"""Reset the calendar-month accumulator when UTC month changes."""
|
|
164
|
+
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
|
|
165
|
+
if current_month != self._monthly_key:
|
|
166
|
+
self._monthly_total = 0
|
|
167
|
+
self._monthly_key = current_month
|
|
168
|
+
|
|
169
|
+
def _prune_daily_window(self) -> None:
|
|
170
|
+
"""Remove entries older than 24 hours from the rolling window."""
|
|
171
|
+
cutoff = time.time() - _24H_SECONDS
|
|
172
|
+
while self._daily_window and self._daily_window[0][0] < cutoff:
|
|
173
|
+
self._daily_window.popleft()
|
|
174
|
+
|
|
175
|
+
def daily_spend_micros(self) -> int:
|
|
176
|
+
"""Current rolling 24h spend in microdollars."""
|
|
177
|
+
self._prune_daily_window()
|
|
178
|
+
return sum(c for _, c in self._daily_window)
|
|
179
|
+
|
|
180
|
+
def monthly_spend_micros(self) -> int:
|
|
181
|
+
"""Current calendar month spend in microdollars."""
|
|
182
|
+
self._roll_month_if_needed()
|
|
183
|
+
return self._monthly_total
|
|
184
|
+
|
|
185
|
+
def check_cap(self, projected_cost_micros: int = 0) -> CapResult:
|
|
186
|
+
"""Check if spend would exceed any configured caps.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
projected_cost_micros: Estimated cost of the pending request.
|
|
190
|
+
In strict mode, added to current spend for pre-flight check.
|
|
191
|
+
In post mode, ignored (only accumulated spend matters).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
CapResult indicating whether any cap is exceeded.
|
|
195
|
+
"""
|
|
196
|
+
if not self.has_caps:
|
|
197
|
+
return CapResult(exceeded=False)
|
|
198
|
+
|
|
199
|
+
extra = projected_cost_micros if self.cap_mode == "strict" else 0
|
|
200
|
+
|
|
201
|
+
if self.daily_cap_micros is not None:
|
|
202
|
+
daily = self.daily_spend_micros() + extra
|
|
203
|
+
if daily >= self.daily_cap_micros:
|
|
204
|
+
return CapResult(
|
|
205
|
+
exceeded=True,
|
|
206
|
+
cap_type="daily",
|
|
207
|
+
current_micros=daily,
|
|
208
|
+
limit_micros=self.daily_cap_micros,
|
|
209
|
+
projected=extra > 0,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if self.monthly_cap_micros is not None:
|
|
213
|
+
monthly = self.monthly_spend_micros() + extra
|
|
214
|
+
if monthly >= self.monthly_cap_micros:
|
|
215
|
+
return CapResult(
|
|
216
|
+
exceeded=True,
|
|
217
|
+
cap_type="monthly",
|
|
218
|
+
current_micros=monthly,
|
|
219
|
+
limit_micros=self.monthly_cap_micros,
|
|
220
|
+
projected=extra > 0,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return CapResult(exceeded=False)
|
|
224
|
+
|
|
225
|
+
def cap_summary(self) -> dict[str, dict[str, float]]:
|
|
226
|
+
"""Return current spend vs caps for CLI display."""
|
|
227
|
+
result: dict[str, dict[str, float]] = {}
|
|
228
|
+
if self.daily_cap_micros is not None:
|
|
229
|
+
daily = self.daily_spend_micros()
|
|
230
|
+
result["daily"] = {
|
|
231
|
+
"current_usd": daily / _MICROS_PER_DOLLAR,
|
|
232
|
+
"limit_usd": self.daily_cap_micros / _MICROS_PER_DOLLAR,
|
|
233
|
+
"percent": round(daily / self.daily_cap_micros * 100, 1) if self.daily_cap_micros > 0 else 0,
|
|
234
|
+
}
|
|
235
|
+
if self.monthly_cap_micros is not None:
|
|
236
|
+
monthly = self.monthly_spend_micros()
|
|
237
|
+
result["monthly"] = {
|
|
238
|
+
"current_usd": monthly / _MICROS_PER_DOLLAR,
|
|
239
|
+
"limit_usd": self.monthly_cap_micros / _MICROS_PER_DOLLAR,
|
|
240
|
+
"percent": round(monthly / self.monthly_cap_micros * 100, 1) if self.monthly_cap_micros > 0 else 0,
|
|
241
|
+
}
|
|
242
|
+
return result
|