webctx 0.2.3 → 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.
@@ -0,0 +1,424 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "math"
7
+ "regexp"
8
+ "strconv"
9
+ "strings"
10
+ "sync"
11
+ )
12
+
13
+ func requiredFields(contract map[string]any) []string {
14
+ var fields []string
15
+ for _, v := range listField(contract["options"]) {
16
+ f := mapField(v)
17
+ if yes, _ := f["required"].(bool); yes {
18
+ fields = append(fields, stringField(f["name"]))
19
+ }
20
+ }
21
+ return fields
22
+ }
23
+
24
+ func validateDataInputs(contract map[string]any, inputs map[string]any) error {
25
+ if inputs == nil {
26
+ inputs = map[string]any{}
27
+ }
28
+ id := stringField(contract["id"])
29
+ fields := map[string]map[string]any{}
30
+ for _, v := range listField(contract["options"]) {
31
+ f := mapField(v)
32
+ if name := stringField(f["name"]); name != "" {
33
+ fields[name] = f
34
+ }
35
+ }
36
+ var issues []string
37
+ for _, name := range requiredFields(contract) {
38
+ if inputs[name] == nil || inputs[name] == "" {
39
+ issues = append(issues, fmt.Sprintf("required field %s is missing", name))
40
+ }
41
+ }
42
+ for _, group := range listField(contract["requiresOneOf"]) {
43
+ found := false
44
+ var alternatives []string
45
+ for _, v := range listField(group) {
46
+ field := stringField(v)
47
+ if field == "" {
48
+ continue
49
+ }
50
+ alternatives = append(alternatives, field)
51
+ if inputs[field] != nil && inputs[field] != "" {
52
+ found = true
53
+ }
54
+ }
55
+ if !found && len(alternatives) > 0 {
56
+ issues = append(issues, "provide at least one of: "+strings.Join(alternatives, ", "))
57
+ }
58
+ }
59
+ for name, value := range inputs {
60
+ field, ok := fields[name]
61
+ if !ok {
62
+ issues = append(issues, fmt.Sprintf("unknown field %q", name))
63
+ continue
64
+ }
65
+ if value == nil {
66
+ issues = append(issues, fmt.Sprintf("field %q must be filled in", name))
67
+ continue
68
+ }
69
+ if !dataTypeMatches(value, stringField(field["type"])) {
70
+ issues = append(issues, fmt.Sprintf("field %q must be %s", name, stringField(field["type"])))
71
+ continue
72
+ }
73
+ if enum := listField(field["oneOf"]); len(enum) > 0 {
74
+ found := false
75
+ for _, allowed := range enum {
76
+ a, _ := json.Marshal(allowed)
77
+ b, _ := json.Marshal(value)
78
+ if string(a) == string(b) {
79
+ found = true
80
+ break
81
+ }
82
+ // Some upstream contracts declare a numeric type but encode
83
+ // its enum entries as strings. Accept the equivalent number.
84
+ if actual, ok := numberField(value); ok {
85
+ if candidate, ok := allowed.(string); ok {
86
+ if parsed, err := strconv.ParseFloat(candidate, 64); err == nil && parsed == actual {
87
+ found = true
88
+ break
89
+ }
90
+ }
91
+ }
92
+ }
93
+ if !found {
94
+ issues = append(issues, fmt.Sprintf("field %q must be one of the values in its inspected oneOf list", name))
95
+ }
96
+ }
97
+ if number, ok := numberField(value); ok {
98
+ if lower, ok := numberField(field["min"]); ok && number < lower {
99
+ issues = append(issues, fmt.Sprintf("field %q must be >= %v", name, lower))
100
+ }
101
+ if upper, ok := numberField(field["max"]); ok && number > upper {
102
+ issues = append(issues, fmt.Sprintf("field %q must be <= %v", name, upper))
103
+ }
104
+ }
105
+ if pattern := stringField(field["pattern"]); pattern != "" {
106
+ if re, err := regexp.Compile(pattern); err == nil && re != nil {
107
+ if s, ok := value.(string); ok && !re.MatchString(s) {
108
+ issues = append(issues, fmt.Sprintf("field %q does not match its required pattern", name))
109
+ }
110
+ }
111
+ }
112
+ }
113
+ if len(issues) > 0 {
114
+ names := make([]string, 0, len(fields))
115
+ for name := range fields {
116
+ names = append(names, name)
117
+ }
118
+ sortStrings(names)
119
+ return &DataError{
120
+ Code: "invalid_inputs",
121
+ Message: "Input validation failed for " + id + ": " + strings.Join(issues, "; ") + ".",
122
+ Details: map[string]any{"available_inputs": names, "next": map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}},
123
+ }
124
+ }
125
+ return nil
126
+ }
127
+
128
+ func dataTypeMatches(value any, kind string) bool {
129
+ switch strings.ToLower(kind) {
130
+ case "", "any", "json":
131
+ return true
132
+ case "string":
133
+ _, ok := value.(string)
134
+ return ok
135
+ case "number":
136
+ _, ok := numberField(value)
137
+ return ok
138
+ case "integer", "int":
139
+ f, ok := numberField(value)
140
+ return ok && !math.IsInf(f, 0) && f == math.Trunc(f)
141
+ case "boolean", "bool":
142
+ _, ok := value.(bool)
143
+ return ok
144
+ case "object":
145
+ _, ok := value.(map[string]any)
146
+ return ok
147
+ case "array":
148
+ return listField(value) != nil
149
+ case "string[]":
150
+ list := listField(value)
151
+ if list == nil {
152
+ return false
153
+ }
154
+ for _, v := range list {
155
+ if _, ok := v.(string); !ok {
156
+ return false
157
+ }
158
+ }
159
+ return true
160
+ case "number[]":
161
+ list := listField(value)
162
+ if list == nil {
163
+ return false
164
+ }
165
+ for _, v := range list {
166
+ if _, ok := numberField(v); !ok {
167
+ return false
168
+ }
169
+ }
170
+ return true
171
+ default:
172
+ return true // Other upstream-specific types are validated by Firecrawl.
173
+ }
174
+ }
175
+
176
+ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, error) {
177
+ cost, valid := numberField(contract["creditsCost"])
178
+ if !valid || cost < 0 {
179
+ return 0, dataError("price_unavailable", "The data source did not supply a usable credit price.")
180
+ }
181
+ if cost == 0 {
182
+ return 0, nil
183
+ }
184
+ estimated := cost
185
+ if perRecord, _ := contract["perRecord"].(bool); perRecord {
186
+ // A per-record price has no hard upper bound without a record limit.
187
+ count := 0.0
188
+ for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
189
+ if n, ok := numberField(inputs[key]); ok && n >= 1 {
190
+ count = n
191
+ break
192
+ }
193
+ }
194
+ if count == 0 {
195
+ for _, v := range listField(contract["options"]) {
196
+ f := mapField(v)
197
+ for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
198
+ if stringField(f["name"]) == key {
199
+ if n, ok := numberField(f["default"]); ok && n >= 1 {
200
+ count = n
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ if count == 0 {
207
+ return 0, dataError("unbounded_per_record_cost",
208
+ "This operation charges per record but exposes no bounded record count. Inspect it and supply an explicit limit before execution.")
209
+ }
210
+ estimated = cost * count
211
+ }
212
+ if estimated > 1000000 {
213
+ return 0, dataError("price_exceeds_limit", "Estimated cost is too large. Reduce the requested record limit.")
214
+ }
215
+ return int(math.Ceil(estimated)), nil
216
+ }
217
+
218
+ func continuationForDataResult(id string, originalInputs map[string]any, data any, maxCredits int) map[string]any {
219
+ value := mapField(data)
220
+ if value == nil {
221
+ return nil
222
+ }
223
+ if next := mapField(value["next"]); next != nil {
224
+ if provider := stringField(next["provider"]); provider != "" {
225
+ if capability := stringField(next["capability"]); capability != "" {
226
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
227
+ "calls": []any{map[string]any{"id": provider + "/" + capability, "inputs": mapField(next["options"])}},
228
+ "max_credits": maxCredits,
229
+ }}
230
+ }
231
+ }
232
+ }
233
+ cursor := stringField(value["next_cursor"])
234
+ if cursor == "" {
235
+ cursor = stringField(value["nextCursor"])
236
+ }
237
+ if cursor == "" {
238
+ cursor = stringField(mapField(value["pagination"])["next_cursor"])
239
+ }
240
+ if cursor != "" {
241
+ next := copyInputs(originalInputs)
242
+ if _, exists := next["page_token"]; exists {
243
+ next["page_token"] = cursor
244
+ } else {
245
+ next["cursor"] = cursor
246
+ }
247
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
248
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
249
+ "max_credits": maxCredits,
250
+ }}
251
+ }
252
+ if page, ok := numberField(value["next_page"]); ok && page > 0 {
253
+ next := copyInputs(originalInputs)
254
+ next["page"] = int(page)
255
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
256
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
257
+ "max_credits": maxCredits,
258
+ }}
259
+ }
260
+ if offset, ok := numberField(value["next_offset"]); ok && offset >= 0 {
261
+ next := copyInputs(originalInputs)
262
+ previous, _ := numberField(next["offset"])
263
+ if offset > previous {
264
+ next["offset"] = int(offset)
265
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
266
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
267
+ "max_credits": maxCredits,
268
+ }}
269
+ }
270
+ }
271
+ return nil
272
+ }
273
+
274
+ func copyInputs(original map[string]any) map[string]any {
275
+ out := make(map[string]any, len(original))
276
+ for k, v := range original {
277
+ out[k] = v
278
+ }
279
+ return out
280
+ }
281
+
282
+ // ExecuteData validates contracts and credit estimates before calling any
283
+ // paid provider. The native API accepts at most ten executions per request.
284
+ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
285
+ if len(input.Calls) == 0 || len(input.Calls) > 10 {
286
+ return nil, dataError("invalid_calls", "Provide 1-10 operation calls with id and inputs.")
287
+ }
288
+ if !paidDataEnabled() {
289
+ return nil, dataError("paid_data_disabled", "Paid data queries are disabled by WEBCTX_ALEXANDRIA_PAID=off.")
290
+ }
291
+ cap := dataCreditCap()
292
+ if input.MaxCredits > 0 && input.MaxCredits < cap {
293
+ cap = input.MaxCredits
294
+ }
295
+ if input.MaxCredits < 0 || input.MaxCredits > dataCreditCap() {
296
+ return nil, dataError("invalid_budget", fmt.Sprintf("max_credits must be between 1 and the configured maximum of %d.", dataCreditCap()))
297
+ }
298
+ if input.RequestID != "" && !safeRequestID.MatchString(input.RequestID) {
299
+ return nil, dataError("invalid_request_id", "request_id must have 8-128 letters, digits, hyphens or underscores.")
300
+ }
301
+ ctx, cancel := defaultDataContext()
302
+ defer cancel()
303
+ type reviewed struct {
304
+ Contract map[string]any
305
+ Err error
306
+ }
307
+ checked := make([]reviewed, len(input.Calls))
308
+ var wg sync.WaitGroup
309
+ for i, call := range input.Calls {
310
+ if _, _, err := parseOperationID(call.ID); err != nil {
311
+ return nil, err
312
+ }
313
+ wg.Add(1)
314
+ go func(i int, call DataCall) {
315
+ defer wg.Done()
316
+ contract, _, err := exactDataContract(ctx, call.ID, []string{"options", "response", "examples"})
317
+ checked[i] = reviewed{Contract: contract, Err: err}
318
+ }(i, call)
319
+ }
320
+ wg.Wait()
321
+ var estimated int
322
+ var calls []map[string]any
323
+ for i, call := range input.Calls {
324
+ if checked[i].Err != nil {
325
+ return nil, checked[i].Err
326
+ }
327
+ contract := checked[i].Contract
328
+ if err := validateDataInputs(contract, call.Inputs); err != nil {
329
+ return nil, err
330
+ }
331
+ cost, err := expectedCallCredits(contract, call.Inputs)
332
+ if err != nil {
333
+ return nil, err
334
+ }
335
+ estimated += cost
336
+ if estimated > cap {
337
+ return nil, &DataError{Code: "credit_budget_exceeded",
338
+ Message: fmt.Sprintf("Preflight estimate %d credits exceeds the %d-credit request cap. Reduce calls/record limits, or configure WEBCTX_ALEXANDRIA_MAX_CREDITS.", estimated, cap),
339
+ Details: map[string]any{"estimated_credits": estimated, "max_credits": cap},
340
+ }
341
+ }
342
+ provider, capability, _ := parseOperationID(call.ID)
343
+ options := call.Inputs
344
+ if options == nil {
345
+ options = map[string]any{}
346
+ }
347
+ calls = append(calls, map[string]any{"provider": provider, "capability": capability, "options": options})
348
+ }
349
+ requestID := input.RequestID
350
+ if requestID == "" {
351
+ var err error
352
+ requestID, err = newRequestID()
353
+ if err != nil {
354
+ return nil, dataError("internal_error", "Could not generate a request identifier.")
355
+ }
356
+ }
357
+ response, err := doDataAPI(ctx, "/scrape", map[string]any{"alexandria": calls}, requestID)
358
+ if err != nil {
359
+ var typed *DataError
360
+ if errorsAsData(err, &typed) {
361
+ typed.Details = map[string]any{"request_id": requestID,
362
+ "retry": map[string]any{"tool": "execute", "arguments": ExecuteDataInput{
363
+ Calls: input.Calls, MaxCredits: cap, RequestID: requestID, Raw: input.Raw,
364
+ }},
365
+ }
366
+ }
367
+ return nil, err
368
+ }
369
+ payload := mapField(response["data"])
370
+ nativeResults := listField(payload["alexandria"])
371
+ if len(nativeResults) == 0 {
372
+ return nil, dataError("invalid_upstream_response", "Paid request returned no Alexandria execution results.")
373
+ }
374
+ results := make([]map[string]any, 0, len(nativeResults))
375
+ for i, value := range nativeResults {
376
+ entry := mapField(value)
377
+ if entry == nil {
378
+ continue
379
+ }
380
+ id := stringField(entry["provider"]) + "/" + stringField(entry["capability"])
381
+ item := map[string]any{
382
+ "id": id,
383
+ "source_id": entry["provider"],
384
+ "data": entry["data"],
385
+ "credits_used": entry["creditsCost"],
386
+ }
387
+ if token := entry["alexandriaId"]; token != nil {
388
+ item["data_id"] = token
389
+ }
390
+ if errValue := entry["error"]; errValue != nil {
391
+ item["error"] = errValue
392
+ }
393
+ if i < len(input.Calls) {
394
+ if next := continuationForDataResult(id, input.Calls[i].Inputs, entry["data"], cap); next != nil {
395
+ item["next"] = next
396
+ }
397
+ }
398
+ results = append(results, item)
399
+ }
400
+ output := map[string]any{
401
+ "results": results,
402
+ "request_id": requestID,
403
+ "estimated_credits": estimated,
404
+ "pricing_note": "max_credits is a preflight estimate; actual per-record charges are determined by the upstream provider and the account's Firecrawl limits.",
405
+ "guidance": "Use next to fetch additional pages where available. Reuse request_id when retrying the same paid request, but generate a new ID for the next page.",
406
+ }
407
+ if cost := payload["creditsCost"]; cost != nil {
408
+ output["credits_used"] = cost
409
+ } else if cost := response["creditsUsed"]; cost != nil {
410
+ output["credits_used"] = cost
411
+ }
412
+ if input.Raw {
413
+ output["raw"] = response
414
+ }
415
+ return output, nil
416
+ }
417
+
418
+ func errorsAsData(err error, into **DataError) bool {
419
+ e, ok := err.(*DataError)
420
+ if ok {
421
+ *into = e
422
+ }
423
+ return ok
424
+ }
@@ -0,0 +1,176 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "regexp"
7
+ "strings"
8
+ )
9
+
10
+ var dataSourceIDPattern = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$")
11
+ var dataCapabilityPattern = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9_./-]{0,249}$")
12
+
13
+ func parseOperationID(id string) (string, string, error) {
14
+ provider, capability, ok := strings.Cut(strings.TrimSpace(id), "/")
15
+ if !ok || !dataSourceIDPattern.MatchString(provider) ||
16
+ !dataCapabilityPattern.MatchString(capability) || strings.Contains(capability, "..") ||
17
+ strings.Contains(capability, "//") {
18
+ return "", "", dataError("invalid_operation", "Use an exact operation ID from research, such as particle/podcasts/episodes/search.")
19
+ }
20
+ return provider, capability, nil
21
+ }
22
+
23
+ func exactDataContract(ctx context.Context, id string, expanded []string) (map[string]any, map[string]any, error) {
24
+ provider, capability, err := parseOperationID(id)
25
+ if err != nil {
26
+ return nil, nil, err
27
+ }
28
+ opts := map[string]any{
29
+ "providers": []string{provider},
30
+ "capabilities": []string{capability},
31
+ "level": "tools", "expand": expanded, "limit": 10,
32
+ }
33
+ catalogue, raw, err := freeDataBrowse(ctx, opts)
34
+ if err != nil {
35
+ return nil, nil, err
36
+ }
37
+ for _, entry := range listField(catalogue["items"]) {
38
+ item := mapField(entry)
39
+ if item == nil {
40
+ continue
41
+ }
42
+ actual := stringField(item["id"])
43
+ if actual == "" {
44
+ actual = stringField(item["provider"]) + "/" + stringField(item["capability"])
45
+ }
46
+ if actual == id {
47
+ return item, raw, nil
48
+ }
49
+ }
50
+ return nil, nil, &DataError{
51
+ Code: "operation_not_found",
52
+ Message: fmt.Sprintf("Operation %q was not found. Use research to discover currently available sources and operations.", id),
53
+ }
54
+ }
55
+
56
+ func nativeInputTemplate(contract map[string]any) (map[string]any, []string) {
57
+ template := map[string]any{}
58
+ var need []string
59
+ seen := map[string]bool{}
60
+ for _, field := range listField(contract["options"]) {
61
+ option := mapField(field)
62
+ name := stringField(option["name"])
63
+ if name == "" {
64
+ continue
65
+ }
66
+ if value, exists := option["default"]; exists {
67
+ template[name] = value
68
+ }
69
+ if required, _ := option["required"].(bool); required {
70
+ template[name] = nil
71
+ need = append(need, name)
72
+ seen[name] = true
73
+ }
74
+ }
75
+ for _, group := range listField(contract["requiresOneOf"]) {
76
+ var matched bool
77
+ for _, field := range listField(group) {
78
+ name := stringField(field)
79
+ if _, already := template[name]; already && template[name] != nil {
80
+ matched = true
81
+ break
82
+ }
83
+ }
84
+ if matched {
85
+ continue
86
+ }
87
+ // Show one candidate with a blank value rather than guessing user
88
+ // intent or silently filling provider-specific search terms.
89
+ for _, field := range listField(group) {
90
+ name := stringField(field)
91
+ if name == "" {
92
+ continue
93
+ }
94
+ template[name] = nil
95
+ if !seen[name] {
96
+ need = append(need, name)
97
+ seen[name] = true
98
+ }
99
+ break
100
+ }
101
+ }
102
+ return template, need
103
+ }
104
+
105
+ func InspectData(input InspectDataInput) (map[string]any, error) {
106
+ id := strings.TrimSpace(input.ID)
107
+ if id == "" {
108
+ return nil, dataError("missing_id", "Provide an operation or source ID returned by research.")
109
+ }
110
+ expanded := []string{"options", "response", "examples"}
111
+ if len(input.Include) > 0 {
112
+ var err error
113
+ expanded, err = expandContract(input.Include)
114
+ if err != nil {
115
+ return nil, err
116
+ }
117
+ // A useful execute template always requires the real input contract,
118
+ // even if the caller only asked to expand examples or output details.
119
+ if !containsString(expanded, "options") {
120
+ expanded = append(expanded, "options")
121
+ }
122
+ }
123
+ if !strings.Contains(id, "/") {
124
+ if !dataSourceIDPattern.MatchString(id) {
125
+ return nil, dataError("invalid_source", "Use an exact source ID returned by research.")
126
+ }
127
+ include := input.Include
128
+ if len(include) == 0 {
129
+ include = []string{"inputs", "output", "examples"}
130
+ }
131
+ list, err := ResearchData(ResearchDataInput{Sources: []string{id}, View: "operations", Include: include, Limit: 25, Raw: input.Raw})
132
+ if err != nil {
133
+ return nil, err
134
+ }
135
+ list["source_id"] = id
136
+ list["guidance"] = "Choose an operation ID and call inspect again for its exact contract and an execute template."
137
+ return list, nil
138
+ }
139
+ ctx, cancel := defaultDataContext()
140
+ defer cancel()
141
+ item, raw, err := exactDataContract(ctx, id, expanded)
142
+ if err != nil {
143
+ return nil, err
144
+ }
145
+ provider, _, _ := parseOperationID(id)
146
+ inputs, missing := nativeInputTemplate(item)
147
+ budget := dataCreditCap()
148
+ execArguments := map[string]any{
149
+ "calls": []any{map[string]any{"id": id, "inputs": inputs}},
150
+ "max_credits": budget,
151
+ }
152
+ response := map[string]any{
153
+ "id": id,
154
+ "name": item["name"],
155
+ "source_id": provider,
156
+ "description": item["description"],
157
+ "cost_credits": 0,
158
+ "price": map[string]any{
159
+ "credits_per_call": item["creditsCost"],
160
+ "per_record": item["perRecord"],
161
+ },
162
+ "inputs": map[string]any{"fields": item["options"], "requires_one_of": item["requiresOneOf"]},
163
+ "output": item["response"],
164
+ "examples": item["examples"],
165
+ "input_template": inputs,
166
+ "next": map[string]any{"tool": "execute", "arguments": execArguments},
167
+ "guidance": "Copy next.arguments into execute, fill any blank required inputs and adjust values according to inputs.fields. Execute uses credits.",
168
+ }
169
+ if len(missing) > 0 {
170
+ response["fill_before_execution"] = missing
171
+ }
172
+ if input.Raw {
173
+ response["raw"] = raw
174
+ }
175
+ return response, nil
176
+ }