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.
- package/.claude/commands/ai-consultants:consult.md +94 -0
- package/.claude/commands/ai-consultants:debate.md +103 -0
- package/.claude/commands/ai-consultants:help.md +30 -0
- package/CLAUDE.md +839 -0
- package/LICENSE +21 -0
- package/README.md +758 -0
- package/SKILL.md +156 -0
- package/bin/ai-consultants +96 -0
- package/docs/COST_RATES.md +172 -0
- package/docs/JSON_SCHEMA.md +305 -0
- package/docs/SETUP.md +879 -0
- package/docs/SMART_ROUTING.md +219 -0
- package/docs/cost_rates.json +143 -0
- package/package.json +47 -0
- package/references/configuration.md +141 -0
- package/references/details.md +60 -0
- package/scripts/build_context.sh +562 -0
- package/scripts/classify_question.sh +148 -0
- package/scripts/config.sh +753 -0
- package/scripts/configure.sh +1093 -0
- package/scripts/consult_all.sh +934 -0
- package/scripts/debate_round.sh +421 -0
- package/scripts/doctor.sh +642 -0
- package/scripts/followup.sh +197 -0
- package/scripts/install.sh +319 -0
- package/scripts/lib/api.sh +640 -0
- package/scripts/lib/api_query.sh +228 -0
- package/scripts/lib/cache.sh +338 -0
- package/scripts/lib/chunking.sh +880 -0
- package/scripts/lib/code_optimizer.sh +671 -0
- package/scripts/lib/common.sh +1088 -0
- package/scripts/lib/costs.sh +674 -0
- package/scripts/lib/personas.sh +372 -0
- package/scripts/lib/progress.sh +251 -0
- package/scripts/lib/reflection.sh +447 -0
- package/scripts/lib/routing.sh +566 -0
- package/scripts/lib/schema.json +165 -0
- package/scripts/lib/session.sh +327 -0
- package/scripts/lib/symbol_map.sh +445 -0
- package/scripts/lib/voting.sh +495 -0
- package/scripts/peer_review.sh +460 -0
- package/scripts/preflight_check.sh +452 -0
- package/scripts/query_aider.sh +83 -0
- package/scripts/query_amp.sh +67 -0
- package/scripts/query_claude.sh +99 -0
- package/scripts/query_codex.sh +92 -0
- package/scripts/query_cursor.sh +72 -0
- package/scripts/query_deepseek.sh +17 -0
- package/scripts/query_gemini.sh +85 -0
- package/scripts/query_glm.sh +17 -0
- package/scripts/query_grok.sh +17 -0
- package/scripts/query_kilo.sh +140 -0
- package/scripts/query_kimi.sh +67 -0
- package/scripts/query_minimax.sh +17 -0
- package/scripts/query_mistral.sh +97 -0
- package/scripts/query_ollama.sh +328 -0
- package/scripts/query_qwen3.sh +93 -0
- package/scripts/setup_wizard.sh +367 -0
- package/scripts/skillport-install.sh +196 -0
- package/scripts/synthesize.sh +332 -0
- package/templates/consultation_report.md +323 -0
- 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
|
+
}
|