fantasy-cli 1.2.6
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/bin/gsd +8 -0
- data/bin/gsd-core-darwin-amd64 +0 -0
- data/bin/gsd-core-darwin-arm64 +0 -0
- data/bin/gsd-core-linux-amd64 +0 -0
- data/bin/gsd-core-linux-arm64 +0 -0
- data/bin/gsd-core-windows-amd64.exe +0 -0
- data/bin/gsd-core-windows-arm64.exe +0 -0
- data/bin/gsd-core.exe +0 -0
- data/lib/gsd/agents/coordinator.rb +195 -0
- data/lib/gsd/agents/task_manager.rb +158 -0
- data/lib/gsd/agents/worker.rb +162 -0
- data/lib/gsd/agents.rb +30 -0
- data/lib/gsd/ai/chat.rb +486 -0
- data/lib/gsd/ai/cli.rb +248 -0
- data/lib/gsd/ai/command_parser.rb +97 -0
- data/lib/gsd/ai/commands/base.rb +42 -0
- data/lib/gsd/ai/commands/clear.rb +20 -0
- data/lib/gsd/ai/commands/context.rb +30 -0
- data/lib/gsd/ai/commands/cost.rb +30 -0
- data/lib/gsd/ai/commands/export.rb +42 -0
- data/lib/gsd/ai/commands/help.rb +61 -0
- data/lib/gsd/ai/commands/model.rb +67 -0
- data/lib/gsd/ai/commands/reset.rb +22 -0
- data/lib/gsd/ai/config.rb +256 -0
- data/lib/gsd/ai/context.rb +324 -0
- data/lib/gsd/ai/cost_tracker.rb +361 -0
- data/lib/gsd/ai/git_context.rb +169 -0
- data/lib/gsd/ai/history.rb +384 -0
- data/lib/gsd/ai/providers/anthropic.rb +429 -0
- data/lib/gsd/ai/providers/base.rb +282 -0
- data/lib/gsd/ai/providers/lmstudio.rb +279 -0
- data/lib/gsd/ai/providers/ollama.rb +336 -0
- data/lib/gsd/ai/providers/openai.rb +396 -0
- data/lib/gsd/ai/providers/openrouter.rb +429 -0
- data/lib/gsd/ai/reference_resolver.rb +225 -0
- data/lib/gsd/ai/repl.rb +349 -0
- data/lib/gsd/ai/streaming.rb +438 -0
- data/lib/gsd/ai/ui.rb +429 -0
- data/lib/gsd/buddy/cli.rb +284 -0
- data/lib/gsd/buddy/gacha.rb +148 -0
- data/lib/gsd/buddy/renderer.rb +108 -0
- data/lib/gsd/buddy/species.rb +190 -0
- data/lib/gsd/buddy/stats.rb +156 -0
- data/lib/gsd/buddy.rb +28 -0
- data/lib/gsd/cli.rb +455 -0
- data/lib/gsd/commands.rb +198 -0
- data/lib/gsd/config.rb +183 -0
- data/lib/gsd/error.rb +188 -0
- data/lib/gsd/frontmatter.rb +123 -0
- data/lib/gsd/go/bridge.rb +173 -0
- data/lib/gsd/history.rb +76 -0
- data/lib/gsd/milestone.rb +75 -0
- data/lib/gsd/output.rb +184 -0
- data/lib/gsd/phase.rb +102 -0
- data/lib/gsd/plugins/base.rb +92 -0
- data/lib/gsd/plugins/cli.rb +330 -0
- data/lib/gsd/plugins/config.rb +164 -0
- data/lib/gsd/plugins/hooks.rb +132 -0
- data/lib/gsd/plugins/installer.rb +158 -0
- data/lib/gsd/plugins/loader.rb +122 -0
- data/lib/gsd/plugins/manager.rb +187 -0
- data/lib/gsd/plugins/marketplace.rb +142 -0
- data/lib/gsd/plugins/sandbox.rb +114 -0
- data/lib/gsd/plugins/search.rb +131 -0
- data/lib/gsd/plugins/validator.rb +157 -0
- data/lib/gsd/plugins.rb +48 -0
- data/lib/gsd/profile.rb +127 -0
- data/lib/gsd/research.rb +85 -0
- data/lib/gsd/roadmap.rb +90 -0
- data/lib/gsd/skills/bundled/commit.md +58 -0
- data/lib/gsd/skills/bundled/debug.md +28 -0
- data/lib/gsd/skills/bundled/explain.md +41 -0
- data/lib/gsd/skills/bundled/plan.md +42 -0
- data/lib/gsd/skills/bundled/verify.md +26 -0
- data/lib/gsd/skills/loader.rb +189 -0
- data/lib/gsd/state.rb +102 -0
- data/lib/gsd/template.rb +106 -0
- data/lib/gsd/tools/ask_user_question.rb +179 -0
- data/lib/gsd/tools/base.rb +204 -0
- data/lib/gsd/tools/bash.rb +246 -0
- data/lib/gsd/tools/file_edit.rb +297 -0
- data/lib/gsd/tools/file_read.rb +199 -0
- data/lib/gsd/tools/file_write.rb +153 -0
- data/lib/gsd/tools/glob.rb +202 -0
- data/lib/gsd/tools/grep.rb +227 -0
- data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
- data/lib/gsd/tools/gsd_phase.rb +140 -0
- data/lib/gsd/tools/gsd_roadmap.rb +108 -0
- data/lib/gsd/tools/gsd_state.rb +143 -0
- data/lib/gsd/tools/gsd_template.rb +157 -0
- data/lib/gsd/tools/gsd_verify.rb +159 -0
- data/lib/gsd/tools/registry.rb +103 -0
- data/lib/gsd/tools/task.rb +235 -0
- data/lib/gsd/tools/todo_write.rb +290 -0
- data/lib/gsd/tools/web.rb +260 -0
- data/lib/gsd/tui/app.rb +366 -0
- data/lib/gsd/tui/auto_complete.rb +79 -0
- data/lib/gsd/tui/colors.rb +111 -0
- data/lib/gsd/tui/command_palette.rb +126 -0
- data/lib/gsd/tui/header.rb +38 -0
- data/lib/gsd/tui/input_box.rb +199 -0
- data/lib/gsd/tui/spinner.rb +40 -0
- data/lib/gsd/tui/status_bar.rb +51 -0
- data/lib/gsd/tui.rb +17 -0
- data/lib/gsd/validator.rb +216 -0
- data/lib/gsd/verify.rb +175 -0
- data/lib/gsd/version.rb +5 -0
- data/lib/gsd/workstream.rb +91 -0
- metadata +231 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module AI
|
|
8
|
+
module Providers
|
|
9
|
+
# OpenRouter Provider - Integração com OpenRouter (múltiplos modelos)
|
|
10
|
+
#
|
|
11
|
+
# Suporta:
|
|
12
|
+
# - Qwen (gratuito)
|
|
13
|
+
# - Llama (gratuito)
|
|
14
|
+
# - Mistral (gratuito)
|
|
15
|
+
# - GPT-4, Claude, etc. (pagos)
|
|
16
|
+
# - 100+ modelos disponíveis
|
|
17
|
+
# - Streaming de respostas
|
|
18
|
+
# - Token counting preciso
|
|
19
|
+
class OpenRouter < Base
|
|
20
|
+
PROVIDER_NAME = 'OpenRouter'
|
|
21
|
+
DEFAULT_MODEL = 'qwen/qwen-2.5-72b-instruct'
|
|
22
|
+
API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
|
23
|
+
BASE_URL = 'https://openrouter.ai'
|
|
24
|
+
|
|
25
|
+
# Modelos gratuitos disponíveis
|
|
26
|
+
FREE_MODELS = [
|
|
27
|
+
'qwen/qwen-3.6-plus-preview:free',
|
|
28
|
+
'qwen/qwen-2.5-72b-instruct',
|
|
29
|
+
'qwen/qwen-2-7b-instruct',
|
|
30
|
+
'meta-llama/llama-3-8b-instruct',
|
|
31
|
+
'meta-llama/llama-3-70b-instruct',
|
|
32
|
+
'mistralai/mistral-7b-instruct',
|
|
33
|
+
'google/gemma-7b-it'
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Modelos com preços
|
|
37
|
+
MODELS = {
|
|
38
|
+
'qwen/qwen-3.6-plus-preview:free' => {
|
|
39
|
+
input_price: 0.0,
|
|
40
|
+
output_price: 0.0,
|
|
41
|
+
context_window: 1_000_000,
|
|
42
|
+
max_output: 8192,
|
|
43
|
+
vision: false,
|
|
44
|
+
caching: false,
|
|
45
|
+
latency: 0.92,
|
|
46
|
+
throughput: 33
|
|
47
|
+
},
|
|
48
|
+
'qwen/qwen-2.5-72b-instruct' => {
|
|
49
|
+
input_price: 0.0,
|
|
50
|
+
output_price: 0.0,
|
|
51
|
+
context_window: 32_768,
|
|
52
|
+
max_output: 8192,
|
|
53
|
+
vision: false,
|
|
54
|
+
caching: false
|
|
55
|
+
},
|
|
56
|
+
'qwen/qwen-2-7b-instruct' => {
|
|
57
|
+
input_price: 0.0,
|
|
58
|
+
output_price: 0.0,
|
|
59
|
+
context_window: 32_768,
|
|
60
|
+
max_output: 4096,
|
|
61
|
+
vision: false,
|
|
62
|
+
caching: false
|
|
63
|
+
},
|
|
64
|
+
'meta-llama/llama-3-8b-instruct' => {
|
|
65
|
+
input_price: 0.0,
|
|
66
|
+
output_price: 0.0,
|
|
67
|
+
context_window: 8192,
|
|
68
|
+
max_output: 4096,
|
|
69
|
+
vision: false,
|
|
70
|
+
caching: false
|
|
71
|
+
},
|
|
72
|
+
'meta-llama/llama-3-70b-instruct' => {
|
|
73
|
+
input_price: 0.0,
|
|
74
|
+
output_price: 0.0,
|
|
75
|
+
context_window: 8192,
|
|
76
|
+
max_output: 4096,
|
|
77
|
+
vision: false,
|
|
78
|
+
caching: false
|
|
79
|
+
},
|
|
80
|
+
'mistralai/mistral-7b-instruct' => {
|
|
81
|
+
input_price: 0.0,
|
|
82
|
+
output_price: 0.0,
|
|
83
|
+
context_window: 32768,
|
|
84
|
+
max_output: 4096,
|
|
85
|
+
vision: false,
|
|
86
|
+
caching: false
|
|
87
|
+
},
|
|
88
|
+
'google/gemma-7b-it' => {
|
|
89
|
+
input_price: 0.0,
|
|
90
|
+
output_price: 0.0,
|
|
91
|
+
context_window: 8192,
|
|
92
|
+
max_output: 4096,
|
|
93
|
+
vision: false,
|
|
94
|
+
caching: false
|
|
95
|
+
}
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
# Inicializa o provider OpenRouter
|
|
99
|
+
#
|
|
100
|
+
# @param model [String] Modelo do OpenRouter
|
|
101
|
+
# @param api_key [String] API key (ou use env OPENROUTER_API_KEY)
|
|
102
|
+
# @param debug [Boolean] Habilitar debug
|
|
103
|
+
def initialize(model: nil, api_key: nil, debug: false)
|
|
104
|
+
super(model: model, debug: debug)
|
|
105
|
+
@api_key = api_key || ENV['OPENROUTER_API_KEY']
|
|
106
|
+
@max_tokens = 4096
|
|
107
|
+
@token_cache = {}
|
|
108
|
+
@site_url = nil
|
|
109
|
+
@site_name = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Configura informações do site (opcional, para ranking no OpenRouter)
|
|
113
|
+
#
|
|
114
|
+
# @param site_url [String] URL do site
|
|
115
|
+
# @param site_name [String] Nome do site
|
|
116
|
+
# @return [void]
|
|
117
|
+
def configure_site(site_url:, site_name:)
|
|
118
|
+
@site_url = site_url
|
|
119
|
+
@site_name = site_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Chama a API do OpenRouter
|
|
123
|
+
#
|
|
124
|
+
# @param system_prompt [String] System prompt
|
|
125
|
+
# @param messages [Array] Histórico de mensagens
|
|
126
|
+
# @param tools [Array] Tools disponíveis
|
|
127
|
+
# @param stream [Boolean] Habilitar streaming
|
|
128
|
+
# @yield [Hash] Chunk de streaming
|
|
129
|
+
# @return [OpenStruct] Resposta
|
|
130
|
+
def call(system_prompt:, messages:, tools:, stream: false, &block)
|
|
131
|
+
validate_api_key!
|
|
132
|
+
|
|
133
|
+
payload = build_payload(system_prompt, messages, tools, stream)
|
|
134
|
+
|
|
135
|
+
log_debug("Calling OpenRouter API with model: #{@model}")
|
|
136
|
+
log_debug("Messages count: #{messages.count}")
|
|
137
|
+
|
|
138
|
+
if stream
|
|
139
|
+
stream_request(payload, &block)
|
|
140
|
+
else
|
|
141
|
+
sync_request(payload)
|
|
142
|
+
end
|
|
143
|
+
rescue => e
|
|
144
|
+
log_debug("Error: #{e.message}")
|
|
145
|
+
raise OpenRouterError, "OpenRouter API error: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Verifica se está configurado
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] true se configurado
|
|
151
|
+
def configured?
|
|
152
|
+
!@api_key.nil? && !@api_key.empty?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Lista modelos disponíveis
|
|
156
|
+
#
|
|
157
|
+
# @return [Array<String>] Lista de modelos
|
|
158
|
+
def list_models
|
|
159
|
+
uri = URI("#{BASE_URL}/api/v1/models")
|
|
160
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
161
|
+
http.use_ssl = true
|
|
162
|
+
|
|
163
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
164
|
+
response = http.request(request)
|
|
165
|
+
|
|
166
|
+
if response.code == '200'
|
|
167
|
+
data = JSON.parse(response.body)
|
|
168
|
+
data['data']&.map { |m| m['id'] } || []
|
|
169
|
+
else
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
rescue => e
|
|
173
|
+
log_debug("Error listing models: #{e.message}")
|
|
174
|
+
[]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Verifica se modelo é gratuito
|
|
178
|
+
#
|
|
179
|
+
# @param model [String] Nome do modelo
|
|
180
|
+
# @return [Boolean] true se gratuito
|
|
181
|
+
def free_model?(model)
|
|
182
|
+
FREE_MODELS.include?(model)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Retorna o preço do modelo
|
|
186
|
+
#
|
|
187
|
+
# @return [Hash] Preço de input e output
|
|
188
|
+
def pricing
|
|
189
|
+
MODELS[@model] || { input_price: 0, output_price: 0 }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# Valida API key
|
|
195
|
+
#
|
|
196
|
+
# @raise [ArgumentError] Se API key não estiver configurada
|
|
197
|
+
def validate_api_key!
|
|
198
|
+
raise ArgumentError, 'OPENROUTER_API_KEY not set' unless configured?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Constrói o payload da requisição
|
|
202
|
+
#
|
|
203
|
+
# @param system_prompt [String] System prompt
|
|
204
|
+
# @param messages [Array] Mensagens
|
|
205
|
+
# @param tools [Array] Tools
|
|
206
|
+
# @param stream [Boolean] Streaming
|
|
207
|
+
# @return [Hash] Payload
|
|
208
|
+
def build_payload(system_prompt, messages, tools, stream)
|
|
209
|
+
formatted_messages = [{ role: 'system', content: system_prompt }] +
|
|
210
|
+
messages.map { |m| { role: m[:role], content: m[:content] } }
|
|
211
|
+
|
|
212
|
+
payload = {
|
|
213
|
+
model: @model,
|
|
214
|
+
max_tokens: @max_tokens,
|
|
215
|
+
messages: formatted_messages,
|
|
216
|
+
stream: stream
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Informações do site (opcional)
|
|
220
|
+
if @site_url && @site_name
|
|
221
|
+
payload[:site_url] = @site_url
|
|
222
|
+
payload[:site_name] = @site_name
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Tools (se suportado pelo modelo)
|
|
226
|
+
if tools.any?
|
|
227
|
+
payload[:tools] = format_tools(tools)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
payload
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Formata tools para API do OpenRouter
|
|
234
|
+
#
|
|
235
|
+
# @param tools [Array] Tools
|
|
236
|
+
# @return [Array] Tools formatadas
|
|
237
|
+
def format_tools(tools)
|
|
238
|
+
tools.map do |tool|
|
|
239
|
+
{
|
|
240
|
+
type: 'function',
|
|
241
|
+
function: {
|
|
242
|
+
name: tool.name,
|
|
243
|
+
description: tool.description,
|
|
244
|
+
parameters: tool.input_schema || { type: 'object', properties: {} }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Faz requisição síncrona
|
|
251
|
+
#
|
|
252
|
+
# @param payload [Hash] Payload da requisição
|
|
253
|
+
# @return [OpenStruct] Resposta
|
|
254
|
+
def sync_request(payload)
|
|
255
|
+
uri = URI(API_URL)
|
|
256
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
257
|
+
http.use_ssl = true
|
|
258
|
+
|
|
259
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
260
|
+
request['Content-Type'] = 'application/json'
|
|
261
|
+
request['Authorization'] = "Bearer #{@api_key}"
|
|
262
|
+
request['HTTP-Referer'] = @site_url || 'https://github.com/claude-ruby'
|
|
263
|
+
request['X-Title'] = @site_name || 'GSD Tools'
|
|
264
|
+
request.body = JSON.generate(payload)
|
|
265
|
+
|
|
266
|
+
log_debug("Sending request to #{API_URL}")
|
|
267
|
+
|
|
268
|
+
response = http.request(request)
|
|
269
|
+
log_debug("Response status: #{response.code}")
|
|
270
|
+
|
|
271
|
+
if response.code == '200'
|
|
272
|
+
parse_response(JSON.parse(response.body))
|
|
273
|
+
else
|
|
274
|
+
raise OpenRouterError, "API error: #{response.code} - #{response.body}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Faz requisição com streaming
|
|
279
|
+
#
|
|
280
|
+
# @param payload [Hash] Payload da requisição
|
|
281
|
+
# @yield [Hash] Chunk de streaming
|
|
282
|
+
# @return [OpenStruct] Resposta
|
|
283
|
+
def stream_request(payload, &block)
|
|
284
|
+
uri = URI(API_URL)
|
|
285
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
286
|
+
http.use_ssl = true
|
|
287
|
+
|
|
288
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
289
|
+
request['Content-Type'] = 'application/json'
|
|
290
|
+
request['Authorization'] = "Bearer #{@api_key}"
|
|
291
|
+
request['HTTP-Referer'] = @site_url || 'https://github.com/claude-ruby'
|
|
292
|
+
request['X-Title'] = @site_name || 'GSD Tools'
|
|
293
|
+
request.body = JSON.generate(payload)
|
|
294
|
+
|
|
295
|
+
log_debug("Sending streaming request to #{API_URL}")
|
|
296
|
+
|
|
297
|
+
content = +''
|
|
298
|
+
tool_calls = []
|
|
299
|
+
usage = {}
|
|
300
|
+
|
|
301
|
+
http.request(request) do |response|
|
|
302
|
+
if response.code == '200'
|
|
303
|
+
response.read_body do |chunk|
|
|
304
|
+
chunk.each_line do |line|
|
|
305
|
+
line = line.strip
|
|
306
|
+
next if line.empty?
|
|
307
|
+
next unless line.start_with?('data: ')
|
|
308
|
+
|
|
309
|
+
data = line[6..-1]
|
|
310
|
+
next if data == '[DONE]'
|
|
311
|
+
|
|
312
|
+
begin
|
|
313
|
+
event = JSON.parse(data)
|
|
314
|
+
yield(event) if block_given?
|
|
315
|
+
|
|
316
|
+
# Acumula conteúdo
|
|
317
|
+
choice = event.dig(:choices, 0)
|
|
318
|
+
if choice
|
|
319
|
+
delta = choice[:delta]
|
|
320
|
+
content << delta[:content].to_s if delta && delta[:content]
|
|
321
|
+
|
|
322
|
+
# Tool calls
|
|
323
|
+
if delta && delta[:tool_calls]
|
|
324
|
+
delta[:tool_calls].each do |tc|
|
|
325
|
+
if tc[:function]
|
|
326
|
+
tool_calls << {
|
|
327
|
+
id: tc[:id],
|
|
328
|
+
name: tc[:function][:name],
|
|
329
|
+
arguments: tc[:function][:arguments].to_s
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Usage (vem no último chunk)
|
|
336
|
+
if choice[:finish_reason] && event[:usage]
|
|
337
|
+
usage = extract_usage(event)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
# Ignora linhas inválidas
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
else
|
|
346
|
+
raise OpenRouterError, "API error: #{response.code}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
OpenStruct.new(
|
|
351
|
+
content: content,
|
|
352
|
+
tool_calls: tool_calls,
|
|
353
|
+
usage: usage,
|
|
354
|
+
raw: {}
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Parseia a resposta da API
|
|
359
|
+
#
|
|
360
|
+
# @param data [Hash] Dados da resposta
|
|
361
|
+
# @return [OpenStruct] Resposta parseada
|
|
362
|
+
def parse_response(data)
|
|
363
|
+
content = extract_text(data)
|
|
364
|
+
tool_calls = extract_tool_calls(data)
|
|
365
|
+
usage = extract_usage(data)
|
|
366
|
+
|
|
367
|
+
OpenStruct.new(
|
|
368
|
+
content: content,
|
|
369
|
+
tool_calls: tool_calls,
|
|
370
|
+
usage: usage,
|
|
371
|
+
raw: data
|
|
372
|
+
)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Extrai texto da resposta
|
|
376
|
+
#
|
|
377
|
+
# @param data [Hash] Dados da resposta
|
|
378
|
+
# @return [String] Texto extraído
|
|
379
|
+
def extract_text(data)
|
|
380
|
+
choices = data['choices'] || []
|
|
381
|
+
return '' if choices.empty?
|
|
382
|
+
|
|
383
|
+
message = choices[0]['message'] || {}
|
|
384
|
+
message['content'] || ''
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Extrai tool calls da resposta
|
|
388
|
+
#
|
|
389
|
+
# @param data [Hash] Dados da resposta
|
|
390
|
+
# @return [Array] Tool calls
|
|
391
|
+
def extract_tool_calls(data)
|
|
392
|
+
choices = data['choices'] || []
|
|
393
|
+
return [] if choices.empty?
|
|
394
|
+
|
|
395
|
+
message = choices[0]['message'] || {}
|
|
396
|
+
tool_calls = message['tool_calls'] || []
|
|
397
|
+
|
|
398
|
+
tool_calls.map do |tc|
|
|
399
|
+
function = tc['function'] || {}
|
|
400
|
+
OpenStruct.new(
|
|
401
|
+
id: tc['id'],
|
|
402
|
+
name: function['name'],
|
|
403
|
+
arguments: JSON.parse(function['arguments'] || '{}')
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
rescue => e
|
|
407
|
+
log_debug("Error extracting tool calls: #{e.message}")
|
|
408
|
+
[]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Extrai usage da resposta
|
|
412
|
+
#
|
|
413
|
+
# @param data [Hash] Dados da resposta
|
|
414
|
+
# @return [Hash] Usage data
|
|
415
|
+
def extract_usage(data)
|
|
416
|
+
usage = data['usage'] || {}
|
|
417
|
+
{
|
|
418
|
+
input_tokens: usage['prompt_tokens'] || 0,
|
|
419
|
+
output_tokens: usage['completion_tokens'] || 0,
|
|
420
|
+
total_tokens: usage['total_tokens'] || 0
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Erro customizado para OpenRouter
|
|
426
|
+
class OpenRouterError < StandardError; end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module AI
|
|
7
|
+
# ReferenceResolver - Resolve referências (@) para conteúdo
|
|
8
|
+
#
|
|
9
|
+
# Responsável por:
|
|
10
|
+
# - Ler arquivos (@arquivo.rb)
|
|
11
|
+
# - Listar pastas (@pasta/)
|
|
12
|
+
# - Converter imagens para base64 (@imagem.png)
|
|
13
|
+
# - Respeitar limites (max 5 arquivos, 100KB cada)
|
|
14
|
+
class ReferenceResolver
|
|
15
|
+
# Limite de arquivos por mensagem
|
|
16
|
+
MAX_FILES = 5
|
|
17
|
+
|
|
18
|
+
# Limite de tamanho por arquivo (bytes)
|
|
19
|
+
MAX_FILE_SIZE = 100 * 1024 # 100KB
|
|
20
|
+
|
|
21
|
+
# Extensões de imagem suportadas
|
|
22
|
+
IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp].freeze
|
|
23
|
+
|
|
24
|
+
# Inicializa o resolver
|
|
25
|
+
#
|
|
26
|
+
# @param cwd [String] Diretório base para resolver paths
|
|
27
|
+
# @param provider [Object] Provider para verificar suporte a imagens
|
|
28
|
+
def initialize(cwd: Dir.pwd, provider: nil)
|
|
29
|
+
@cwd = cwd
|
|
30
|
+
@provider = provider
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolve uma lista de referências
|
|
34
|
+
#
|
|
35
|
+
# @param references [Array<String>] Lista de @referências
|
|
36
|
+
# @return [Hash] { text: "expandido", images: [{...}], errors: [] }
|
|
37
|
+
def resolve(references)
|
|
38
|
+
return { text: '', images: [], errors: [] } if references.nil? || references.empty?
|
|
39
|
+
|
|
40
|
+
files = []
|
|
41
|
+
dirs = []
|
|
42
|
+
images = []
|
|
43
|
+
errors = []
|
|
44
|
+
|
|
45
|
+
references.first(MAX_FILES).each do |ref|
|
|
46
|
+
path = File.expand_path(ref, @cwd)
|
|
47
|
+
|
|
48
|
+
unless File.exist?(path)
|
|
49
|
+
errors << "Não encontrado: @#{ref}"
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if File.directory?(path)
|
|
54
|
+
dirs << { ref: ref, path: path, content: list_directory(path) }
|
|
55
|
+
elsif image_file?(path)
|
|
56
|
+
images << resolve_image(ref, path, errors)
|
|
57
|
+
else
|
|
58
|
+
files << resolve_file(ref, path, errors)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Ignora imagens se provider não suportar
|
|
63
|
+
images = [] unless provider_supports_vision?
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
text: build_text(files, dirs),
|
|
67
|
+
images: images.compact,
|
|
68
|
+
errors: errors
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve uma única referência
|
|
73
|
+
#
|
|
74
|
+
# @param ref [String] Referência sem @
|
|
75
|
+
# @return [Hash] { type: :file|:dir|:image, content: ..., error: ... }
|
|
76
|
+
def resolve_one(ref)
|
|
77
|
+
path = File.expand_path(ref, @cwd)
|
|
78
|
+
|
|
79
|
+
return { type: :error, error: "Não encontrado: @#{ref}" } unless File.exist?(path)
|
|
80
|
+
|
|
81
|
+
if File.directory?(path)
|
|
82
|
+
{ type: :dir, content: list_directory(path) }
|
|
83
|
+
elsif image_file?(path)
|
|
84
|
+
# Sempre retorna image type, mesmo se não tiver provider
|
|
85
|
+
encoded = encode_image(path)
|
|
86
|
+
if encoded[:error]
|
|
87
|
+
{ type: :error, error: encoded[:error] }
|
|
88
|
+
else
|
|
89
|
+
{ type: :image, content: encoded }
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
content = read_file(path)
|
|
93
|
+
return { type: :error, error: content[:error] } if content[:error]
|
|
94
|
+
|
|
95
|
+
{ type: :file, content: content[:data] }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def resolve_file(ref, path, errors)
|
|
102
|
+
content = read_file(path)
|
|
103
|
+
if content[:error]
|
|
104
|
+
errors << content[:error]
|
|
105
|
+
nil
|
|
106
|
+
else
|
|
107
|
+
{ ref: ref, path: path, content: content[:data] }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_image(ref, path, errors)
|
|
112
|
+
return nil unless provider_supports_vision?
|
|
113
|
+
|
|
114
|
+
encoded = encode_image(path)
|
|
115
|
+
if encoded[:error]
|
|
116
|
+
errors << encoded[:error]
|
|
117
|
+
nil
|
|
118
|
+
else
|
|
119
|
+
{ ref: ref, path: path, base64: encoded[:data], mime: encoded[:mime] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def read_file(path)
|
|
124
|
+
size = File.size(path)
|
|
125
|
+
if size > MAX_FILE_SIZE
|
|
126
|
+
return { error: "Arquivo muito grande (>#{MAX_FILE_SIZE / 1024}KB): #{path}" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{ data: File.read(path) }
|
|
130
|
+
rescue => e
|
|
131
|
+
{ error: "Erro ao ler #{path}: #{e.message}" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def list_directory(path)
|
|
135
|
+
entries = Dir.entries(path).reject { |e| e.start_with?('.') }
|
|
136
|
+
files = entries.select { |e| File.file?(File.join(path, e)) }
|
|
137
|
+
dirs = entries.select { |e| File.directory?(File.join(path, e)) }
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
path: path,
|
|
141
|
+
files: files,
|
|
142
|
+
directories: dirs,
|
|
143
|
+
total: entries.count
|
|
144
|
+
}
|
|
145
|
+
rescue => e
|
|
146
|
+
{ error: "Erro ao listar #{path}: #{e.message}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def image_file?(path)
|
|
150
|
+
ext = File.extname(path).downcase
|
|
151
|
+
IMAGE_EXTENSIONS.include?(ext)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def encode_image(path)
|
|
155
|
+
ext = File.extname(path).downcase
|
|
156
|
+
mime = case ext
|
|
157
|
+
when '.png' then 'image/png'
|
|
158
|
+
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
159
|
+
when '.gif' then 'image/gif'
|
|
160
|
+
when '.webp' then 'image/webp'
|
|
161
|
+
when '.bmp' then 'image/bmp'
|
|
162
|
+
else 'application/octet-stream'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
size = File.size(path)
|
|
166
|
+
if size > MAX_FILE_SIZE
|
|
167
|
+
return { error: "Imagem muito grande (>#{MAX_FILE_SIZE / 1024}KB): #{path}" }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
data = Base64.strict_encode64(File.read(path, mode: 'rb'))
|
|
171
|
+
{ data: data, mime: mime }
|
|
172
|
+
rescue => e
|
|
173
|
+
{ error: "Erro ao codificar imagem #{path}: #{e.message}" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def provider_supports_vision?
|
|
177
|
+
return false if @provider.nil?
|
|
178
|
+
|
|
179
|
+
# Verifica se provider tem método supports_vision? ou inferir do nome
|
|
180
|
+
if @provider.respond_to?(:supports_vision?)
|
|
181
|
+
@provider.supports_vision?
|
|
182
|
+
else
|
|
183
|
+
# Inferir do nome da classe
|
|
184
|
+
provider_name = @provider.class.name.downcase
|
|
185
|
+
provider_name.include?('anthropic') || provider_name.include?('openai')
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_text(files, dirs)
|
|
190
|
+
parts = []
|
|
191
|
+
|
|
192
|
+
files.each do |f|
|
|
193
|
+
next if f.nil?
|
|
194
|
+
|
|
195
|
+
parts << <<~FILE
|
|
196
|
+
--- #{f[:ref]} ---
|
|
197
|
+
#{f[:content]}
|
|
198
|
+
---
|
|
199
|
+
FILE
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
dirs.each do |d|
|
|
203
|
+
content = d[:content]
|
|
204
|
+
if content.nil? || (content.is_a?(Hash) && content[:error])
|
|
205
|
+
error_msg = content&.dig(:error) || 'Erro desconhecido'
|
|
206
|
+
parts << "--- #{d[:ref]} ---\n[Erro: #{error_msg}]\n---"
|
|
207
|
+
else
|
|
208
|
+
tree = []
|
|
209
|
+
tree << "Diretório: #{content[:path]}"
|
|
210
|
+
tree << "Subdiretórios: #{content[:directories].join(', ')}" if content[:directories]&.any?
|
|
211
|
+
tree << "Arquivos: #{content[:files].join(', ')}" if content[:files]&.any?
|
|
212
|
+
|
|
213
|
+
parts << <<~DIR
|
|
214
|
+
--- #{d[:ref]} ---
|
|
215
|
+
#{tree.join("\n")}
|
|
216
|
+
---
|
|
217
|
+
DIR
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
parts.join("\n")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|