llm_gateway 0.2.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 +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  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/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  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/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  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/openai/chat_completions/output_mapper.rb +40 -0
  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/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  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/openai/responses/output_mapper.rb +47 -0
  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/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  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 +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  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 +23 -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 +169 -10
  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 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  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,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