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
data/test/test_llm.rb ADDED
@@ -0,0 +1,250 @@
1
+ # Tests for Tep::Llm encode/parse via a live tep app. The app
2
+ # exposes one route per test that exercises the corresponding
3
+ # static method and returns the result as the body; the MRI test
4
+ # side GETs each route + asserts on the body.
5
+ #
6
+ # Integration coverage (Tep::Llm.chat() pointed at a live OpenAI-
7
+ # compatible backend) is the job of examples/chat/ once it lands
8
+ # per OriPekelman/tep#10 -- that demo exercises the network path.
9
+ require_relative "helper"
10
+
11
+ class TestLlm < TepTest
12
+ # Single-quoted heredoc so the Phase B test bodies (which embed
13
+ # `\r\n` chunked-transfer terminators) pass through literally
14
+ # rather than getting interpreted as raw CR+LF at heredoc-parse
15
+ # time.
16
+ app_source <<~'RB'
17
+ require "sinatra"
18
+
19
+ get "/build_simple" do
20
+ msg = Tep::Llm::Message.new("user", "Hello!")
21
+ Tep::Llm.build_request_body("gpt-2", "", [msg])
22
+ end
23
+
24
+ get "/build_system" do
25
+ msg = Tep::Llm::Message.new("user", "Hi")
26
+ Tep::Llm.build_request_body("llama3", "You are concise.", [msg])
27
+ end
28
+
29
+ get "/build_multiturn" do
30
+ msgs = [
31
+ Tep::Llm::Message.new("user", "What is 2+2?"),
32
+ Tep::Llm::Message.new("assistant", "4"),
33
+ Tep::Llm::Message.new("user", "Now multiply by 3."),
34
+ ]
35
+ Tep::Llm.build_request_body("gpt-2", "", msgs)
36
+ end
37
+
38
+ get "/extract_simple" do
39
+ Tep::Llm.extract_str_field('{"foo":"bar","baz":"qux"}', "foo", 0)
40
+ end
41
+
42
+ get "/extract_missing" do
43
+ r = Tep::Llm.extract_str_field('{"foo":"bar"}', "missing", 0)
44
+ # Distinguish empty-string-found from empty-string-default;
45
+ # the empty-string-default case is what we want here.
46
+ r.length == 0 ? "MISSING" : "FOUND:" + r
47
+ end
48
+
49
+ get "/parse_openai_happy" do
50
+ fake = Tep::Http::Response.new
51
+ fake.status = 200
52
+ fake.body = '{"choices":[{"index":0,' +
53
+ '"message":{"role":"assistant","content":"Hello!"},' +
54
+ '"finish_reason":"stop"}]}'
55
+ out = Tep::Llm.parse_response(fake)
56
+ out.content + "|" + out.role + "|" + out.stop_reason
57
+ end
58
+
59
+ get "/parse_transport_error" do
60
+ fake = Tep::Http::Response.new
61
+ fake.status = 0
62
+ out = Tep::Llm.parse_response(fake)
63
+ out.stop_reason
64
+ end
65
+
66
+ get "/parse_http_404" do
67
+ fake = Tep::Http::Response.new
68
+ fake.status = 404
69
+ fake.body = '{"error":"not found"}'
70
+ out = Tep::Llm.parse_response(fake)
71
+ out.stop_reason
72
+ end
73
+
74
+ get "/client_setters" do
75
+ c = Tep::Llm.new("http://example.test")
76
+ c.set_model("m")
77
+ c.set_api_key("k")
78
+ c.set_system_prompt("p")
79
+ c.model + "|" + c.api_key + "|" + c.system_prompt
80
+ end
81
+
82
+ # --- Phase B: chunked decode + SSE event consume ---
83
+
84
+ get "/hex_to_int_valid" do
85
+ Tep::Llm.hex_to_int("ff").to_s + "|" +
86
+ Tep::Llm.hex_to_int("a").to_s + "|" +
87
+ Tep::Llm.hex_to_int("100").to_s
88
+ end
89
+
90
+ get "/hex_to_int_invalid" do
91
+ Tep::Llm.hex_to_int("zz").to_s + "|" +
92
+ Tep::Llm.hex_to_int("").to_s
93
+ end
94
+
95
+ # One chunked body: 5 bytes "Hello", then last-chunk 0.
96
+ get "/dechunk_complete" do
97
+ s = "5\r\nHello\r\n0\r\n\r\n"
98
+ Tep::Llm.dechunk_consume(s)
99
+ end
100
+
101
+ # Two chunks in one buffer.
102
+ get "/dechunk_multiple" do
103
+ s = "3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n"
104
+ Tep::Llm.dechunk_consume(s)
105
+ end
106
+
107
+ # Partial body: chunk header present, but body bytes not all there.
108
+ # dechunk_consume returns the already-consumed portion ("");
109
+ # dechunk_leftover returns the still-pending tail.
110
+ get "/dechunk_partial" do
111
+ s = "5\r\nHel"
112
+ consumed = Tep::Llm.dechunk_consume(s)
113
+ leftover = Tep::Llm.dechunk_leftover(s)
114
+ "consumed=" + consumed.length.to_s + "|leftover=" + leftover
115
+ end
116
+
117
+ # consume_sse_events on a buffer with one delta + DONE marker.
118
+ # The mock stream just counts writes and records the last write.
119
+ get "/sse_one_delta_then_done" do
120
+ state = Tep::Llm::StreamState.new
121
+ state.leftover = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n" +
122
+ "data: [DONE]\n\n"
123
+ sink = Tep::Stream.new(0) # write goes to fd 0 (stdout); we
124
+ # only assert on state.acc + done
125
+ Tep::Llm.consume_sse_events(sink, state)
126
+ state.acc + "|done=" + (state.done ? "true" : "false")
127
+ end
128
+
129
+ # Partial: one full delta, then half of the next data: line.
130
+ # consume_sse_events should drain the full one + leave the rest.
131
+ get "/sse_partial_tail" do
132
+ state = Tep::Llm::StreamState.new
133
+ state.leftover = "data: {\"choices\":[{\"delta\":{\"content\":\"X\"}}]}\n\n" +
134
+ "data: {\"choices\":[{\"delta\":{\"content\""
135
+ sink = Tep::Stream.new(0)
136
+ Tep::Llm.consume_sse_events(sink, state)
137
+ "acc=" + state.acc + "|done=" + (state.done ? "true" : "false") +
138
+ "|leftover_len=" + state.leftover.length.to_s
139
+ end
140
+
141
+ # finish_reason in a data line should set state.done.
142
+ get "/sse_finish_reason_ends" do
143
+ state = Tep::Llm::StreamState.new
144
+ state.leftover = "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"
145
+ sink = Tep::Stream.new(0)
146
+ Tep::Llm.consume_sse_events(sink, state)
147
+ "done=" + (state.done ? "true" : "false")
148
+ end
149
+ RB
150
+
151
+ def test_build_simple_user_message
152
+ res = get("/build_simple")
153
+ assert_equal "200", res.code
154
+ assert_equal(
155
+ '{"model":"gpt-2","messages":[{"role":"user","content":"Hello!"}]}',
156
+ res.body
157
+ )
158
+ end
159
+
160
+ def test_build_with_system_prompt
161
+ res = get("/build_system")
162
+ assert_equal(
163
+ '{"model":"llama3","messages":[' \
164
+ '{"role":"system","content":"You are concise."},' \
165
+ '{"role":"user","content":"Hi"}' \
166
+ ']}',
167
+ res.body
168
+ )
169
+ end
170
+
171
+ def test_build_multi_turn
172
+ res = get("/build_multiturn")
173
+ assert_match(/"role":"user","content":"What is 2\+2\?"/, res.body)
174
+ assert_match(/"role":"assistant","content":"4"/, res.body)
175
+ assert_match(/"role":"user","content":"Now multiply by 3\."/, res.body)
176
+ end
177
+
178
+ def test_extract_str_field_simple
179
+ res = get("/extract_simple")
180
+ assert_equal "bar", res.body
181
+ end
182
+
183
+ def test_extract_str_field_missing_returns_empty
184
+ res = get("/extract_missing")
185
+ assert_equal "MISSING", res.body
186
+ end
187
+
188
+ def test_parse_response_openai_happy_path
189
+ res = get("/parse_openai_happy")
190
+ assert_equal "Hello!|assistant|stop", res.body
191
+ end
192
+
193
+ def test_parse_response_transport_failure
194
+ res = get("/parse_transport_error")
195
+ assert_equal "error", res.body
196
+ end
197
+
198
+ def test_parse_response_http_404
199
+ res = get("/parse_http_404")
200
+ assert_equal "http_404", res.body
201
+ end
202
+
203
+ def test_client_setters_round_trip
204
+ res = get("/client_setters")
205
+ assert_equal "m|k|p", res.body
206
+ end
207
+
208
+ # --- Phase B: chunked + SSE primitives ---
209
+
210
+ def test_hex_to_int_valid
211
+ res = get("/hex_to_int_valid")
212
+ assert_equal "255|10|256", res.body
213
+ end
214
+
215
+ def test_hex_to_int_malformed_returns_neg_one
216
+ res = get("/hex_to_int_invalid")
217
+ assert_equal "-1|-1", res.body
218
+ end
219
+
220
+ def test_dechunk_complete_single_chunk
221
+ res = get("/dechunk_complete")
222
+ assert_equal "Hello", res.body
223
+ end
224
+
225
+ def test_dechunk_complete_multiple_chunks
226
+ res = get("/dechunk_multiple")
227
+ assert_equal "foobar", res.body
228
+ end
229
+
230
+ def test_dechunk_partial_tail_left_for_next_recv
231
+ res = get("/dechunk_partial")
232
+ # No full chunk yet -- consumed empty, leftover holds the full tail.
233
+ assert_equal "consumed=0|leftover=5\r\nHel", res.body
234
+ end
235
+
236
+ def test_sse_one_delta_then_done_sets_done
237
+ res = get("/sse_one_delta_then_done")
238
+ assert_equal "Hello|done=true", res.body
239
+ end
240
+
241
+ def test_sse_partial_tail_preserved_for_next_recv
242
+ res = get("/sse_partial_tail")
243
+ assert_match(/^acc=X\|done=false\|leftover_len=\d+/, res.body)
244
+ end
245
+
246
+ def test_sse_finish_reason_terminates_stream
247
+ res = get("/sse_finish_reason_ends")
248
+ assert_equal "done=true", res.body
249
+ end
250
+ end
@@ -0,0 +1,95 @@
1
+ require_relative "helper"
2
+ require "json"
3
+
4
+ # examples/llm_gateway integration: a Tep::Proxy that streams an SSE
5
+ # upstream through to the client AND emits one toy/v1 inference event
6
+ # at end-of-stream via Tep::Events. Self-contained (self-call SSE
7
+ # upstream, like test_proxy_streaming): proves proxy streaming +
8
+ # Tep::Events compose -- the chunk-6.3 payoff.
9
+ #
10
+ # The example app itself uses the block DSL; here the gateway is the
11
+ # subclass-override form so it can be built per-request with the
12
+ # harness's runtime port (block-DSL proxies construct at load time,
13
+ # before the port is known -- same reason test_proxy_streaming uses
14
+ # in-handler construction).
15
+ class TestLlmGateway < TepTest
16
+ EV_PATH = "/tmp/tep_gateway_test.jsonl"
17
+
18
+ app_source <<~RB
19
+ require 'sinatra'
20
+
21
+ set :scheduler, :scheduled
22
+ set :workers, 1
23
+
24
+ PATH = "#{EV_PATH}"
25
+ EVENTS = Tep::Events.new(PATH)
26
+
27
+ # Upstream: an OpenAI-shaped streaming chat completion.
28
+ class ChatSse < Tep::Streamer
29
+ def pump(out)
30
+ out.write("data: {\\"choices\\":[{\\"delta\\":{\\"content\\":\\"He\\"}}]}\\n\\n")
31
+ out.write("data: {\\"choices\\":[{\\"delta\\":{\\"content\\":\\"llo\\"}}]}\\n\\n")
32
+ out.write("data: [DONE]\\n\\n")
33
+ end
34
+ end
35
+
36
+ post '/v1/upstream' do
37
+ res.headers["Content-Type"] = "text/event-stream"
38
+ stream ChatSse.new
39
+ end
40
+
41
+ # The gateway: stream through + emit one inference event at end.
42
+ class Gateway < Tep::Proxy
43
+ def rewrite_path(path)
44
+ "/v1/upstream"
45
+ end
46
+ def stream_request?(req)
47
+ true
48
+ end
49
+ def on_stream_chunk(chunk, out, stats)
50
+ out.write(chunk.chunk_text)
51
+ 0
52
+ end
53
+ def on_stream_end(req, out, stats)
54
+ model = Tep::Json.get_str(req.raw_body, "model")
55
+ extra = "{" + Tep::Json.encode_pair_str("request_id", "req-1") + "}"
56
+ EVENTS.inference(model, 0, stats.chunk_count, 1000000, extra)
57
+ 0
58
+ end
59
+ end
60
+
61
+ post '/gw/:port' do
62
+ File.write(PATH, "")
63
+ Gateway.new("http://127.0.0.1:" + params[:port]).handle(req, res)
64
+ res.body
65
+ end
66
+
67
+ get '/events' do
68
+ File.read(PATH)
69
+ end
70
+ RB
71
+
72
+ def test_streams_upstream_through_gateway
73
+ res = post("/gw/#{@port}", "{\"model\":\"demo-llm\",\"stream\":true}")
74
+ assert_equal "200", res.code
75
+ # The three upstream SSE events pass through unchanged.
76
+ assert_equal "data: {\"choices\":[{\"delta\":{\"content\":\"He\"}}]}\n\n" \
77
+ "data: {\"choices\":[{\"delta\":{\"content\":\"llo\"}}]}\n\n" \
78
+ "data: [DONE]\n\n", res.body
79
+ end
80
+
81
+ def test_emits_one_inference_event
82
+ post("/gw/#{@port}", "{\"model\":\"demo-llm\",\"stream\":true}")
83
+ lines = get("/events").body.split("\n").reject(&:empty?)
84
+ assert_equal 1, lines.length, "expected exactly one inference event"
85
+ ev = JSON.parse(lines[0])
86
+ # toy/v1 inference shape (migrated in #136): kind "eval", name
87
+ # "request", and model/token fields nested under "extra".
88
+ assert_equal "eval", ev["kind"]
89
+ assert_equal "serve", ev["phase"]
90
+ assert_equal "request", ev["name"]
91
+ assert_equal "demo-llm", ev["extra"]["model"]
92
+ assert_equal 3, ev["extra"]["completion_tokens"] # 3 SSE events dispatched
93
+ assert_equal "req-1", ev["extra"]["request_id"]
94
+ end
95
+ end
@@ -0,0 +1,101 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Logger -- levelled logger with stderr / file output.
4
+ class TestLogger < TepTest
5
+ TMP_LOG = "/tmp/tep_logger_test_#{$$}.log"
6
+
7
+ app_source <<~RB
8
+ require 'sinatra'
9
+
10
+ LOGGER = Tep::Logger.new
11
+ LOGGER.set_level("debug")
12
+ LOGGER.to_file("#{TMP_LOG}")
13
+
14
+ before do
15
+ LOGGER.info(req.verb + " " + req.path)
16
+ end
17
+
18
+ get '/dbg' do
19
+ LOGGER.debug("dbg-line")
20
+ "ok"
21
+ end
22
+
23
+ get '/info' do
24
+ LOGGER.info("info-line")
25
+ "ok"
26
+ end
27
+
28
+ get '/warn' do
29
+ LOGGER.warn("warn-line")
30
+ "ok"
31
+ end
32
+
33
+ get '/err' do
34
+ LOGGER.error("err-line")
35
+ "ok"
36
+ end
37
+
38
+ get '/clear' do
39
+ File.write("#{TMP_LOG}", "")
40
+ "cleared"
41
+ end
42
+
43
+ # Toggle level at runtime.
44
+ get '/level/:lvl' do
45
+ LOGGER.set_level(params[:lvl])
46
+ "level=" + params[:lvl]
47
+ end
48
+ RB
49
+
50
+ Minitest.after_run do
51
+ File.unlink(TMP_LOG) if File.exist?(TMP_LOG)
52
+ end
53
+
54
+ def read_log
55
+ File.exist?(TMP_LOG) ? File.read(TMP_LOG) : ""
56
+ end
57
+
58
+ def clear_log
59
+ get("/clear")
60
+ end
61
+
62
+ def setup
63
+ super
64
+ # Tests run in randomised order against a single booted app
65
+ # that shares LOGGER state. Reset level + clear log per test.
66
+ get("/level/debug")
67
+ clear_log
68
+ end
69
+
70
+ def test_each_level_writes_a_line
71
+ get("/dbg")
72
+ get("/info")
73
+ get("/warn")
74
+ get("/err")
75
+ log = read_log
76
+ assert_match(/\[debug\] dbg-line/, log)
77
+ assert_match(/\[info\] info-line/, log)
78
+ assert_match(/\[warn\] warn-line/, log)
79
+ assert_match(/\[error\] err-line/, log)
80
+ end
81
+
82
+ def test_level_filter_drops_below_threshold
83
+ get("/level/warn")
84
+ clear_log # drop the "/level/warn" before-filter line too
85
+ get("/dbg")
86
+ get("/info")
87
+ get("/warn")
88
+ get("/err")
89
+ log = read_log
90
+ refute_match(/\[debug\]/, log)
91
+ refute_match(/\[info\]/, log)
92
+ assert_match(/\[warn\] warn-line/, log)
93
+ assert_match(/\[error\] err-line/, log)
94
+ end
95
+
96
+ def test_format_includes_unix_timestamp
97
+ get("/info")
98
+ log = read_log
99
+ assert_match(/\A\[\d+\] \[info\]/, log)
100
+ end
101
+ end
@@ -0,0 +1,86 @@
1
+ require "minitest/autorun"
2
+ require "net/http"
3
+ require "socket"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+
7
+ # Companion to test_geohash_example.rb, but for the example whose ENTIRE
8
+ # gem API compiles: examples/maidenhead/app.rb runs on the unmodified
9
+ # published maidenhead 1.0.1 gem (MIT), vendored + require_relative'd.
10
+ # Every route is checked against CRuby's Maidenhead.* output. Builds the
11
+ # real example in place so the relative require_relative resolves.
12
+ class TestMaidenheadExample < Minitest::Test
13
+ TEP_BIN = File.expand_path("../bin/tep", __dir__)
14
+ APP = File.expand_path("../examples/maidenhead/app.rb", __dir__)
15
+ EX_DIR = File.dirname(APP)
16
+
17
+ # The gem source under vendor/spinel is generated from the Gemfile.lock
18
+ # by bundler-spinel (`spinel-compat vendor`), not committed. Regenerate
19
+ # it before building; skip cleanly if spinelgems isn't reachable (e.g.
20
+ # the suite run outside the dev container, which mounts /spinelgems).
21
+ def ensure_vendored
22
+ deps = File.join(EX_DIR, "vendor", "spinel", "deps.rb")
23
+ return if File.exist?(deps)
24
+ sg = ENV["SPINELGEMS"] || "/spinelgems"
25
+ skip "spinelgems not at #{sg}; run `make vendor-examples`" unless File.directory?(File.join(sg, "exe"))
26
+ out = `cd #{EX_DIR} && ruby -I #{sg}/lib #{sg}/exe/spinel-compat vendor 2>&1`
27
+ skip "spinel-compat vendor failed (offline?):\n#{out}" unless $?.success? && File.exist?(deps)
28
+ end
29
+
30
+ def setup
31
+ ensure_vendored
32
+ @tmp = Dir.mktmpdir("tep-maidenhead")
33
+ @bin = File.join(@tmp, "maidenhead")
34
+ out = `#{TEP_BIN} build #{APP} -o #{@bin} 2>&1`
35
+ raise "maidenhead example build failed:\n#{out}" unless $?.success? && File.executable?(@bin)
36
+ @port = 4960 + (Process.pid % 80)
37
+ @log = File.join(@tmp, "app.log")
38
+ @pid = Process.spawn(@bin, "-p", @port.to_s, out: @log, err: [:child, :out], pgroup: true)
39
+ wait_for_port(@port)
40
+ end
41
+
42
+ def teardown
43
+ if @pid
44
+ Process.kill("TERM", -@pid) rescue nil
45
+ Process.wait(@pid) rescue nil
46
+ end
47
+ FileUtils.remove_entry(@tmp) if @tmp && File.directory?(@tmp)
48
+ end
49
+
50
+ def wait_for_port(port, timeout: 10.0)
51
+ deadline = Time.now + timeout
52
+ while Time.now < deadline
53
+ begin
54
+ TCPSocket.new("127.0.0.1", port).close
55
+ return
56
+ rescue
57
+ sleep 0.05
58
+ end
59
+ end
60
+ raise "maidenhead app never bound :#{port}\n#{File.read(@log) rescue ''}"
61
+ end
62
+
63
+ def get(path)
64
+ Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}#{path}")).body
65
+ end
66
+
67
+ def test_valid_true
68
+ assert_equal "true", get("/valid?loc=FN31pr")
69
+ end
70
+
71
+ def test_valid_false
72
+ assert_equal "false", get("/valid?loc=invalid")
73
+ end
74
+
75
+ def test_to_latlon
76
+ assert_equal "41.731076,-72.704514", get("/to_latlon?loc=FN31pr")
77
+ end
78
+
79
+ def test_to_grid_precision_3
80
+ assert_equal "FN20xr", get("/to_grid?lat=40.7128&lon=-74.0060&precision=3")
81
+ end
82
+
83
+ def test_to_grid_precision_2
84
+ assert_equal "IO91", get("/to_grid?lat=51.5074&lon=-0.1278&precision=2")
85
+ end
86
+ end