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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- 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,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"
|