exa-ai 0.2.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/README.md +45 -585
- data/exe/exa-ai +1 -3
- data/exe/exa-ai-answer +32 -12
- data/exe/exa-ai-context +1 -4
- data/exe/exa-ai-get-contents +191 -38
- data/exe/exa-ai-research-get +1 -2
- data/exe/exa-ai-research-list +1 -2
- data/exe/exa-ai-research-start +3 -5
- data/exe/exa-ai-search +1 -8
- data/lib/exa/cli/base.rb +3 -3
- data/lib/exa/client.rb +16 -0
- data/lib/exa/connection.rb +8 -1
- data/lib/exa/services/answer_stream.rb +90 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa-ai.rb +5 -0
- data/lib/exa.rb +2 -0
- metadata +17 -1
data/exe/exa-ai
CHANGED
data/exe/exa-ai-answer
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
require "bundler/setup"
|
|
6
|
-
require "exa"
|
|
7
|
-
require "exa/cli/base"
|
|
8
|
-
require "exa/cli/formatters/answer_formatter"
|
|
4
|
+
require "exa-ai"
|
|
9
5
|
|
|
10
6
|
# Parse command-line arguments
|
|
11
7
|
def parse_args(argv)
|
|
12
8
|
args = {
|
|
13
9
|
output_format: "json",
|
|
14
10
|
api_key: nil,
|
|
15
|
-
text: false
|
|
11
|
+
text: false,
|
|
12
|
+
stream: false
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
# Extract query (first non-flag argument)
|
|
@@ -24,9 +21,15 @@ def parse_args(argv)
|
|
|
24
21
|
when "--text"
|
|
25
22
|
args[:text] = true
|
|
26
23
|
i += 1
|
|
24
|
+
when "--stream"
|
|
25
|
+
args[:stream] = true
|
|
26
|
+
i += 1
|
|
27
27
|
when "--output-schema"
|
|
28
28
|
args[:output_schema] = argv[i + 1]
|
|
29
29
|
i += 2
|
|
30
|
+
when "--system-prompt"
|
|
31
|
+
args[:system_prompt] = argv[i + 1]
|
|
32
|
+
i += 2
|
|
30
33
|
when "--api-key"
|
|
31
34
|
args[:api_key] = argv[i + 1]
|
|
32
35
|
i += 2
|
|
@@ -43,17 +46,21 @@ def parse_args(argv)
|
|
|
43
46
|
QUERY Question or query to answer (required)
|
|
44
47
|
|
|
45
48
|
Options:
|
|
49
|
+
--stream Stream answer chunks in real-time
|
|
46
50
|
--text Include full text content from sources
|
|
47
51
|
--output-schema JSON JSON schema for structured output
|
|
52
|
+
--system-prompt TEXT System prompt to guide answer generation
|
|
48
53
|
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
|
49
54
|
--output-format FMT Output format: json, pretty, or text (default: json)
|
|
50
55
|
--help, -h Show this help message
|
|
51
56
|
|
|
52
57
|
Examples:
|
|
53
58
|
exa-api answer "What is the capital of France?"
|
|
59
|
+
exa-api answer "Latest AI breakthroughs" --stream
|
|
54
60
|
exa-api answer "Latest AI breakthroughs" --text
|
|
55
61
|
exa-api answer "Ruby best practices" --output-format pretty
|
|
56
62
|
exa-api answer "What is the capital of France?" --output-schema '{"type":"object","properties":{"city":{"type":"string"},"state":{"type":"string"}}}'
|
|
63
|
+
exa-api answer "What is Paris?" --system-prompt "Respond in the voice of a pirate"
|
|
57
64
|
HELP
|
|
58
65
|
exit 0
|
|
59
66
|
else
|
|
@@ -89,6 +96,7 @@ begin
|
|
|
89
96
|
# Prepare answer parameters
|
|
90
97
|
answer_params = {}
|
|
91
98
|
answer_params[:text] = args[:text] if args[:text]
|
|
99
|
+
answer_params[:system_prompt] = args[:system_prompt] if args[:system_prompt]
|
|
92
100
|
|
|
93
101
|
# Parse output_schema as JSON if provided
|
|
94
102
|
if args[:output_schema]
|
|
@@ -100,12 +108,24 @@ begin
|
|
|
100
108
|
end
|
|
101
109
|
end
|
|
102
110
|
|
|
103
|
-
# Execute answer
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
# Execute answer or streaming answer
|
|
112
|
+
if args[:stream]
|
|
113
|
+
# Streaming mode - output chunks in real-time
|
|
114
|
+
client.answer_stream(args[:query], **answer_params) do |chunk|
|
|
115
|
+
# Extract content from the streaming response format
|
|
116
|
+
# API returns: {"choices":[{"delta":{"content":"..."}}]}
|
|
117
|
+
if chunk["choices"]&.first&.dig("delta", "content")
|
|
118
|
+
print chunk["choices"][0]["delta"]["content"]
|
|
119
|
+
$stdout.flush
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
puts # Add newline at the end
|
|
123
|
+
else
|
|
124
|
+
# Non-streaming mode - collect full response and format
|
|
125
|
+
result = client.answer(args[:query], **answer_params)
|
|
126
|
+
output = Exa::CLI::Formatters::AnswerFormatter.format(result, output_format)
|
|
127
|
+
puts output
|
|
128
|
+
end
|
|
109
129
|
|
|
110
130
|
rescue Exa::ConfigurationError => e
|
|
111
131
|
$stderr.puts "Configuration error: #{e.message}"
|
data/exe/exa-ai-context
CHANGED
data/exe/exa-ai-get-contents
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "
|
|
5
|
-
require "exa"
|
|
6
|
-
require "exa/cli/base"
|
|
7
|
-
require "exa/cli/formatters/contents_formatter"
|
|
4
|
+
require "exa-ai"
|
|
8
5
|
|
|
9
6
|
# Parse command line arguments
|
|
10
7
|
def parse_args(args)
|
|
11
8
|
ids = []
|
|
12
9
|
api_key = nil
|
|
13
10
|
text = false
|
|
14
|
-
|
|
11
|
+
text_max_characters = nil
|
|
12
|
+
include_html_tags = false
|
|
15
13
|
summary = false
|
|
14
|
+
summary_query = nil
|
|
15
|
+
summary_schema = nil
|
|
16
|
+
subpages = nil
|
|
17
|
+
subpage_target = []
|
|
18
|
+
links = nil
|
|
19
|
+
image_links = nil
|
|
20
|
+
context = false
|
|
21
|
+
context_max_characters = nil
|
|
22
|
+
livecrawl_timeout = nil
|
|
16
23
|
output_format = nil
|
|
17
24
|
|
|
18
25
|
i = 0
|
|
@@ -25,10 +32,48 @@ def parse_args(args)
|
|
|
25
32
|
api_key = args[i]
|
|
26
33
|
when "--text"
|
|
27
34
|
text = true
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
i += 1
|
|
36
|
+
when "--text-max-characters"
|
|
37
|
+
i += 1
|
|
38
|
+
text_max_characters = args[i].to_i
|
|
39
|
+
when "--include-html-tags"
|
|
40
|
+
include_html_tags = true
|
|
41
|
+
i += 1
|
|
30
42
|
when "--summary"
|
|
31
43
|
summary = true
|
|
44
|
+
i += 1
|
|
45
|
+
when "--summary-query"
|
|
46
|
+
i += 1
|
|
47
|
+
summary_query = args[i]
|
|
48
|
+
when "--summary-schema"
|
|
49
|
+
i += 1
|
|
50
|
+
schema_arg = args[i]
|
|
51
|
+
summary_schema = if schema_arg.start_with?("@")
|
|
52
|
+
JSON.parse(File.read(schema_arg[1..]))
|
|
53
|
+
else
|
|
54
|
+
JSON.parse(schema_arg)
|
|
55
|
+
end
|
|
56
|
+
when "--subpages"
|
|
57
|
+
i += 1
|
|
58
|
+
subpages = args[i].to_i
|
|
59
|
+
when "--subpage-target"
|
|
60
|
+
i += 1
|
|
61
|
+
subpage_target << args[i]
|
|
62
|
+
when "--links"
|
|
63
|
+
i += 1
|
|
64
|
+
links = args[i].to_i
|
|
65
|
+
when "--image-links"
|
|
66
|
+
i += 1
|
|
67
|
+
image_links = args[i].to_i
|
|
68
|
+
when "--context"
|
|
69
|
+
context = true
|
|
70
|
+
i += 1
|
|
71
|
+
when "--context-max-characters"
|
|
72
|
+
i += 1
|
|
73
|
+
context_max_characters = args[i].to_i
|
|
74
|
+
when "--livecrawl-timeout"
|
|
75
|
+
i += 1
|
|
76
|
+
livecrawl_timeout = args[i].to_i
|
|
32
77
|
when "--output-format"
|
|
33
78
|
i += 1
|
|
34
79
|
output_format = args[i]
|
|
@@ -41,34 +86,130 @@ def parse_args(args)
|
|
|
41
86
|
ids_arg = arg
|
|
42
87
|
ids = ids_arg.include?(",") ? ids_arg.split(",").map(&:strip) : [ids_arg]
|
|
43
88
|
end
|
|
89
|
+
i += 1
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
ids: ids,
|
|
95
|
+
api_key: api_key,
|
|
96
|
+
text: text,
|
|
97
|
+
text_max_characters: text_max_characters,
|
|
98
|
+
include_html_tags: include_html_tags,
|
|
99
|
+
summary: summary,
|
|
100
|
+
summary_query: summary_query,
|
|
101
|
+
summary_schema: summary_schema,
|
|
102
|
+
subpages: subpages,
|
|
103
|
+
subpage_target: subpage_target,
|
|
104
|
+
links: links,
|
|
105
|
+
image_links: image_links,
|
|
106
|
+
context: context,
|
|
107
|
+
context_max_characters: context_max_characters,
|
|
108
|
+
livecrawl_timeout: livecrawl_timeout,
|
|
109
|
+
output_format: output_format
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Build contents parameters from extracted flags
|
|
114
|
+
def build_contents_params(args)
|
|
115
|
+
params = {}
|
|
116
|
+
|
|
117
|
+
# Text options
|
|
118
|
+
if args[:text]
|
|
119
|
+
if args[:text_max_characters] || args[:include_html_tags]
|
|
120
|
+
params[:text] = {}
|
|
121
|
+
params[:text][:max_characters] = args[:text_max_characters] if args[:text_max_characters]
|
|
122
|
+
params[:text][:include_html_tags] = args[:include_html_tags] if args[:include_html_tags]
|
|
123
|
+
else
|
|
124
|
+
params[:text] = true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Summary options
|
|
129
|
+
if args[:summary]
|
|
130
|
+
if args[:summary_query] || args[:summary_schema]
|
|
131
|
+
params[:summary] = {}
|
|
132
|
+
params[:summary][:query] = args[:summary_query] if args[:summary_query]
|
|
133
|
+
params[:summary][:schema] = args[:summary_schema] if args[:summary_schema]
|
|
134
|
+
else
|
|
135
|
+
params[:summary] = true
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Context options
|
|
140
|
+
if args[:context]
|
|
141
|
+
if args[:context_max_characters]
|
|
142
|
+
params[:context] = { max_characters: args[:context_max_characters] }
|
|
143
|
+
else
|
|
144
|
+
params[:context] = true
|
|
44
145
|
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Subpages options
|
|
149
|
+
params[:subpages] = args[:subpages] if args[:subpages]
|
|
150
|
+
params[:subpage_target] = args[:subpage_target] if args[:subpage_target].any?
|
|
45
151
|
|
|
46
|
-
|
|
152
|
+
# Extras options
|
|
153
|
+
if args[:links] || args[:image_links]
|
|
154
|
+
params[:extras] = {}
|
|
155
|
+
params[:extras][:links] = args[:links] if args[:links]
|
|
156
|
+
params[:extras][:image_links] = args[:image_links] if args[:image_links]
|
|
47
157
|
end
|
|
48
158
|
|
|
49
|
-
|
|
159
|
+
# Livecrawl options
|
|
160
|
+
params[:livecrawl_timeout] = args[:livecrawl_timeout] if args[:livecrawl_timeout]
|
|
161
|
+
|
|
162
|
+
params.empty? ? nil : params
|
|
50
163
|
end
|
|
51
164
|
|
|
52
165
|
def print_help
|
|
53
|
-
puts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
166
|
+
puts <<~HELP
|
|
167
|
+
Usage: exa-api get-contents <urls> [options]
|
|
168
|
+
|
|
169
|
+
Retrieve full page contents from URLs
|
|
170
|
+
|
|
171
|
+
Arguments:
|
|
172
|
+
urls Comma-separated list of URLs (required)
|
|
173
|
+
|
|
174
|
+
Options:
|
|
175
|
+
Text Extraction:
|
|
176
|
+
--text Include page text in response
|
|
177
|
+
--text-max-characters N Max characters for page text
|
|
178
|
+
--include-html-tags Include HTML tags in text extraction
|
|
179
|
+
|
|
180
|
+
Summary:
|
|
181
|
+
--summary Include AI-generated summary
|
|
182
|
+
--summary-query PROMPT Custom prompt for summary generation
|
|
183
|
+
--summary-schema FILE JSON schema for summary structure (@file syntax)
|
|
184
|
+
|
|
185
|
+
Context:
|
|
186
|
+
--context Format results as context for LLM RAG
|
|
187
|
+
--context-max-characters N Max characters for context string
|
|
188
|
+
|
|
189
|
+
Subpages:
|
|
190
|
+
--subpages N Number of subpages to crawl
|
|
191
|
+
--subpage-target PHRASE Subpage target phrases (repeatable)
|
|
192
|
+
|
|
193
|
+
Extras:
|
|
194
|
+
--links N Number of links to extract per result
|
|
195
|
+
--image-links N Number of image links to extract
|
|
196
|
+
|
|
197
|
+
Livecrawl:
|
|
198
|
+
--livecrawl-timeout N Timeout for livecrawling in milliseconds
|
|
199
|
+
|
|
200
|
+
General:
|
|
201
|
+
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
|
202
|
+
--output-format FMT Output format: json, pretty, or text (default: json)
|
|
203
|
+
--help, -h Show this help message
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
exa-api get-contents 'https://example.com'
|
|
207
|
+
exa-api get-contents 'https://example.com' --text
|
|
208
|
+
exa-api get-contents 'https://example.com' --text --text-max-characters 3000 --include-html-tags
|
|
209
|
+
exa-api get-contents 'url1,url2' --summary --summary-query "Be terse"
|
|
210
|
+
exa-api get-contents 'https://example.com' --subpages 1 --subpage-target about
|
|
211
|
+
exa-api get-contents 'https://example.com' --links 5 --image-links 10
|
|
212
|
+
HELP
|
|
72
213
|
end
|
|
73
214
|
|
|
74
215
|
begin
|
|
@@ -77,9 +218,9 @@ begin
|
|
|
77
218
|
|
|
78
219
|
# Validate IDs
|
|
79
220
|
if options[:ids].empty?
|
|
80
|
-
puts "Error:
|
|
81
|
-
puts ""
|
|
82
|
-
puts "Run 'exa-api get-contents --help' for usage information."
|
|
221
|
+
$stderr.puts "Error: URLs argument required"
|
|
222
|
+
$stderr.puts ""
|
|
223
|
+
$stderr.puts "Run 'exa-api get-contents --help' for usage information."
|
|
83
224
|
exit 1
|
|
84
225
|
end
|
|
85
226
|
|
|
@@ -93,10 +234,8 @@ begin
|
|
|
93
234
|
client = Exa::CLI::Base.build_client(api_key)
|
|
94
235
|
|
|
95
236
|
# Build request parameters
|
|
96
|
-
params =
|
|
97
|
-
params
|
|
98
|
-
params[:highlights] = true if options[:highlights]
|
|
99
|
-
params[:summary] = true if options[:summary]
|
|
237
|
+
params = build_contents_params(options)
|
|
238
|
+
params ||= {}
|
|
100
239
|
|
|
101
240
|
# Call API
|
|
102
241
|
result = client.get_contents(options[:ids], **params)
|
|
@@ -104,11 +243,25 @@ begin
|
|
|
104
243
|
# Format and output
|
|
105
244
|
output = Exa::CLI::Formatters::ContentsFormatter.format(result, output_format)
|
|
106
245
|
puts output
|
|
246
|
+
rescue Exa::ConfigurationError => e
|
|
247
|
+
$stderr.puts "Configuration error: #{e.message}"
|
|
248
|
+
exit 1
|
|
249
|
+
rescue Exa::Unauthorized => e
|
|
250
|
+
$stderr.puts "Authentication error: #{e.message}"
|
|
251
|
+
$stderr.puts "Check your API key (set EXA_API_KEY or use --api-key)"
|
|
252
|
+
exit 1
|
|
253
|
+
rescue Exa::ClientError => e
|
|
254
|
+
$stderr.puts "Client error: #{e.message}"
|
|
255
|
+
exit 1
|
|
256
|
+
rescue Exa::ServerError => e
|
|
257
|
+
$stderr.puts "Server error: #{e.message}"
|
|
258
|
+
$stderr.puts "The Exa API may be experiencing issues. Please try again later."
|
|
259
|
+
exit 1
|
|
107
260
|
rescue Exa::Error => e
|
|
108
|
-
puts "Error: #{e.message}"
|
|
261
|
+
$stderr.puts "Error: #{e.message}"
|
|
109
262
|
exit 1
|
|
110
263
|
rescue StandardError => e
|
|
111
|
-
puts "Unexpected error: #{e.message}"
|
|
112
|
-
puts e.backtrace.first(5) if ENV["DEBUG"]
|
|
264
|
+
$stderr.puts "Unexpected error: #{e.message}"
|
|
265
|
+
$stderr.puts e.backtrace.first(5) if ENV["DEBUG"]
|
|
113
266
|
exit 1
|
|
114
267
|
end
|
data/exe/exa-ai-research-get
CHANGED
data/exe/exa-ai-research-list
CHANGED
data/exe/exa-ai-research-start
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
require "bundler/setup"
|
|
6
|
-
require "exa"
|
|
4
|
+
require "exa-ai"
|
|
7
5
|
|
|
8
6
|
# Parse command-line arguments
|
|
9
7
|
def parse_args(argv)
|
|
@@ -47,7 +45,7 @@ def parse_args(argv)
|
|
|
47
45
|
|
|
48
46
|
Options:
|
|
49
47
|
--instructions TEXT Research instructions (required)
|
|
50
|
-
--model MODEL
|
|
48
|
+
--model MODEL Research model: exa-research (default), exa-research-pro, exa-research-fast
|
|
51
49
|
--output-schema JSON JSON schema string for structured output
|
|
52
50
|
--wait Wait for task to complete (polls until done)
|
|
53
51
|
--events Include event log in output (only with --wait)
|
|
@@ -58,7 +56,7 @@ def parse_args(argv)
|
|
|
58
56
|
Examples:
|
|
59
57
|
exa-api research-start --instructions "Find Ruby performance tips"
|
|
60
58
|
exa-api research-start --instructions "Analyze AI trends" --wait --events
|
|
61
|
-
exa-api research-start --instructions "Summarize papers" --model
|
|
59
|
+
exa-api research-start --instructions "Summarize papers" --model exa-research-pro --wait
|
|
62
60
|
exa-api research-start --instructions "Find stats" --output-schema '{"type":"object"}'
|
|
63
61
|
HELP
|
|
64
62
|
exit 0
|
data/exe/exa-ai-search
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
require "bundler/setup"
|
|
6
|
-
require "exa"
|
|
4
|
+
require "exa-ai"
|
|
7
5
|
|
|
8
6
|
# Parse command-line arguments
|
|
9
7
|
def parse_args(argv)
|
|
@@ -39,9 +37,6 @@ def parse_args(argv)
|
|
|
39
37
|
when "--exclude-domains"
|
|
40
38
|
args[:exclude_domains] = argv[i + 1].split(",").map(&:strip)
|
|
41
39
|
i += 2
|
|
42
|
-
when "--use-autoprompt"
|
|
43
|
-
args[:use_autoprompt] = true
|
|
44
|
-
i += 1
|
|
45
40
|
when "--api-key"
|
|
46
41
|
args[:api_key] = argv[i + 1]
|
|
47
42
|
i += 2
|
|
@@ -159,7 +154,6 @@ def parse_args(argv)
|
|
|
159
154
|
--image-links N Number of image links to extract
|
|
160
155
|
|
|
161
156
|
General Options:
|
|
162
|
-
--use-autoprompt Use Exa's autoprompt feature
|
|
163
157
|
--linkedin TYPE Search LinkedIn: company, person, or all
|
|
164
158
|
--api-key KEY Exa API key (or set EXA_API_KEY env var)
|
|
165
159
|
--output-format FMT Output format: json, pretty, or text (default: json)
|
|
@@ -271,7 +265,6 @@ begin
|
|
|
271
265
|
search_params[:exclude_text] = args[:exclude_text] if args[:exclude_text]
|
|
272
266
|
contents = build_contents(args)
|
|
273
267
|
search_params.merge!(contents) if contents
|
|
274
|
-
search_params[:useAutoprompt] = args[:use_autoprompt] if args[:use_autoprompt]
|
|
275
268
|
|
|
276
269
|
# Execute search based on LinkedIn type
|
|
277
270
|
result = case args[:linkedin]
|
data/lib/exa/cli/base.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Exa
|
|
|
11
11
|
env_key = ENV["EXA_API_KEY"]
|
|
12
12
|
return env_key if env_key && !env_key.empty?
|
|
13
13
|
|
|
14
|
-
raise ConfigurationError,
|
|
14
|
+
raise Exa::ConfigurationError,
|
|
15
15
|
"Missing API key. Set EXA_API_KEY environment variable or use --api-key flag"
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -24,13 +24,13 @@ module Exa
|
|
|
24
24
|
|
|
25
25
|
return format if valid_formats.include?(format)
|
|
26
26
|
|
|
27
|
-
raise ConfigurationError,
|
|
27
|
+
raise Exa::ConfigurationError,
|
|
28
28
|
"Invalid output format: #{format}. Valid formats: #{valid_formats.join(', ')}"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Build a client instance with the given API key
|
|
32
32
|
def self.build_client(api_key, **options)
|
|
33
|
-
Client.new(api_key: api_key, **options)
|
|
33
|
+
Exa::Client.new(api_key: api_key, **options)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# Format output data based on format type
|
data/lib/exa/client.rb
CHANGED
|
@@ -71,6 +71,21 @@ module Exa
|
|
|
71
71
|
Services::Answer.new(connection, query: query, **options).call
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Stream AI-generated answers to a query
|
|
75
|
+
#
|
|
76
|
+
# Returns partial answer chunks as they are generated by the API.
|
|
77
|
+
#
|
|
78
|
+
# @param query [String] Question or query
|
|
79
|
+
# @param options [Hash] Answer options
|
|
80
|
+
# @option options [Boolean] :text Include full text content (default: false)
|
|
81
|
+
# @option options [Hash] :output_schema JSON schema for structured output
|
|
82
|
+
# @yield [chunk] Yields each answer chunk as it arrives
|
|
83
|
+
# @yieldparam chunk [Hash] Partial answer data with {"answer" => "text"}
|
|
84
|
+
# @return [void]
|
|
85
|
+
def answer_stream(query, **options, &block)
|
|
86
|
+
Services::AnswerStream.new(connection, query: query, **options).call(&block)
|
|
87
|
+
end
|
|
88
|
+
|
|
74
89
|
# Start an asynchronous research task
|
|
75
90
|
#
|
|
76
91
|
# @param params [Hash] Research parameters
|
|
@@ -146,6 +161,7 @@ module Exa
|
|
|
146
161
|
options = {}
|
|
147
162
|
options[:base_url] = @options[:base_url] if @options[:base_url]
|
|
148
163
|
options[:timeout] = @options[:timeout] if @options[:timeout]
|
|
164
|
+
options[:debug] = true if ENV["EXA_DEBUG"]
|
|
149
165
|
options
|
|
150
166
|
end
|
|
151
167
|
|
data/lib/exa/connection.rb
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "logger"
|
|
4
5
|
|
|
5
6
|
module Exa
|
|
6
7
|
class Connection
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.exa.ai"
|
|
7
9
|
def self.build(api_key:, **options, &block)
|
|
8
10
|
Faraday.new(url: options[:base_url] || DEFAULT_BASE_URL) do |conn|
|
|
9
11
|
# Authentication
|
|
10
|
-
conn.
|
|
12
|
+
conn.headers["x-api-key"] = api_key
|
|
11
13
|
|
|
12
14
|
# Request/Response JSON encoding
|
|
13
15
|
conn.request :json
|
|
14
16
|
|
|
17
|
+
# Debug logging (when enabled via option)
|
|
18
|
+
if options[:debug]
|
|
19
|
+
conn.response :logger, Logger.new($stdout), headers: true, bodies: true
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
# Custom error handling (registered before JSON so it runs after in response chain)
|
|
16
23
|
conn.response :raise_error
|
|
17
24
|
conn.response :json, content_type: /\bjson$/
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Exa
|
|
4
|
+
module Services
|
|
5
|
+
class AnswerStream
|
|
6
|
+
def initialize(connection, **params)
|
|
7
|
+
@connection = connection
|
|
8
|
+
@params = params.merge(stream: true)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(&block)
|
|
12
|
+
raise ArgumentError, "block required for streaming" unless block_given?
|
|
13
|
+
|
|
14
|
+
# Use instance variable to track buffer across on_data callbacks
|
|
15
|
+
@buffer = ""
|
|
16
|
+
|
|
17
|
+
# Configure the request to stream chunks via on_data callback
|
|
18
|
+
@connection.post("/answer", @params) do |req|
|
|
19
|
+
req.options.on_data = proc do |chunk|
|
|
20
|
+
# Add chunk to buffer and process complete SSE events
|
|
21
|
+
@buffer += chunk
|
|
22
|
+
process_sse_buffer(&block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Process any remaining data in buffer after stream ends
|
|
27
|
+
process_remaining_buffer(&block) if @buffer.length.positive?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def process_sse_buffer
|
|
33
|
+
# Extract and process complete SSE events (separated by \n\n)
|
|
34
|
+
# If buffer ends with \n\n, all parts are complete.
|
|
35
|
+
# Otherwise, keep the last part in buffer for next chunk.
|
|
36
|
+
|
|
37
|
+
return if @buffer.empty?
|
|
38
|
+
|
|
39
|
+
parts = @buffer.split("\n\n")
|
|
40
|
+
|
|
41
|
+
# When split by a delimiter, if the string ends with the delimiter,
|
|
42
|
+
# split doesn't add a trailing empty string. So we need to track
|
|
43
|
+
# whether the buffer ended with \n\n to know if the last part is incomplete.
|
|
44
|
+
if @buffer.end_with?("\n\n")
|
|
45
|
+
# All parts are complete, clear buffer
|
|
46
|
+
complete_parts = parts
|
|
47
|
+
@buffer = ""
|
|
48
|
+
else
|
|
49
|
+
# Last part is incomplete, keep it for next chunk
|
|
50
|
+
complete_parts = parts[0...-1]
|
|
51
|
+
@buffer = parts.last || ""
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Process all complete events
|
|
55
|
+
complete_parts.each do |event|
|
|
56
|
+
next if event.empty?
|
|
57
|
+
|
|
58
|
+
lines = event.split("\n")
|
|
59
|
+
lines.each do |line|
|
|
60
|
+
if line.start_with?("data: ")
|
|
61
|
+
json_str = line.sub(/^data: /, "").strip
|
|
62
|
+
begin
|
|
63
|
+
data = JSON.parse(json_str)
|
|
64
|
+
yield(data)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
# Skip lines that aren't valid JSON
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def process_remaining_buffer
|
|
74
|
+
# Process any remaining incomplete buffer
|
|
75
|
+
lines = @buffer.split("\n")
|
|
76
|
+
lines.each do |line|
|
|
77
|
+
if line.start_with?("data: ")
|
|
78
|
+
json_str = line.sub(/^data: /, "").strip
|
|
79
|
+
begin
|
|
80
|
+
data = JSON.parse(json_str)
|
|
81
|
+
yield(data)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
# Skip lines that aren't valid JSON
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/exa/version.rb
CHANGED