whoosh 1.2.2 → 1.3.1
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/README.md +76 -12
- data/lib/whoosh/ai/llm.rb +139 -0
- data/lib/whoosh/ai/structured_output.rb +31 -0
- data/lib/whoosh/ai.rb +18 -0
- data/lib/whoosh/app.rb +17 -1
- data/lib/whoosh/cli/main.rb +170 -6
- data/lib/whoosh/cli/project_generator.rb +175 -0
- data/lib/whoosh/streaming/websocket.rb +157 -13
- data/lib/whoosh/version.rb +1 -1
- data/lib/whoosh.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99edefedd7d17b1c477fcd07d7ba72b317560d670706ec1361c197df013a9834
|
|
4
|
+
data.tar.gz: d94a866bf3e588e64591b1e1e32e62db4b5fae74511420b5942e42a04eb1dc8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2d1793270458869874e09f720c053eb3625b2943323bd1ff24a9e9620bebd3cbe9705fb1bde81cfe60211084f8d7a0862407548f1699a5f5a25c4bb392f764b
|
|
7
|
+
data.tar.gz: f86e8837a57fcbee513a514d6bf32d2901833187f038cd64d0b8bfb71897a97507c65ee5297af3ce92e940ee697d0443165efeda980a8fb1d8b3cb334a4ae89b
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
|
|
14
14
|
<img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
|
16
|
-
<img src="https://img.shields.io/badge/tests-
|
|
16
|
+
<img src="https://img.shields.io/badge/tests-540%20passing-brightgreen" alt="Tests">
|
|
17
17
|
<img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
|
|
22
22
|
## Why Whoosh?
|
|
23
23
|
|
|
24
|
-
- **AI-first** — MCP server
|
|
25
|
-
- **Fast** — 2.5µs framework overhead,
|
|
26
|
-
- **Batteries included** — Auth, rate limiting, caching, background jobs, file uploads, pagination, metrics
|
|
27
|
-
- **Zero config to start** — `whoosh new myapp && cd myapp && whoosh s`
|
|
28
|
-
- **
|
|
24
|
+
- **AI-first** — Every app is an MCP server automatically. LLM wrapper with structured output, caching, and streaming. Vector search built-in. 18+ AI gem auto-discovery.
|
|
25
|
+
- **Fast** — 2.5µs framework overhead, 87K req/s with Falcon. Beats Fastify on multi-worker PostgreSQL benchmarks.
|
|
26
|
+
- **Batteries included** — Auth, rate limiting, caching, background jobs, file uploads, vector search, pagination, metrics, CI pipeline.
|
|
27
|
+
- **Zero config to start** — `whoosh new myapp && cd myapp && whoosh s` — everything works.
|
|
28
|
+
- **AI-agent friendly** — `whoosh describe` dumps your app as JSON for AI tools. Generated `CLAUDE.md` in every project. `whoosh check` catches mistakes before runtime.
|
|
29
29
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
@@ -130,6 +130,45 @@ app.access_control do
|
|
|
130
130
|
end
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
### AI / LLM Integration
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# Chat with any LLM (auto-detects ruby_llm gem)
|
|
137
|
+
app.post "/chat" do |req, llm:|
|
|
138
|
+
{ reply: llm.chat(req.body["message"]) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Structured output — LLM returns validated JSON
|
|
142
|
+
app.post "/extract" do |req, llm:|
|
|
143
|
+
data = llm.extract(req.body["text"], schema: InvoiceSchema)
|
|
144
|
+
{ invoice: data }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# RAG in 3 lines
|
|
148
|
+
app.post "/ask" do |req, vectors:, llm:|
|
|
149
|
+
context = vectors.search("knowledge", vector: embed(req.body["q"]), limit: 5)
|
|
150
|
+
{ answer: llm.chat(req.body["q"], system: "Context: #{context}") }
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Vector Search
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
app.post "/index" do |req, vectors:|
|
|
158
|
+
vectors.insert("docs", id: req.body["id"],
|
|
159
|
+
vector: req.body["embedding"],
|
|
160
|
+
metadata: { title: req.body["title"] })
|
|
161
|
+
{ indexed: true }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
app.post "/search" do |req, vectors:|
|
|
165
|
+
results = vectors.search("docs", vector: req.body["embedding"], limit: 10)
|
|
166
|
+
{ results: results }
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
In-memory by default (cosine similarity). Install `zvec` gem for production-grade HNSW index.
|
|
171
|
+
|
|
133
172
|
### LLM Streaming (OpenAI-compatible)
|
|
134
173
|
|
|
135
174
|
```ruby
|
|
@@ -153,16 +192,21 @@ end
|
|
|
153
192
|
|
|
154
193
|
### MCP (Model Context Protocol)
|
|
155
194
|
|
|
195
|
+
**Every route is automatically an MCP tool.** No `mcp: true` needed.
|
|
196
|
+
|
|
156
197
|
```ruby
|
|
157
|
-
#
|
|
158
|
-
app.post "/summarize",
|
|
198
|
+
# These are all MCP tools automatically:
|
|
199
|
+
app.post "/summarize", request: SummarizeRequest do |req|
|
|
159
200
|
{ summary: llm.summarize(req.body[:text]) }
|
|
160
201
|
end
|
|
161
202
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
203
|
+
app.post "/translate" do |req|
|
|
204
|
+
{ result: translate(req.body["text"]) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Opt OUT with mcp: false for internal routes:
|
|
208
|
+
app.get "/internal", mcp: false do
|
|
209
|
+
{ debug: "not exposed as MCP tool" }
|
|
166
210
|
end
|
|
167
211
|
```
|
|
168
212
|
|
|
@@ -319,9 +363,13 @@ whoosh new my_api # scaffold project (with Dockerfile)
|
|
|
319
363
|
whoosh s # start server (like rails s)
|
|
320
364
|
whoosh s --reload # hot reload on file changes
|
|
321
365
|
whoosh routes # list all routes
|
|
366
|
+
whoosh describe # dump app as JSON (AI-friendly)
|
|
367
|
+
whoosh check # validate config, catch mistakes
|
|
322
368
|
whoosh console # IRB with app loaded
|
|
369
|
+
whoosh ci # lint + security + audit + tests + coverage
|
|
323
370
|
whoosh worker # background job worker
|
|
324
371
|
whoosh mcp # MCP stdio server
|
|
372
|
+
whoosh mcp --list # list all MCP tools
|
|
325
373
|
|
|
326
374
|
whoosh generate endpoint chat # endpoint + schema + test
|
|
327
375
|
whoosh generate schema User # schema file
|
|
@@ -335,6 +383,22 @@ whoosh db rollback # rollback
|
|
|
335
383
|
whoosh db status # migration status
|
|
336
384
|
```
|
|
337
385
|
|
|
386
|
+
## AI Agent DX
|
|
387
|
+
|
|
388
|
+
Every `whoosh new` project includes a `CLAUDE.md` with all framework patterns, commands, and conventions — so AI agents (Claude Code, Cursor, Copilot) can build with Whoosh immediately.
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
# Dump your entire app structure as JSON (routes, schemas, config, MCP tools)
|
|
392
|
+
whoosh describe
|
|
393
|
+
|
|
394
|
+
# AI tools can consume this to understand your API
|
|
395
|
+
whoosh describe --routes # routes with request/response schemas
|
|
396
|
+
whoosh describe --schemas # all schema definitions
|
|
397
|
+
|
|
398
|
+
# Catch mistakes before runtime
|
|
399
|
+
whoosh check # validates config, auth, dependencies
|
|
400
|
+
```
|
|
401
|
+
|
|
338
402
|
## Performance
|
|
339
403
|
|
|
340
404
|
### HTTP Benchmark: `GET /health → {"status":"ok"}`
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module AI
|
|
5
|
+
class LLM
|
|
6
|
+
attr_reader :provider, :model
|
|
7
|
+
|
|
8
|
+
def initialize(provider: "auto", model: nil, cache_enabled: true)
|
|
9
|
+
@provider = provider
|
|
10
|
+
@model = model
|
|
11
|
+
@cache_enabled = cache_enabled
|
|
12
|
+
@cache = cache_enabled ? {} : nil
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@ruby_llm = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Chat with an LLM — returns response text
|
|
18
|
+
def chat(message, model: nil, system: nil, max_tokens: nil, temperature: nil, cache: nil)
|
|
19
|
+
use_cache = cache.nil? ? @cache_enabled : cache
|
|
20
|
+
cache_key = "chat:#{model || @model}:#{message}" if use_cache
|
|
21
|
+
|
|
22
|
+
# Check cache
|
|
23
|
+
if use_cache && @cache && (cached = @cache[cache_key])
|
|
24
|
+
return cached
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = call_llm(
|
|
28
|
+
messages: [{ role: "user", content: message }],
|
|
29
|
+
model: model || @model,
|
|
30
|
+
system: system,
|
|
31
|
+
max_tokens: max_tokens,
|
|
32
|
+
temperature: temperature
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Cache result
|
|
36
|
+
if use_cache && @cache
|
|
37
|
+
@mutex.synchronize { @cache[cache_key] = result }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Extract structured data — returns validated hash
|
|
44
|
+
def extract(text, schema:, model: nil, prompt: nil)
|
|
45
|
+
schema_desc = describe_schema(schema)
|
|
46
|
+
system_prompt = prompt || "Extract structured data from the text. Return ONLY valid JSON matching this schema:\n#{schema_desc}"
|
|
47
|
+
|
|
48
|
+
response = chat(text, model: model, system: system_prompt, cache: false)
|
|
49
|
+
|
|
50
|
+
# Parse JSON from LLM response
|
|
51
|
+
json_str = extract_json(response)
|
|
52
|
+
parsed = Serialization::Json.decode(json_str)
|
|
53
|
+
|
|
54
|
+
# Validate against schema
|
|
55
|
+
result = schema.validate(parsed)
|
|
56
|
+
if result.success?
|
|
57
|
+
result.data
|
|
58
|
+
else
|
|
59
|
+
raise Errors::ValidationError.new(result.errors)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stream LLM response — yields chunks
|
|
64
|
+
def stream(message, model: nil, system: nil, &block)
|
|
65
|
+
ensure_ruby_llm!
|
|
66
|
+
|
|
67
|
+
messages = [{ role: "user", content: message }]
|
|
68
|
+
# Delegate to ruby_llm's streaming interface
|
|
69
|
+
if @ruby_llm
|
|
70
|
+
# ruby_llm streaming would go here
|
|
71
|
+
# For now, fall back to non-streaming
|
|
72
|
+
result = chat(message, model: model, system: system, cache: false)
|
|
73
|
+
yield result if block_given?
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if LLM is available
|
|
79
|
+
def available?
|
|
80
|
+
ruby_llm_available?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def call_llm(messages:, model:, system: nil, max_tokens: nil, temperature: nil)
|
|
86
|
+
ensure_ruby_llm!
|
|
87
|
+
|
|
88
|
+
if @ruby_llm
|
|
89
|
+
# Use ruby_llm gem
|
|
90
|
+
chat = RubyLLM.chat(model: model || "claude-sonnet-4-20250514")
|
|
91
|
+
chat.with_instructions(system) if system
|
|
92
|
+
response = chat.ask(messages.last[:content])
|
|
93
|
+
response.content
|
|
94
|
+
else
|
|
95
|
+
raise Errors::DependencyError, "No LLM provider available. Add 'ruby_llm' to your Gemfile."
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_ruby_llm!
|
|
100
|
+
return if @ruby_llm == false # already checked, not available
|
|
101
|
+
|
|
102
|
+
if ruby_llm_available?
|
|
103
|
+
@ruby_llm = true
|
|
104
|
+
else
|
|
105
|
+
@ruby_llm = false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ruby_llm_available?
|
|
110
|
+
require "ruby_llm"
|
|
111
|
+
true
|
|
112
|
+
rescue LoadError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def describe_schema(schema)
|
|
117
|
+
return "{}" unless schema.respond_to?(:fields)
|
|
118
|
+
fields = schema.fields.map do |name, opts|
|
|
119
|
+
type = OpenAPI::SchemaConverter.type_for(opts[:type])
|
|
120
|
+
desc = opts[:desc] ? " — #{opts[:desc]}" : ""
|
|
121
|
+
required = opts[:required] ? " (required)" : ""
|
|
122
|
+
" #{name}: #{type}#{required}#{desc}"
|
|
123
|
+
end
|
|
124
|
+
"{\n#{fields.join(",\n")}\n}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def extract_json(text)
|
|
128
|
+
# Try to find JSON in LLM response (may be wrapped in markdown code blocks)
|
|
129
|
+
if text =~ /```(?:json)?\s*\n?(.*?)\n?```/m
|
|
130
|
+
$1.strip
|
|
131
|
+
elsif text.strip.start_with?("{") || text.strip.start_with?("[")
|
|
132
|
+
text.strip
|
|
133
|
+
else
|
|
134
|
+
text.strip
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module AI
|
|
5
|
+
# Validates LLM output against a Whoosh::Schema
|
|
6
|
+
module StructuredOutput
|
|
7
|
+
def self.validate(data, schema:)
|
|
8
|
+
result = schema.validate(data)
|
|
9
|
+
return result.data if result.success?
|
|
10
|
+
raise Errors::ValidationError.new(result.errors)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.prompt_for(schema)
|
|
14
|
+
return "" unless schema.respond_to?(:fields)
|
|
15
|
+
|
|
16
|
+
lines = schema.fields.map do |name, opts|
|
|
17
|
+
type = OpenAPI::SchemaConverter.type_for(opts[:type])
|
|
18
|
+
parts = ["#{name}: #{type}"]
|
|
19
|
+
parts << "(required)" if opts[:required]
|
|
20
|
+
parts << "— #{opts[:desc]}" if opts[:desc]
|
|
21
|
+
parts << "[min: #{opts[:min]}]" if opts[:min]
|
|
22
|
+
parts << "[max: #{opts[:max]}]" if opts[:max]
|
|
23
|
+
parts << "[default: #{opts[:default]}]" if opts.key?(:default)
|
|
24
|
+
" #{parts.join(" ")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
"Return ONLY valid JSON matching this schema:\n{\n#{lines.join(",\n")}\n}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/whoosh/ai.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module AI
|
|
5
|
+
autoload :LLM, "whoosh/ai/llm"
|
|
6
|
+
autoload :StructuredOutput, "whoosh/ai/structured_output"
|
|
7
|
+
|
|
8
|
+
# Build an AI client from config
|
|
9
|
+
def self.build(config_data = {})
|
|
10
|
+
ai_config = config_data["ai"] || {}
|
|
11
|
+
LLM.new(
|
|
12
|
+
provider: ai_config["provider"] || "auto",
|
|
13
|
+
model: ai_config["model"],
|
|
14
|
+
cache_enabled: ai_config["cache"] != false
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/whoosh/app.rb
CHANGED
|
@@ -30,6 +30,7 @@ module Whoosh
|
|
|
30
30
|
auto_register_storage
|
|
31
31
|
auto_register_http
|
|
32
32
|
auto_register_vectors
|
|
33
|
+
auto_register_ai
|
|
33
34
|
auto_configure_jobs
|
|
34
35
|
@metrics = Metrics.new
|
|
35
36
|
auto_register_metrics
|
|
@@ -234,6 +235,13 @@ module Whoosh
|
|
|
234
235
|
[200, Streaming::LlmStream.headers, body]
|
|
235
236
|
end
|
|
236
237
|
|
|
238
|
+
# WebSocket endpoint helper — use in handle_request, returns hijack response
|
|
239
|
+
def websocket(env, &block)
|
|
240
|
+
ws = Streaming::WebSocket.new(env)
|
|
241
|
+
block.call(ws)
|
|
242
|
+
ws.rack_response
|
|
243
|
+
end
|
|
244
|
+
|
|
237
245
|
def paginate(collection, page:, per_page: 20)
|
|
238
246
|
Paginate.offset(collection, page: page, per_page: per_page)
|
|
239
247
|
end
|
|
@@ -307,6 +315,10 @@ module Whoosh
|
|
|
307
315
|
@di.provide(:vectors) { VectorStore.build(@config.data) }
|
|
308
316
|
end
|
|
309
317
|
|
|
318
|
+
def auto_register_ai
|
|
319
|
+
@di.provide(:llm) { AI.build(@config.data) }
|
|
320
|
+
end
|
|
321
|
+
|
|
310
322
|
def auto_configure_jobs
|
|
311
323
|
backend = Jobs.build_backend(@config.data)
|
|
312
324
|
Jobs.configure(backend: backend, di: @di)
|
|
@@ -448,8 +460,12 @@ module Whoosh
|
|
|
448
460
|
end
|
|
449
461
|
|
|
450
462
|
def register_mcp_tools
|
|
463
|
+
internal_paths = %w[/openapi.json /docs /redoc /metrics /healthz]
|
|
464
|
+
|
|
451
465
|
@router.routes.each do |route|
|
|
452
|
-
|
|
466
|
+
# Auto-expose all routes as MCP tools (opt-out with mcp: false)
|
|
467
|
+
next if route[:metadata] && route[:metadata][:mcp] == false
|
|
468
|
+
next if internal_paths.include?(route[:path])
|
|
453
469
|
|
|
454
470
|
tool_name = "#{route[:method]} #{route[:path]}"
|
|
455
471
|
match = @router.match(route[:method], route[:path])
|
data/lib/whoosh/cli/main.rb
CHANGED
|
@@ -92,17 +92,166 @@ module Whoosh
|
|
|
92
92
|
|
|
93
93
|
desc "routes", "List all registered routes"
|
|
94
94
|
def routes
|
|
95
|
+
app = load_app
|
|
96
|
+
return unless app
|
|
97
|
+
app.routes.each { |r| puts " #{r[:method].ljust(8)} #{r[:path]}" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
desc "describe", "Dump app structure as JSON (AI-friendly introspection)"
|
|
101
|
+
option :routes, type: :boolean, default: false, desc: "Routes only"
|
|
102
|
+
option :schemas, type: :boolean, default: false, desc: "Schemas only"
|
|
103
|
+
def describe
|
|
104
|
+
app = load_app
|
|
105
|
+
return unless app
|
|
106
|
+
app.to_rack # ensure everything is built
|
|
107
|
+
|
|
108
|
+
output = {}
|
|
109
|
+
|
|
110
|
+
unless options[:schemas]
|
|
111
|
+
output[:routes] = app.routes.map do |r|
|
|
112
|
+
match = app.instance_variable_get(:@router).match(r[:method], r[:path])
|
|
113
|
+
handler = match[:handler] if match
|
|
114
|
+
route_info = {
|
|
115
|
+
method: r[:method],
|
|
116
|
+
path: r[:path],
|
|
117
|
+
auth: r[:metadata]&.dig(:auth),
|
|
118
|
+
mcp: r[:metadata]&.dig(:mcp) || false
|
|
119
|
+
}
|
|
120
|
+
if handler && handler[:request_schema]
|
|
121
|
+
route_info[:request_schema] = OpenAPI::SchemaConverter.convert(handler[:request_schema])
|
|
122
|
+
end
|
|
123
|
+
if handler && handler[:response_schema]
|
|
124
|
+
route_info[:response_schema] = OpenAPI::SchemaConverter.convert(handler[:response_schema])
|
|
125
|
+
end
|
|
126
|
+
route_info
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless options[:routes]
|
|
131
|
+
# Collect all Schema subclasses
|
|
132
|
+
schemas = {}
|
|
133
|
+
ObjectSpace.each_object(Class).select { |k| k < Schema && k != Schema }.each do |klass|
|
|
134
|
+
schemas[klass.name] = OpenAPI::SchemaConverter.convert(klass) if klass.name
|
|
135
|
+
end
|
|
136
|
+
output[:schemas] = schemas unless schemas.empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
output[:config] = {
|
|
140
|
+
app_name: app.config.app_name,
|
|
141
|
+
port: app.config.port,
|
|
142
|
+
env: app.config.env,
|
|
143
|
+
docs_enabled: app.config.docs_enabled?,
|
|
144
|
+
auth_configured: !!app.authenticator,
|
|
145
|
+
rate_limit_configured: !!app.rate_limiter_instance,
|
|
146
|
+
mcp_tools: app.mcp_server.list_tools.map { |t| t[:name] }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
output[:framework] = {
|
|
150
|
+
version: Whoosh::VERSION,
|
|
151
|
+
ruby: RUBY_VERSION,
|
|
152
|
+
yjit: Performance.yjit_enabled?,
|
|
153
|
+
json_engine: Serialization::Json.engine
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
puts JSON.pretty_generate(output)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
desc "check", "Validate app configuration and catch common mistakes"
|
|
160
|
+
def check
|
|
95
161
|
app_file = File.join(Dir.pwd, "app.rb")
|
|
96
162
|
unless File.exist?(app_file)
|
|
97
|
-
puts "
|
|
163
|
+
puts "✗ No app.rb found"
|
|
98
164
|
exit 1
|
|
99
165
|
end
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
166
|
+
|
|
167
|
+
puts "=> Whoosh App Check"
|
|
168
|
+
puts ""
|
|
169
|
+
|
|
170
|
+
issues = []
|
|
171
|
+
warnings = []
|
|
172
|
+
|
|
173
|
+
# Check .env
|
|
174
|
+
if File.exist?(".env")
|
|
175
|
+
env_content = File.read(".env")
|
|
176
|
+
if env_content.include?("change_me") || env_content.include?("CHANGE_ME")
|
|
177
|
+
issues << "JWT_SECRET in .env still has default value — run: ruby -e \"puts SecureRandom.hex(32)\""
|
|
178
|
+
end
|
|
104
179
|
else
|
|
105
|
-
|
|
180
|
+
warnings << "No .env file — copy .env.example to .env"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check .gitignore includes .env
|
|
184
|
+
if File.exist?(".gitignore")
|
|
185
|
+
unless File.read(".gitignore").include?(".env")
|
|
186
|
+
issues << ".gitignore does not exclude .env — secrets may be committed"
|
|
187
|
+
end
|
|
188
|
+
else
|
|
189
|
+
warnings << "No .gitignore file"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Load and validate the app
|
|
193
|
+
begin
|
|
194
|
+
require app_file
|
|
195
|
+
app = ObjectSpace.each_object(Whoosh::App).first
|
|
196
|
+
if app
|
|
197
|
+
puts " ✓ App loads successfully"
|
|
198
|
+
|
|
199
|
+
# Check auth
|
|
200
|
+
if app.authenticator
|
|
201
|
+
puts " ✓ Auth configured"
|
|
202
|
+
else
|
|
203
|
+
warnings << "No auth configured — API is open to everyone"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Check rate limiting
|
|
207
|
+
if app.rate_limiter_instance
|
|
208
|
+
puts " ✓ Rate limiting configured"
|
|
209
|
+
else
|
|
210
|
+
warnings << "No rate limiting — vulnerable to abuse"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Check routes
|
|
214
|
+
route_count = app.routes.length
|
|
215
|
+
puts " ✓ #{route_count} routes registered"
|
|
216
|
+
|
|
217
|
+
# Check MCP
|
|
218
|
+
app.to_rack
|
|
219
|
+
mcp_count = app.mcp_server.list_tools.length
|
|
220
|
+
puts " ✓ #{mcp_count} MCP tools"
|
|
221
|
+
|
|
222
|
+
else
|
|
223
|
+
issues << "No Whoosh::App instance found in app.rb"
|
|
224
|
+
end
|
|
225
|
+
rescue => e
|
|
226
|
+
issues << "App failed to load: #{e.message}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Check dependencies
|
|
230
|
+
puts ""
|
|
231
|
+
%w[falcon oj sequel].each do |gem_name|
|
|
232
|
+
begin
|
|
233
|
+
require gem_name
|
|
234
|
+
puts " ✓ #{gem_name} available"
|
|
235
|
+
rescue LoadError
|
|
236
|
+
label = case gem_name
|
|
237
|
+
when "falcon" then "(recommended server)"
|
|
238
|
+
when "oj" then "(5-10x faster JSON)"
|
|
239
|
+
when "sequel" then "(database)"
|
|
240
|
+
end
|
|
241
|
+
warnings << "#{gem_name} not installed #{label}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Report
|
|
246
|
+
puts ""
|
|
247
|
+
if issues.empty? && warnings.empty?
|
|
248
|
+
puts "=> All checks passed! ✓"
|
|
249
|
+
else
|
|
250
|
+
issues.each { |i| puts " ✗ #{i}" }
|
|
251
|
+
warnings.each { |w| puts " ⚠ #{w}" }
|
|
252
|
+
puts ""
|
|
253
|
+
puts issues.empty? ? "=> #{warnings.length} warning(s)" : "=> #{issues.length} issue(s), #{warnings.length} warning(s)"
|
|
254
|
+
exit 1 unless issues.empty?
|
|
106
255
|
end
|
|
107
256
|
end
|
|
108
257
|
|
|
@@ -306,6 +455,21 @@ module Whoosh
|
|
|
306
455
|
|
|
307
456
|
private
|
|
308
457
|
|
|
458
|
+
def load_app
|
|
459
|
+
app_file = File.join(Dir.pwd, "app.rb")
|
|
460
|
+
unless File.exist?(app_file)
|
|
461
|
+
puts "Error: app.rb not found in #{Dir.pwd}"
|
|
462
|
+
return nil
|
|
463
|
+
end
|
|
464
|
+
require app_file
|
|
465
|
+
app = ObjectSpace.each_object(Whoosh::App).first
|
|
466
|
+
unless app
|
|
467
|
+
puts "Error: No Whoosh::App instance found"
|
|
468
|
+
return nil
|
|
469
|
+
end
|
|
470
|
+
app
|
|
471
|
+
end
|
|
472
|
+
|
|
309
473
|
def run_secret_scan
|
|
310
474
|
patterns = [
|
|
311
475
|
/(?:api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9+\/=]{8,}["']/i,
|
|
@@ -33,6 +33,7 @@ module Whoosh
|
|
|
33
33
|
write(dir, ".dockerignore", dockerignore)
|
|
34
34
|
write(dir, ".rubocop.yml", rubocop_config)
|
|
35
35
|
write(dir, "README.md", readme(name))
|
|
36
|
+
write(dir, "CLAUDE.md", claude_md(name))
|
|
36
37
|
|
|
37
38
|
# Create empty SQLite DB directory
|
|
38
39
|
FileUtils.mkdir_p(File.join(dir, "db"))
|
|
@@ -406,6 +407,180 @@ module Whoosh
|
|
|
406
407
|
IGNORE
|
|
407
408
|
end
|
|
408
409
|
|
|
410
|
+
def claude_md(name)
|
|
411
|
+
title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
|
|
412
|
+
<<~MD
|
|
413
|
+
# #{title}
|
|
414
|
+
|
|
415
|
+
## Framework
|
|
416
|
+
|
|
417
|
+
Built with [Whoosh](https://github.com/johannesdwicahyo/whoosh) — AI-first Ruby API framework.
|
|
418
|
+
|
|
419
|
+
## Commands
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
whoosh s # start server (http://localhost:9292)
|
|
423
|
+
whoosh s --reload # hot reload on file changes
|
|
424
|
+
whoosh ci # run lint + security + tests
|
|
425
|
+
whoosh routes # list all routes
|
|
426
|
+
whoosh describe # dump app structure as JSON
|
|
427
|
+
whoosh check # validate configuration
|
|
428
|
+
whoosh console # IRB with app loaded
|
|
429
|
+
whoosh mcp --list # list MCP tools
|
|
430
|
+
whoosh worker # start background job worker
|
|
431
|
+
bundle exec rspec # run tests
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Project Structure
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
app.rb # main app — routes, auth, config
|
|
438
|
+
config.ru # Rack entry point
|
|
439
|
+
config/app.yml # configuration (database, cache, jobs, logging)
|
|
440
|
+
config/plugins.yml # plugin configuration
|
|
441
|
+
endpoints/ # class-based endpoints (auto-loaded)
|
|
442
|
+
schemas/ # request/response schemas (dry-schema)
|
|
443
|
+
models/ # Sequel models
|
|
444
|
+
db/migrations/ # database migrations
|
|
445
|
+
middleware/ # custom middleware
|
|
446
|
+
test/ # RSpec tests
|
|
447
|
+
.env # secrets (never commit)
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## Patterns
|
|
451
|
+
|
|
452
|
+
### Adding an endpoint
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
whoosh generate endpoint users name:string email:string
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Or manually:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
# endpoints/users.rb
|
|
462
|
+
class UsersEndpoint < Whoosh::Endpoint
|
|
463
|
+
get "/users"
|
|
464
|
+
post "/users", request: CreateUserSchema
|
|
465
|
+
|
|
466
|
+
def call(req)
|
|
467
|
+
case req.method
|
|
468
|
+
when "GET" then { users: [] }
|
|
469
|
+
when "POST" then { created: true }
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Adding a schema
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# schemas/user.rb
|
|
479
|
+
class CreateUserSchema < Whoosh::Schema
|
|
480
|
+
field :name, String, required: true, desc: "User name"
|
|
481
|
+
field :email, String, required: true, desc: "Email"
|
|
482
|
+
field :age, Integer, min: 0, max: 150
|
|
483
|
+
end
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Inline routes (in app.rb)
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
App.get "/health" do
|
|
490
|
+
{ status: "ok" }
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
App.post "/items", request: ItemSchema, auth: :api_key do |req|
|
|
494
|
+
{ created: req.body[:name] }
|
|
495
|
+
end
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Auth
|
|
499
|
+
|
|
500
|
+
Protected routes use `auth: :api_key` or `auth: :jwt`:
|
|
501
|
+
```ruby
|
|
502
|
+
App.get "/protected", auth: :api_key do |req|
|
|
503
|
+
{ user: req.env["whoosh.auth"][:key] }
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Background jobs
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
class MyJob < Whoosh::Job
|
|
511
|
+
inject :db # DI injection
|
|
512
|
+
queue :default
|
|
513
|
+
retry_limit 3
|
|
514
|
+
retry_backoff :exponential
|
|
515
|
+
|
|
516
|
+
def perform(user_id:)
|
|
517
|
+
{ done: true }
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
MyJob.perform_async(user_id: 42)
|
|
522
|
+
MyJob.perform_in(3600, user_id: 42) # 1 hour delay
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Streaming (SSE/LLM)
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
App.post "/chat" do |req|
|
|
529
|
+
stream_llm do |out|
|
|
530
|
+
out << "Hello "
|
|
531
|
+
out << "World"
|
|
532
|
+
out.finish
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### MCP tools
|
|
538
|
+
|
|
539
|
+
Routes with `mcp: true` are auto-exposed as MCP tools:
|
|
540
|
+
```ruby
|
|
541
|
+
App.post "/analyze", mcp: true, request: AnalyzeSchema do |req|
|
|
542
|
+
{ result: "analyzed" }
|
|
543
|
+
end
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Testing
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
require "whoosh/test"
|
|
550
|
+
RSpec.describe "API" do
|
|
551
|
+
include Whoosh::Test
|
|
552
|
+
def app = App.to_rack
|
|
553
|
+
|
|
554
|
+
it "works" do
|
|
555
|
+
post_json "/items", { name: "test" }
|
|
556
|
+
assert_response 200
|
|
557
|
+
assert_json(name: "test")
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## AI Introspection
|
|
563
|
+
|
|
564
|
+
```bash
|
|
565
|
+
whoosh describe # full app structure as JSON
|
|
566
|
+
whoosh describe --routes # routes with schemas
|
|
567
|
+
whoosh describe --schemas # all schema definitions
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
## Configuration
|
|
571
|
+
|
|
572
|
+
All config in `config/app.yml`. Environment variables override YAML.
|
|
573
|
+
Secrets in `.env` (auto-loaded at boot, never committed).
|
|
574
|
+
|
|
575
|
+
## Response Format
|
|
576
|
+
|
|
577
|
+
Simple flat JSON. Not JSON:API.
|
|
578
|
+
- Success: `{"name": "Alice"}`
|
|
579
|
+
- Error: `{"error": "validation_failed", "details": [...]}`
|
|
580
|
+
- Pagination: `{"data": [...], "pagination": {"page": 1}}`
|
|
581
|
+
MD
|
|
582
|
+
end
|
|
583
|
+
|
|
409
584
|
def readme(name)
|
|
410
585
|
title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
|
|
411
586
|
<<~MD
|
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
# lib/whoosh/streaming/websocket.rb
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "base64"
|
|
5
6
|
|
|
6
7
|
module Whoosh
|
|
7
8
|
module Streaming
|
|
8
9
|
class WebSocket
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
GUID = "258EAFA5-E914-47DA-95CA-5AB5DC65C3E5"
|
|
11
|
+
|
|
12
|
+
attr_reader :env
|
|
13
|
+
|
|
14
|
+
def initialize(env)
|
|
15
|
+
@env = env
|
|
16
|
+
@io = nil
|
|
11
17
|
@closed = false
|
|
12
18
|
@on_message = nil
|
|
13
19
|
@on_close = nil
|
|
20
|
+
@on_open = nil
|
|
14
21
|
end
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
# Check if the request is a WebSocket upgrade
|
|
24
|
+
def self.websocket?(env)
|
|
25
|
+
env["HTTP_UPGRADE"]&.downcase == "websocket" &&
|
|
26
|
+
env["HTTP_CONNECTION"]&.downcase&.include?("upgrade")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Register callbacks
|
|
30
|
+
def on_open(&block)
|
|
31
|
+
@on_open = block
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
def on_message(&block)
|
|
@@ -30,6 +39,67 @@ module Whoosh
|
|
|
30
39
|
@on_close = block
|
|
31
40
|
end
|
|
32
41
|
|
|
42
|
+
# Send data to the client
|
|
43
|
+
def send(data)
|
|
44
|
+
return if @closed
|
|
45
|
+
formatted = data.is_a?(String) ? data : JSON.generate(data)
|
|
46
|
+
write_frame(formatted)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
return if @closed
|
|
51
|
+
@closed = true
|
|
52
|
+
write_close_frame
|
|
53
|
+
@io&.close rescue nil
|
|
54
|
+
@on_close&.call
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def closed?
|
|
58
|
+
@closed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns a Rack response that hijacks the connection
|
|
62
|
+
def rack_response
|
|
63
|
+
unless self.class.websocket?(@env)
|
|
64
|
+
return [400, { "content-type" => "text/plain" }, ["Not a WebSocket request"]]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# WebSocket handshake
|
|
68
|
+
key = @env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
69
|
+
accept = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
70
|
+
|
|
71
|
+
headers = {
|
|
72
|
+
"Upgrade" => "websocket",
|
|
73
|
+
"Connection" => "Upgrade",
|
|
74
|
+
"Sec-WebSocket-Accept" => accept
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Use rack.hijack to take over the connection
|
|
78
|
+
if @env["rack.hijack"]
|
|
79
|
+
@env["rack.hijack"].call
|
|
80
|
+
@io = @env["rack.hijack_io"]
|
|
81
|
+
|
|
82
|
+
# Send handshake response manually
|
|
83
|
+
@io.write("HTTP/1.1 101 Switching Protocols\r\n")
|
|
84
|
+
headers.each { |k, v| @io.write("#{k}: #{v}\r\n") }
|
|
85
|
+
@io.write("\r\n")
|
|
86
|
+
@io.flush
|
|
87
|
+
|
|
88
|
+
# Notify open
|
|
89
|
+
@on_open&.call
|
|
90
|
+
|
|
91
|
+
# Start reading frames in a thread
|
|
92
|
+
Thread.new { read_loop }
|
|
93
|
+
|
|
94
|
+
# Return a dummy response (connection is hijacked)
|
|
95
|
+
[-1, {}, []]
|
|
96
|
+
else
|
|
97
|
+
# Fallback: return 101 and let the server handle hijack
|
|
98
|
+
[101, headers, []]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# For testing — simulate without real socket
|
|
33
103
|
def trigger_message(msg)
|
|
34
104
|
@on_message&.call(msg)
|
|
35
105
|
end
|
|
@@ -39,12 +109,86 @@ module Whoosh
|
|
|
39
109
|
@closed = true
|
|
40
110
|
end
|
|
41
111
|
|
|
42
|
-
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def read_loop
|
|
115
|
+
while !@closed && @io && !@io.closed?
|
|
116
|
+
frame = read_frame
|
|
117
|
+
break unless frame
|
|
118
|
+
|
|
119
|
+
case frame[:opcode]
|
|
120
|
+
when 0x1 # Text frame
|
|
121
|
+
@on_message&.call(frame[:data])
|
|
122
|
+
when 0x8 # Close frame
|
|
123
|
+
close
|
|
124
|
+
break
|
|
125
|
+
when 0x9 # Ping
|
|
126
|
+
write_pong(frame[:data])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
43
130
|
@closed = true
|
|
131
|
+
@on_close&.call
|
|
44
132
|
end
|
|
45
133
|
|
|
46
|
-
def
|
|
47
|
-
@closed
|
|
134
|
+
def read_frame
|
|
135
|
+
return nil unless @io && !@io.closed?
|
|
136
|
+
|
|
137
|
+
first_byte = @io.readbyte
|
|
138
|
+
fin = (first_byte & 0x80) != 0
|
|
139
|
+
opcode = first_byte & 0x0F
|
|
140
|
+
|
|
141
|
+
second_byte = @io.readbyte
|
|
142
|
+
masked = (second_byte & 0x80) != 0
|
|
143
|
+
length = second_byte & 0x7F
|
|
144
|
+
|
|
145
|
+
if length == 126
|
|
146
|
+
length = @io.read(2).unpack1("n")
|
|
147
|
+
elsif length == 127
|
|
148
|
+
length = @io.read(8).unpack1("Q>")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
mask_key = masked ? @io.read(4).bytes : nil
|
|
152
|
+
payload = @io.read(length)&.bytes || []
|
|
153
|
+
|
|
154
|
+
if masked && mask_key
|
|
155
|
+
payload = payload.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{ opcode: opcode, data: payload.pack("C*"), fin: fin }
|
|
159
|
+
rescue EOFError, IOError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def write_frame(data, opcode: 0x1)
|
|
164
|
+
return unless @io && !@io.closed?
|
|
165
|
+
|
|
166
|
+
bytes = data.encode("UTF-8").bytes
|
|
167
|
+
frame = [0x80 | opcode] # FIN + opcode
|
|
168
|
+
|
|
169
|
+
if bytes.length < 126
|
|
170
|
+
frame << bytes.length
|
|
171
|
+
elsif bytes.length < 65536
|
|
172
|
+
frame << 126
|
|
173
|
+
frame += [bytes.length].pack("n").bytes
|
|
174
|
+
else
|
|
175
|
+
frame << 127
|
|
176
|
+
frame += [bytes.length].pack("Q>").bytes
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
frame += bytes
|
|
180
|
+
@io.write(frame.pack("C*"))
|
|
181
|
+
@io.flush
|
|
182
|
+
rescue IOError, Errno::EPIPE
|
|
183
|
+
@closed = true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def write_close_frame
|
|
187
|
+
write_frame("", opcode: 0x8) rescue nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def write_pong(data)
|
|
191
|
+
write_frame(data, opcode: 0xA) rescue nil
|
|
48
192
|
end
|
|
49
193
|
end
|
|
50
194
|
end
|
data/lib/whoosh/version.rb
CHANGED
data/lib/whoosh.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: whoosh
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Johannes Dwi Cahyo
|
|
@@ -174,6 +174,9 @@ files:
|
|
|
174
174
|
- README.md
|
|
175
175
|
- exe/whoosh
|
|
176
176
|
- lib/whoosh.rb
|
|
177
|
+
- lib/whoosh/ai.rb
|
|
178
|
+
- lib/whoosh/ai/llm.rb
|
|
179
|
+
- lib/whoosh/ai/structured_output.rb
|
|
177
180
|
- lib/whoosh/app.rb
|
|
178
181
|
- lib/whoosh/auth/access_control.rb
|
|
179
182
|
- lib/whoosh/auth/api_key.rb
|