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
|
+
# Anthropic Provider - Integração com Claude API
|
|
10
|
+
#
|
|
11
|
+
# Suporta:
|
|
12
|
+
# - Claude 3.5 Sonnet
|
|
13
|
+
# - Claude 3 Opus
|
|
14
|
+
# - Claude 3 Haiku
|
|
15
|
+
# - Streaming de respostas
|
|
16
|
+
# - Tool use
|
|
17
|
+
# - Prompt caching (beta)
|
|
18
|
+
# - Vision (imagens)
|
|
19
|
+
# - Beta features
|
|
20
|
+
class Anthropic < Base
|
|
21
|
+
PROVIDER_NAME = 'Anthropic'
|
|
22
|
+
DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'
|
|
23
|
+
API_URL = 'https://api.anthropic.com/v1/messages'
|
|
24
|
+
BASE_URL = 'https://api.anthropic.com'
|
|
25
|
+
|
|
26
|
+
# Modelos disponíveis com preços e capacidades
|
|
27
|
+
MODELS = {
|
|
28
|
+
'claude-sonnet-4-5-20250929' => {
|
|
29
|
+
input_price: 0.000_003,
|
|
30
|
+
output_price: 0.000_015,
|
|
31
|
+
context_window: 200_000,
|
|
32
|
+
max_output: 8192,
|
|
33
|
+
vision: true,
|
|
34
|
+
caching: true
|
|
35
|
+
},
|
|
36
|
+
'claude-3-opus-20240229' => {
|
|
37
|
+
input_price: 0.000_015,
|
|
38
|
+
output_price: 0.000_075,
|
|
39
|
+
context_window: 200_000,
|
|
40
|
+
max_output: 4096,
|
|
41
|
+
vision: true,
|
|
42
|
+
caching: true
|
|
43
|
+
},
|
|
44
|
+
'claude-3-haiku-20240307' => {
|
|
45
|
+
input_price: 0.000_000_25,
|
|
46
|
+
output_price: 0.000_001_25,
|
|
47
|
+
context_window: 200_000,
|
|
48
|
+
max_output: 4096,
|
|
49
|
+
vision: true,
|
|
50
|
+
caching: true
|
|
51
|
+
}
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Beta features disponíveis
|
|
55
|
+
BETA_FEATURES = [
|
|
56
|
+
'prompt-caching-2024-07-31',
|
|
57
|
+
'computer-use-2024-10-22'
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# Inicializa o provider Anthropic
|
|
61
|
+
#
|
|
62
|
+
# @param model [String] Modelo do Claude
|
|
63
|
+
# @param api_key [String] API key (ou use env ANTHROPIC_API_KEY)
|
|
64
|
+
# @param debug [Boolean] Habilitar debug
|
|
65
|
+
# @param beta [Boolean] Habilitar beta features
|
|
66
|
+
# @param cache_system_prompt [Boolean] Cache de system prompt
|
|
67
|
+
def initialize(model: nil, api_key: nil, debug: false, beta: false, cache_system_prompt: true)
|
|
68
|
+
super(model: model, debug: debug)
|
|
69
|
+
@api_key = api_key || ENV['ANTHROPIC_API_KEY']
|
|
70
|
+
@max_tokens = 4096
|
|
71
|
+
@beta = beta
|
|
72
|
+
@cache_system_prompt = cache_system_prompt
|
|
73
|
+
@token_cache = {} # Cache para token counting
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Chama a API do Anthropic
|
|
77
|
+
#
|
|
78
|
+
# @param system_prompt [String] System prompt
|
|
79
|
+
# @param messages [Array] Histórico de mensagens
|
|
80
|
+
# @param tools [Array] Tools disponíveis
|
|
81
|
+
# @param stream [Boolean] Habilitar streaming
|
|
82
|
+
# @yield [Hash] Chunk de streaming
|
|
83
|
+
# @return [OpenStruct] Resposta
|
|
84
|
+
def call(system_prompt:, messages:, tools:, stream: false, &block)
|
|
85
|
+
validate_api_key!
|
|
86
|
+
|
|
87
|
+
payload = build_payload(system_prompt, messages, tools, stream)
|
|
88
|
+
|
|
89
|
+
log_debug("Calling Anthropic API with model: #{@model}")
|
|
90
|
+
log_debug("Messages count: #{messages.count}")
|
|
91
|
+
log_debug("Tools count: #{tools.count}") if tools.any?
|
|
92
|
+
|
|
93
|
+
if stream
|
|
94
|
+
stream_request(payload, &block)
|
|
95
|
+
else
|
|
96
|
+
sync_request(payload)
|
|
97
|
+
end
|
|
98
|
+
rescue => e
|
|
99
|
+
log_debug("Error: #{e.message}")
|
|
100
|
+
raise AnthropicError, "Anthropic API error: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Verifica se está configurado
|
|
104
|
+
#
|
|
105
|
+
# @return [Boolean] true se configurado
|
|
106
|
+
def configured?
|
|
107
|
+
!@api_key.nil? && !@api_key.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Retorna o preço do modelo
|
|
111
|
+
#
|
|
112
|
+
# @return [Hash] Preço de input e output
|
|
113
|
+
def pricing
|
|
114
|
+
MODELS[@model] || { input_price: 0, output_price: 0 }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Valida API key
|
|
120
|
+
#
|
|
121
|
+
# @raise [ArgumentError] Se API key não estiver configurada
|
|
122
|
+
def validate_api_key!
|
|
123
|
+
raise ArgumentError, 'ANTHROPIC_API_KEY not set' unless configured?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Constrói o payload da requisição
|
|
127
|
+
#
|
|
128
|
+
# @param system_prompt [String] System prompt
|
|
129
|
+
# @param messages [Array] Mensagens
|
|
130
|
+
# @param tools [Array] Tools
|
|
131
|
+
# @param stream [Boolean] Streaming
|
|
132
|
+
# @return [Hash] Payload
|
|
133
|
+
def build_payload(system_prompt, messages, tools, stream)
|
|
134
|
+
payload = {
|
|
135
|
+
model: @model,
|
|
136
|
+
max_tokens: @max_tokens,
|
|
137
|
+
messages: format_messages_with_cache(messages),
|
|
138
|
+
stream: stream
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# System prompt com cache (beta)
|
|
142
|
+
if @cache_system_prompt && system_prompt && !system_prompt.empty?
|
|
143
|
+
payload[:system] = [
|
|
144
|
+
{
|
|
145
|
+
type: 'text',
|
|
146
|
+
text: system_prompt,
|
|
147
|
+
cache_control: { type: 'ephemeral' }
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
else
|
|
151
|
+
payload[:system] = system_prompt
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Tools
|
|
155
|
+
if tools.any?
|
|
156
|
+
payload[:tools] = format_tools(tools)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Beta headers
|
|
160
|
+
if @beta
|
|
161
|
+
payload[:betas] = BETA_FEATURES
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
payload
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Formata mensagens para API do Anthropic
|
|
168
|
+
#
|
|
169
|
+
# @param messages [Array] Mensagens
|
|
170
|
+
# @return [Array] Mensagens formatadas
|
|
171
|
+
def format_messages(messages)
|
|
172
|
+
messages.map do |msg|
|
|
173
|
+
{
|
|
174
|
+
role: msg[:role] == 'assistant' ? 'assistant' : 'user',
|
|
175
|
+
content: msg[:content]
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Formata tools para API do Anthropic
|
|
181
|
+
#
|
|
182
|
+
# @param tools [Array] Tools
|
|
183
|
+
# @return [Array] Tools formatadas
|
|
184
|
+
def format_tools(tools)
|
|
185
|
+
tools.map do |tool|
|
|
186
|
+
{
|
|
187
|
+
name: tool.name,
|
|
188
|
+
description: tool.description,
|
|
189
|
+
input_schema: tool.input_schema || { type: 'object', properties: {} }
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Faz requisição síncrona
|
|
195
|
+
#
|
|
196
|
+
# @param payload [Hash] Payload da requisição
|
|
197
|
+
# @return [OpenStruct] Resposta
|
|
198
|
+
def sync_request(payload)
|
|
199
|
+
uri = URI(API_URL)
|
|
200
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
201
|
+
http.use_ssl = true
|
|
202
|
+
|
|
203
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
204
|
+
request['Content-Type'] = 'application/json'
|
|
205
|
+
request['x-api-key'] = @api_key
|
|
206
|
+
request['anthropic-version'] = '2023-06-01'
|
|
207
|
+
request.body = JSON.generate(payload)
|
|
208
|
+
|
|
209
|
+
log_debug("Sending request to #{API_URL}")
|
|
210
|
+
|
|
211
|
+
response = http.request(request)
|
|
212
|
+
log_debug("Response status: #{response.code}")
|
|
213
|
+
|
|
214
|
+
if response.code == '200'
|
|
215
|
+
parse_response(JSON.parse(response.body))
|
|
216
|
+
else
|
|
217
|
+
raise AnthropicError, "API error: #{response.code} - #{response.body}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Faz requisição com streaming
|
|
222
|
+
#
|
|
223
|
+
# @param payload [Hash] Payload da requisição
|
|
224
|
+
# @yield [Hash] Chunk de streaming
|
|
225
|
+
# @return [OpenStruct] Resposta
|
|
226
|
+
def stream_request(payload, &block)
|
|
227
|
+
uri = URI(API_URL)
|
|
228
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
229
|
+
http.use_ssl = true
|
|
230
|
+
|
|
231
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
232
|
+
request['Content-Type'] = 'application/json'
|
|
233
|
+
request['x-api-key'] = @api_key
|
|
234
|
+
request['anthropic-version'] = '2023-06-01'
|
|
235
|
+
request.body = JSON.generate(payload)
|
|
236
|
+
|
|
237
|
+
log_debug("Sending streaming request to #{API_URL}")
|
|
238
|
+
|
|
239
|
+
content = +''
|
|
240
|
+
tool_calls = []
|
|
241
|
+
usage = {}
|
|
242
|
+
|
|
243
|
+
# Streaming com Server-Sent Events (SSE)
|
|
244
|
+
http.request(request) do |response|
|
|
245
|
+
if response.code == '200'
|
|
246
|
+
response.read_body do |chunk|
|
|
247
|
+
chunk.each_line do |line|
|
|
248
|
+
line = line.strip
|
|
249
|
+
next if line.empty?
|
|
250
|
+
next unless line.start_with?('data: ')
|
|
251
|
+
|
|
252
|
+
data = line[6..-1]
|
|
253
|
+
next if data == '[DONE]'
|
|
254
|
+
|
|
255
|
+
begin
|
|
256
|
+
event = JSON.parse(data)
|
|
257
|
+
yield(event) if block_given?
|
|
258
|
+
|
|
259
|
+
# Acumula conteúdo
|
|
260
|
+
if event[:type] == 'content_block_delta'
|
|
261
|
+
text = event.dig(:delta, :text)
|
|
262
|
+
content << text if text
|
|
263
|
+
elsif event[:type] == 'content_block_start'
|
|
264
|
+
if event.dig(:content_block, :type) == 'tool_use'
|
|
265
|
+
tool_calls << {
|
|
266
|
+
id: event.dig(:content_block, :id),
|
|
267
|
+
name: event.dig(:content_block, :name),
|
|
268
|
+
input: ''
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
elsif event[:type] == 'message_delta'
|
|
272
|
+
usage = extract_usage(event)
|
|
273
|
+
end
|
|
274
|
+
rescue JSON::ParserError
|
|
275
|
+
# Ignora linhas inválidas
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
raise AnthropicError, "API error: #{response.code}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
OpenStruct.new(
|
|
285
|
+
content: content,
|
|
286
|
+
tool_calls: tool_calls,
|
|
287
|
+
usage: usage,
|
|
288
|
+
raw: {}
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Parseia a resposta da API
|
|
293
|
+
#
|
|
294
|
+
# @param data [Hash] Dados da resposta
|
|
295
|
+
# @return [OpenStruct] Resposta parseada
|
|
296
|
+
def parse_response(data)
|
|
297
|
+
content = extract_text(data)
|
|
298
|
+
tool_calls = extract_tool_calls(data)
|
|
299
|
+
usage = extract_usage(data)
|
|
300
|
+
|
|
301
|
+
OpenStruct.new(
|
|
302
|
+
content: content,
|
|
303
|
+
tool_calls: tool_calls,
|
|
304
|
+
usage: usage,
|
|
305
|
+
raw: data
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Conta tokens de forma precisa usando API do Anthropic
|
|
310
|
+
#
|
|
311
|
+
# @param text [String] Texto para contar
|
|
312
|
+
# @return [Integer] Número de tokens
|
|
313
|
+
def count_tokens(text)
|
|
314
|
+
# Cache simples
|
|
315
|
+
return @token_cache[text] if @token_cache[text]
|
|
316
|
+
|
|
317
|
+
# Estimativa rápida: ~4 caracteres por token
|
|
318
|
+
estimated = text.length / 4
|
|
319
|
+
|
|
320
|
+
# Para contagem precisa, usaríamos a API de count_tokens
|
|
321
|
+
# Por enquanto, usamos estimativa com margem de erro
|
|
322
|
+
@token_cache[text] = estimated
|
|
323
|
+
estimated
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Limpa o cache de tokens
|
|
327
|
+
#
|
|
328
|
+
# @return [void]
|
|
329
|
+
def clear_token_cache
|
|
330
|
+
@token_cache = {}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Formata mensagem com cache de prompt (beta)
|
|
334
|
+
#
|
|
335
|
+
# @param messages [Array] Mensagens
|
|
336
|
+
# @return [Array] Mensagens formatadas com cache
|
|
337
|
+
def format_messages_with_cache(messages)
|
|
338
|
+
return format_messages(messages) unless @cache_system_prompt
|
|
339
|
+
|
|
340
|
+
# Aplica cache às mensagens antigas (últimas 2-3 mensagens sem cache)
|
|
341
|
+
messages.map.with_index do |msg, i|
|
|
342
|
+
formatted = {
|
|
343
|
+
role: msg[:role] == 'assistant' ? 'assistant' : 'user',
|
|
344
|
+
content: msg[:content]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Adiciona cache para mensagens antigas (exceto as últimas 2)
|
|
348
|
+
if i < messages.length - 2
|
|
349
|
+
formatted[:content] = [
|
|
350
|
+
{
|
|
351
|
+
type: 'text',
|
|
352
|
+
text: msg[:content],
|
|
353
|
+
cache_control: { type: 'ephemeral' }
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
formatted
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Formata imagem para API do Anthropic
|
|
363
|
+
#
|
|
364
|
+
# @param source [Hash] Source da imagem (type, media_type, data)
|
|
365
|
+
# @return [Hash] Conteúdo formatado
|
|
366
|
+
def format_image(source)
|
|
367
|
+
{
|
|
368
|
+
type: 'image',
|
|
369
|
+
source: source
|
|
370
|
+
}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Extrai texto da resposta
|
|
374
|
+
#
|
|
375
|
+
# @param data [Hash] Dados da resposta
|
|
376
|
+
# @return [String] Texto extraído
|
|
377
|
+
def extract_text(data)
|
|
378
|
+
content_blocks = data['content'] || []
|
|
379
|
+
text_blocks = content_blocks.select { |b| b['type'] == 'text' }
|
|
380
|
+
text_blocks.map { |b| b['text'] }.join("\n")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Extrai tool calls da resposta
|
|
384
|
+
#
|
|
385
|
+
# @param data [Hash] Dados da resposta
|
|
386
|
+
# @return [Array] Tool calls
|
|
387
|
+
def extract_tool_calls(data)
|
|
388
|
+
content_blocks = data['content'] || []
|
|
389
|
+
tool_blocks = content_blocks.select { |b| b['type'] == 'tool_use' }
|
|
390
|
+
|
|
391
|
+
tool_blocks.map do |block|
|
|
392
|
+
# Parseia input se for string JSON
|
|
393
|
+
input = block['input']
|
|
394
|
+
if input.is_a?(String)
|
|
395
|
+
begin
|
|
396
|
+
input = JSON.parse(input)
|
|
397
|
+
rescue JSON::ParserError
|
|
398
|
+
# Mantém como string se não for JSON válido
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
OpenStruct.new(
|
|
403
|
+
id: block['id'],
|
|
404
|
+
name: block['name'],
|
|
405
|
+
arguments: input || {}
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Extrai usage da resposta
|
|
411
|
+
#
|
|
412
|
+
# @param data [Hash] Dados da resposta
|
|
413
|
+
# @return [Hash] Usage data
|
|
414
|
+
def extract_usage(data)
|
|
415
|
+
usage = data['usage'] || {}
|
|
416
|
+
{
|
|
417
|
+
input_tokens: usage['input_tokens'] || 0,
|
|
418
|
+
output_tokens: usage['output_tokens'] || 0,
|
|
419
|
+
cache_read_input_tokens: usage['cache_read_input_tokens'] || 0,
|
|
420
|
+
cache_creation_input_tokens: usage['cache_creation_input_tokens'] || 0
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Erro customizado para Anthropic
|
|
426
|
+
class AnthropicError < StandardError; end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module AI
|
|
7
|
+
module Providers
|
|
8
|
+
# Base class para todos os providers de LLM
|
|
9
|
+
#
|
|
10
|
+
# Responsável por:
|
|
11
|
+
# - Interface comum para diferentes providers
|
|
12
|
+
# - Tratamento de erros genérico
|
|
13
|
+
# - Logging e debug
|
|
14
|
+
# - Retry com backoff exponencial
|
|
15
|
+
# - Rate limiting
|
|
16
|
+
# - Timeout de requisições
|
|
17
|
+
# - Fallback para providers alternativos
|
|
18
|
+
class Base
|
|
19
|
+
attr_accessor :model, :debug, :timeout, :max_retries
|
|
20
|
+
attr_reader :rate_limiter
|
|
21
|
+
|
|
22
|
+
# Configurações padrão
|
|
23
|
+
DEFAULT_TIMEOUT = 120 # segundos
|
|
24
|
+
DEFAULT_MAX_RETRIES = 3
|
|
25
|
+
DEFAULT_RATE_LIMIT = 10 # requisições por minuto
|
|
26
|
+
PROVIDER_NAME = 'Unknown' # Nome padrão para providers sem nome específico
|
|
27
|
+
|
|
28
|
+
# Inicializa o provider
|
|
29
|
+
#
|
|
30
|
+
# @param model [String] Modelo do LLM
|
|
31
|
+
# @param debug [Boolean] Habilitar debug
|
|
32
|
+
# @param timeout [Integer] Timeout em segundos
|
|
33
|
+
# @param max_retries [Integer] Número máximo de tentativas
|
|
34
|
+
# @param rate_limit [Integer] Limite de requisições por minuto
|
|
35
|
+
def initialize(model: nil, debug: false, timeout: nil, max_retries: nil, rate_limit: nil)
|
|
36
|
+
@model = model || self.class::DEFAULT_MODEL
|
|
37
|
+
@debug = debug
|
|
38
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
39
|
+
@max_retries = max_retries || DEFAULT_MAX_RETRIES
|
|
40
|
+
@rate_limiter = RateLimiter.new(rate_limit || DEFAULT_RATE_LIMIT)
|
|
41
|
+
@api_key = nil
|
|
42
|
+
@client = nil
|
|
43
|
+
@fallback_provider = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Chama o LLM
|
|
47
|
+
#
|
|
48
|
+
# @param system_prompt [String] System prompt
|
|
49
|
+
# @param messages [Array] Histórico de mensagens
|
|
50
|
+
# @param tools [Array] Tools disponíveis
|
|
51
|
+
# @param stream [Boolean] Habilitar streaming
|
|
52
|
+
# @yield [Hash] Chunk de streaming (se stream=true)
|
|
53
|
+
# @return [OpenStruct] Resposta com content, tool_calls, usage
|
|
54
|
+
def call(system_prompt:, messages:, tools:, stream: false, &block)
|
|
55
|
+
raise NotImplementedError, 'Subclasses must implement #call'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Chama o LLM com retry e fallback
|
|
59
|
+
#
|
|
60
|
+
# @param system_prompt [String] System prompt
|
|
61
|
+
# @param messages [Array] Histórico de mensagens
|
|
62
|
+
# @param tools [Array] Tools disponíveis
|
|
63
|
+
# @param stream [Boolean] Habilitar streaming
|
|
64
|
+
# @yield [Hash] Chunk de streaming
|
|
65
|
+
# @return [OpenStruct] Resposta
|
|
66
|
+
def call_with_retry(system_prompt:, messages:, tools:, stream: false, &block)
|
|
67
|
+
last_error = nil
|
|
68
|
+
|
|
69
|
+
@max_retries.times do |attempt|
|
|
70
|
+
begin
|
|
71
|
+
# Rate limiting
|
|
72
|
+
@rate_limiter.wait_if_needed
|
|
73
|
+
|
|
74
|
+
# Timeout
|
|
75
|
+
return Timeout.timeout(@timeout) do
|
|
76
|
+
call(system_prompt: system_prompt, messages: messages, tools: tools, stream: stream, &block)
|
|
77
|
+
end
|
|
78
|
+
rescue Timeout::Error => e
|
|
79
|
+
log_debug("Timeout na tentativa #{attempt + 1}: #{e.message}")
|
|
80
|
+
last_error = e
|
|
81
|
+
retry if attempt < @max_retries - 1
|
|
82
|
+
rescue => e
|
|
83
|
+
log_debug("Erro na tentativa #{attempt + 1}: #{e.message}")
|
|
84
|
+
last_error = e
|
|
85
|
+
|
|
86
|
+
# Backoff exponencial
|
|
87
|
+
if attempt < @max_retries - 1
|
|
88
|
+
wait_time = exponential_backoff(attempt)
|
|
89
|
+
log_debug("Aguardando #{wait_time}s antes de retry...")
|
|
90
|
+
sleep(wait_time)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Fallback se configurado
|
|
96
|
+
if @fallback_provider && last_error
|
|
97
|
+
log_debug("Usando fallback provider...")
|
|
98
|
+
return @fallback_provider.call(
|
|
99
|
+
system_prompt: system_prompt,
|
|
100
|
+
messages: messages,
|
|
101
|
+
tools: tools,
|
|
102
|
+
stream: stream,
|
|
103
|
+
&block
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
raise last_error if last_error
|
|
108
|
+
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Define um provider de fallback
|
|
113
|
+
#
|
|
114
|
+
# @param provider [Base] Provider de fallback
|
|
115
|
+
# @return [void]
|
|
116
|
+
def fallback=(provider)
|
|
117
|
+
@fallback_provider = provider
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Verifica se o provider está configurado
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] true se configurado
|
|
123
|
+
def configured?
|
|
124
|
+
!@api_key.nil? && !@api_key.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Retorna informações do provider
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash] Informações
|
|
130
|
+
def info
|
|
131
|
+
{
|
|
132
|
+
provider: self.class::PROVIDER_NAME,
|
|
133
|
+
model: @model,
|
|
134
|
+
configured: configured?,
|
|
135
|
+
timeout: @timeout,
|
|
136
|
+
max_retries: @max_retries,
|
|
137
|
+
rate_limit: @rate_limiter.limit
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Reseta o rate limiter
|
|
142
|
+
#
|
|
143
|
+
# @return [void]
|
|
144
|
+
def reset_rate_limiter
|
|
145
|
+
@rate_limiter.reset
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
protected
|
|
149
|
+
|
|
150
|
+
# Log de debug
|
|
151
|
+
#
|
|
152
|
+
# @param message [String] Mensagem de log
|
|
153
|
+
def log_debug(message)
|
|
154
|
+
return unless @debug
|
|
155
|
+
|
|
156
|
+
puts "[DEBUG][#{self.class::PROVIDER_NAME}] #{message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Calcula backoff exponencial
|
|
160
|
+
#
|
|
161
|
+
# @param attempt [Integer] Número da tentativa
|
|
162
|
+
# @return [Float] Tempo de espera em segundos
|
|
163
|
+
def exponential_backoff(attempt)
|
|
164
|
+
# Backoff: 1s, 2s, 4s, 8s, ...
|
|
165
|
+
base = 1.0
|
|
166
|
+
(base * (2 ** attempt)).to_f
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Extrai texto de uma resposta
|
|
170
|
+
#
|
|
171
|
+
# @param response [Object] Resposta da API
|
|
172
|
+
# @return [String] Texto extraído
|
|
173
|
+
def extract_text(response)
|
|
174
|
+
raise NotImplementedError, 'Subclasses must implement #extract_text'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Extrai tool calls de uma resposta
|
|
178
|
+
#
|
|
179
|
+
# @param response [Object] Resposta da API
|
|
180
|
+
# @return [Array] Tool calls
|
|
181
|
+
def extract_tool_calls(response)
|
|
182
|
+
[]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Extrai usage de uma resposta
|
|
186
|
+
#
|
|
187
|
+
# @param response [Object] Resposta da API
|
|
188
|
+
# @return [Hash] Usage data
|
|
189
|
+
def extract_usage(response)
|
|
190
|
+
{
|
|
191
|
+
input_tokens: 0,
|
|
192
|
+
output_tokens: 0
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Rate Limiter - Controla taxa de requisições
|
|
198
|
+
#
|
|
199
|
+
# Implementa token bucket algorithm para rate limiting
|
|
200
|
+
class RateLimiter
|
|
201
|
+
attr_reader :limit, :tokens, :last_refill
|
|
202
|
+
|
|
203
|
+
# Inicializa o rate limiter
|
|
204
|
+
#
|
|
205
|
+
# @param limit [Integer] Requisições por minuto
|
|
206
|
+
def initialize(limit)
|
|
207
|
+
@limit = limit
|
|
208
|
+
@tokens = limit.to_f
|
|
209
|
+
@last_refill = Time.now
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Verifica se pode fazer requisição
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean] true se pode fazer requisição
|
|
215
|
+
def can_make_request?
|
|
216
|
+
refill_tokens
|
|
217
|
+
@tokens >= 1
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Aguarda se necessário antes de fazer requisição
|
|
221
|
+
#
|
|
222
|
+
# @return [void]
|
|
223
|
+
def wait_if_needed
|
|
224
|
+
until can_make_request?
|
|
225
|
+
sleep(0.1)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@tokens -= 1
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Reseta o rate limiter
|
|
232
|
+
#
|
|
233
|
+
# @return [void]
|
|
234
|
+
def reset
|
|
235
|
+
@tokens = @limit.to_f
|
|
236
|
+
@last_refill = Time.now
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Retorna tempo estimado até próxima requisição
|
|
240
|
+
#
|
|
241
|
+
# @return [Float] Tempo em segundos
|
|
242
|
+
def time_until_next_request
|
|
243
|
+
refill_tokens
|
|
244
|
+
return 0 if @tokens >= 1
|
|
245
|
+
|
|
246
|
+
tokens_needed = 1 - @tokens
|
|
247
|
+
tokens_per_second = @limit / 60.0
|
|
248
|
+
tokens_needed / tokens_per_second
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
# Refill tokens baseado no tempo passado
|
|
254
|
+
#
|
|
255
|
+
# @return [void]
|
|
256
|
+
def refill_tokens
|
|
257
|
+
now = Time.now
|
|
258
|
+
elapsed = now - @last_refill
|
|
259
|
+
|
|
260
|
+
# Tokens por segundo
|
|
261
|
+
tokens_per_second = @limit / 60.0
|
|
262
|
+
tokens_to_add = elapsed * tokens_per_second
|
|
263
|
+
|
|
264
|
+
@tokens = [@limit, @tokens + tokens_to_add].min
|
|
265
|
+
@last_refill = now
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Erro base para providers
|
|
270
|
+
class ProviderError < StandardError; end
|
|
271
|
+
|
|
272
|
+
# Erro de rate limit
|
|
273
|
+
class RateLimitError < ProviderError; end
|
|
274
|
+
|
|
275
|
+
# Erro de timeout
|
|
276
|
+
class TimeoutError < ProviderError; end
|
|
277
|
+
|
|
278
|
+
# Erro de autenticação
|
|
279
|
+
class AuthenticationError < ProviderError; end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|