fal 0.0.1 → 0.0.3
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/.env.example +1 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/README.md +165 -0
- data/lib/fal/client.rb +108 -0
- data/lib/fal/request.rb +159 -0
- data/lib/fal/stream.rb +92 -0
- data/lib/fal/version.rb +1 -1
- data/lib/fal/webhook_request.rb +99 -0
- data/lib/fal.rb +93 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f22dff48b2f5121df441220f3fffe41f3e548c8a2c229dd0f1c7d3c19072b74f
|
|
4
|
+
data.tar.gz: 8e5c092fb5246f8ea7ee88b80123e63d5ed7384f37b1cfc61474bcd3daef1431
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '05697e66efb4f7d26fb110d707627755395244fa5a9fca86d3624e4bd6d33cae7a42ccbe625cd14dd8646b4a934496e2f169247a387ad046cf049ece487eb0d4'
|
|
7
|
+
data.tar.gz: b7bd9bf3bddb0f1a62ed61bb9d681a5531f4e00ff628cbc4a77b720e4367ef9a6d7a45253bb4777654e078c1e55e67ab6aaa95cebb2bf83ebc0e4a28df5b49c0
|
data/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
FAL_KEY=
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
fal (0.0.
|
|
4
|
+
fal (0.0.3)
|
|
5
5
|
faraday (>= 1)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
8
8
|
remote: https://rubygems.org/
|
|
9
9
|
specs:
|
|
10
10
|
ast (2.4.2)
|
|
11
|
+
dotenv (3.1.8)
|
|
11
12
|
faraday (2.14.0)
|
|
12
13
|
faraday-net_http (>= 2.0, < 3.5)
|
|
13
14
|
json
|
|
@@ -53,6 +54,7 @@ PLATFORMS
|
|
|
53
54
|
x86_64-darwin-23
|
|
54
55
|
|
|
55
56
|
DEPENDENCIES
|
|
57
|
+
dotenv
|
|
56
58
|
fal!
|
|
57
59
|
minitest (~> 5.0)
|
|
58
60
|
rake (~> 13.0)
|
data/README.md
CHANGED
|
@@ -9,3 +9,168 @@ Install the gem and add to the application"s Gemfile by executing:
|
|
|
9
9
|
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
10
10
|
|
|
11
11
|
$ gem install fal
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Configuration
|
|
16
|
+
|
|
17
|
+
Configure the client once at boot (e.g., in Rails an initializer) using `Fal.configure`.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Fal.configure do |config|
|
|
21
|
+
config.api_key = "your-key" # Optional. Defaults to ENV["FAL_KEY"] if not set.
|
|
22
|
+
config.queue_base = "https://queue.fal.run" # Optional (default: https://queue.fal.run)
|
|
23
|
+
config.sync_base = "https://fal.run" # Optional (default: https://fal.run)
|
|
24
|
+
config.request_timeout = 120 # Optional (default: 120)
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Create a queued request
|
|
29
|
+
|
|
30
|
+
The Queue API is the recommended way to call models on fal. Provide a `model_id` in "namespace/name" format and an input payload.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
model_id = "fal-ai/fast-sdxl"
|
|
34
|
+
|
|
35
|
+
request = Fal::Request.create!(
|
|
36
|
+
model_id: model_id,
|
|
37
|
+
input: { prompt: "a cat" }
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
request.id # => request_id from fal
|
|
41
|
+
request.status # => "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You can also specify a webhook URL to be notified when the request is finished.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
request = Fal::Request.create!(
|
|
48
|
+
model_id: model_id,
|
|
49
|
+
input: { prompt: "a cat playing piano" },
|
|
50
|
+
webhook_url: "https://example.com/fal/webhook"
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Get request status (find and reload)
|
|
55
|
+
|
|
56
|
+
Fetch the current status by id:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
status = Fal::Request.find_by!(id: request.id, model_id: model_id)
|
|
60
|
+
status.in_queue? # => true/false
|
|
61
|
+
status.in_progress? # => true/false
|
|
62
|
+
status.completed? # => true/false
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Reload an instance in-place, optionally including logs.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
request.reload! # refreshes state
|
|
69
|
+
request.reload!(logs: true)
|
|
70
|
+
request.logs # => array of log entries (if provided by model and logs=1)
|
|
71
|
+
# When status is COMPLETED, reload! will also fetch and set request.response
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Status constants are available for direct comparisons:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Fal::Request::Status::IN_QUEUE
|
|
78
|
+
Fal::Request::Status::IN_PROGRESS
|
|
79
|
+
Fal::Request::Status::COMPLETED
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Fetch the response payload after completion
|
|
83
|
+
|
|
84
|
+
Call `reload!` to populate `request.response`:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# poll until completed
|
|
88
|
+
until request.completed?
|
|
89
|
+
request.reload!
|
|
90
|
+
sleep 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
request.response # => model-specific response body
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Cancel a request
|
|
97
|
+
|
|
98
|
+
Requests that are still in the queue can be cancelled:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
request.cancel! # => { "status" => "CANCELLATION_REQUESTED" }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Webhooks
|
|
105
|
+
|
|
106
|
+
fal can POST a webhook to your server when a request completes. Use `Fal::WebhookRequest` to parse the incoming payload.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# rails controller example
|
|
110
|
+
class FalWebhooksController < ApplicationController
|
|
111
|
+
skip_before_action :verify_authenticity_token
|
|
112
|
+
|
|
113
|
+
def create
|
|
114
|
+
webhook = Fal::WebhookRequest.from_rack_request(request)
|
|
115
|
+
|
|
116
|
+
if webhook.success?
|
|
117
|
+
# webhook.response contains the model-specific payload
|
|
118
|
+
# webhook.logs, webhook.metrics may also be present
|
|
119
|
+
head :ok
|
|
120
|
+
else
|
|
121
|
+
Rails.logger.error("fal webhook error: #{webhook.error} detail=#{webhook.error_detail}")
|
|
122
|
+
head :ok
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Error handling
|
|
129
|
+
|
|
130
|
+
HTTP and API errors raise typed exceptions:
|
|
131
|
+
|
|
132
|
+
- `Fal::UnauthorizedError` (401)
|
|
133
|
+
- `Fal::ForbiddenError` (403)
|
|
134
|
+
- `Fal::NotFoundError` (404)
|
|
135
|
+
- `Fal::ServerError` (other non-success)
|
|
136
|
+
|
|
137
|
+
Rescue them as needed:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
begin
|
|
141
|
+
Fal::Request.create!(model_id: model_id, input: { prompt: "hi" })
|
|
142
|
+
rescue Fal::UnauthorizedError
|
|
143
|
+
# handle invalid/missing FAL_KEY
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Stream synchronous responses
|
|
148
|
+
|
|
149
|
+
Use `stream!` for SSE streaming from synchronous endpoints. It yields each chunk’s data Hash and returns a `Fal::Request` whose `response` contains the last chunk’s payload.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
model_id = "fal-ai/flux/dev"
|
|
153
|
+
|
|
154
|
+
last = Fal::Request.stream!(model_id: model_id, input: { prompt: "a cat" }) do |chunk|
|
|
155
|
+
# chunk is a Hash, e.g. { images: [...] }
|
|
156
|
+
puts chunk
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
last.completed? # => true/false
|
|
160
|
+
last.response # => last streamed data hash (e.g., { "response" => { ... } } or final payload)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Development
|
|
164
|
+
|
|
165
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
166
|
+
|
|
167
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to rubygems.org.
|
|
168
|
+
|
|
169
|
+
For local development, copy the example environment file and set your API key so `bin/console` can load it automatically:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
cp .env.example .env
|
|
173
|
+
echo 'FAL_KEY=your_api_key_here' >> .env
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The console uses dotenv to load `.env`, so `Fal.configure` will default to `ENV["FAL_KEY"]`.
|
data/lib/fal/client.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fal
|
|
4
|
+
class Client
|
|
5
|
+
attr_accessor :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(configuration = Fal.configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def post(path, payload = {}, headers: {})
|
|
12
|
+
response = connection.post(build_url(path)) do |request|
|
|
13
|
+
request.headers["Authorization"] = "Key #{@configuration.api_key}" if @configuration.api_key
|
|
14
|
+
request.headers["Content-Type"] = "application/json"
|
|
15
|
+
request.headers["Accept"] = "application/json"
|
|
16
|
+
request.headers.merge!(headers)
|
|
17
|
+
request.body = payload.compact.to_json
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
handle_error(response) unless response.success?
|
|
21
|
+
|
|
22
|
+
parse_json(response.body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Perform a POST to the streaming (sync) base with SSE/text-event-stream handling.
|
|
26
|
+
# The provided on_data Proc will be used to receive chunked data.
|
|
27
|
+
# @param path [String]
|
|
28
|
+
# @param payload [Hash]
|
|
29
|
+
# @param on_data [Proc] called with chunks as they arrive
|
|
30
|
+
# @return [void]
|
|
31
|
+
def post_stream(path, payload = {}, on_data:)
|
|
32
|
+
url = build_sync_url(path)
|
|
33
|
+
connection.post(url) do |request|
|
|
34
|
+
request.headers["Authorization"] = "Key #{@configuration.api_key}" if @configuration.api_key
|
|
35
|
+
request.headers["Accept"] = "text/event-stream"
|
|
36
|
+
request.headers["Cache-Control"] = "no-store"
|
|
37
|
+
request.headers["Content-Type"] = "application/json"
|
|
38
|
+
request.body = payload.compact.to_json
|
|
39
|
+
request.options.on_data = on_data
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def get(path, query: nil, headers: {})
|
|
44
|
+
url = build_url(path)
|
|
45
|
+
url = "#{url}?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
46
|
+
|
|
47
|
+
response = connection.get(url) do |request|
|
|
48
|
+
request.headers["Authorization"] = "Key #{@configuration.api_key}" if @configuration.api_key
|
|
49
|
+
request.headers["Accept"] = "application/json"
|
|
50
|
+
request.headers.merge!(headers)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
handle_error(response) unless response.success?
|
|
54
|
+
|
|
55
|
+
parse_json(response.body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def put(path)
|
|
59
|
+
response = connection.put(build_url(path)) do |request|
|
|
60
|
+
request.headers["Authorization"] = "Key #{@configuration.api_key}" if @configuration.api_key
|
|
61
|
+
request.headers["Content-Type"] = "application/json"
|
|
62
|
+
request.headers["Accept"] = "application/json"
|
|
63
|
+
request.body = {}.to_json
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
handle_error(response) unless response.success?
|
|
67
|
+
|
|
68
|
+
parse_json(response.body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def handle_error(response)
|
|
72
|
+
case response.status
|
|
73
|
+
when 401
|
|
74
|
+
raise UnauthorizedError, response.body
|
|
75
|
+
when 403
|
|
76
|
+
raise ForbiddenError, response.body
|
|
77
|
+
when 404
|
|
78
|
+
raise NotFoundError, response.body
|
|
79
|
+
else
|
|
80
|
+
raise ServerError, response.body
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def parse_json(body)
|
|
87
|
+
return nil if body.nil? || body.strip.empty?
|
|
88
|
+
|
|
89
|
+
JSON.parse(body)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_url(path)
|
|
93
|
+
"#{@configuration.queue_base}#{path}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_sync_url(path)
|
|
97
|
+
"#{@configuration.sync_base}#{path}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def connection
|
|
101
|
+
Faraday.new do |faraday|
|
|
102
|
+
faraday.request :url_encoded
|
|
103
|
+
faraday.options.timeout = @configuration.request_timeout
|
|
104
|
+
faraday.options.open_timeout = @configuration.request_timeout
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/fal/request.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fal
|
|
4
|
+
# Represents a queued request submitted to a fal model endpoint.
|
|
5
|
+
# Provides helpers to create, query status, cancel, and fetch response payloads
|
|
6
|
+
# using the Queue API described in the fal docs.
|
|
7
|
+
# See: https://docs.fal.ai/model-apis/model-endpoints/queue
|
|
8
|
+
class Request
|
|
9
|
+
# Request status values returned by the Queue API.
|
|
10
|
+
module Status
|
|
11
|
+
# @return [String]
|
|
12
|
+
IN_QUEUE = "IN_QUEUE"
|
|
13
|
+
# @return [String]
|
|
14
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
15
|
+
# @return [String]
|
|
16
|
+
COMPLETED = "COMPLETED"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String] The request identifier (request_id)
|
|
20
|
+
attr_reader :id
|
|
21
|
+
# @return [String] The current status, one of Fal::Request::Status constants
|
|
22
|
+
attr_reader :status
|
|
23
|
+
# @return [Integer, nil] The current position in the queue, if available
|
|
24
|
+
attr_reader :queue_position
|
|
25
|
+
# @return [Array<Hash>, nil] Log entries when requested via logs=1
|
|
26
|
+
attr_reader :logs
|
|
27
|
+
# @return [Hash, nil] Response payload when status is COMPLETED
|
|
28
|
+
attr_reader :response
|
|
29
|
+
# @return [String] The model identifier used when creating this request
|
|
30
|
+
attr_reader :model_id
|
|
31
|
+
|
|
32
|
+
# @param attributes [Hash] Raw attributes from fal Queue API
|
|
33
|
+
# @param model_id [String] Model ID in "namespace/name" format
|
|
34
|
+
# @param client [Fal::Client] HTTP client to use for subsequent calls
|
|
35
|
+
def initialize(attributes, model_id:, client: Fal.client)
|
|
36
|
+
@client = client
|
|
37
|
+
@model_id = model_id
|
|
38
|
+
reset_attributes(attributes)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Create a new queued request for a model.
|
|
43
|
+
# Corresponds to POST https://queue.fal.run/{model_id}
|
|
44
|
+
# Optionally appends fal_webhook query param per docs.
|
|
45
|
+
# @param model_id [String]
|
|
46
|
+
# @param input [Hash]
|
|
47
|
+
# @param webhook_url [String, nil]
|
|
48
|
+
# @param client [Fal::Client]
|
|
49
|
+
# @return [Fal::Request]
|
|
50
|
+
def create!(model_id:, input:, webhook_url: nil, client: Fal.client)
|
|
51
|
+
path = "/#{model_id}"
|
|
52
|
+
body = input || {}
|
|
53
|
+
path = "#{path}?fal_webhook=#{CGI.escape(webhook_url)}" if webhook_url
|
|
54
|
+
attrs = client.post(path, body)
|
|
55
|
+
new(attrs, model_id: model_id, client: client)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Find the current status for a given request.
|
|
59
|
+
# Corresponds to GET https://queue.fal.run/{model_id}/requests/{request_id}/status
|
|
60
|
+
# @param id [String]
|
|
61
|
+
# @param model_id [String]
|
|
62
|
+
# @param logs [Boolean] include logs if true
|
|
63
|
+
# @param client [Fal::Client]
|
|
64
|
+
# @return [Fal::Request]
|
|
65
|
+
def find_by!(id:, model_id:, logs: false, client: Fal.client)
|
|
66
|
+
model_id_without_subpath = model_id.split("/").slice(0, 2).join("/")
|
|
67
|
+
attrs = client.get("/#{model_id_without_subpath}/requests/#{id}/status", query: (logs ? { logs: 1 } : nil))
|
|
68
|
+
new(attrs, model_id: model_id, client: client)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stream a synchronous request using SSE and yield response chunks as they arrive.
|
|
72
|
+
# It returns a Fal::Request initialized with the last streamed data in the response field.
|
|
73
|
+
# @param model_id [String]
|
|
74
|
+
# @param input [Hash]
|
|
75
|
+
# @param client [Fal::Client]
|
|
76
|
+
# @yield [chunk] yields each parsed chunk Hash from the stream
|
|
77
|
+
# @yieldparam chunk [Hash]
|
|
78
|
+
# @return [Fal::Request]
|
|
79
|
+
def stream!(model_id:, input:, client: Fal.client, &block)
|
|
80
|
+
path = "/#{model_id}/stream"
|
|
81
|
+
last_data = nil
|
|
82
|
+
|
|
83
|
+
Stream.new(path: path, input: input, client: client).each do |event|
|
|
84
|
+
data = event["data"]
|
|
85
|
+
last_data = data
|
|
86
|
+
block&.call(data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Wrap last chunk into a Request-like object for convenience
|
|
90
|
+
# Build attributes from last event, using inner response if available
|
|
91
|
+
response_payload = if last_data&.key?("response")
|
|
92
|
+
last_data["response"]
|
|
93
|
+
else
|
|
94
|
+
last_data
|
|
95
|
+
end
|
|
96
|
+
attrs = {
|
|
97
|
+
"request_id" => last_data && last_data["request_id"],
|
|
98
|
+
"status" => last_data && last_data["status"],
|
|
99
|
+
"response" => response_payload
|
|
100
|
+
}.compact
|
|
101
|
+
new(attrs, model_id: model_id, client: client)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [String] The model ID without the subpath
|
|
106
|
+
def model_id_without_subpath
|
|
107
|
+
@model_id.split("/").slice(0, 2).join("/")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reload the current status from the Queue API.
|
|
111
|
+
# @param logs [Boolean] include logs if true
|
|
112
|
+
# @return [Fal::Request]
|
|
113
|
+
def reload!(logs: false)
|
|
114
|
+
if @status == Status::IN_PROGRESS || @status == Status::IN_QUEUE
|
|
115
|
+
attrs = @client.get("/#{model_id_without_subpath}/requests/#{@id}/status", query: (logs ? { logs: 1 } : nil))
|
|
116
|
+
reset_attributes(attrs)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@response = @client.get("/#{model_id_without_subpath}/requests/#{@id}") if @status == Status::COMPLETED
|
|
120
|
+
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Attempt to cancel the request if still in queue.
|
|
125
|
+
# @return [Hash] cancellation response
|
|
126
|
+
def cancel!
|
|
127
|
+
@client.put("/#{model_id_without_subpath}/requests/#{@id}/cancel")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def in_queue?
|
|
132
|
+
@status == Status::IN_QUEUE
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Boolean]
|
|
136
|
+
def in_progress?
|
|
137
|
+
@status == Status::IN_PROGRESS
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean]
|
|
141
|
+
def completed?
|
|
142
|
+
@status == Status::COMPLETED
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Normalize attributes from different Queue API responses.
|
|
148
|
+
# @param attributes [Hash]
|
|
149
|
+
# @return [void]
|
|
150
|
+
def reset_attributes(attributes)
|
|
151
|
+
@id = attributes["request_id"] || @id
|
|
152
|
+
# Default to IN_QUEUE if no status provided and no previous status
|
|
153
|
+
@status = attributes["status"] || @status || Status::IN_QUEUE
|
|
154
|
+
@queue_position = attributes["queue_position"]
|
|
155
|
+
@logs = attributes["logs"]
|
|
156
|
+
@response = attributes["response"]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/fal/stream.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fal
|
|
4
|
+
# Streaming helper for Server-Sent Events from fal.run synchronous endpoints.
|
|
5
|
+
# It parses SSE lines and yields decoded event hashes with symbolized keys.
|
|
6
|
+
class Stream
|
|
7
|
+
# @return [String] endpoint path under fal.run, e.g. "/fal-ai/flux/dev/stream"
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
# @param path [String] full path under sync_base (leading slash), ex: "/fal-ai/flux/dev/stream"
|
|
11
|
+
# @param input [Hash] request input payload
|
|
12
|
+
# @param client [Fal::Client] HTTP client
|
|
13
|
+
def initialize(path:, input:, client: Fal.client)
|
|
14
|
+
@path = path
|
|
15
|
+
@input = input
|
|
16
|
+
@client = client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Stream events; yields a Hash for each event data chunk. Blocks until stream ends.
|
|
20
|
+
# @yield [event] yields decoded event hash
|
|
21
|
+
# @yieldparam event [Hash]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def each(&block)
|
|
24
|
+
buffer = ""
|
|
25
|
+
decoder = SSEDecoder.new
|
|
26
|
+
|
|
27
|
+
@client.post_stream(@path, @input, on_data: proc do |chunk, _total_bytes|
|
|
28
|
+
buffer = (buffer + chunk).gsub(/\r\n?/, "\n")
|
|
29
|
+
lines = buffer.split("\n", -1)
|
|
30
|
+
buffer = lines.pop || ""
|
|
31
|
+
lines.each do |line|
|
|
32
|
+
event = decoder.decode(line)
|
|
33
|
+
block.call(event) if event
|
|
34
|
+
end
|
|
35
|
+
end)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Minimal SSE decoder for parsing standard server-sent event stream lines.
|
|
39
|
+
class SSEDecoder
|
|
40
|
+
def initialize
|
|
41
|
+
@event = ""
|
|
42
|
+
@data = ""
|
|
43
|
+
@id = nil
|
|
44
|
+
@retry = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param line [String]
|
|
48
|
+
# @return [Hash, nil]
|
|
49
|
+
def decode(line)
|
|
50
|
+
return flush_event if line.empty?
|
|
51
|
+
return if line.start_with?(":")
|
|
52
|
+
|
|
53
|
+
field, _, value = line.partition(":")
|
|
54
|
+
value = value.lstrip
|
|
55
|
+
|
|
56
|
+
case field
|
|
57
|
+
when "event"
|
|
58
|
+
@event = value
|
|
59
|
+
when "data"
|
|
60
|
+
@data += "#{value}\n"
|
|
61
|
+
when "id"
|
|
62
|
+
@id = value
|
|
63
|
+
when "retry"
|
|
64
|
+
@retry = value.to_i
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def flush_event
|
|
73
|
+
return if @data.empty?
|
|
74
|
+
|
|
75
|
+
data = @data.chomp
|
|
76
|
+
parsed = JSON.parse(data)
|
|
77
|
+
|
|
78
|
+
event = { "data" => parsed }
|
|
79
|
+
event["event"] = @event unless @event.empty?
|
|
80
|
+
event["id"] = @id if @id
|
|
81
|
+
event["retry"] = @retry if @retry
|
|
82
|
+
|
|
83
|
+
@event = ""
|
|
84
|
+
@data = ""
|
|
85
|
+
@id = nil
|
|
86
|
+
@retry = nil
|
|
87
|
+
|
|
88
|
+
event
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/fal/version.rb
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fal
|
|
4
|
+
# WebhookRequest parses incoming webhook payloads from fal and exposes
|
|
5
|
+
# convenient helpers to inspect success/error and access the response payload.
|
|
6
|
+
# Follows Rails-like naming with predicate helpers.
|
|
7
|
+
class WebhookRequest
|
|
8
|
+
# Webhook status values.
|
|
9
|
+
module Status
|
|
10
|
+
# @return [String]
|
|
11
|
+
OK = "OK"
|
|
12
|
+
# @return [String]
|
|
13
|
+
ERROR = "ERROR"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] The request identifier
|
|
17
|
+
attr_reader :request_id
|
|
18
|
+
# @return [String, nil] The gateway request identifier, when present
|
|
19
|
+
attr_reader :gateway_request_id
|
|
20
|
+
# @return [String, nil] Webhook status (OK/ERROR) when provided
|
|
21
|
+
attr_reader :status
|
|
22
|
+
# @return [String, nil] Error message when provided
|
|
23
|
+
attr_reader :error
|
|
24
|
+
# @return [Hash, nil] Model-specific response payload
|
|
25
|
+
attr_reader :response
|
|
26
|
+
# @return [Array<Hash>, nil] Log entries, when present
|
|
27
|
+
attr_reader :logs
|
|
28
|
+
# @return [Hash, nil] Metrics, when present
|
|
29
|
+
attr_reader :metrics
|
|
30
|
+
# @return [Hash] The raw parsed payload
|
|
31
|
+
attr_reader :raw
|
|
32
|
+
|
|
33
|
+
# Initialize from a parsed payload Hash (string keys expected, tolerant of symbol keys).
|
|
34
|
+
# @param attributes [Hash]
|
|
35
|
+
def initialize(attributes)
|
|
36
|
+
@raw = attributes
|
|
37
|
+
reset_attributes(attributes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Build from a JSON string body.
|
|
42
|
+
# @param json [String]
|
|
43
|
+
# @return [Fal::WebhookRequest]
|
|
44
|
+
def from_json(json)
|
|
45
|
+
new(JSON.parse(json))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build from a Rack::Request.
|
|
49
|
+
# @param req [#body]
|
|
50
|
+
# @return [Fal::WebhookRequest]
|
|
51
|
+
def from_rack_request(req)
|
|
52
|
+
body = req.body.read
|
|
53
|
+
req.body.rewind if req.body.respond_to?(:rewind)
|
|
54
|
+
from_json(body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build from a Hash payload.
|
|
58
|
+
# @param payload [Hash]
|
|
59
|
+
# @return [Fal::WebhookRequest]
|
|
60
|
+
def from_hash(payload)
|
|
61
|
+
new(payload)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def success?
|
|
67
|
+
@status == Status::OK || (
|
|
68
|
+
@status.nil? && @error.nil?
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def error?
|
|
74
|
+
!success?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Back-compat alias matching older naming (payload vs response).
|
|
78
|
+
# @return [Hash, nil]
|
|
79
|
+
def payload
|
|
80
|
+
@response
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] Any nested error detail
|
|
84
|
+
def error_detail = @response&.dig(:detail)
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def reset_attributes(attributes)
|
|
89
|
+
attributes = Fal.deep_symbolize_keys(attributes)
|
|
90
|
+
@request_id = attributes[:request_id]
|
|
91
|
+
@gateway_request_id = attributes[:gateway_request_id]
|
|
92
|
+
@status = attributes[:status]
|
|
93
|
+
@error = attributes[:error]
|
|
94
|
+
@response = attributes[:payload]
|
|
95
|
+
@logs = attributes[:logs]
|
|
96
|
+
@metrics = attributes[:metrics]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/fal.rb
CHANGED
|
@@ -2,6 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "time"
|
|
5
|
+
require "json"
|
|
6
|
+
require "cgi"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "fal/version"
|
|
5
10
|
|
|
6
11
|
module Fal
|
|
12
|
+
# Base error class for all fal-related errors.
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
# Raised when a request is unauthorized (HTTP 401).
|
|
15
|
+
class UnauthorizedError < Error; end
|
|
16
|
+
# Raised when a requested resource is not found (HTTP 404).
|
|
17
|
+
class NotFoundError < Error; end
|
|
18
|
+
# Raised when the server returns an unexpected error (HTTP 5xx or others).
|
|
19
|
+
class ServerError < Error; end
|
|
20
|
+
# Raised when the client is misconfigured.
|
|
21
|
+
class ConfigurationError < Error; end
|
|
22
|
+
# Raised when access is forbidden (HTTP 403).
|
|
23
|
+
class ForbiddenError < Error; end
|
|
24
|
+
|
|
25
|
+
# Global configuration for the fal client.
|
|
26
|
+
class Configuration
|
|
27
|
+
DEFAULT_QUEUE_BASE = "https://queue.fal.run"
|
|
28
|
+
DEFAULT_SYNC_BASE = "https://fal.run"
|
|
29
|
+
DEFAULT_REQUEST_TIMEOUT = 120
|
|
30
|
+
|
|
31
|
+
# API key used for authenticating with fal endpoints.
|
|
32
|
+
# Defaults to ENV["FAL_KEY"].
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_accessor :api_key
|
|
35
|
+
|
|
36
|
+
# Base URL for fal queue endpoints.
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_accessor :queue_base
|
|
39
|
+
|
|
40
|
+
# Base URL for synchronous streaming endpoints (fal.run).
|
|
41
|
+
# @return [String]
|
|
42
|
+
attr_accessor :sync_base
|
|
43
|
+
|
|
44
|
+
# Timeout in seconds for opening and processing HTTP requests.
|
|
45
|
+
# @return [Integer]
|
|
46
|
+
attr_accessor :request_timeout
|
|
47
|
+
|
|
48
|
+
# Initialize configuration with sensible defaults.
|
|
49
|
+
# @return [Fal::Configuration]
|
|
50
|
+
def initialize
|
|
51
|
+
@api_key = ENV.fetch("FAL_KEY", nil)
|
|
52
|
+
@queue_base = DEFAULT_QUEUE_BASE
|
|
53
|
+
@sync_base = DEFAULT_SYNC_BASE
|
|
54
|
+
@request_timeout = DEFAULT_REQUEST_TIMEOUT
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# The global configuration instance.
|
|
60
|
+
# @return [Fal::Configuration]
|
|
61
|
+
attr_accessor :configuration
|
|
62
|
+
|
|
63
|
+
# Configure the fal client.
|
|
64
|
+
# @yield [Fal::Configuration] the configuration object to mutate
|
|
65
|
+
# @return [void]
|
|
66
|
+
def configure
|
|
67
|
+
self.configuration ||= Configuration.new
|
|
68
|
+
yield(configuration)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Global client accessor using the configured settings.
|
|
72
|
+
# @return [Fal::Client]
|
|
73
|
+
def client
|
|
74
|
+
configuration = self.configuration || Configuration.new
|
|
75
|
+
@client ||= Fal::Client.new(configuration)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Deep symbolize keys of a Hash or Array.
|
|
79
|
+
# @param obj [Hash, Array]
|
|
80
|
+
# @return [Hash, Array]
|
|
81
|
+
def deep_symbolize_keys(obj)
|
|
82
|
+
case obj
|
|
83
|
+
when Hash
|
|
84
|
+
obj.each_with_object({}) do |(k, v), result|
|
|
85
|
+
key = k.is_a?(String) ? k.to_sym : k
|
|
86
|
+
result[key] = deep_symbolize_keys(v)
|
|
87
|
+
end
|
|
88
|
+
when Array
|
|
89
|
+
obj.map { |e| deep_symbolize_keys(e) }
|
|
90
|
+
else
|
|
91
|
+
obj
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
7
95
|
end
|
|
96
|
+
|
|
97
|
+
require_relative "fal/client"
|
|
98
|
+
require_relative "fal/request"
|
|
99
|
+
require_relative "fal/stream"
|
|
100
|
+
require_relative "fal/webhook_request"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dylan Player
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-10-
|
|
11
|
+
date: 2025-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -31,6 +31,7 @@ executables: []
|
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
33
33
|
files:
|
|
34
|
+
- ".env.example"
|
|
34
35
|
- ".rubocop.yml"
|
|
35
36
|
- ".ruby-version"
|
|
36
37
|
- Gemfile
|
|
@@ -40,7 +41,11 @@ files:
|
|
|
40
41
|
- Rakefile
|
|
41
42
|
- fal.gemspec
|
|
42
43
|
- lib/fal.rb
|
|
44
|
+
- lib/fal/client.rb
|
|
45
|
+
- lib/fal/request.rb
|
|
46
|
+
- lib/fal/stream.rb
|
|
43
47
|
- lib/fal/version.rb
|
|
48
|
+
- lib/fal/webhook_request.rb
|
|
44
49
|
homepage: https://github.com/851-labs/fal
|
|
45
50
|
licenses:
|
|
46
51
|
- MIT
|