chill-agent 1.4.0 → 1.6.0

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.
Files changed (240) hide show
  1. package/.agents/rules/ponytail.md +30 -0
  2. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  3. package/.claude-plugin/marketplace.json +17 -0
  4. package/.claude-plugin/plugin.json +8 -3
  5. package/.cline-plugin/plugin.js +6 -0
  6. package/.clinerules/ponytail.md +30 -0
  7. package/.codex-plugin/plugin.json +4 -3
  8. package/.cursor/rules/ponytail.mdc +36 -0
  9. package/.cursor-plugin/marketplace.json +16 -0
  10. package/.cursor-plugin/plugin.json +2 -2
  11. package/.devin-plugin/plugin.json +2 -2
  12. package/.grok-plugin/marketplace.json +16 -0
  13. package/.kimi-plugin/plugin.json +11 -29
  14. package/.kiro/steering/ponytail.md +35 -0
  15. package/.omp-plugin/marketplace.json +15 -0
  16. package/.openclaw/skills/ponytail/SKILL.md +47 -0
  17. package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
  18. package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
  19. package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
  20. package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
  21. package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
  22. package/.opencode/command/ponytail-audit.md +5 -0
  23. package/.opencode/command/ponytail-debt.md +5 -0
  24. package/.opencode/command/ponytail-gain.md +5 -0
  25. package/.opencode/command/ponytail-help.md +5 -0
  26. package/.opencode/command/ponytail-review.md +5 -0
  27. package/.opencode/command/ponytail.md +5 -0
  28. package/.opencode/plugins/index.js +4 -0
  29. package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
  30. package/.opencode/plugins/ponytail.mjs +206 -0
  31. package/.qoder-plugin/plugin.json +12 -26
  32. package/.windsurf/rules/ponytail.md +30 -0
  33. package/AGENTS.md +32 -0
  34. package/__init__.py +228 -0
  35. package/assets/antislop-banner.png +0 -0
  36. package/assets/band/band.png +0 -0
  37. package/assets/band/board.png +0 -0
  38. package/assets/benchmark-3model.svg +21 -0
  39. package/assets/benchmark-agentic.svg +62 -0
  40. package/assets/codex-icon.png +0 -0
  41. package/assets/compare/code/after.webp +0 -0
  42. package/assets/compare/code/before.webp +0 -0
  43. package/assets/compare/text/after.webp +0 -0
  44. package/assets/compare/text/before.webp +0 -0
  45. package/assets/compare/ui/w-all.webp +0 -0
  46. package/assets/compare/ui/w-antislop.webp +0 -0
  47. package/assets/compare/ui/w-design.webp +0 -0
  48. package/assets/compare/ui/without.webp +0 -0
  49. package/assets/logo-dark.png +0 -0
  50. package/assets/logo-dark.svg +115 -0
  51. package/assets/logo-greenpt-dark.svg +27 -0
  52. package/assets/logo-greenpt.svg +27 -0
  53. package/assets/logo.png +0 -0
  54. package/assets/photoreunion-logo-dark.svg +4 -0
  55. package/assets/photoreunion-logo-light.svg +4 -0
  56. package/assets/profile.png +0 -0
  57. package/assets/retriever-logo-dark.svg +4 -0
  58. package/assets/retriever-logo-light.svg +4 -0
  59. package/assets/stories-banner.png +0 -0
  60. package/assets/v5/audit-es.png +0 -0
  61. package/assets/v5/audit-ja.png +0 -0
  62. package/assets/v5/audit-ko.png +0 -0
  63. package/assets/v5/audit-zh-CN.png +0 -0
  64. package/assets/v5/audit.png +0 -0
  65. package/assets/v5/beforeafter-es.png +0 -0
  66. package/assets/v5/beforeafter-ja.png +0 -0
  67. package/assets/v5/beforeafter-ko.png +0 -0
  68. package/assets/v5/beforeafter-zh-CN.png +0 -0
  69. package/assets/v5/beforeafter.png +0 -0
  70. package/assets/v5/chart-es.png +0 -0
  71. package/assets/v5/chart-ja.png +0 -0
  72. package/assets/v5/chart-ko.png +0 -0
  73. package/assets/v5/chart-zh-CN.png +0 -0
  74. package/assets/v5/chart.png +0 -0
  75. package/assets/v5/hero-es.jpg +0 -0
  76. package/assets/v5/hero-ja.jpg +0 -0
  77. package/assets/v5/hero-ko.jpg +0 -0
  78. package/assets/v5/hero-zh-CN.jpg +0 -0
  79. package/assets/v5/hero.jpg +0 -0
  80. package/assets/v5/ladder-es.png +0 -0
  81. package/assets/v5/ladder-ja.png +0 -0
  82. package/assets/v5/ladder-ko.png +0 -0
  83. package/assets/v5/ladder-zh-CN.png +0 -0
  84. package/assets/v5/ladder.png +0 -0
  85. package/assets/v5/review-es.png +0 -0
  86. package/assets/v5/review-ja.png +0 -0
  87. package/assets/v5/review-ko.png +0 -0
  88. package/assets/v5/review-zh-CN.png +0 -0
  89. package/assets/v5/review.png +0 -0
  90. package/assets/v5/tests-es.png +0 -0
  91. package/assets/v5/tests-ja.png +0 -0
  92. package/assets/v5/tests-ko.png +0 -0
  93. package/assets/v5/tests-zh-CN.png +0 -0
  94. package/assets/v5/tests.png +0 -0
  95. package/assets/waitlist-banner-es.png +0 -0
  96. package/assets/waitlist-banner-ja.png +0 -0
  97. package/assets/waitlist-banner-ko.png +0 -0
  98. package/assets/waitlist-banner-zh-CN.png +0 -0
  99. package/assets/waitlist-banner.png +0 -0
  100. package/benchmarks/.env.example +2 -0
  101. package/benchmarks/agentic/README.md +201 -0
  102. package/benchmarks/agentic/answer.py +288 -0
  103. package/benchmarks/agentic/compare.py +93 -0
  104. package/benchmarks/agentic/complete.py +178 -0
  105. package/benchmarks/agentic/judge.py +198 -0
  106. package/benchmarks/agentic/run.py +543 -0
  107. package/benchmarks/agentic/tasks.py +1544 -0
  108. package/benchmarks/arms/baseline.js +2 -0
  109. package/benchmarks/arms/caveman-SKILL.md +67 -0
  110. package/benchmarks/arms/caveman.js +8 -0
  111. package/benchmarks/arms/ponytail.js +8 -0
  112. package/benchmarks/behavior.js +54 -0
  113. package/benchmarks/behavior.yaml +40 -0
  114. package/benchmarks/correctness.js +291 -0
  115. package/benchmarks/loc.js +17 -19
  116. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  117. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  118. package/benchmarks/promptfooconfig.yaml +41 -0
  119. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  120. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  121. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  122. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  123. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  124. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  125. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  126. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  127. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  128. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  129. package/cli/index.mjs +241 -0
  130. package/cli/lib/banner.mjs +21 -0
  131. package/cli/lib/install.mjs +498 -0
  132. package/cli/lib/settings.mjs +48 -0
  133. package/cli/package-lock.json +87 -0
  134. package/cli/package.json +42 -0
  135. package/cli/scripts/smoke-test.mjs +95 -0
  136. package/cli/scripts/smoke-worker.mjs +411 -0
  137. package/cli/scripts/sync-skills.mjs +81 -0
  138. package/commands/ponytail-audit.toml +2 -0
  139. package/commands/ponytail-debt.toml +2 -0
  140. package/commands/ponytail-gain.toml +2 -0
  141. package/commands/ponytail-help.toml +2 -0
  142. package/commands/ponytail-review.toml +2 -0
  143. package/commands/ponytail.toml +2 -0
  144. package/examples/email-validation.md +156 -0
  145. package/examples/group-by.md +35 -0
  146. package/examples/infinite-scroll.md +58 -0
  147. package/examples/number-formatting.md +37 -0
  148. package/examples/rate-limit.md +272 -0
  149. package/examples/react-countdown.md +390 -0
  150. package/examples/url-params.md +41 -0
  151. package/gemini-extension.json +2 -2
  152. package/hooks/band/board.ts +560 -0
  153. package/hooks/band/logo.ts +3 -0
  154. package/hooks/band/metrics.ts +311 -0
  155. package/hooks/band/register.tsx +495 -0
  156. package/hooks/band/types.d.ts +34 -0
  157. package/hooks/chill-statusline.ps1 +1 -1
  158. package/hooks/claude-band.json +6 -0
  159. package/hooks/claude-codex-hooks.json +34 -4
  160. package/hooks/copilot-hooks.json +12 -2
  161. package/hooks/cursor-hooks.json +2 -2
  162. package/hooks/ponytail-activate.js +201 -0
  163. package/hooks/ponytail-band-import.js +274 -0
  164. package/hooks/ponytail-config.js +175 -0
  165. package/hooks/ponytail-instructions.js +98 -0
  166. package/hooks/ponytail-map.js +111 -0
  167. package/hooks/ponytail-mode-tracker.js +216 -0
  168. package/hooks/ponytail-runtime.js +182 -0
  169. package/hooks/ponytail-statusline.ps1 +24 -0
  170. package/hooks/ponytail-statusline.sh +18 -0
  171. package/hooks/ponytail-subagent.js +94 -0
  172. package/hooks/qoder-hooks.json +19 -4
  173. package/i18n/README.ko.md +230 -0
  174. package/package.json +17 -6
  175. package/pi-extension/index.js +254 -0
  176. package/pi-extension/package.json +8 -0
  177. package/pi-extension/test/extension.test.js +367 -0
  178. package/pi-extension/test/helpers.test.js +179 -0
  179. package/plugin.yaml +21 -12
  180. package/rules/antislop.mdc +18 -0
  181. package/scripts/build-openclaw-skills.js +60 -0
  182. package/scripts/check-repo.mjs +389 -0
  183. package/scripts/check-rule-copies.js +74 -0
  184. package/scripts/check-versions.js +77 -0
  185. package/scripts/cursor-hooks.js +145 -0
  186. package/scripts/generate-chillslop.mjs +42 -0
  187. package/scripts/generate-examples.mjs +149 -0
  188. package/scripts/generate-plugin-doors.mjs +108 -0
  189. package/scripts/publish-openclaw-skills.js +75 -0
  190. package/scripts/skill-index.mjs +81 -0
  191. package/scripts/sync-ponytail.mjs +65 -0
  192. package/scripts/uninstall.js +92 -0
  193. package/skills/antislop/SKILL.md +700 -0
  194. package/skills/antislop/VERSION +1 -0
  195. package/skills/antislop-code/SKILL.md +128 -0
  196. package/skills/antislop-copywriting/SKILL.md +407 -0
  197. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  198. package/skills/antislop-human/SKILL.md +204 -0
  199. package/skills/antislop-human/contrast-check.py +168 -0
  200. package/skills/antislop-human/contrast-mcp.py +121 -0
  201. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  202. package/skills/antislop-human/references/WCAG.md +127 -0
  203. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  204. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  205. package/skills/antislop-ui/SKILL.md +344 -0
  206. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  207. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  208. package/skills/plugin.json +5 -0
  209. package/skills/ponytail/SKILL.md +52 -0
  210. package/skills/ponytail-audit/SKILL.md +103 -0
  211. package/skills/ponytail-debt/SKILL.md +44 -0
  212. package/skills/ponytail-gain/SKILL.md +52 -0
  213. package/skills/ponytail-help/SKILL.md +76 -0
  214. package/skills/ponytail-review/SKILL.md +101 -0
  215. package/src/package.json +3 -0
  216. package/tests/activation-map.test.js +63 -0
  217. package/tests/band-import.test.js +111 -0
  218. package/tests/band.test.tsx +520 -0
  219. package/tests/behavior.test.js +80 -0
  220. package/tests/commands.test.js +94 -0
  221. package/tests/copilot-plugin.test.js +35 -0
  222. package/tests/correctness.test.js +245 -0
  223. package/tests/cursor-hooks.test.js +343 -0
  224. package/tests/gemini-extension.test.js +91 -0
  225. package/tests/grok-plugin.test.js +39 -0
  226. package/tests/hermes-plugin.test.js +403 -0
  227. package/tests/hooks-windows.test.js +205 -0
  228. package/tests/hooks.test.js +1005 -0
  229. package/tests/kimi-plugin.test.js +25 -0
  230. package/tests/loc.test.js +18 -0
  231. package/tests/map.test.js +30 -0
  232. package/tests/openclaw-skills.test.js +26 -0
  233. package/tests/opencode-plugin.test.js +340 -0
  234. package/tests/package-scripts.test.js +35 -0
  235. package/tests/package.test.js +23 -0
  236. package/tests/qoder-plugin.test.js +84 -0
  237. package/tests/uninstall.test.js +202 -0
  238. /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
  239. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  240. /package/tests/{rules.test.js → rules.test.mjs} +0 -0
@@ -0,0 +1,156 @@
1
+ # Email Validation
2
+
3
+ **Task:** "Write me a Python function that validates email addresses."
4
+
5
+ Verbatim model output from a benchmark run, Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
6
+
7
+ ## Without Ponytail, 75 lines of code
8
+
9
+ # Email Validation Function
10
+
11
+ Here's a practical Python function that validates email addresses:
12
+
13
+ ```python
14
+ import re
15
+
16
+ def validate_email(email: str) -> bool:
17
+ """
18
+ Validates an email address using a regular expression.
19
+
20
+ Args:
21
+ email (str): The email address to validate
22
+
23
+ Returns:
24
+ bool: True if valid, False otherwise
25
+ """
26
+ # Regular expression pattern for email validation
27
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
28
+
29
+ return re.match(pattern, email) is not None
30
+
31
+
32
+ # Test cases
33
+ if __name__ == "__main__":
34
+ test_emails = [
35
+ "user@example.com", # Valid
36
+ "john.doe@company.co.uk", # Valid
37
+ "test+tag@domain.org", # Valid
38
+ "invalid.email@", # Invalid
39
+ "missing@domain", # Invalid
40
+ "@nodomain.com", # Invalid
41
+ "spaces in@email.com", # Invalid
42
+ "user@domain..com", # Invalid (double dot)
43
+ ]
44
+
45
+ for email in test_emails:
46
+ result = validate_email(email)
47
+ print(f"{email:<30} -> {result}")
48
+ ```
49
+
50
+ ## More Robust Version (with additional checks)
51
+
52
+ ```python
53
+ import re
54
+
55
+ def validate_email_advanced(email: str) -> tuple[bool, str]:
56
+ """
57
+ Advanced email validation with detailed feedback.
58
+
59
+ Args:
60
+ email (str): The email address to validate
61
+
62
+ Returns:
63
+ tuple: (is_valid, message)
64
+ """
65
+ # Basic checks
66
+ if not email or not isinstance(email, str):
67
+ return False, "Email must be a non-empty string"
68
+
69
+ email = email.strip()
70
+
71
+ if len(email) > 254:
72
+ return False, "Email is too long (max 254 characters)"
73
+
74
+ # Regular expression pattern
75
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
76
+
77
+ if not re.match(pattern, email):
78
+ return False, "Email format is invalid"
79
+
80
+ # Additional checks
81
+ local_part, domain = email.rsplit('@', 1)
82
+
83
+ if len(local_part) > 64:
84
+ return False, "Local part is too long (max 64 characters)"
85
+
86
+ if local_part.startswith('.') or local_part.endswith('.'):
87
+ return False, "Local part cannot start or end with a dot"
88
+
89
+ if '..' in email:
90
+ return False, "Email cannot contain consecutive dots"
91
+
92
+ return True, "Email is valid"
93
+
94
+
95
+ # Test the advanced version
96
+ if __name__ == "__main__":
97
+ test_emails = [
98
+ "user@example.com",
99
+ "invalid..email@domain.com",
100
+ ".invalid@domain.com",
101
+ "user@domain",
102
+ ]
103
+
104
+ for email in test_emails:
105
+ is_valid, message = validate_email_advanced(email)
106
+ print(f"{email:<35} -> {is_valid:5} ({message})")
107
+ ```
108
+
109
+ ## Using a Third-Party Library (Recommended for Production)
110
+
111
+ ```python
112
+ from email_validator import validate_email, EmailNotValidError
113
+
114
+ def validate_email_production(email: str) -> bool:
115
+ """
116
+ Validates email using the email_validator library.
117
+ More reliable for production use.
118
+
119
+ Install with: pip install email-validator
120
+ """
121
+ try:
122
+ # Validate and normalize the email
123
+ valid = validate_email(email)
124
+ return True
125
+ except EmailNotValidError:
126
+ return False
127
+
128
+
129
+ # Usage
130
+ if __name__ == "__main__":
131
+ print(validate_email_production("user@example.com")) # True
132
+ print(validate_email_production("invalid@")) # False
133
+ ```
134
+
135
+ ## Comparison
136
+
137
+ | Method | Pros | Cons |
138
+ |--------|------|------|
139
+ | **Regex (Simple)** | Fast, no dependencies | Limited validation |
140
+ | **Regex (Advanced)** | More thorough | Still not RFC-compliant |
141
+ | **email-validator** | RFC-compliant, reliable | External dependency |
142
+
143
+ **Recommendation**: Use the `email-validator` library for production applications, as it properly handles RFC 5321/5322 standards. Use the simple regex version for quick scripts or when you want to avoid dependencies.
144
+
145
+ ## With Ponytail, 3 lines of code
146
+
147
+ ```python
148
+ import re
149
+
150
+ def is_valid_email(email: str) -> bool:
151
+ return bool(re.match(r'^[^@]+@[^@]+\.[^@]+$', email))
152
+ ```
153
+
154
+ Skipped: RFC 5322 parser, DNS MX lookup, confirmation email. Add when you actually need to reject `user+tag@sub.domain.co.uk` or catch typos, until then, this catches 99% of "oops I fat-fingered it" cases.
155
+
156
+ **75 → 3 lines of code**, same model, same prompt.
@@ -0,0 +1,35 @@
1
+ # Group By
2
+
3
+ **Task:** "Group this array of objects by a key."
4
+
5
+ ## Without Ponytail
6
+
7
+ ```bash
8
+ npm install lodash
9
+ ```
10
+
11
+ ```js
12
+ import { groupBy } from "lodash";
13
+
14
+ const byStatus = groupBy(orders, "status");
15
+ // → { pending: [...], shipped: [...], delivered: [...] }
16
+ ```
17
+
18
+ Or the hand-rolled version:
19
+
20
+ ```js
21
+ const byStatus = orders.reduce((acc, order) => {
22
+ (acc[order.status] ??= []).push(order);
23
+ return acc;
24
+ }, {});
25
+ ```
26
+
27
+ ## With Ponytail
28
+
29
+ ```js
30
+ // ponytail: Object.groupBy does this
31
+ const byStatus = Object.groupBy(orders, order => order.status);
32
+ // → { pending: [...], shipped: [...], delivered: [...] }
33
+ ```
34
+
35
+ **1 dependency (or a reduce) → 1 built-in.** `Object.groupBy` shipped in Chrome 117, Firefox 119, Safari 17.4, Node.js 21. If you need a `Map` instead of a plain object: `Map.groupBy(orders, o => o.status)`. Check your target runtime; if you need IE11 or old Node, the `reduce` one-liner is still the right call, not lodash.
@@ -0,0 +1,58 @@
1
+ # Infinite Scroll
2
+
3
+ **Task:** "Load more items when the user scrolls to the bottom."
4
+
5
+ ## Without Ponytail
6
+
7
+ ```bash
8
+ npm install react-infinite-scroll-component
9
+ ```
10
+
11
+ ```jsx
12
+ import InfiniteScroll from "react-infinite-scroll-component";
13
+
14
+ export function Feed({ items, fetchMore, hasMore }) {
15
+ return (
16
+ <InfiniteScroll
17
+ dataLength={items.length}
18
+ next={fetchMore}
19
+ hasMore={hasMore}
20
+ loader={<Spinner />}
21
+ endMessage={<p>No more items</p>}
22
+ scrollThreshold={0.9}
23
+ >
24
+ {items.map(item => <Card key={item.id} item={item} />)}
25
+ </InfiniteScroll>
26
+ );
27
+ }
28
+ ```
29
+
30
+ A dependency to watch scroll position and fire a callback.
31
+
32
+ ## With Ponytail
33
+
34
+ ```jsx
35
+ // ponytail: IntersectionObserver does this, no scroll listener needed
36
+ import { useEffect, useRef } from "react";
37
+
38
+ export function Feed({ items, fetchMore, hasMore }) {
39
+ const sentinel = useRef(null);
40
+
41
+ useEffect(() => {
42
+ const observer = new IntersectionObserver(([entry]) => {
43
+ if (entry.isIntersecting && hasMore) fetchMore();
44
+ });
45
+ if (sentinel.current) observer.observe(sentinel.current);
46
+ return () => observer.disconnect();
47
+ }, [hasMore, fetchMore]);
48
+
49
+ return (
50
+ <>
51
+ {items.map(item => <Card key={item.id} item={item} />)}
52
+ <div ref={sentinel} />
53
+ </>
54
+ );
55
+ }
56
+ ```
57
+
58
+ **1 dependency → 0 dependencies.** `IntersectionObserver` fires only when the sentinel enters the viewport, no scroll event, no throttling, no jank. Ships in every browser. The library wraps exactly this API.
@@ -0,0 +1,37 @@
1
+ # Number Formatting
2
+
3
+ **Task:** "Format numbers as currency and with thousand separators."
4
+
5
+ ## Without Ponytail
6
+
7
+ ```bash
8
+ npm install numeral
9
+ # or: npm install accounting
10
+ ```
11
+
12
+ ```js
13
+ import numeral from "numeral";
14
+
15
+ numeral(1234567.89).format("$1,234.00"); // "$1,234,567.89"
16
+ numeral(0.745).format("0.0%"); // "74.5%"
17
+ numeral(1500).format("0.0a"); // "1.5k"
18
+ ```
19
+
20
+ ## With Ponytail
21
+
22
+ ```js
23
+ // ponytail: Intl.NumberFormat does this, locale-aware
24
+ new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
25
+ .format(1234567.89);
26
+ // → "$1,234,567.89"
27
+
28
+ new Intl.NumberFormat("en-US", { style: "percent" })
29
+ .format(0.745);
30
+ // → "74.5%"
31
+
32
+ new Intl.NumberFormat("en-US", { notation: "compact" })
33
+ .format(1500);
34
+ // → "1.5K"
35
+ ```
36
+
37
+ **1 dependency → 0 dependencies.** `Intl.NumberFormat` is built into every JS runtime, handles every locale correctly, and gets currency symbols, decimal separators, and grouping right for any market without a lookup table. A library that hardcodes formats will always be wrong for someone.
@@ -0,0 +1,272 @@
1
+ # Rate Limiting
2
+
3
+ **Task:** "Add rate limiting to my FastAPI endpoint so users can't spam it."
4
+
5
+ Verbatim model output from a benchmark run, Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
6
+
7
+ ## Without Ponytail, 128 lines of code
8
+
9
+ # Rate Limiting in FastAPI
10
+
11
+ Here are several approaches, from simplest to most robust:
12
+
13
+ ## 1. **Using `slowapi` (Recommended - Easiest)**
14
+
15
+ ```bash
16
+ pip install slowapi
17
+ ```
18
+
19
+ ```python
20
+ from fastapi import FastAPI, Request
21
+ from slowapi import Limiter
22
+ from slowapi.util import get_remote_address
23
+ from slowapi.errors import RateLimitExceeded
24
+ from fastapi.responses import JSONResponse
25
+
26
+ app = FastAPI()
27
+
28
+ limiter = Limiter(key_func=get_remote_address)
29
+ app.state.limiter = limiter
30
+ app.add_exception_handler(RateLimitExceeded, lambda request, exc: JSONResponse(
31
+ status_code=429,
32
+ content={"detail": "Rate limit exceeded"}
33
+ ))
34
+
35
+ @app.get("/api/data")
36
+ @limiter.limit("5/minute")
37
+ async def get_data(request: Request):
38
+ return {"message": "Success"}
39
+ ```
40
+
41
+ **Rate limit formats:**
42
+ - `"5/minute"` - 5 requests per minute
43
+ - `"100/hour"` - 100 requests per hour
44
+ - `"1000/day"` - 1000 requests per day
45
+
46
+ ---
47
+
48
+ ## 2. **Using `limits` Library (More Control)**
49
+
50
+ ```bash
51
+ pip install limits
52
+ ```
53
+
54
+ ```python
55
+ from fastapi import FastAPI, HTTPException, Request
56
+ from limits import parse
57
+ from limits.storage import MemoryStorage
58
+ from limits.strategies import MovingWindowRateLimiter
59
+
60
+ app = FastAPI()
61
+
62
+ storage = MemoryStorage()
63
+ limiter = MovingWindowRateLimiter(storage)
64
+ rate_limit = parse("5/minute")
65
+
66
+ @app.get("/api/data")
67
+ async def get_data(request: Request):
68
+ client_id = request.client.host
69
+
70
+ if not limiter.hit(rate_limit, client_id):
71
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
72
+
73
+ return {"message": "Success"}
74
+ ```
75
+
76
+ ---
77
+
78
+ ## 3. **Custom Middleware (Full Control)**
79
+
80
+ ```python
81
+ from fastapi import FastAPI, HTTPException, Request
82
+ from datetime import datetime, timedelta
83
+ from collections import defaultdict
84
+ import asyncio
85
+
86
+ app = FastAPI()
87
+
88
+ # Store request timestamps per IP
89
+ request_history = defaultdict(list)
90
+ RATE_LIMIT = 5 # requests
91
+ TIME_WINDOW = 60 # seconds
92
+
93
+ @app.middleware("http")
94
+ async def rate_limit_middleware(request: Request, call_next):
95
+ client_ip = request.client.host
96
+ now = datetime.now()
97
+
98
+ # Clean old requests outside time window
99
+ request_history[client_ip] = [
100
+ req_time for req_time in request_history[client_ip]
101
+ if now - req_time < timedelta(seconds=TIME_WINDOW)
102
+ ]
103
+
104
+ # Check if limit exceeded
105
+ if len(request_history[client_ip]) >= RATE_LIMIT:
106
+ return JSONResponse(
107
+ status_code=429,
108
+ content={"detail": "Rate limit exceeded"}
109
+ )
110
+
111
+ # Record this request
112
+ request_history[client_ip].append(now)
113
+
114
+ return await call_next(request)
115
+
116
+ @app.get("/api/data")
117
+ async def get_data():
118
+ return {"message": "Success"}
119
+ ```
120
+
121
+ ---
122
+
123
+ ## 4. **Per-User Rate Limiting (With Authentication)**
124
+
125
+ ```python
126
+ from fastapi import FastAPI, Depends, HTTPException
127
+ from slowapi import Limiter
128
+ from slowapi.util import get_remote_address
129
+
130
+ app = FastAPI()
131
+ limiter = Limiter(key_func=get_remote_address)
132
+
133
+ def get_user_id(token: str = Header(None)) -> str:
134
+ # Your auth logic here
135
+ return token or "anonymous"
136
+
137
+ @app.get("/api/data")
138
+ @limiter.limit("10/minute")
139
+ async def get_data(request: Request, user_id: str = Depends(get_user_id)):
140
+ return {"message": f"Success for {user_id}"}
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 5. **Redis-Based Rate Limiting (Production)**
146
+
147
+ ```bash
148
+ pip install slowapi redis
149
+ ```
150
+
151
+ ```python
152
+ from fastapi import FastAPI, Request
153
+ from slowapi import Limiter
154
+ from slowapi.util import get_remote_address
155
+ from slowapi.errors import RateLimitExceeded
156
+ from slowapi.storage import RedisStorage
157
+ from redis import Redis
158
+ from fastapi.responses import JSONResponse
159
+
160
+ redis_client = Redis.from_url("redis://localhost:6379")
161
+ storage = RedisStorage(redis_client)
162
+ limiter = Limiter(key_func=get_remote_address, storage=storage)
163
+
164
+ app = FastAPI()
165
+ app.state.limiter = limiter
166
+
167
+ app.add_exception_handler(
168
+ RateLimitExceeded,
169
+ lambda request, exc: JSONResponse(
170
+ status_code=429,
171
+ content={"detail": "Rate limit exceeded"}
172
+ )
173
+ )
174
+
175
+ @app.get("/api/data")
176
+ @limiter.limit("5/minute")
177
+ async def get_data(request: Request):
178
+ return {"message": "Success"}
179
+ ```
180
+
181
+ ---
182
+
183
+ ## 6. **Complete Example with Multiple Endpoints**
184
+
185
+ ```python
186
+ from fastapi import FastAPI, Request
187
+ from slowapi import Limiter
188
+ from slowapi.util import get_remote_address
189
+ from slowapi.errors import RateLimitExceeded
190
+ from fastapi.responses import JSONResponse
191
+
192
+ app = FastAPI()
193
+ limiter = Limiter(key_func=get_remote_address)
194
+ app.state.limiter = limiter
195
+
196
+ @app.exception_handler(RateLimitExceeded)
197
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
198
+ return JSONResponse(
199
+ status_code=429,
200
+ content={
201
+ "detail": "Too many requests",
202
+ "retry_after": exc.detail
203
+ }
204
+ )
205
+
206
+ # Strict limit
207
+ @app.post("/api/login")
208
+ @limiter.limit("3/minute")
209
+ async def login(request: Request):
210
+ return {"token": "xxx"}
211
+
212
+ # Moderate limit
213
+ @app.get("/api/data")
214
+ @limiter.limit("30/minute")
215
+ async def get_data(request: Request):
216
+ return {"data": []}
217
+
218
+ # Generous limit
219
+ @app.get("/api/public")
220
+ @limiter.limit("100/minute")
221
+ async def get_public(request: Request):
222
+ return {"public": "data"}
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Comparison Table
228
+
229
+ | Method | Pros | Cons | Best For |
230
+ |--------|------|------|----------|
231
+ | **slowapi** | Easy, decorator-based | In-memory only | Development, small apps |
232
+ | **limits** | Flexible, multiple backends | More setup | Custom logic |
233
+ | **Middleware** | Full control, no dependencies | Manual cleanup | Learning, simple cases |
234
+ | **Redis** | Distributed, scalable | Requires Redis | Production, multi-server |
235
+
236
+ ---
237
+
238
+ ## Testing Rate Limits
239
+
240
+ ```python
241
+ import httpx
242
+
243
+ async def test_rate_limit():
244
+ async with httpx.AsyncClient(app=app, base_url="http://test") as client:
245
+ for i in range(6):
246
+ response = await client.get("/api/data")
247
+ print(f"Request {i+1}: {response.status_code}")
248
+ # First 5 should be 200, 6th should be 429
249
+ ```
250
+
251
+ **Recommendation:** Use `slowapi` for most cases, it's the most popular and easiest to implement! 🚀
252
+
253
+ ## With Ponytail, 10 lines of code
254
+
255
+ ```python
256
+ from fastapi import FastAPI, HTTPException
257
+ from slowapi import Limiter
258
+ from slowapi.util import get_remote_address
259
+
260
+ app = FastAPI()
261
+ limiter = Limiter(key_func=get_remote_address)
262
+ app.state.limiter = limiter
263
+
264
+ @app.get("/api/endpoint")
265
+ @limiter.limit("10/minute")
266
+ async def my_endpoint(request):
267
+ return {"status": "ok"}
268
+ ```
269
+
270
+ Skipped: custom rate limit logic, Redis, sliding windows, `slowapi` handles it. Add when: you need distributed rate limiting across multiple servers (swap `Limiter` for Redis backend) or per-user limits (add `key_func=lambda r: r.headers.get("authorization")`).
271
+
272
+ **128 → 10 lines of code**, same model, same prompt.