webctx 0.2.5 → 0.2.7

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.
@@ -3,6 +3,7 @@ package app
3
3
  import (
4
4
  "context"
5
5
  "crypto/rand"
6
+ "crypto/sha256"
6
7
  "encoding/hex"
7
8
  "encoding/json"
8
9
  "errors"
@@ -14,18 +15,27 @@ import (
14
15
  "regexp"
15
16
  "strconv"
16
17
  "strings"
18
+ "sync"
17
19
  "time"
20
+
21
+ "golang.org/x/sync/singleflight"
18
22
  )
19
23
 
20
24
  const dataAPIBase = "https://api.firecrawl.dev/v2"
21
- const defaultDataCreditBudget = 100
25
+
26
+ // The server, not a client-provided tool parameter, controls this preflight ceiling.
27
+ // A production override is configured on the Origo Vercel project.
28
+ const defaultDataCreditBudget = 200
22
29
  const maxDataResponseBytes int64 = 12 << 20
23
30
 
24
31
  type DataError struct {
25
- Code string `json:"code"`
26
- Message string `json:"message"`
27
- RequiresAction map[string]any `json:"requires_action,omitempty"`
28
- Details any `json:"details,omitempty"`
32
+ Code string `json:"code"`
33
+ Message string `json:"message"`
34
+ RequiresAction map[string]any `json:"requires_action,omitempty"`
35
+ Details any `json:"details,omitempty"`
36
+ RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
37
+ RetryAt string `json:"retry_at,omitempty"`
38
+ Retryable bool `json:"retryable,omitempty"`
29
39
  }
30
40
 
31
41
  func (e *DataError) Error() string { return e.Code + ": " + e.Message }
@@ -43,22 +53,108 @@ func dataAPIKey() (string, error) {
43
53
 
44
54
  func dataCreditCap() int {
45
55
  value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_MAX_CREDITS")))
46
- if err != nil || value < 1 || value > 100000 {
56
+ if err != nil || value < 1 || value > defaultDataCreditBudget {
47
57
  return defaultDataCreditBudget
48
58
  }
49
59
  return value
50
60
  }
51
61
 
52
- func paidDataEnabled() bool {
53
- switch strings.ToLower(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_PAID"))) {
54
- case "0", "false", "no", "off":
55
- return false
56
- default:
57
- return true
62
+ var safeRequestID = regexp.MustCompile("^[A-Za-z0-9_-]{8,128}$")
63
+
64
+ var freeDataCooldown struct {
65
+ sync.Mutex
66
+ until time.Time
67
+ }
68
+
69
+ type dataReadRetryKey struct{}
70
+
71
+ var upstreamDataGate = make(chan struct{}, 3)
72
+
73
+ type catalogueCacheEntry struct {
74
+ payload map[string]any
75
+ response map[string]any
76
+ expires time.Time
77
+ }
78
+
79
+ var catalogueCache = struct {
80
+ sync.Mutex
81
+ entries map[string]catalogueCacheEntry
82
+ }{entries: map[string]catalogueCacheEntry{}}
83
+ var catalogueFlight singleflight.Group
84
+
85
+ func cacheableCatalogueKey(options map[string]any) string {
86
+ key, err := dataAPIKey()
87
+ if err != nil || strings.HasPrefix(key, "test") {
88
+ return ""
89
+ }
90
+ snapshot, err := json.Marshal(options)
91
+ if err != nil {
92
+ return ""
58
93
  }
94
+ hash := sha256.Sum256(append(append([]byte(key), 0), snapshot...))
95
+ return hex.EncodeToString(hash[:])
59
96
  }
60
97
 
61
- var safeRequestID = regexp.MustCompile("^[A-Za-z0-9_-]{8,128}$")
98
+ // Coordinate upstream read traffic per warm worker. Vercel has multiple
99
+ // workers, so this is a local best-effort throttle, not a global quota.
100
+ func dataReadCooldown(ctx context.Context) error {
101
+ freeDataCooldown.Lock()
102
+ until := freeDataCooldown.until
103
+ freeDataCooldown.Unlock()
104
+ if remaining := time.Until(until); remaining > 0 {
105
+ if remaining < 4*time.Second {
106
+ timer := time.NewTimer(remaining)
107
+ defer timer.Stop()
108
+ select {
109
+ case <-timer.C:
110
+ return nil
111
+ case <-ctx.Done():
112
+ return dataError("request_cancelled", "The provider read was cancelled while throttled.")
113
+ }
114
+ }
115
+ return rateLimitError(int(remaining.Seconds())+1, "The provider's request allowance is temporarily exhausted.", "local_cooldown")
116
+ }
117
+ return nil
118
+ }
119
+
120
+ func rateLimitError(seconds int, message, source string) *DataError {
121
+ if seconds < 1 {
122
+ seconds = 60
123
+ }
124
+ if seconds > 3600 {
125
+ seconds = 3600
126
+ }
127
+ return &DataError{
128
+ Code: "rate_limited", Message: message, Retryable: true, RetryAfterSeconds: seconds,
129
+ RetryAt: time.Now().Add(time.Duration(seconds) * time.Second).UTC().Format(time.RFC3339),
130
+ Details: map[string]any{"retry_after_source": source, "guidance": "Retry this read after the indicated delay. For paid calls, preserve the original request_id and exact inputs."},
131
+ }
132
+ }
133
+
134
+ func readRetryDelay(headers http.Header) (int, string) {
135
+ if raw := strings.TrimSpace(headers.Get("Retry-After")); raw != "" {
136
+ if n, e := strconv.Atoi(raw); e == nil && n > 0 {
137
+ return n, "retry-after-header"
138
+ }
139
+ if t, e := http.ParseTime(raw); e == nil {
140
+ seconds := int(time.Until(t).Seconds()) + 1
141
+ if seconds > 0 {
142
+ return seconds, "retry-after-header"
143
+ }
144
+ }
145
+ }
146
+ if raw := strings.TrimSpace(headers.Get("RateLimit-Reset")); raw != "" {
147
+ if n, e := strconv.ParseInt(raw, 10, 64); e == nil {
148
+ if n > 1e9 {
149
+ n = int64(time.Until(time.Unix(n, 0)).Seconds()) + 1
150
+ }
151
+ if n > 0 && n < 3601 {
152
+ return int(n), "ratelimit-reset-header"
153
+ }
154
+ }
155
+ }
156
+ return 60, "estimated_minute_window"
157
+ }
62
158
 
63
159
  func newRequestID() (string, error) {
64
160
  var bytes [16]byte
@@ -86,7 +182,18 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
86
182
  if requestID != "" {
87
183
  req.Header.Set("x-request-id", requestID)
88
184
  }
185
+ if requestID == "" {
186
+ if err := dataReadCooldown(ctx); err != nil {
187
+ return nil, err
188
+ }
189
+ }
190
+ select {
191
+ case upstreamDataGate <- struct{}{}:
192
+ case <-ctx.Done():
193
+ return nil, dataError("request_cancelled", "Timed out waiting for an upstream request slot.")
194
+ }
89
195
  res, err := http.DefaultClient.Do(req)
196
+ <-upstreamDataGate
90
197
  if err != nil {
91
198
  return nil, &DataError{Code: "network_error", Message: "The provider request did not complete. For a paid call, retry only with the same request_id to prevent duplicate charges."}
92
199
  }
@@ -100,6 +207,10 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
100
207
  }
101
208
  var decoded map[string]any
102
209
  if json.Unmarshal(raw, &decoded) != nil {
210
+ if res.StatusCode == http.StatusTooManyRequests {
211
+ seconds, source := readRetryDelay(res.Header)
212
+ return nil, rateLimitError(seconds, "The upstream data provider is rate limiting requests.", source)
213
+ }
103
214
  return nil, dataError("invalid_upstream_response", fmt.Sprintf("Provider returned non-JSON HTTP %d.", res.StatusCode))
104
215
  }
105
216
  if res.StatusCode < 200 || res.StatusCode >= 300 || decoded["success"] == false {
@@ -117,7 +228,36 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
117
228
  if len(message) > 800 {
118
229
  message = message[:800]
119
230
  }
231
+ if res.StatusCode == http.StatusTooManyRequests || strings.EqualFold(code, "rate_limit_exceeded") || strings.EqualFold(code, "rate_limited") {
232
+ seconds, source := readRetryDelay(res.Header)
233
+ if requestID == "" {
234
+ freeDataCooldown.Lock()
235
+ next := time.Now().Add(time.Duration(seconds) * time.Second)
236
+ if next.After(freeDataCooldown.until) {
237
+ freeDataCooldown.until = next
238
+ }
239
+ freeDataCooldown.Unlock()
240
+ }
241
+ // A short retry is safe for free discovery. Never automatically
242
+ // repeat a paid execution: its actual charging state may be unknown.
243
+ attempt, _ := ctx.Value(dataReadRetryKey{}).(int)
244
+ if requestID == "" && seconds <= 2 && attempt == 0 {
245
+ timer := time.NewTimer(time.Duration(seconds) * time.Second)
246
+ defer timer.Stop()
247
+ select {
248
+ case <-timer.C:
249
+ return doDataAPI(context.WithValue(ctx, dataReadRetryKey{}, 1), endpoint, payload, requestID)
250
+ case <-ctx.Done():
251
+ return nil, rateLimitError(seconds, "The provider's allowance has not recovered yet.", source)
252
+ }
253
+ }
254
+ return nil, rateLimitError(seconds, "The upstream data provider is rate limiting requests.", source)
255
+ }
120
256
  out := &DataError{Code: code, Message: message}
257
+ if strings.EqualFold(code, "duplicate_request") {
258
+ out.Message = "This request_id was already used with different inputs. Reuse the exact original request body to replay it, or omit request_id to start a new query."
259
+ out.Details = map[string]any{"guidance": "Do not retry the conflicting payload with the same request_id.", "recovery": "Restore the exact original calls for replay, or generate a fresh request_id for intentionally new calls."}
260
+ }
121
261
  if action, ok := decoded["requiresAction"].(map[string]any); ok {
122
262
  if target := stringValue(action["url"]); target != "" {
123
263
  if u, e := url.Parse(target); e == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
@@ -154,6 +294,43 @@ func extractNativeData(response map[string]any) (map[string]any, error) {
154
294
  }
155
295
 
156
296
  func freeDataBrowse(ctx context.Context, options map[string]any) (map[string]any, map[string]any, error) {
297
+ cacheKey := cacheableCatalogueKey(options)
298
+ if cacheKey != "" {
299
+ catalogueCache.Lock()
300
+ cached, ok := catalogueCache.entries[cacheKey]
301
+ catalogueCache.Unlock()
302
+ if ok && time.Now().Before(cached.expires) {
303
+ return cached.payload, cached.response, nil
304
+ }
305
+ type cacheResult struct {
306
+ payload map[string]any
307
+ response map[string]any
308
+ }
309
+ result, err, _ := catalogueFlight.Do(cacheKey, func() (any, error) {
310
+ data, raw, e := uncachedFreeDataBrowse(ctx, options)
311
+ if e != nil {
312
+ return nil, e
313
+ }
314
+ catalogueCache.Lock()
315
+ if len(catalogueCache.entries) > 256 {
316
+ catalogueCache.entries = map[string]catalogueCacheEntry{}
317
+ }
318
+ catalogueCache.entries[cacheKey] = catalogueCacheEntry{
319
+ payload: data, response: raw, expires: time.Now().Add(2 * time.Minute),
320
+ }
321
+ catalogueCache.Unlock()
322
+ return cacheResult{payload: data, response: raw}, nil
323
+ })
324
+ if err != nil {
325
+ return nil, nil, err
326
+ }
327
+ got := result.(cacheResult)
328
+ return got.payload, got.response, nil
329
+ }
330
+ return uncachedFreeDataBrowse(ctx, options)
331
+ }
332
+
333
+ func uncachedFreeDataBrowse(ctx context.Context, options map[string]any) (map[string]any, map[string]any, error) {
157
334
  response, err := nativeDataCall(ctx, "firecrawl", "find-tools", options, "")
158
335
  if err != nil {
159
336
  return nil, nil, err
@@ -164,7 +341,12 @@ func freeDataBrowse(ctx context.Context, options map[string]any) (map[string]any
164
341
  }
165
342
  details, ok := item["data"].(map[string]any)
166
343
  if !ok {
167
- return nil, nil, dataError("invalid_upstream_response", "The catalogue returned no browsable entries.")
344
+ if item["data"] == nil && item["error"] == nil {
345
+ // Some Alexandria catalogues signal an unknown ID by returning
346
+ // a successful find-tools call with data=null.
347
+ return map[string]any{"items": []any{}, "total": 0}, response, nil
348
+ }
349
+ return nil, nil, dataError("invalid_upstream_response", "The catalogue returned an unreadable response. Retry discovery before selecting an operation.")
168
350
  }
169
351
  return details, response, nil
170
352
  }
@@ -0,0 +1,65 @@
1
+ package app
2
+
3
+ import (
4
+ "strings"
5
+ )
6
+
7
+ var integerDataInputs = map[string]bool{
8
+ "page": true, "page_number": true, "pageNumber": true,
9
+ "page_size": true, "per_page": true, "offset": true,
10
+ "limit": true, "k": true, "top_k": true, "topK": true,
11
+ "count": true, "size": true, "max_results": true,
12
+ "num_results": true, "max_records": true, "results_per_page": true,
13
+ }
14
+
15
+ // Return normalized, machine-readable hints alongside the untouched native
16
+ // field contract. Do not overwrite the upstream schema's declared types.
17
+ func normalizedDataInputFields(contract map[string]any) []map[string]any {
18
+ fields := make([]map[string]any, 0)
19
+ for _, v := range listField(contract["options"]) {
20
+ item := mapField(v)
21
+ if item == nil {
22
+ continue
23
+ }
24
+ name := stringField(item["name"])
25
+ if name == "" {
26
+ continue
27
+ }
28
+ normalized := map[string]any{"name": name, "type": item["type"]}
29
+ typ := strings.ToLower(stringField(item["type"]))
30
+ description := strings.ToLower(stringField(item["about"]))
31
+ if typ == "number" && (integerDataInputs[name] || strings.Contains(description, "integer")) {
32
+ normalized["type"] = "integer"
33
+ normalized["original_type"] = item["type"]
34
+ }
35
+ for _, key := range []string{"pattern", "required", "min", "max", "default", "oneOf", "nullable", "requires", "dependsOn", "mutuallyExclusiveWith"} {
36
+ if value, ok := item[key]; ok {
37
+ normalized[key] = value
38
+ }
39
+ }
40
+ fields = append(fields, normalized)
41
+ }
42
+ return fields
43
+ }
44
+
45
+ func normalizedDataOutputFields(contract map[string]any) []map[string]any {
46
+ response := mapField(contract["response"])
47
+ fields := make([]map[string]any, 0)
48
+ for _, v := range listField(response["fields"]) {
49
+ item := mapField(v)
50
+ if item == nil {
51
+ continue
52
+ }
53
+ normalized := map[string]any{"name": item["name"], "type": item["type"]}
54
+ about := strings.ToLower(stringField(item["about"]))
55
+ nullable, _ := item["nullable"].(bool)
56
+ if strings.Contains(about, "null") || strings.Contains(about, "missing value") {
57
+ nullable = true
58
+ }
59
+ if nullable {
60
+ normalized["nullable"] = true
61
+ }
62
+ fields = append(fields, normalized)
63
+ }
64
+ return fields
65
+ }
@@ -0,0 +1,185 @@
1
+ package app
2
+
3
+ import (
4
+ "regexp"
5
+ "strings"
6
+ )
7
+
8
+ var backtickedIdentifier = regexp.MustCompile("`([A-Za-z][A-Za-z0-9_/-]{1,30})`")
9
+
10
+ // Upstream examples can legitimately be absent. Offer a clearly marked,
11
+ // locally validated sample input without claiming it was published by the
12
+ // provider or that it was executed.
13
+ func contractExamples(contract map[string]any) any {
14
+ if published := contract["examples"]; published != nil {
15
+ if len(listField(published)) > 0 {
16
+ return published
17
+ }
18
+ if len(mapField(published)) > 0 {
19
+ return published
20
+ }
21
+ }
22
+ input, needed := nativeInputTemplate(contract)
23
+ fieldInfo := map[string]map[string]any{}
24
+ for _, v := range listField(contract["options"]) {
25
+ if field := mapField(v); field != nil {
26
+ fieldInfo[stringField(field["name"])] = field
27
+ }
28
+ }
29
+ for _, name := range needed {
30
+ field := fieldInfo[name]
31
+ if value, ok := sampleFieldValue(name, field); ok {
32
+ input[name] = value
33
+ }
34
+ }
35
+ var chooseOne []any
36
+ for _, v := range listField(contract["requiresOneOf"]) {
37
+ alternatives := listField(v)
38
+ satisfied := false
39
+ for _, candidate := range alternatives {
40
+ name := stringField(candidate)
41
+ if input[name] != nil && input[name] != "" {
42
+ satisfied = true
43
+ break
44
+ }
45
+ }
46
+ if satisfied {
47
+ continue
48
+ }
49
+ for _, candidate := range alternatives {
50
+ name := stringField(candidate)
51
+ if value, ok := sampleFieldValue(name, fieldInfo[name]); ok {
52
+ input[name] = value
53
+ satisfied = true
54
+ break
55
+ }
56
+ }
57
+ if !satisfied {
58
+ chooseOne = append(chooseOne, alternatives)
59
+ }
60
+ }
61
+ // An illustrative example should not accidentally fetch huge datasets
62
+ // when copied verbatim. Users may adjust the inputs freely.
63
+ for _, name := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
64
+ if n, ok := numberField(input[name]); ok && n > 5 {
65
+ input[name] = 5
66
+ }
67
+ }
68
+ if _, _, err := parseOperationID(stringField(contract["id"])); err != nil {
69
+ return nil
70
+ }
71
+ validation := validateDataInputs(contract, input)
72
+ practical := validation == nil
73
+ if len(input) == 0 && len(listField(contract["options"])) > 0 {
74
+ practical = false
75
+ }
76
+ // A purely structural URL or a placeholder never proves a real record
77
+ // exists. High-credit person-profile lookups must use a real user URL.
78
+ for name, value := range input {
79
+ lower := strings.ToLower(name)
80
+ s := stringField(value)
81
+ if strings.Contains(lower, "linkedin") && strings.Contains(s, "example") {
82
+ practical = false
83
+ }
84
+ if strings.Contains(strings.ToLower(s), "placeholder") ||
85
+ strings.Contains(strings.ToLower(s), "identifier") {
86
+ practical = false
87
+ }
88
+ }
89
+ id := strings.ToLower(stringField(contract["id"]))
90
+ if (strings.HasSuffix(id, "/get") || strings.HasSuffix(id, "/lookup") ||
91
+ strings.HasSuffix(id, "/detail")) && len(input) == 0 {
92
+ practical = false
93
+ }
94
+ example := map[string]any{
95
+ "kind": "generated_from_inspected_schema",
96
+ "note": "Illustrative Origo-generated inputs; not a published provider example or an executed query.",
97
+ "inputs": input,
98
+ "structurally_valid": validation == nil,
99
+ "valid_input_shape": validation == nil && practical,
100
+ "ready_to_execute": practical,
101
+ }
102
+ if validation != nil || !practical {
103
+ example["note"] = "Illustrative input template; fill required fields and check source-specific constraints before execution."
104
+ example["needs_input"] = needed
105
+ if len(chooseOne) > 0 {
106
+ example["needs_one_of"] = chooseOne
107
+ }
108
+ } else {
109
+ example["next"] = map[string]any{"tool": "execute", "arguments": map[string]any{
110
+ "calls": []any{map[string]any{"id": contract["id"], "inputs": input}},
111
+ }}
112
+ }
113
+ return []any{example}
114
+ }
115
+
116
+ func sampleFieldValue(name string, field map[string]any) (any, bool) {
117
+ if field == nil {
118
+ return nil, false
119
+ }
120
+ if values := listField(field["oneOf"]); len(values) > 0 {
121
+ for _, v := range values {
122
+ if v != nil && v != "" {
123
+ return v, true
124
+ }
125
+ }
126
+ }
127
+ if sample, ok := field["example"]; ok && sample != nil {
128
+ if s := stringField(sample); s != "" &&
129
+ (strings.Contains(strings.ToLower(s), "placeholder") ||
130
+ strings.Contains(strings.ToLower(s), "identifier")) {
131
+ // Example-like placeholders are not executable source IDs.
132
+ } else {
133
+ return sample, true
134
+ }
135
+ }
136
+ if samples := listField(field["examples"]); len(samples) > 0 && samples[0] != nil {
137
+ return samples[0], true
138
+ }
139
+ if v, ok := field["default"]; ok && v != nil {
140
+ return v, true
141
+ }
142
+ info := stringField(field["about"]) + " " + stringField(field["description"])
143
+ lower := strings.ToLower(name + " " + info)
144
+ switch {
145
+ case name == "companyKey":
146
+ return "NASDAQ_AAPL", true
147
+ case name == "repo" || name == "repository":
148
+ return "openai/openai-python", true
149
+ case name == "symbol" || name == "ticker":
150
+ return "AAPL", true
151
+ case name == "from" || name == "base_currency":
152
+ return "USD", true
153
+ case name == "to" || name == "quote_currency":
154
+ return "EUR", true
155
+ case name == "domain":
156
+ return "github.com", true
157
+ case strings.Contains(lower, "linkedin") || strings.Contains(lower, "professional_network_url"):
158
+ return nil, false
159
+ case strings.Contains(name, "series_id") && strings.Contains(info, "CPIAUCSL"):
160
+ return "CPIAUCSL", true
161
+ case strings.Contains(name, "series_id") && strings.Contains(info, "UNRATE"):
162
+ return "UNRATE", true
163
+ case strings.Contains(name, "series_id") && strings.Contains(info, "GDP"):
164
+ return "GDP", true
165
+ case strings.Contains(lower, "semantic_search"):
166
+ return "AI agents", true
167
+ case name == "query" || name == "search" || name == "keyword_search":
168
+ return "example topic", true
169
+ case name == "url" || strings.HasSuffix(name, "_url"):
170
+ return "https://example.com", true
171
+ }
172
+ // Backticked descriptions sometimes contain sample codes or IDs.
173
+ if strings.Contains(lower, "id") || strings.Contains(lower, "code") {
174
+ for _, match := range backtickedIdentifier.FindAllStringSubmatch(info, 10) {
175
+ candidate := match[1]
176
+ if strings.Contains(candidate, "/") || strings.Contains(candidate, "_") {
177
+ continue
178
+ }
179
+ if len(candidate) >= 3 && !strings.EqualFold(candidate, "required") {
180
+ return candidate, true
181
+ }
182
+ }
183
+ }
184
+ return nil, false
185
+ }