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.
- 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/README.md +6 -5
- 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 +134 -93
- 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 +72 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 440ba4bc444318eb021fa61653935297e7fee6876f7ce09e06bd45e091f5a14a
|
|
4
|
+
data.tar.gz: 3babc819f652798441f6f9ecc0f088afb398dae0e977186e9bb3c3e9fc455562
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 514652e89448eef94073b1680228ccbc876f53df63712f580f1c3c8e8b50b0852d0630f456ca26df93844456d7b124ee1681c794e997d41dffd8299e8cb3058d
|
|
7
|
+
data.tar.gz: be8785d792ad05e0dcde98a04aba8426b967230a42797e47d1536ad67057a7f78bf03f23ded9194c0abd10d3e33c8d0b6023964e7735020d2ca002fb0afc90d1
|
data/Makefile
CHANGED
|
@@ -34,7 +34,15 @@ TEP_PG_LIBS ?= $(shell \
|
|
|
34
34
|
(pg_config --libdir 2>/dev/null | sed -e 's|^|-L|' ; echo "-lpq") | tr '\n' ' ')
|
|
35
35
|
export TEP_PG_CFLAGS TEP_PG_LIBS
|
|
36
36
|
|
|
37
|
-
.PHONY: all clean helper hello sinatra_style bench bench-tep bench-sinatra demo test test-parallel spinel-fresh test-pg vendor-examples
|
|
37
|
+
.PHONY: all clean helper hello sinatra_style bench bench-tep bench-sinatra demo test test-parallel spinel-fresh test-pg vendor-examples vendor-spinelkit doctor
|
|
38
|
+
|
|
39
|
+
# Re-sync the vendored SpinelKit lib (lib/spinel_kit/, sig/spinel_kit/) from the
|
|
40
|
+
# upstream checkout. spinel_kit is a published gem; tep depends on its JSON codec
|
|
41
|
+
# + logger. INTERIM vendoring (committed under lib/ so it travels with tep) until
|
|
42
|
+
# spinel-compat gains transitive gem->gem vendoring -- OriPekelman/spinelgems#19.
|
|
43
|
+
# Override the source with SPINELKIT_DIR. See tools/vendor-spinelkit.sh.
|
|
44
|
+
vendor-spinelkit:
|
|
45
|
+
@$(TEP_ROOT)/tools/vendor-spinelkit.sh
|
|
38
46
|
|
|
39
47
|
# Vendor each gem example's Gemfile-declared dependencies via
|
|
40
48
|
# bundler-spinel (spinel-compat vendor, from $(SPINELGEMS)) into
|
|
@@ -96,6 +104,28 @@ test: helper
|
|
|
96
104
|
@pkill -f tep-test 2>/dev/null; true
|
|
97
105
|
ruby test/run_all.rb
|
|
98
106
|
|
|
107
|
+
# `make doctor` -- OPTIONAL, DEV-ONLY inference health check via
|
|
108
|
+
# spinel-dev's `doctor` (compile-probe + emit-rbs untyped scan +
|
|
109
|
+
# value-bisect). Runs on the inlined .tep.rb of a couple of examples
|
|
110
|
+
# (tep apps are FFI, so --no-cruby single-sided). NOT a prerequisite of
|
|
111
|
+
# `all`/`test`/CI -- it self-skips when ../spinel-dev is absent, so tep
|
|
112
|
+
# never gains a hard dependency on the tooling. Run `make all` first to
|
|
113
|
+
# produce the .tep.rb. Override the tool path with DOCTOR=.
|
|
114
|
+
DOCTOR ?= ../spinel-dev/tools/doctor/doctor.sh
|
|
115
|
+
doctor:
|
|
116
|
+
@if [ ! -x "$(DOCTOR)" ]; then \
|
|
117
|
+
echo "make doctor: spinel-dev not found at $(DOCTOR) (optional dev tooling); skipping."; \
|
|
118
|
+
else \
|
|
119
|
+
for tep in examples/.hello.tep.rb examples/.sinatra_style.tep.rb; do \
|
|
120
|
+
if [ -f "$$tep" ]; then \
|
|
121
|
+
echo "== doctor $$tep =="; \
|
|
122
|
+
SPINEL_DIR="$${SPINEL_DIR:-$$HOME/sites/spinel}" "$(DOCTOR)" --no-cruby "$$tep" || true; \
|
|
123
|
+
else \
|
|
124
|
+
echo "make doctor: $$tep not built (run \`make all\`); skipping it."; \
|
|
125
|
+
fi; \
|
|
126
|
+
done; \
|
|
127
|
+
fi
|
|
128
|
+
|
|
99
129
|
# `make test-parallel` -- dev fast loop. Runs each test/test_*.rb in
|
|
100
130
|
# its own process in parallel (sidestepping the harness's "one thread
|
|
101
131
|
# per class" constraint), with per-process port-base allocation. Cuts
|
data/README.md
CHANGED
|
@@ -158,8 +158,8 @@ through Spinel.
|
|
|
158
158
|
| Battery | What it covers |
|
|
159
159
|
|------------------|---|
|
|
160
160
|
| `Tep::SQLite` | libsqlite3 wrapper via a small C shim — exec / prepare / bind / step / col / first_str / first_int. |
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
161
|
+
| `SpinelKit::Json` | encode primitives + flat-key decoder for JSON-over-HTTP. |
|
|
162
|
+
| `SpinelKit::Log` | levelled logger (debug/info/warn/error), stderr or file. |
|
|
163
163
|
| `Tep::Jwt` | HS256 JWT encode / verify / decode. |
|
|
164
164
|
| `Tep::Password` | PBKDF2-SHA256, 200k iters, self-describing storage. |
|
|
165
165
|
| `Tep::Security` | `Cors` (before-filter) + `Headers` (HSTS, nosniff, ...). |
|
|
@@ -216,8 +216,8 @@ lives in [`docs/BATTERIES-DESIGN.md`](docs/BATTERIES-DESIGN.md).
|
|
|
216
216
|
exercising the full pre-agentic battery surface
|
|
217
217
|
(`Tep::Server::Scheduled` + `Tep::Llm` + `Tep::SQLite` +
|
|
218
218
|
`Tep::Streamer` + `Tep::Session` + `Tep::Password` + `Tep::Jwt` +
|
|
219
|
-
`Tep::Security::{Cors,Headers}` + `Tep::Assets` + `
|
|
220
|
-
`Tep::Job` + `
|
|
219
|
+
`Tep::Security::{Cors,Headers}` + `Tep::Assets` + `SpinelKit::Json` +
|
|
220
|
+
`Tep::Job` + `SpinelKit::Log`) in ~1500 lines of Ruby + HTML + CSS + JS.
|
|
221
221
|
- **[`examples/llm_gateway/`](examples/llm_gateway/app.rb)** — the
|
|
222
222
|
`Tep::Proxy` streaming demo. Block-form DSL gateway in front of an
|
|
223
223
|
OpenAI-compatible upstream; pumps SSE token deltas straight through
|
data/SINATRA_COMPAT.md
CHANGED
|
@@ -10,8 +10,8 @@ streaming, regex routes, modular `Sinatra::Base`, ERB. v0.3 added
|
|
|
10
10
|
`send_file 'path'`, `configure { ... }` (incl. `:env`), `__END__`
|
|
11
11
|
inline templates, `pass`, multiple chained `before`/`after`,
|
|
12
12
|
optional path segments, full Rack::Request method surface, ERB
|
|
13
|
-
ivar locals, a Mustache subset, Tep::SQLite,
|
|
14
|
-
|
|
13
|
+
ivar locals, a Mustache subset, Tep::SQLite, SpinelKit::Json,
|
|
14
|
+
SpinelKit::Log, Tep::Jwt, Tep::Password, Tep::Security
|
|
15
15
|
(CORS + secure headers), Tep::Assets (compile-time asset
|
|
16
16
|
bundling), Tep::Scheduler (cooperative fiber scheduler with
|
|
17
17
|
poll(2)-backed `io_wait`), Tep::Shell (popen + small-file
|
|
@@ -62,8 +62,8 @@ shape, lowered by the `websocket '/p' do |ws| ... end` DSL hook).
|
|
|
62
62
|
| **ERB ivar locals (`@name`)** | ✅ 3 | Sinatra-style: `@x = v` in handler / `before` filter, `<%= @x %>` in template. Translator stores on a per-request `req.ivars` String=>String bag; templates take `(locals, ivars)`. Values are `(...).to_s`-coerced on write. |
|
|
63
63
|
| **Mustache (subset)** | ✅ 3 | Build-time compiled; `mustache :name` DSL parallel to `erb :name`. See "Mustache subset" below. |
|
|
64
64
|
| **SQLite (libsqlite3 wrapper)** | ✅ 5 | `Tep::SQLite` class wrapping libsqlite3 via a thin C shim (tep_sqlite.c). Same FFI pattern as sphttp.c -- spinel can't load gem-style native extensions, so we link a static .o instead. See "SQLite" below. |
|
|
65
|
-
| **JSON (subset)** | ✅ 13 | Pure-Ruby `
|
|
66
|
-
| **Logger** | ✅ 3 | `
|
|
65
|
+
| **JSON (subset)** | ✅ 13 | Pure-Ruby `SpinelKit::Json`: encode primitives + flat-key decoder. See "JSON subset" below. |
|
|
66
|
+
| **Logger** | ✅ 3 | `SpinelKit::Log` with debug/info/warn/error levels. stderr by default; `to_file(path)` appends. Format: `[<unix_seconds>] [<level>] <msg>`. |
|
|
67
67
|
| **JWT (HS256)** | ✅ 10 | `Tep::Jwt` -- encode/verify/decode. HS256 only (asymmetric algs would need OpenSSL); `none` deliberately not supported (RFC 8725 §3.1). Tokens verify cleanly against the canonical `jwt` Ruby gem (interop test included). New base64url helpers (`sphttp_b64url_encode/decode`, `sphttp_hmac_sha256_b64url`) ride on top of the existing HMAC-SHA256 used by the session store. |
|
|
68
68
|
| **Password hashing (PBKDF2)** | ✅ 9 | `Tep::Password.hash` / `verify`. PBKDF2-SHA256, 200k iters by default, 16-byte CSPRNG salt. Self-describing storage format (`pbkdf2-sha256$<iters>$<salt>$<derived>`) so iter rotation can land later without breaking old hashes. New `sphttp_pbkdf2_sha256_b64url` + `sphttp_random_b64url` C helpers. (`Klass.hash(plain)` factory shape resolved via spinel #407.) |
|
|
69
69
|
| **CORS + secure headers** | ✅ 4 | `Tep::Security::Cors` (before-filter; configurable origin / verbs / headers / max-age; OPTIONS preflight short-circuits with 204) and `Tep::Security::Headers` (after-filter; `nosniff`, `SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-XSS-Protection: 0`, optional HSTS via `set_hsts(seconds)`). |
|
|
@@ -119,9 +119,9 @@ Two flagship examples that put the framework through its paces.
|
|
|
119
119
|
### `examples/blog/`
|
|
120
120
|
|
|
121
121
|
Posts + users persisted in SQLite, web login via sessions +
|
|
122
|
-
`Tep::Password`, JSON API with `
|
|
122
|
+
`Tep::Password`, JSON API with `SpinelKit::Json`, JWT-authed writes via
|
|
123
123
|
`Tep::Jwt`, ERB views with Sinatra-style `@ivar` locals, request
|
|
124
|
-
logging via `
|
|
124
|
+
logging via `SpinelKit::Log`, CORS + secure headers via
|
|
125
125
|
`Tep::Security`. First boot seeds `alice / hunter2` and an intro
|
|
126
126
|
post explaining what tep is.
|
|
127
127
|
|
|
@@ -308,7 +308,7 @@ Constraints:
|
|
|
308
308
|
|
|
309
309
|
## JSON subset
|
|
310
310
|
|
|
311
|
-
`
|
|
311
|
+
`SpinelKit::Json` is a pure-Ruby JSON shim covering the encode + decode
|
|
312
312
|
shapes that JSON-over-HTTP APIs use in practice. It deliberately
|
|
313
313
|
trades full library breadth for spinel-friendly code paths.
|
|
314
314
|
|
|
@@ -316,24 +316,24 @@ trades full library breadth for spinel-friendly code paths.
|
|
|
316
316
|
|
|
317
317
|
```ruby
|
|
318
318
|
# Primitives.
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
SpinelKit::Json.escape(s) # body of a JSON string literal (no quotes)
|
|
320
|
+
SpinelKit::Json.quote(s) # "<escaped s>"
|
|
321
321
|
|
|
322
322
|
# Object building blocks (fixed-arity; compose by concatenation).
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
SpinelKit::Json.encode_pair_str("k", v_string) # "k":"v"
|
|
324
|
+
SpinelKit::Json.encode_pair_int("k", v_int) # "k":N
|
|
325
325
|
|
|
326
326
|
# Build a full object literal:
|
|
327
|
-
"{" +
|
|
328
|
-
|
|
327
|
+
"{" + SpinelKit::Json.encode_pair_str("name", name) + "," +
|
|
328
|
+
SpinelKit::Json.encode_pair_int("age", age) + "}"
|
|
329
329
|
|
|
330
330
|
# Arrays.
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
SpinelKit::Json.from_str_array(["a", "b"]) # ["a","b"]
|
|
332
|
+
SpinelKit::Json.from_int_array([1, 2, 3]) # [1,2,3]
|
|
333
333
|
|
|
334
334
|
# Hashes (String=>String, String=>Int).
|
|
335
|
-
|
|
336
|
-
|
|
335
|
+
SpinelKit::Json.from_str_hash({"name" => "alice"}) # {"name":"alice"}
|
|
336
|
+
SpinelKit::Json.from_int_hash({"age" => 30}) # {"age":30}
|
|
337
337
|
```
|
|
338
338
|
|
|
339
339
|
The hash forms `each`-iterate and inline `Json.quote` on the
|
|
@@ -345,9 +345,9 @@ a Hash first.
|
|
|
345
345
|
### Decode (flat-key, top-level only)
|
|
346
346
|
|
|
347
347
|
```ruby
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
348
|
+
SpinelKit::Json.get_str(body, "name") # value of top-level "name", or "" if absent / non-string
|
|
349
|
+
SpinelKit::Json.get_int(body, "age") # 0 if absent / non-numeric
|
|
350
|
+
SpinelKit::Json.has_key?(body, "x") # boolean
|
|
351
351
|
```
|
|
352
352
|
|
|
353
353
|
The hand-rolled state-machine parser walks one `{ "k": <value>, ... }`
|
data/bin/tep
CHANGED
|
@@ -563,7 +563,7 @@ def translate(input_path)
|
|
|
563
563
|
# Tep::MCP emission. For each `mcp_tool 'name', "desc" do ... end`:
|
|
564
564
|
#
|
|
565
565
|
# * TepMCP_Tools.call_<i>(req, args_json) -- static cmeth that
|
|
566
|
-
# parses args out of args_json (via
|
|
566
|
+
# parses args out of args_json (via SpinelKit::Json.get_str/get_int),
|
|
567
567
|
# binds them as locals, and runs the user's on_call body. The
|
|
568
568
|
# body is rewritten the same way route bodies are (req / res /
|
|
569
569
|
# params rewrites). Returns Tep::MCP::Result.
|
|
@@ -620,11 +620,11 @@ def translate(input_path)
|
|
|
620
620
|
end
|
|
621
621
|
t[:params].each do |p|
|
|
622
622
|
if p[:type] == "Integer"
|
|
623
|
-
out << " #{p[:name]} =
|
|
623
|
+
out << " #{p[:name]} = SpinelKit::Json.get_int(args_json, #{p[:name].inspect})"
|
|
624
624
|
elsif p[:type] == "Float"
|
|
625
|
-
out << " #{p[:name]} =
|
|
625
|
+
out << " #{p[:name]} = SpinelKit::Json.get_str(args_json, #{p[:name].inspect}).to_f"
|
|
626
626
|
else
|
|
627
|
-
out << " #{p[:name]} =
|
|
627
|
+
out << " #{p[:name]} = SpinelKit::Json.get_str(args_json, #{p[:name].inspect})"
|
|
628
628
|
end
|
|
629
629
|
end
|
|
630
630
|
out << indent(body, 4)
|
|
@@ -707,8 +707,8 @@ def translate(input_path)
|
|
|
707
707
|
out << " def handle(req, res)"
|
|
708
708
|
out << " res.headers[\"Content-Type\"] = \"application/json\""
|
|
709
709
|
out << " body = req.raw_body"
|
|
710
|
-
out << " method =
|
|
711
|
-
out << " req_id =
|
|
710
|
+
out << " method = SpinelKit::Json.get_str(body, \"method\")"
|
|
711
|
+
out << " req_id = SpinelKit::Json.get_int(body, \"id\")"
|
|
712
712
|
out << " if method == \"initialize\""
|
|
713
713
|
out << " return Tep::MCP.initialize_envelope(req_id, #{server_name.inspect}, #{server_version.inspect})"
|
|
714
714
|
out << " end"
|
|
@@ -724,7 +724,7 @@ def translate(input_path)
|
|
|
724
724
|
out << " end"
|
|
725
725
|
out << " if method == \"tools/call\""
|
|
726
726
|
out << " params = Tep::MCP.nested_extract(body, \"params\")"
|
|
727
|
-
out << " tool_name =
|
|
727
|
+
out << " tool_name = SpinelKit::Json.get_str(params, \"name\")"
|
|
728
728
|
out << " args = Tep::MCP.nested_extract(params, \"arguments\")"
|
|
729
729
|
mcp_tools.each_with_index do |t, i|
|
|
730
730
|
out << " if tool_name == #{t[:name].inspect}"
|
|
@@ -740,7 +740,7 @@ def translate(input_path)
|
|
|
740
740
|
out << " end"
|
|
741
741
|
out << " if method == \"resources/read\""
|
|
742
742
|
out << " res_params = Tep::MCP.nested_extract(body, \"params\")"
|
|
743
|
-
out << " res_uri =
|
|
743
|
+
out << " res_uri = SpinelKit::Json.get_str(res_params, \"uri\")"
|
|
744
744
|
mcp_resources.each_with_index do |rdef, i|
|
|
745
745
|
out << " if res_uri == #{rdef[:name].inspect}"
|
|
746
746
|
out << " c = TepMCP_Resources.read_#{i}(req)"
|
data/examples/api_gateway/app.rb
CHANGED
|
@@ -22,7 +22,7 @@ require 'sinatra'
|
|
|
22
22
|
UPSTREAM = ENV["UPSTREAM"] || "http://127.0.0.1:8080"
|
|
23
23
|
UPSTREAM_KEY = ENV["UPSTREAM_KEY"] || ""
|
|
24
24
|
GATEWAY_KEY = ENV["GATEWAY_KEY"] || "let-me-in"
|
|
25
|
-
LOGGER =
|
|
25
|
+
LOGGER = SpinelKit::Log.new # stderr; .to_file(path) to redirect
|
|
26
26
|
|
|
27
27
|
# Stand-in for the Auth battery: grant :call_upstream to callers
|
|
28
28
|
# presenting the gateway key. A real app installs Tep::Auth (bearer
|
data/examples/blog/app.rb
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
# - Tep::Password PBKDF2 password hashing
|
|
6
6
|
# - Tep::Jwt JSON API token issue / verify
|
|
7
7
|
# - Sessions web-side login (signed cookie)
|
|
8
|
-
# -
|
|
9
|
-
# -
|
|
8
|
+
# - SpinelKit::Json JSON encode + flat-key decode
|
|
9
|
+
# - SpinelKit::Log request log + auth events
|
|
10
10
|
# - Tep::Security CORS + secure-headers
|
|
11
11
|
# - ERB + @ivar locals public-facing views
|
|
12
12
|
#
|
|
@@ -36,7 +36,7 @@ SEED_PASSWORD = "hunter2"
|
|
|
36
36
|
# at build time for convenience.
|
|
37
37
|
Tep.session_secret = SESSION_SEED
|
|
38
38
|
|
|
39
|
-
LOGGER =
|
|
39
|
+
LOGGER = SpinelKit::Log.new
|
|
40
40
|
LOGGER.set_level("info")
|
|
41
41
|
|
|
42
42
|
CORS = Tep::Security::Cors.new
|
|
@@ -270,9 +270,9 @@ get '/api/posts' do
|
|
|
270
270
|
end
|
|
271
271
|
first = false
|
|
272
272
|
out = out + "{" +
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
SpinelKit::Json.encode_pair_int("id", db.col_int(0)) + "," +
|
|
274
|
+
SpinelKit::Json.encode_pair_str("title", db.col_str(1)) + "," +
|
|
275
|
+
SpinelKit::Json.encode_pair_str("author", db.col_str(2)) + "}"
|
|
276
276
|
end
|
|
277
277
|
db.finalize
|
|
278
278
|
db.close
|
|
@@ -293,16 +293,16 @@ get '/api/posts/:id' do
|
|
|
293
293
|
return "{}"
|
|
294
294
|
end
|
|
295
295
|
"{" +
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
SpinelKit::Json.encode_pair_str("title", title) + "," +
|
|
297
|
+
SpinelKit::Json.encode_pair_str("body", body) + "," +
|
|
298
|
+
SpinelKit::Json.encode_pair_str("author", author) + "}"
|
|
299
299
|
end
|
|
300
300
|
|
|
301
301
|
# Issue a JWT for API access. Same credentials as web login.
|
|
302
302
|
post '/api/token' do
|
|
303
303
|
res.headers["Content-Type"] = "application/json"
|
|
304
|
-
user =
|
|
305
|
-
pwd =
|
|
304
|
+
user = SpinelKit::Json.get_str(req.raw_body, "user")
|
|
305
|
+
pwd = SpinelKit::Json.get_str(req.raw_body, "password")
|
|
306
306
|
|
|
307
307
|
db = Tep::SQLite.new
|
|
308
308
|
db.open(DB_PATH)
|
|
@@ -322,8 +322,8 @@ post '/api/token' do
|
|
|
322
322
|
end
|
|
323
323
|
|
|
324
324
|
payload = "{" +
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
SpinelKit::Json.encode_pair_str("sub", user) + "," +
|
|
326
|
+
SpinelKit::Json.encode_pair_int("exp", Time.now.to_i + 3600) + "}"
|
|
327
327
|
token = Tep::Jwt.encode_hs256(payload, JWT_SECRET)
|
|
328
328
|
LOGGER.info("api token issued: " + user)
|
|
329
329
|
"{\"token\":\"" + token + "\"}"
|
|
@@ -344,10 +344,10 @@ post '/api/posts' do
|
|
|
344
344
|
res.set_status(401)
|
|
345
345
|
return "{\"error\":\"unauthorized\"}"
|
|
346
346
|
end
|
|
347
|
-
user =
|
|
347
|
+
user = SpinelKit::Json.get_str(payload, "sub")
|
|
348
348
|
|
|
349
|
-
title =
|
|
350
|
-
body =
|
|
349
|
+
title = SpinelKit::Json.get_str(req.raw_body, "title")
|
|
350
|
+
body = SpinelKit::Json.get_str(req.raw_body, "body")
|
|
351
351
|
|
|
352
352
|
db = Tep::SQLite.new
|
|
353
353
|
db.open(DB_PATH)
|
|
@@ -363,5 +363,5 @@ post '/api/posts' do
|
|
|
363
363
|
|
|
364
364
|
LOGGER.info("api post created id=" + id.to_s + " by " + user)
|
|
365
365
|
res.set_status(201)
|
|
366
|
-
"{" +
|
|
366
|
+
"{" + SpinelKit::Json.encode_pair_int("id", id) + "}"
|
|
367
367
|
end
|
data/examples/chat/app.rb
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
# - Presence heartbeat table refreshed via POST every
|
|
14
14
|
# few seconds; `who` query lists rows touched
|
|
15
15
|
# in the last 30 s
|
|
16
|
-
# -
|
|
16
|
+
# - SpinelKit::Json wire format for the SSE event payloads
|
|
17
17
|
# and the /who endpoint
|
|
18
|
-
# -
|
|
18
|
+
# - SpinelKit::Log per-connection trace
|
|
19
19
|
# - Tep::Security CORS + secure-headers
|
|
20
20
|
# - ERB + @ivar locals the chat UI page
|
|
21
21
|
#
|
|
@@ -54,7 +54,7 @@ STREAM_MAX = 30 # seconds; streamers self-close after this and
|
|
|
54
54
|
# the client reconnects (so we don't pile up
|
|
55
55
|
# connection-state forever in any one worker)
|
|
56
56
|
|
|
57
|
-
LOGGER =
|
|
57
|
+
LOGGER = SpinelKit::Log.new
|
|
58
58
|
LOGGER.set_level("info")
|
|
59
59
|
|
|
60
60
|
CORS = Tep::Security::Cors.new
|
|
@@ -138,7 +138,7 @@ post '/chat/send' do
|
|
|
138
138
|
db.close
|
|
139
139
|
|
|
140
140
|
LOGGER.info("send id=" + id.to_s + " by " + author + ": " + body)
|
|
141
|
-
"{" +
|
|
141
|
+
"{" + SpinelKit::Json.encode_pair_int("id", id) + "}"
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
post '/chat/heartbeat' do
|
|
@@ -175,8 +175,8 @@ get '/chat/who' do
|
|
|
175
175
|
end
|
|
176
176
|
first = false
|
|
177
177
|
out = out + "{" +
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
SpinelKit::Json.encode_pair_str("user", db.col_str(0)) + "," +
|
|
179
|
+
SpinelKit::Json.encode_pair_int("last_seen", db.col_int(1)) + "}"
|
|
180
180
|
end
|
|
181
181
|
db.finalize
|
|
182
182
|
db.close
|
|
@@ -200,9 +200,9 @@ get '/chat/recent' do
|
|
|
200
200
|
end
|
|
201
201
|
first = false
|
|
202
202
|
out = out + "{" +
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
SpinelKit::Json.encode_pair_int("id", db.col_int(0)) + "," +
|
|
204
|
+
SpinelKit::Json.encode_pair_str("author", db.col_str(1)) + "," +
|
|
205
|
+
SpinelKit::Json.encode_pair_str("body", db.col_str(2)) + "}"
|
|
206
206
|
end
|
|
207
207
|
db.finalize
|
|
208
208
|
db.close
|
|
@@ -245,9 +245,9 @@ class ChatStreamer < Tep::Streamer
|
|
|
245
245
|
author = db.col_str(1)
|
|
246
246
|
body = db.col_str(2)
|
|
247
247
|
line = "data: {" +
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
SpinelKit::Json.encode_pair_int("id", id) + "," +
|
|
249
|
+
SpinelKit::Json.encode_pair_str("author", author) + "," +
|
|
250
|
+
SpinelKit::Json.encode_pair_str("body", body) + "}\n\n"
|
|
251
251
|
out.write(line)
|
|
252
252
|
if id > last_id
|
|
253
253
|
last_id = id
|
data/examples/chatbot/README.md
CHANGED
|
@@ -66,7 +66,7 @@ closed):
|
|
|
66
66
|
| `Tep::Llm` | `Tep::Llm.new(BACKEND_URL).chat(history)` + `.chat_stream(history)` |
|
|
67
67
|
| `Tep::Http` | (under `Tep::Llm`) the actual HTTP transport |
|
|
68
68
|
| `Tep::SQLite` | conversations + messages + app_config tables |
|
|
69
|
-
| `
|
|
69
|
+
| `SpinelKit::Json` | response payloads, manual encoding for nested arrays |
|
|
70
70
|
| `Tep::Password` | first-boot setup + login verify |
|
|
71
71
|
| `Tep::Session` | signed cookie + `authed` flag |
|
|
72
72
|
| `Tep::Assets` | bundled CSS / JS / markdown renderer (served at `/style.css`, `/chat.js`, `/markdown.js` — Tep::Assets paths-relative-to-assets/) |
|
|
@@ -77,7 +77,7 @@ closed):
|
|
|
77
77
|
| `Tep::WebSocket` | live chat over WS (the default `/api/c/ws` route); `Driver#write` is a Streamer-shape alias so `Tep::Llm.chat_stream` drives the socket directly |
|
|
78
78
|
| `Tep::Job` | background conversation-title summarisation |
|
|
79
79
|
| `Tep::Parallel` | multi-backend compare endpoint (sequential dispatch today; the genuine fork fan-out is blocked on [matz/spinel#575](https://github.com/matz/spinel/issues/575)) |
|
|
80
|
-
| `
|
|
80
|
+
| `SpinelKit::Log` | per-request trace to stderr |
|
|
81
81
|
|
|
82
82
|
Phase F is done (closes [tep#11](https://github.com/OriPekelman/tep/issues/11)):
|
|
83
83
|
the JS client opens one WebSocket to `/api/c/ws` and sends
|
data/examples/chatbot/app.rb
CHANGED
|
@@ -59,7 +59,7 @@ HEADERS = Tep::Security::Headers.new
|
|
|
59
59
|
HEADERS.set_hsts(HSTS_SECONDS)
|
|
60
60
|
Tep.after HEADERS
|
|
61
61
|
|
|
62
|
-
LOGGER =
|
|
62
|
+
LOGGER = SpinelKit::Log.new
|
|
63
63
|
LOGGER.set_level("info")
|
|
64
64
|
LOGGER.to_stderr
|
|
65
65
|
|
|
@@ -233,7 +233,7 @@ def conversations_as_json
|
|
|
233
233
|
out = out + ","
|
|
234
234
|
end
|
|
235
235
|
out = out + "{\"id\":" + id.to_s +
|
|
236
|
-
",\"title\":" +
|
|
236
|
+
",\"title\":" + SpinelKit::Json.quote(title) +
|
|
237
237
|
",\"created_at\":" + created.to_s + "}"
|
|
238
238
|
first = false
|
|
239
239
|
end
|
|
@@ -297,7 +297,7 @@ def append_message(conv_id, role, content)
|
|
|
297
297
|
end
|
|
298
298
|
|
|
299
299
|
# Build a JSON envelope for the messages list. Hand-rolled because
|
|
300
|
-
#
|
|
300
|
+
# SpinelKit::Json's flat encoders don't cover nested arrays-of-hashes
|
|
301
301
|
# (same shape Tep::Llm uses internally).
|
|
302
302
|
def messages_as_json(conv_id)
|
|
303
303
|
db = db_open
|
|
@@ -311,8 +311,8 @@ def messages_as_json(conv_id)
|
|
|
311
311
|
if !first
|
|
312
312
|
out = out + ","
|
|
313
313
|
end
|
|
314
|
-
out = out + "{\"role\":" +
|
|
315
|
-
",\"content\":" +
|
|
314
|
+
out = out + "{\"role\":" + SpinelKit::Json.quote(role) +
|
|
315
|
+
",\"content\":" + SpinelKit::Json.quote(content) + "}"
|
|
316
316
|
first = false
|
|
317
317
|
end
|
|
318
318
|
db.finalize
|
|
@@ -415,7 +415,7 @@ class ChatbotFilter < Tep::Filter
|
|
|
415
415
|
def self.deny(res, why)
|
|
416
416
|
res.set_status(401)
|
|
417
417
|
res.headers["Content-Type"] = "application/json"
|
|
418
|
-
res.body = '{"error":"unauthorized","reason":' +
|
|
418
|
+
res.body = '{"error":"unauthorized","reason":' + SpinelKit::Json.quote(why) + '}'
|
|
419
419
|
res.halted = true
|
|
420
420
|
0
|
|
421
421
|
end
|
|
@@ -505,9 +505,9 @@ class JobWorker
|
|
|
505
505
|
if json.length == 0
|
|
506
506
|
return 0
|
|
507
507
|
end
|
|
508
|
-
job_id =
|
|
509
|
-
name =
|
|
510
|
-
arg =
|
|
508
|
+
job_id = SpinelKit::Json.get_int(json, "id")
|
|
509
|
+
name = SpinelKit::Json.get_str(json, "job_name")
|
|
510
|
+
arg = SpinelKit::Json.get_str(json, "arg")
|
|
511
511
|
if name == "TitleJob"
|
|
512
512
|
TitleJob.new.perform(arg)
|
|
513
513
|
Tep::Job.mark_done(DB_PATH, job_id, "")
|
|
@@ -585,7 +585,7 @@ post '/api/token' do
|
|
|
585
585
|
payload_json = '{"sub":"user","iat":' + Time.now.to_i.to_s + '}'
|
|
586
586
|
token = Tep::Jwt.encode_hs256(payload_json, JWT_SECRET)
|
|
587
587
|
res.headers["Content-Type"] = "application/json"
|
|
588
|
-
'{"token":' +
|
|
588
|
+
'{"token":' + SpinelKit::Json.quote(token) + '}'
|
|
589
589
|
end
|
|
590
590
|
|
|
591
591
|
# -------------------------------------------------------------------
|
|
@@ -613,7 +613,7 @@ end
|
|
|
613
613
|
# -------------------------------------------------------------------
|
|
614
614
|
|
|
615
615
|
# Parse the OpenAI request body into a Tep::Llm::Message array.
|
|
616
|
-
# Hand-rolled because
|
|
616
|
+
# Hand-rolled because SpinelKit::Json's flat decoder doesn't dive into
|
|
617
617
|
# the messages-array shape. Walks `"messages":[{"role":"...","content":"..."},...]`
|
|
618
618
|
# and pulls each role/content pair.
|
|
619
619
|
def parse_openai_messages(body)
|
|
@@ -660,10 +660,10 @@ end
|
|
|
660
660
|
def openai_envelope(model, content, stop_reason)
|
|
661
661
|
'{"id":"chatcmpl-tep","object":"chat.completion","created":' +
|
|
662
662
|
Time.now.to_i.to_s +
|
|
663
|
-
',"model":' +
|
|
663
|
+
',"model":' + SpinelKit::Json.quote(model) +
|
|
664
664
|
',"choices":[{"index":0,"message":{"role":"assistant","content":' +
|
|
665
|
-
|
|
666
|
-
'},"finish_reason":' +
|
|
665
|
+
SpinelKit::Json.quote(content) +
|
|
666
|
+
'},"finish_reason":' + SpinelKit::Json.quote(stop_reason) +
|
|
667
667
|
'}]}'
|
|
668
668
|
end
|
|
669
669
|
|
|
@@ -700,7 +700,7 @@ post '/api/v1/chat/completions' do
|
|
|
700
700
|
|
|
701
701
|
# Extract model + stream flag from the JSON body. Model
|
|
702
702
|
# falls back to the chatbot's configured default.
|
|
703
|
-
model =
|
|
703
|
+
model = SpinelKit::Json.get_str(body, "model")
|
|
704
704
|
if model.length == 0
|
|
705
705
|
model = MODEL
|
|
706
706
|
end
|
|
@@ -807,10 +807,10 @@ post '/api/compare' do
|
|
|
807
807
|
if i > 0
|
|
808
808
|
out = out + ","
|
|
809
809
|
end
|
|
810
|
-
out = out + "{\"backend\":" +
|
|
811
|
-
",\"model\":" +
|
|
810
|
+
out = out + "{\"backend\":" + SpinelKit::Json.quote(backend) +
|
|
811
|
+
",\"model\":" + SpinelKit::Json.quote(model) +
|
|
812
812
|
",\"took_s\":" + took.to_s +
|
|
813
|
-
",\"content\":" +
|
|
813
|
+
",\"content\":" + SpinelKit::Json.quote(content) + "}"
|
|
814
814
|
i += 1
|
|
815
815
|
end
|
|
816
816
|
out + "]}"
|
|
@@ -830,8 +830,8 @@ def compare_backends_as_json
|
|
|
830
830
|
if i > 0
|
|
831
831
|
out = out + ","
|
|
832
832
|
end
|
|
833
|
-
out = out + "{\"backend\":" +
|
|
834
|
-
",\"model\":" +
|
|
833
|
+
out = out + "{\"backend\":" + SpinelKit::Json.quote(backend) +
|
|
834
|
+
",\"model\":" + SpinelKit::Json.quote(model) + "}"
|
|
835
835
|
i += 1
|
|
836
836
|
end
|
|
837
837
|
out + "]"
|
|
@@ -961,8 +961,8 @@ end
|
|
|
961
961
|
# keeps sending message frames.
|
|
962
962
|
websocket "/api/c/ws" do |ws|
|
|
963
963
|
on_message do |evt|
|
|
964
|
-
conv_id =
|
|
965
|
-
content =
|
|
964
|
+
conv_id = SpinelKit::Json.get_int(evt.data, "conv_id")
|
|
965
|
+
content = SpinelKit::Json.get_str(evt.data, "content")
|
|
966
966
|
if conv_id > 0 && content.length > 0
|
|
967
967
|
append_message(conv_id, "user", content)
|
|
968
968
|
msgs = conversation_history(conv_id)
|
|
@@ -1019,6 +1019,6 @@ post '/api/send' do
|
|
|
1019
1019
|
end
|
|
1020
1020
|
|
|
1021
1021
|
res.headers["Content-Type"] = "application/json"
|
|
1022
|
-
'{"role":"assistant","content":' +
|
|
1023
|
-
',"stop_reason":' +
|
|
1022
|
+
'{"role":"assistant","content":' + SpinelKit::Json.quote(reply.content) +
|
|
1023
|
+
',"stop_reason":' + SpinelKit::Json.quote(reply.stop_reason) + '}'
|
|
1024
1024
|
end
|
|
@@ -40,9 +40,9 @@ curl -s localhost:4567/v1/chat/completions \
|
|
|
40
40
|
"messages":[{"role":"user","content":"hi"}]}'
|
|
41
41
|
|
|
42
42
|
tail -1 /tmp/gateway.events.jsonl
|
|
43
|
-
# {"kind":"
|
|
44
|
-
#
|
|
45
|
-
#
|
|
43
|
+
# {"kind":"eval","phase":"serve","t":3,"name":"request","extra":{
|
|
44
|
+
# "model":"gpt-4o-mini","prompt_tokens":0,"completion_tokens":42,
|
|
45
|
+
# "latency_us":3000000,"request_id":"...","principal_id":"anonymous"}}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
The events stream is the toy/v1 envelope, so a research-lab
|
|
@@ -58,8 +58,9 @@ the same way it ingests a training run.
|
|
|
58
58
|
0. A real gateway parses `delta.content` / the request `messages`.
|
|
59
59
|
The origin-server battery (`Tep::Llm::OpenAI::Server`) reports exact
|
|
60
60
|
counts from the backend.
|
|
61
|
-
- **`
|
|
62
|
-
|
|
61
|
+
- **`latency_us` is second-resolution** (the caller passes `wall_us`,
|
|
62
|
+
emitted on the wire as `latency_us`; `Time.now` exposes only integer
|
|
63
|
+
epoch seconds, and LLM requests are seconds-scale, so latency is still
|
|
63
64
|
meaningful). Sub-second timing would need a µs-clock primitive.
|
|
64
65
|
- **Auth/capabilities** flow through `req.identity` like any tep
|
|
65
66
|
route — gate the gateway with `req.identity.may?(:call_upstream)` in
|
data/examples/llm_gateway/app.rb
CHANGED
|
@@ -53,7 +53,7 @@ gw.before do |req, res, ureq|
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Stream when the client asked for it. OpenAI signals streaming with
|
|
56
|
-
# `"stream": true` in the JSON body;
|
|
56
|
+
# `"stream": true` in the JSON body; SpinelKit::Json has no bool getter, so
|
|
57
57
|
# we match the literal (with or without the space).
|
|
58
58
|
gw.stream_request? do |req|
|
|
59
59
|
b = req.raw_body
|
|
@@ -70,15 +70,15 @@ end
|
|
|
70
70
|
|
|
71
71
|
# One inference event at end-of-stream -- the right cardinality.
|
|
72
72
|
gw.on_stream_end do |req, out, stats|
|
|
73
|
-
model =
|
|
73
|
+
model = SpinelKit::Json.get_str(req.raw_body, "model")
|
|
74
74
|
t0 = req.ivars["t0"].to_i
|
|
75
75
|
wall = Time.now.to_i - t0
|
|
76
76
|
if wall < 0
|
|
77
77
|
wall = 0
|
|
78
78
|
end
|
|
79
79
|
extra = "{" +
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
SpinelKit::Json.encode_pair_str("request_id", req.req_headers["x-request-id"]) + "," +
|
|
81
|
+
SpinelKit::Json.encode_pair_str("principal_id", req.identity.principal_id) +
|
|
82
82
|
"}"
|
|
83
83
|
# prompt_tokens unknown at the proxy (no tokenizer); completion_tokens
|
|
84
84
|
# approximated by the SSE event count. wall_us is second-resolution
|