typesafe-sdk-ruby 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +81 -1
- data/LICENSE +1 -0
- data/README.md +365 -94
- data/docs/COMPATIBILITY.md +119 -0
- data/docs/RELEASING.md +38 -0
- data/docs/UPSTREAM_ISSUES.md +74 -0
- data/lib/typesafe/sdk/assets.rb +326 -0
- data/lib/typesafe/sdk/client.rb +98 -30
- data/lib/typesafe/sdk/compression.rb +50 -0
- data/lib/typesafe/sdk/errors.rb +35 -6
- data/lib/typesafe/sdk/http.rb +58 -9
- data/lib/typesafe/sdk/logging.rb +19 -7
- data/lib/typesafe/sdk/questions.rb +74 -35
- data/lib/typesafe/sdk/resources/models.rb +6 -4
- data/lib/typesafe/sdk/retry.rb +4 -4
- data/lib/typesafe/sdk/types.rb +69 -11
- data/lib/typesafe/sdk/version.rb +3 -2
- data/lib/typesafe-sdk-ruby.rb +30 -0
- metadata +17 -7
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# JavaScript SDK compatibility
|
|
2
|
+
|
|
3
|
+
This unofficial Ruby client targets the official `@typesafe-ai/sdk` **0.7.0** release,
|
|
4
|
+
commit [`b844cd1aca12855a5d24a2df52a1401c86e7d37a`](https://github.com/typesafe-ai/typesafe-sdk-js/tree/b844cd1aca12855a5d24a2df52a1401c86e7d37a).
|
|
5
|
+
Audit date: 2026-10-16. This is a tested compatibility target, not a claim of endorsement
|
|
6
|
+
or a guarantee about future API changes.
|
|
7
|
+
|
|
8
|
+
## What is covered
|
|
9
|
+
|
|
10
|
+
| Surface | Ruby behavior |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| Endpoints | `POST /v1/systemone`, `GET /v1/models` |
|
|
13
|
+
| Defaults | `jev-latest`, 10-second attempt timeout, two retries |
|
|
14
|
+
| Questions | Noul, choice, ordered score criteria, and box; structured instructions/state and nullable choice descriptions |
|
|
15
|
+
| Images | `image`, `Image.from_base64`, images in content, `with_assets`/`join_assets` text, named `assets:`; multipart encoding, part names, content types, and reference validation |
|
|
16
|
+
| Compression | zstd for bodies of 1 KiB or more (UTF-8 bytes), `compression: false`, protected `Content-Encoding`, same bytes on every retry |
|
|
17
|
+
| Serialization | Non-finite floats sent as `"NaN"`, `"Infinity"`, `"-Infinity"`; the request is copied when the call starts |
|
|
18
|
+
| Configuration | Explicit settings override trimmed environment values |
|
|
19
|
+
| Retries | 408, 429, 500–599; connection and timeout failures independently configurable |
|
|
20
|
+
| Server delays | Fractional milliseconds/seconds, HTTP dates, maximum delay and jitter |
|
|
21
|
+
| Headers | Case-insensitive overrides; protected auth, content type, SDK and retry headers |
|
|
22
|
+
| Errors | HTTP subclasses, validation messages, request IDs, connection/timeout/cancellation errors |
|
|
23
|
+
| Transport | Timeout includes full body delivery; cancellation interrupts an active attempt and backoff; truncated bodies are retried |
|
|
24
|
+
| Results | Typed answers (including box answers and nullable usage), `to_h` with unmodeled fields; model cards; `with_response: true` for HTTP metadata |
|
|
25
|
+
| Extension fields | `extra_body:` forwards additional JSON fields without overriding named arguments |
|
|
26
|
+
|
|
27
|
+
The checked-in `spec/fixtures/js-0.7.0.json` was generated by executing the published
|
|
28
|
+
JavaScript package, rather than by copying expected output from the Ruby implementation.
|
|
29
|
+
`parity_spec.rb` compares question JSON, request payloads (including non-finite numbers),
|
|
30
|
+
multipart requests and parts with images in content, text, and named assets, `withAssets.join`
|
|
31
|
+
text, asset validation messages, base64 decoding, compression thresholds, metadata, error
|
|
32
|
+
mapping/messages, complete answer fields (including box answers, fractional scores, and string
|
|
33
|
+
rubric keys), retry header parsing, and nullable retry flags. Image references are random, so
|
|
34
|
+
the fixture replaces them with stable labels. `http_spec.rb` uses real loopback sockets to exercise behavior
|
|
35
|
+
that mocked HTTP adapters cannot verify. Other specs cover Ruby configuration and logging.
|
|
36
|
+
Known upstream bugs are corrected explicitly in the comparisons; the fixtures retain the
|
|
37
|
+
original JS behavior for those cases.
|
|
38
|
+
See the [issue-by-issue audit](UPSTREAM_ISSUES.md) for every upstream issue reviewed.
|
|
39
|
+
|
|
40
|
+
To regenerate the fixtures with Node.js 22.15 or newer, unpack the official package
|
|
41
|
+
(`npm pack @typesafe-ai/sdk@0.7.0`, or the asset from the
|
|
42
|
+
[GitHub release](https://github.com/typesafe-ai/typesafe-sdk-js/releases/tag/v0.7.0)) and run:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
node script/generate_parity_fixtures.cjs /path/to/package > spec/fixtures/js-0.7.0.json
|
|
46
|
+
bundle exec rake
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Intentional Ruby differences and limits
|
|
50
|
+
|
|
51
|
+
- Images: Ruby accepts a binary `String`, a `Pathname` (read when the request is sent, like a
|
|
52
|
+
JavaScript `Blob`), or an IO (read immediately, since it may not be readable twice). Ruby has
|
|
53
|
+
no tagged template literals, so `with_assets(*parts)` joins text and images given as separate
|
|
54
|
+
arguments, converting other values with `to_s` (`nil` becomes `""`, as in interpolation; the
|
|
55
|
+
JavaScript template writes `null`). A Hash or Array part is written with `to_s`, which includes
|
|
56
|
+
the references of images inside it, so those images are attached; JavaScript writes
|
|
57
|
+
`[object Object]` and attaches none. `join_assets` defaults to `","`, as `withAssets.join` does.
|
|
58
|
+
- `assets:` must be a Hash of nonempty names to images; Ruby checks this before sending.
|
|
59
|
+
A request containing a cycle raises `TypeSafeError`; JavaScript raises a `TypeError`.
|
|
60
|
+
- Compression needs the optional `zstd-ruby` gem; it is detected once per process, as JS
|
|
61
|
+
detects Node.js zstd support. `compression:` accepts `:zstd`, `"zstd"`, or `false`, and
|
|
62
|
+
`client.compression` returns `:zstd` or `false`.
|
|
63
|
+
- JS 0.7.0 returns rejected promises instead of throwing for invalid requests. Ruby calls are
|
|
64
|
+
synchronous, so invalid requests and options raise `TypeSafeError` from the call, before any
|
|
65
|
+
image is read or request is sent.
|
|
66
|
+
- Ruby keeps its 0.6.0.1 validation messages. JS 0.7.0 adds similar checks for nil state and
|
|
67
|
+
empty noul questions with its own wording, and both now reject non-Hash noul criteria.
|
|
68
|
+
|
|
69
|
+
- Since 0.6.0.1, Ruby validates API keys without echoing their contents and rejects reported
|
|
70
|
+
API-invalid state/question shapes locally. Score levels must be non-nil and number 2–10;
|
|
71
|
+
choice options must number 1–255; noul requires instructions or an outcome description;
|
|
72
|
+
state must be a string, object, array, or image; question names cannot be empty.
|
|
73
|
+
Builders and raw question hashes use the same checks. This intentionally tightens the
|
|
74
|
+
JS 0.7.0 behavior, which still accepts nil score levels and unbounded score/choice sizes; local validation is not a complete server schema validator.
|
|
75
|
+
- Blank retry headers fall back to configured backoff (or another valid delay header).
|
|
76
|
+
JS 0.7.0 still treats blanks as zero. Literal zero remains valid in Ruby.
|
|
77
|
+
- Debug logs fully mask `x-api-key` and keep only an alphabetic authorization scheme followed
|
|
78
|
+
by a credential (upstream #18, unchanged in JS 0.7.0). Cloudflare HTML error pages get a
|
|
79
|
+
message naming the edge block and `cf-ray` ID (upstream #15).
|
|
80
|
+
- HTTP 402, 409, and 413 have `PaymentRequiredError`, `ConflictError`, and
|
|
81
|
+
`PayloadTooLargeError` subclasses. Existing `APIError` rescue clauses still catch them.
|
|
82
|
+
- Methods/options use snake_case. `timeout:` is in **seconds**; retry settings ending in
|
|
83
|
+
`_ms` remain milliseconds. `retry_policy:` corresponds to JavaScript's `retry` option.
|
|
84
|
+
- Calls are synchronous. There is no JavaScript `Promise`, `AbortController`, or compile-time
|
|
85
|
+
inference. Ruby returns answer classes and accepts `Typesafe::SDK::Signal` for cancellation.
|
|
86
|
+
- `with_response: true` returns `.data`, `.response`, and `.request_id`. The response exposes
|
|
87
|
+
`.status`, `.headers`, and an already parsed `.body`; it is not a Fetch response or stream.
|
|
88
|
+
Non-2xx responses still raise typed errors. Unlike JavaScript's `asResponse()`, these
|
|
89
|
+
resource methods always construct typed results; there is no unparsed Fetch body API.
|
|
90
|
+
- `result[:question]` and `result["question"]` are both supported. `result.answers` itself
|
|
91
|
+
is a Hash with string keys, as received in JSON.
|
|
92
|
+
- The noul builder serializes omitted criteria as JSON null; upstream omits the property.
|
|
93
|
+
Both are accepted by the upstream question type. Explicit criteria serialize identically.
|
|
94
|
+
- The built-in transport opens one connection and one private worker per attempt. It closes
|
|
95
|
+
and joins them on completion, timeout, or cancellation. It does not pool connections or
|
|
96
|
+
implement a fiber-native asynchronous interface. Custom adapters own timeout/cancellation
|
|
97
|
+
enforcement and must implement the documented `HTTP#request` contract.
|
|
98
|
+
- Net::HTTP does not follow redirects automatically; a 3xx raises `APIError`. Use the canonical
|
|
99
|
+
API URL. JavaScript Fetch may follow redirects.
|
|
100
|
+
- Ruby's HTTP-date parser and JavaScript's Date parser may differ for malformed, non-HTTP dates.
|
|
101
|
+
Standard HTTP dates and the tested numeric forms are covered.
|
|
102
|
+
|
|
103
|
+
## Live verification
|
|
104
|
+
|
|
105
|
+
Offline tests do not prove account authentication, current model availability, billing, or
|
|
106
|
+
production inference behavior. No live API key was available during this audit, so no live
|
|
107
|
+
TypeSafe request was made.
|
|
108
|
+
|
|
109
|
+
An explicit smoke test checks model listing and the noul, choice, and score answer types with
|
|
110
|
+
synthetic input:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
# Set TYPESAFE_API_KEY securely in your environment first.
|
|
114
|
+
TYPESAFE_LIVE_TEST=1 bundle exec ruby script/smoke_test.rb
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This makes one inference request, which may incur normal API charges. It does not retry or
|
|
118
|
+
print the API key, input bodies, or full responses. Add `TYPESAFE_LIVE_IMAGES=1` for an account
|
|
119
|
+
with image access to also send one image with a box question to `jev-preview`.
|
data/docs/RELEASING.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Only the repository owner merges pull requests and initiates releases. Opening or merging
|
|
4
|
+
a pull request does not publish a gem. Publication starts when the owner pushes a version tag.
|
|
5
|
+
|
|
6
|
+
## Versions
|
|
7
|
+
|
|
8
|
+
`Typesafe::SDK::UPSTREAM_VERSION` records the official JS compatibility target.
|
|
9
|
+
`Typesafe::SDK::VERSION` is the Ruby package version and is the single source for the gemspec.
|
|
10
|
+
|
|
11
|
+
- A new upstream port uses its exact three-part version, for example `0.7.0`.
|
|
12
|
+
- Ruby-only corrections use a fourth numeric component, for example `0.6.0.1` and `0.6.0.2`.
|
|
13
|
+
These still target JS `0.6.0`; they do not imply an upstream `0.6.1` release.
|
|
14
|
+
- Published versions and tags must not be replaced or reused. RubyGems accepts numeric
|
|
15
|
+
fourth-component versions as stable releases.
|
|
16
|
+
|
|
17
|
+
Before a new upstream port, compare the tagged source and tests, update the compatibility
|
|
18
|
+
document and generated fixtures, and run the suite. Version equality alone is not evidence
|
|
19
|
+
of compatibility.
|
|
20
|
+
|
|
21
|
+
## Owner checklist
|
|
22
|
+
|
|
23
|
+
1. Review and merge the release pull request after all CI matrix jobs pass.
|
|
24
|
+
2. Run the opt-in live smoke test described in [COMPATIBILITY.md](COMPATIBILITY.md).
|
|
25
|
+
3. Set the changelog release date and update the README release-status notice in a reviewed
|
|
26
|
+
pull request before tagging.
|
|
27
|
+
4. On the merged main commit, run `bundle exec rake`, `ruby script/check_release.rb`, and
|
|
28
|
+
`gem build typesafe-sdk-ruby.gemspec --strict`.
|
|
29
|
+
5. Create and push the matching annotated tag, for example `v0.6.0.1`.
|
|
30
|
+
6. Verify the Release workflow, RubyGems version, GitHub release, and attached gem artifact.
|
|
31
|
+
|
|
32
|
+
The workflow rejects non-owner runs, tags that differ from the gem version, commits outside
|
|
33
|
+
main's history, and upstream versions that do not match the official tagged package.
|
|
34
|
+
It reruns tests and lint before publishing with the repository's `RUBYGEMS_API_KEY` secret.
|
|
35
|
+
Keep that credential restricted to this gem. Required branch checks remain in force.
|
|
36
|
+
|
|
37
|
+
If RubyGems publication succeeds but GitHub release creation fails, verify the published
|
|
38
|
+
artifact before recovering the GitHub release; do not delete the tag or republish the version.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Upstream issue audit
|
|
2
|
+
|
|
3
|
+
Reviewed all **17 issues** in the official JavaScript repository's issue list, including their
|
|
4
|
+
comments: the first 11 on **2026-09-21** against `@typesafe-ai/sdk` **0.6.0** (commit
|
|
5
|
+
`66880cc`), with Ruby fixes in **0.6.0.1**, and #15–#20 on **2026-10-16** against **0.7.0**
|
|
6
|
+
(commit `b844cd1aca12855a5d24a2df52a1401c86e7d37a`), with Ruby fixes in **0.7.0**. The issue
|
|
7
|
+
page showed no closed issues; it does not show each issue's state, so the six new issues were
|
|
8
|
+
checked individually and were open.
|
|
9
|
+
|
|
10
|
+
This audit distinguishes client fixes from runtime differences and server feature requests.
|
|
11
|
+
It does not claim that the upstream issues are closed or that every requested API feature
|
|
12
|
+
is available. Valid supported requests continue to follow the JS wire format; reproducing
|
|
13
|
+
known JS bugs is not a compatibility goal.
|
|
14
|
+
|
|
15
|
+
## Disposition of every issue
|
|
16
|
+
|
|
17
|
+
| Upstream issue | Ruby disposition | Evidence or remaining limitation |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| [#20: 0.7.0 fails every request on Bun](https://github.com/typesafe-ai/typesafe-sdk-js/issues/20) | Bun/Fetch-specific; not applicable | The failure is in JS 0.7.0's Fetch body buffering under Bun. Ruby's Net::HTTP transport reads each body once in its own worker; real-socket tests cover body delivery, timeouts, and cancellation. |
|
|
20
|
+
| [#19: custom `fetch` called with the client as `this`](https://github.com/typesafe-ai/typesafe-sdk-js/issues/19) | JavaScript-specific; not applicable | Ruby calls a custom `http:` adapter's `request` method on the adapter itself, so there is no receiver mismatch. |
|
|
21
|
+
| [#18: secrets shown as an authorization scheme in logs](https://github.com/typesafe-ai/typesafe-sdk-js/issues/18) | Fixed locally | Ruby had the same logic. `x-api-key` values are always fully masked; authorization headers keep only an alphabetic scheme followed by a credential. `"sk-live-… x"` and `"TOKEN "` are no longer shown. Unchanged in JS 0.7.0. |
|
|
22
|
+
| [#17: non-finite numbers serialized as null](https://github.com/typesafe-ai/typesafe-sdk-js/issues/17) | Matches JS 0.7.0 | Ruby 0.6.0.1 raised `JSON::GeneratorError`. Like JS 0.7.0, Ruby now sends `"NaN"`, `"Infinity"`, and `"-Infinity"` instead; the issue proposed rejecting them, and Ruby follows the released upstream behavior. |
|
|
23
|
+
| [#16: token admission bounds and billing reconciliation](https://github.com/typesafe-ai/typesafe-sdk-js/issues/16) | Requires upstream API/documentation | There is no official tokenizer, token-count endpoint, or charge export to wrap. Ruby doesn't estimate tokens; `with_response: true` exposes the request ID and raw `usage` for reconciliation. |
|
|
24
|
+
| [#15: Cloudflare HTML 403 for state containing shell commands](https://github.com/typesafe-ai/typesafe-sdk-js/issues/15) | Server-side; clearer Ruby error | The block happens before the API, so no client change can let these requests through. Ruby now names a Cloudflare HTML page and its `cf-ray` ID in the error message instead of quoting HTML. The class stays `PermissionDeniedError`, and the HTML stays in `body`. |
|
|
25
|
+
| [#14: API key disclosure and empty keys](https://github.com/typesafe-ai/typesafe-sdk-js/issues/14) | Fixed locally | Validate explicit and environment keys at construction, trim outer spaces/tabs/line endings, reject empty/non-ASCII/control/embedded-whitespace values, and copy/freeze the key. Validation errors contain no key and make no request or retry. Header-construction failures also propagate without connection retries. |
|
|
26
|
+
| [#13: actionable HTTP error classes](https://github.com/typesafe-ai/typesafe-sdk-js/issues/13) | Implemented | HTTP 402, 409, and 413 map to `PaymentRequiredError`, `ConflictError`, and `PayloadTooLargeError`. Each remains an `APIError`, retains response metadata, and is not retried by default. |
|
|
27
|
+
| [#12: null score levels](https://github.com/typesafe-ai/typesafe-sdk-js/issues/12) | Fixed locally; still accepted by JS 0.7.0 | Builders and raw hashes reject nil levels. Use an explicit empty string for an undescribed position; entries are never removed or renumbered. This workaround is reported accepted in the upstream issue, not independently verified against the live API here. |
|
|
28
|
+
| [#11: calibrated multi-label selection](https://github.com/typesafe-ai/typesafe-sdk-js/issues/11) | Requires upstream API/model support | Choice selects one option. Multiple noul questions are independent judgments, not a calibrated competitive multi-label substitute. The Ruby SDK does not invent such an endpoint or promise equivalent calibration. |
|
|
29
|
+
| [#10: cached or registered criteria](https://github.com/typesafe-ai/typesafe-sdk-js/issues/10) | Requires upstream API support | Reusing a Ruby question Hash still sends the criteria on each request and does not reduce billed tokens. There is no supported registration/cache API in the audited JS SDK. No synthetic `cached_tokens` field is exposed. |
|
|
30
|
+
| [#9: blank retry delay headers](https://github.com/typesafe-ai/typesafe-sdk-js/issues/9) | Fixed locally; unchanged in JS 0.7.0 | Blank/whitespace-only delays fall back to configured backoff; blank milliseconds can fall through to valid seconds. Literal zero is still honored. Request-path tests verify the actual retry delay. |
|
|
31
|
+
| [#8: Node timer overflow](https://github.com/typesafe-ai/typesafe-sdk-js/issues/8) | Node-specific; Ruby equivalent tested | Ruby does not use Node timers. Real loopback requests with delayed responses succeed at and just above Node's 2,147,483,647 ms boundary, for both constructor and per-call settings. This is not a guarantee for every arbitrarily large OS timeout. |
|
|
32
|
+
| [#7: list the community Go SDK](https://github.com/typesafe-ai/typesafe-sdk-js/issues/7) | Upstream documentation request | No Ruby defect. Whether TypeSafe lists third-party clients is an upstream decision; this repository clearly identifies itself as unofficial. |
|
|
33
|
+
| [#6: API-invalid request shapes and limits](https://github.com/typesafe-ai/typesafe-sdk-js/issues/6) | Fixed locally; partly in JS 0.7.0 | Require string/object/array/image state and reject nil; require noul instructions or a non-nil true/false description; enforce 2–10 score levels, 1–255 choice options, and nonempty question names. Empty state containers, single-option choice, and whitespace-only names remain allowed. JS 0.7.0 now rejects null state, empty noul questions, and empty or non-map choice criteria, but not the size bounds. |
|
|
34
|
+
| [#4: numeric choice label inference](https://github.com/typesafe-ai/typesafe-sdk-js/issues/4) | TypeScript-specific; Ruby equivalent tested | Ruby has no TypeScript `never` inference. Numeric labels serialize as JSON string keys and choice answers/probability keys remain strings. |
|
|
35
|
+
| [#2: handled cancellation crashes Node](https://github.com/typesafe-ai/typesafe-sdk-js/issues/2) | Different transport; Ruby equivalent tested | The Net::HTTP worker is cleaned up before cancellation returns. Separate processes with `Thread.abort_on_exception = true` catch `APIUserAbortError` and exit normally after cancellation during successful and error response bodies, for inference and model listing. Existing socket tests also exercise cancellation and worker cleanup. |
|
|
36
|
+
|
|
37
|
+
## Compatibility and migration
|
|
38
|
+
|
|
39
|
+
The local validation, blank-header handling, and added error subclasses intentionally differ
|
|
40
|
+
from JS 0.6.0, and most still differ from JS 0.7.0. Previously accepted local inputs that are reported rejected by the API now raise
|
|
41
|
+
`TypeSafeError` earlier. In particular, replace `noul()` with an actual question or outcome
|
|
42
|
+
criteria, supply non-nil state, and replace nil score placeholders explicitly with descriptions
|
|
43
|
+
or empty strings. Do not drop score slots to pass validation, because their positions define
|
|
44
|
+
the scale. Nil choice descriptions remain supported.
|
|
45
|
+
|
|
46
|
+
Raw question hashes, including JSON-loaded hashes with string keys, receive the same checks
|
|
47
|
+
as builders. These targeted checks are not a complete copy of the server's evolving schema;
|
|
48
|
+
the API remains authoritative for other validation and model behavior.
|
|
49
|
+
|
|
50
|
+
New error subclasses retain the public `APIError` contract. Catching `APIError` continues to
|
|
51
|
+
work; code comparing exact classes may need updating. HTTP 402/409/413 are still excluded
|
|
52
|
+
from the default retry statuses.
|
|
53
|
+
|
|
54
|
+
For future server fields, `with_response: true` exposes the parsed response body, including
|
|
55
|
+
its raw `usage` object. This does not mean the server currently reports cache usage. Consult
|
|
56
|
+
upstream documentation before relying on newly introduced fields or sending `extra_body:`.
|
|
57
|
+
|
|
58
|
+
## Verification scope
|
|
59
|
+
|
|
60
|
+
- [Issue regressions](../spec/typesafe/sdk/upstream_issues_spec.rb): request validation,
|
|
61
|
+
API key handling, retry scheduling, error mapping, and numeric labels using synthetic data.
|
|
62
|
+
- [0.7.0 regressions](../spec/typesafe/sdk/release_0_7_spec.rb): non-finite numbers (#17),
|
|
63
|
+
credential redaction (#18), Cloudflare error pages (#15), and box/validation changes.
|
|
64
|
+
- [Socket regressions](../spec/typesafe/sdk/http_spec.rb): actual Ruby transport behavior,
|
|
65
|
+
including the Node timer boundary, cancellation, and slow/truncated bodies.
|
|
66
|
+
- [Subprocess cancellation](../spec/typesafe/sdk/cancellation_process_spec.rb): handled
|
|
67
|
+
cancellation must not crash the caller process or leak an uncaught worker error.
|
|
68
|
+
- [JS baseline comparisons](../spec/typesafe/sdk/parity_spec.rb): fixtures generated by
|
|
69
|
+
running the published JS package; known corrections are explicit in assertions and the
|
|
70
|
+
fixture preserves the original JS output for those cases.
|
|
71
|
+
|
|
72
|
+
No live API key was available. Input constraints and reported server behavior above come from
|
|
73
|
+
upstream issue reports; tests prove Ruby's handling, not an independent live reproduction.
|
|
74
|
+
See [compatibility and live verification](COMPATIBILITY.md) for the opt-in smoke test.
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Typesafe
|
|
8
|
+
module SDK
|
|
9
|
+
# A PNG or JPEG image asset, created by {SDK.image} or {Image.from_base64}.
|
|
10
|
+
#
|
|
11
|
+
# Place it in `state`, question instructions, or criteria, join it into text with
|
|
12
|
+
# {SDK.with_assets}, or pass it in `assets:` under a name of your choice.
|
|
13
|
+
class Image
|
|
14
|
+
# The file signatures that PNG (PNG spec §5.2) and JPEG (ITU T.81 SOI marker) files start
|
|
15
|
+
# with, and the content type each identifies.
|
|
16
|
+
CONTENT_TYPES = [
|
|
17
|
+
["image/png", "\x89PNG\r\n\x1A\n".b],
|
|
18
|
+
["image/jpeg", "\xFF\xD8\xFF".b]
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# The asset type sent to the API.
|
|
22
|
+
def type
|
|
23
|
+
"image"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The frozen binary file bytes, or a `Pathname` read when the request is sent.
|
|
27
|
+
attr_reader :data
|
|
28
|
+
|
|
29
|
+
# @param data [String, Pathname, #read] file bytes, a path to read when the request is
|
|
30
|
+
# sent, or an IO read now.
|
|
31
|
+
# @raise [TypeSafeError] `data` is empty, unreadable, or not a supported type.
|
|
32
|
+
def initialize(data)
|
|
33
|
+
@data = coerce(data)
|
|
34
|
+
size = @data.is_a?(Pathname) ? file_size(@data) : @data.bytesize
|
|
35
|
+
raise TypeSafeError, "Image data is empty." if size.zero?
|
|
36
|
+
|
|
37
|
+
@reference = "<image:#{SecureRandom.hex(16)}>".freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create an image from base64-encoded file bytes, decoding them now.
|
|
41
|
+
#
|
|
42
|
+
# @param text [String] the file bytes, base64-encoded, without a `data:` URL prefix.
|
|
43
|
+
# ASCII whitespace and missing padding are accepted, as by JavaScript's `atob`.
|
|
44
|
+
# @raise [TypeSafeError] `text` isn't valid base64 or decodes to no bytes.
|
|
45
|
+
def self.from_base64(text)
|
|
46
|
+
raise TypeSafeError, "Image data must contain valid base64 text." unless text.is_a?(String)
|
|
47
|
+
|
|
48
|
+
# The forgiving-base64 decode that `atob` uses (WHATWG Infra Standard).
|
|
49
|
+
compact = text.b.delete(" \t\n\f\r")
|
|
50
|
+
compact = compact.sub(/={1,2}\z/, "") if (compact.length % 4).zero?
|
|
51
|
+
unless compact.match?(%r{\A[A-Za-z0-9+/]*\z}) && compact.length % 4 != 1
|
|
52
|
+
raise TypeSafeError, "Image data must contain valid base64 text."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
new("#{compact}#{'=' * (-compact.length % 4)}".unpack1("m"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The bytes to send, reading `Pathname` data.
|
|
59
|
+
# @param name [String] the asset's name or reference, for error messages.
|
|
60
|
+
def read(name)
|
|
61
|
+
return @data unless @data.is_a?(Pathname)
|
|
62
|
+
|
|
63
|
+
File.binread(@data).freeze
|
|
64
|
+
rescue SystemCallError, IOError => e
|
|
65
|
+
raise TypeSafeError.new("Asset #{name.inspect} could not be read: #{e.message}", cause: e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The unique reference this image is sent under, for referencing it inside text.
|
|
69
|
+
def to_s
|
|
70
|
+
@reference
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The reference, written wherever the image appears in JSON.
|
|
74
|
+
def to_json(*args)
|
|
75
|
+
@reference.to_json(*args)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inspect # :nodoc:
|
|
79
|
+
"#<#{self.class.name} #{@reference}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The content type whose signature `bytes` start with, or `application/octet-stream`.
|
|
83
|
+
def self.content_type_of(bytes)
|
|
84
|
+
CONTENT_TYPES.each { |type, signature| return type if bytes.b.start_with?(signature) }
|
|
85
|
+
"application/octet-stream"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def coerce(data)
|
|
91
|
+
case data
|
|
92
|
+
when String then data.b.freeze
|
|
93
|
+
when Pathname then data
|
|
94
|
+
else
|
|
95
|
+
unless data.respond_to?(:read)
|
|
96
|
+
raise TypeSafeError,
|
|
97
|
+
"Image data must be file bytes (a String), a Pathname, or an IO; got #{data.class}."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
bytes = data.read
|
|
101
|
+
raise TypeSafeError, "Image data read from #{data.class} must be a String." unless bytes.is_a?(String)
|
|
102
|
+
|
|
103
|
+
bytes.b.freeze
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def file_size(path)
|
|
108
|
+
File.size(path)
|
|
109
|
+
rescue SystemCallError => e
|
|
110
|
+
raise TypeSafeError.new("Image file could not be read: #{e.message}", cause: e)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Text from {SDK.with_assets} or {SDK.join_assets}, with the assets written into it.
|
|
115
|
+
class TextWithAssets
|
|
116
|
+
# The text, with each asset written as its reference.
|
|
117
|
+
attr_reader :text
|
|
118
|
+
# The assets written into the text, including those in nested `TextWithAssets`.
|
|
119
|
+
attr_reader :assets
|
|
120
|
+
|
|
121
|
+
def initialize(text, assets)
|
|
122
|
+
@text = text.dup.freeze
|
|
123
|
+
@assets = assets.dup.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_s
|
|
127
|
+
@text
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The text, written wherever this value appears in JSON.
|
|
131
|
+
def to_json(*args)
|
|
132
|
+
@text.to_json(*args)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def inspect # :nodoc:
|
|
136
|
+
"#<#{self.class.name} #{@text.inspect}>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Join `parts` into text, as an interpolated string would, and attach the assets among them.
|
|
140
|
+
def self.build(*parts)
|
|
141
|
+
attach(parts.map { |part| stringify(part) }.join, parts)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Join `values` with `separator` and attach the assets among them.
|
|
145
|
+
def self.join(values, separator = ",")
|
|
146
|
+
values = values.to_a
|
|
147
|
+
attach(values.map { |value| stringify(value) }.join(separator.to_s), values)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# `text` with the assets in `values` whose references it contains, in order of appearance.
|
|
151
|
+
def self.attach(text, values)
|
|
152
|
+
interpolated = {}
|
|
153
|
+
collect(values, interpolated, {}.compare_by_identity)
|
|
154
|
+
assets = {}
|
|
155
|
+
text.scan(Assets::REFERENCE) do |reference|
|
|
156
|
+
asset = interpolated[reference]
|
|
157
|
+
assets[reference] = asset if asset
|
|
158
|
+
end
|
|
159
|
+
new(text, assets.values)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.stringify(value)
|
|
163
|
+
value.is_a?(TextWithAssets) ? value.text : value.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Add the images in `value`, including those in `TextWithAssets`, to `found` by reference.
|
|
167
|
+
def self.collect(value, found, visited)
|
|
168
|
+
case value
|
|
169
|
+
when Image then found[value.to_s] = value
|
|
170
|
+
when TextWithAssets then value.assets.each { |asset| found[asset.to_s] = asset }
|
|
171
|
+
when Hash, Array
|
|
172
|
+
return if visited.key?(value)
|
|
173
|
+
|
|
174
|
+
visited[value] = true
|
|
175
|
+
(value.is_a?(Hash) ? value.values : value).each { |item| collect(item, found, visited) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A `multipart/form-data` request body (RFC 7578) whose first part is a JSON payload.
|
|
181
|
+
class MultipartBody
|
|
182
|
+
# The JSON payload, sent as the `request` part.
|
|
183
|
+
attr_reader :payload
|
|
184
|
+
# The `Content-Type` header value, including the boundary.
|
|
185
|
+
attr_reader :content_type
|
|
186
|
+
# The encoded binary body.
|
|
187
|
+
attr_reader :content
|
|
188
|
+
|
|
189
|
+
# @param parts [Array<Hash>] `name`, `content_type`, `content`, and optional `filename`.
|
|
190
|
+
def initialize(payload, parts)
|
|
191
|
+
# A random boundary, as browsers use, is practically certain not to occur in the content.
|
|
192
|
+
boundary = SecureRandom.hex(16)
|
|
193
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
194
|
+
[{ name: "request", content_type: "application/json", content: JSON.generate(payload) }, *parts]
|
|
195
|
+
.each do |part|
|
|
196
|
+
disposition = %(name="#{part[:name]}")
|
|
197
|
+
disposition += %(; filename="#{part[:filename]}") if part[:filename]
|
|
198
|
+
content << "--#{boundary}\r\nContent-Disposition: form-data; #{disposition}\r\n" \
|
|
199
|
+
"Content-Type: #{part[:content_type]}\r\n\r\n".b << part[:content].b << "\r\n"
|
|
200
|
+
end
|
|
201
|
+
content << "--#{boundary}--\r\n"
|
|
202
|
+
@payload = payload
|
|
203
|
+
@content_type = "multipart/form-data; boundary=#{boundary}"
|
|
204
|
+
@content = content.freeze
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Request serialization and `multipart/form-data` encoding for assets.
|
|
209
|
+
module Assets
|
|
210
|
+
# Matches `Image#to_s`: the reference an image is sent under.
|
|
211
|
+
REFERENCE = /<image:[0-9a-f]{32}>/
|
|
212
|
+
|
|
213
|
+
module_function
|
|
214
|
+
|
|
215
|
+
# Validate `assets:` and return it with string names, preserving order.
|
|
216
|
+
def normalize(assets)
|
|
217
|
+
return {} if assets.nil?
|
|
218
|
+
raise TypeSafeError, "`assets` must be a Hash of names to images." unless assets.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
assets.each_with_object({}) do |(name, asset), normalized|
|
|
221
|
+
key = name.to_s
|
|
222
|
+
raise TypeSafeError, "Asset names must be nonempty." if key.empty?
|
|
223
|
+
unless asset.is_a?(Image)
|
|
224
|
+
raise TypeSafeError,
|
|
225
|
+
"Asset #{key.inspect} must be an image created with Typesafe::SDK.image, got #{asset.class}."
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
normalized[key] = asset
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Serialize `payload` to JSON, writing each image as its name in `assets`, or else as its
|
|
233
|
+
# reference, and collect the assets the request sends.
|
|
234
|
+
#
|
|
235
|
+
# The assets are those in `assets`, then each image whose reference appears in the JSON, in
|
|
236
|
+
# order of appearance. Non-finite floats are sent as the strings `NaN`, `Infinity`, and
|
|
237
|
+
# `-Infinity`, as the JavaScript SDK does.
|
|
238
|
+
#
|
|
239
|
+
# @return [Array(String, Hash{String => Image}, nil)] the JSON and the assets to send.
|
|
240
|
+
# @raise [TypeSafeError] the payload holds a cycle, a reference of an image not attached to
|
|
241
|
+
# the request, or doesn't contain the name of an image in `assets`.
|
|
242
|
+
def serialize_request(payload, assets = {})
|
|
243
|
+
# An image under several names is written as the last one.
|
|
244
|
+
names = {}.compare_by_identity
|
|
245
|
+
assets.each { |name, asset| names[asset] = name }
|
|
246
|
+
known = assets.values.to_h { |asset| [asset.to_s, asset] }
|
|
247
|
+
json = JSON.generate(prepare(payload, names, known, {}.compare_by_identity))
|
|
248
|
+
|
|
249
|
+
unreferenced = assets.keys.reject { |name| json.include?(JSON.generate(name)[1..-2]) }
|
|
250
|
+
unless unreferenced.empty?
|
|
251
|
+
raise TypeSafeError,
|
|
252
|
+
"Assets not referenced in the request: #{unreferenced.join(', ')}. " \
|
|
253
|
+
"Write each name in `assets` into `state`, question instructions, or criteria."
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
sent = assets.dup
|
|
257
|
+
missing = []
|
|
258
|
+
json.scan(REFERENCE) do |reference|
|
|
259
|
+
next if sent.key?(reference)
|
|
260
|
+
|
|
261
|
+
asset = known[reference]
|
|
262
|
+
asset ? sent[reference] = asset : missing << reference
|
|
263
|
+
end
|
|
264
|
+
unless missing.empty?
|
|
265
|
+
raise TypeSafeError,
|
|
266
|
+
"Image references without an attached image: #{missing.uniq.join(', ')}. " \
|
|
267
|
+
"Join images into text with Typesafe::SDK.with_assets instead of string interpolation."
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
[json, sent.empty? ? nil : sent]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A JSON-ready copy of `value` with assets written as names or references.
|
|
274
|
+
def prepare(value, names, known, path)
|
|
275
|
+
case value
|
|
276
|
+
when Image
|
|
277
|
+
name = names[value]
|
|
278
|
+
return name if name
|
|
279
|
+
|
|
280
|
+
known[value.to_s] = value
|
|
281
|
+
value.to_s
|
|
282
|
+
when TextWithAssets
|
|
283
|
+
value.assets.reduce(value.text) do |text, asset|
|
|
284
|
+
name = names[asset]
|
|
285
|
+
known[asset.to_s] = asset unless name
|
|
286
|
+
name ? text.gsub(asset.to_s) { name } : text
|
|
287
|
+
end
|
|
288
|
+
when Float
|
|
289
|
+
value.finite? ? value : value.to_s
|
|
290
|
+
when Hash, Array
|
|
291
|
+
raise TypeSafeError, "The request contains a circular reference." if path.key?(value)
|
|
292
|
+
|
|
293
|
+
path[value] = true
|
|
294
|
+
begin
|
|
295
|
+
if value.is_a?(Hash)
|
|
296
|
+
value.to_h { |key, item| [key.to_s, prepare(item, names, known, path)] }
|
|
297
|
+
else
|
|
298
|
+
value.map { |item| prepare(item, names, known, path) }
|
|
299
|
+
end
|
|
300
|
+
ensure
|
|
301
|
+
path.delete(value)
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
value
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A multipart body whose `request` part is `payload` with `assets` added, followed by each
|
|
309
|
+
# asset's file bytes in its own part. The API accepts each part for only one asset, so an
|
|
310
|
+
# image under several references is sent once per reference.
|
|
311
|
+
#
|
|
312
|
+
# @raise [TypeSafeError] a `Pathname` image can't be read.
|
|
313
|
+
def encode(payload, assets)
|
|
314
|
+
parts = []
|
|
315
|
+
entries = {}
|
|
316
|
+
assets.each do |name, asset|
|
|
317
|
+
bytes = asset.read(name)
|
|
318
|
+
part = "asset-#{parts.length}"
|
|
319
|
+
parts << { name: part, content_type: Image.content_type_of(bytes), content: bytes, filename: part }
|
|
320
|
+
entries[name] = { "type" => asset.type, "part" => part }
|
|
321
|
+
end
|
|
322
|
+
MultipartBody.new(payload.merge("assets" => entries), parts)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|