tep 0.11.2 → 0.11.4

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +31 -1
  3. data/README.md +4 -4
  4. data/SINATRA_COMPAT.md +20 -20
  5. data/bin/tep +8 -8
  6. data/examples/api_gateway/app.rb +1 -1
  7. data/examples/blog/app.rb +17 -17
  8. data/examples/chat/app.rb +12 -12
  9. data/examples/chatbot/README.md +2 -2
  10. data/examples/chatbot/app.rb +24 -24
  11. data/examples/llm_gateway/README.md +6 -5
  12. data/examples/llm_gateway/app.rb +4 -4
  13. data/lib/spinel_kit/hex.rb +65 -0
  14. data/lib/spinel_kit/json.rb +151 -0
  15. data/lib/spinel_kit/json_decoder.rb +396 -0
  16. data/lib/{tep/logger.rb → spinel_kit/log.rb} +25 -21
  17. data/lib/spinel_kit/url.rb +166 -0
  18. data/lib/tep/auth_bearer_token.rb +6 -6
  19. data/lib/tep/auth_oauth2.rb +4 -4
  20. data/lib/tep/events.rb +37 -37
  21. data/lib/tep/http.rb +3 -3
  22. data/lib/tep/job.rb +2 -2
  23. data/lib/tep/jwt.rb +4 -4
  24. data/lib/tep/live_view.rb +4 -4
  25. data/lib/tep/llm.rb +13 -45
  26. data/lib/tep/mcp.rb +12 -12
  27. data/lib/tep/multipart.rb +1 -1
  28. data/lib/tep/openai_server.rb +134 -93
  29. data/lib/tep/parser.rb +2 -2
  30. data/lib/tep/presence.rb +11 -11
  31. data/lib/tep/proxy.rb +7 -7
  32. data/lib/tep/request.rb +1 -1
  33. data/lib/tep/response.rb +1 -1
  34. data/lib/tep/router.rb +1 -1
  35. data/lib/tep/session.rb +2 -2
  36. data/lib/tep/version.rb +1 -1
  37. data/lib/tep.rb +30 -29
  38. data/test/helper.rb +95 -8
  39. data/test/run_parallel.rb +44 -7
  40. data/test/test_auth.rb +17 -17
  41. data/test/test_auth_oauth2.rb +5 -5
  42. data/test/test_http_pool.rb +4 -4
  43. data/test/test_http_pool_send.rb +3 -3
  44. data/test/test_json.rb +12 -12
  45. data/test/test_jwt.rb +4 -4
  46. data/test/test_live_view.rb +3 -3
  47. data/test/test_llm.rb +12 -9
  48. data/test/test_llm_gateway.rb +2 -2
  49. data/test/test_logger.rb +2 -2
  50. data/test/test_openai_server.rb +72 -1
  51. data/test/test_password.rb +3 -3
  52. data/test/test_real_world.rb +6 -1
  53. data/test/test_shutdown.rb +40 -0
  54. metadata +9 -8
  55. data/lib/tep/json.rb +0 -572
  56. data/lib/tep/url.rb +0 -161
data/lib/tep/json.rb DELETED
@@ -1,572 +0,0 @@
1
- # Tep::Json -- a small JSON encoder + flat-key decoder for
2
- # spinel-AOT'd apps.
3
- #
4
- # Why bundle one? The stdlib `json` gem's fast path is a CRuby
5
- # native extension (`JSON::Ext`); the pure-Ruby fallback
6
- # (`JSON::Pure`) is heavily metaprogrammed (`define_method` for
7
- # state-machine transitions, `class_eval`'d generator dispatch),
8
- # which spinel can't lower. The `oj` / `yajl-ruby` / `multi_json`
9
- # alternatives are all C extensions or thin wrappers thereof.
10
- #
11
- # Scope
12
- # -----
13
- # This is a *batteries-included for JSON-over-HTTP* shim, not a
14
- # full JSON library:
15
- #
16
- # * **Encode**: produce JSON strings from typed Ruby values --
17
- # `escape(s)` / `quote(s)` for strings; `encode_pair_str(k, v)`
18
- # and `encode_pair_int(k, v)` as fixed-arity building blocks
19
- # for object literals; `from_str_array(a)` and
20
- # `from_int_array(a)` for array literals. Compose objects in
21
- # user code by concatenation:
22
- #
23
- # "{" + Tep::Json.encode_pair_str("name", name) + "," +
24
- # Tep::Json.encode_pair_int("age", age) + "}"
25
- #
26
- # * **Decode**: typed accessors that read a *top-level key* from
27
- # a flat JSON object: `get_str`, `get_int`, `has_key?`. The
28
- # parser walks the object once, skipping nested values without
29
- # materialising them. For deeper traversal users hand-write
30
- # the walk; nested JSON-object access of the form
31
- # `payload.user.email` should be done by the API contract:
32
- # pass discrete fields rather than a nested blob.
33
- #
34
- # Out of scope
35
- # ------------
36
- # * Floats. Numbers parse / emit as int (`.to_s`). JSON's number
37
- # grammar is wider but APIs in practice send integers for IDs,
38
- # counts, timestamps; treat anything fractional as a string in
39
- # transport.
40
- # * Unicode-escape decoding (`\uXXXX`). Round-trips through
41
- # escape/unescape work for ASCII; non-ASCII input bytes are
42
- # passed through verbatim.
43
- # * Streaming / pull parsers. Loads the whole string into the
44
- # parser; suitable for request-body sizes typical of APIs.
45
- module Tep
46
- class Json
47
- # ---- Encoders ----
48
-
49
- # Escape a string for inclusion inside a JSON string literal
50
- # (does NOT add the surrounding quotes -- use `quote(s)` for
51
- # that). Handles ", \, and the JSON-required control-char
52
- # escapes (\b, \f, \n, \r, \t); other control bytes go through
53
- # \u00XX. Forward slash is left unescaped (legal either way;
54
- # the unescaped form is more readable and shorter).
55
- def self.escape(s)
56
- out = ""
57
- i = 0
58
- n = s.length
59
- while i < n
60
- c = s[i]
61
- if c == "\""
62
- out = out + "\\\""
63
- elsif c == "\\"
64
- out = out + "\\\\"
65
- elsif c == "\n"
66
- out = out + "\\n"
67
- elsif c == "\r"
68
- out = out + "\\r"
69
- elsif c == "\t"
70
- out = out + "\\t"
71
- elsif c == "\b"
72
- out = out + "\\b"
73
- elsif c == "\f"
74
- out = out + "\\f"
75
- elsif c < " "
76
- # Other control byte -- emit \u00XX. c.getbyte(0) is the
77
- # raw byte value, mapped to two hex digits.
78
- b = c.getbyte(0)
79
- out = out + "\\u00" + Json.hex2(b)
80
- else
81
- out = out + c
82
- end
83
- i += 1
84
- end
85
- out
86
- end
87
-
88
- # Two-digit lowercase hex of a byte (0..255).
89
- def self.hex2(n)
90
- hex = "0123456789abcdef"
91
- out = ""
92
- out = out + hex[(n / 16) % 16, 1]
93
- out = out + hex[n % 16, 1]
94
- out
95
- end
96
-
97
- # Wrap a string in JSON quotes, escaping its body.
98
- def self.quote(s)
99
- "\"" + Json.escape(s) + "\""
100
- end
101
-
102
- # Encode a single key/value pair as `"k":"v"` (escaped both
103
- # sides). Building block for ad-hoc object literals where the
104
- # caller wants control over key ordering or layout:
105
- #
106
- # "{" + Tep::Json.encode_pair_str("name", name) + "," +
107
- # Tep::Json.encode_pair_int("age", age) + "}"
108
- #
109
- # When you have a real Hash, prefer `from_str_hash` /
110
- # `from_int_hash` -- those iterate via `each |k, v|` directly.
111
- def self.encode_pair_str(k, v)
112
- Json.quote(k) + ":" + Json.quote(v)
113
- end
114
-
115
- # Same shape, integer value side. `v` is rendered via `.to_s`
116
- # so JSON-numeric output without quoting.
117
- def self.encode_pair_int(k, v)
118
- Json.quote(k) + ":" + v.to_s
119
- end
120
-
121
- # Encode a Hash<String,String> as a JSON object.
122
- def self.from_str_hash(h)
123
- out = "{"
124
- first = true
125
- h.each do |k, v|
126
- if !first
127
- out = out + ","
128
- end
129
- first = false
130
- out = out + Json.quote(k) + ":" + Json.quote(v)
131
- end
132
- out + "}"
133
- end
134
-
135
- # Same shape with integer values. JSON-numeric, no quoting.
136
- def self.from_int_hash(h)
137
- out = "{"
138
- first = true
139
- h.each do |k, v|
140
- if !first
141
- out = out + ","
142
- end
143
- first = false
144
- out = out + Json.quote(k) + ":" + v.to_s
145
- end
146
- out + "}"
147
- end
148
-
149
- # Encode a string array as a JSON array of quoted strings.
150
- def self.from_str_array(a)
151
- out = "["
152
- i = 0
153
- while i < a.length
154
- if i > 0
155
- out = out + ","
156
- end
157
- out = out + Json.quote(a[i])
158
- i += 1
159
- end
160
- out + "]"
161
- end
162
-
163
- # Encode an int array as a JSON array of numbers.
164
- def self.from_int_array(a)
165
- out = "["
166
- i = 0
167
- while i < a.length
168
- if i > 0
169
- out = out + ","
170
- end
171
- out = out + a[i].to_s
172
- i += 1
173
- end
174
- out + "]"
175
- end
176
-
177
- # ---- Decoders (flat-key, top-level only) ----
178
- #
179
- # `get_str(s, key)` finds the entry for `key` in the top-level
180
- # object literal `s` and returns its value as a string.
181
- # Returns "" when `key` is absent or the value isn't a string.
182
- # Same shape for `get_int`. `has_key?(s, key)` returns a
183
- # boolean independent of value type.
184
- #
185
- # The parser is a hand-rolled state machine that walks one
186
- # `{ "k": <value>, ... }` pair at a time, skipping over any
187
- # value (including nested objects / arrays) it doesn't need.
188
- # Strings inside values are honoured for escape sequences so
189
- # that `\"` doesn't terminate the string and corrupt the walk.
190
-
191
- def self.get_str(s, key)
192
- pos = Json.find_value_start(s, key)
193
- if pos < 0
194
- return ""
195
- end
196
- Json.parse_str_value(s, pos)
197
- end
198
-
199
- def self.get_int(s, key)
200
- pos = Json.find_value_start(s, key)
201
- if pos < 0
202
- return 0
203
- end
204
- Json.parse_int_value(s, pos)
205
- end
206
-
207
- # Decode a JSON number value at `key` -> Float. Accepts both
208
- # integer-literal (`42`) and float-literal (`3.14`, `-0.5`, `1e2`)
209
- # JSON-number syntax; the integer form returns N.0. Missing key
210
- # or malformed value returns 0.0 (consistent with the other
211
- # getters' missing-key defaults).
212
- #
213
- # Implementation: delegates the value-span walking to skip_value
214
- # (already handles all JSON-number syntax + structural-char
215
- # boundaries), then String#to_f on the substring. Inlined rather
216
- # than factored into a parse_float_value helper because spinel's
217
- # type inference mis-widens `s` to int through the indirection
218
- # ("cannot resolve call to 'length' on int" + the downstream
219
- # skip_ws/skip_value pointer-vs-int conversion errors).
220
- def self.get_float(s, key)
221
- pos = Json.find_value_start(s, key)
222
- if pos < 0
223
- return 0.0
224
- end
225
- pos = Json.skip_ws(s, pos)
226
- if pos >= s.length
227
- return 0.0
228
- end
229
- end_pos = Json.skip_value(s, pos)
230
- if end_pos <= pos
231
- return 0.0
232
- end
233
- s[pos, end_pos - pos].to_f
234
- end
235
-
236
- def self.has_key?(s, key)
237
- Json.find_value_start(s, key) >= 0
238
- end
239
-
240
- # Decode a flat JSON array of integers at `key` -> Array[Integer].
241
- # The `prompt` of /v1/completions is a token-id array
242
- # (`[464, 6193, ...]`). A missing or non-array value yields []
243
- # (the tep typed-empty-array idiom); non-int elements are skipped.
244
- def self.get_int_array(s, key)
245
- out = [0]
246
- out.delete_at(0)
247
- pos = Json.find_value_start(s, key)
248
- if pos < 0
249
- return out
250
- end
251
- pos = Json.skip_ws(s, pos)
252
- if pos >= s.length || s[pos] != "["
253
- return out
254
- end
255
- pos += 1
256
- while pos < s.length
257
- pos = Json.skip_ws(s, pos)
258
- if pos >= s.length
259
- return out
260
- end
261
- c = s[pos]
262
- if c == "]"
263
- return out
264
- elsif c == ","
265
- pos += 1
266
- elsif (c >= "0" && c <= "9") || c == "-"
267
- out.push(Json.parse_int_value(s, pos))
268
- # Advance past the number parse_int_value just consumed
269
- # (optional '-' then digits).
270
- if s[pos] == "-"
271
- pos += 1
272
- end
273
- while pos < s.length && s[pos] >= "0" && s[pos] <= "9"
274
- pos += 1
275
- end
276
- else
277
- # Non-int element (string / object / etc.): skip it.
278
- pos = Json.skip_value(s, pos)
279
- end
280
- end
281
- out
282
- end
283
-
284
- # ---- Internal helpers ----
285
-
286
- # Skip whitespace starting at `pos`, return the new position.
287
- def self.skip_ws(s, pos)
288
- while pos < s.length
289
- c = s[pos]
290
- if c == " " || c == "\t" || c == "\n" || c == "\r"
291
- pos += 1
292
- else
293
- return pos
294
- end
295
- end
296
- pos
297
- end
298
-
299
- # Walk a JSON-quoted string starting at `pos` (which must point
300
- # at the opening `"`). Returns the position one past the
301
- # closing `"`. Returns -1 on malformed input.
302
- def self.skip_str(s, pos)
303
- if pos >= s.length || s[pos] != "\""
304
- return -1
305
- end
306
- pos += 1
307
- while pos < s.length
308
- c = s[pos]
309
- if c == "\\"
310
- # Skip the escape and the escaped character. \uXXXX
311
- # spans 6 chars total but skipping 2 still keeps us
312
- # inside the string for the rest of the walk -- the
313
- # remaining 4 hex digits look like ordinary string
314
- # bytes and won't terminate the literal.
315
- pos += 2
316
- elsif c == "\""
317
- return pos + 1
318
- else
319
- pos += 1
320
- end
321
- end
322
- -1
323
- end
324
-
325
- # Walk a JSON value starting at `pos` (which must point at the
326
- # first non-ws char of the value). Returns the position one
327
- # past the value (or the input length on truncation).
328
- def self.skip_value(s, pos)
329
- pos = Json.skip_ws(s, pos)
330
- if pos >= s.length
331
- return pos
332
- end
333
- c = s[pos]
334
- if c == "\""
335
- return Json.skip_str(s, pos)
336
- end
337
- if c == "{" || c == "["
338
- return Json.skip_container(s, pos)
339
- end
340
- # number / true / false / null -- read until the next
341
- # structural / whitespace char.
342
- while pos < s.length
343
- c = s[pos]
344
- if c == "," || c == "}" || c == "]" ||
345
- c == " " || c == "\t" || c == "\n" || c == "\r"
346
- return pos
347
- end
348
- pos += 1
349
- end
350
- pos
351
- end
352
-
353
- # Walk a balanced { ... } or [ ... ] starting at `pos`. Honours
354
- # string literals so that `{` / `}` inside a value-string don't
355
- # confuse the brace counter. Returns position one past the
356
- # matching closer.
357
- def self.skip_container(s, pos)
358
- open_c = s[pos]
359
- close_c = open_c == "{" ? "}" : "]"
360
- depth = 1
361
- pos += 1
362
- while pos < s.length && depth > 0
363
- c = s[pos]
364
- if c == "\""
365
- # whole nested string -- skip past it
366
- npos = Json.skip_str(s, pos)
367
- if npos < 0
368
- return s.length
369
- end
370
- pos = npos
371
- elsif c == open_c
372
- depth += 1
373
- pos += 1
374
- elsif c == close_c
375
- depth -= 1
376
- pos += 1
377
- else
378
- pos += 1
379
- end
380
- end
381
- pos
382
- end
383
-
384
- # Read a JSON-quoted string at `pos` and return its decoded
385
- # contents (no surrounding quotes). Decodes the same escape
386
- # sequences that `escape` produces. Returns "" on malformed
387
- # input.
388
- def self.parse_str_value(s, pos)
389
- pos = Json.skip_ws(s, pos)
390
- if pos >= s.length || s[pos] != "\""
391
- return ""
392
- end
393
- pos += 1
394
- out = ""
395
- while pos < s.length
396
- c = s[pos]
397
- if c == "\""
398
- return out
399
- end
400
- if c == "\\"
401
- if pos + 1 >= s.length
402
- return out
403
- end
404
- esc = s[pos + 1]
405
- if esc == "\""
406
- out = out + "\""
407
- elsif esc == "\\"
408
- out = out + "\\"
409
- elsif esc == "/"
410
- out = out + "/"
411
- elsif esc == "n"
412
- out = out + "\n"
413
- elsif esc == "r"
414
- out = out + "\r"
415
- elsif esc == "t"
416
- out = out + "\t"
417
- elsif esc == "b"
418
- out = out + "\b"
419
- elsif esc == "f"
420
- out = out + "\f"
421
- elsif esc == "u"
422
- # \u00XX -> map the two-digit hex back to a byte. Wider
423
- # codepoints (Ā+ or surrogate pairs) aren't
424
- # decoded; the byte we emit is the low byte of the
425
- # codepoint, which round-trips ASCII at minimum.
426
- if pos + 5 < s.length
427
- h1 = Json.hex_nibble(s[pos + 4])
428
- h2 = Json.hex_nibble(s[pos + 5])
429
- if h1 >= 0 && h2 >= 0
430
- # rebuild the byte and push it -- spinel strings
431
- # are byte-blobs, so this works for ASCII; for
432
- # non-ASCII the original encoder would have used a
433
- # passthrough byte anyway.
434
- b = h1 * 16 + h2
435
- out = out + Json.byte_to_chr(b)
436
- pos += 6
437
- next
438
- end
439
- end
440
- out = out + "?"
441
- pos += 2
442
- next
443
- else
444
- out = out + esc
445
- end
446
- pos += 2
447
- else
448
- out = out + c
449
- pos += 1
450
- end
451
- end
452
- out
453
- end
454
-
455
- def self.hex_nibble(c)
456
- if c >= "0" && c <= "9"
457
- return c.getbyte(0) - "0".getbyte(0)
458
- end
459
- if c >= "a" && c <= "f"
460
- return c.getbyte(0) - "a".getbyte(0) + 10
461
- end
462
- if c >= "A" && c <= "F"
463
- return c.getbyte(0) - "A".getbyte(0) + 10
464
- end
465
- -1
466
- end
467
-
468
- # Build a single-byte string from an integer 0..255.
469
- # Spinel doesn't expose `n.chr` for arbitrary bytes uniformly;
470
- # the table covers the ASCII printable range and falls back to
471
- # "?" for anything else (the JSON encoder side never produces
472
- # non-ASCII via \u, so the fallback is reachable only for
473
- # malformed input).
474
- def self.byte_to_chr(n)
475
- printable = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
476
- if n >= 32 && n < 127
477
- return printable[n - 32, 1]
478
- end
479
- if n == 9
480
- return "\t"
481
- end
482
- if n == 10
483
- return "\n"
484
- end
485
- if n == 13
486
- return "\r"
487
- end
488
- "?"
489
- end
490
-
491
- # Read an integer at `pos`. Accepts an optional leading `-`.
492
- # Returns 0 on no-digit / non-numeric input (caller can use
493
- # `has_key?` first if 0-vs-absent matters).
494
- def self.parse_int_value(s, pos)
495
- pos = Json.skip_ws(s, pos)
496
- if pos >= s.length
497
- return 0
498
- end
499
- neg = false
500
- if s[pos] == "-"
501
- neg = true
502
- pos += 1
503
- end
504
- n = 0
505
- saw_digit = false
506
- while pos < s.length
507
- c = s[pos]
508
- if c >= "0" && c <= "9"
509
- n = n * 10 + (c.getbyte(0) - "0".getbyte(0))
510
- saw_digit = true
511
- pos += 1
512
- else
513
- break
514
- end
515
- end
516
- if !saw_digit
517
- return 0
518
- end
519
- neg ? -n : n
520
- end
521
-
522
- # Walk the top-level object looking for the entry whose key
523
- # matches `target_key`; return the position of the value's
524
- # first non-ws character. Returns -1 if not found.
525
- def self.find_value_start(s, target_key)
526
- pos = Json.skip_ws(s, 0)
527
- if pos >= s.length || s[pos] != "{"
528
- return -1
529
- end
530
- pos += 1
531
- while pos < s.length
532
- pos = Json.skip_ws(s, pos)
533
- if pos >= s.length
534
- return -1
535
- end
536
- if s[pos] == "}"
537
- return -1
538
- end
539
- # Read a key.
540
- if s[pos] != "\""
541
- return -1
542
- end
543
- key_start = pos
544
- pos = Json.skip_str(s, pos)
545
- if pos < 0
546
- return -1
547
- end
548
- # Decode the key for comparison (handles \" inside keys).
549
- key = Json.parse_str_value(s, key_start)
550
- # Skip ws, ":".
551
- pos = Json.skip_ws(s, pos)
552
- if pos >= s.length || s[pos] != ":"
553
- return -1
554
- end
555
- pos += 1
556
- pos = Json.skip_ws(s, pos)
557
- if key == target_key
558
- return pos
559
- end
560
- # Skip the value, then the comma (if any).
561
- pos = Json.skip_value(s, pos)
562
- pos = Json.skip_ws(s, pos)
563
- if pos < s.length && s[pos] == ","
564
- pos += 1
565
- elsif pos < s.length && s[pos] == "}"
566
- return -1
567
- end
568
- end
569
- -1
570
- end
571
- end
572
- end