tep 0.11.0

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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,225 @@
1
+ # experiments -- mock training-run manager driven by MCP tools.
2
+ #
3
+ # The full agentic-driver surface: Claude Code (or OpenCode /
4
+ # Gravity / any MCP client) discovers tools + resources via
5
+ # /mcp, runs experiments, polls metrics, cancels runs -- all via
6
+ # the natural agent-tool loop, no human-in-the-loop UI required.
7
+ #
8
+ # The runs are simulated (no actual ML). State lives in module-
9
+ # level arrays. A real version would persist to SQLite via the
10
+ # same exact tool/resource API.
11
+ #
12
+ # Run:
13
+ # bin/tep build examples/experiments/app.rb -o /tmp/experiments
14
+ # /tmp/experiments -p 4567
15
+ #
16
+ # Then point an MCP client at http://127.0.0.1:4567/mcp,
17
+ # or for non-MCP agents: http://127.0.0.1:4567/openapi.json +
18
+ # http://127.0.0.1:4567/llms.txt for discovery.
19
+ require 'sinatra'
20
+
21
+ # ---- mock experiment state ----
22
+
23
+ # Parallel arrays per experiment so spinel sees typed slots
24
+ # instead of an Array<Hash> with mixed value types.
25
+ EXP_IDS = [0]; EXP_IDS.delete_at(0)
26
+ EXP_NAMES = [""]; EXP_NAMES.delete_at(0)
27
+ EXP_LRS = [""]; EXP_LRS.delete_at(0)
28
+ EXP_EPOCHS = [0]; EXP_EPOCHS.delete_at(0)
29
+ EXP_CUR_EPOCH = [0]; EXP_CUR_EPOCH.delete_at(0)
30
+ EXP_STATUS = [""]; EXP_STATUS.delete_at(0) # "queued" | "running" | "done" | "cancelled"
31
+ EXP_LOSS = [""]; EXP_LOSS.delete_at(0) # serialized loss-per-epoch, comma-joined
32
+
33
+ NEXT_ID = [0]
34
+
35
+ # Allocate a new experiment id, append all defaults.
36
+ def enqueue_experiment(name, lr_str, epochs)
37
+ NEXT_ID[0] = NEXT_ID[0] + 1
38
+ id = NEXT_ID[0]
39
+ EXP_IDS.push(id)
40
+ EXP_NAMES.push(name)
41
+ EXP_LRS.push(lr_str)
42
+ EXP_EPOCHS.push(epochs)
43
+ EXP_CUR_EPOCH.push(0)
44
+ EXP_STATUS.push("queued")
45
+ EXP_LOSS.push("")
46
+ id
47
+ end
48
+
49
+ # Linear scan -- O(n), fine for the demo since n stays small.
50
+ # Returns the array index for id, or -1 if not found.
51
+ def find_exp_index(id)
52
+ i = 0
53
+ while i < EXP_IDS.length
54
+ if EXP_IDS[i] == id
55
+ return i
56
+ end
57
+ i = i + 1
58
+ end
59
+ -1
60
+ end
61
+
62
+ # Format a single experiment as `id=N name=foo status=running lr=1e-3 epoch=3/10 loss=0.42,0.31`.
63
+ def format_experiment(i)
64
+ "id=" + EXP_IDS[i].to_s +
65
+ " name=" + EXP_NAMES[i] +
66
+ " lr=" + EXP_LRS[i] +
67
+ " status=" + EXP_STATUS[i] +
68
+ " epoch=" + EXP_CUR_EPOCH[i].to_s + "/" + EXP_EPOCHS[i].to_s +
69
+ " loss=" + EXP_LOSS[i]
70
+ end
71
+
72
+ # Simulate one epoch of training -- bump cur_epoch + append a
73
+ # synthetic loss value. When epoch == epochs, flip status to done.
74
+ def step_experiment(i)
75
+ if EXP_STATUS[i] == "running"
76
+ EXP_CUR_EPOCH[i] = EXP_CUR_EPOCH[i] + 1
77
+ # Synthetic loss: starts around 1.0, decays ~10% per step.
78
+ base_x100 = 100 - (EXP_CUR_EPOCH[i] * 10)
79
+ if base_x100 < 1
80
+ base_x100 = 1
81
+ end
82
+ new_loss = "0." + base_x100.to_s
83
+ if EXP_LOSS[i].length > 0
84
+ EXP_LOSS[i] = EXP_LOSS[i] + "," + new_loss
85
+ else
86
+ EXP_LOSS[i] = new_loss
87
+ end
88
+ if EXP_CUR_EPOCH[i] >= EXP_EPOCHS[i]
89
+ EXP_STATUS[i] = "done"
90
+ end
91
+ end
92
+ 0
93
+ end
94
+
95
+ # ---- MCP tools ----
96
+
97
+ mcp_tool 'start_experiment', "Enqueue a new training run", caps: [:run_experiments] do
98
+ param :name, String, "experiment name (free-text label)"
99
+ param :learning_rate, String, "learning rate as string (e.g. '1e-3', '0.001')"
100
+ param :epochs, Integer, "number of training epochs"
101
+
102
+ on_call do |name:, learning_rate:, epochs:|
103
+ id = enqueue_experiment(name, learning_rate, epochs)
104
+ idx = find_exp_index(id)
105
+ # Auto-advance to running for the demo. A real runner would
106
+ # background this and update status as worker fibers progress.
107
+ EXP_STATUS[idx] = "running"
108
+ Tep::MCP.text("started experiment id=" + id.to_s + " (" + name + ")")
109
+ end
110
+ end
111
+
112
+ mcp_tool 'step_experiment', "Advance one experiment by one epoch" do
113
+ param :id, Integer, "experiment id to advance"
114
+
115
+ on_call do |id:|
116
+ idx = find_exp_index(id)
117
+ if idx < 0
118
+ Tep::MCP.error("no such experiment id=" + id.to_s)
119
+ else
120
+ step_experiment(idx)
121
+ Tep::MCP.text(format_experiment(idx))
122
+ end
123
+ end
124
+ end
125
+
126
+ mcp_tool 'list_experiments', "List all experiments + their current state" do
127
+ on_call do
128
+ if EXP_IDS.length == 0
129
+ Tep::MCP.text("no experiments yet")
130
+ else
131
+ out = ""
132
+ i = 0
133
+ while i < EXP_IDS.length
134
+ if i > 0
135
+ out = out + "\n"
136
+ end
137
+ out = out + format_experiment(i)
138
+ i = i + 1
139
+ end
140
+ Tep::MCP.text(out)
141
+ end
142
+ end
143
+ end
144
+
145
+ mcp_tool 'cancel_experiment', "Mark an experiment as cancelled", caps: [:run_experiments] do
146
+ param :id, Integer, "experiment id to cancel"
147
+
148
+ on_call do |id:|
149
+ idx = find_exp_index(id)
150
+ if idx < 0
151
+ Tep::MCP.error("no such experiment id=" + id.to_s)
152
+ else
153
+ EXP_STATUS[idx] = "cancelled"
154
+ Tep::MCP.text("cancelled id=" + id.to_s)
155
+ end
156
+ end
157
+ end
158
+
159
+ # ---- MCP resources ----
160
+
161
+ mcp_resource 'experiments/all', "Snapshot of every experiment" do
162
+ on_read do
163
+ body = ""
164
+ i = 0
165
+ while i < EXP_IDS.length
166
+ if i > 0
167
+ body = body + "\n"
168
+ end
169
+ body = body + format_experiment(i)
170
+ i = i + 1
171
+ end
172
+ Tep::MCP.resource_text("experiments/all", body)
173
+ end
174
+ end
175
+
176
+ mcp_resource 'experiments/active', "Currently-running experiments" do
177
+ on_read do
178
+ body = ""
179
+ i = 0
180
+ while i < EXP_IDS.length
181
+ if EXP_STATUS[i] == "running"
182
+ if body.length > 0
183
+ body = body + "\n"
184
+ end
185
+ body = body + format_experiment(i)
186
+ end
187
+ i = i + 1
188
+ end
189
+ if body.length == 0
190
+ body = "no active experiments"
191
+ end
192
+ Tep::MCP.resource_text("experiments/active", body)
193
+ end
194
+ end
195
+
196
+ # Plain HTTP landing for humans poking around.
197
+ get '/' do
198
+ res.headers["Content-Type"] = "text/plain; charset=utf-8"
199
+ "experiments demo (tep MCP battery)\n" +
200
+ "\n" +
201
+ "Catalog: /llms.txt\n" +
202
+ "OpenAPI: /openapi.json\n" +
203
+ "MCP: POST /mcp (JSON-RPC 2.0)\n" +
204
+ "\n" +
205
+ "Tools:\n" +
206
+ " POST /tools/start_experiment (requires X-Test-Cap-Run header below)\n" +
207
+ " POST /tools/step_experiment\n" +
208
+ " POST /tools/list_experiments\n" +
209
+ " POST /tools/cancel_experiment (capped)\n" +
210
+ "\n" +
211
+ "Resources:\n" +
212
+ " GET /resources/experiments/all\n" +
213
+ " GET /resources/experiments/active\n"
214
+ end
215
+
216
+ # Caps shim for the demo: in a real app, an MCP client would
217
+ # hand a Tep::AuthBearerToken JWT with the run_experiments cap
218
+ # baked in. Here, accept an X-Demo-Cap-Run header as the
219
+ # capability source so humans can drive the demo with curl.
220
+ before do
221
+ if req.req_headers["x-demo-cap-run"].length > 0
222
+ req.identity = Tep::Identity.new(
223
+ "user:demo", nil, [:run_experiments])
224
+ end
225
+ end
@@ -0,0 +1,11 @@
1
+ source "https://rubygems.org"
2
+ ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
3
+
4
+ # A real published gem, declared the normal way. spinel-compat vendor
5
+ # (bundler-spinel, ../spinelgems) resolves it from Gemfile.lock into
6
+ # vendor/spinel/. NOTE: pr_geohash's GeoHash.encode compiles under
7
+ # spinel; its neighbors/decode use Array#flatten / #transpose, which
8
+ # spinel doesn't support yet (matz/spinel#1078 / #1079) -- so this app
9
+ # only exposes encode. Gem reuse is per-method; the Gemfile + the
10
+ # spinelgems verdict are exactly how you find that out.
11
+ gem "pr_geohash", "1.0.0"
@@ -0,0 +1,17 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ pr_geohash (1.0.0)
5
+
6
+ PLATFORMS
7
+ aarch64-linux
8
+ ruby
9
+
10
+ DEPENDENCIES
11
+ pr_geohash (= 1.0.0)
12
+
13
+ RUBY VERSION
14
+ ruby 3.4.9p82
15
+
16
+ BUNDLED WITH
17
+ 2.6.9
@@ -0,0 +1,58 @@
1
+ # geohash — using a real published gem from a tep app (via a Gemfile)
2
+
3
+ This example demonstrates something tep couldn't do before: **compile and
4
+ run an app that depends on a published Ruby gem, declared in a `Gemfile`.**
5
+
6
+ The gem is [`pr_geohash` 1.0.0](https://rubygems.org/gems/pr_geohash) (MIT),
7
+ a pure-Ruby geohash encoder. It's declared in [`Gemfile`](Gemfile) and
8
+ resolved by [`bundler-spinel`](https://github.com/OriPekelman/spinelgems)
9
+ (`spinel-compat vendor`) — not hand-vendored.
10
+
11
+ ```ruby
12
+ require_relative 'vendor/spinel/deps' # generated from Gemfile.lock
13
+ # ...
14
+ get '/geohash' do
15
+ GeoHash.encode(params["lat"].to_f, params["lon"].to_f, 12)
16
+ end
17
+ ```
18
+
19
+ ## How it works (the spinelgems convention)
20
+
21
+ tep apps are AOT-compiled by spinel — no runtime gem loader, no gem load
22
+ path. `spinel-compat vendor` reads `Gemfile.lock`, places each gem under
23
+ `vendor/spinel/<name>/`, and writes `vendor/spinel/deps.rb` (a
24
+ `require_relative` per gem). The app pulls them in with one
25
+ `require_relative "vendor/spinel/deps"`, and `bin/tep build` inlines that
26
+ chain recursively into the binary. `Gemfile`/`Gemfile.lock` are committed;
27
+ `vendor/spinel/` is generated (gitignored).
28
+
29
+ ## Build + run
30
+
31
+ ```sh
32
+ make vendor-examples # spinel-compat vendor -> vendor/spinel/ (needs ../spinelgems)
33
+ bin/tep build examples/geohash/app.rb -o /tmp/geohash
34
+ /tmp/geohash -p 4979 &
35
+ curl 'http://127.0.0.1:4979/geohash?lat=48.8584&lon=2.2945&precision=8'
36
+ # => u09tunqu (identical to CRuby's GeoHash.encode)
37
+ ```
38
+
39
+ `test/test_geohash_example.rb` vendors, builds this app, and checks the
40
+ output matches CRuby byte-for-byte.
41
+
42
+ ## What works, what doesn't (gem reuse is per-method)
43
+
44
+ `GeoHash.encode` compiles and runs cleanly — it uses only `map`, `join`,
45
+ integer bit-ops and Float comparisons, all of which spinel supports.
46
+
47
+ `GeoHash.neighbors` and `GeoHash.decode` are deliberately **not** exposed:
48
+ they rely on `Array#flatten` (on a string-element array) / `Array#transpose`,
49
+ which spinel doesn't compile yet — tracked upstream as
50
+ [matz/spinel#1078](https://github.com/matz/spinel/issues/1078) (flatten is
51
+ specialized for int-element arrays only) and
52
+ [matz/spinel#1079](https://github.com/matz/spinel/issues/1079) (transpose
53
+ missing). That's the honest shape of gem reuse under tep today: the hot
54
+ path of a well-behaved pure-Ruby gem drops straight in, while methods that
55
+ reach for unsupported stdlib corners don't.
56
+
57
+ For an example where the gem's **entire** API compiles — no caveats — see
58
+ [`examples/maidenhead`](../maidenhead/README.md).
@@ -0,0 +1,33 @@
1
+ require 'sinatra'
2
+
3
+ # Use a REAL published Ruby gem -- pr_geohash 1.0.0 (MIT) -- from a tep
4
+ # app, declared in a Gemfile and resolved by `spinel-compat vendor`
5
+ # (bundler-spinel, ../spinelgems) into vendor/spinel/. The one require
6
+ # below pulls in the generated deps.rb chain; bin/tep inlines it into the
7
+ # AOT binary. Run `make vendor` (or see README.md) before building.
8
+ require_relative 'vendor/spinel/deps'
9
+
10
+ # GET /geohash?lat=..&lon=..&precision=..
11
+ # Encodes a latitude/longitude into a geohash using GeoHash.encode from
12
+ # the gem. Query params (not path segments) so the '-' and '.' in real
13
+ # coordinates don't need escaping.
14
+ get '/geohash' do
15
+ lat = params["lat"].to_f
16
+ lon = params["lon"].to_f
17
+ prec = params["precision"].to_i
18
+ if prec <= 0
19
+ prec = 12
20
+ end
21
+ GeoHash.encode(lat, lon, prec)
22
+ end
23
+
24
+ # NOTE: GeoHash.neighbors / GeoHash.decode are intentionally NOT exposed.
25
+ # They lean on Array#flatten / Array#transpose, which spinel doesn't
26
+ # compile yet (encode uses only map/join/bit-ops and works fine). A good
27
+ # illustration that gem reuse is per-method: the hot path compiles, a
28
+ # couple of helpers don't -- see README.md.
29
+
30
+ get '/' do
31
+ "tep + pr_geohash 1.0.0 (a real, unmodified published gem)\n" +
32
+ "try: /geohash?lat=48.8584&lon=2.2945&precision=8\n"
33
+ end
data/examples/hello.rb ADDED
@@ -0,0 +1,120 @@
1
+ # Tep "hello" demo -- exercises the full v0.1 surface.
2
+ require_relative "../lib/tep"
3
+
4
+ class Root < Tep::Handler
5
+ def handle(req, res)
6
+ "<!doctype html><html><head><title>tep " + Tep::VERSION + "</title>" +
7
+ "<link rel=\"stylesheet\" href=\"/style.css\">" +
8
+ "</head><body>" +
9
+ "<h1>tep " + Tep::VERSION + "</h1>" +
10
+ "<p>Sinatra-flavoured framework, AOT-compiled by Spinel.</p>" +
11
+ "<ul>" +
12
+ "<li><a href=\"/hi/world\">/hi/:name</a> -- path params</li>" +
13
+ "<li><a href=\"/search?q=ruby&page=2\">/search?q=...&page=...</a> -- query string</li>" +
14
+ "<li><a href=\"/square/12\">/square/:n</a> -- typed path param</li>" +
15
+ "<li><a href=\"/about\">/about</a> -- custom Content-Type</li>" +
16
+ "<li><a href=\"/old\">/old</a> -- 302 redirect</li>" +
17
+ "<li><a href=\"/secret\">/secret</a> -- 401 halt</li>" +
18
+ "<li><a href=\"/hello.txt\">/hello.txt</a> -- static file from public/</li>" +
19
+ "<li><a href=\"/style.css\">/style.css</a> -- static CSS</li>" +
20
+ "<li><a href=\"/missing\">/missing</a> -- custom 404</li>" +
21
+ "<li>POST /echo (form-urlencoded body)</li>" +
22
+ "</ul></body></html>"
23
+ end
24
+ end
25
+
26
+ class Hi < Tep::Handler
27
+ def handle(req, res)
28
+ "<p>hi, " + req.params["name"] + "!</p>\n"
29
+ end
30
+ end
31
+
32
+ class Search < Tep::Handler
33
+ def handle(req, res)
34
+ "<p>q=" + req.params["q"] + " page=" + req.params["page"] + "</p>\n"
35
+ end
36
+ end
37
+
38
+ class Square < Tep::Handler
39
+ def handle(req, res)
40
+ n = req.params["n"].to_i
41
+ "<p>" + n.to_s + "<sup>2</sup> = " + (n * n).to_s + "</p>\n"
42
+ end
43
+ end
44
+
45
+ class About < Tep::Handler
46
+ def handle(req, res)
47
+ res.headers["Content-Type"] = "text/plain; charset=utf-8"
48
+ "tep " + Tep::VERSION + " -- spinel-compiled Sinatra-flavoured framework\n"
49
+ end
50
+ end
51
+
52
+ class Old < Tep::Handler
53
+ def handle(req, res)
54
+ res.set_status(302)
55
+ res.headers["Location"] = "/"
56
+ ""
57
+ end
58
+ end
59
+
60
+ class Secret < Tep::Handler
61
+ def handle(req, res)
62
+ res.set_status(401)
63
+ res.headers["WWW-Authenticate"] = "Basic realm=\"tep\""
64
+ "<h1>401</h1><p>nothing here for you.</p>\n"
65
+ end
66
+ end
67
+
68
+ class Echo < Tep::Handler
69
+ def handle(req, res)
70
+ res.headers["Content-Type"] = "text/plain; charset=utf-8"
71
+ "verb=" + req.verb + "\n" +
72
+ "path=" + req.path + "\n" +
73
+ "name=" + req.params["name"] + "\n" +
74
+ "body=" + req.raw_body + "\n"
75
+ end
76
+ end
77
+
78
+ class CustomNotFound < Tep::Handler
79
+ def handle(req, res)
80
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
81
+ "<h1>nope -- " + req.path + "</h1>" +
82
+ "<p>this is a custom 404 page from Tep.not_found</p>\n"
83
+ end
84
+ end
85
+
86
+ class LoggerFilter < Tep::Filter
87
+ def before(req, res)
88
+ puts "[" + req.verb + "] " + req.path
89
+ 0
90
+ end
91
+ end
92
+
93
+ class TimerFilter < Tep::Filter
94
+ def after(req, res)
95
+ res.headers["X-Tep-Powered-By"] = "spinel-aot"
96
+ 0
97
+ end
98
+ end
99
+
100
+ # ---- registrations ----
101
+
102
+ # Spinel doesn't have `__dir__` at compile time, so the path
103
+ # substituted here has to be absolute *or* relative to the binary's
104
+ # CWD at runtime. Adjust to suit your deployment.
105
+ Tep.public_dir "./public"
106
+ Tep.before LoggerFilter.new
107
+ Tep.after TimerFilter.new
108
+ Tep.not_found CustomNotFound.new
109
+
110
+ Tep.get "/", Root.new
111
+ Tep.get "/hi/:name", Hi.new
112
+ Tep.get "/search", Search.new
113
+ Tep.get "/square/:n", Square.new
114
+ Tep.get "/about", About.new
115
+ Tep.get "/old", Old.new
116
+ Tep.get "/secret", Secret.new
117
+ Tep.post "/echo", Echo.new
118
+
119
+ # port, workers, quiet
120
+ Tep.run!(4567, 1, false)
@@ -0,0 +1,73 @@
1
+ # llm_gateway — an LLM API gateway on `Tep::Proxy`
2
+
3
+ Fronts a remote OpenAI-compatible upstream and adds, in ~40 lines of
4
+ Ruby, the three things a gateway exists for:
5
+
6
+ 1. **Credential swap** — strips the client's `Authorization`, attaches
7
+ the server-side key (`before`). The upstream only ever sees the
8
+ gateway's key.
9
+ 2. **Transparent streaming** — `stream: true` requests are forwarded
10
+ over a held-open connection and the SSE events pass straight back
11
+ to the client, unbuffered (`stream_request?` + `on_stream_chunk`).
12
+ 3. **Per-request telemetry** — exactly one toy/v1 `inference` event
13
+ per request, emitted at end-of-stream (`on_stream_end` +
14
+ `Tep::Events`) — the right cardinality (one per request, not per
15
+ chunk), with token counts + latency.
16
+
17
+ This is the showcase for proxy battery chunk 6.2 (streaming +
18
+ `on_stream_end`) composed with `Tep::Events`. It uses the **block-form
19
+ proxy DSL** (`gw.before do … end`), which `bin/tep` lowers to a
20
+ `Tep::Proxy` subclass.
21
+
22
+ ## Run
23
+
24
+ ```sh
25
+ UPSTREAM=https://api.openai.com \
26
+ OPENAI_KEY=sk-... \
27
+ EVENTS_JSONL=/tmp/gateway.events.jsonl \
28
+ bin/tep build examples/llm_gateway/app.rb -o /tmp/gw && /tmp/gw -p 4567
29
+ ```
30
+
31
+ (Points at any OpenAI-compatible server — a local `ollama`, vLLM,
32
+ llama.cpp's server, or the real OpenAI API. `EVENTS_JSONL` unset
33
+ disables emission with zero overhead.)
34
+
35
+ ```sh
36
+ # streaming chat completion — SSE passthrough + one inference event
37
+ curl -s localhost:4567/v1/chat/completions \
38
+ -H 'content-type: application/json' \
39
+ -d '{"model":"gpt-4o-mini","stream":true,
40
+ "messages":[{"role":"user","content":"hi"}]}'
41
+
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"}}
46
+ ```
47
+
48
+ The events stream is the toy/v1 envelope, so a research-lab
49
+ orchestrator (or any consumer of training/serving events) ingests it
50
+ the same way it ingests a training run.
51
+
52
+ ## Notes / limits
53
+
54
+ - **Streaming requires the scheduled server** (`set :scheduler,
55
+ :scheduled`) — the pump parks on `io_wait`, same as WebSocket.
56
+ - **Token counts are approximate at the proxy:** `completion_tokens`
57
+ is the SSE-event count (no tokenizer here); `prompt_tokens` is left
58
+ 0. A real gateway parses `delta.content` / the request `messages`.
59
+ The origin-server battery (`Tep::Llm::OpenAI::Server`) reports exact
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
63
+ meaningful). Sub-second timing would need a µs-clock primitive.
64
+ - **Auth/capabilities** flow through `req.identity` like any tep
65
+ route — gate the gateway with `req.identity.may?(:call_upstream)` in
66
+ `before` if you want per-principal access control.
67
+
68
+ ## See also
69
+
70
+ - [`docs/PROXY-BATTERY.md`](../../docs/PROXY-BATTERY.md) — the battery.
71
+ - `lib/tep/events.rb` — the toy/v1 emitter.
72
+ - `examples/api_gateway` — the non-streaming sibling (auth-attach +
73
+ observability), 6.3's other half.
@@ -0,0 +1,91 @@
1
+ # examples/llm_gateway -- an LLM API gateway built on Tep::Proxy.
2
+ #
3
+ # Fronts a remote OpenAI-compatible upstream: swaps the client's
4
+ # credential for the server-side key, streams the SSE response back
5
+ # unchanged, and emits ONE toy/v1 serving event (kind:eval,
6
+ # phase:serve, name:request) per request at end-of-stream (via
7
+ # Tep::Events#inference). This is the payoff of
8
+ # the proxy streaming battery (chunk 6.2) + the events emitter --
9
+ # token-count + latency telemetry with the right cardinality (one
10
+ # event per request, not per chunk), using on_stream_end as the
11
+ # one-shot finalizer.
12
+ #
13
+ # Run:
14
+ # UPSTREAM=https://api.openai.com OPENAI_KEY=sk-... \
15
+ # EVENTS_JSONL=/tmp/gateway.events.jsonl \
16
+ # bin/tep build examples/llm_gateway/app.rb -o /tmp/gw && /tmp/gw -p 4567
17
+ #
18
+ # # streaming chat completion -> SSE passthrough + one serving event
19
+ # curl -s localhost:4567/v1/chat/completions -H 'content-type: application/json' \
20
+ # -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}'
21
+ # tail -1 /tmp/gateway.events.jsonl
22
+ # # {"kind":"eval","phase":"serve","t":3,"name":"request","extra":{
23
+ # # "model":"gpt-4o-mini","prompt_tokens":0,"completion_tokens":42,
24
+ # # "latency_us":3000000, ...}}
25
+ require 'sinatra'
26
+
27
+ # Streaming proxying needs the cooperative server (the pump parks on
28
+ # io_wait); same constraint as WebSocket.
29
+ set :scheduler, :scheduled
30
+ set :workers, 1
31
+
32
+ UPSTREAM = ENV["UPSTREAM"] || "http://127.0.0.1:11434" # e.g. a local ollama
33
+ OPENAI_KEY = ENV["OPENAI_KEY"] || ""
34
+ EVENTS = Tep::Events.new(ENV["EVENTS_JSONL"] || "") # "" disables emission
35
+
36
+ on_start do
37
+ EVENTS.run_start("gateway", "proxy", "upstream", UPSTREAM,
38
+ "{\"server\":\"tep-llm-gateway\"}")
39
+ end
40
+
41
+ # Block-form proxy DSL (lowered to a Tep::Proxy subclass by bin/tep).
42
+ gw = Tep::Proxy.new(UPSTREAM)
43
+
44
+ # Swap the credential: strip whatever the client sent, attach the
45
+ # server-side key. Also stamp a coarse start time for the latency
46
+ # measurement in on_stream_end (req.ivars is per-request state).
47
+ gw.before do |req, res, ureq|
48
+ if OPENAI_KEY.length > 0
49
+ ureq.set_header("Authorization", "Bearer " + OPENAI_KEY)
50
+ end
51
+ req.ivars["t0"] = Time.now.to_i.to_s
52
+ false
53
+ end
54
+
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
57
+ # we match the literal (with or without the space).
58
+ gw.stream_request? do |req|
59
+ b = req.raw_body
60
+ b.include?("\"stream\":true") || b.include?("\"stream\": true")
61
+ end
62
+
63
+ # Pass each SSE event straight through. The framework's StreamStats
64
+ # tracks chunk_count / byte_count for us (chunk_count ~ completion
65
+ # tokens for this demo; a real gateway would parse delta.content).
66
+ gw.on_stream_chunk do |chunk, out, stats|
67
+ out.write(chunk.chunk_text)
68
+ 0
69
+ end
70
+
71
+ # One inference event at end-of-stream -- the right cardinality.
72
+ gw.on_stream_end do |req, out, stats|
73
+ model = Tep::Json.get_str(req.raw_body, "model")
74
+ t0 = req.ivars["t0"].to_i
75
+ wall = Time.now.to_i - t0
76
+ if wall < 0
77
+ wall = 0
78
+ end
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) +
82
+ "}"
83
+ # prompt_tokens unknown at the proxy (no tokenizer); completion_tokens
84
+ # approximated by the SSE event count. wall_us is second-resolution
85
+ # (no µs clock) -- fine for seconds-scale LLM latency.
86
+ EVENTS.inference(model, 0, stats.chunk_count, wall * 1000000, extra)
87
+ 0
88
+ end
89
+
90
+ Tep.get "/v1/models", gw
91
+ Tep.post "/v1/chat/completions", gw
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+ ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
3
+
4
+ # A real published gem, declared the normal way. bundler-spinel
5
+ # (spinel-compat vendor) resolves it from the lockfile and places its
6
+ # source under vendor/spinel/ where tep's build will follow it.
7
+ gem "maidenhead", "1.0.1"
@@ -0,0 +1,17 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ maidenhead (1.0.1)
5
+
6
+ PLATFORMS
7
+ aarch64-linux
8
+ ruby
9
+
10
+ DEPENDENCIES
11
+ maidenhead (= 1.0.1)
12
+
13
+ RUBY VERSION
14
+ ruby 3.4.9p82
15
+
16
+ BUNDLED WITH
17
+ 2.6.9