language-operator 0.0.1 → 0.1.31
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 +4 -4
- data/.rubocop.yml +125 -0
- data/CHANGELOG.md +88 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +82 -0
- data/README.md +3 -11
- data/Rakefile +63 -0
- data/bin/aictl +7 -0
- data/completions/_aictl +232 -0
- data/completions/aictl.bash +121 -0
- data/completions/aictl.fish +114 -0
- data/docs/architecture/agent-runtime.md +585 -0
- data/docs/dsl/SCHEMA_VERSION.md +250 -0
- data/docs/dsl/agent-reference.md +604 -0
- data/docs/dsl/best-practices.md +1078 -0
- data/docs/dsl/chat-endpoints.md +895 -0
- data/docs/dsl/constraints.md +671 -0
- data/docs/dsl/mcp-integration.md +1177 -0
- data/docs/dsl/webhooks.md +932 -0
- data/docs/dsl/workflows.md +744 -0
- data/lib/language_operator/agent/base.rb +110 -0
- data/lib/language_operator/agent/executor.rb +440 -0
- data/lib/language_operator/agent/instrumentation.rb +54 -0
- data/lib/language_operator/agent/metrics_tracker.rb +183 -0
- data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
- data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
- data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
- data/lib/language_operator/agent/safety/content_filter.rb +93 -0
- data/lib/language_operator/agent/safety/manager.rb +207 -0
- data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
- data/lib/language_operator/agent/safety/safe_executor.rb +127 -0
- data/lib/language_operator/agent/scheduler.rb +183 -0
- data/lib/language_operator/agent/telemetry.rb +116 -0
- data/lib/language_operator/agent/web_server.rb +610 -0
- data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
- data/lib/language_operator/agent.rb +149 -0
- data/lib/language_operator/cli/commands/agent.rb +1205 -0
- data/lib/language_operator/cli/commands/cluster.rb +371 -0
- data/lib/language_operator/cli/commands/install.rb +404 -0
- data/lib/language_operator/cli/commands/model.rb +266 -0
- data/lib/language_operator/cli/commands/persona.rb +393 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +143 -0
- data/lib/language_operator/cli/commands/system.rb +772 -0
- data/lib/language_operator/cli/commands/tool.rb +537 -0
- data/lib/language_operator/cli/commands/use.rb +47 -0
- data/lib/language_operator/cli/errors/handler.rb +180 -0
- data/lib/language_operator/cli/errors/suggestions.rb +176 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +77 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +288 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +49 -0
- data/lib/language_operator/cli/formatters/status_formatter.rb +37 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +163 -0
- data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
- data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
- data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
- data/lib/language_operator/cli/helpers/pastel_helper.rb +24 -0
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
- data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
- data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
- data/lib/language_operator/cli/main.rb +236 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
- data/lib/language_operator/client/base.rb +214 -0
- data/lib/language_operator/client/config.rb +136 -0
- data/lib/language_operator/client/cost_calculator.rb +37 -0
- data/lib/language_operator/client/mcp_connector.rb +123 -0
- data/lib/language_operator/client.rb +19 -0
- data/lib/language_operator/config/cluster_config.rb +101 -0
- data/lib/language_operator/config/tool_patterns.yaml +57 -0
- data/lib/language_operator/config/tool_registry.rb +96 -0
- data/lib/language_operator/config.rb +138 -0
- data/lib/language_operator/dsl/adapter.rb +124 -0
- data/lib/language_operator/dsl/agent_context.rb +90 -0
- data/lib/language_operator/dsl/agent_definition.rb +427 -0
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
- data/lib/language_operator/dsl/config.rb +119 -0
- data/lib/language_operator/dsl/context.rb +50 -0
- data/lib/language_operator/dsl/execution_context.rb +47 -0
- data/lib/language_operator/dsl/helpers.rb +109 -0
- data/lib/language_operator/dsl/http.rb +184 -0
- data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
- data/lib/language_operator/dsl/parameter_definition.rb +124 -0
- data/lib/language_operator/dsl/registry.rb +36 -0
- data/lib/language_operator/dsl/schema.rb +1102 -0
- data/lib/language_operator/dsl/shell.rb +125 -0
- data/lib/language_operator/dsl/tool_definition.rb +112 -0
- data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
- data/lib/language_operator/dsl/webhook_definition.rb +106 -0
- data/lib/language_operator/dsl/workflow_definition.rb +259 -0
- data/lib/language_operator/dsl.rb +161 -0
- data/lib/language_operator/errors.rb +60 -0
- data/lib/language_operator/kubernetes/client.rb +279 -0
- data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
- data/lib/language_operator/loggable.rb +47 -0
- data/lib/language_operator/logger.rb +141 -0
- data/lib/language_operator/retry.rb +123 -0
- data/lib/language_operator/retryable.rb +132 -0
- data/lib/language_operator/templates/README.md +23 -0
- data/lib/language_operator/templates/examples/agent_synthesis.tmpl +115 -0
- data/lib/language_operator/templates/examples/persona_distillation.tmpl +19 -0
- data/lib/language_operator/templates/schema/.gitkeep +0 -0
- data/lib/language_operator/templates/schema/CHANGELOG.md +93 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +306 -0
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +452 -0
- data/lib/language_operator/tool_loader.rb +242 -0
- data/lib/language_operator/validators.rb +170 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +65 -3
- data/requirements/tasks/challenge.md +9 -0
- data/requirements/tasks/iterate.md +36 -0
- data/requirements/tasks/optimize.md +21 -0
- data/requirements/tasks/tag.md +5 -0
- data/test_agent_dsl.rb +108 -0
- metadata +507 -20
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'rackup'
|
|
5
|
+
require 'mcp'
|
|
6
|
+
|
|
7
|
+
module LanguageOperator
|
|
8
|
+
module Agent
|
|
9
|
+
# Web Server for Reactive Agents
|
|
10
|
+
#
|
|
11
|
+
# Enables agents to receive HTTP requests (webhooks, API calls) and respond
|
|
12
|
+
# to them. Agents in :reactive mode run an HTTP server that listens for
|
|
13
|
+
# incoming requests and triggers agent execution.
|
|
14
|
+
#
|
|
15
|
+
# @example Starting a web server for an agent
|
|
16
|
+
# server = LanguageOperator::Agent::WebServer.new(agent)
|
|
17
|
+
# server.start
|
|
18
|
+
class WebServer
|
|
19
|
+
attr_reader :agent, :port
|
|
20
|
+
|
|
21
|
+
# Initialize the web server
|
|
22
|
+
#
|
|
23
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
24
|
+
# @param port [Integer] Port to listen on (default: ENV['PORT'] || 8080)
|
|
25
|
+
def initialize(agent, port: nil)
|
|
26
|
+
@agent = agent
|
|
27
|
+
@port = port || ENV.fetch('PORT', '8080').to_i
|
|
28
|
+
@routes = {}
|
|
29
|
+
@executor = Executor.new(agent)
|
|
30
|
+
@mcp_server = nil
|
|
31
|
+
@mcp_transport = nil
|
|
32
|
+
|
|
33
|
+
setup_default_routes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Start the HTTP server
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def start
|
|
40
|
+
puts "Starting Agent HTTP server on http://0.0.0.0:#{@port}"
|
|
41
|
+
puts "Agent: #{@agent.class.name}"
|
|
42
|
+
puts 'Mode: reactive'
|
|
43
|
+
|
|
44
|
+
# Start the server with Puma
|
|
45
|
+
Rackup::Handler.get('puma').run(rack_app, Port: @port, Host: '0.0.0.0')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Register a webhook route
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] The URL path
|
|
51
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete, :patch)
|
|
52
|
+
# @param authentication [LanguageOperator::Dsl::WebhookAuthentication, nil] Authentication configuration
|
|
53
|
+
# @param validations [Array<Hash>, nil] Validation rules
|
|
54
|
+
# @param handler [Proc] Request handler block
|
|
55
|
+
# @return [void]
|
|
56
|
+
def register_route(path, method: :post, authentication: nil, validations: nil, &handler)
|
|
57
|
+
@routes[normalize_route_key(path, method)] = {
|
|
58
|
+
handler: handler,
|
|
59
|
+
authentication: authentication,
|
|
60
|
+
validations: validations || []
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if a route exists
|
|
65
|
+
#
|
|
66
|
+
# @param path [String] The URL path
|
|
67
|
+
# @param method [Symbol] HTTP method
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def route_exists?(path, method)
|
|
70
|
+
@routes.key?(normalize_route_key(path, method))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Register MCP tools
|
|
74
|
+
#
|
|
75
|
+
# Sets up MCP protocol endpoints for tool discovery and execution.
|
|
76
|
+
# Tools defined in the agent will be exposed via MCP protocol.
|
|
77
|
+
#
|
|
78
|
+
# @param mcp_server_def [LanguageOperator::Dsl::McpServerDefinition] MCP server definition
|
|
79
|
+
# @return [void]
|
|
80
|
+
def register_mcp_tools(mcp_server_def)
|
|
81
|
+
require_relative '../dsl/adapter'
|
|
82
|
+
|
|
83
|
+
# Convert tool definitions to MCP::Tool classes
|
|
84
|
+
mcp_tools = mcp_server_def.all_tools.map do |tool_def|
|
|
85
|
+
Dsl::Adapter.tool_definition_to_mcp_tool(tool_def)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create MCP server
|
|
89
|
+
@mcp_server = MCP::Server.new(
|
|
90
|
+
name: mcp_server_def.server_name,
|
|
91
|
+
version: LanguageOperator::VERSION,
|
|
92
|
+
tools: mcp_tools
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Create the Streamable HTTP transport
|
|
96
|
+
@mcp_transport = MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server)
|
|
97
|
+
@mcp_server.transport = @mcp_transport
|
|
98
|
+
|
|
99
|
+
# Register MCP endpoint
|
|
100
|
+
register_route('/mcp', method: :post) do |context|
|
|
101
|
+
handle_mcp_request(context[:request])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "Registered #{mcp_tools.size} MCP tools"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Register chat completion endpoint
|
|
108
|
+
#
|
|
109
|
+
# Sets up OpenAI-compatible chat completion endpoint.
|
|
110
|
+
# Agents can be used as drop-in LLM replacements.
|
|
111
|
+
#
|
|
112
|
+
# @param chat_endpoint_def [LanguageOperator::Dsl::ChatEndpointDefinition] Chat endpoint definition
|
|
113
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
114
|
+
# @return [void]
|
|
115
|
+
def register_chat_endpoint(chat_endpoint_def, agent)
|
|
116
|
+
@chat_endpoint = chat_endpoint_def
|
|
117
|
+
@chat_agent = agent
|
|
118
|
+
|
|
119
|
+
# Register OpenAI-compatible endpoint
|
|
120
|
+
register_route('/v1/chat/completions', method: :post) do |context|
|
|
121
|
+
handle_chat_completion(context)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Also register models endpoint for compatibility
|
|
125
|
+
register_route('/v1/models', method: :get) do |_context|
|
|
126
|
+
{
|
|
127
|
+
object: 'list',
|
|
128
|
+
data: [
|
|
129
|
+
{
|
|
130
|
+
id: chat_endpoint_def.model_name,
|
|
131
|
+
object: 'model',
|
|
132
|
+
created: Time.now.to_i,
|
|
133
|
+
owned_by: 'language-operator',
|
|
134
|
+
permission: [],
|
|
135
|
+
root: chat_endpoint_def.model_name,
|
|
136
|
+
parent: nil
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
puts "Registered chat completion endpoint as model: #{chat_endpoint_def.model_name}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Handle incoming HTTP request
|
|
146
|
+
#
|
|
147
|
+
# @param env [Hash] Rack environment
|
|
148
|
+
# @return [Array] Rack response [status, headers, body]
|
|
149
|
+
def handle_request(env)
|
|
150
|
+
request = Rack::Request.new(env)
|
|
151
|
+
path = request.path
|
|
152
|
+
method = request.request_method.downcase.to_sym
|
|
153
|
+
|
|
154
|
+
# Try to find a matching route
|
|
155
|
+
route_key = normalize_route_key(path, method)
|
|
156
|
+
route_config = @routes[route_key]
|
|
157
|
+
|
|
158
|
+
if route_config
|
|
159
|
+
execute_handler(route_config, request)
|
|
160
|
+
else
|
|
161
|
+
not_found_response(path, method)
|
|
162
|
+
end
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
error_response(e)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Build the Rack application
|
|
170
|
+
#
|
|
171
|
+
# @return [Rack::Builder]
|
|
172
|
+
def rack_app
|
|
173
|
+
server = self
|
|
174
|
+
|
|
175
|
+
Rack::Builder.new do
|
|
176
|
+
use Rack::CommonLogger
|
|
177
|
+
use Rack::ShowExceptions
|
|
178
|
+
use Rack::ContentLength
|
|
179
|
+
|
|
180
|
+
run ->(env) { server.handle_request(env) }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Execute a route handler
|
|
185
|
+
#
|
|
186
|
+
# @param route_config [Hash, Proc] Route configuration or legacy handler proc
|
|
187
|
+
# @param request [Rack::Request] The request
|
|
188
|
+
# @return [Array] Rack response
|
|
189
|
+
def execute_handler(route_config, request)
|
|
190
|
+
require_relative 'webhook_authenticator'
|
|
191
|
+
|
|
192
|
+
# Support legacy handler-only format
|
|
193
|
+
route_config = { handler: route_config, authentication: nil, validations: [] } if route_config.is_a?(Proc)
|
|
194
|
+
|
|
195
|
+
handler = route_config[:handler]
|
|
196
|
+
authentication = route_config[:authentication]
|
|
197
|
+
validations = route_config[:validations]
|
|
198
|
+
|
|
199
|
+
# Build request context
|
|
200
|
+
context = build_request_context(request)
|
|
201
|
+
|
|
202
|
+
# Perform authentication
|
|
203
|
+
if authentication
|
|
204
|
+
authenticated = WebhookAuthenticator.authenticate(authentication, context)
|
|
205
|
+
unless authenticated
|
|
206
|
+
return [
|
|
207
|
+
401,
|
|
208
|
+
{ 'Content-Type' => 'application/json' },
|
|
209
|
+
[JSON.generate({ error: 'Unauthorized', message: 'Authentication failed' })]
|
|
210
|
+
]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Perform validations
|
|
215
|
+
unless validations.empty?
|
|
216
|
+
validation_errors = WebhookAuthenticator.validate(validations, context)
|
|
217
|
+
unless validation_errors.empty?
|
|
218
|
+
return [
|
|
219
|
+
400,
|
|
220
|
+
{ 'Content-Type' => 'application/json' },
|
|
221
|
+
[JSON.generate({ error: 'Bad Request', message: 'Validation failed', errors: validation_errors })]
|
|
222
|
+
]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Execute handler (could be async)
|
|
227
|
+
result = handler.call(context)
|
|
228
|
+
|
|
229
|
+
# Build response
|
|
230
|
+
success_response(result)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Build request context for handlers
|
|
234
|
+
#
|
|
235
|
+
# @param request [Rack::Request] The request
|
|
236
|
+
# @return [Hash] Request context
|
|
237
|
+
def build_request_context(request)
|
|
238
|
+
# Read body, handling nil case
|
|
239
|
+
body_content = if request.body
|
|
240
|
+
request.body.read
|
|
241
|
+
else
|
|
242
|
+
''
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
path: request.path,
|
|
247
|
+
method: request.request_method,
|
|
248
|
+
headers: extract_headers(request),
|
|
249
|
+
params: request.params,
|
|
250
|
+
body: body_content,
|
|
251
|
+
request: request
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Extract relevant headers from request
|
|
256
|
+
#
|
|
257
|
+
# @param request [Rack::Request] The request
|
|
258
|
+
# @return [Hash] Headers hash
|
|
259
|
+
def extract_headers(request)
|
|
260
|
+
headers = {}
|
|
261
|
+
request.each_header do |key, value|
|
|
262
|
+
# Convert HTTP_HEADER_NAME to Header-Name
|
|
263
|
+
if key.start_with?('HTTP_')
|
|
264
|
+
# Strip HTTP_ prefix and normalize
|
|
265
|
+
header_name = key[5..].split('_').map(&:capitalize).join('-')
|
|
266
|
+
headers[header_name] = value
|
|
267
|
+
# Also include standard CGI headers like CONTENT_TYPE, CONTENT_LENGTH
|
|
268
|
+
# But only if not already set by HTTP_ version (HTTP_CONTENT_TYPE takes precedence)
|
|
269
|
+
elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
|
|
270
|
+
header_name = key.split('_').map(&:capitalize).join('-')
|
|
271
|
+
headers[header_name] ||= value # Only set if not already present
|
|
272
|
+
# In test environment (Rack::Test), headers may come through as-is
|
|
273
|
+
elsif key.include?('-') || key.start_with?('X-', 'Authorization')
|
|
274
|
+
headers[key] = value
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
headers
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Setup default routes
|
|
281
|
+
#
|
|
282
|
+
# @return [void]
|
|
283
|
+
def setup_default_routes
|
|
284
|
+
# Health check endpoint
|
|
285
|
+
register_route('/health', method: :get) do |_context|
|
|
286
|
+
{ status: 'healthy', agent: @agent.class.name }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Ready check endpoint
|
|
290
|
+
register_route('/ready', method: :get) do |_context|
|
|
291
|
+
{
|
|
292
|
+
status: @agent.workspace_available? ? 'ready' : 'not_ready',
|
|
293
|
+
workspace: @agent.workspace_path
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Default webhook endpoint (fallback)
|
|
298
|
+
register_route('/webhook', method: :post) do |context|
|
|
299
|
+
handle_webhook(context)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Handle webhook request by executing agent
|
|
304
|
+
#
|
|
305
|
+
# @param context [Hash] Request context
|
|
306
|
+
# @return [Hash] Response data
|
|
307
|
+
def handle_webhook(context)
|
|
308
|
+
# Execute agent with webhook context
|
|
309
|
+
result = @executor.execute_with_context(
|
|
310
|
+
instruction: 'Process incoming webhook request',
|
|
311
|
+
context: context
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
status: 'processed',
|
|
316
|
+
result: result,
|
|
317
|
+
timestamp: Time.now.iso8601
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Handle MCP protocol request
|
|
322
|
+
#
|
|
323
|
+
# @param request [Rack::Request] The request
|
|
324
|
+
# @return [Hash] Response data (will be converted to Rack response by transport)
|
|
325
|
+
def handle_mcp_request(request)
|
|
326
|
+
return { error: 'MCP server not initialized' } unless @mcp_transport
|
|
327
|
+
|
|
328
|
+
# The transport handles the MCP protocol
|
|
329
|
+
@mcp_transport.handle_request(request)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Handle chat completion request
|
|
333
|
+
#
|
|
334
|
+
# @param context [Hash] Request context
|
|
335
|
+
# @return [Array, Hash] Rack response or hash for streaming
|
|
336
|
+
def handle_chat_completion(context)
|
|
337
|
+
return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_endpoint
|
|
338
|
+
|
|
339
|
+
# Parse request body
|
|
340
|
+
request_data = JSON.parse(context[:body])
|
|
341
|
+
|
|
342
|
+
# Check if streaming is requested
|
|
343
|
+
if request_data['stream']
|
|
344
|
+
handle_streaming_chat(request_data, context[:request])
|
|
345
|
+
else
|
|
346
|
+
handle_non_streaming_chat(request_data)
|
|
347
|
+
end
|
|
348
|
+
rescue JSON::ParserError => e
|
|
349
|
+
error_response(StandardError.new("Invalid JSON: #{e.message}"))
|
|
350
|
+
rescue StandardError => e
|
|
351
|
+
error_response(e)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Handle non-streaming chat completion
|
|
355
|
+
#
|
|
356
|
+
# @param request_data [Hash] Parsed request data
|
|
357
|
+
# @return [Hash] Chat completion response
|
|
358
|
+
def handle_non_streaming_chat(request_data)
|
|
359
|
+
messages = request_data['messages'] || []
|
|
360
|
+
|
|
361
|
+
# Build prompt from messages
|
|
362
|
+
prompt = build_prompt_from_messages(messages)
|
|
363
|
+
|
|
364
|
+
# Execute agent
|
|
365
|
+
result = @chat_agent.execute(prompt)
|
|
366
|
+
|
|
367
|
+
# Build OpenAI-compatible response
|
|
368
|
+
{
|
|
369
|
+
id: "chatcmpl-#{SecureRandom.hex(12)}",
|
|
370
|
+
object: 'chat.completion',
|
|
371
|
+
created: Time.now.to_i,
|
|
372
|
+
model: @chat_endpoint.model_name,
|
|
373
|
+
choices: [
|
|
374
|
+
{
|
|
375
|
+
index: 0,
|
|
376
|
+
message: {
|
|
377
|
+
role: 'assistant',
|
|
378
|
+
content: result
|
|
379
|
+
},
|
|
380
|
+
finish_reason: 'stop'
|
|
381
|
+
}
|
|
382
|
+
],
|
|
383
|
+
usage: {
|
|
384
|
+
prompt_tokens: estimate_tokens(prompt),
|
|
385
|
+
completion_tokens: estimate_tokens(result),
|
|
386
|
+
total_tokens: estimate_tokens(prompt) + estimate_tokens(result)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Handle streaming chat completion
|
|
392
|
+
#
|
|
393
|
+
# @param request_data [Hash] Parsed request data
|
|
394
|
+
# @param request [Rack::Request] The Rack request
|
|
395
|
+
# @return [Array] Rack streaming response
|
|
396
|
+
def handle_streaming_chat(request_data, _request)
|
|
397
|
+
messages = request_data['messages'] || []
|
|
398
|
+
prompt = build_prompt_from_messages(messages)
|
|
399
|
+
|
|
400
|
+
# Return a streaming response
|
|
401
|
+
[
|
|
402
|
+
200,
|
|
403
|
+
{
|
|
404
|
+
'Content-Type' => 'text/event-stream',
|
|
405
|
+
'Cache-Control' => 'no-cache',
|
|
406
|
+
'Connection' => 'keep-alive'
|
|
407
|
+
},
|
|
408
|
+
StreamingBody.new(@chat_agent, prompt, @chat_endpoint.model_name)
|
|
409
|
+
]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Build prompt from OpenAI message format
|
|
413
|
+
#
|
|
414
|
+
# @param messages [Array<Hash>] Array of message objects
|
|
415
|
+
# @return [String] Combined prompt
|
|
416
|
+
def build_prompt_from_messages(messages)
|
|
417
|
+
# Combine all messages into a single prompt
|
|
418
|
+
# System messages become instructions
|
|
419
|
+
# User/assistant messages become conversation
|
|
420
|
+
prompt_parts = []
|
|
421
|
+
|
|
422
|
+
# Add system prompt if configured
|
|
423
|
+
prompt_parts << "System: #{@chat_endpoint.system_prompt}" if @chat_endpoint.system_prompt
|
|
424
|
+
|
|
425
|
+
# Add conversation history
|
|
426
|
+
messages.each do |msg|
|
|
427
|
+
role = msg['role']
|
|
428
|
+
content = msg['content']
|
|
429
|
+
|
|
430
|
+
case role
|
|
431
|
+
when 'system'
|
|
432
|
+
prompt_parts << "System: #{content}"
|
|
433
|
+
when 'user'
|
|
434
|
+
prompt_parts << "User: #{content}"
|
|
435
|
+
when 'assistant'
|
|
436
|
+
prompt_parts << "Assistant: #{content}"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
prompt_parts.join("\n\n")
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Estimate token count (rough approximation)
|
|
444
|
+
#
|
|
445
|
+
# @param text [String] Text to estimate
|
|
446
|
+
# @return [Integer] Estimated token count
|
|
447
|
+
def estimate_tokens(text)
|
|
448
|
+
# Rough approximation: 1 token ≈ 4 characters
|
|
449
|
+
(text.length / 4.0).ceil
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Build success response
|
|
453
|
+
#
|
|
454
|
+
# @param data [Hash, String] Response data
|
|
455
|
+
# @return [Array] Rack response
|
|
456
|
+
def success_response(data)
|
|
457
|
+
# If data is already a Rack response tuple, return as-is
|
|
458
|
+
return data if data.is_a?(Array) && data.length == 3 && data[0].is_a?(Integer)
|
|
459
|
+
|
|
460
|
+
body = data.is_a?(Hash) ? JSON.generate(data) : data.to_s
|
|
461
|
+
|
|
462
|
+
[
|
|
463
|
+
200,
|
|
464
|
+
{ 'Content-Type' => 'application/json' },
|
|
465
|
+
[body]
|
|
466
|
+
]
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Build not found response
|
|
470
|
+
#
|
|
471
|
+
# @param path [String] Request path
|
|
472
|
+
# @param method [Symbol] HTTP method
|
|
473
|
+
# @return [Array] Rack response
|
|
474
|
+
def not_found_response(path, method)
|
|
475
|
+
[
|
|
476
|
+
404,
|
|
477
|
+
{ 'Content-Type' => 'application/json' },
|
|
478
|
+
[JSON.generate({
|
|
479
|
+
error: 'Not Found',
|
|
480
|
+
message: "No route for #{method.upcase} #{path}",
|
|
481
|
+
available_routes: @routes.keys
|
|
482
|
+
})]
|
|
483
|
+
]
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Build error response
|
|
487
|
+
#
|
|
488
|
+
# @param error [Exception] The error
|
|
489
|
+
# @return [Array] Rack response
|
|
490
|
+
def error_response(error)
|
|
491
|
+
[
|
|
492
|
+
500,
|
|
493
|
+
{ 'Content-Type' => 'application/json' },
|
|
494
|
+
[JSON.generate({
|
|
495
|
+
error: error.class.name,
|
|
496
|
+
message: error.message,
|
|
497
|
+
backtrace: error.backtrace&.first(5)
|
|
498
|
+
})]
|
|
499
|
+
]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Normalize route key for storage/lookup
|
|
503
|
+
#
|
|
504
|
+
# @param path [String] URL path
|
|
505
|
+
# @param method [Symbol] HTTP method
|
|
506
|
+
# @return [String] Normalized key
|
|
507
|
+
def normalize_route_key(path, method)
|
|
508
|
+
"#{method.to_s.upcase} #{path}"
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Streaming body for Server-Sent Events (SSE)
|
|
513
|
+
#
|
|
514
|
+
# Implements the Rack streaming protocol for chat completion responses.
|
|
515
|
+
# Streams agent output as it's generated.
|
|
516
|
+
class StreamingBody
|
|
517
|
+
def initialize(agent, prompt, model_name)
|
|
518
|
+
@agent = agent
|
|
519
|
+
@prompt = prompt
|
|
520
|
+
@model_name = model_name
|
|
521
|
+
@id = "chatcmpl-#{SecureRandom.hex(12)}"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Implement each for Rack::Test compatibility
|
|
525
|
+
#
|
|
526
|
+
# @yield [String] Each chunk of data
|
|
527
|
+
# @return [void]
|
|
528
|
+
def each
|
|
529
|
+
buffer = StringIO.new
|
|
530
|
+
stream = MockStream.new(buffer)
|
|
531
|
+
call(stream)
|
|
532
|
+
yield buffer.string
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Mock stream for testing
|
|
536
|
+
class MockStream
|
|
537
|
+
def initialize(buffer)
|
|
538
|
+
@buffer = buffer
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def write(data)
|
|
542
|
+
@buffer.write(data)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def close
|
|
546
|
+
# No-op
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Called by Rack to stream the response
|
|
551
|
+
#
|
|
552
|
+
# @param stream [Object] The stream object
|
|
553
|
+
# @return [void]
|
|
554
|
+
def call(stream)
|
|
555
|
+
# Execute agent and stream response
|
|
556
|
+
result = @agent.execute(@prompt)
|
|
557
|
+
|
|
558
|
+
# Send the result as a single chunk (for simplicity)
|
|
559
|
+
# In a real implementation, this could stream token-by-token
|
|
560
|
+
chunk = {
|
|
561
|
+
id: @id,
|
|
562
|
+
object: 'chat.completion.chunk',
|
|
563
|
+
created: Time.now.to_i,
|
|
564
|
+
model: @model_name,
|
|
565
|
+
choices: [
|
|
566
|
+
{
|
|
567
|
+
index: 0,
|
|
568
|
+
delta: {
|
|
569
|
+
role: 'assistant',
|
|
570
|
+
content: result
|
|
571
|
+
},
|
|
572
|
+
finish_reason: nil
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
stream.write("data: #{JSON.generate(chunk)}\n\n")
|
|
578
|
+
|
|
579
|
+
# Send final chunk with finish_reason
|
|
580
|
+
final_chunk = {
|
|
581
|
+
id: @id,
|
|
582
|
+
object: 'chat.completion.chunk',
|
|
583
|
+
created: Time.now.to_i,
|
|
584
|
+
model: @model_name,
|
|
585
|
+
choices: [
|
|
586
|
+
{
|
|
587
|
+
index: 0,
|
|
588
|
+
delta: {},
|
|
589
|
+
finish_reason: 'stop'
|
|
590
|
+
}
|
|
591
|
+
]
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
stream.write("data: #{JSON.generate(final_chunk)}\n\n")
|
|
595
|
+
stream.write("data: [DONE]\n\n")
|
|
596
|
+
rescue StandardError => e
|
|
597
|
+
error_chunk = {
|
|
598
|
+
error: {
|
|
599
|
+
message: e.message,
|
|
600
|
+
type: 'server_error',
|
|
601
|
+
code: nil
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
stream.write("data: #{JSON.generate(error_chunk)}\n\n")
|
|
605
|
+
ensure
|
|
606
|
+
stream.close
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|