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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d69b5bf2070f8476c240f2d4ad1c0a14d111d1011737bd0339811bf7915c3318
4
- data.tar.gz: 2f7e495e12fdf876999e7c49e4b98f04ac92e2b9a275c260e065551e890d7dd5
3
+ metadata.gz: 440ba4bc444318eb021fa61653935297e7fee6876f7ce09e06bd45e091f5a14a
4
+ data.tar.gz: 3babc819f652798441f6f9ecc0f088afb398dae0e977186e9bb3c3e9fc455562
5
5
  SHA512:
6
- metadata.gz: 1501e534c029c1819f6b4a868d0392dcf12672a24a6bf35239d1c4aaacd75555e0b59406905af94e614452640026c2a65f182fdccbc5b931a9fa568b6567fe60
7
- data.tar.gz: 5b96806e47ae6c826133acf5e88b46976698dc076f869723198fca643327a35651f600e95f5c9bf999c9201d2788e2b24cef234313ae8cf089b4e67541eaa587
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
- | `Tep::Json` | encode primitives + flat-key decoder for JSON-over-HTTP. |
162
- | `Tep::Logger` | levelled logger (debug/info/warn/error), stderr or file. |
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` + `Tep::Json` +
220
- `Tep::Job` + `Tep::Logger`) in ~1500 lines of Ruby + HTML + CSS + JS.
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, Tep::Json,
14
- Tep::Logger, Tep::Jwt, Tep::Password, Tep::Security
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 `Tep::Json`: encode primitives + flat-key decoder. See "JSON subset" below. |
66
- | **Logger** | ✅ 3 | `Tep::Logger` with debug/info/warn/error levels. stderr by default; `to_file(path)` appends. Format: `[<unix_seconds>] [<level>] <msg>`. |
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 `Tep::Json`, JWT-authed writes via
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 `Tep::Logger`, CORS + secure headers 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
- `Tep::Json` is a pure-Ruby JSON shim covering the encode + decode
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
- Tep::Json.escape(s) # body of a JSON string literal (no quotes)
320
- Tep::Json.quote(s) # "<escaped s>"
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
- Tep::Json.encode_pair_str("k", v_string) # "k":"v"
324
- Tep::Json.encode_pair_int("k", v_int) # "k":N
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
- "{" + Tep::Json.encode_pair_str("name", name) + "," +
328
- Tep::Json.encode_pair_int("age", age) + "}"
327
+ "{" + SpinelKit::Json.encode_pair_str("name", name) + "," +
328
+ SpinelKit::Json.encode_pair_int("age", age) + "}"
329
329
 
330
330
  # Arrays.
331
- Tep::Json.from_str_array(["a", "b"]) # ["a","b"]
332
- Tep::Json.from_int_array([1, 2, 3]) # [1,2,3]
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
- Tep::Json.from_str_hash({"name" => "alice"}) # {"name":"alice"}
336
- Tep::Json.from_int_hash({"age" => 30}) # {"age":30}
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
- Tep::Json.get_str(body, "name") # value of top-level "name", or "" if absent / non-string
349
- Tep::Json.get_int(body, "age") # 0 if absent / non-numeric
350
- Tep::Json.has_key?(body, "x") # boolean
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 Tep::Json.get_str/get_int),
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]} = Tep::Json.get_int(args_json, #{p[:name].inspect})"
623
+ out << " #{p[:name]} = SpinelKit::Json.get_int(args_json, #{p[:name].inspect})"
624
624
  elsif p[:type] == "Float"
625
- out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect}).to_f"
625
+ out << " #{p[:name]} = SpinelKit::Json.get_str(args_json, #{p[:name].inspect}).to_f"
626
626
  else
627
- out << " #{p[:name]} = Tep::Json.get_str(args_json, #{p[:name].inspect})"
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 = Tep::Json.get_str(body, \"method\")"
711
- out << " req_id = Tep::Json.get_int(body, \"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 = Tep::Json.get_str(params, \"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 = Tep::Json.get_str(res_params, \"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)"
@@ -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 = Tep::Logger.new # stderr; .to_file(path) to redirect
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
- # - Tep::Json JSON encode + flat-key decode
9
- # - Tep::Logger request log + auth events
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 = Tep::Logger.new
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
- Tep::Json.encode_pair_int("id", db.col_int(0)) + "," +
274
- Tep::Json.encode_pair_str("title", db.col_str(1)) + "," +
275
- Tep::Json.encode_pair_str("author", db.col_str(2)) + "}"
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
- Tep::Json.encode_pair_str("title", title) + "," +
297
- Tep::Json.encode_pair_str("body", body) + "," +
298
- Tep::Json.encode_pair_str("author", author) + "}"
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 = Tep::Json.get_str(req.raw_body, "user")
305
- pwd = Tep::Json.get_str(req.raw_body, "password")
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
- Tep::Json.encode_pair_str("sub", user) + "," +
326
- Tep::Json.encode_pair_int("exp", Time.now.to_i + 3600) + "}"
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 = Tep::Json.get_str(payload, "sub")
347
+ user = SpinelKit::Json.get_str(payload, "sub")
348
348
 
349
- title = Tep::Json.get_str(req.raw_body, "title")
350
- body = Tep::Json.get_str(req.raw_body, "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
- "{" + Tep::Json.encode_pair_int("id", id) + "}"
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
- # - Tep::Json wire format for the SSE event payloads
16
+ # - SpinelKit::Json wire format for the SSE event payloads
17
17
  # and the /who endpoint
18
- # - Tep::Logger per-connection trace
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 = Tep::Logger.new
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
- "{" + Tep::Json.encode_pair_int("id", id) + "}"
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
- Tep::Json.encode_pair_str("user", db.col_str(0)) + "," +
179
- Tep::Json.encode_pair_int("last_seen", db.col_int(1)) + "}"
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
- Tep::Json.encode_pair_int("id", db.col_int(0)) + "," +
204
- Tep::Json.encode_pair_str("author", db.col_str(1)) + "," +
205
- Tep::Json.encode_pair_str("body", db.col_str(2)) + "}"
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
- Tep::Json.encode_pair_int("id", id) + "," +
249
- Tep::Json.encode_pair_str("author", author) + "," +
250
- Tep::Json.encode_pair_str("body", body) + "}\n\n"
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
@@ -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
- | `Tep::Json` | response payloads, manual encoding for nested arrays |
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
- | `Tep::Logger` | per-request trace to stderr |
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
@@ -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 = Tep::Logger.new
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\":" + Tep::Json.quote(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
- # Tep::Json's flat encoders don't cover nested arrays-of-hashes
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\":" + Tep::Json.quote(role) +
315
- ",\"content\":" + Tep::Json.quote(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":' + Tep::Json.quote(why) + '}'
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 = Tep::Json.get_int(json, "id")
509
- name = Tep::Json.get_str(json, "job_name")
510
- arg = Tep::Json.get_str(json, "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":' + Tep::Json.quote(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 Tep::Json's flat decoder doesn't dive into
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":' + Tep::Json.quote(model) +
663
+ ',"model":' + SpinelKit::Json.quote(model) +
664
664
  ',"choices":[{"index":0,"message":{"role":"assistant","content":' +
665
- Tep::Json.quote(content) +
666
- '},"finish_reason":' + Tep::Json.quote(stop_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 = Tep::Json.get_str(body, "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\":" + Tep::Json.quote(backend) +
811
- ",\"model\":" + Tep::Json.quote(model) +
810
+ out = out + "{\"backend\":" + SpinelKit::Json.quote(backend) +
811
+ ",\"model\":" + SpinelKit::Json.quote(model) +
812
812
  ",\"took_s\":" + took.to_s +
813
- ",\"content\":" + Tep::Json.quote(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\":" + Tep::Json.quote(backend) +
834
- ",\"model\":" + Tep::Json.quote(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 = Tep::Json.get_int(evt.data, "conv_id")
965
- content = Tep::Json.get_str(evt.data, "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":' + Tep::Json.quote(reply.content) +
1023
- ',"stop_reason":' + Tep::Json.quote(reply.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":"inference","phase":"serve","t":3,"model":"gpt-4o-mini",
44
- # "prompt_tokens":0,"completion_tokens":42,"wall_us":3000000,
45
- # "extra":{"request_id":"...","principal_id":"anonymous"}}
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
- - **`wall_us` is second-resolution** (`Time.now` exposes only integer
62
- epoch seconds; LLM requests are seconds-scale, so latency is still
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
@@ -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; Tep::Json has no bool getter, so
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 = Tep::Json.get_str(req.raw_body, "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
- Tep::Json.encode_pair_str("request_id", req.req_headers["x-request-id"]) + "," +
81
- Tep::Json.encode_pair_str("principal_id", req.identity.principal_id) +
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