Skip to content

Commit cee833f

Browse files
authored
Merge pull request #37 from x3c3/feat/rate-limit-llm-calls
feat: add rate limiting for LLM API calls
2 parents f304631 + 49f753f commit cee833f

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

evaluate/evaluate.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type Options struct {
4747
MaxLen int
4848
CacheDir string // Override cache directory; defaults to judge.CacheDir(skillDir) when empty
4949
Progress ProgressFunc // Optional progress callback; nil means no output
50+
RateLimit int // Max LLM API requests per second; 0 means unlimited
5051
}
5152

5253
// progress calls the progress callback if set.
@@ -65,6 +66,17 @@ func resolveCacheDir(opts Options, skillDir string) string {
6566
return judge.CacheDir(skillDir)
6667
}
6768

69+
// newThrottle returns a function that blocks until the next request is allowed.
70+
// If rps is 0 or negative, the returned function is a no-op.
71+
// The caller must call the returned stop function when done.
72+
func newThrottle(rps int) (wait func(), stop func()) {
73+
if rps <= 0 {
74+
return func() {}, func() {}
75+
}
76+
ticker := time.NewTicker(time.Second / time.Duration(rps))
77+
return func() { <-ticker.C }, ticker.Stop
78+
}
79+
6880
// EvaluateSkill scores a skill directory (SKILL.md and/or reference files).
6981
func EvaluateSkill(ctx context.Context, dir string, client judge.LLMClient, opts Options) (*Result, error) {
7082
result := &Result{SkillDir: dir}
@@ -77,6 +89,9 @@ func EvaluateSkill(ctx context.Context, dir string, client judge.LLMClient, opts
7789
return nil, fmt.Errorf("loading skill: %w", err)
7890
}
7991

92+
wait, stop := newThrottle(opts.RateLimit)
93+
defer stop()
94+
8095
// Score SKILL.md
8196
if !opts.RefsOnly {
8297
progress(opts, "scoring", fmt.Sprintf("%s/SKILL.md", skillName))
@@ -94,6 +109,7 @@ func EvaluateSkill(ctx context.Context, dir string, client judge.LLMClient, opts
94109
}
95110

96111
if result.SkillScores == nil {
112+
wait()
97113
scores, err := judge.ScoreSkill(ctx, s.RawContent, client, opts.MaxLen)
98114
if err != nil {
99115
return nil, fmt.Errorf("scoring SKILL.md: %w", err)
@@ -148,6 +164,7 @@ func EvaluateSkill(ctx context.Context, dir string, client judge.LLMClient, opts
148164
}
149165

150166
if refScores == nil {
167+
wait()
151168
scores, err := judge.ScoreReference(ctx, content, s.Frontmatter.Name, skillDesc, client, opts.MaxLen)
152169
if err != nil {
153170
progress(opts, "error", fmt.Sprintf("scoring %s: %v", name, err))
@@ -235,6 +252,10 @@ func EvaluateSingleFile(ctx context.Context, absPath string, client judge.LLMCli
235252
}
236253
}
237254

255+
wait, stop := newThrottle(opts.RateLimit)
256+
defer stop()
257+
258+
wait()
238259
scores, err := judge.ScoreReference(ctx, string(content), skillName, s.Frontmatter.Description, client, opts.MaxLen)
239260
if err != nil {
240261
return nil, fmt.Errorf("scoring %s: %w", fileName, err)

evaluate/evaluate_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"path/filepath"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"github.com/agent-ecosystem/skill-validator/judge"
1213
)
@@ -492,3 +493,43 @@ func TestEvaluateSingleFile_LLMError(t *testing.T) {
492493
t.Errorf("unexpected error: %v", err)
493494
}
494495
}
496+
497+
func TestEvaluateSkill_RateLimiting(t *testing.T) {
498+
dir := makeSkillDir(t, map[string]string{
499+
"a.md": "# A",
500+
"b.md": "# B",
501+
"c.md": "# C",
502+
})
503+
client := &mockLLMClient{responses: []string{skillJSON, refJSON, refJSON, refJSON}}
504+
505+
// 5 req/s = 200ms between calls. 4 calls (1 skill + 3 refs) = at least 600ms.
506+
start := time.Now()
507+
_, err := EvaluateSkill(context.Background(), dir, client, Options{
508+
MaxLen: 8000,
509+
RateLimit: 5,
510+
})
511+
elapsed := time.Since(start)
512+
if err != nil {
513+
t.Fatalf("EvaluateSkill error = %v", err)
514+
}
515+
// 4 API calls at 5/s means 3 intervals of 200ms = 600ms minimum
516+
if elapsed < 500*time.Millisecond {
517+
t.Errorf("expected >= 500ms for rate-limited calls, got %v", elapsed)
518+
}
519+
}
520+
521+
func TestEvaluateSkill_RateLimitZeroDisabled(t *testing.T) {
522+
dir := makeSkillDir(t, map[string]string{"a.md": "# A"})
523+
client := &mockLLMClient{responses: []string{skillJSON, refJSON}}
524+
525+
start := time.Now()
526+
_, err := EvaluateSkill(context.Background(), dir, client, Options{MaxLen: 8000})
527+
elapsed := time.Since(start)
528+
if err != nil {
529+
t.Fatalf("EvaluateSkill error = %v", err)
530+
}
531+
// With no rate limit (default 0), should complete quickly
532+
if elapsed > 2*time.Second {
533+
t.Errorf("expected fast completion without rate limit, got %v", elapsed)
534+
}
535+
}

0 commit comments

Comments
 (0)