ai-consultants 2.10.1

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 (62) hide show
  1. package/.claude/commands/ai-consultants:consult.md +94 -0
  2. package/.claude/commands/ai-consultants:debate.md +103 -0
  3. package/.claude/commands/ai-consultants:help.md +30 -0
  4. package/CLAUDE.md +839 -0
  5. package/LICENSE +21 -0
  6. package/README.md +758 -0
  7. package/SKILL.md +156 -0
  8. package/bin/ai-consultants +96 -0
  9. package/docs/COST_RATES.md +172 -0
  10. package/docs/JSON_SCHEMA.md +305 -0
  11. package/docs/SETUP.md +879 -0
  12. package/docs/SMART_ROUTING.md +219 -0
  13. package/docs/cost_rates.json +143 -0
  14. package/package.json +47 -0
  15. package/references/configuration.md +141 -0
  16. package/references/details.md +60 -0
  17. package/scripts/build_context.sh +562 -0
  18. package/scripts/classify_question.sh +148 -0
  19. package/scripts/config.sh +753 -0
  20. package/scripts/configure.sh +1093 -0
  21. package/scripts/consult_all.sh +934 -0
  22. package/scripts/debate_round.sh +421 -0
  23. package/scripts/doctor.sh +642 -0
  24. package/scripts/followup.sh +197 -0
  25. package/scripts/install.sh +319 -0
  26. package/scripts/lib/api.sh +640 -0
  27. package/scripts/lib/api_query.sh +228 -0
  28. package/scripts/lib/cache.sh +338 -0
  29. package/scripts/lib/chunking.sh +880 -0
  30. package/scripts/lib/code_optimizer.sh +671 -0
  31. package/scripts/lib/common.sh +1088 -0
  32. package/scripts/lib/costs.sh +674 -0
  33. package/scripts/lib/personas.sh +372 -0
  34. package/scripts/lib/progress.sh +251 -0
  35. package/scripts/lib/reflection.sh +447 -0
  36. package/scripts/lib/routing.sh +566 -0
  37. package/scripts/lib/schema.json +165 -0
  38. package/scripts/lib/session.sh +327 -0
  39. package/scripts/lib/symbol_map.sh +445 -0
  40. package/scripts/lib/voting.sh +495 -0
  41. package/scripts/peer_review.sh +460 -0
  42. package/scripts/preflight_check.sh +452 -0
  43. package/scripts/query_aider.sh +83 -0
  44. package/scripts/query_amp.sh +67 -0
  45. package/scripts/query_claude.sh +99 -0
  46. package/scripts/query_codex.sh +92 -0
  47. package/scripts/query_cursor.sh +72 -0
  48. package/scripts/query_deepseek.sh +17 -0
  49. package/scripts/query_gemini.sh +85 -0
  50. package/scripts/query_glm.sh +17 -0
  51. package/scripts/query_grok.sh +17 -0
  52. package/scripts/query_kilo.sh +140 -0
  53. package/scripts/query_kimi.sh +67 -0
  54. package/scripts/query_minimax.sh +17 -0
  55. package/scripts/query_mistral.sh +97 -0
  56. package/scripts/query_ollama.sh +328 -0
  57. package/scripts/query_qwen3.sh +93 -0
  58. package/scripts/setup_wizard.sh +367 -0
  59. package/scripts/skillport-install.sh +196 -0
  60. package/scripts/synthesize.sh +332 -0
  61. package/templates/consultation_report.md +323 -0
  62. package/templates/synthesis_prompt.md +120 -0
@@ -0,0 +1,640 @@
1
+ #!/bin/bash
2
+ # api.sh - HTTP/API abstraction layer for AI Consultants v2.0
3
+ #
4
+ # Provides functions for making HTTP API calls to external AI services
5
+ # with retry logic, error handling, and response parsing.
6
+ #
7
+ # Supported APIs: Qwen3 (DashScope), GLM (Zhipu), Grok (xAI)
8
+
9
+ # Load configuration (if not already loaded)
10
+ SCRIPT_DIR_API="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ if [[ -z "${AI_CONSULTANTS_VERSION:-}" ]]; then
12
+ source "$SCRIPT_DIR_API/../config.sh"
13
+ fi
14
+
15
+ # =============================================================================
16
+ # SECURITY: ERROR MESSAGE SANITIZATION
17
+ # =============================================================================
18
+
19
+ # Sanitize error messages to prevent leaking sensitive data
20
+ # Removes API keys, tokens, and other sensitive patterns
21
+ # Usage: sanitize_error_message <message>
22
+ sanitize_error_message() {
23
+ local message="$1"
24
+
25
+ # Pattern list for sensitive data that should be redacted
26
+ # - API keys (various formats)
27
+ # - Bearer tokens
28
+ # - Passwords in URLs
29
+ # - Authorization headers
30
+
31
+ echo "$message" | sed -E \
32
+ -e 's/(api[_-]?key[[:space:]]*[:=][[:space:]]*)[^[:space:]"'\'']+/\1[REDACTED]/gi' \
33
+ -e 's/(bearer[[:space:]]+)[^[:space:]"'\'']+/\1[REDACTED]/gi' \
34
+ -e 's/(authorization[[:space:]]*:[[:space:]]*)[^[:space:]]+/\1[REDACTED]/gi' \
35
+ -e 's/(password[[:space:]]*[:=][[:space:]]*)[^[:space:]"'\'']+/\1[REDACTED]/gi' \
36
+ -e 's/(token[[:space:]]*[:=][[:space:]]*)[^[:space:]"'\'']+/\1[REDACTED]/gi' \
37
+ -e 's/(sk-[a-zA-Z0-9]{20,})/[REDACTED_KEY]/g' \
38
+ -e 's/([a-zA-Z0-9_-]{32,})/[POSSIBLE_KEY]/g'
39
+ }
40
+
41
+ # =============================================================================
42
+ # API KEY VALIDATION
43
+ # =============================================================================
44
+
45
+ # Check if an API key environment variable is set
46
+ # Usage: check_api_key "QWEN3_API_KEY" "Qwen3"
47
+ check_api_key() {
48
+ local key_var_name="$1"
49
+ local consultant_name="$2"
50
+
51
+ # Get the value of the environment variable by name
52
+ local key_value="${!key_var_name:-}"
53
+
54
+ if [[ -z "$key_value" ]]; then
55
+ log_error "[$consultant_name] API key not set: $key_var_name"
56
+ log_info "Set the $key_var_name environment variable to use $consultant_name"
57
+ return 1
58
+ fi
59
+
60
+ # Basic validation: key should be non-empty and not contain obvious placeholders
61
+ if [[ "$key_value" == *"your_"* ]] || [[ "$key_value" == *"YOUR_"* ]] || [[ "$key_value" == "xxx"* ]]; then
62
+ log_error "[$consultant_name] API key appears to be a placeholder: $key_var_name"
63
+ return 1
64
+ fi
65
+
66
+ # Validate key format (minimum length, no whitespace)
67
+ if [[ ${#key_value} -lt 10 ]]; then
68
+ log_error "[$consultant_name] API key too short: $key_var_name"
69
+ return 1
70
+ fi
71
+
72
+ if [[ "$key_value" =~ [[:space:]] ]]; then
73
+ log_error "[$consultant_name] API key contains whitespace: $key_var_name"
74
+ return 1
75
+ fi
76
+
77
+ log_debug "[$consultant_name] API key validated"
78
+ return 0
79
+ }
80
+
81
+ # =============================================================================
82
+ # HTTP ERROR HANDLING
83
+ # =============================================================================
84
+
85
+ # Classify HTTP error codes
86
+ # Usage: classify_http_error <http_code>
87
+ # Returns: auth|rate_limit|server|client|success|unknown
88
+ classify_http_error() {
89
+ local http_code="$1"
90
+
91
+ case "$http_code" in
92
+ 200|201)
93
+ echo "success"
94
+ ;;
95
+ 400)
96
+ echo "client"
97
+ ;;
98
+ 401|403)
99
+ echo "auth"
100
+ ;;
101
+ 404)
102
+ echo "not_found"
103
+ ;;
104
+ 429)
105
+ echo "rate_limit"
106
+ ;;
107
+ 500|502|503|504)
108
+ echo "server"
109
+ ;;
110
+ *)
111
+ echo "unknown"
112
+ ;;
113
+ esac
114
+ }
115
+
116
+ # Calculate exponential backoff delay
117
+ # Usage: calculate_backoff <attempt_number>
118
+ calculate_backoff() {
119
+ local attempt="$1"
120
+ local base_delay="${API_BASE_BACKOFF:-2}"
121
+ local max_delay="${API_MAX_BACKOFF:-60}"
122
+
123
+ # Exponential backoff: base * 2^(attempt-1)
124
+ local delay=$((base_delay * (1 << (attempt - 1))))
125
+
126
+ # Cap at max delay
127
+ if [[ $delay -gt $max_delay ]]; then
128
+ delay=$max_delay
129
+ fi
130
+
131
+ echo "$delay"
132
+ }
133
+
134
+ # Extract Retry-After header value from response headers
135
+ # Usage: extract_retry_after <headers_file>
136
+ extract_retry_after() {
137
+ local headers_file="$1"
138
+
139
+ if [[ -f "$headers_file" ]]; then
140
+ local retry_after
141
+ retry_after=$(grep -i "^retry-after:" "$headers_file" 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' \r\n')
142
+ if [[ -n "$retry_after" && "$retry_after" =~ ^[0-9]+$ ]]; then
143
+ echo "$retry_after"
144
+ return 0
145
+ fi
146
+ fi
147
+ echo ""
148
+ return 1
149
+ }
150
+
151
+ # =============================================================================
152
+ # REQUEST BUILDERS
153
+ # =============================================================================
154
+
155
+ # Build request body for Qwen3 (Alibaba DashScope format)
156
+ # Usage: build_qwen_request <prompt> <model>
157
+ build_qwen_request() {
158
+ local prompt="$1"
159
+ local model="${2:-qwen-max}"
160
+
161
+ jq -n \
162
+ --arg model "$model" \
163
+ --arg prompt "$prompt" \
164
+ '{
165
+ model: $model,
166
+ input: {
167
+ messages: [
168
+ { role: "user", content: $prompt }
169
+ ]
170
+ },
171
+ parameters: {
172
+ result_format: "message"
173
+ }
174
+ }'
175
+ }
176
+
177
+ # Build request body for OpenAI-compatible APIs (GLM, Grok, Codex API mode, Mistral API mode)
178
+ # Usage: build_openai_request <prompt> <model> [max_tokens]
179
+ build_openai_request() {
180
+ local prompt="$1"
181
+ local model="${2:-gpt-4}"
182
+ local max_tokens="${3:-4096}"
183
+
184
+ jq -n \
185
+ --arg model "$model" \
186
+ --arg prompt "$prompt" \
187
+ --argjson max_tokens "$max_tokens" \
188
+ '{
189
+ model: $model,
190
+ messages: [
191
+ { role: "user", content: $prompt }
192
+ ],
193
+ max_tokens: $max_tokens
194
+ }'
195
+ }
196
+
197
+ # Build request body for Anthropic API (Claude API mode)
198
+ # Usage: build_anthropic_request <prompt> <model> [max_tokens]
199
+ build_anthropic_request() {
200
+ local prompt="$1"
201
+ local model="${2:-sonnet-4.6}"
202
+ local max_tokens="${3:-4096}"
203
+
204
+ jq -n \
205
+ --arg model "$model" \
206
+ --arg prompt "$prompt" \
207
+ --argjson max_tokens "$max_tokens" \
208
+ '{
209
+ model: $model,
210
+ max_tokens: $max_tokens,
211
+ messages: [
212
+ { role: "user", content: $prompt }
213
+ ]
214
+ }'
215
+ }
216
+
217
+ # Build request body for Google AI API (Gemini API mode)
218
+ # Usage: build_google_ai_request <prompt> [model]
219
+ # Note: Model is appended to URL, not in request body for Google AI
220
+ build_google_ai_request() {
221
+ local prompt="$1"
222
+
223
+ jq -n \
224
+ --arg prompt "$prompt" \
225
+ '{
226
+ contents: [
227
+ { parts: [{ text: $prompt }] }
228
+ ],
229
+ generationConfig: {
230
+ maxOutputTokens: 4096
231
+ }
232
+ }'
233
+ }
234
+
235
+ # =============================================================================
236
+ # RESPONSE PARSERS
237
+ # =============================================================================
238
+
239
+ # Parse Qwen3 response and extract content
240
+ # Usage: parse_qwen_response <response_json>
241
+ parse_qwen_response() {
242
+ local response="$1"
243
+
244
+ # Try different response paths (Qwen format varies)
245
+ local content
246
+ content=$(echo "$response" | jq -r '.output.choices[0].message.content // .output.text // empty' 2>/dev/null)
247
+
248
+ if [[ -n "$content" && "$content" != "null" ]]; then
249
+ echo "$content"
250
+ return 0
251
+ fi
252
+
253
+ # Fallback: return raw output if structured parsing fails
254
+ echo "$response" | jq -r '.output // empty' 2>/dev/null
255
+ }
256
+
257
+ # Parse OpenAI-compatible response (GLM, Grok, Codex API mode, Mistral API mode)
258
+ # Usage: parse_openai_response <response_json>
259
+ parse_openai_response() {
260
+ local response="$1"
261
+
262
+ local content
263
+ content=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
264
+
265
+ if [[ -n "$content" && "$content" != "null" ]]; then
266
+ echo "$content"
267
+ return 0
268
+ fi
269
+
270
+ echo ""
271
+ return 1
272
+ }
273
+
274
+ # Parse Anthropic API response (Claude API mode)
275
+ # Usage: parse_anthropic_response <response_json>
276
+ parse_anthropic_response() {
277
+ local response="$1"
278
+
279
+ local content
280
+ content=$(echo "$response" | jq -r '.content[0].text // empty' 2>/dev/null)
281
+
282
+ if [[ -n "$content" && "$content" != "null" ]]; then
283
+ echo "$content"
284
+ return 0
285
+ fi
286
+
287
+ echo ""
288
+ return 1
289
+ }
290
+
291
+ # Parse Google AI API response (Gemini API mode)
292
+ # Usage: parse_google_ai_response <response_json>
293
+ parse_google_ai_response() {
294
+ local response="$1"
295
+
296
+ local content
297
+ content=$(echo "$response" | jq -r '.candidates[0].content.parts[0].text // empty' 2>/dev/null)
298
+
299
+ if [[ -n "$content" && "$content" != "null" ]]; then
300
+ echo "$content"
301
+ return 0
302
+ fi
303
+
304
+ echo ""
305
+ return 1
306
+ }
307
+
308
+ # Extract token usage from API response
309
+ # Usage: extract_token_usage <response_json> <format>
310
+ # format: "qwen", "openai", "anthropic", or "google_ai"
311
+ extract_token_usage() {
312
+ local response="$1"
313
+ local format="${2:-openai}"
314
+
315
+ local input_tokens=0
316
+ local output_tokens=0
317
+
318
+ case "$format" in
319
+ qwen)
320
+ input_tokens=$(echo "$response" | jq -r '.usage.input_tokens // 0' 2>/dev/null)
321
+ output_tokens=$(echo "$response" | jq -r '.usage.output_tokens // 0' 2>/dev/null)
322
+ ;;
323
+ anthropic)
324
+ input_tokens=$(echo "$response" | jq -r '.usage.input_tokens // 0' 2>/dev/null)
325
+ output_tokens=$(echo "$response" | jq -r '.usage.output_tokens // 0' 2>/dev/null)
326
+ ;;
327
+ google_ai)
328
+ input_tokens=$(echo "$response" | jq -r '.usageMetadata.promptTokenCount // 0' 2>/dev/null)
329
+ output_tokens=$(echo "$response" | jq -r '.usageMetadata.candidatesTokenCount // 0' 2>/dev/null)
330
+ ;;
331
+ *) # openai format (default)
332
+ input_tokens=$(echo "$response" | jq -r '.usage.prompt_tokens // 0' 2>/dev/null)
333
+ output_tokens=$(echo "$response" | jq -r '.usage.completion_tokens // 0' 2>/dev/null)
334
+ ;;
335
+ esac
336
+
337
+ local total=$((input_tokens + output_tokens))
338
+ echo "$total"
339
+ }
340
+
341
+ # =============================================================================
342
+ # MAIN API QUERY FUNCTION
343
+ # =============================================================================
344
+
345
+ # Execute an HTTP API query with retry and timeout
346
+ #
347
+ # Usage: run_api_query <consultant_name> <output_file> <timeout_sec> \
348
+ # <api_endpoint> <api_key_var> <request_body> [auth_style]
349
+ #
350
+ # Parameters:
351
+ # consultant_name - Display name for logging (e.g., "Qwen3")
352
+ # output_file - Path to write response body
353
+ # timeout_sec - Request timeout in seconds
354
+ # api_endpoint - Full API URL
355
+ # api_key_var - Name of env var containing API key (not the value!)
356
+ # For auth_style="none", pass empty string ""
357
+ # request_body - JSON request body
358
+ # auth_style - Optional: "bearer" (default), "apikey", "anthropic", or "none"
359
+ #
360
+ # Returns:
361
+ # 0 on success
362
+ # 1 on auth failure (no retry)
363
+ # 2 on rate limit (after max retries)
364
+ # 3 on server error (after max retries)
365
+ # 124 on timeout
366
+ run_api_query() {
367
+ local consultant_name="$1"
368
+ local output_file="$2"
369
+ local timeout_seconds="$3"
370
+ local api_endpoint="$4"
371
+ local api_key_var="$5"
372
+ local request_body="$6"
373
+ local auth_style="${7:-bearer}"
374
+
375
+ # Check rate limiting before proceeding
376
+ check_rate_limit "$consultant_name"
377
+
378
+ # Resolve API key from env var name (skip for "none" auth style)
379
+ local api_key=""
380
+ if [[ "$auth_style" != "none" ]]; then
381
+ api_key="${!api_key_var:-}"
382
+ if [[ -z "$api_key" ]]; then
383
+ log_error "[$consultant_name] API key not available: $api_key_var"
384
+ return 1
385
+ fi
386
+ fi
387
+
388
+ # Build authorization headers based on auth style
389
+ local auth_headers=()
390
+ case "$auth_style" in
391
+ none)
392
+ # No auth header needed
393
+ ;;
394
+ google_ai)
395
+ # Google AI uses x-goog-api-key header (more secure than URL param)
396
+ auth_headers+=("-H" "x-goog-api-key: $api_key")
397
+ ;;
398
+ anthropic)
399
+ auth_headers+=("-H" "x-api-key: $api_key")
400
+ auth_headers+=("-H" "anthropic-version: 2023-06-01")
401
+ ;;
402
+ apikey)
403
+ auth_headers+=("-H" "X-API-Key: $api_key")
404
+ ;;
405
+ *) # Default to bearer token
406
+ auth_headers+=("-H" "Authorization: Bearer $api_key")
407
+ ;;
408
+ esac
409
+
410
+ # Temporary files
411
+ local temp_response=$(mktemp)
412
+ local temp_headers=$(mktemp)
413
+ local error_file="${output_file}.err"
414
+
415
+ # Note: We rely on explicit cleanup before each return rather than a trap,
416
+ # because traps in functions affect the entire shell and can interfere
417
+ # with other code paths. Cleanup happens in each exit branch below.
418
+
419
+ log_info "[$consultant_name] Querying API (timeout: ${timeout_seconds}s, max retry: $MAX_RETRIES)..."
420
+
421
+ local attempt=1
422
+ local last_http_code=0
423
+ local success=false
424
+
425
+ while (( attempt <= MAX_RETRIES )); do
426
+ log_debug "[$consultant_name] Attempt $attempt of $MAX_RETRIES..."
427
+
428
+ # Execute curl request
429
+ local http_code
430
+ http_code=$(curl -s -w "%{http_code}" \
431
+ -o "$temp_response" \
432
+ -D "$temp_headers" \
433
+ -X POST "$api_endpoint" \
434
+ "${auth_headers[@]}" \
435
+ -H "Content-Type: application/json" \
436
+ -m "$timeout_seconds" \
437
+ -d "$request_body" 2>"$error_file")
438
+
439
+ local curl_exit=$?
440
+ last_http_code="$http_code"
441
+
442
+ # Handle curl-level errors
443
+ if [[ $curl_exit -eq 28 ]]; then
444
+ log_warn "[$consultant_name] Timeout after ${timeout_seconds}s"
445
+ ((attempt++))
446
+ if (( attempt <= MAX_RETRIES )); then
447
+ local backoff=$(calculate_backoff "$attempt")
448
+ log_info "[$consultant_name] Waiting ${backoff}s before retry..."
449
+ sleep "$backoff"
450
+ fi
451
+ continue
452
+ elif [[ $curl_exit -ne 0 ]]; then
453
+ log_warn "[$consultant_name] Network error (curl exit: $curl_exit)"
454
+ local error_msg=""
455
+ [[ -f "$error_file" ]] && error_msg=$(head -3 "$error_file" 2>/dev/null)
456
+ # Sanitize error message to avoid leaking sensitive data
457
+ [[ -n "$error_msg" ]] && log_debug "[$consultant_name] Error: $(sanitize_error_message "$error_msg")"
458
+ ((attempt++))
459
+ if (( attempt <= MAX_RETRIES )); then
460
+ sleep "$RETRY_DELAY_SECONDS"
461
+ fi
462
+ continue
463
+ fi
464
+
465
+ # Classify HTTP response
466
+ local error_type=$(classify_http_error "$http_code")
467
+
468
+ case "$error_type" in
469
+ success)
470
+ # Verify response is not empty
471
+ if [[ -s "$temp_response" ]]; then
472
+ log_success "[$consultant_name] Response received (HTTP $http_code, $(wc -c < "$temp_response" | tr -d ' ') bytes)"
473
+ cp "$temp_response" "$output_file"
474
+ success=true
475
+ break
476
+ else
477
+ log_warn "[$consultant_name] Empty response body"
478
+ fi
479
+ ;;
480
+ auth)
481
+ log_error "[$consultant_name] Authentication failed (HTTP $http_code)"
482
+ log_error "[$consultant_name] Check that $api_key_var is correct"
483
+ rm -f "$temp_response" "$temp_headers" "$error_file"
484
+ return 1 # No retry for auth failures
485
+ ;;
486
+ rate_limit)
487
+ # Try to get retry-after header
488
+ local retry_after=$(extract_retry_after "$temp_headers")
489
+ local backoff
490
+ if [[ -n "$retry_after" ]]; then
491
+ backoff="$retry_after"
492
+ log_warn "[$consultant_name] Rate limited, server requests ${retry_after}s wait"
493
+ else
494
+ backoff=$(calculate_backoff "$attempt")
495
+ log_warn "[$consultant_name] Rate limited (HTTP 429), backing off ${backoff}s"
496
+ fi
497
+ ((attempt++))
498
+ if (( attempt <= MAX_RETRIES )); then
499
+ sleep "$backoff"
500
+ fi
501
+ ;;
502
+ server)
503
+ log_warn "[$consultant_name] Server error (HTTP $http_code)"
504
+ ((attempt++))
505
+ if (( attempt <= MAX_RETRIES )); then
506
+ local backoff=$(calculate_backoff "$attempt")
507
+ log_info "[$consultant_name] Waiting ${backoff}s before retry..."
508
+ sleep "$backoff"
509
+ fi
510
+ ;;
511
+ client)
512
+ log_error "[$consultant_name] Client error (HTTP $http_code)"
513
+ # Log response body for debugging (sanitized to avoid leaking sensitive data)
514
+ if [[ -s "$temp_response" ]]; then
515
+ local error_msg=$(jq -r '.error.message // .message // .' "$temp_response" 2>/dev/null | head -5)
516
+ log_debug "[$consultant_name] Response: $(sanitize_error_message "$error_msg")"
517
+ fi
518
+ rm -f "$temp_response" "$temp_headers" "$error_file"
519
+ return 1 # No retry for client errors
520
+ ;;
521
+ *)
522
+ log_warn "[$consultant_name] Unexpected response (HTTP $http_code)"
523
+ ((attempt++))
524
+ if (( attempt <= MAX_RETRIES )); then
525
+ sleep "$RETRY_DELAY_SECONDS"
526
+ fi
527
+ ;;
528
+ esac
529
+ done
530
+
531
+ # Cleanup
532
+ rm -f "$temp_response" "$temp_headers" "$error_file"
533
+
534
+ if [[ "$success" == "true" ]]; then
535
+ return 0
536
+ else
537
+ log_error "[$consultant_name] All $MAX_RETRIES attempts failed (last HTTP: $last_http_code)"
538
+ return 3
539
+ fi
540
+ }
541
+
542
+ # =============================================================================
543
+ # API CONFIGURATION DEFAULTS
544
+ # =============================================================================
545
+
546
+ # Base backoff delay in seconds
547
+ API_BASE_BACKOFF="${API_BASE_BACKOFF:-2}"
548
+
549
+ # Maximum backoff delay in seconds
550
+ API_MAX_BACKOFF="${API_MAX_BACKOFF:-60}"
551
+
552
+ # =============================================================================
553
+ # RATE LIMITING
554
+ # =============================================================================
555
+
556
+ # Rate limiting configuration
557
+ # Requests per minute limit per consultant
558
+ API_RATE_LIMIT="${API_RATE_LIMIT:-30}"
559
+
560
+ # Rate limit state file (per consultant)
561
+ RATE_LIMIT_DIR="${RATE_LIMIT_DIR:-/tmp/ai_consultants_ratelimit}"
562
+
563
+ # Initialize rate limit directory
564
+ _init_rate_limit_dir() {
565
+ if [[ ! -d "$RATE_LIMIT_DIR" ]]; then
566
+ mkdir -p "$RATE_LIMIT_DIR"
567
+ chmod 700 "$RATE_LIMIT_DIR"
568
+ fi
569
+ }
570
+
571
+ # Check and enforce rate limit for a consultant
572
+ # Returns 0 if request can proceed, 1 if rate limited (with delay)
573
+ # Usage: check_rate_limit <consultant_name>
574
+ check_rate_limit() {
575
+ local consultant_name="$1"
576
+ local limit="${API_RATE_LIMIT:-30}"
577
+ local window=60 # 1 minute window
578
+
579
+ _init_rate_limit_dir
580
+
581
+ # Normalize consultant name for filename
582
+ local safe_name=$(echo "$consultant_name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '_')
583
+ local state_file="${RATE_LIMIT_DIR}/${safe_name}_ratelimit"
584
+
585
+ local now=$(date +%s)
586
+ local window_start=$((now - window))
587
+
588
+ # Create state file if it doesn't exist
589
+ if [[ ! -f "$state_file" ]]; then
590
+ touch "$state_file"
591
+ chmod 600 "$state_file"
592
+ fi
593
+
594
+ # Read timestamps from state file and count requests in current window
595
+ local count=0
596
+ local new_timestamps=""
597
+
598
+ while IFS= read -r timestamp; do
599
+ if [[ -n "$timestamp" ]] && [[ "$timestamp" =~ ^[0-9]+$ ]]; then
600
+ if [[ $timestamp -ge $window_start ]]; then
601
+ ((count++)) || true
602
+ new_timestamps+="$timestamp"$'\n'
603
+ fi
604
+ fi
605
+ done < "$state_file"
606
+
607
+ # Check if we're at the limit
608
+ if [[ $count -ge $limit ]]; then
609
+ # Calculate wait time until oldest request expires
610
+ local oldest=$(echo "$new_timestamps" | head -1)
611
+ if [[ -n "$oldest" ]]; then
612
+ local wait_time=$((oldest + window - now + 1))
613
+ if [[ $wait_time -gt 0 ]]; then
614
+ log_warn "[$consultant_name] Rate limit reached ($count/$limit requests/min), waiting ${wait_time}s..."
615
+ sleep "$wait_time"
616
+ fi
617
+ fi
618
+ fi
619
+
620
+ # Record this request
621
+ new_timestamps+="$now"$'\n'
622
+ echo "$new_timestamps" > "$state_file"
623
+
624
+ return 0
625
+ }
626
+
627
+ # Clear rate limit state for a consultant (useful for testing)
628
+ # Usage: clear_rate_limit <consultant_name>
629
+ clear_rate_limit() {
630
+ local consultant_name="$1"
631
+ local safe_name=$(echo "$consultant_name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '_')
632
+ local state_file="${RATE_LIMIT_DIR}/${safe_name}_ratelimit"
633
+
634
+ rm -f "$state_file" 2>/dev/null || true
635
+ }
636
+
637
+ # Clear all rate limit state
638
+ clear_all_rate_limits() {
639
+ rm -rf "$RATE_LIMIT_DIR" 2>/dev/null || true
640
+ }