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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. 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