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
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
|
data/test/test_logger.rb
ADDED
|
@@ -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
|