webctx 0.2.4 → 0.2.5
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/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +137 -0
- package/internal/app/data_client.go +220 -0
- package/internal/app/data_execute.go +424 -0
- package/internal/app/data_inspect.go +176 -0
- package/internal/app/data_research.go +323 -0
- package/internal/app/data_tools_test.go +336 -0
- package/internal/app/data_types.go +40 -0
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +46 -0
- package/pkg/origo/server_test.go +11 -6
- package/pkg/retrieval/data.go +13 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
func researchView(view, query string) (string, error) {
|
|
6
|
+
switch strings.ToLower(strings.TrimSpace(view)) {
|
|
7
|
+
case "":
|
|
8
|
+
if strings.TrimSpace(query) == "" {
|
|
9
|
+
return "providers", nil
|
|
10
|
+
}
|
|
11
|
+
return "tools", nil
|
|
12
|
+
case "sources", "providers":
|
|
13
|
+
return "providers", nil
|
|
14
|
+
case "groups":
|
|
15
|
+
return "groups", nil
|
|
16
|
+
case "operations", "tools":
|
|
17
|
+
return "tools", nil
|
|
18
|
+
default:
|
|
19
|
+
return "", dataError("invalid_view", "Use view=sources, groups, or operations.")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func expandContract(include []string) ([]string, error) {
|
|
24
|
+
seen := map[string]bool{}
|
|
25
|
+
out := make([]string, 0, len(include))
|
|
26
|
+
for _, value := range include {
|
|
27
|
+
name := ""
|
|
28
|
+
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
29
|
+
case "inputs", "options":
|
|
30
|
+
name = "options"
|
|
31
|
+
case "output", "response":
|
|
32
|
+
name = "response"
|
|
33
|
+
case "examples":
|
|
34
|
+
name = "examples"
|
|
35
|
+
default:
|
|
36
|
+
return nil, dataError("invalid_include", "Supported include values: inputs, output, examples.")
|
|
37
|
+
}
|
|
38
|
+
if !seen[name] {
|
|
39
|
+
out = append(out, name)
|
|
40
|
+
seen[name] = true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out, nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string, []string, error) {
|
|
47
|
+
switch input.Mode {
|
|
48
|
+
case "", "ranked", "catalogue":
|
|
49
|
+
default:
|
|
50
|
+
return input, "", nil, dataError("invalid_mode", "Use mode=ranked or mode=catalogue.")
|
|
51
|
+
}
|
|
52
|
+
view, err := researchView(input.View, input.Query)
|
|
53
|
+
if err != nil {
|
|
54
|
+
return input, "", nil, err
|
|
55
|
+
}
|
|
56
|
+
expand, err := expandContract(input.Include)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return input, "", nil, err
|
|
59
|
+
}
|
|
60
|
+
if input.Limit == 0 {
|
|
61
|
+
input.Limit = 10
|
|
62
|
+
}
|
|
63
|
+
if input.Limit < 1 || input.Limit > 100 {
|
|
64
|
+
return input, "", nil, dataError("invalid_limit", "limit must be between 1 and 100.")
|
|
65
|
+
}
|
|
66
|
+
if input.Offset < 0 || input.Offset > 100000 {
|
|
67
|
+
return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
|
|
68
|
+
}
|
|
69
|
+
for _, target := range input.URLs {
|
|
70
|
+
if err := validateSourceURL(target); err != nil {
|
|
71
|
+
return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return input, view, expand, nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func nativeBrowseOptions(input ResearchDataInput, view string, expand []string) map[string]any {
|
|
78
|
+
o := map[string]any{"level": view, "limit": input.Limit, "offset": input.Offset}
|
|
79
|
+
if input.Query != "" {
|
|
80
|
+
o["query"] = input.Query
|
|
81
|
+
}
|
|
82
|
+
if len(input.URLs) > 0 {
|
|
83
|
+
o["urls"] = input.URLs
|
|
84
|
+
}
|
|
85
|
+
if len(input.Sources) > 0 {
|
|
86
|
+
o["providers"] = input.Sources
|
|
87
|
+
}
|
|
88
|
+
if len(input.Categories) > 0 {
|
|
89
|
+
o["categories"] = input.Categories
|
|
90
|
+
}
|
|
91
|
+
if len(input.Groups) > 0 {
|
|
92
|
+
o["groups"] = input.Groups
|
|
93
|
+
}
|
|
94
|
+
if len(input.Operations) > 0 {
|
|
95
|
+
var caps []string
|
|
96
|
+
sourceSet := map[string]bool{}
|
|
97
|
+
for _, source := range input.Sources {
|
|
98
|
+
sourceSet[source] = true
|
|
99
|
+
}
|
|
100
|
+
for _, id := range input.Operations {
|
|
101
|
+
provider, capability, err := parseOperationID(id)
|
|
102
|
+
if err == nil {
|
|
103
|
+
caps = append(caps, capability)
|
|
104
|
+
sourceSet[provider] = true
|
|
105
|
+
} else {
|
|
106
|
+
caps = append(caps, id)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
o["capabilities"] = caps
|
|
110
|
+
if len(input.Sources) == 0 {
|
|
111
|
+
var providers []string
|
|
112
|
+
for provider := range sourceSet {
|
|
113
|
+
providers = append(providers, provider)
|
|
114
|
+
}
|
|
115
|
+
// Sorting keeps responses, cursors and test fixtures stable.
|
|
116
|
+
sortStrings(providers)
|
|
117
|
+
if len(providers) > 0 {
|
|
118
|
+
o["providers"] = providers
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if len(expand) > 0 {
|
|
123
|
+
o["expand"] = expand
|
|
124
|
+
}
|
|
125
|
+
return o
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func sortStrings(values []string) {
|
|
129
|
+
for i := 1; i < len(values); i++ {
|
|
130
|
+
for j := i; j > 0 && values[j] < values[j-1]; j-- {
|
|
131
|
+
values[j], values[j-1] = values[j-1], values[j]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ResearchData supports ranked semantic search and the complete filterable,
|
|
137
|
+
// pageable catalogue. Each result includes the exact next tool/arguments.
|
|
138
|
+
func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
139
|
+
input, view, expand, err := normalizeResearchInput(input)
|
|
140
|
+
if err != nil {
|
|
141
|
+
return nil, err
|
|
142
|
+
}
|
|
143
|
+
ctx, cancel := defaultDataContext()
|
|
144
|
+
defer cancel()
|
|
145
|
+
semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
|
|
146
|
+
len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
|
|
147
|
+
len(input.Groups) == 0 && len(input.Operations) == 0
|
|
148
|
+
var items []any
|
|
149
|
+
var total any
|
|
150
|
+
var next any
|
|
151
|
+
var raw map[string]any
|
|
152
|
+
mode := "catalogue"
|
|
153
|
+
if semantic {
|
|
154
|
+
mode = "ranked_search"
|
|
155
|
+
// Ask for full metadata even when the agent requested concise output:
|
|
156
|
+
// compact upstream matches may omit both price and operation name.
|
|
157
|
+
// We still return compact result cards unless include was requested.
|
|
158
|
+
detail := "full"
|
|
159
|
+
raw, err = doDataAPI(ctx, "/search", map[string]any{
|
|
160
|
+
"query": input.Query, "sources": []string{"alexandria"},
|
|
161
|
+
"limit": input.Limit, "toolDetail": detail,
|
|
162
|
+
}, "")
|
|
163
|
+
if err != nil {
|
|
164
|
+
return nil, err
|
|
165
|
+
}
|
|
166
|
+
data := mapField(raw["data"])
|
|
167
|
+
items = listField(data["tools"])
|
|
168
|
+
// Ranked search has no native cursor. Expose the free, complete
|
|
169
|
+
// catalogue with matching semantic query as an optional expansion.
|
|
170
|
+
next = map[string]any{"tool": "research", "arguments": map[string]any{
|
|
171
|
+
"query": input.Query, "view": "operations", "mode": "catalogue", "limit": input.Limit,
|
|
172
|
+
}, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
|
|
173
|
+
} else {
|
|
174
|
+
options := nativeBrowseOptions(input, view, expand)
|
|
175
|
+
var catalogue map[string]any
|
|
176
|
+
catalogue, raw, err = freeDataBrowse(ctx, options)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return nil, err
|
|
179
|
+
}
|
|
180
|
+
items = listField(catalogue["items"])
|
|
181
|
+
total = catalogue["total"]
|
|
182
|
+
if native := mapField(catalogue["next"]); native != nil {
|
|
183
|
+
next = continuationFromCatalogue(native)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
results := make([]map[string]any, 0, len(items))
|
|
187
|
+
for _, value := range items {
|
|
188
|
+
entry := mapField(value)
|
|
189
|
+
if entry == nil {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
id := stringField(entry["id"])
|
|
193
|
+
if id == "" && view == "tools" {
|
|
194
|
+
if provider := stringField(entry["provider"]); provider != "" {
|
|
195
|
+
id = provider + "/" + stringField(entry["capability"])
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
name := stringField(entry["name"])
|
|
199
|
+
if name == "" {
|
|
200
|
+
segments := strings.Split(id, "/")
|
|
201
|
+
name = strings.ReplaceAll(strings.ReplaceAll(segments[len(segments)-1], "_", " "), "-", " ")
|
|
202
|
+
}
|
|
203
|
+
result := map[string]any{"id": id, "name": name, "description": entry["description"]}
|
|
204
|
+
if provider := stringField(entry["provider"]); provider != "" {
|
|
205
|
+
result["source_id"] = provider
|
|
206
|
+
}
|
|
207
|
+
if cost, ok := entry["creditsCost"]; ok {
|
|
208
|
+
result["credits_per_call"] = cost
|
|
209
|
+
}
|
|
210
|
+
if cost, ok := entry["perRecord"]; ok {
|
|
211
|
+
result["per_record"] = cost
|
|
212
|
+
}
|
|
213
|
+
if count, ok := entry["toolCount"]; ok {
|
|
214
|
+
result["operation_count"] = count
|
|
215
|
+
}
|
|
216
|
+
if rank, ok := entry["similarity"]; ok {
|
|
217
|
+
result["relevance"] = rank
|
|
218
|
+
}
|
|
219
|
+
if len(expand) > 0 {
|
|
220
|
+
if selected := entry["options"]; selected != nil && containsString(expand, "options") {
|
|
221
|
+
result["inputs"] = selected
|
|
222
|
+
}
|
|
223
|
+
if selected := entry["response"]; selected != nil && containsString(expand, "response") {
|
|
224
|
+
result["output"] = selected
|
|
225
|
+
}
|
|
226
|
+
if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
|
|
227
|
+
result["examples"] = selected
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if continuation := mapField(entry["next"]); continuation != nil {
|
|
231
|
+
result["next"] = continuationFromCatalogue(continuation)
|
|
232
|
+
} else if id != "" {
|
|
233
|
+
result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
|
|
234
|
+
}
|
|
235
|
+
results = append(results, result)
|
|
236
|
+
}
|
|
237
|
+
output := map[string]any{
|
|
238
|
+
"mode": mode, "view": niceDataView(view), "results": results,
|
|
239
|
+
"count": len(results), "cost_credits": 0,
|
|
240
|
+
"guidance": "Use inspect with an operation ID to see complete inputs, output, examples and the next execute call. To explore a source, inspect its source ID.",
|
|
241
|
+
}
|
|
242
|
+
if total != nil {
|
|
243
|
+
output["total"] = total
|
|
244
|
+
}
|
|
245
|
+
if next != nil {
|
|
246
|
+
output["next"] = next
|
|
247
|
+
}
|
|
248
|
+
if input.Raw {
|
|
249
|
+
output["raw"] = raw
|
|
250
|
+
}
|
|
251
|
+
return output, nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func niceDataView(view string) string {
|
|
255
|
+
switch view {
|
|
256
|
+
case "providers":
|
|
257
|
+
return "sources"
|
|
258
|
+
case "tools":
|
|
259
|
+
return "operations"
|
|
260
|
+
default:
|
|
261
|
+
return view
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func containsString(items []string, needle string) bool {
|
|
266
|
+
for _, item := range items {
|
|
267
|
+
if item == needle {
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func continuationFromCatalogue(native map[string]any) map[string]any {
|
|
275
|
+
options := mapField(native["options"])
|
|
276
|
+
if options == nil {
|
|
277
|
+
return map[string]any{"native_request": native}
|
|
278
|
+
}
|
|
279
|
+
args := map[string]any{"mode": "catalogue"}
|
|
280
|
+
for _, field := range []string{"query", "urls", "providers", "categories", "groups", "limit", "offset"} {
|
|
281
|
+
if value, ok := options[field]; ok {
|
|
282
|
+
name := field
|
|
283
|
+
if field == "providers" {
|
|
284
|
+
name = "sources"
|
|
285
|
+
}
|
|
286
|
+
args[name] = value
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if level := stringField(options["level"]); level != "" {
|
|
290
|
+
args["view"] = niceDataView(level)
|
|
291
|
+
}
|
|
292
|
+
if caps := listField(options["capabilities"]); len(caps) > 0 {
|
|
293
|
+
var ids []string
|
|
294
|
+
sourceNames := listField(options["providers"])
|
|
295
|
+
for _, capability := range caps {
|
|
296
|
+
cap := stringField(capability)
|
|
297
|
+
if cap == "" {
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if len(sourceNames) == 1 {
|
|
301
|
+
ids = append(ids, stringField(sourceNames[0])+"/"+cap)
|
|
302
|
+
} else {
|
|
303
|
+
ids = append(ids, cap)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
args["operations"] = ids
|
|
307
|
+
}
|
|
308
|
+
if exp := listField(options["expand"]); len(exp) > 0 {
|
|
309
|
+
var include []string
|
|
310
|
+
for _, value := range exp {
|
|
311
|
+
switch stringField(value) {
|
|
312
|
+
case "options":
|
|
313
|
+
include = append(include, "inputs")
|
|
314
|
+
case "response":
|
|
315
|
+
include = append(include, "output")
|
|
316
|
+
case "examples":
|
|
317
|
+
include = append(include, "examples")
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
args["include"] = include
|
|
321
|
+
}
|
|
322
|
+
return map[string]any{"tool": "research", "arguments": args, "native_request": native}
|
|
323
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func dataTestContract() map[string]any {
|
|
14
|
+
return map[string]any{
|
|
15
|
+
"id": "particle/podcasts/episodes/search",
|
|
16
|
+
"provider": "particle",
|
|
17
|
+
"capability": "podcasts/episodes/search",
|
|
18
|
+
"name": "Search podcast conversations",
|
|
19
|
+
"description": "Find episodes by what people discussed.",
|
|
20
|
+
"creditsCost": 15,
|
|
21
|
+
"perRecord": false,
|
|
22
|
+
"requiresOneOf": []any{[]any{"semantic_search", "keyword_search"}},
|
|
23
|
+
"options": []any{
|
|
24
|
+
map[string]any{"name": "semantic_search", "type": "string", "about": "Topic described in your own words."},
|
|
25
|
+
map[string]any{"name": "keyword_search", "type": "string", "about": "Exact transcript keyword."},
|
|
26
|
+
map[string]any{"name": "limit", "type": "number", "default": 25, "min": 1, "max": 100},
|
|
27
|
+
map[string]any{"name": "cursor", "type": "string", "about": "Opaque pagination token."},
|
|
28
|
+
},
|
|
29
|
+
"response": map[string]any{"key": "data", "paginated": true, "fields": []any{map[string]any{"name": "episode", "type": "object"}}},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type dataTestTransport struct {
|
|
34
|
+
mu sync.Mutex
|
|
35
|
+
executions int
|
|
36
|
+
lastHeaders http.Header
|
|
37
|
+
lastPayload map[string]any
|
|
38
|
+
blockPaid bool
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
42
|
+
if req.URL.Host != "api.firecrawl.dev" || req.Method != http.MethodPost {
|
|
43
|
+
return testHTTPResponse(req, http.StatusNotFound, "{}", nil), nil
|
|
44
|
+
}
|
|
45
|
+
body, err := io.ReadAll(req.Body)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return nil, err
|
|
48
|
+
}
|
|
49
|
+
var payload map[string]any
|
|
50
|
+
if err := json.Unmarshal(body, &payload); err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
d.mu.Lock()
|
|
54
|
+
d.lastPayload = payload
|
|
55
|
+
d.lastHeaders = req.Header.Clone()
|
|
56
|
+
d.mu.Unlock()
|
|
57
|
+
write := func(status int, content map[string]any) (*http.Response, error) {
|
|
58
|
+
b, _ := json.Marshal(content)
|
|
59
|
+
return testHTTPResponse(req, status, string(b), nil), nil
|
|
60
|
+
}
|
|
61
|
+
if req.URL.Path == "/v2/search" {
|
|
62
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
63
|
+
"tools": []any{dataTestContract()},
|
|
64
|
+
}})
|
|
65
|
+
}
|
|
66
|
+
if req.URL.Path != "/v2/scrape" {
|
|
67
|
+
return write(404, map[string]any{"success": false})
|
|
68
|
+
}
|
|
69
|
+
if operation := mapField(payload["alexandria"]); operation != nil &&
|
|
70
|
+
operation["provider"] == "firecrawl" && operation["capability"] == "find-tools" {
|
|
71
|
+
opts := mapField(operation["options"])
|
|
72
|
+
if opts["level"] == "providers" {
|
|
73
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
74
|
+
"alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
|
|
75
|
+
"level": "providers", "total": 3,
|
|
76
|
+
"items": []any{map[string]any{"id": "particle", "name": "Particle", "description": "Podcast data", "toolCount": 40}},
|
|
77
|
+
"next": map[string]any{"provider": "firecrawl", "capability": "find-tools", "options": map[string]any{
|
|
78
|
+
"level": "providers", "limit": 2, "offset": 2,
|
|
79
|
+
}},
|
|
80
|
+
}}},
|
|
81
|
+
}})
|
|
82
|
+
}
|
|
83
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
84
|
+
"alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
|
|
85
|
+
"level": "tools", "total": 1, "items": []any{dataTestContract()},
|
|
86
|
+
}}},
|
|
87
|
+
}})
|
|
88
|
+
}
|
|
89
|
+
if calls := listField(payload["alexandria"]); len(calls) > 0 {
|
|
90
|
+
d.mu.Lock()
|
|
91
|
+
d.executions++
|
|
92
|
+
d.mu.Unlock()
|
|
93
|
+
if d.blockPaid {
|
|
94
|
+
return write(403, map[string]any{
|
|
95
|
+
"success": false, "code": "THIRD_PARTY_DATA_TERMS_REQUIRED",
|
|
96
|
+
"error": "Provider terms have not been accepted.",
|
|
97
|
+
"requiresAction": map[string]any{"url": "https://firecrawl.dev/app/settings?tab=data-sources"},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
var results []any
|
|
101
|
+
var total int
|
|
102
|
+
for _, value := range calls {
|
|
103
|
+
call := mapField(value)
|
|
104
|
+
results = append(results, map[string]any{
|
|
105
|
+
"provider": call["provider"], "capability": call["capability"],
|
|
106
|
+
"creditsCost": 15, "alexandriaId": "paid-data-id-1",
|
|
107
|
+
"data": map[string]any{"data": []any{map[string]any{"episode": map[string]any{"id": "ep_1"}}}, "next_cursor": "after-1"},
|
|
108
|
+
})
|
|
109
|
+
total += 15
|
|
110
|
+
}
|
|
111
|
+
return write(200, map[string]any{
|
|
112
|
+
"success": true, "data": map[string]any{"alexandria": results, "creditsCost": total},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return write(400, map[string]any{"success": false, "error": "Bad test request."})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func installDataTestTransport(t *testing.T) *dataTestTransport {
|
|
119
|
+
t.Helper()
|
|
120
|
+
old := http.DefaultClient
|
|
121
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
122
|
+
d := &dataTestTransport{}
|
|
123
|
+
http.DefaultClient = &http.Client{Transport: d}
|
|
124
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-key")
|
|
125
|
+
t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
|
|
126
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "100")
|
|
127
|
+
return d
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
|
|
131
|
+
mock := installDataTestTransport(t)
|
|
132
|
+
found, err := ResearchData(ResearchDataInput{Query: "podcast conversations about AI agents", Limit: 2})
|
|
133
|
+
if err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
if found["cost_credits"] != 0 || found["mode"] != "ranked_search" {
|
|
137
|
+
t.Fatalf("ranked discovery not free: %#v", found)
|
|
138
|
+
}
|
|
139
|
+
results := found["results"].([]map[string]any)
|
|
140
|
+
id := results[0]["id"].(string)
|
|
141
|
+
if id != "particle/podcasts/episodes/search" {
|
|
142
|
+
t.Fatalf("wrong discovery ID: %s", id)
|
|
143
|
+
}
|
|
144
|
+
next := mapField(results[0]["next"])
|
|
145
|
+
if next["tool"] != "inspect" || mapField(next["arguments"])["id"] != id {
|
|
146
|
+
t.Fatalf("discovery did not provide inspect arguments: %#v", next)
|
|
147
|
+
}
|
|
148
|
+
details, err := InspectData(InspectDataInput{ID: id})
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if mapField(details["price"])["credits_per_call"] != float64(15) {
|
|
153
|
+
t.Fatalf("missing pricing: %#v", details)
|
|
154
|
+
}
|
|
155
|
+
if len(listField(mapField(details["inputs"])["fields"])) != 4 {
|
|
156
|
+
t.Fatalf("missing upstream contract fields: %#v", details["inputs"])
|
|
157
|
+
}
|
|
158
|
+
continued := mapField(details["next"])
|
|
159
|
+
if continued["tool"] != "execute" {
|
|
160
|
+
t.Fatalf("no next execution: %#v", continued)
|
|
161
|
+
}
|
|
162
|
+
args := mapField(continued["arguments"])
|
|
163
|
+
skeleton := listField(args["calls"])
|
|
164
|
+
if len(skeleton) != 1 {
|
|
165
|
+
t.Fatalf("invalid next argument shape: %#v", args)
|
|
166
|
+
}
|
|
167
|
+
inspectedCall := mapField(skeleton[0])
|
|
168
|
+
if inspectedCall == nil || inspectedCall["id"] != id || mapField(inspectedCall["inputs"])["semantic_search"] != nil {
|
|
169
|
+
t.Fatalf("invalid template: %#v", skeleton)
|
|
170
|
+
}
|
|
171
|
+
got, err := ExecuteData(ExecuteDataInput{
|
|
172
|
+
Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
|
|
173
|
+
MaxCredits: 20,
|
|
174
|
+
})
|
|
175
|
+
if err != nil {
|
|
176
|
+
t.Fatal(err)
|
|
177
|
+
}
|
|
178
|
+
if got["credits_used"] != float64(15) {
|
|
179
|
+
t.Fatalf("credits missing: %#v", got)
|
|
180
|
+
}
|
|
181
|
+
executions := got["results"].([]map[string]any)
|
|
182
|
+
if executions[0]["data_id"] != "paid-data-id-1" {
|
|
183
|
+
t.Fatalf("provider provenance missing: %#v", got)
|
|
184
|
+
}
|
|
185
|
+
followup := mapField(executions[0]["next"])
|
|
186
|
+
if followup["tool"] != "execute" {
|
|
187
|
+
t.Fatalf("missing cursor continuation: %#v", followup)
|
|
188
|
+
}
|
|
189
|
+
followArgs := mapField(followup["arguments"])
|
|
190
|
+
nextCall := mapField(listField(followArgs["calls"])[0])
|
|
191
|
+
if mapField(nextCall["inputs"])["cursor"] != "after-1" {
|
|
192
|
+
t.Fatalf("pagination lost filters: %#v", nextCall)
|
|
193
|
+
}
|
|
194
|
+
mock.mu.Lock()
|
|
195
|
+
defer mock.mu.Unlock()
|
|
196
|
+
if mock.executions != 1 || !safeRequestID.MatchString(got["request_id"].(string)) ||
|
|
197
|
+
mock.lastHeaders.Get("x-request-id") != got["request_id"].(string) {
|
|
198
|
+
t.Fatalf("request not paid once with stable ID: executions=%d req=%s", mock.executions, got["request_id"])
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func TestCatalogueBrowsingReturnsExactPaginationCall(t *testing.T) {
|
|
203
|
+
installDataTestTransport(t)
|
|
204
|
+
result, err := ResearchData(ResearchDataInput{View: "sources", Limit: 2})
|
|
205
|
+
if err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
if result["view"] != "sources" || result["total"] != float64(3) {
|
|
209
|
+
t.Fatalf("bad catalogue: %#v", result)
|
|
210
|
+
}
|
|
211
|
+
next := mapField(result["next"])
|
|
212
|
+
args := mapField(next["arguments"])
|
|
213
|
+
if next["tool"] != "research" || args["offset"] != float64(2) || args["view"] != "sources" || args["mode"] != "catalogue" {
|
|
214
|
+
t.Fatalf("invalid pagination continuation: %#v", next)
|
|
215
|
+
}
|
|
216
|
+
fromSource, err := InspectData(InspectDataInput{ID: "particle"})
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if fromSource["view"] != "operations" {
|
|
221
|
+
t.Fatalf("provider exploration failed: %#v", fromSource)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestExecutionValidationAndBudgetRejectBeforePaidCall(t *testing.T) {
|
|
226
|
+
mock := installDataTestTransport(t)
|
|
227
|
+
id := "particle/podcasts/episodes/search"
|
|
228
|
+
for _, testCase := range []struct {
|
|
229
|
+
inputs map[string]any
|
|
230
|
+
credits int
|
|
231
|
+
code string
|
|
232
|
+
}{
|
|
233
|
+
{map[string]any{}, 20, "invalid_inputs"},
|
|
234
|
+
{map[string]any{"semantic_search": "AI agents", "limit": 101}, 20, "invalid_inputs"},
|
|
235
|
+
{map[string]any{"semantic_search": "AI agents", "unknown_field": 123}, 20, "invalid_inputs"},
|
|
236
|
+
{map[string]any{"semantic_search": "AI agents", "limit": 5}, 5, "credit_budget_exceeded"},
|
|
237
|
+
} {
|
|
238
|
+
_, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}, MaxCredits: testCase.credits})
|
|
239
|
+
typed, ok := err.(*DataError)
|
|
240
|
+
if !ok || typed.Code != testCase.code {
|
|
241
|
+
t.Fatalf("wanted %s, got %v", testCase.code, err)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
mock.mu.Lock()
|
|
245
|
+
defer mock.mu.Unlock()
|
|
246
|
+
if mock.executions != 0 {
|
|
247
|
+
t.Fatalf("invalid calls charged credits: %d", mock.executions)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
|
|
252
|
+
mock := installDataTestTransport(t)
|
|
253
|
+
mock.blockPaid = true
|
|
254
|
+
_, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{
|
|
255
|
+
ID: "particle/podcasts/episodes/search",
|
|
256
|
+
Inputs: map[string]any{"semantic_search": "AI agents"},
|
|
257
|
+
}}})
|
|
258
|
+
typed, ok := err.(*DataError)
|
|
259
|
+
if !ok || typed.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" ||
|
|
260
|
+
mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
|
|
261
|
+
t.Fatalf("terms condition lost: %+v", err)
|
|
262
|
+
}
|
|
263
|
+
if mapField(typed.Details)["request_id"] == nil {
|
|
264
|
+
t.Fatalf("lost retriable request identifier: %+v", typed)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func TestCLIResearchAndInspectJSON(t *testing.T) {
|
|
269
|
+
installDataTestTransport(t)
|
|
270
|
+
var out, errBuf bytes.Buffer
|
|
271
|
+
if code := Run([]string{"research", "podcasts about AI agents", "--limit", "2", "--raw"}, &out, &errBuf); code != 0 {
|
|
272
|
+
t.Fatalf("CLI research failed: %s", errBuf.String())
|
|
273
|
+
}
|
|
274
|
+
var result map[string]any
|
|
275
|
+
if json.Unmarshal(out.Bytes(), &result) != nil || mapField(result["raw"]) == nil {
|
|
276
|
+
t.Fatalf("CLI JSON or --raw missing: %s", out.String())
|
|
277
|
+
}
|
|
278
|
+
out.Reset()
|
|
279
|
+
if code := Run([]string{"inspect", "particle/podcasts/episodes/search"}, &out, &errBuf); code != 0 {
|
|
280
|
+
t.Fatalf("CLI inspect failed: %s", errBuf.String())
|
|
281
|
+
}
|
|
282
|
+
if !strings.Contains(out.String(), "semantic_search") || !strings.Contains(out.String(), "max_credits") {
|
|
283
|
+
t.Fatalf("CLI inspect omitted exact execute syntax: %s", out.String())
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
func TestProviderNextOffsetCreatesCopyReadyContinuation(t *testing.T) {
|
|
288
|
+
id := "fred-stlouisfed-org/economic-data/series_observations"
|
|
289
|
+
inputs := map[string]any{"series_id": "CPIAUCSL", "limit": 2.0, "offset": 0.0}
|
|
290
|
+
next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0}, 15)
|
|
291
|
+
if mapField(next)["tool"] != "execute" {
|
|
292
|
+
t.Fatalf("expected executable pagination: %+v", next)
|
|
293
|
+
}
|
|
294
|
+
args := mapField(mapField(next)["arguments"])
|
|
295
|
+
call := mapField(listField(args["calls"])[0])
|
|
296
|
+
fields := mapField(call["inputs"])
|
|
297
|
+
if fields["offset"] != 2 || fields["series_id"] != "CPIAUCSL" {
|
|
298
|
+
t.Fatalf("next offset dropped exact source inputs: %+v", fields)
|
|
299
|
+
}
|
|
300
|
+
if original, _ := numberField(inputs["offset"]); original != 0 {
|
|
301
|
+
t.Fatal("original inputs mutated")
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
func TestPerRecordPricingRequiresKnownBound(t *testing.T) {
|
|
306
|
+
contract := map[string]any{"creditsCost": float64(5), "perRecord": true, "options": []any{}}
|
|
307
|
+
if n, err := expectedCallCredits(contract, map[string]any{"limit": float64(3)}); err != nil || n != 15 {
|
|
308
|
+
t.Fatalf("bad predicted per-record charge: %d %v", n, err)
|
|
309
|
+
}
|
|
310
|
+
if _, err := expectedCallCredits(contract, map[string]any{}); err == nil {
|
|
311
|
+
t.Fatal("unknown record count must not trigger unbounded paid call")
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func TestNumericEnumsMayArriveAsUpstreamStrings(t *testing.T) {
|
|
316
|
+
contract := map[string]any{"id": "example/enum", "options": []any{
|
|
317
|
+
map[string]any{"name": "year", "type": "number", "required": true, "oneOf": []any{"2021", "2023"}},
|
|
318
|
+
}}
|
|
319
|
+
if err := validateDataInputs(contract, map[string]any{"year": float64(2023)}); err != nil {
|
|
320
|
+
t.Fatalf("upstream numeric enum from strings should work: %v", err)
|
|
321
|
+
}
|
|
322
|
+
if err := validateDataInputs(contract, map[string]any{"year": float64(2026)}); err == nil {
|
|
323
|
+
t.Fatal("unlisted numeric value must be rejected")
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func TestInspectOnlyOutputStillHasExecuteInputTemplate(t *testing.T) {
|
|
328
|
+
installDataTestTransport(t)
|
|
329
|
+
details, err := InspectData(InspectDataInput{ID: "particle/podcasts/episodes/search", Include: []string{"output"}})
|
|
330
|
+
if err != nil {
|
|
331
|
+
t.Fatal(err)
|
|
332
|
+
}
|
|
333
|
+
if mapField(details["input_template"])["semantic_search"] != nil || len(listField(mapField(details["inputs"])["fields"])) != 4 {
|
|
334
|
+
t.Fatalf("missing necessary next-call schema after include=output: %+v", details)
|
|
335
|
+
}
|
|
336
|
+
}
|