ace-llm 0.30.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.
- checksums.yaml +7 -0
- data/.ace-defaults/llm/config.yml +31 -0
- data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
- data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
- data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
- data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
- data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
- data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
- data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
- data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
- data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
- data/.ace-defaults/llm/providers/anthropic.yml +34 -0
- data/.ace-defaults/llm/providers/google.yml +36 -0
- data/.ace-defaults/llm/providers/groq.yml +29 -0
- data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
- data/.ace-defaults/llm/providers/mistral.yml +33 -0
- data/.ace-defaults/llm/providers/openai.yml +33 -0
- data/.ace-defaults/llm/providers/openrouter.yml +45 -0
- data/.ace-defaults/llm/providers/togetherai.yml +26 -0
- data/.ace-defaults/llm/providers/xai.yml +30 -0
- data/.ace-defaults/llm/providers/zai.yml +18 -0
- data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
- data/CHANGELOG.md +641 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-llm +25 -0
- data/handbook/guides/llm-query-tool-reference.g.md +683 -0
- data/handbook/templates/agent/plan-mode.template.md +48 -0
- data/lib/ace/llm/atoms/env_reader.rb +155 -0
- data/lib/ace/llm/atoms/error_classifier.rb +200 -0
- data/lib/ace/llm/atoms/http_client.rb +162 -0
- data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
- data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
- data/lib/ace/llm/cli/commands/query.rb +280 -0
- data/lib/ace/llm/cli.rb +24 -0
- data/lib/ace/llm/configuration.rb +180 -0
- data/lib/ace/llm/models/fallback_config.rb +216 -0
- data/lib/ace/llm/molecules/client_registry.rb +336 -0
- data/lib/ace/llm/molecules/config_loader.rb +39 -0
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
- data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
- data/lib/ace/llm/molecules/format_handlers.rb +183 -0
- data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
- data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
- data/lib/ace/llm/molecules/preset_loader.rb +99 -0
- data/lib/ace/llm/molecules/provider_loader.rb +198 -0
- data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
- data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
- data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
- data/lib/ace/llm/organisms/base_client.rb +264 -0
- data/lib/ace/llm/organisms/google_client.rb +187 -0
- data/lib/ace/llm/organisms/groq_client.rb +197 -0
- data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
- data/lib/ace/llm/organisms/mistral_client.rb +180 -0
- data/lib/ace/llm/organisms/openai_client.rb +195 -0
- data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
- data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
- data/lib/ace/llm/organisms/xai_client.rb +213 -0
- data/lib/ace/llm/organisms/zai_client.rb +149 -0
- data/lib/ace/llm/query_interface.rb +455 -0
- data/lib/ace/llm/version.rb +7 -0
- data/lib/ace/llm.rb +61 -0
- metadata +318 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_client"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Organisms
|
|
8
|
+
# LMStudioClient handles interactions with local LM Studio server
|
|
9
|
+
# LM Studio provides an OpenAI-compatible API for local models
|
|
10
|
+
class LMStudioClient < BaseClient
|
|
11
|
+
API_BASE_URL = "http://localhost:1234"
|
|
12
|
+
DEFAULT_MODEL = "local-model"
|
|
13
|
+
DEFAULT_GENERATION_CONFIG = {
|
|
14
|
+
temperature: 0.7,
|
|
15
|
+
max_tokens: nil,
|
|
16
|
+
top_p: nil,
|
|
17
|
+
top_k: nil
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Get the provider name
|
|
21
|
+
# @return [String] Provider name
|
|
22
|
+
def self.provider_name
|
|
23
|
+
"lmstudio"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if this client needs API credentials
|
|
27
|
+
# @return [Boolean] False - LM Studio doesn't need API keys
|
|
28
|
+
def needs_credentials?
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Generate a response from LM Studio
|
|
33
|
+
# @param messages [Array<Hash>, String] Messages or prompt
|
|
34
|
+
# @param options [Hash] Generation options
|
|
35
|
+
# @return [Hash] Response with text and metadata
|
|
36
|
+
def generate(messages, **options)
|
|
37
|
+
messages_array = build_messages(messages)
|
|
38
|
+
generation_params = extract_generation_options(options)
|
|
39
|
+
|
|
40
|
+
request_body = build_request_body(messages_array, generation_params)
|
|
41
|
+
response = make_api_request(request_body)
|
|
42
|
+
|
|
43
|
+
parse_response(response)
|
|
44
|
+
rescue => e
|
|
45
|
+
handle_api_error(e)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Build request body for LM Studio API (OpenAI-compatible)
|
|
51
|
+
# @param messages [Array<Hash>] Messages
|
|
52
|
+
# @param generation_params [Hash] Generation parameters
|
|
53
|
+
# @return [Hash] Request body
|
|
54
|
+
def build_request_body(messages, generation_params)
|
|
55
|
+
request = {
|
|
56
|
+
messages: messages,
|
|
57
|
+
stream: false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Add generation parameters
|
|
61
|
+
request[:temperature] = generation_params[:temperature] if generation_params[:temperature]
|
|
62
|
+
request[:max_tokens] = generation_params[:max_tokens] if generation_params[:max_tokens]
|
|
63
|
+
request[:top_p] = generation_params[:top_p] if generation_params[:top_p]
|
|
64
|
+
|
|
65
|
+
# LM Studio may not support all OpenAI parameters, but we'll include them
|
|
66
|
+
request
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Make API request to LM Studio
|
|
70
|
+
# @param body [Hash] Request body
|
|
71
|
+
# @return [Hash] API response
|
|
72
|
+
def make_api_request(body)
|
|
73
|
+
url = "#{@base_url}/v1/chat/completions"
|
|
74
|
+
|
|
75
|
+
response = @http_client.post(
|
|
76
|
+
url,
|
|
77
|
+
body,
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type" => "application/json"
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
unless response.success?
|
|
84
|
+
# LM Studio might not be running
|
|
85
|
+
if response.status == 0 || response.body.nil?
|
|
86
|
+
raise Ace::LLM::ProviderError,
|
|
87
|
+
"Cannot connect to LM Studio at #{@base_url}. " \
|
|
88
|
+
"Please ensure LM Studio is running with the local server enabled."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
error_body = begin
|
|
92
|
+
response.body
|
|
93
|
+
rescue
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
error_message = error_body["error"] || "Unknown error"
|
|
97
|
+
|
|
98
|
+
raise Ace::LLM::ProviderError, "LM Studio API error (#{response.status}): #{error_message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
response.body
|
|
102
|
+
rescue Faraday::ConnectionFailed => e
|
|
103
|
+
raise Ace::LLM::ProviderError,
|
|
104
|
+
"Cannot connect to LM Studio at #{@base_url}. " \
|
|
105
|
+
"Please ensure LM Studio is running with the local server enabled. " \
|
|
106
|
+
"Error: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Parse API response
|
|
110
|
+
# @param response [Hash] Raw API response
|
|
111
|
+
# @return [Hash] Parsed response with text and metadata
|
|
112
|
+
def parse_response(response)
|
|
113
|
+
# LM Studio uses OpenAI-compatible format
|
|
114
|
+
choice = response.dig("choices", 0)
|
|
115
|
+
text = choice.dig("message", "content") if choice
|
|
116
|
+
|
|
117
|
+
unless text
|
|
118
|
+
raise Ace::LLM::ProviderError, "No text in response from LM Studio"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Extract metadata
|
|
122
|
+
metadata = {
|
|
123
|
+
finish_reason: choice["finish_reason"],
|
|
124
|
+
model_used: response["model"] || "local-model"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Add token usage if available
|
|
128
|
+
usage = response["usage"]
|
|
129
|
+
if usage
|
|
130
|
+
metadata[:input_tokens] = usage["prompt_tokens"]
|
|
131
|
+
metadata[:output_tokens] = usage["completion_tokens"]
|
|
132
|
+
metadata[:total_tokens] = usage["total_tokens"]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
create_response(text, metadata)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Fetch API key from environment (overridden to return nil)
|
|
139
|
+
# @return [nil] LM Studio doesn't need API keys
|
|
140
|
+
def fetch_api_key_from_env
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_client"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Organisms
|
|
8
|
+
# MistralClient handles interactions with Mistral AI's API
|
|
9
|
+
class MistralClient < BaseClient
|
|
10
|
+
API_BASE_URL = "https://api.mistral.ai"
|
|
11
|
+
DEFAULT_MODEL = "mistral-large-latest"
|
|
12
|
+
|
|
13
|
+
# Generation parameters to include in API request
|
|
14
|
+
GENERATION_KEYS = %i[temperature max_tokens top_p random_seed].freeze
|
|
15
|
+
|
|
16
|
+
DEFAULT_GENERATION_CONFIG = {
|
|
17
|
+
temperature: 0.7,
|
|
18
|
+
max_tokens: nil,
|
|
19
|
+
top_p: nil,
|
|
20
|
+
random_seed: nil
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Get the provider name
|
|
24
|
+
# @return [String] Provider name
|
|
25
|
+
def self.provider_name
|
|
26
|
+
"mistral"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate a response from Mistral
|
|
30
|
+
# @param messages [Array<Hash>, String] Messages or prompt
|
|
31
|
+
# @param options [Hash] Generation options
|
|
32
|
+
# @return [Hash] Response with text and metadata
|
|
33
|
+
def generate(messages, **options)
|
|
34
|
+
messages_array = build_messages(messages)
|
|
35
|
+
generation_params = extract_generation_options(options)
|
|
36
|
+
|
|
37
|
+
request_body = build_request_body(messages_array, generation_params)
|
|
38
|
+
response = make_api_request(request_body)
|
|
39
|
+
|
|
40
|
+
parse_response(response)
|
|
41
|
+
rescue => e
|
|
42
|
+
handle_api_error(e)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Build request body for Mistral API
|
|
48
|
+
# @param messages [Array<Hash>] Messages
|
|
49
|
+
# @param generation_params [Hash] Generation parameters
|
|
50
|
+
# @return [Hash] Request body
|
|
51
|
+
def build_request_body(messages, generation_params)
|
|
52
|
+
request = {
|
|
53
|
+
model: @model,
|
|
54
|
+
messages: messages
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Add generation parameters (use nil? to preserve zero values like temperature: 0)
|
|
58
|
+
GENERATION_KEYS.each do |key|
|
|
59
|
+
request[key] = generation_params[key] unless generation_params[key].nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
request
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Make API request to Mistral
|
|
66
|
+
# @param body [Hash] Request body
|
|
67
|
+
# @return [Hash] API response
|
|
68
|
+
def make_api_request(body)
|
|
69
|
+
url = "#{@base_url}/v1/chat/completions"
|
|
70
|
+
|
|
71
|
+
response = @http_client.post(
|
|
72
|
+
url,
|
|
73
|
+
body,
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type" => "application/json",
|
|
76
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
unless response.success?
|
|
81
|
+
_error_type, error_message, status = parse_error_response(response)
|
|
82
|
+
raise Ace::LLM::ProviderError, "Mistral API error (#{status}): #{error_message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
response.body
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Parse error response from API
|
|
89
|
+
# Extracts error details from the response body, handling both JSON (Hash)
|
|
90
|
+
# and non-JSON (String) responses gracefully.
|
|
91
|
+
#
|
|
92
|
+
# Mistral API can return errors in different formats:
|
|
93
|
+
# - {"error": {"message": "...", "type": "..."}} (OpenAI-style)
|
|
94
|
+
# - {"message": "..."} (Mistral-style)
|
|
95
|
+
# - {"error": "simple message"} (flat format)
|
|
96
|
+
#
|
|
97
|
+
# @param response [Faraday::Response] Failed API response
|
|
98
|
+
# @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
|
|
99
|
+
def parse_error_response(response)
|
|
100
|
+
raw_body = response.body
|
|
101
|
+
status = response.status
|
|
102
|
+
|
|
103
|
+
case raw_body
|
|
104
|
+
in Hash => error_body
|
|
105
|
+
# Check for Mistral-style top-level message first
|
|
106
|
+
if error_body["message"].is_a?(String)
|
|
107
|
+
return ["unknown", error_body["message"], status]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
error_obj = error_body["error"]
|
|
111
|
+
case error_obj
|
|
112
|
+
when Hash
|
|
113
|
+
error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
|
|
114
|
+
error_type = error_obj["type"] || "unknown"
|
|
115
|
+
when String
|
|
116
|
+
error_message = error_obj
|
|
117
|
+
error_type = "unknown"
|
|
118
|
+
else
|
|
119
|
+
error_message = build_fallback_error_message(raw_body, status)
|
|
120
|
+
error_type = "unknown"
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
error_message = build_fallback_error_message(raw_body, status)
|
|
124
|
+
error_type = "unknown"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[error_type, error_message, status]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Build fallback error message for non-JSON responses
|
|
131
|
+
#
|
|
132
|
+
# @param raw_body [Object] Raw response body
|
|
133
|
+
# @param status [Integer] HTTP status code
|
|
134
|
+
# @return [String] Human-readable error message
|
|
135
|
+
def build_fallback_error_message(raw_body, status)
|
|
136
|
+
if raw_body.is_a?(String) && !raw_body.empty?
|
|
137
|
+
snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
|
|
138
|
+
snippet += "..." if raw_body.bytesize > 100
|
|
139
|
+
"Non-JSON response: #{snippet}"
|
|
140
|
+
else
|
|
141
|
+
"Unknown error: #{status}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Parse API response
|
|
146
|
+
# @param response [Hash] Raw API response
|
|
147
|
+
# @return [Hash] Parsed response with text and metadata
|
|
148
|
+
def parse_response(response)
|
|
149
|
+
# Extract text from response
|
|
150
|
+
choice = response.dig("choices", 0)
|
|
151
|
+
text = choice.dig("message", "content") if choice
|
|
152
|
+
|
|
153
|
+
unless text
|
|
154
|
+
raise Ace::LLM::ProviderError, "No text in response from Mistral"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Extract metadata
|
|
158
|
+
metadata = {
|
|
159
|
+
finish_reason: choice["finish_reason"],
|
|
160
|
+
id: response["id"],
|
|
161
|
+
created: response["created"]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Add token usage if available
|
|
165
|
+
usage = response["usage"]
|
|
166
|
+
if usage
|
|
167
|
+
metadata[:input_tokens] = usage["prompt_tokens"]
|
|
168
|
+
metadata[:output_tokens] = usage["completion_tokens"]
|
|
169
|
+
metadata[:total_tokens] = usage["total_tokens"]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Add model info
|
|
173
|
+
metadata[:model_used] = response["model"] if response["model"]
|
|
174
|
+
|
|
175
|
+
create_response(text, metadata)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_client"
|
|
4
|
+
require_relative "../molecules/openai_compatible_params"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Organisms
|
|
9
|
+
# OpenAIClient handles interactions with OpenAI's API
|
|
10
|
+
class OpenAIClient < BaseClient
|
|
11
|
+
include Molecules::OpenAICompatibleParams
|
|
12
|
+
|
|
13
|
+
API_BASE_URL = "https://api.openai.com"
|
|
14
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
15
|
+
|
|
16
|
+
# Generation parameters to include in API request
|
|
17
|
+
GENERATION_KEYS = %i[temperature max_tokens top_p frequency_penalty presence_penalty].freeze
|
|
18
|
+
|
|
19
|
+
DEFAULT_GENERATION_CONFIG = {
|
|
20
|
+
temperature: 0.7,
|
|
21
|
+
max_tokens: nil,
|
|
22
|
+
top_p: nil,
|
|
23
|
+
frequency_penalty: nil,
|
|
24
|
+
presence_penalty: nil
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Get the provider name
|
|
28
|
+
# @return [String] Provider name
|
|
29
|
+
def self.provider_name
|
|
30
|
+
"openai"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate a response from OpenAI
|
|
34
|
+
# @param messages [Array<Hash>, String] Messages or prompt
|
|
35
|
+
# @param options [Hash] Generation options
|
|
36
|
+
# @return [Hash] Response with text and metadata
|
|
37
|
+
def generate(messages, **options)
|
|
38
|
+
messages_array = build_messages(messages)
|
|
39
|
+
generation_params = extract_generation_options(options)
|
|
40
|
+
|
|
41
|
+
request_body = build_request_body(messages_array, generation_params)
|
|
42
|
+
response = make_api_request(request_body)
|
|
43
|
+
|
|
44
|
+
parse_response(response)
|
|
45
|
+
rescue => e
|
|
46
|
+
handle_api_error(e)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Build request body for OpenAI API
|
|
52
|
+
# @param messages [Array<Hash>] Messages
|
|
53
|
+
# @param generation_params [Hash] Generation parameters
|
|
54
|
+
# @return [Hash] Request body
|
|
55
|
+
def build_request_body(messages, generation_params)
|
|
56
|
+
# Handle system_append - use shared helper for deep copy and concatenation
|
|
57
|
+
processed_messages = process_messages_with_system_append(
|
|
58
|
+
messages,
|
|
59
|
+
generation_params[:system_append]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
request = {
|
|
63
|
+
model: @model,
|
|
64
|
+
messages: processed_messages
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Add generation parameters (use nil? to preserve zero values like temperature: 0)
|
|
68
|
+
GENERATION_KEYS.each do |key|
|
|
69
|
+
request[key] = generation_params[key] unless generation_params[key].nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add streaming flag (always false for now)
|
|
73
|
+
request[:stream] = false
|
|
74
|
+
|
|
75
|
+
request
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extract generation options including OpenAI-specific ones
|
|
79
|
+
# @param options [Hash] Raw options
|
|
80
|
+
# @return [Hash] Generation parameters
|
|
81
|
+
def extract_generation_options(options)
|
|
82
|
+
gen_opts = super
|
|
83
|
+
|
|
84
|
+
# Add OpenAI-compatible options
|
|
85
|
+
extract_openai_compatible_options(options, gen_opts)
|
|
86
|
+
|
|
87
|
+
gen_opts.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Make API request to OpenAI
|
|
91
|
+
# @param body [Hash] Request body
|
|
92
|
+
# @return [Hash] API response
|
|
93
|
+
def make_api_request(body)
|
|
94
|
+
url = "#{@base_url}/v1/chat/completions"
|
|
95
|
+
|
|
96
|
+
response = @http_client.post(
|
|
97
|
+
url,
|
|
98
|
+
body,
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type" => "application/json",
|
|
101
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
unless response.success?
|
|
106
|
+
error_type, error_message, status = parse_error_response(response)
|
|
107
|
+
raise Ace::LLM::ProviderError, "OpenAI API error (#{status}): #{error_type} - #{error_message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
response.body
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Parse error response from API
|
|
114
|
+
# Extracts error details from the response body, handling both JSON (Hash)
|
|
115
|
+
# and non-JSON (String) responses gracefully.
|
|
116
|
+
#
|
|
117
|
+
# @param response [Faraday::Response] Failed API response
|
|
118
|
+
# @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
|
|
119
|
+
def parse_error_response(response)
|
|
120
|
+
raw_body = response.body
|
|
121
|
+
status = response.status
|
|
122
|
+
|
|
123
|
+
case raw_body
|
|
124
|
+
in Hash => error_body
|
|
125
|
+
error_obj = error_body["error"]
|
|
126
|
+
case error_obj
|
|
127
|
+
when Hash
|
|
128
|
+
error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
|
|
129
|
+
error_type = error_obj["type"] || "unknown"
|
|
130
|
+
when String
|
|
131
|
+
error_message = error_obj
|
|
132
|
+
error_type = "unknown"
|
|
133
|
+
else
|
|
134
|
+
error_message = build_fallback_error_message(raw_body, status)
|
|
135
|
+
error_type = "unknown"
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
error_message = build_fallback_error_message(raw_body, status)
|
|
139
|
+
error_type = "unknown"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[error_type, error_message, status]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build fallback error message for non-JSON responses
|
|
146
|
+
#
|
|
147
|
+
# @param raw_body [Object] Raw response body
|
|
148
|
+
# @param status [Integer] HTTP status code
|
|
149
|
+
# @return [String] Human-readable error message
|
|
150
|
+
def build_fallback_error_message(raw_body, status)
|
|
151
|
+
if raw_body.is_a?(String) && !raw_body.empty?
|
|
152
|
+
snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
|
|
153
|
+
snippet += "..." if raw_body.bytesize > 100
|
|
154
|
+
"Non-JSON response: #{snippet}"
|
|
155
|
+
else
|
|
156
|
+
"Unknown error: #{status}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Parse API response
|
|
161
|
+
# @param response [Hash] Raw API response
|
|
162
|
+
# @return [Hash] Parsed response with text and metadata
|
|
163
|
+
def parse_response(response)
|
|
164
|
+
# Extract text from response
|
|
165
|
+
choice = response.dig("choices", 0)
|
|
166
|
+
text = choice.dig("message", "content") if choice
|
|
167
|
+
|
|
168
|
+
unless text
|
|
169
|
+
raise Ace::LLM::ProviderError, "No text in response from OpenAI"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Extract metadata
|
|
173
|
+
metadata = {
|
|
174
|
+
finish_reason: choice["finish_reason"],
|
|
175
|
+
id: response["id"],
|
|
176
|
+
created: response["created"]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Add token usage if available
|
|
180
|
+
usage = response["usage"]
|
|
181
|
+
if usage
|
|
182
|
+
metadata[:input_tokens] = usage["prompt_tokens"]
|
|
183
|
+
metadata[:output_tokens] = usage["completion_tokens"]
|
|
184
|
+
metadata[:total_tokens] = usage["total_tokens"]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Add model info
|
|
188
|
+
metadata[:model_used] = response["model"] if response["model"]
|
|
189
|
+
|
|
190
|
+
create_response(text, metadata)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|