webctx 0.2.6 → 0.2.8

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,7 +15,10 @@ 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"
@@ -25,10 +29,13 @@ const defaultDataCreditBudget = 200
25
29
  const maxDataResponseBytes int64 = 12 << 20
26
30
 
27
31
  type DataError struct {
28
- Code string `json:"code"`
29
- Message string `json:"message"`
30
- RequiresAction map[string]any `json:"requires_action,omitempty"`
31
- 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"`
32
39
  }
33
40
 
34
41
  func (e *DataError) Error() string { return e.Code + ": " + e.Message }
@@ -54,6 +61,101 @@ func dataCreditCap() int {
54
61
 
55
62
  var safeRequestID = regexp.MustCompile("^[A-Za-z0-9_-]{8,128}$")
56
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 ""
93
+ }
94
+ hash := sha256.Sum256(append(append([]byte(key), 0), snapshot...))
95
+ return hex.EncodeToString(hash[:])
96
+ }
97
+
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
+ }
158
+
57
159
  func newRequestID() (string, error) {
58
160
  var bytes [16]byte
59
161
  if _, err := rand.Read(bytes[:]); err != nil {
@@ -80,7 +182,18 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
80
182
  if requestID != "" {
81
183
  req.Header.Set("x-request-id", requestID)
82
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
+ }
83
195
  res, err := http.DefaultClient.Do(req)
196
+ <-upstreamDataGate
84
197
  if err != nil {
85
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."}
86
199
  }
@@ -94,6 +207,10 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
94
207
  }
95
208
  var decoded map[string]any
96
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
+ }
97
214
  return nil, dataError("invalid_upstream_response", fmt.Sprintf("Provider returned non-JSON HTTP %d.", res.StatusCode))
98
215
  }
99
216
  if res.StatusCode < 200 || res.StatusCode >= 300 || decoded["success"] == false {
@@ -111,7 +228,36 @@ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID stri
111
228
  if len(message) > 800 {
112
229
  message = message[:800]
113
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
+ }
114
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
+ }
115
261
  if action, ok := decoded["requiresAction"].(map[string]any); ok {
116
262
  if target := stringValue(action["url"]); target != "" {
117
263
  if u, e := url.Parse(target); e == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
@@ -148,6 +294,43 @@ func extractNativeData(response map[string]any) (map[string]any, error) {
148
294
  }
149
295
 
150
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) {
151
334
  response, err := nativeDataCall(ctx, "firecrawl", "find-tools", options, "")
152
335
  if err != nil {
153
336
  return nil, nil, err
@@ -158,7 +341,12 @@ func freeDataBrowse(ctx context.Context, options map[string]any) (map[string]any
158
341
  }
159
342
  details, ok := item["data"].(map[string]any)
160
343
  if !ok {
161
- 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.")
162
350
  }
163
351
  return details, response, nil
164
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
+ }
@@ -32,6 +32,32 @@ func contractExamples(contract map[string]any) any {
32
32
  input[name] = value
33
33
  }
34
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
+ }
35
61
  // An illustrative example should not accidentally fetch huge datasets
36
62
  // when copied verbatim. Users may adjust the inputs freely.
37
63
  for _, name := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
@@ -43,15 +69,42 @@ func contractExamples(contract map[string]any) any {
43
69
  return nil
44
70
  }
45
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
+ }
46
94
  example := map[string]any{
47
- "kind": "generated_from_inspected_schema",
48
- "note": "Illustrative Origo-generated inputs; not a published provider example or an executed query.",
49
- "inputs": input,
50
- "valid_input_shape": validation == nil,
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,
51
101
  }
52
- if validation != nil {
102
+ if validation != nil || !practical {
53
103
  example["note"] = "Illustrative input template; fill required fields and check source-specific constraints before execution."
54
104
  example["needs_input"] = needed
105
+ if len(chooseOne) > 0 {
106
+ example["needs_one_of"] = chooseOne
107
+ }
55
108
  } else {
56
109
  example["next"] = map[string]any{"tool": "execute", "arguments": map[string]any{
57
110
  "calls": []any{map[string]any{"id": contract["id"], "inputs": input}},
@@ -72,7 +125,13 @@ func sampleFieldValue(name string, field map[string]any) (any, bool) {
72
125
  }
73
126
  }
74
127
  if sample, ok := field["example"]; ok && sample != nil {
75
- return sample, true
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
+ }
76
135
  }
77
136
  if samples := listField(field["examples"]); len(samples) > 0 && samples[0] != nil {
78
137
  return samples[0], true
@@ -83,6 +142,20 @@ func sampleFieldValue(name string, field map[string]any) (any, bool) {
83
142
  info := stringField(field["about"]) + " " + stringField(field["description"])
84
143
  lower := strings.ToLower(name + " " + info)
85
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
86
159
  case strings.Contains(name, "series_id") && strings.Contains(info, "CPIAUCSL"):
87
160
  return "CPIAUCSL", true
88
161
  case strings.Contains(name, "series_id") && strings.Contains(info, "UNRATE"):