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.
- package/README.md +4 -4
- package/internal/app/app.go +34 -11
- package/internal/app/data_audit_test.go +433 -0
- package/internal/app/data_client.go +196 -14
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +185 -0
- package/internal/app/data_execute.go +231 -79
- package/internal/app/data_inspect.go +21 -40
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +83 -0
- package/internal/app/data_research.go +178 -6
- package/internal/app/data_tools_test.go +124 -16
- package/internal/app/data_types.go +38 -16
- package/internal/app/research.go +24 -5
- package/internal/app/research_answers.go +30 -0
- package/internal/app/research_copyable.go +48 -0
- package/internal/app/research_copyable_test.go +57 -0
- package/internal/app/site_map_page.go +256 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +31 -5
- package/pkg/origo/server.go +106 -28
- package/pkg/origo/server_test.go +56 -4
- package/pkg/retrieval/data.go +3 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ webctx read-link <url>
|
|
|
10
10
|
webctx map-site <url>
|
|
11
11
|
webctx research "podcast conversations about AI agents"
|
|
12
12
|
webctx inspect particle/podcasts/episodes/search
|
|
13
|
-
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
13
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool. Search now retrieves relevant source excerpts automatically instead of returning only URLs. For a specific URL, add an optional question to explore related documentation and surface exact code and API details.
|
|
@@ -30,17 +30,17 @@ Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL
|
|
|
30
30
|
|
|
31
31
|
## Structured data: discover → inspect → execute
|
|
32
32
|
|
|
33
|
-
`research` discovers datasets and operations by meaning, website, source, category, or group. `inspect` retrieves exact parameter types, required fields, response shapes, pricing, and copy-ready `execute` arguments. `execute` validates
|
|
33
|
+
`research` discovers datasets and operations by meaning, website, source, category, or group. `inspect` retrieves exact parameter types, required fields, response shapes, pricing, and copy-ready `execute` arguments. `execute` validates inputs and estimated charges before independently querying each operation, preserving native JSON, pagination and partial results when another provider rejects access.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
webctx research "podcast conversations about AI agents" --limit 5
|
|
37
37
|
webctx research --view sources --limit 5
|
|
38
38
|
webctx research --mode catalogue --view operations --sources particle --limit 10
|
|
39
39
|
webctx inspect particle/podcasts/episodes/search
|
|
40
|
-
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
40
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Browse [the structured-data guide](https://webctx.ashray.xyz/docs/data-sources) for filters, paging, batches, full upstream responses, validation, and credit handling. Discovery/inspection is free; `execute` uses paid Alexandria credits. The per
|
|
43
|
+
Browse [the structured-data guide](https://webctx.ashray.xyz/docs/data-sources) for filters, paging, batches, full upstream responses, validation, and credit handling. Discovery/inspection is free; `execute` uses paid Alexandria credits. The hosted Origo server enforces a 200-credit limit per request, with no user-facing budget controls. Estimated and actual credits are shown in responses. Third-party provider terms require separate human acceptance; Origo never accepts them automatically.
|
|
44
44
|
|
|
45
45
|
Origo deploys independently through the `origo-api` Vercel project on pushes to `main`. CLI releases and npm publishing are unchanged. See the [Origo MCP guide](https://webctx.ashray.xyz/docs/origo) for architecture, setup, and security notes.
|
|
46
46
|
|
package/internal/app/app.go
CHANGED
|
@@ -70,6 +70,32 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
70
70
|
_, _ = fmt.Fprintln(stdout, "Usage: webctx map-site <url>")
|
|
71
71
|
return 1
|
|
72
72
|
}
|
|
73
|
+
if len(flags) > 0 {
|
|
74
|
+
limit, offset := 0, 0
|
|
75
|
+
if raw := flags["limit"]; raw != "" {
|
|
76
|
+
value, e := strconv.Atoi(raw)
|
|
77
|
+
if e != nil {
|
|
78
|
+
return cliDataError(stderr, dataError("invalid_limit", "--limit must be an integer"))
|
|
79
|
+
}
|
|
80
|
+
limit = value
|
|
81
|
+
}
|
|
82
|
+
if raw := flags["offset"]; raw != "" {
|
|
83
|
+
value, e := strconv.Atoi(raw)
|
|
84
|
+
if e != nil {
|
|
85
|
+
return cliDataError(stderr, dataError("invalid_offset", "--offset must be an integer"))
|
|
86
|
+
}
|
|
87
|
+
offset = value
|
|
88
|
+
}
|
|
89
|
+
result, e := MapSitePage(MapSiteInput{
|
|
90
|
+
URL: input, Limit: limit, Offset: offset, Query: flags["query"],
|
|
91
|
+
PathPrefix: flags["path-prefix"], Language: flags["language"],
|
|
92
|
+
})
|
|
93
|
+
if e != nil {
|
|
94
|
+
return cliDataError(stderr, e)
|
|
95
|
+
}
|
|
96
|
+
writeDataJSON(stdout, result)
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
73
99
|
text, err := MapSite(input)
|
|
74
100
|
if err != nil {
|
|
75
101
|
_, _ = fmt.Fprintln(stderr, err.Error())
|
|
@@ -99,6 +125,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
99
125
|
return cliDataError(stderr, dataError("invalid_limit", "--limit requires an integer."))
|
|
100
126
|
}
|
|
101
127
|
research.Limit = n
|
|
128
|
+
research.limitProvided = true
|
|
102
129
|
}
|
|
103
130
|
if flag := flags["offset"]; flag != "" {
|
|
104
131
|
n, e := strconv.Atoi(flag)
|
|
@@ -125,12 +152,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
125
152
|
return 0
|
|
126
153
|
case "execute":
|
|
127
154
|
request := ExecuteDataInput{Raw: flags["raw"] == "true", RequestID: flags["request-id"]}
|
|
128
|
-
if
|
|
129
|
-
|
|
130
|
-
if e != nil {
|
|
131
|
-
return cliDataError(stderr, dataError("invalid_budget", "--max-credits requires an integer."))
|
|
132
|
-
}
|
|
133
|
-
request.MaxCredits = n
|
|
155
|
+
if _, supplied := flags["max-credits"]; supplied {
|
|
156
|
+
return cliDataError(stderr, dataError("unsupported_flag", "Credit limits are managed by the server; remove --max-credits."))
|
|
134
157
|
}
|
|
135
158
|
if jsonCalls := flags["calls"]; jsonCalls != "" {
|
|
136
159
|
data, e := cliJSONString(jsonCalls)
|
|
@@ -153,7 +176,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
153
176
|
}
|
|
154
177
|
request.Calls = []DataCall{{ID: input, Inputs: inputs}}
|
|
155
178
|
} else {
|
|
156
|
-
return cliDataError(stderr, dataError("missing_calls", "Usage: webctx execute <operation-id> --inputs JSON
|
|
179
|
+
return cliDataError(stderr, dataError("missing_calls", "Usage: webctx execute <operation-id> --inputs JSON or --calls JSON."))
|
|
157
180
|
}
|
|
158
181
|
result, e := ExecuteData(request)
|
|
159
182
|
if e != nil {
|
|
@@ -174,11 +197,11 @@ func usageText() string {
|
|
|
174
197
|
Usage:
|
|
175
198
|
webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
|
|
176
199
|
webctx read-link <url> [--question 'what to find']
|
|
177
|
-
webctx map-site <url>
|
|
200
|
+
webctx map-site <url> [--query topic] [--limit 30] [--offset 0] [--path-prefix /api] [--language en|all]
|
|
178
201
|
webctx research [question] [--view sources|groups|operations] [--sources IDs] [--include inputs,output,examples] [--limit N] [--offset N]
|
|
179
202
|
webctx inspect <operation-or-source-id> [--raw]
|
|
180
|
-
webctx execute <operation-id> --inputs '{"field":"value"}' [--
|
|
181
|
-
webctx execute --calls '[{"id":"source/operation","inputs":{}}]'
|
|
203
|
+
webctx execute <operation-id> --inputs '{"field":"value"}' [--request-id ID]
|
|
204
|
+
webctx execute --calls '[{"id":"source/operation","inputs":{}}]'
|
|
182
205
|
|
|
183
206
|
Examples:
|
|
184
207
|
webctx search "next.js server components"
|
|
@@ -187,7 +210,7 @@ Examples:
|
|
|
187
210
|
webctx research "podcast conversations about AI agents"
|
|
188
211
|
webctx research --view sources --limit 5
|
|
189
212
|
webctx inspect particle/podcasts/episodes/search
|
|
190
|
-
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
213
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}'
|
|
191
214
|
webctx read-link https://docs.example.com/guide
|
|
192
215
|
webctx read-link https://docs.example.com/api --question "How do auth and pagination work?"
|
|
193
216
|
webctx map-site https://example.com`, version)
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/url"
|
|
10
|
+
"strconv"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func TestAuditPerRecordBoundsFromActualOperations(t *testing.T) {
|
|
17
|
+
tests := []struct {
|
|
18
|
+
contract map[string]any
|
|
19
|
+
inputs map[string]any
|
|
20
|
+
credits int
|
|
21
|
+
}{
|
|
22
|
+
{
|
|
23
|
+
map[string]any{"id": "firecrawl-developer-index/search", "creditsCost": 2, "perRecord": true,
|
|
24
|
+
"options": []any{map[string]any{"name": "k", "type": "number", "default": 10}}},
|
|
25
|
+
map[string]any{"query": "Rust async programming", "k": float64(2)}, 4,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
map[string]any{"id": "fullenrich/companies/lookup", "creditsCost": 5, "perRecord": true,
|
|
29
|
+
"response": map[string]any{"paginated": false}, "options": []any{map[string]any{"name": "domain", "type": "string"}}},
|
|
30
|
+
map[string]any{"domain": "firecrawl.dev"}, 5,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
map[string]any{"id": "wikipedia/entries/lookup", "creditsCost": 2, "perRecord": true,
|
|
34
|
+
"options": []any{map[string]any{"name": "ids", "type": "array"}}},
|
|
35
|
+
map[string]any{"ids": []any{"A", "B"}}, 4,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
for _, tt := range tests {
|
|
39
|
+
got, err := expectedCallCredits(tt.contract, tt.inputs)
|
|
40
|
+
if err != nil || got != tt.credits {
|
|
41
|
+
t.Errorf("%s: got %d (%v), want %d", tt.contract["id"], got, err, tt.credits)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestAuditPaginationAdaptersPreserveFilters(t *testing.T) {
|
|
47
|
+
tests := []struct {
|
|
48
|
+
name string
|
|
49
|
+
inputs map[string]any
|
|
50
|
+
data map[string]any
|
|
51
|
+
param string
|
|
52
|
+
want any
|
|
53
|
+
}{
|
|
54
|
+
{"treasury-link", map[string]any{"page": float64(1), "fields": "date,rate"},
|
|
55
|
+
map[string]any{"links": map[string]any{"next": "https://fiscaldata.treasury.gov/api?page[number]=2&page[size]=1"}},
|
|
56
|
+
"page", 2},
|
|
57
|
+
{"clinical-trials", map[string]any{"query": "recruiting diabetes", "page_size": float64(2)},
|
|
58
|
+
map[string]any{"next_page_token": "next-study-page"}, "page_token", "next-study-page"},
|
|
59
|
+
{"semantic-scholar", map[string]any{"query": "CRISPR papers"},
|
|
60
|
+
map[string]any{"continuation": "2@"}, "continuation", "2@"},
|
|
61
|
+
{"fullenrich", map[string]any{"query": "finance firms", "limit": float64(1)},
|
|
62
|
+
map[string]any{"metadata": map[string]any{"search_after": "WzAsNjAyNjE2MCJd"}}, "search_after", "WzAsNjAyNjE2MCJd"},
|
|
63
|
+
}
|
|
64
|
+
for _, tt := range tests {
|
|
65
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
66
|
+
next := continuationForDataResult("example/search", tt.inputs, tt.data)
|
|
67
|
+
if next == nil {
|
|
68
|
+
t.Fatal("missing continuation")
|
|
69
|
+
}
|
|
70
|
+
args := mapField(mapField(next)["arguments"])
|
|
71
|
+
call := mapField(listField(args["calls"])[0])
|
|
72
|
+
parameters := mapField(call["inputs"])
|
|
73
|
+
if parameters[tt.param] != tt.want {
|
|
74
|
+
t.Fatalf("missing next %s=%v: %+v", tt.param, tt.want, parameters)
|
|
75
|
+
}
|
|
76
|
+
for k, v := range tt.inputs {
|
|
77
|
+
if k != tt.param && parameters[k] != v {
|
|
78
|
+
t.Fatalf("lost original filter %s=%v", k, v)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if tt.inputs[tt.param] == tt.want {
|
|
82
|
+
t.Fatal("test did not advance cursor")
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func TestAuditSourceMapIsBoundedAndFilterable(t *testing.T) {
|
|
89
|
+
old := http.DefaultClient
|
|
90
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
91
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-map")
|
|
92
|
+
var links []any
|
|
93
|
+
for i := 0; i < 2803; i++ {
|
|
94
|
+
path := "https://docs.firecrawl.dev/features/page/" + strconv.Itoa(i)
|
|
95
|
+
if i%4 == 0 {
|
|
96
|
+
path = "https://docs.firecrawl.dev/ja/features/page/" + strconv.Itoa(i)
|
|
97
|
+
}
|
|
98
|
+
links = append(links, map[string]any{"url": path, "title": "Guide " + strconv.Itoa(i), "description": "Overview of API features"})
|
|
99
|
+
}
|
|
100
|
+
mock := map[string]any{"success": true, "links": links}
|
|
101
|
+
body, _ := json.Marshal(mock)
|
|
102
|
+
calls := 0
|
|
103
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
104
|
+
calls++
|
|
105
|
+
if req.URL.Path != "/v2/map" {
|
|
106
|
+
return nil, errors.New("unexpected path")
|
|
107
|
+
}
|
|
108
|
+
return testHTTPResponse(req, 200, string(body), nil), nil
|
|
109
|
+
})}
|
|
110
|
+
base := MapSiteInput{URL: "https://docs.firecrawl.dev"}
|
|
111
|
+
first, err := MapSitePage(base)
|
|
112
|
+
if err != nil {
|
|
113
|
+
t.Fatal(err)
|
|
114
|
+
}
|
|
115
|
+
if first["count"] != 30 || first["total_discovered"] != 2803 || first["language"] != "en" {
|
|
116
|
+
t.Fatalf("unbounded mapping: %+v", first)
|
|
117
|
+
}
|
|
118
|
+
next := mapField(first["next"])
|
|
119
|
+
if next["tool"] != "map_site" {
|
|
120
|
+
t.Fatalf("missing paged navigation: %+v", next)
|
|
121
|
+
}
|
|
122
|
+
args := next["arguments"].(MapSiteInput)
|
|
123
|
+
if args.Offset != 30 {
|
|
124
|
+
t.Fatalf("wrong offset: %d", args.Offset)
|
|
125
|
+
}
|
|
126
|
+
second, err := MapSitePage(args)
|
|
127
|
+
if err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
page1 := first["links"].([]map[string]any)
|
|
131
|
+
page2 := second["links"].([]map[string]any)
|
|
132
|
+
if page1[0]["url"] == page2[0]["url"] {
|
|
133
|
+
t.Fatal("next site-map page repeats the first")
|
|
134
|
+
}
|
|
135
|
+
for _, v := range page1 {
|
|
136
|
+
if strings.Contains(v["url"].(string), "/ja/") {
|
|
137
|
+
t.Fatal("localized page leaked into English default")
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
jp, err := MapSitePage(MapSiteInput{URL: base.URL, Language: "ja", Limit: 10, PathPrefix: "/ja/features/"})
|
|
141
|
+
if err != nil || jp["count"] != 10 {
|
|
142
|
+
t.Fatalf("Japanese/path filtered page failed: %+v %v", jp, err)
|
|
143
|
+
}
|
|
144
|
+
_, err = MapSitePage(MapSiteInput{URL: base.URL, Limit: 101})
|
|
145
|
+
if err == nil {
|
|
146
|
+
t.Fatal("must refuse unbounded client page")
|
|
147
|
+
}
|
|
148
|
+
oldText, err := MapSite(base.URL)
|
|
149
|
+
if err != nil || len(oldText) > 13500 {
|
|
150
|
+
t.Fatalf("URL-only map output too large (%d bytes): %v", len(oldText), err)
|
|
151
|
+
}
|
|
152
|
+
if calls != 4 {
|
|
153
|
+
t.Logf("Site-map mock calls (cache disabled for test credential): %d", calls)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func TestAuditTrueSchemaAlternativesAndExamples(t *testing.T) {
|
|
158
|
+
contract := map[string]any{
|
|
159
|
+
"id": "fiscal-ai/financials/income-statement",
|
|
160
|
+
"options": []any{
|
|
161
|
+
map[string]any{"name": "fscl", "type": "string", "example": "companyFiscalIdentifier"},
|
|
162
|
+
map[string]any{"name": "companyKey", "type": "string"},
|
|
163
|
+
map[string]any{"name": "ticker", "type": "string"},
|
|
164
|
+
},
|
|
165
|
+
"requiresOneOf": []any{[]any{"fscl", "companyKey", "ticker"}},
|
|
166
|
+
}
|
|
167
|
+
template, required := nativeInputTemplate(contract)
|
|
168
|
+
if len(required) > 0 || template["fscl"] != nil {
|
|
169
|
+
t.Fatalf("one-of group made fscl falsely required: %+v %v", template, required)
|
|
170
|
+
}
|
|
171
|
+
example := mapField(listField(contractExamples(contract))[0])
|
|
172
|
+
if !example["ready_to_execute"].(bool) || mapField(example["inputs"])["companyKey"] != "NASDAQ_AAPL" {
|
|
173
|
+
t.Fatalf("expected verified source-compatible alternative: %+v", example)
|
|
174
|
+
}
|
|
175
|
+
if err := validateDataInputs(contract, map[string]any{"companyKey": "NASDAQ_AAPL"}); err != nil {
|
|
176
|
+
t.Fatalf("alternative company key should validate: %v", err)
|
|
177
|
+
}
|
|
178
|
+
emptyLookup := map[string]any{"id": "clinicaltrials-gov/studies/get", "options": []any{map[string]any{"name": "nct_id", "type": "string"}}}
|
|
179
|
+
lookupExample := mapField(listField(contractExamples(emptyLookup))[0])
|
|
180
|
+
if lookupExample["ready_to_execute"] == true || lookupExample["next"] != nil {
|
|
181
|
+
t.Fatalf("no-identifier lookup must not advertise runnable example: %+v", lookupExample)
|
|
182
|
+
}
|
|
183
|
+
expensive := map[string]any{"id": "fullenrich/contacts/mobile", "creditsCost": 175, "options": []any{
|
|
184
|
+
map[string]any{"name": "linkedin_url", "type": "string", "required": true, "example": "https://example.com"},
|
|
185
|
+
}}
|
|
186
|
+
mobile := mapField(listField(contractExamples(expensive))[0])
|
|
187
|
+
if mobile["ready_to_execute"] == true || mobile["next"] != nil {
|
|
188
|
+
t.Fatalf("a fake LinkedIn profile must not be executable: %+v", mobile)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func TestAuditDirectRetrievalBeatsAncillarySearchOperations(t *testing.T) {
|
|
193
|
+
cases := []struct {
|
|
194
|
+
query, direct, ancillary string
|
|
195
|
+
}{
|
|
196
|
+
{"Historical US inflation from FRED", "fred-stlouisfed-org/economic-data/series_observations", "fred-stlouisfed-org/economic-data/releases"},
|
|
197
|
+
{"Recruiting diabetes clinical trials", "clinicaltrials-gov/studies/search", "clinicaltrials-gov/studies/suggest"},
|
|
198
|
+
{"Podcast episodes about AI agents", "particle/podcasts/episodes/search", "particle/podcasts/shows/directory"},
|
|
199
|
+
{"Scientific papers about CRISPR", "semantic-scholar/papers/search", "semantic-scholar/papers/related"},
|
|
200
|
+
}
|
|
201
|
+
for _, tt := range cases {
|
|
202
|
+
direct := map[string]any{"id": tt.direct, "similarity": 0.45, "name": "Search source records"}
|
|
203
|
+
side := map[string]any{"id": tt.ancillary, "similarity": 0.55, "name": "Related source operations"}
|
|
204
|
+
if intentRelevance(direct, tt.query) <= intentRelevance(side, tt.query) {
|
|
205
|
+
t.Fatalf("ancillary operation beat direct retrieval for %q: %v <= %v", tt.query, intentRelevance(direct, tt.query), intentRelevance(side, tt.query))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func TestAuditTypedConstraintsAndNullableOutput(t *testing.T) {
|
|
211
|
+
c := map[string]any{"id": "example/list", "options": []any{
|
|
212
|
+
map[string]any{"name": "limit", "type": "number", "min": 1, "max": 100},
|
|
213
|
+
map[string]any{"name": "query", "type": "string"},
|
|
214
|
+
}, "response": map[string]any{"fields": []any{
|
|
215
|
+
map[string]any{"name": "value", "type": "number", "about": "null when data is unavailable"},
|
|
216
|
+
}}}
|
|
217
|
+
fields := normalizedDataInputFields(c)
|
|
218
|
+
if fields[0]["type"] != "integer" || normalizedDataOutputFields(c)[0]["nullable"] != true {
|
|
219
|
+
t.Fatalf("lost typed metadata %+v %+v", fields, normalizedDataOutputFields(c))
|
|
220
|
+
}
|
|
221
|
+
if err := validateDataInputs(c, map[string]any{"limit": float64(1.5)}); err == nil {
|
|
222
|
+
t.Fatal("fractional page size accepted even though it must be an integer")
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func TestAuditURLAndLicenseAnswers(t *testing.T) {
|
|
227
|
+
repo := "https://github.com/firecrawl/firecrawl"
|
|
228
|
+
body := "---\nrepository: firecrawl/firecrawl\nlicense: \"AGPL-3.0\"\n---\n\n" +
|
|
229
|
+
"[License](https://github.com/firecrawl/firecrawl/blob/main/LICENSE)\n" + strings.Repeat("Unrelated markdown line\n", 300)
|
|
230
|
+
answer, err := readLinkFocused(repo, "What license does this public repository use? Quote the specific source URL.", func(string) (string, error) { return body, nil })
|
|
231
|
+
if err != nil || !strings.Contains(answer, "**AGPL-3.0**") ||
|
|
232
|
+
!strings.Contains(answer, repo+"/blob/main/LICENSE") || len(answer) > 650 {
|
|
233
|
+
t.Fatalf("focused GitHub license answer poor: %s / %v", answer, err)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func TestAuditRateLimitReturnsMachineReadableReset(t *testing.T) {
|
|
238
|
+
previous := http.DefaultClient
|
|
239
|
+
t.Cleanup(func() {
|
|
240
|
+
http.DefaultClient = previous
|
|
241
|
+
freeDataCooldown.Lock()
|
|
242
|
+
freeDataCooldown.until = time.Time{}
|
|
243
|
+
freeDataCooldown.Unlock()
|
|
244
|
+
})
|
|
245
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-ratelimit")
|
|
246
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
247
|
+
return testHTTPResponse(req, 429, `{"success":false,"error":"Rate limit exceeded. Upgrade your plan."}`,
|
|
248
|
+
map[string]string{"Retry-After": "31"}), nil
|
|
249
|
+
})}
|
|
250
|
+
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
251
|
+
defer cancel()
|
|
252
|
+
_, err := doDataAPI(ctx, "/search", map[string]any{"query": "test"}, "")
|
|
253
|
+
e, ok := err.(*DataError)
|
|
254
|
+
if !ok || e.Code != "rate_limited" || e.RetryAfterSeconds != 31 || e.RetryAt == "" || !e.Retryable {
|
|
255
|
+
t.Fatalf("rate limit not actionable: %+v", err)
|
|
256
|
+
}
|
|
257
|
+
_, err = doDataAPI(ctx, "/search", map[string]any{"query": "test"}, "")
|
|
258
|
+
if e, ok = err.(*DataError); !ok || e.Code != "rate_limited" || e.RetryAfterSeconds < 20 {
|
|
259
|
+
t.Fatalf("worker ignored cooldown: %+v", err)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func TestAuditUnknownOperationErrorIsPrecise(t *testing.T) {
|
|
264
|
+
installDataTestTransport(t)
|
|
265
|
+
_, err := InspectData(InspectDataInput{ID: "particle/never-existing/operation"})
|
|
266
|
+
e, ok := err.(*DataError)
|
|
267
|
+
if !ok || e.Code != "operation_not_found" {
|
|
268
|
+
t.Fatalf("wrong unknown-op error: %+v", err)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestAuditCatalogueContinuationAndFallbackExamples(t *testing.T) {
|
|
273
|
+
installDataTestTransport(t)
|
|
274
|
+
id := "particle/podcasts/episodes/search"
|
|
275
|
+
page, err := ResearchData(ResearchDataInput{Mode: "catalogue", View: "operations",
|
|
276
|
+
Operations: []string{id}, Include: []string{"inputs", "output", "examples"}, Limit: 1})
|
|
277
|
+
if err != nil {
|
|
278
|
+
t.Fatal(err)
|
|
279
|
+
}
|
|
280
|
+
items := page["results"].([]map[string]any)
|
|
281
|
+
if len(items) != 1 || mapField(items[0]["next"])["tool"] != "inspect" {
|
|
282
|
+
t.Fatalf("exact operation should have inspect continuation: %+v", page)
|
|
283
|
+
}
|
|
284
|
+
if len(listField(items[0]["examples"])) == 0 {
|
|
285
|
+
t.Fatalf("research include examples must generate usable fallback: %+v", items[0])
|
|
286
|
+
}
|
|
287
|
+
source, err := InspectData(InspectDataInput{ID: "particle"})
|
|
288
|
+
if err != nil {
|
|
289
|
+
t.Fatal(err)
|
|
290
|
+
}
|
|
291
|
+
first := source["results"].([]map[string]any)[0]
|
|
292
|
+
if first["inputs"] != nil || first["output"] != nil {
|
|
293
|
+
t.Fatalf("source overview must not expand every schema by default: %+v", first)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func TestAuditWorkerIdempotentReplayAndCollision(t *testing.T) {
|
|
298
|
+
mock := installDataTestTransport(t)
|
|
299
|
+
id := "audit-replay-unique-20261010"
|
|
300
|
+
calls := []DataCall{{ID: "particle/podcasts/episodes/search", Inputs: map[string]any{"semantic_search": "AI agents"}}}
|
|
301
|
+
first, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: id})
|
|
302
|
+
if err != nil {
|
|
303
|
+
t.Fatal(err)
|
|
304
|
+
}
|
|
305
|
+
again, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: id})
|
|
306
|
+
if err != nil {
|
|
307
|
+
t.Fatal(err)
|
|
308
|
+
}
|
|
309
|
+
if again["replay_status"] != "served_from_local_cache" || again["replayed"] != true ||
|
|
310
|
+
again["credits_charged_this_request"] != float64(0) && again["credits_charged_this_request"] != 0 {
|
|
311
|
+
t.Fatalf("replay accounting ambiguous: %+v", again)
|
|
312
|
+
}
|
|
313
|
+
if again["original_credits_used"] != first["credits_used"] {
|
|
314
|
+
t.Fatal("lost original provider credit amount")
|
|
315
|
+
}
|
|
316
|
+
mock.mu.Lock()
|
|
317
|
+
n := mock.executions
|
|
318
|
+
mock.mu.Unlock()
|
|
319
|
+
if n != 1 {
|
|
320
|
+
t.Fatalf("replay caused duplicate upstream execution (%d)", n)
|
|
321
|
+
}
|
|
322
|
+
changed := []DataCall{{ID: calls[0].ID, Inputs: map[string]any{"semantic_search": "Completely different"}}}
|
|
323
|
+
_, err = ExecuteData(ExecuteDataInput{Calls: changed, RequestID: id})
|
|
324
|
+
e, ok := err.(*DataError)
|
|
325
|
+
if !ok || e.Code != "duplicate_request" || mapField(e.Details)["retry"] != nil {
|
|
326
|
+
t.Fatalf("conflicting ID returned unsafe recovery: %+v", err)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
|
|
331
|
+
previous := http.DefaultClient
|
|
332
|
+
t.Cleanup(func() { http.DefaultClient = previous })
|
|
333
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-mixed-batch")
|
|
334
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "200")
|
|
335
|
+
paid := map[string]int{}
|
|
336
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
337
|
+
var payload map[string]any
|
|
338
|
+
_ = json.NewDecoder(req.Body).Decode(&payload)
|
|
339
|
+
if find := mapField(payload["alexandria"]); find != nil {
|
|
340
|
+
providers := listField(mapField(find["options"])["providers"])
|
|
341
|
+
provider := stringField(providers[0])
|
|
342
|
+
item := map[string]any{"id": provider + "/data/search", "provider": provider,
|
|
343
|
+
"capability": "data/search", "name": "Search records", "creditsCost": 5,
|
|
344
|
+
"perRecord": false, "options": []any{map[string]any{"name": "query", "type": "string", "required": true}},
|
|
345
|
+
}
|
|
346
|
+
encoded, _ := json.Marshal(map[string]any{"success": true, "data": map[string]any{"alexandria": []any{
|
|
347
|
+
map[string]any{"provider": "firecrawl", "capability": "find-tools", "data": map[string]any{
|
|
348
|
+
"items": []any{item}, "total": 1,
|
|
349
|
+
}},
|
|
350
|
+
}}})
|
|
351
|
+
return testHTTPResponse(req, 200, string(encoded), nil), nil
|
|
352
|
+
}
|
|
353
|
+
calls := listField(payload["alexandria"])
|
|
354
|
+
if len(calls) != 1 {
|
|
355
|
+
return nil, errors.New("mixed providers must not share an upstream batch transaction")
|
|
356
|
+
}
|
|
357
|
+
provider := stringField(mapField(calls[0])["provider"])
|
|
358
|
+
paid[provider]++
|
|
359
|
+
if req.Header.Get("x-request-id") == "" {
|
|
360
|
+
return nil, errors.New("missing idempotency request id")
|
|
361
|
+
}
|
|
362
|
+
if provider == "particle" {
|
|
363
|
+
return testHTTPResponse(req, 403, `{"success":false,"code":"THIRD_PARTY_DATA_TERMS_REQUIRED","error":"You must accept terms to query Particle","requiresAction":{"url":"https://firecrawl.dev/app/settings?tab=data-sources"}}`, nil), nil
|
|
364
|
+
}
|
|
365
|
+
data := map[string]any{"success": true, "data": map[string]any{"creditsCost": 5, "alexandria": []any{
|
|
366
|
+
map[string]any{"provider": provider, "capability": "data/search", "creditsCost": 5, "alexandriaId": "shared-upstream-id",
|
|
367
|
+
"data": map[string]any{"results": []any{map[string]any{"id": provider + "-row"}}}},
|
|
368
|
+
}}}
|
|
369
|
+
encoded, _ := json.Marshal(data)
|
|
370
|
+
return testHTTPResponse(req, 200, string(encoded), nil), nil
|
|
371
|
+
})}
|
|
372
|
+
calls := []DataCall{
|
|
373
|
+
{ID: "particle/data/search", Inputs: map[string]any{"query": "podcasts"}},
|
|
374
|
+
{ID: "fred/data/search", Inputs: map[string]any{"query": "inflation"}},
|
|
375
|
+
{ID: "sec/data/search", Inputs: map[string]any{"query": "filings"}},
|
|
376
|
+
}
|
|
377
|
+
result, err := ExecuteData(ExecuteDataInput{Calls: calls})
|
|
378
|
+
if err != nil {
|
|
379
|
+
t.Fatal(err)
|
|
380
|
+
}
|
|
381
|
+
if result["status"] != "partial_success" || result["failed_operations"] != 1 ||
|
|
382
|
+
result["credits_used"] != float64(10) {
|
|
383
|
+
t.Fatalf("mixed failure hid authorized work or incorrectly counted credits: %+v", result)
|
|
384
|
+
}
|
|
385
|
+
entries := result["results"].([]map[string]any)
|
|
386
|
+
if len(entries) != 3 || entries[0]["ok"] != false || entries[1]["ok"] != true || entries[2]["ok"] != true {
|
|
387
|
+
t.Fatalf("provider isolation failed: %+v", entries)
|
|
388
|
+
}
|
|
389
|
+
failure := entries[0]["error"].(*DataError)
|
|
390
|
+
if failure.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" || failure.RequiresAction["url"] == nil {
|
|
391
|
+
t.Fatalf("provider terms error lost: %+v", failure)
|
|
392
|
+
}
|
|
393
|
+
for i := 1; i < 3; i++ {
|
|
394
|
+
if entries[i]["operation_index"] != i ||
|
|
395
|
+
entries[i]["receipt_scope"] != "provider_response_or_batch" ||
|
|
396
|
+
entries[i]["provider_request_id"] == entries[1-i+1]["provider_request_id"] && i == 2 {
|
|
397
|
+
t.Fatalf("provenance does not distinguish provider responses: %+v", entries)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if paid["particle"] != 1 || paid["fred"] != 1 || paid["sec"] != 1 {
|
|
401
|
+
t.Fatalf("provider calls were not independent: %+v", paid)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func TestAuditHighPriceOperationCannotBeRecommendedAsRunnable(t *testing.T) {
|
|
406
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "200")
|
|
407
|
+
original := http.DefaultClient
|
|
408
|
+
t.Cleanup(func() { http.DefaultClient = original })
|
|
409
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-high-price")
|
|
410
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
411
|
+
body := map[string]any{"success": true, "data": map[string]any{"alexandria": []any{
|
|
412
|
+
map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0,
|
|
413
|
+
"data": map[string]any{"items": []any{
|
|
414
|
+
map[string]any{"id": "similarweb/channels/share", "provider": "similarweb", "capability": "channels/share",
|
|
415
|
+
"name": "Channel traffic share", "creditsCost": 550, "options": []any{map[string]any{"name": "domain", "type": "string", "required": true}}},
|
|
416
|
+
}},
|
|
417
|
+
},
|
|
418
|
+
}}}
|
|
419
|
+
encoded, _ := json.Marshal(body)
|
|
420
|
+
return testHTTPResponse(req, 200, string(encoded), nil), nil
|
|
421
|
+
})}
|
|
422
|
+
details, err := InspectData(InspectDataInput{ID: "similarweb/channels/share"})
|
|
423
|
+
if err != nil {
|
|
424
|
+
t.Fatal(err)
|
|
425
|
+
}
|
|
426
|
+
if details["executable"] != false || details["availability"] != "exceeds_server_credit_limit" ||
|
|
427
|
+
details["next"] != nil || details["server_credit_limit"] != 200 {
|
|
428
|
+
t.Fatalf("unaffordable operation falsely recommended: %+v", details)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
var _ = fmt.Sprint
|
|
433
|
+
var _ = url.URL{}
|