vision_api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +451 -0
- data/lib/vision_api/client.rb +431 -0
- data/lib/vision_api/errors.rb +239 -0
- data/lib/vision_api/multipart.rb +106 -0
- data/lib/vision_api/result.rb +111 -0
- data/lib/vision_api/version.rb +5 -0
- data/lib/vision_api/webhook.rb +94 -0
- data/lib/vision_api.rb +31 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 026b5d01ade4e97dc286a8a50ba1da90c3fb66168ff9211f885448a20fe19ccd
|
|
4
|
+
data.tar.gz: 65cd70231e951a869a7d42330679f1c3069751056262753b98f583c1936db0f0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 870f53100ba29c39e4441df6241242b10a5d2b2aea565d719795a4dc7acfdf619efeb71d274430866030830bba5b3bbcd687444784643e24dddc7a084d27f81e
|
|
7
|
+
data.tar.gz: 0e6203b0e6b123135892acdd95792869b1becda6192afd9ce71998cbf79e5b7cfcc11c3c3c153091464cb5584bc9546212d4dd0daa10d62488cb3da2fe529fdd
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the gem follows
|
|
5
|
+
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] — 2026-08-09
|
|
8
|
+
|
|
9
|
+
First public release.
|
|
10
|
+
|
|
11
|
+
- `analyze`, `analyze_async`, `analyze_and_wait`, `ask`, `ask_async`, `detect`
|
|
12
|
+
- `get_task`, `wait_for_task`, `credits`, `requests`, `each_request`
|
|
13
|
+
- `presets`, `preset`, and the saved-schema endpoints
|
|
14
|
+
- Exception hierarchy over the API's `error.code` contract
|
|
15
|
+
- Retries that honor `Retry-After`, with an automatic `Idempotency-Key` on every
|
|
16
|
+
billable POST so a retry replays rather than re-charges
|
|
17
|
+
- `VisionAPI::Webhook.verify` — constant-time, rotation-aware, over the raw bytes
|
|
18
|
+
- `VisionAPI::Result` helpers that understand line-item arrays, whose cells are
|
|
19
|
+
wrapped individually
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vision API
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# Vision API — Ruby client
|
|
2
|
+
|
|
3
|
+
Official Ruby client for [Vision API](https://visionapi.io) — send an image or a PDF,
|
|
4
|
+
describe the fields you want in plain language, get structured JSON back with a confidence
|
|
5
|
+
level on every value.
|
|
6
|
+
|
|
7
|
+
[](https://rubygems.org/gems/vision_api)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
|
|
10
|
+
- **Website** — <https://visionapi.io>
|
|
11
|
+
- **Documentation** — <https://docs.visionapi.io>
|
|
12
|
+
- **API keys** — <https://app.visionapi.io/dashboard/keys>
|
|
13
|
+
- **Preset catalog** — <https://visionapi.io/presets>
|
|
14
|
+
- **Playground** — <https://visionapi.io/playground>
|
|
15
|
+
- **Support** — <https://support.visionapi.io> · <https://visionapi.io/contact-us>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "vision_api"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install vision_api
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Ruby 3.0+. No runtime dependencies — `net/http`, `json` and `openssl` are all standard
|
|
31
|
+
library.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "vision_api"
|
|
37
|
+
|
|
38
|
+
vision = VisionAPI.new # reads ENV["VISION_API_KEY"]
|
|
39
|
+
|
|
40
|
+
res = vision.analyze(file: "invoice.pdf", preset: "invoice")
|
|
41
|
+
|
|
42
|
+
res["result"]["invoice_id"]["value"] # => "A-10422"
|
|
43
|
+
res["result"]["total"]["value"] # => 1284.5, or nil if the invoice has no total
|
|
44
|
+
res["credits_used"] # => 3
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requests are metered in credits, per image and per *selected* PDF page — see
|
|
48
|
+
[pricing](https://visionapi.io/pricing) for current rates. Failures cost nothing: the
|
|
49
|
+
reservation is released in full on any non-2xx, so there is no compensating logic to write.
|
|
50
|
+
|
|
51
|
+
> **Server-side only.** There is no publishable key and no test mode — an API key is a live
|
|
52
|
+
> spending credential. Keep it in the environment (or Rails credentials), never in a
|
|
53
|
+
> repository and never in anything a browser downloads.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Reading a result
|
|
58
|
+
|
|
59
|
+
Responses are plain hashes with the wire's keys, so everything you already know about
|
|
60
|
+
hashes applies. Two rules explain almost every surprise:
|
|
61
|
+
|
|
62
|
+
**1. Every scalar is wrapped.** `{"value" => …, "confidence" => "low"|"mid"|"high"}`. Read
|
|
63
|
+
`res["result"]["total"]["value"]`, not `res["result"]["total"]`.
|
|
64
|
+
|
|
65
|
+
**2. A preset response contains every field of that preset** — including the ones the
|
|
66
|
+
document does not carry, which come back as `{"value" => nil, "confidence" => "low"}`. A
|
|
67
|
+
key being present does not mean a value was found.
|
|
68
|
+
|
|
69
|
+
Line-item arrays are the one shape worth looking at twice. The array itself is *not*
|
|
70
|
+
wrapped; each **cell** inside each row is:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
{
|
|
74
|
+
"invoice_id" => { "value" => "A-10422", "confidence" => "high" },
|
|
75
|
+
"carrier" => { "value" => nil, "confidence" => "low" },
|
|
76
|
+
"line_item" => [
|
|
77
|
+
{ "description" => { "value" => "Widget", "confidence" => "high" },
|
|
78
|
+
"quantity" => { "value" => 2, "confidence" => "high" },
|
|
79
|
+
"amount" => { "value" => 25.0, "confidence" => "mid" } }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`VisionAPI::Result` covers the common readings, so you rarely have to spell that out:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
include VisionAPI::Result # or call them as VisionAPI::Result.unwrap(…)
|
|
88
|
+
|
|
89
|
+
unwrap(res["result"])
|
|
90
|
+
# => {"invoice_id" => "A-10422", "carrier" => nil, "line_item" => [{"description" => "Widget", …}]}
|
|
91
|
+
|
|
92
|
+
unwrap(res["result"], drop_null: true) # only what was actually found
|
|
93
|
+
value(res["result"], "total", 0) # 1284.5, or 0 when absent
|
|
94
|
+
rows(res["result"], "line_item") # [] when the invoice has no lines
|
|
95
|
+
present(res["result"]) # ["invoice_id", "total", "line_item"]
|
|
96
|
+
missing(res["result"]) # ["carrier", …]
|
|
97
|
+
below_confidence(res["result"], "high") # fields to route to a human
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## What you can send
|
|
103
|
+
|
|
104
|
+
Exactly one file source per call:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
vision.analyze(file: "invoice.pdf", preset: "invoice") # a path
|
|
108
|
+
vision.analyze(file: Pathname("invoice.pdf"), preset: "invoice") # a Pathname
|
|
109
|
+
vision.analyze(file: File.open("invoice.pdf", "rb"), …) # an open binary IO
|
|
110
|
+
vision.analyze(file: ["scan.png", bytes], …) # bytes + a name
|
|
111
|
+
vision.analyze(file_url: "https://example.com/invoice.pdf", …) # a public URL
|
|
112
|
+
vision.analyze(file_base64: encoded, …) # "data:" prefix optional
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
JPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic
|
|
116
|
+
bytes — the filename is ignored.
|
|
117
|
+
|
|
118
|
+
### Options
|
|
119
|
+
|
|
120
|
+
| Keyword | Default | What it does |
|
|
121
|
+
| ------------------ | ------------ | ------------------------------------------------------------------------- |
|
|
122
|
+
| `preset:` | — | A catalog name, or `"auto"` to let the API classify the file first (free). |
|
|
123
|
+
| `schema:` | — | Custom fields, alone or on top of a preset. |
|
|
124
|
+
| `schema_name:` | — | A schema saved in your dashboard. Excludes `preset:` and `schema:`. |
|
|
125
|
+
| `pages:` | all | PDF page selection, e.g. `"1-3,7"`. You pay for selected pages only. |
|
|
126
|
+
| `language_hint:` | auto | ISO 639-1 code, e.g. `"es"`. |
|
|
127
|
+
| `detail:` | `"standard"` | `"high"` renders pages at higher resolution. Same cost, slower. |
|
|
128
|
+
| `output:` | `"json"` | `"text"` returns raw OCR text instead of fields. |
|
|
129
|
+
| `include_raw_text:`| `false` | Adds `full_text`, the whole transcription, alongside `result`. |
|
|
130
|
+
| `min_confidence:` | `"low"` | Fields below the level come back nil, with confidence preserved. |
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Custom fields
|
|
135
|
+
|
|
136
|
+
A schema is a flat hash: each key is a field name, each value describes what to extract.
|
|
137
|
+
It is compiled **before** any credit moves, so a bad schema costs nothing.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
res = vision.analyze(
|
|
141
|
+
file: "invoice.pdf",
|
|
142
|
+
preset: "invoice",
|
|
143
|
+
schema: {
|
|
144
|
+
# Plain form — the string is the description, type defaults to string.
|
|
145
|
+
"machine_serial" => 'Serial number of the machine being invoiced, without the "SN:" prefix',
|
|
146
|
+
|
|
147
|
+
# Typed form.
|
|
148
|
+
"total_net" => { "type" => "number", "description" => "Total before tax" },
|
|
149
|
+
"signed_on" => { "type" => "date", "description" => "Date the contract was signed" },
|
|
150
|
+
|
|
151
|
+
# Reserved key: injects fields into every row of the preset's line-item array.
|
|
152
|
+
"line_item" => { "lot_number" => "The lot number printed on the line, if present" }
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Field names must match `^[a-z][a-z0-9_]{0,63}$`. Types are `string` (default), `number`,
|
|
158
|
+
`boolean`, `date`, `array` and `object`. A custom name that collides with a preset field is
|
|
159
|
+
a 422 `schema_field_conflict` — rename it, or use the preset's own field.
|
|
160
|
+
|
|
161
|
+
**Descriptions are the prompt.** "The invoice number exactly as printed, without the `#`"
|
|
162
|
+
extracts better than "invoice number". Say what to do when the value is missing or
|
|
163
|
+
ambiguous if it matters.
|
|
164
|
+
|
|
165
|
+
Reuse a combination by saving it:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
vision.create_schema("our-invoices", preset: "invoice", schema: { "machine_serial" => "…" })
|
|
169
|
+
vision.analyze(file: "invoice.pdf", schema_name: "our-invoices")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Picking a preset
|
|
175
|
+
|
|
176
|
+
28 presets ship with the API. Fetch the catalog rather than hardcoding field names from
|
|
177
|
+
memory — presets are versioned, and the catalog is the source of truth:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
vision.presets.each { |p| puts "#{p['name']} (#{p['kind']}) — #{p['field_count']} fields" }
|
|
181
|
+
vision.preset("invoice")["fields"].map { |f| f["name"] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Three ways to choose:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# 1. You know what it is.
|
|
188
|
+
vision.analyze(file: "receipt.jpg", preset: "receipt")
|
|
189
|
+
|
|
190
|
+
# 2. You don't, and you want the data anyway. Classification is free.
|
|
191
|
+
res = vision.analyze(file: "unknown.pdf", preset: "auto")
|
|
192
|
+
res["detection"]["preset"] # what ran
|
|
193
|
+
res["detection"]["fallback"] # true = "shape unknown", not a match
|
|
194
|
+
res["detection"]["alternatives"] # the rest of the ranking, best first
|
|
195
|
+
|
|
196
|
+
# 3. The *type* is the decision — routing a mixed inbox, or refusing to spend
|
|
197
|
+
# on a 40-page PDF until you know what it is. Far cheaper than extracting.
|
|
198
|
+
guess = vision.detect(file: "unknown.pdf")
|
|
199
|
+
vision.analyze(file: "unknown.pdf", preset: guess["recommended"]) unless guess["fallback"]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`detect` reads page 1 only, so an image and a 300-page PDF cost the same, and it is metered
|
|
203
|
+
in batches rather than per call: most calls report `credits_used` 0 and an occasional one
|
|
204
|
+
carries the charge. See [pricing](https://visionapi.io/pricing) for the rate.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Questions instead of fields
|
|
209
|
+
|
|
210
|
+
Up to 5 questions about one file, priced exactly like an extraction. The questions
|
|
211
|
+
themselves are free.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
res = vision.ask(
|
|
215
|
+
file: "photo.jpg",
|
|
216
|
+
questions: ["Is there a dog in the image?", "How many people are visible?"]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
res["answers"].each do |a|
|
|
220
|
+
case a["verdict"]
|
|
221
|
+
when "yes", "no" then handle(a["verdict"])
|
|
222
|
+
when "uncertain" then flag_for_review(a) # the image does not settle it — a real answer
|
|
223
|
+
when "n/a" then puts a["answer"] # it wasn't a yes/no question
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Long jobs: async and webhooks
|
|
231
|
+
|
|
232
|
+
Synchronous requests are killed at 60 seconds with a 504 `sync_timeout`. Anything that
|
|
233
|
+
might run longer — a long PDF, `detail: "high"`, a batch — belongs on the queue.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# Submit, then poll. wait_for_task handles the loop and the failure case.
|
|
237
|
+
task = vision.analyze_and_wait(
|
|
238
|
+
file: "contract-80-pages.pdf",
|
|
239
|
+
preset: "contract",
|
|
240
|
+
pages: "1-50",
|
|
241
|
+
poll_interval: 2,
|
|
242
|
+
max_wait: 900,
|
|
243
|
+
on_poll: ->(t) { Rails.logger.info(t["status"]) }
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Or submit and walk away — the result comes to you.
|
|
247
|
+
ref = vision.analyze_async(
|
|
248
|
+
file: "contract.pdf",
|
|
249
|
+
preset: "contract",
|
|
250
|
+
webhook_url: "https://yourapp.com/hooks/vision"
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Results stay retrievable for 7 days; after that `get_task` raises `ResultExpiredError`
|
|
255
|
+
(metadata survives, the payload does not).
|
|
256
|
+
|
|
257
|
+
### Verifying a delivery
|
|
258
|
+
|
|
259
|
+
Deliveries are signed. Verify over the **raw bytes** before parsing — a re-serialized body
|
|
260
|
+
has different bytes and will not match.
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class VisionHooksController < ApplicationController
|
|
264
|
+
skip_before_action :verify_authenticity_token
|
|
265
|
+
|
|
266
|
+
def create
|
|
267
|
+
event = VisionAPI::Webhook.verify(
|
|
268
|
+
request.raw_post,
|
|
269
|
+
request.headers["X-Vision-Signature"],
|
|
270
|
+
ENV.fetch("VISION_WEBHOOK_SECRET")
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
VisionResultJob.perform_later(event) # any 2xx is success — ack fast, work afterwards
|
|
274
|
+
head :accepted
|
|
275
|
+
rescue VisionAPI::WebhookSignatureError
|
|
276
|
+
head :bad_request # never parse an unverified body
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`VisionAPI::Webhook.verify` rejects a bad signature, a malformed header and a timestamp
|
|
282
|
+
more than 5 minutes old, and accepts a delivery if **any** `v1=` part matches — which is
|
|
283
|
+
what makes a secret rotation seamless. Get the secret from
|
|
284
|
+
<https://app.visionapi.io/dashboard/webhooks>. Failed deliveries retry at +1 m, +5 m,
|
|
285
|
+
+15 m and +40 m, then stop.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Errors
|
|
290
|
+
|
|
291
|
+
Every failure raises a subclass of `VisionAPI::APIError` carrying the HTTP `status`, the
|
|
292
|
+
stable `code`, and whatever `details` the endpoint attached. Rescue the class you mean, or
|
|
293
|
+
switch on `code` — never on the message text, which is prose and changes.
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
begin
|
|
297
|
+
res = vision.analyze(file: "scan.pdf", preset: "invoice")
|
|
298
|
+
rescue VisionAPI::InsufficientCreditsError => e
|
|
299
|
+
alert_ops("needs #{e.required}, has #{e.available}") # never retried — it cannot succeed
|
|
300
|
+
rescue VisionAPI::SyncTimeoutError
|
|
301
|
+
task = vision.analyze_and_wait(file: "scan.pdf", preset: "invoice")
|
|
302
|
+
rescue VisionAPI::UnsupportedTypeError
|
|
303
|
+
quarantine("not an image or a PDF")
|
|
304
|
+
rescue VisionAPI::APIError => e
|
|
305
|
+
Rails.logger.error("vision #{e.code} (#{e.status}) request_id=#{e.request_id}")
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
| Class | HTTP | Codes |
|
|
310
|
+
| -------------------------- | ---- | -------------------------------------------------------------------------------------------------- |
|
|
311
|
+
| `InvalidRequestError` | 400 | `invalid_request` |
|
|
312
|
+
| `AuthenticationError` | 401 | `invalid_api_key`, `unauthorized` |
|
|
313
|
+
| `InsufficientCreditsError` | 402 | `insufficient_credits` — with `#required` / `#available` |
|
|
314
|
+
| `PermissionDeniedError` | 403 | `forbidden`, `email_not_verified` |
|
|
315
|
+
| `NotFoundError` | 404 | `task_not_found`, `schema_not_found` |
|
|
316
|
+
| `ConflictError` | 409 | `conflict` |
|
|
317
|
+
| `ResultExpiredError` | 410 | `result_expired` |
|
|
318
|
+
| `PayloadTooLargeError` | 413 | `file_too_large`, `page_limit_exceeded` |
|
|
319
|
+
| `UnsupportedTypeError` | 415 | `unsupported_type` |
|
|
320
|
+
| `UnprocessableError` | 422 | `pdf_encrypted`, `invalid_page_selection`, `invalid_schema`, `schema_field_conflict`, `too_many_questions` |
|
|
321
|
+
| `RateLimitError` | 429 | `rate_limited` — with `#retry_after` |
|
|
322
|
+
| `TooManyTasksError` | 429 | `too_many_tasks` — the per-plan async concurrency cap, with `#max_tasks`. Subclasses `RateLimitError`, but is not auto-retried: it clears when one of *your* tasks finishes |
|
|
323
|
+
| `InternalError` | 500 | `internal_error` — with `#request_id` |
|
|
324
|
+
| `ProviderError` | 502 | `provider_error` |
|
|
325
|
+
| `SyncTimeoutError` | 504 | `sync_timeout` |
|
|
326
|
+
|
|
327
|
+
`UsageError` (bad arguments), `ConnectionError` / `TimeoutError` (the request never got a
|
|
328
|
+
response) and `TaskFailedError` / `TaskTimeoutError` come from the client itself.
|
|
329
|
+
|
|
330
|
+
### Retries and idempotency
|
|
331
|
+
|
|
332
|
+
The client retries 429, 500, 502 and network failures — three attempts by default, with the
|
|
333
|
+
server's own `Retry-After` honored on 429 and exponential backoff with jitter elsewhere.
|
|
334
|
+
Input errors and `insufficient_credits` are never retried, because they cannot succeed.
|
|
335
|
+
|
|
336
|
+
Every billable POST is sent with a generated `Idempotency-Key`, so a retried upload replays
|
|
337
|
+
the first response instead of paying twice. Supply your own when the *caller* may retry — a
|
|
338
|
+
Sidekiq job that re-runs, a queue that redelivers — because a fresh process generates a
|
|
339
|
+
fresh key:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
vision.analyze(file: path, preset: "invoice", idempotency_key: "invoice-#{invoice.id}")
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Reusing a key with a *different* payload raises `ConflictError`, which is the mechanism
|
|
346
|
+
working: it means the key already stands for something else.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Configuration
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
vision = VisionAPI.new(
|
|
354
|
+
api_key: ENV["VISION_API_KEY"], # default: ENV["VISION_API_KEY"]
|
|
355
|
+
base_url: "https://api.visionapi.io", # default; override for a self-hosted deployment
|
|
356
|
+
timeout: 120, # per request, seconds
|
|
357
|
+
max_retries: 3,
|
|
358
|
+
auto_idempotency: true,
|
|
359
|
+
headers: { "X-Trace-Id" => trace_id } # sent on every request
|
|
360
|
+
)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Every method takes per-call `idempotency_key:` and `timeout:`.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Account and usage
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
credits = vision.credits
|
|
371
|
+
credits["balance"] # buckets are spent in order: subscription → rollover → pack → welcome
|
|
372
|
+
|
|
373
|
+
vision.each_request(limit: 100) do |record|
|
|
374
|
+
puts [record["created_at"], record["endpoint"], record["preset"], record["credits_used"]].join(" ")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# each_request without a block returns an Enumerator, so this fetches one page:
|
|
378
|
+
vision.each_request.first(10)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Usage history is metadata only — never the file, never the extracted values. Uploaded files
|
|
382
|
+
are never retained: a synchronous request holds yours in memory for the length of the call, and
|
|
383
|
+
an async request stages it only until the worker finishes with it.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Limits
|
|
388
|
+
|
|
389
|
+
Same for everyone:
|
|
390
|
+
|
|
391
|
+
| Limit | Value |
|
|
392
|
+
| ------------------------- | ----- |
|
|
393
|
+
| Max file size | 20 MB |
|
|
394
|
+
| Max PDF pages per request | 50 |
|
|
395
|
+
| Sync request timeout | 60 s |
|
|
396
|
+
|
|
397
|
+
Per plan:
|
|
398
|
+
|
|
399
|
+
| Limit | Free | Starter | Growth | Pro | Scale |
|
|
400
|
+
| ---------------------------- | ---- | ------- | ------ | --- | --------- |
|
|
401
|
+
| Requests per minute, per key | 10 | 60 | 120 | 300 | 600 |
|
|
402
|
+
| Burst capacity | 20 | 120 | 240 | 600 | 1,200 |
|
|
403
|
+
| Concurrent async tasks | 1 | 4 | 8 | 16 | 32 |
|
|
404
|
+
| Active API keys per account | 1 | 5 | 10 | 20 | 50 |
|
|
405
|
+
| Saved schemas | 3 | 10 | 25 | 100 | unlimited |
|
|
406
|
+
| Max questions per `ask` | 5 | 5 | 5 | 10 | 10 |
|
|
407
|
+
|
|
408
|
+
The rate-limit bucket is per **API key**, not per account — splitting a workload across
|
|
409
|
+
keys splits the limit too. The concurrency cap is per *account* and does not split that way:
|
|
410
|
+
over it, an async submission answers 429 `too_many_tasks` and is charged nothing.
|
|
411
|
+
Higher limits on paid plans: <https://visionapi.io/pricing>.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## Examples
|
|
416
|
+
|
|
417
|
+
Runnable scripts in [`examples/`](./examples):
|
|
418
|
+
|
|
419
|
+
| File | What it shows |
|
|
420
|
+
| ------------------------------------------------------------- | ------------------------------------------------------ |
|
|
421
|
+
| [`analyze.rb`](./examples/analyze.rb) | The smallest useful call, and how to read the result |
|
|
422
|
+
| [`custom_schema.rb`](./examples/custom_schema.rb) | Custom fields, line-item injection, saved schemas |
|
|
423
|
+
| [`detect_then_analyze.rb`](./examples/detect_then_analyze.rb) | Routing a mixed inbox before spending on extraction |
|
|
424
|
+
| [`async_batch.rb`](./examples/async_batch.rb) | A folder of long PDFs, queued with bounded concurrency |
|
|
425
|
+
| [`webhook_server.rb`](./examples/webhook_server.rb) | A verified receiver, with no framework |
|
|
426
|
+
| [`ask.rb`](./examples/ask.rb) | Visual Q&A and the `verdict` field |
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
export VISION_API_KEY=sk_live_…
|
|
430
|
+
ruby examples/analyze.rb invoice.pdf
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Development
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
bundle install
|
|
439
|
+
rake test # offline: a stub server stands in for the API, no key needed
|
|
440
|
+
rubocop
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Contributing
|
|
444
|
+
|
|
445
|
+
Issues and pull requests are welcome at
|
|
446
|
+
<https://github.com/devrobotlabs/visionapi-ruby>. For anything about the API itself — a
|
|
447
|
+
preset, a limit, an error code — <https://support.visionapi.io> reaches the team faster.
|
|
448
|
+
|
|
449
|
+
## License
|
|
450
|
+
|
|
451
|
+
[MIT](./LICENSE) © Vision API
|