llm_gateway 0.3.0 → 0.4.0

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +544 -186
  4. data/Rakefile +1 -2
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
  20. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/output_mapper.rb +1 -1
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +62 -1
  41. data/lib/llm_gateway/client.rb +45 -129
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +21 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +165 -14
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -28
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  65. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -1,40 +0,0 @@
1
- require_relative 'prompt'
2
- require_relative 'agent'
3
- require 'debug'
4
-
5
- # Bash File Search Assistant using LlmGateway architecture
6
-
7
- class ClaudeCloneClone
8
- def initialize(model, api_key)
9
- @agent = Agent.new(Prompt, model, api_key)
10
- end
11
-
12
- def query(input)
13
- begin
14
- @agent.run(input) do |message|
15
- case message[:type]
16
- when 'text'
17
- puts "\n\e[32m•\e[0m #{message[:text]}"
18
- when 'tool_use'
19
- puts "\n\e[33m•\e[0m \e[36m#{message[:name]}\e[0m"
20
- if message[:input] && !message[:input].empty?
21
- puts " \e[90m#{message[:input]}\e[0m"
22
- end
23
- when 'tool_result'
24
- if message[:content] && !message[:content].empty?
25
- content_preview = message[:content].to_s.split("\n").first(3).join("\n")
26
- if content_preview.length > 100
27
- content_preview = content_preview[0..97] + "..."
28
- end
29
- puts " \e[90m#{content_preview}\e[0m"
30
- end
31
- when 'error'
32
- puts "\n\e[31m•\e[0m \e[91mError: #{message[:message]}\e[0m"
33
- end
34
- end
35
- rescue => e
36
- puts "\n\e[31m•\e[0m \e[91mError: #{e.message}\e[0m"
37
- puts "\e[90m #{e.backtrace.first}\e[0m" if e.backtrace&.first
38
- end
39
- end
40
- end
@@ -1,79 +0,0 @@
1
- require_relative 'tools/edit_tool'
2
- require_relative 'tools/read_tool'
3
- require_relative 'tools/todowrite_tool'
4
- require_relative 'tools/bash_tool'
5
- require_relative 'tools/grep_tool'
6
-
7
- class Prompt < LlmGateway::Prompt
8
- def initialize(model, transcript, api_key)
9
- super(model)
10
- @transcript = transcript
11
- @api_key = api_key
12
- end
13
-
14
- def prompt
15
- @transcript
16
- end
17
-
18
- def system_prompt
19
- <<~SYSTEM
20
- You are Claude Code Clone, an interactive CLI tool that assists with software engineering tasks.
21
-
22
- # Core Capabilities
23
-
24
- I provide assistance with:
25
- - Code analysis and debugging
26
- - Feature implementation
27
- - File editing and creation
28
- - Running tests and builds
29
- - Git operations
30
- - Web browsing and research
31
- - Task planning and management
32
-
33
- ## Available Tools
34
-
35
- You have access to these specialized tools:
36
- - `Edit` - Modify existing files by replacing specific text strings
37
- - `Read` - Read file contents with optional pagination
38
- - `TodoWrite` - Create and manage structured task lists
39
- - `Bash` - Execute shell commands with timeout support
40
- - `Grep` - Search for patterns in files using regex
41
-
42
- ## Core Instructions
43
-
44
- I am designed to:
45
- - Be concise and direct (minimize output tokens)
46
- - Follow existing code conventions and patterns
47
- - Use defensive security practices only
48
- - Plan tasks with the TodoWrite tool for complex work
49
- - Run linting/typechecking after making changes
50
- - Never commit unless explicitly asked
51
-
52
- ## Process
53
-
54
- 1. **Understand the Request**: Parse what the user needs accomplished
55
- 2. **Plan if Complex**: Use TodoWrite for multi-step tasks
56
- 3. **Execute Tools**: Use appropriate tools to complete the work
57
- 4. **Validate**: Run tests/linting when applicable
58
- 5. **Report**: Provide concise status updates
59
-
60
- Always use the available tools to perform actions rather than just suggesting commands.
61
-
62
- Before starting any task, build a todo list of what you need to do, ensuring each item is actionable and prioritized. Then, execute the tasks one by one, using the TodoWrite tool to track progress and completion.
63
-
64
- After completing each task, update the TodoWrite list to reflect the status and any necessary follow-up actions.
65
- SYSTEM
66
- end
67
-
68
- def self.tools
69
- [ EditTool, ReadTool, TodoWriteTool, BashTool, GrepTool ]
70
- end
71
-
72
- def tools
73
- self.class.tools.map(&:definition)
74
- end
75
-
76
- def post
77
- LlmGateway::Client.chat(model, prompt, tools: tools, system: system_prompt, api_key: @api_key)
78
- end
79
- end
@@ -1,47 +0,0 @@
1
- require 'tty-prompt'
2
- require_relative '../../lib/llm_gateway'
3
- require_relative 'claude_code_clone.rb'
4
-
5
- # Terminal Runner for FileSearchBot
6
- class FileSearchTerminalRunner
7
- def initialize
8
- @prompt = TTY::Prompt.new
9
- end
10
-
11
- def start
12
- puts "First, let's configure your LLM settings:\n\n"
13
-
14
- model, api_key = setup_configuration
15
- bot = ClaudeCloneClone.new(model, api_key)
16
-
17
- puts "Type 'quit' or 'exit' to stop.\n\n"
18
-
19
- loop do
20
- user_input = @prompt.ask("What can i do for you?")
21
-
22
- break if [ 'quit', 'exit' ].include?(user_input.downcase)
23
-
24
- bot.query(user_input)
25
- end
26
- end
27
-
28
- private
29
-
30
- def setup_configuration
31
- model = @prompt.ask("Enter model (default: claude-3-7-sonnet-20250219):") do |q|
32
- q.default 'claude-3-7-sonnet-20250219'
33
- end
34
-
35
- api_key = @prompt.mask("Enter your API key:") do |q|
36
- q.required true
37
- end
38
-
39
- [ model, api_key ]
40
- end
41
- end
42
-
43
- # Start the bot
44
- if __FILE__ == $0
45
- runner = FileSearchTerminalRunner.new
46
- runner.start
47
- end
@@ -1,54 +0,0 @@
1
- class BashTool < LlmGateway::Tool
2
- name 'Bash'
3
- description 'Execute shell commands'
4
- input_schema({
5
- type: 'object',
6
- properties: {
7
- command: { type: 'string', description: 'Shell command to execute' },
8
- description: { type: 'string', description: 'Human-readable description' },
9
- timeout: { type: 'integer', description: 'Timeout in milliseconds' }
10
- },
11
- required: [ 'command' ]
12
- })
13
-
14
- def execute(input)
15
- command = input[:command]
16
- description = input[:description]
17
- timeout = input[:timeout] || 120000 # Default 2 minutes
18
-
19
- if description
20
- puts "Executing: #{command}"
21
- puts "Description: #{description}\n\n"
22
- else
23
- puts "Executing: #{command}\n\n"
24
- end
25
-
26
- begin
27
- # Convert timeout from milliseconds to seconds
28
- timeout_seconds = timeout / 1000.0
29
-
30
- # Use timeout command if available, otherwise use Ruby's timeout
31
- if system('which timeout > /dev/null 2>&1')
32
- result = `timeout #{timeout_seconds}s #{command} 2>&1`
33
- exit_status = $?
34
- else
35
- require 'timeout'
36
- result = Timeout.timeout(timeout_seconds) do
37
- `#{command} 2>&1`
38
- end
39
- exit_status = $?
40
- end
41
-
42
- if exit_status.success?
43
- result.empty? ? "Command completed successfully (no output)" : result
44
- else
45
- "Command failed with exit code #{exit_status.exitstatus}:\n#{result}"
46
- end
47
-
48
- rescue Timeout::Error
49
- "Command timed out after #{timeout_seconds} seconds"
50
- rescue => e
51
- "Error executing command: #{e.message}"
52
- end
53
- end
54
- end
@@ -1,61 +0,0 @@
1
- class EditTool < LlmGateway::Tool
2
- name 'Edit'
3
- description 'Modify existing files by replacing specific text strings'
4
- input_schema({
5
- type: 'object',
6
- properties: {
7
- file_path: { type: 'string', description: 'Absolute path to file to modify' },
8
- old_string: { type: 'string', description: 'Exact text to replace' },
9
- new_string: { type: 'string', description: 'Replacement text' },
10
- replace_all: { type: 'boolean', description: 'Replace all occurrences (default: false)' }
11
- },
12
- required: [ 'file_path', 'old_string', 'new_string' ]
13
- })
14
-
15
- def execute(input)
16
- file_path = input[:file_path]
17
- old_string = input[:old_string]
18
- new_string = input[:new_string]
19
- replace_all = input[:replace_all] || false
20
-
21
- # Validate file exists
22
- unless File.exist?(file_path)
23
- return "Error: File not found at #{file_path}"
24
- end
25
-
26
- # Read file content
27
- begin
28
- content = File.read(file_path)
29
- rescue => e
30
- return "Error reading file: #{e.message}"
31
- end
32
-
33
- # Check if old_string exists in file
34
- unless content.include?(old_string)
35
- return "Error: Text '#{old_string}' not found in file"
36
- end
37
-
38
- # Perform replacement
39
- if replace_all
40
- updated_content = content.gsub(old_string, new_string)
41
- occurrences = content.scan(old_string).length
42
- else
43
- # Replace only first occurrence
44
- updated_content = content.sub(old_string, new_string)
45
- occurrences = 1
46
- end
47
-
48
- # Check if replacement would result in same content
49
- if content == updated_content
50
- return "Error: old_string and new_string are identical, no changes made"
51
- end
52
-
53
- # Write updated content back to file
54
- begin
55
- File.write(file_path, updated_content)
56
- "Successfully replaced #{occurrences} occurrence(s) in #{file_path}"
57
- rescue => e
58
- "Error writing file: #{e.message}"
59
- end
60
- end
61
- end
@@ -1,113 +0,0 @@
1
- class GrepTool < LlmGateway::Tool
2
- name 'Grep'
3
- description 'Search for patterns in files using regex'
4
- input_schema({
5
- type: 'object',
6
- properties: {
7
- pattern: { type: 'string', description: 'Regex pattern to search for' },
8
- path: { type: 'string', description: 'File or directory path' },
9
- output_mode: {
10
- type: 'string',
11
- enum: [ 'content', 'files_with_matches', 'count' ],
12
- description: 'Output mode: content, files_with_matches, or count'
13
- },
14
- glob: { type: 'string', description: 'File pattern filter (e.g., "*.rb")' },
15
- '-n': { type: 'boolean', description: 'Show line numbers' },
16
- '-i': { type: 'boolean', description: 'Case insensitive search' },
17
- '-C': { type: 'integer', description: 'Context lines around matches' }
18
- },
19
- required: [ 'pattern' ]
20
- })
21
-
22
- def execute(input)
23
- pattern = input[:pattern]
24
- path = input[:path] || '.'
25
- output_mode = input[:output_mode] || 'files_with_matches'
26
- glob = input[:glob]
27
- show_line_numbers = input['-n'] || false
28
- case_insensitive = input['-i'] || false
29
- context_lines = input['-C'] || 0
30
-
31
- # Build grep command
32
- cmd_parts = [ 'grep' ]
33
-
34
- # Add flags
35
- cmd_parts << '-r' unless File.file?(path) # Recursive for directories
36
- cmd_parts << '-n' if show_line_numbers && output_mode == 'content'
37
- cmd_parts << '-i' if case_insensitive
38
- cmd_parts << "-C#{context_lines}" if context_lines > 0 && output_mode == 'content'
39
-
40
- # Output mode flags
41
- case output_mode
42
- when 'files_with_matches'
43
- cmd_parts << '-l'
44
- when 'count'
45
- cmd_parts << '-c'
46
- end
47
-
48
- # Add pattern and path
49
- cmd_parts << "'#{pattern}'"
50
-
51
- # Handle glob pattern
52
- if glob
53
- if File.directory?(path)
54
- cmd_parts << "#{path}/**/*"
55
- # Use shell globbing with find for better glob support
56
- find_cmd = "find #{path} -name '#{glob}' -type f"
57
- files_result = `#{find_cmd} 2>/dev/null`
58
- if files_result.empty?
59
- return "No files found matching pattern '#{glob}' in #{path}"
60
- end
61
-
62
- # Run grep on each matching file
63
- files = files_result.strip.split("\n")
64
- results = []
65
-
66
- files.each do |file|
67
- grep_cmd = cmd_parts[0..-2].join(' ') + " '#{pattern}' '#{file}'"
68
- result = `#{grep_cmd} 2>/dev/null`
69
- results << result unless result.empty?
70
- end
71
-
72
- return results.empty? ? "No matches found" : results.join("\n")
73
- else
74
- cmd_parts << path
75
- end
76
- else
77
- cmd_parts << path
78
- end
79
-
80
- command = cmd_parts.join(' ')
81
-
82
- begin
83
- puts "Executing: #{command}"
84
- result = `#{command} 2>&1`
85
- exit_status = $?
86
-
87
- if exit_status.success?
88
- if result.empty?
89
- "No matches found"
90
- else
91
- case output_mode
92
- when 'content'
93
- result
94
- when 'files_with_matches'
95
- result
96
- when 'count'
97
- result
98
- else
99
- result
100
- end
101
- end
102
- elsif exit_status.exitstatus == 1
103
- # grep returns 1 when no matches found, which is normal
104
- "No matches found"
105
- else
106
- "Error: #{result}"
107
- end
108
-
109
- rescue => e
110
- "Error executing grep: #{e.message}"
111
- end
112
- end
113
- end
@@ -1,61 +0,0 @@
1
- class ReadTool < LlmGateway::Tool
2
- name 'Read'
3
- description 'Read file contents with optional pagination'
4
- input_schema({
5
- type: 'object',
6
- properties: {
7
- file_path: { type: 'string', description: 'Absolute path to file' },
8
- limit: { type: 'integer', description: 'Number of lines to read' },
9
- offset: { type: 'integer', description: 'Starting line number' }
10
- },
11
- required: [ 'file_path' ]
12
- })
13
-
14
- def execute(input)
15
- file_path = input[:file_path]
16
- limit = input[:limit]
17
- offset = input[:offset] || 0
18
-
19
- # Validate file exists
20
- unless File.exist?(file_path)
21
- return "Error: File not found at #{file_path}"
22
- end
23
-
24
- # Check if it's a directory
25
- if File.directory?(file_path)
26
- return "Error: #{file_path} is a directory, not a file"
27
- end
28
-
29
- begin
30
- lines = File.readlines(file_path, chomp: true)
31
-
32
- # Apply offset
33
- if offset > 0
34
- if offset >= lines.length
35
- return "Error: Offset #{offset} exceeds file length (#{lines.length} lines)"
36
- end
37
- lines = lines[offset..-1]
38
- end
39
-
40
- # Apply limit
41
- if limit && limit > 0
42
- lines = lines[0, limit]
43
- end
44
-
45
- # Format output with line numbers (similar to cat -n)
46
- output = lines.each_with_index.map do |line, index|
47
- line_number = offset + index + 1
48
- "#{line_number.to_s.rjust(6)}→#{line}"
49
- end
50
-
51
- if output.empty?
52
- "File is empty or no lines in specified range"
53
- else
54
- output.join("\n")
55
- end
56
-
57
- rescue => e
58
- "Error reading file: #{e.message}"
59
- end
60
- end
61
- end
@@ -1,98 +0,0 @@
1
- require 'json'
2
-
3
- class TodoWriteTool < LlmGateway::Tool
4
- name 'TodoWrite'
5
- description 'Create and manage structured task lists'
6
- input_schema({
7
- type: 'object',
8
- properties: {
9
- todos: {
10
- type: 'array',
11
- description: 'Array of todo objects',
12
- items: {
13
- type: 'object',
14
- properties: {
15
- id: { type: 'string', description: 'Unique identifier' },
16
- content: { type: 'string', description: 'Task description' },
17
- status: {
18
- type: 'string',
19
- enum: [ 'pending', 'in_progress', 'completed' ],
20
- description: 'Task status'
21
- },
22
- priority: {
23
- type: 'string',
24
- enum: [ 'high', 'medium', 'low' ],
25
- description: 'Task priority'
26
- }
27
- },
28
- required: [ 'id', 'content', 'status', 'priority' ]
29
- }
30
- }
31
- },
32
- required: [ 'todos' ]
33
- })
34
-
35
- def execute(input)
36
- todos = input[:todos]
37
-
38
- # Validate todos structure
39
- todos.each_with_index do |todo, index|
40
- unless todo.is_a?(Hash)
41
- return "Error: Todo at index #{index} is not a hash"
42
- end
43
-
44
- required_fields = [ 'id', 'content', 'status', 'priority' ]
45
- missing_fields = required_fields - todo.keys.map(&:to_s)
46
- unless missing_fields.empty?
47
- return "Error: Todo at index #{index} missing required fields: #{missing_fields.join(', ')}"
48
- end
49
-
50
- valid_statuses = [ 'pending', 'in_progress', 'completed' ]
51
- unless valid_statuses.include?(todo['status'])
52
- return "Error: Invalid status '#{todo['status']}' in todo #{todo['id']}. Must be one of: #{valid_statuses.join(', ')}"
53
- end
54
-
55
- valid_priorities = [ 'high', 'medium', 'low' ]
56
- unless valid_priorities.include?(todo['priority'])
57
- return "Error: Invalid priority '#{todo['priority']}' in todo #{todo['id']}. Must be one of: #{valid_priorities.join(', ')}"
58
- end
59
- end
60
-
61
- # Store todos (in practice, this might be saved to a file or database)
62
- @todos = todos
63
-
64
- # Generate summary
65
- total = todos.length
66
- pending = todos.count { |t| t['status'] == 'pending' }
67
- in_progress = todos.count { |t| t['status'] == 'in_progress' }
68
- completed = todos.count { |t| t['status'] == 'completed' }
69
-
70
- summary = "Todo list updated successfully:\n"
71
- summary += "Total tasks: #{total}\n"
72
- summary += "Pending: #{pending}, In Progress: #{in_progress}, Completed: #{completed}\n\n"
73
-
74
- # List current todos
75
- summary += "Current tasks:\n"
76
- todos.each do |todo|
77
- status_icon = case todo['status']
78
- when 'pending' then '⏳'
79
- when 'in_progress' then '🔄'
80
- when 'completed' then '✅'
81
- end
82
-
83
- priority_icon = case todo['priority']
84
- when 'high' then '🔴'
85
- when 'medium' then '🟡'
86
- when 'low' then '🟢'
87
- end
88
-
89
- summary += "#{status_icon} #{priority_icon} [#{todo['id']}] #{todo['content']}\n"
90
- end
91
-
92
- summary
93
- end
94
-
95
- def self.current_todos
96
- @todos || []
97
- end
98
- end