tep 0.11.3 → 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.
- checksums.yaml +4 -4
- data/Makefile +31 -1
- data/README.md +4 -4
- data/SINATRA_COMPAT.md +20 -20
- data/bin/tep +8 -8
- data/examples/api_gateway/app.rb +1 -1
- data/examples/blog/app.rb +17 -17
- data/examples/chat/app.rb +12 -12
- data/examples/chatbot/README.md +2 -2
- data/examples/chatbot/app.rb +24 -24
- data/examples/llm_gateway/app.rb +4 -4
- data/lib/spinel_kit/hex.rb +65 -0
- data/lib/spinel_kit/json.rb +151 -0
- data/lib/spinel_kit/json_decoder.rb +396 -0
- data/lib/{tep/logger.rb → spinel_kit/log.rb} +25 -21
- data/lib/spinel_kit/url.rb +166 -0
- data/lib/tep/auth_bearer_token.rb +6 -6
- data/lib/tep/auth_oauth2.rb +4 -4
- data/lib/tep/events.rb +37 -37
- data/lib/tep/http.rb +3 -3
- data/lib/tep/job.rb +2 -2
- data/lib/tep/jwt.rb +4 -4
- data/lib/tep/live_view.rb +4 -4
- data/lib/tep/llm.rb +13 -45
- data/lib/tep/mcp.rb +12 -12
- data/lib/tep/multipart.rb +1 -1
- data/lib/tep/openai_server.rb +102 -94
- data/lib/tep/parser.rb +2 -2
- data/lib/tep/presence.rb +11 -11
- data/lib/tep/proxy.rb +7 -7
- data/lib/tep/request.rb +1 -1
- data/lib/tep/response.rb +1 -1
- data/lib/tep/router.rb +1 -1
- data/lib/tep/session.rb +2 -2
- data/lib/tep/version.rb +1 -1
- data/lib/tep.rb +30 -29
- data/test/helper.rb +95 -8
- data/test/run_parallel.rb +44 -7
- data/test/test_auth.rb +17 -17
- data/test/test_auth_oauth2.rb +5 -5
- data/test/test_http_pool.rb +4 -4
- data/test/test_http_pool_send.rb +3 -3
- data/test/test_json.rb +12 -12
- data/test/test_jwt.rb +4 -4
- data/test/test_live_view.rb +3 -3
- data/test/test_llm.rb +12 -9
- data/test/test_llm_gateway.rb +2 -2
- data/test/test_logger.rb +2 -2
- data/test/test_openai_server.rb +10 -1
- data/test/test_password.rb +3 -3
- data/test/test_real_world.rb +6 -1
- data/test/test_shutdown.rb +40 -0
- metadata +9 -8
- data/lib/tep/json.rb +0 -572
- 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
|
-
#
|
|
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: { ... }`):
|
|
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 =
|
|
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 =
|
|
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 =
|
|
77
|
+
caps_str = SpinelKit::Json.get_str(payload, "caps")
|
|
78
78
|
caps = Tep::AuthBearerToken.parse_caps(caps_str)
|
|
79
79
|
|
|
80
|
-
delegate_str =
|
|
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)
|
data/lib/tep/auth_oauth2.rb
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
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
|
-
#
|
|
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 =
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
-
#
|
|
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 = "{" +
|
|
17
|
-
#
|
|
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 =
|
|
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 `
|
|
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
|
|
136
|
-
#
|
|
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 =
|
|
180
|
-
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 =
|
|
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.
|
|
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].
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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 =
|
|
92
|
+
pos = SpinelKit::Json.find_value_start(json, key)
|
|
93
93
|
if pos < 0
|
|
94
94
|
return "{}"
|
|
95
95
|
end
|
|
96
|
-
end_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\":" +
|
|
113
|
-
"\"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\":" +
|
|
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\":" +
|
|
167
|
-
"\"mimeType\":" +
|
|
168
|
-
"\"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\":" +
|
|
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\":" +
|
|
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\":" +
|
|
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
|