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
@@ -0,0 +1,166 @@
1
+ # VENDORED from OriPekelman/spinelkit @ 09e8558 -- DO NOT EDIT HERE.
2
+ # Edit upstream and re-sync with `make vendor-spinelkit`.
3
+ require_relative "hex"
4
+
5
+ # SpinelKit::Url -- percent-encode/decode (the CGI / URI-component surface
6
+ # Spinel can't get from stdlib) plus a form-query parser and a small URL
7
+ # splitter. Ported from Tep::Url; the hex digits now come from SpinelKit::Hex.
8
+ #
9
+ # Self-contained: the empty str=>str hashes are seeded inline (the
10
+ # `{"" => ""}`-then-delete idiom that pins Spinel's value type), and substring
11
+ # search is a private `find_idx` (the `< 0` callsites can't narrow against
12
+ # String#index's int|nil under Spinel's current model). All pure string ops.
13
+ module SpinelKit
14
+ class Url
15
+ # "%41+b" -> "A b" (form-decode: `+` is space, `%XX` is a byte).
16
+ def self.unescape(s)
17
+ out = ""
18
+ i = 0
19
+ n = s.length
20
+ while i < n
21
+ c = s[i]
22
+ if c == "+"
23
+ out = out + " "
24
+ i += 1
25
+ elsif c == "%" && i + 2 < n
26
+ hi = Hex.nibble(s[i + 1])
27
+ lo = Hex.nibble(s[i + 2])
28
+ if hi >= 0 && lo >= 0
29
+ out = out + ((hi * 16 + lo).chr)
30
+ i += 3
31
+ else
32
+ out = out + c
33
+ i += 1
34
+ end
35
+ else
36
+ out = out + c
37
+ i += 1
38
+ end
39
+ end
40
+ out
41
+ end
42
+
43
+ # Percent-encode everything outside the RFC 3986 unreserved set
44
+ # (ALPHA / DIGIT / `-._~`); the rest becomes `%XX` with UPPERCASE hex.
45
+ # (Space -> `%20`, not `+` -- this is the URI-component, not form, encoder.)
46
+ #
47
+ # Byte-oriented: under Spinel `String#[]` indexes BYTES, so a multi-byte
48
+ # UTF-8 char is encoded byte-by-byte (correct %XX of each byte). Under CRuby,
49
+ # pass a binary string for the same behaviour (else `[]` splits on chars).
50
+ def self.escape(s)
51
+ out = ""
52
+ i = 0
53
+ while i < s.length
54
+ c = s[i]
55
+ if (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
56
+ (c >= "0" && c <= "9") || c == "-" || c == "." ||
57
+ c == "_" || c == "~"
58
+ out = out + c
59
+ else
60
+ b = c.getbyte(0)
61
+ out = out + "%" + Hex.nibble_char(b / 16) + Hex.nibble_char(b % 16)
62
+ end
63
+ i += 1
64
+ end
65
+ out
66
+ end
67
+
68
+ # "a=1&b=2&c" -> {"a"=>"1","b"=>"2","c"=>""}. Keys + values are
69
+ # form-decoded (`unescape`).
70
+ def self.parse_query(s)
71
+ h = {"" => ""}
72
+ h.delete("")
73
+ if s.length == 0
74
+ return h
75
+ end
76
+ pairs = s.split("&")
77
+ pairs.each do |pair|
78
+ if pair.length > 0
79
+ eq = Url.find_idx(pair, "=", 0)
80
+ if eq < 0
81
+ h[Url.unescape(pair)] = ""
82
+ else
83
+ k = pair[0, eq]
84
+ v = pair[eq + 1, pair.length - eq - 1]
85
+ h[Url.unescape(k)] = Url.unescape(v)
86
+ end
87
+ end
88
+ end
89
+ h
90
+ end
91
+
92
+ # Split `http(s)://host[:port]/path?query` into a str=>str hash keyed
93
+ # scheme / host / port / path / query. Without a scheme the input is
94
+ # treated as a path (host stays empty). Default ports follow the scheme
95
+ # (80 / 443); `query` is the raw substring after `?` (not decoded).
96
+ #
97
+ # One body on purpose: Spinel widens a Hash-typed value when a helper
98
+ # mutates it and the caller keeps reading, so `out` stays StrStrHash only
99
+ # if nothing factors the mutation out (find_idx returns an int, no mutate).
100
+ def self.split_url(u)
101
+ out = {"" => ""}
102
+ out.delete("")
103
+ out["scheme"] = ""
104
+ out["host"] = ""
105
+ out["port"] = ""
106
+ out["path"] = "/"
107
+ out["query"] = ""
108
+
109
+ rest = u
110
+ if rest.length >= 7 && rest[0, 7] == "http://"
111
+ out["scheme"] = "http"
112
+ out["port"] = "80"
113
+ rest = rest[7, rest.length - 7]
114
+ elsif rest.length >= 8 && rest[0, 8] == "https://"
115
+ out["scheme"] = "https"
116
+ out["port"] = "443"
117
+ rest = rest[8, rest.length - 8]
118
+ end
119
+
120
+ if out["scheme"].length > 0
121
+ slash = Url.find_idx(rest, "/", 0)
122
+ hostport = rest
123
+ tail = "/"
124
+ if slash >= 0
125
+ hostport = rest[0, slash]
126
+ tail = rest[slash, rest.length - slash]
127
+ end
128
+ colon = Url.find_idx(hostport, ":", 0)
129
+ if colon >= 0
130
+ out["host"] = hostport[0, colon]
131
+ out["port"] = hostport[colon + 1, hostport.length - colon - 1]
132
+ else
133
+ out["host"] = hostport
134
+ end
135
+ rest = tail
136
+ end
137
+
138
+ qi = Url.find_idx(rest, "?", 0)
139
+ if qi >= 0
140
+ out["path"] = rest[0, qi]
141
+ out["query"] = rest[qi + 1, rest.length - qi - 1]
142
+ else
143
+ out["path"] = rest
144
+ end
145
+ if out["path"].length == 0
146
+ out["path"] = "/"
147
+ end
148
+ out
149
+ end
150
+
151
+ # First index of `needle` in `s` at/after `start`, or -1. Internal
152
+ # (Spinel-safe substring search; see the module comment).
153
+ def self.find_idx(s, needle, start)
154
+ nlen = needle.length
155
+ slen = s.length
156
+ pos = start
157
+ while pos <= slen - nlen
158
+ if s[pos, nlen] == needle
159
+ return pos
160
+ end
161
+ pos += 1
162
+ end
163
+ -1
164
+ end
165
+ end
166
+ end
@@ -9,7 +9,7 @@
9
9
  # Tep::AuthBearerToken.set_secret(ENV["JWT_SECRET"])
10
10
  #
11
11
  # Token payload schema (flat JSON, single level -- matches
12
- # Tep::Json's flat-object extraction surface):
12
+ # SpinelKit::Json's flat-object extraction surface):
13
13
  #
14
14
  # {
15
15
  # "sub": "user:42", # principal_id (required)
@@ -22,7 +22,7 @@
22
22
  # # agent_id|issued_at|expires_at|origin
23
23
  # }
24
24
  #
25
- # Why flat (not nested `acting_via: { ... }`): Tep::Json today
25
+ # Why flat (not nested `acting_via: { ... }`): SpinelKit::Json today
26
26
  # extracts flat keys only. A nested-object getter is a separate
27
27
  # tiny battery; for v1 of Auth the flat pipe-encoded delegate
28
28
  # string is the smallest thing that ships and round-trips
@@ -64,20 +64,20 @@ module Tep
64
64
 
65
65
  # Check expiry first -- a token whose exp passed gets rejected
66
66
  # even if the signature still verifies. exp is unix epoch sec.
67
- exp = Tep::Json.get_int(payload, "exp")
67
+ exp = SpinelKit::Json.get_int(payload, "exp")
68
68
  if exp > 0 && Time.now.to_i >= exp
69
69
  return nil
70
70
  end
71
71
 
72
- sub = Tep::Json.get_str(payload, "sub")
72
+ sub = SpinelKit::Json.get_str(payload, "sub")
73
73
  if sub.length == 0
74
74
  return nil
75
75
  end
76
76
 
77
- caps_str = Tep::Json.get_str(payload, "caps")
77
+ caps_str = SpinelKit::Json.get_str(payload, "caps")
78
78
  caps = Tep::AuthBearerToken.parse_caps(caps_str)
79
79
 
80
- delegate_str = Tep::Json.get_str(payload, "delegate")
80
+ delegate_str = SpinelKit::Json.get_str(payload, "delegate")
81
81
  delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
82
82
 
83
83
  Tep::Identity.new(sub, delegation, caps)
@@ -161,10 +161,10 @@ module Tep
161
161
  delegate_str = rec.client_id + "|" + now_ts.to_s + "|" +
162
162
  exp_ts.to_s + "|oauth_grant"
163
163
  payload = "{" +
164
- Tep::Json.encode_pair_str("sub", rec.principal_id) + "," +
165
- Tep::Json.encode_pair_int("exp", exp_ts) + "," +
166
- Tep::Json.encode_pair_str("caps", rec.caps_str) + "," +
167
- Tep::Json.encode_pair_str("delegate", delegate_str) +
164
+ SpinelKit::Json.encode_pair_str("sub", rec.principal_id) + "," +
165
+ SpinelKit::Json.encode_pair_int("exp", exp_ts) + "," +
166
+ SpinelKit::Json.encode_pair_str("caps", rec.caps_str) + "," +
167
+ SpinelKit::Json.encode_pair_str("delegate", delegate_str) +
168
168
  "}"
169
169
  Tep::Jwt.encode_hs256(payload, secret)
170
170
  end
data/lib/tep/events.rb CHANGED
@@ -80,19 +80,19 @@ module Tep
80
80
  # was a bare string before #115. os + arch come from uname() via
81
81
  # Sock.sphttp_os_kind / sphttp_arch_kind.
82
82
  line = "{" +
83
- Json.encode_pair_str("kind", "run_start") + "," +
84
- Json.encode_pair_str("schema", "toy/v1") + "," +
85
- Json.encode_pair_int("t", 0) + "," +
86
- Json.encode_pair_str("started_at", started) + "," +
83
+ SpinelKit::Json.encode_pair_str("kind", "run_start") + "," +
84
+ SpinelKit::Json.encode_pair_str("schema", "toy/v1") + "," +
85
+ SpinelKit::Json.encode_pair_int("t", 0) + "," +
86
+ SpinelKit::Json.encode_pair_str("started_at", started) + "," +
87
87
  "\"host\":{" +
88
- Json.encode_pair_str("name", host) + "," +
89
- Json.encode_pair_str("os", Sock.sphttp_os_kind) + "," +
90
- Json.encode_pair_str("arch", Sock.sphttp_arch_kind) +
88
+ SpinelKit::Json.encode_pair_str("name", host) + "," +
89
+ SpinelKit::Json.encode_pair_str("os", Sock.sphttp_os_kind) + "," +
90
+ SpinelKit::Json.encode_pair_str("arch", Sock.sphttp_arch_kind) +
91
91
  "}," +
92
- "\"backend\":{" + Json.encode_pair_str("kind", backend_kind) + "}," +
92
+ "\"backend\":{" + SpinelKit::Json.encode_pair_str("kind", backend_kind) + "}," +
93
93
  "\"model\":{" +
94
- Json.encode_pair_str("name", model_name) + "," +
95
- Json.encode_pair_str("path", model_path) +
94
+ SpinelKit::Json.encode_pair_str("name", model_name) + "," +
95
+ SpinelKit::Json.encode_pair_str("path", model_path) +
96
96
  "}," +
97
97
  "\"config\":" + config_json +
98
98
  "}"
@@ -120,10 +120,10 @@ module Tep
120
120
  # Build the merged extra: spec fields first, then caller's
121
121
  # fields appended (if non-empty).
122
122
  extra = "{" +
123
- Json.encode_pair_str("model", model) + "," +
124
- Json.encode_pair_int("prompt_tokens", prompt_tokens) + "," +
125
- Json.encode_pair_int("completion_tokens", completion_tokens) + "," +
126
- Json.encode_pair_int("latency_us", wall_us)
123
+ SpinelKit::Json.encode_pair_str("model", model) + "," +
124
+ SpinelKit::Json.encode_pair_int("prompt_tokens", prompt_tokens) + "," +
125
+ SpinelKit::Json.encode_pair_int("completion_tokens", completion_tokens) + "," +
126
+ SpinelKit::Json.encode_pair_int("latency_us", wall_us)
127
127
  caller_inner = ""
128
128
  if extra_json.length > 2
129
129
  # Strip the outer braces -- "{...}" -> "...".
@@ -134,10 +134,10 @@ module Tep
134
134
  end
135
135
  extra = extra + "}"
136
136
  line = "{" +
137
- Json.encode_pair_str("kind", "eval") + "," +
138
- Json.encode_pair_str("phase", "serve") + "," +
139
- Json.encode_pair_int("t", rel_t) + "," +
140
- Json.encode_pair_str("name", "request") + "," +
137
+ SpinelKit::Json.encode_pair_str("kind", "eval") + "," +
138
+ SpinelKit::Json.encode_pair_str("phase", "serve") + "," +
139
+ SpinelKit::Json.encode_pair_int("t", rel_t) + "," +
140
+ SpinelKit::Json.encode_pair_str("name", "request") + "," +
141
141
  "\"extra\":" + extra +
142
142
  "}"
143
143
  append_line(line)
@@ -163,14 +163,14 @@ module Tep
163
163
  end
164
164
  ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
165
165
  line = "{" +
166
- Json.encode_pair_str("kind", "run_end") + "," +
167
- Json.encode_pair_int("t", rel_t) + "," +
168
- Json.encode_pair_str("ended_at", ended) + "," +
169
- Json.encode_pair_str("reason", reason) + "," +
166
+ SpinelKit::Json.encode_pair_str("kind", "run_end") + "," +
167
+ SpinelKit::Json.encode_pair_int("t", rel_t) + "," +
168
+ SpinelKit::Json.encode_pair_str("ended_at", ended) + "," +
169
+ SpinelKit::Json.encode_pair_str("reason", reason) + "," +
170
170
  "\"stats\":{" +
171
- Json.encode_pair_int("requests", @req_count) + "," +
172
- Json.encode_pair_int("errors", @err_count) + "," +
173
- Json.encode_pair_int("tokens_out", @tok_out) +
171
+ SpinelKit::Json.encode_pair_int("requests", @req_count) + "," +
172
+ SpinelKit::Json.encode_pair_int("errors", @err_count) + "," +
173
+ SpinelKit::Json.encode_pair_int("tokens_out", @tok_out) +
174
174
  "}" +
175
175
  "}"
176
176
  append_line(line)
@@ -205,28 +205,28 @@ module Tep
205
205
  Tep.str_find(line_s, "\"name\":\"request\"", 0) >= 0
206
206
  reqs += 1
207
207
  # completion_tokens now lives nested inside the `extra`
208
- # object. Tep::Json.find_value_start walks only the
208
+ # object. SpinelKit::Json.find_value_start walks only the
209
209
  # top-level keys (it skips over nested objects), so we
210
210
  # have to extract extra first, then get_int within it.
211
- extra_pos = Json.find_value_start(line_s, "extra")
211
+ extra_pos = SpinelKit::Json.find_value_start(line_s, "extra")
212
212
  if extra_pos >= 0
213
- obj_end = Json.skip_container(line_s, extra_pos)
213
+ obj_end = SpinelKit::Json.skip_container(line_s, extra_pos)
214
214
  extra_obj = line_s[extra_pos, obj_end - extra_pos]
215
- toks += Json.get_int(extra_obj, "completion_tokens")
215
+ toks += SpinelKit::Json.get_int(extra_obj, "completion_tokens")
216
216
  end
217
217
  end
218
218
  i += 1
219
219
  end
220
220
  ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
221
221
  out = "{" +
222
- Json.encode_pair_str("kind", "run_end") + "," +
223
- Json.encode_pair_int("t", rel_t) + "," +
224
- Json.encode_pair_str("ended_at", ended) + "," +
225
- Json.encode_pair_str("reason", reason) + "," +
222
+ SpinelKit::Json.encode_pair_str("kind", "run_end") + "," +
223
+ SpinelKit::Json.encode_pair_int("t", rel_t) + "," +
224
+ SpinelKit::Json.encode_pair_str("ended_at", ended) + "," +
225
+ SpinelKit::Json.encode_pair_str("reason", reason) + "," +
226
226
  "\"stats\":{" +
227
- Json.encode_pair_int("requests", reqs) + "," +
228
- Json.encode_pair_int("errors", errs) + "," +
229
- Json.encode_pair_int("tokens_out", toks) +
227
+ SpinelKit::Json.encode_pair_int("requests", reqs) + "," +
228
+ SpinelKit::Json.encode_pair_int("errors", errs) + "," +
229
+ SpinelKit::Json.encode_pair_int("tokens_out", toks) +
230
230
  "}" +
231
231
  "}"
232
232
  append_line(out)
@@ -243,7 +243,7 @@ module Tep
243
243
  end
244
244
 
245
245
  # Append one JSON line. Best-effort, append mode -- mirrors
246
- # Tep::Logger's file sink. Telemetry must never fail a request, so
246
+ # SpinelKit::Log's file sink. Telemetry must never fail a request, so
247
247
  # a malformed/unwritable path degrades to a dropped line rather
248
248
  # than a raised error reaching the handler. Callers gate on a
249
249
  # non-empty @path before reaching here.
data/lib/tep/http.rb CHANGED
@@ -151,7 +151,7 @@ module Tep
151
151
 
152
152
  def self.send_req_blocking(verb, url, body, headers)
153
153
  out = Tep::Http::Response.new
154
- parts = Tep::Url.split_url(url)
154
+ parts = SpinelKit::Url.split_url(url)
155
155
  scheme = parts["scheme"]
156
156
  if scheme != "http" && scheme != "https"
157
157
  # Unknown scheme.
@@ -168,7 +168,7 @@ module Tep
168
168
  # pooling TLS sockets is out of scope for 6.7b (#126). HTTP/1.0 +
169
169
  # Connection: close + recv-until-EOF over a fresh verified socket.
170
170
  if scheme == "https"
171
- fd = Sock.sphttp_connect_tls(host, port) # port 443 via Tep::Url
171
+ fd = Sock.sphttp_connect_tls(host, port) # port 443 via SpinelKit::Url
172
172
  if fd < 0
173
173
  return out
174
174
  end
@@ -275,7 +275,7 @@ module Tep
275
275
  # docs/MACOS-CONCURRENCY.md for the why.
276
276
  def self.send_req_coop(verb, url, body, headers)
277
277
  out = Tep::Http::Response.new
278
- parts = Tep::Url.split_url(url)
278
+ parts = SpinelKit::Url.split_url(url)
279
279
  scheme = parts["scheme"]
280
280
  if scheme != "http" && scheme != "https"
281
281
  return out
data/lib/tep/job.rb CHANGED
@@ -24,7 +24,7 @@
24
24
  # )
25
25
  #
26
26
  # The single-arg payload is intentional: structured data goes
27
- # through JSON (Tep::Json) which we already ship. Sidekiq's
27
+ # through JSON (SpinelKit::Json) which we already ship. Sidekiq's
28
28
  # multi-arg `perform_async(a, b, c)` translates to encoding the
29
29
  # tuple as a JSON string and decoding it in `perform`.
30
30
  #
@@ -34,7 +34,7 @@
34
34
  #
35
35
  # class HelloJob < Tep::Job
36
36
  # def perform(arg)
37
- # Tep::Logger.new.info("hello " + arg)
37
+ # SpinelKit::Log.new.info("hello " + arg)
38
38
  # "done"
39
39
  # end
40
40
  # end
data/lib/tep/jwt.rb CHANGED
@@ -13,14 +13,14 @@
13
13
  # Surface
14
14
  # -------
15
15
  #
16
- # payload_json = "{" + Tep::Json.encode_pair_str("sub", user_id) + "," +
17
- # Tep::Json.encode_pair_int("exp", exp_unix) + "}"
16
+ # payload_json = "{" + SpinelKit::Json.encode_pair_str("sub", user_id) + "," +
17
+ # SpinelKit::Json.encode_pair_int("exp", exp_unix) + "}"
18
18
  # token = Tep::Jwt.encode_hs256(payload_json, secret)
19
19
  #
20
20
  # # On the receiving side:
21
21
  # if Tep::Jwt.verify_hs256(token, secret)
22
22
  # payload = Tep::Jwt.decode_payload(token) # the JSON string
23
- # sub = Tep::Json.get_str(payload, "sub")
23
+ # sub = SpinelKit::Json.get_str(payload, "sub")
24
24
  # end
25
25
  #
26
26
  # Scope
@@ -32,7 +32,7 @@
32
32
  #
33
33
  # **Claims validation:** the `verify_hs256` only checks the
34
34
  # signature. `exp` / `nbf` / `iss` / `aud` claim checks are left
35
- # to caller code -- pull them with `Tep::Json.get_int(payload, "exp")`
35
+ # to caller code -- pull them with `SpinelKit::Json.get_int(payload, "exp")`
36
36
  # and compare against `Time.now.to_i`. This keeps the surface
37
37
  # small and lets the app's policy decide what's required (some
38
38
  # apps want skew tolerance, some want strict expiry).
data/lib/tep/live_view.rb CHANGED
@@ -132,8 +132,8 @@ module Tep
132
132
  # "note": <free text>,
133
133
  # "until_ts": <unix ts or 0> }
134
134
  #
135
- # Subclasses typically pull a few keys via Tep::Json.get_str /
136
- # Tep::Json.get_int + update an @presence ivar; then either
135
+ # Subclasses typically pull a few keys via SpinelKit::Json.get_str /
136
+ # SpinelKit::Json.get_int + update an @presence ivar; then either
137
137
  # call broadcast_render (to fan out the new HTML to every
138
138
  # subscriber) or just mutate (if the LiveView is the only
139
139
  # observer).
@@ -176,8 +176,8 @@ module Tep
176
176
  # imeth on the base class dispatches through the typed slot
177
177
  # of the subclass instance and avoids the box.
178
178
  def dispatch_event_json(json_msg, req)
179
- event = Tep::Json.get_str(json_msg, "event")
180
- payload = Tep::Json.get_str(json_msg, "payload")
179
+ event = SpinelKit::Json.get_str(json_msg, "event")
180
+ payload = SpinelKit::Json.get_str(json_msg, "payload")
181
181
  handle_event(event, payload, req)
182
182
  0
183
183
  end
data/lib/tep/llm.rb CHANGED
@@ -96,7 +96,7 @@ module Tep
96
96
  # argument's typed-callsite to a single shape -- splitting
97
97
  # tripped spinel's cross-method param inference.
98
98
  body = body[0, body.length - 1] + ",\"stream\":true}"
99
- parts = Tep::Url.split_url(@base_url)
99
+ parts = SpinelKit::Url.split_url(@base_url)
100
100
  host = parts["host"]
101
101
  port = parts["port"].to_i
102
102
  fd = Sock.sphttp_connect(host, port)
@@ -123,15 +123,15 @@ module Tep
123
123
  out
124
124
  end
125
125
 
126
- # Hand-rolled JSON build. Tep::Json doesn't ship nested
126
+ # Hand-rolled JSON build. SpinelKit::Json doesn't ship nested
127
127
  # array-of-hash support (its public encoders are flat); the
128
128
  # request body is a fixed shape so the inline assembly stays
129
129
  # bounded.
130
130
  def self.build_request_body(model, system_prompt, messages)
131
- out = "{\"model\":" + Json.quote(model) + ",\"messages\":["
131
+ out = "{\"model\":" + SpinelKit::Json.quote(model) + ",\"messages\":["
132
132
  first = true
133
133
  if system_prompt.length > 0
134
- out = out + "{\"role\":\"system\",\"content\":" + Json.quote(system_prompt) + "}"
134
+ out = out + "{\"role\":\"system\",\"content\":" + SpinelKit::Json.quote(system_prompt) + "}"
135
135
  first = false
136
136
  end
137
137
  i = 0
@@ -140,8 +140,8 @@ module Tep
140
140
  out = out + ","
141
141
  end
142
142
  msg = messages[i]
143
- out = out + "{\"role\":" + Json.quote(msg.role) +
144
- ",\"content\":" + Json.quote(msg.content) + "}"
143
+ out = out + "{\"role\":" + SpinelKit::Json.quote(msg.role) +
144
+ ",\"content\":" + SpinelKit::Json.quote(msg.content) + "}"
145
145
  first = false
146
146
  i += 1
147
147
  end
@@ -152,7 +152,7 @@ module Tep
152
152
  # OpenAI response shape:
153
153
  # {"choices":[{"message":{"role":"assistant","content":"..."},
154
154
  # "finish_reason":"stop"}], ...}
155
- # We extract two fields, both inside choices[0]. Tep::Json's
155
+ # We extract two fields, both inside choices[0]. SpinelKit::Json's
156
156
  # flat-key decoder doesn't dive that deep, so we hand-walk the
157
157
  # JSON looking for `"message":{...}` and pull "content" + (the
158
158
  # surrounding) "finish_reason" out of it.
@@ -344,7 +344,7 @@ module Tep
344
344
  delta = Llm.extract_str_field(payload, "content", 0)
345
345
  if delta.length > 0
346
346
  state.acc = state.acc + delta
347
- out_stream.write("data: {" + Json.encode_pair_str("content", delta) + "}\n\n")
347
+ out_stream.write("data: {" + SpinelKit::Json.encode_pair_str("content", delta) + "}\n\n")
348
348
  end
349
349
  # finish_reason on the last frame -- not load-bearing for
350
350
  # the accumulator but signals upstream end-of-stream.
@@ -375,11 +375,10 @@ module Tep
375
375
  return out
376
376
  end
377
377
  hex = s[i, eol - i]
378
- n = Llm.hex_to_int(hex)
379
- if n < 0
380
- # Malformed length; bail.
381
- return out
382
- end
378
+ # to_int parses the leading hex (so a `size;ext` chunk-extension
379
+ # yields the size, not a parse error) and is >= 0, so 0 -- empty or
380
+ # no leading hex -- is the terminating chunk / give-up point.
381
+ n = SpinelKit::Hex.to_int(hex)
383
382
  if n == 0
384
383
  # Last chunk -- done.
385
384
  return out
@@ -407,10 +406,7 @@ module Tep
407
406
  return s[i, s.length - i]
408
407
  end
409
408
  hex = s[i, eol - i]
410
- n = Llm.hex_to_int(hex)
411
- if n < 0
412
- return s[i, s.length - i]
413
- end
409
+ n = SpinelKit::Hex.to_int(hex) # leading-hex, >= 0 (see dechunk_consume)
414
410
  if n == 0
415
411
  return ""
416
412
  end
@@ -443,34 +439,6 @@ module Tep
443
439
  state.acc
444
440
  end
445
441
 
446
- # Parse a (small) hex string to Integer; -1 on malformed.
447
- # Chunked sizes are at most 8 hex chars in practice (4 GB);
448
- # we cap at 16 for safety.
449
- def self.hex_to_int(s)
450
- if s.length == 0 || s.length > 16
451
- return -1
452
- end
453
- n = 0
454
- i = 0
455
- while i < s.length
456
- c = s[i]
457
- d = -1
458
- if c >= "0" && c <= "9"
459
- d = (c.ord - 48)
460
- elsif c >= "a" && c <= "f"
461
- d = (c.ord - 87)
462
- elsif c >= "A" && c <= "F"
463
- d = (c.ord - 55)
464
- end
465
- if d < 0
466
- return -1
467
- end
468
- n = n * 16 + d
469
- i += 1
470
- end
471
- n
472
- end
473
-
474
442
  # Per-stream state carried across consume_sse_events / read
475
443
  # loop iterations. See chat_stream + read_sse_response for use.
476
444
  class StreamState
data/lib/tep/mcp.rb CHANGED
@@ -86,14 +86,14 @@ module Tep
86
86
  # handing the arguments sub-object to the per-tool cmeth.
87
87
  #
88
88
  # Returns "{}" when the key isn't present (so downstream
89
- # Tep::Json.get_str / get_int calls see an empty object that
89
+ # SpinelKit::Json.get_str / get_int calls see an empty object that
90
90
  # returns their zero-default cleanly).
91
91
  def self.nested_extract(json, key)
92
- pos = Tep::Json.find_value_start(json, key)
92
+ pos = SpinelKit::Json.find_value_start(json, key)
93
93
  if pos < 0
94
94
  return "{}"
95
95
  end
96
- end_pos = Tep::Json.skip_value(json, pos)
96
+ end_pos = SpinelKit::Json.skip_value(json, pos)
97
97
  if end_pos <= pos
98
98
  return "{}"
99
99
  end
@@ -109,8 +109,8 @@ module Tep
109
109
  "\"protocolVersion\":\"" + Tep::MCP::PROTOCOL_VERSION + "\"," +
110
110
  "\"capabilities\":{\"tools\":{},\"resources\":{}}," +
111
111
  "\"serverInfo\":{" +
112
- "\"name\":" + Tep::Json.quote(server_name) + "," +
113
- "\"version\":" + Tep::Json.quote(server_version) +
112
+ "\"name\":" + SpinelKit::Json.quote(server_name) + "," +
113
+ "\"version\":" + SpinelKit::Json.quote(server_version) +
114
114
  "}" +
115
115
  "}" +
116
116
  "}"
@@ -138,7 +138,7 @@ module Tep
138
138
  "{\"jsonrpc\":\"2.0\",\"id\":" + req_id.to_s + "," +
139
139
  "\"result\":{" +
140
140
  "\"content\":[" +
141
- "{\"type\":\"text\",\"text\":" + Tep::Json.quote(text) + "}" +
141
+ "{\"type\":\"text\",\"text\":" + SpinelKit::Json.quote(text) + "}" +
142
142
  "]," +
143
143
  "\"isError\":" + is_err_str +
144
144
  "}" +
@@ -163,9 +163,9 @@ module Tep
163
163
  def self.resources_read_envelope(req_id, uri, mime, text)
164
164
  "{\"jsonrpc\":\"2.0\",\"id\":" + req_id.to_s + "," +
165
165
  "\"result\":{\"contents\":[" +
166
- "{\"uri\":" + Tep::Json.quote(uri) + "," +
167
- "\"mimeType\":" + Tep::Json.quote(mime) + "," +
168
- "\"text\":" + Tep::Json.quote(text) + "}" +
166
+ "{\"uri\":" + SpinelKit::Json.quote(uri) + "," +
167
+ "\"mimeType\":" + SpinelKit::Json.quote(mime) + "," +
168
+ "\"text\":" + SpinelKit::Json.quote(text) + "}" +
169
169
  "]}" +
170
170
  "}"
171
171
  end
@@ -175,7 +175,7 @@ module Tep
175
175
  def self.unknown_resource_envelope(req_id, uri)
176
176
  "{\"jsonrpc\":\"2.0\",\"id\":" + req_id.to_s + "," +
177
177
  "\"error\":{\"code\":-32602," +
178
- "\"message\":" + Tep::Json.quote("unknown resource: " + uri) +
178
+ "\"message\":" + SpinelKit::Json.quote("unknown resource: " + uri) +
179
179
  "}" +
180
180
  "}"
181
181
  end
@@ -185,7 +185,7 @@ module Tep
185
185
  def self.unknown_tool_envelope(req_id, tool_name)
186
186
  "{\"jsonrpc\":\"2.0\",\"id\":" + req_id.to_s + "," +
187
187
  "\"error\":{\"code\":-32602," +
188
- "\"message\":" + Tep::Json.quote("unknown tool: " + tool_name) +
188
+ "\"message\":" + SpinelKit::Json.quote("unknown tool: " + tool_name) +
189
189
  "}" +
190
190
  "}"
191
191
  end
@@ -195,7 +195,7 @@ module Tep
195
195
  def self.method_not_found_envelope(req_id, method_name)
196
196
  "{\"jsonrpc\":\"2.0\",\"id\":" + req_id.to_s + "," +
197
197
  "\"error\":{\"code\":-32601," +
198
- "\"message\":" + Tep::Json.quote("method not found: " + method_name) +
198
+ "\"message\":" + SpinelKit::Json.quote("method not found: " + method_name) +
199
199
  "}" +
200
200
  "}"
201
201
  end
data/lib/tep/multipart.rb CHANGED
@@ -9,7 +9,7 @@
9
9
  # different surface (likely `req.files`) plus an NUL-safe byte
10
10
  # array, both follow-ups.
11
11
  #
12
- # Public API mirrors Url.parse_query: pass the raw body + the
12
+ # Public API mirrors SpinelKit::Url.parse_query: pass the raw body + the
13
13
  # request's Content-Type header value; get back a string-keyed
14
14
  # string-valued hash, ready to merge into `req.params`.
15
15
  module Tep