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/lib/tep/proxy.rb ADDED
@@ -0,0 +1,801 @@
1
+ # Tep::Proxy -- HTTP reverse proxy battery (chunk 6.1).
2
+ #
3
+ # A Tep::Handler subclass that forwards a request to an upstream
4
+ # HTTP server, runs user hooks in both directions, and copies the
5
+ # response back to the client. Mount it at any route like a normal
6
+ # handler; one instance can serve many paths:
7
+ #
8
+ # class OpenAIProxy < Tep::Proxy
9
+ # def before_forward(req, res, ureq)
10
+ # ureq.set_header("Authorization", "Bearer " + ENV["OPENAI_KEY"])
11
+ # false # forward (return true to short-circuit)
12
+ # end
13
+ #
14
+ # def after_forward(req, ures, res)
15
+ # Tep::Logger.info("upstream " + ures.status.to_s)
16
+ # 0
17
+ # end
18
+ # end
19
+ #
20
+ # api = OpenAIProxy.new("http://api.internal:8080")
21
+ # Tep.post "/v1/chat/completions", api
22
+ # Tep.get "/v1/models", api
23
+ #
24
+ # Why subclass-and-override instead of the `api.before do ... end`
25
+ # block DSL in docs/PROXY-BATTERY.md: that block form needs the
26
+ # bin/tep translator to recognise `<proxyvar>.before do ... end`
27
+ # (a receiver-method call with a block) and lower it into instance
28
+ # methods on a generated subclass -- spinel can't store a
29
+ # PtrArray<Block> on the instance. Until that translator chunk
30
+ # lands, overriding `before_forward` / `after_forward` on a
31
+ # subclass IS the lowering target, just hand-authored. This mirrors
32
+ # how Tep::LiveView shipped its overridable hooks before the
33
+ # `Tep.live` auto-wire helper (see lib/tep/live_view.rb).
34
+ #
35
+ # The hook names are `before_forward` / `after_forward` rather than
36
+ # bare `before` / `after` on purpose: Tep::Filter / Tep::Security /
37
+ # Tep::Auth already define 2-arg `before(req, res)` / `after(req,
38
+ # res)` imeths, and spinel's virtual-imeth dispatch unifies on the
39
+ # method name -- a 3-arg `before` here would collide with those
40
+ # (see [[spinel-widening-dispatch]]). Distinct names sidestep it.
41
+ #
42
+ # Scope (6.1): non-streaming bodies only. Streaming (chunked / SSE
43
+ # pass-through) + the on_stream_chunk / on_stream_end hooks land in
44
+ # chunk 6.2. Outbound is HTTP/1.0 via Tep::Http, so the upstream
45
+ # must be reachable over plaintext http:// (https:// upstreams need
46
+ # the TLS-capable outbound client deferred to a later chunk).
47
+ module Tep
48
+ # Retry behaviour for the buffered forward path (chunk 6.5).
49
+ # Returned by Tep::Proxy#retry_policy(req); fresh instance per
50
+ # request so the policy can be derived from the request (e.g.
51
+ # idempotent verbs get more attempts, POSTs none).
52
+ #
53
+ # Backoff is integer-MILLISECONDS via Sock.sphttp_sleep_ms (a
54
+ # nanosleep-backed C helper). Sub-second pacing is the right
55
+ # default for HTTP retries -- whole-second backoffs throw away
56
+ # throughput on transient blips that resolve quickly. Two setters
57
+ # for the base backoff:
58
+ # * base_backoff_ms = 100 # int, direct ms.
59
+ # * base_backoff_secs = 0.1 # Float, converted to ms.
60
+ # Set whichever reads better at the call site; both feed the same
61
+ # ms-int through backoff_for. If both are set, the LAST write
62
+ # wins (whichever setter you called second).
63
+ #
64
+ # Default shape: max_attempts=1 (no retry, back-compat).
65
+ class Proxy
66
+ class RetryPolicy
67
+ attr_accessor :max_attempts, :base_backoff_ms, :backoff_multiplier
68
+ attr_accessor :retry_on_status
69
+
70
+ def initialize
71
+ @max_attempts = 1
72
+ @base_backoff_ms = 0
73
+ @backoff_multiplier = 2
74
+ # Default: transient upstream errors (gateway / unavailable /
75
+ # timeout). 502 also catches our own connect-failure mapping.
76
+ @retry_on_status = [502, 503, 504]
77
+ end
78
+
79
+ # Float-seconds convenience setter (e.g. 0.5 -> 500ms). Stores
80
+ # the value in @base_backoff_ms as an int so backoff_for / the
81
+ # sleep call stay int-only on the hot path.
82
+ def base_backoff_secs=(f)
83
+ @base_backoff_ms = (f * 1000.0).to_i
84
+ end
85
+
86
+ # Reader symmetric to the setter (Float seconds derived from
87
+ # the stored ms). Cheap; only the setter does the conversion
88
+ # in the common case.
89
+ def base_backoff_secs
90
+ @base_backoff_ms.to_f / 1000.0
91
+ end
92
+
93
+ # Milliseconds to sleep BEFORE attempt N (0-indexed). attempt=0
94
+ # is the first retry's pre-delay; attempt=1 the second's, etc.
95
+ # Returns 0 when base is 0 (test-friendly: no delay between
96
+ # retries by default).
97
+ def backoff_for(attempt)
98
+ if @base_backoff_ms <= 0
99
+ return 0
100
+ end
101
+ d = @base_backoff_ms
102
+ i = 0
103
+ while i < attempt
104
+ d = d * @backoff_multiplier
105
+ i += 1
106
+ end
107
+ d
108
+ end
109
+
110
+ # Should the proxy retry given the upstream response status?
111
+ # Connect/send failures (status == 0) always count as retriable.
112
+ def retriable?(status)
113
+ if status == 0
114
+ return true
115
+ end
116
+ i = 0
117
+ while i < @retry_on_status.length
118
+ if @retry_on_status[i] == status
119
+ return true
120
+ end
121
+ i += 1
122
+ end
123
+ false
124
+ end
125
+ end
126
+ end
127
+
128
+ class Proxy < Tep::Handler
129
+ attr_accessor :upstream, :timeout
130
+ # Body size caps (chunk 6.6). max_request_body_bytes bounds the
131
+ # inbound body the proxy will accept (over -> 413 Payload Too
132
+ # Large before any upstream call). max_response_body_bytes
133
+ # bounds the upstream response body the proxy will forward
134
+ # (over -> 502 with a proxy_error JSON). Defaults: 1 MiB request
135
+ # / 8 MiB response -- enough for typical JSON-API gateway use,
136
+ # small enough that a malicious / malfunctioning peer can't
137
+ # easily OOM the worker. Override in initialize() (or expose a
138
+ # block-DSL setter) for larger / smaller caps per deployment.
139
+ # Set either to 0 to disable that cap (not recommended).
140
+ attr_accessor :max_request_body_bytes, :max_response_body_bytes
141
+
142
+ def initialize(upstream)
143
+ @upstream = upstream
144
+ @timeout = 30
145
+ @max_request_body_bytes = 1 * 1024 * 1024
146
+ @max_response_body_bytes = 8 * 1024 * 1024
147
+ end
148
+
149
+ # ---- Overridable hooks (subclasses customise these) ----
150
+
151
+ # Per-request retry policy (chunk 6.5). Return a
152
+ # Tep::Proxy::RetryPolicy whose max_attempts > 1 to retry the
153
+ # buffered forward on transient upstream failure. Default: 1
154
+ # attempt (no retry). Override to enable retries; gate on the
155
+ # request shape so non-idempotent POSTs can skip retries while
156
+ # GETs use them:
157
+ #
158
+ # class ApiGateway < Tep::Proxy
159
+ # def retry_policy(req)
160
+ # p = Tep::Proxy::RetryPolicy.new
161
+ # p.max_attempts = 3
162
+ # p.base_backoff_ms = 100 # exponential: 100ms, 200ms, 400ms
163
+ # p
164
+ # end
165
+ # end
166
+ #
167
+ # Also available as a block-DSL hook (lowered by bin/tep).
168
+ # Streaming requests don't retry (the stream may have already
169
+ # written bytes to the client when failure occurs); only the
170
+ # buffered path consults the policy.
171
+ def retry_policy(req)
172
+ Tep::Proxy::RetryPolicy.new
173
+ end
174
+
175
+ # Per-request upstream selection (chunk 6.4). Return the URL of
176
+ # the upstream this request should be forwarded to. Default
177
+ # returns @upstream (the constructor's single-upstream value),
178
+ # preserving back-compat. Override to route by path / header /
179
+ # tenant / capability:
180
+ #
181
+ # class ApiGateway < Tep::Proxy
182
+ # def pick_upstream(req)
183
+ # if req.path.start_with?("/api/v1/")
184
+ # "http://upstream-v1.local:8080"
185
+ # else
186
+ # "http://upstream-v2.local:8080"
187
+ # end
188
+ # end
189
+ # end
190
+ #
191
+ # Also available as a block-DSL hook (lowered by bin/tep):
192
+ #
193
+ # gw = Tep::Proxy.new("http://default.local:8080")
194
+ # gw.pick_upstream do |req|
195
+ # ...
196
+ # end
197
+ #
198
+ # The returned URL is prefix-joined with the rewrite_path output,
199
+ # so it should NOT include the request path (just scheme://host:port
200
+ # + optional fixed prefix).
201
+ def pick_upstream(req)
202
+ @upstream
203
+ end
204
+
205
+ # Map the inbound request's path+query to the upstream
206
+ # path+query. Default: forward verbatim. Override to strip a
207
+ # mount prefix, pin a fixed upstream path, etc.
208
+ def rewrite_path(path)
209
+ path
210
+ end
211
+
212
+ # Runs after the request body is fully received, before
213
+ # forwarding. `ureq` is a mutable Tep::Proxy::UpstreamRequest
214
+ # (verb / path / headers / body) pre-filled from the inbound
215
+ # request with hop-by-hop headers stripped. Mutate it to tweak
216
+ # what the upstream sees. Return `true` to short-circuit -- the
217
+ # upstream call is skipped and `res` (which you set) is sent to
218
+ # the client. Return `false` to forward. Default: forward.
219
+ def before_forward(req, res, ureq)
220
+ false
221
+ end
222
+
223
+ # Runs after the upstream responds, before `res` is written to
224
+ # the client. `ures` is the Tep::Http::Response from upstream
225
+ # (status 0 + empty body on connect failure; an empty Response
226
+ # when a before_forward short-circuited). `res` is mutable and
227
+ # already carries the upstream status / headers / body. Use this
228
+ # to transform the final response or emit logs/metrics. Runs on
229
+ # the short-circuit path too, so audit logging sees rejected
230
+ # requests. Default: no-op.
231
+ def after_forward(req, ures, res)
232
+ 0
233
+ end
234
+
235
+ # Streaming opt-in predicate. Return true to forward this request
236
+ # over a held-open connection and pump the upstream response
237
+ # through on_stream_chunk / on_stream_end (chunk 6.2) instead of
238
+ # the buffered before/after path. Default: false (buffered).
239
+ #
240
+ # tep uses a request-side opt-in rather than sniffing the upstream
241
+ # response Content-Type because (a) it keeps the non-streaming path
242
+ # on the unchanged buffered Tep::Http.send_req (no manual-connect
243
+ # tax on the common case), and (b) it matches how streaming APIs
244
+ # actually signal intent -- an OpenAI client sets `"stream": true`
245
+ # in the request body, so the proxy knows before it connects.
246
+ # An LLM gateway typically overrides this as:
247
+ #
248
+ # def stream_request?(req)
249
+ # Tep::Json.get_bool(req.raw_body, "stream")
250
+ # end
251
+ def stream_request?(req)
252
+ false
253
+ end
254
+
255
+ # Per-chunk streaming hook (chunk 6.2). Called once per upstream
256
+ # body chunk -- one dechunked HTTP chunk for a chunked upstream,
257
+ # or one complete SSE event record ("...\n\n", including the
258
+ # trailing blank line) for a text/event-stream upstream. `out` is
259
+ # the Tep::Stream writer to the client; `stats` is a
260
+ # Tep::Proxy::StreamStats carried across the whole stream (the
261
+ # framework maintains stats.byte_count / stats.chunk_count;
262
+ # accumulate your own counters in stats.meta_bag["key"]). Default:
263
+ # pass the chunk through unchanged. Drop it by not calling
264
+ # out.write; transform by writing modified bytes; fan out by
265
+ # writing more than once.
266
+ #
267
+ # `chunk` is a Tep::Proxy::StreamChunk, NOT a bare String: read
268
+ # the bytes via `chunk.chunk_text`. The wrapper exists because spinel
269
+ # boxes a primitive String arg to poly when it flows through the
270
+ # poly-receiver dispatch into this overridable hook -- a bare
271
+ # String param would arrive poly and block String methods
272
+ # (chunk.include? etc.). An object param survives the dispatch as
273
+ # a typed pointer (same reason Tep::WebSocket passes `evt` with an
274
+ # evt.data accessor). See [[spinel-widening-dispatch]].
275
+ def on_stream_chunk(chunk, out, stats)
276
+ out.write(chunk.chunk_text)
277
+ 0
278
+ end
279
+
280
+ # End-of-stream finalizer (chunk 6.2, #81). Fires exactly once
281
+ # after the last chunk has been emitted and the upstream closed
282
+ # (cleanly or via error -- stats.errored distinguishes). `out` is
283
+ # still writable, so a finalizer can emit one last frame (e.g. a
284
+ # closing SSE event). `stats` is the same object on_stream_chunk
285
+ # accumulated into. Default: no-op.
286
+ def on_stream_end(req, out, stats)
287
+ 0
288
+ end
289
+
290
+ # ---- Tep::Handler interface ----
291
+
292
+ def handle(req, res)
293
+ # Request-body cap (chunk 6.6). Reject oversize bodies BEFORE
294
+ # any upstream call. 413 Payload Too Large with an OpenAI-shape
295
+ # error JSON for symmetry with the other handler error paths.
296
+ # max_request_body_bytes == 0 disables the cap.
297
+ if @max_request_body_bytes > 0 && req.raw_body.length > @max_request_body_bytes
298
+ res.set_status(413)
299
+ res.headers["Content-Type"] = "application/json"
300
+ err_body = "{\"error\":{" +
301
+ Tep::Json.encode_pair_str("message",
302
+ "request body exceeds proxy cap of " +
303
+ @max_request_body_bytes.to_s + " bytes") + "," +
304
+ Tep::Json.encode_pair_str("type", "payload_too_large") +
305
+ "}}"
306
+ res.set_body(err_body)
307
+ return err_body
308
+ end
309
+
310
+ ureq = Tep::Proxy::UpstreamRequest.new
311
+ ureq.verb = req.verb
312
+ ureq.path = rewrite_path(req.raw_path)
313
+ ureq.body = req.raw_body
314
+ # Copy inbound headers minus: hop-by-hop (RFC 7230), `host`
315
+ # (Tep::Http derives Host from the upstream URL -- forwarding
316
+ # the client's Host would emit a duplicate, which nginx-class
317
+ # upstreams 400), and `content-length` (Tep::Http computes its
318
+ # own from the body, same duplicate risk).
319
+ req.req_headers.each do |k, v|
320
+ lc = k.downcase
321
+ if !Tep::Proxy.hop_by_hop?(k) && lc != "host" && lc != "content-length"
322
+ ureq.headers[k] = v
323
+ end
324
+ end
325
+
326
+ short = before_forward(req, res, ureq)
327
+ if short
328
+ # Short-circuited: no upstream call. after_forward still
329
+ # runs (audit), with an empty upstream Response.
330
+ after_forward(req, Tep::Http::Response.new, res)
331
+ return res.body
332
+ end
333
+
334
+ # Streaming branch (chunk 6.2). When the handler opts the
335
+ # request into streaming, forward over a held-open connection
336
+ # and pump the upstream body through on_stream_chunk to the
337
+ # client, firing on_stream_end once at the end. Requires the
338
+ # scheduled server (cooperative io_wait), same constraint as
339
+ # WebSocket. after_forward is NOT run for streamed responses
340
+ # (it's the non-streaming analog; on_stream_end is its
341
+ # streaming counterpart).
342
+ if stream_request?(req)
343
+ return start_streaming_forward(req, res, ureq)
344
+ end
345
+
346
+ url = pick_upstream(req) + ureq.path
347
+ policy = retry_policy(req)
348
+ attempt = 0
349
+ ures = Tep::Http::Response.new
350
+ while attempt < policy.max_attempts
351
+ ures = Tep::Http.send_req(ureq.verb, url, ureq.body, ureq.headers)
352
+ # Success or non-retriable failure -- done.
353
+ if !policy.retriable?(ures.status)
354
+ break
355
+ end
356
+ attempt += 1
357
+ # Sleep before the NEXT attempt, only if there is one. Backoff
358
+ # is integer milliseconds via the nanosleep-backed C helper;
359
+ # default 0 (no delay) keeps tests fast.
360
+ if attempt < policy.max_attempts
361
+ backoff = policy.backoff_for(attempt - 1)
362
+ if backoff > 0
363
+ Sock.sphttp_sleep_ms(backoff)
364
+ end
365
+ end
366
+ end
367
+ # Expose retry count to observability filters via req.ivars.
368
+ req.ivars["proxy_retry_count"] = attempt.to_s
369
+
370
+ # Response-body cap (chunk 6.6). If the upstream returned more
371
+ # bytes than the proxy will forward, fail with 502 + a
372
+ # proxy_error JSON. The body has already been buffered by
373
+ # Tep::Http (no streaming on the buffered path), so this is a
374
+ # post-hoc reject -- worst case the worker briefly holds the
375
+ # large body then drops it. A future streaming-aware cap can
376
+ # bail mid-recv.
377
+ if @max_response_body_bytes > 0 && ures.body.length > @max_response_body_bytes
378
+ res.set_status(502)
379
+ res.headers["Content-Type"] = "application/json"
380
+ err_body = "{\"error\":{" +
381
+ Tep::Json.encode_pair_str("message",
382
+ "upstream response body exceeds proxy cap of " +
383
+ @max_response_body_bytes.to_s + " bytes") + "," +
384
+ Tep::Json.encode_pair_str("type", "upstream_body_too_large") +
385
+ "}}"
386
+ res.set_body(err_body)
387
+ return err_body
388
+ end
389
+
390
+ if ures.status > 0
391
+ res.set_status(ures.status)
392
+ else
393
+ # Connect / send failure, or non-http upstream scheme.
394
+ res.set_status(502)
395
+ end
396
+
397
+ # Copy upstream response headers, minus hop-by-hop AND
398
+ # content-length: the tep server writer computes its own
399
+ # Content-Length from res.body, so a copied one would
400
+ # duplicate the header.
401
+ ures.headers.each do |k, v|
402
+ if !Tep::Proxy.hop_by_hop?(k) && k.downcase != "content-length"
403
+ res.headers[k] = v
404
+ end
405
+ end
406
+
407
+ # Force the body assignment through a Response method (self is
408
+ # unambiguously Response there) -- a direct `res.body =` from
409
+ # this poly-dispatched handle() mis-codegens under spinel.
410
+ res.set_body(ures.body)
411
+
412
+ after_forward(req, ures, res)
413
+ res.body
414
+ end
415
+
416
+ # Streaming forward (chunk 6.2). Connects to the upstream, writes
417
+ # the request, reads just the response head, then hands the still-
418
+ # open fd to a ProxyStreamer via res.start_stream -- the server
419
+ # later drives streamer.pump, which recv-loops the upstream body
420
+ # and dispatches it through on_stream_chunk / on_stream_end.
421
+ #
422
+ # Returns "" (the streamed body goes out via the streamer, not the
423
+ # buffered res.body). On connect/scheme/head-read failure, sets a
424
+ # 502 and returns "" without starting a stream.
425
+ def start_streaming_forward(req, res, ureq)
426
+ url = pick_upstream(req) + ureq.path
427
+ parts = Tep::Url.split_url(url)
428
+ if parts["scheme"] != "http"
429
+ res.set_status(502)
430
+ return ""
431
+ end
432
+ host = parts["host"]
433
+ port = parts["port"].to_i
434
+ path = parts["path"]
435
+ if parts["query"].length > 0
436
+ path = path + "?" + parts["query"]
437
+ end
438
+
439
+ fd = Sock.sphttp_connect(host, port)
440
+ if fd < 0
441
+ res.set_status(502)
442
+ return ""
443
+ end
444
+ Sock.sphttp_set_nonblock(fd)
445
+
446
+ head = ureq.verb + " " + path + " HTTP/1.1\r\n" +
447
+ "Host: " + host + "\r\n" +
448
+ "Connection: close\r\n"
449
+ ureq.headers.each do |k, v|
450
+ head = head + k + ": " + v + "\r\n"
451
+ end
452
+ if ureq.body.length > 0
453
+ head = head + "Content-Length: " + ureq.body.length.to_s + "\r\n"
454
+ end
455
+ head = head + "\r\n"
456
+ if Sock.sphttp_write_str(fd, head) < 0
457
+ Sock.sphttp_close(fd)
458
+ res.set_status(502)
459
+ return ""
460
+ end
461
+ if ureq.body.length > 0
462
+ if Sock.sphttp_write_str(fd, ureq.body) < 0
463
+ Sock.sphttp_close(fd)
464
+ res.set_status(502)
465
+ return ""
466
+ end
467
+ end
468
+
469
+ uh = Tep::Proxy.read_upstream_head(fd)
470
+ if !uh.ok
471
+ Sock.sphttp_close(fd)
472
+ res.set_status(502)
473
+ return ""
474
+ end
475
+
476
+ res.set_status(uh.status)
477
+ # Copy upstream headers minus hop-by-hop, content-length (the
478
+ # client side is chunked -- no fixed length), and transfer-
479
+ # encoding (the server writer re-applies chunked itself).
480
+ uh.headers.each do |k, v|
481
+ lc = k.downcase
482
+ if !Tep::Proxy.hop_by_hop?(k) && lc != "content-length"
483
+ res.headers[k] = v
484
+ end
485
+ end
486
+
487
+ streamer = Tep::Proxy::ProxyStreamer.new
488
+ streamer.proxy = self
489
+ streamer.fd = fd
490
+ streamer.leftover = uh.leftover
491
+ streamer.is_chunked = uh.is_chunked
492
+ streamer.is_sse = uh.is_sse
493
+ streamer.req = req
494
+ res.start_stream(streamer)
495
+ ""
496
+ end
497
+
498
+ # The streaming pump, called from ProxyStreamer#pump as
499
+ # @proxy.run_stream(...). It lives here, on Tep::Proxy, rather
500
+ # than on the streamer so that on_stream_chunk / on_stream_end
501
+ # below are invoked as plain (implicit-self) calls. spinel
502
+ # resolves an implicit-self call inside a base-class method
503
+ # polymorphically -- it includes every subclass arm -- so a
504
+ # subclass's overrides are reached. A call through the streamer's
505
+ # @proxy slot (statically Tep::Proxy) would bind only the base
506
+ # hooks. Same reason rewrite_path / stream_request? (implicit-self
507
+ # from handle) dispatch to overrides but a slot call would not.
508
+ #
509
+ # Recv-loops the held-open upstream fd: dechunks (chunked
510
+ # upstream), splits SSE event records (text/event-stream), and
511
+ # dispatches each unit through dispatch_one. Fires on_stream_end
512
+ # once at EOF / timeout. Cooperative -- parks on io_wait between
513
+ # recvs, so requires Tep::Server::Scheduled.
514
+ def run_stream(out, fd, leftover, is_chunked, is_sse, req)
515
+ stats = Tep::Proxy::StreamStats.new
516
+ buf = leftover # raw (possibly chunked) bytes
517
+ body_buf = "" # dechunked bytes awaiting SSE split
518
+ done = false
519
+ while !done
520
+ if is_chunked
521
+ consumed = Tep::Llm.dechunk_consume(buf)
522
+ buf = Tep::Llm.dechunk_leftover(buf)
523
+ if consumed.length > 0
524
+ body_buf = body_buf + consumed
525
+ end
526
+ else
527
+ body_buf = body_buf + buf
528
+ buf = ""
529
+ end
530
+
531
+ if is_sse
532
+ body_buf = drain_events(out, stats, body_buf)
533
+ else
534
+ if body_buf.length > 0
535
+ dispatch_one(out, stats, body_buf)
536
+ body_buf = ""
537
+ end
538
+ end
539
+
540
+ ready = Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, 60)
541
+ if ready == 0
542
+ stats.errored = true
543
+ done = true
544
+ else
545
+ more = Sock.sphttp_recv_some(fd, 4096)
546
+ if more.length == 0
547
+ done = true # clean EOF
548
+ else
549
+ buf = buf + more
550
+ end
551
+ end
552
+ end
553
+
554
+ # Flush a trailing partial SSE event (some upstreams omit the
555
+ # final blank line before closing).
556
+ if is_sse && body_buf.length > 0
557
+ drain_events(out, stats, body_buf + "\n\n")
558
+ end
559
+
560
+ Sock.sphttp_close(fd)
561
+ on_stream_end(req, out, stats)
562
+ 0
563
+ end
564
+
565
+ # Split body_buf into complete "\n\n"-terminated SSE event records
566
+ # and dispatch each (the record includes the trailing blank line,
567
+ # per the doc's "data: {...}\n\n" contract). Returns the
568
+ # unconsumed tail.
569
+ def drain_events(out, stats, body_buf)
570
+ while true
571
+ sep = Tep.str_find(body_buf, "\n\n", 0)
572
+ if sep < 0
573
+ return body_buf
574
+ end
575
+ relay_buf = body_buf[0, sep + 2]
576
+ body_buf = body_buf[sep + 2, body_buf.length - sep - 2]
577
+ dispatch_one(out, stats, relay_buf)
578
+ end
579
+ body_buf
580
+ end
581
+
582
+ # Count one unit + dispatch it to on_stream_chunk via implicit
583
+ # self (polymorphic -- reaches subclass overrides). `relay_buf`
584
+ # is named distinctly from `chunk` / `frame`: spinel unifies
585
+ # param types by name file-wide, and both of those names carry
586
+ # foreign types (poly hook param / WS int-array) that would
587
+ # mis-type this String. See [[spinel-widening-dispatch]].
588
+ def dispatch_one(out, stats, relay_buf)
589
+ stats.byte_count = stats.byte_count + relay_buf.length
590
+ stats.chunk_count = stats.chunk_count + 1
591
+ on_stream_chunk(Tep::Proxy::StreamChunk.new(relay_buf), out, stats)
592
+ 0
593
+ end
594
+
595
+ # Read an upstream response head (status line + headers up to the
596
+ # blank line) cooperatively. Returns a Tep::Proxy::UpstreamHead
597
+ # carrying the parsed status, the per-name header bag, the
598
+ # chunked / SSE flags, the body bytes already read past the head
599
+ # (leftover -- handed to the streamer so no bytes are lost), and
600
+ # an ok flag (false on timeout / EOF before the head completed).
601
+ def self.read_upstream_head(fd)
602
+ out = Tep::Proxy::UpstreamHead.new
603
+ buf = ""
604
+ while true
605
+ ready = Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, 60)
606
+ if ready == 0
607
+ return out # timeout -- ok stays false
608
+ end
609
+ chunk = Sock.sphttp_recv_some(fd, 4096)
610
+ if chunk.length == 0
611
+ return out # EOF before head completed
612
+ end
613
+ buf = buf + chunk
614
+ eoh = Tep.str_find(buf, "\r\n\r\n", 0)
615
+ if eoh >= 0
616
+ header_blob = buf[0, eoh]
617
+ out.leftover = buf[eoh + 4, buf.length - eoh - 4]
618
+ out.fill_from(header_blob)
619
+ out.ok = true
620
+ return out
621
+ end
622
+ if buf.length > 65535
623
+ return out # head too large -- bail
624
+ end
625
+ end
626
+ out
627
+ end
628
+
629
+ # RFC 7230 §6.1 hop-by-hop headers: meaningful only for a single
630
+ # transport-level connection, never forwarded by a proxy. Lower-
631
+ # cased compare since both inbound and upstream header names are
632
+ # downcased by tep's parsers.
633
+ def self.hop_by_hop?(name)
634
+ lc = name.downcase
635
+ lc == "connection" ||
636
+ lc == "keep-alive" ||
637
+ lc == "transfer-encoding" ||
638
+ lc == "upgrade" ||
639
+ lc == "proxy-authorization" ||
640
+ lc == "proxy-authenticate" ||
641
+ lc == "te" ||
642
+ lc == "trailer"
643
+ end
644
+
645
+ # Mutable descriptor of the outbound request, handed to
646
+ # before_forward so hooks can rewrite verb / path / headers /
647
+ # body before the upstream call. `set_header` mirrors
648
+ # Tep::Http#set_header for muscle-memory parity.
649
+ class UpstreamRequest
650
+ attr_accessor :verb, :path, :headers, :body
651
+
652
+ def initialize
653
+ @verb = "GET"
654
+ @path = "/"
655
+ @headers = Tep.str_hash
656
+ @body = ""
657
+ end
658
+
659
+ def set_header(k, v)
660
+ @headers[k] = v
661
+ end
662
+ end
663
+
664
+ # Parsed upstream response head, produced by read_upstream_head.
665
+ # `fill_from` parses a header blob ("Status-Line\r\nH: v\r\n...",
666
+ # no trailing blank line) into status + the downcased-name header
667
+ # bag + the chunked / SSE transport flags.
668
+ class UpstreamHead
669
+ attr_accessor :status, :headers, :is_chunked, :is_sse, :leftover, :ok
670
+
671
+ def initialize
672
+ @status = 0
673
+ @headers = Tep.str_hash
674
+ @is_chunked = false
675
+ @is_sse = false
676
+ @leftover = ""
677
+ @ok = false
678
+ end
679
+
680
+ def fill_from(blob)
681
+ eol = Tep.str_find(blob, "\r\n", 0)
682
+ if eol < 0
683
+ return 0
684
+ end
685
+ line = blob[0, eol]
686
+ sp1 = Tep.str_find(line, " ", 0)
687
+ if sp1 >= 0
688
+ rest = line[sp1 + 1, line.length - sp1 - 1]
689
+ sp2 = Tep.str_find(rest, " ", 0)
690
+ if sp2 >= 0
691
+ @status = rest[0, sp2].to_i
692
+ else
693
+ @status = rest.to_i
694
+ end
695
+ end
696
+ # Header lines.
697
+ pos = eol + 2
698
+ while pos < blob.length
699
+ neol = Tep.str_find(blob, "\r\n", pos)
700
+ stop = neol
701
+ if stop < 0
702
+ stop = blob.length
703
+ end
704
+ line2 = blob[pos, stop - pos]
705
+ ci = Tep.str_find(line2, ":", 0)
706
+ if ci > 0
707
+ name = line2[0, ci].downcase
708
+ vpos = ci + 1
709
+ # skip one leading space
710
+ if vpos < line2.length && line2[vpos, 1] == " "
711
+ vpos += 1
712
+ end
713
+ val = line2[vpos, line2.length - vpos]
714
+ @headers[name] = val
715
+ if name == "transfer-encoding" && Tep.str_find(val.downcase, "chunked", 0) >= 0
716
+ @is_chunked = true
717
+ end
718
+ if name == "content-type" && val.downcase.start_with?("text/event-stream")
719
+ @is_sse = true
720
+ end
721
+ end
722
+ if neol < 0
723
+ return 0
724
+ end
725
+ pos = neol + 2
726
+ end
727
+ 0
728
+ end
729
+ end
730
+
731
+ # One unit handed to on_stream_chunk: a dechunked HTTP chunk or a
732
+ # complete SSE event record. Read the bytes via `chunk_text`.
733
+ #
734
+ # Two spinel constraints shape this:
735
+ # * The hook param is poly-boxed (it flows through the poly
736
+ # on_stream_chunk dispatch), so a bare String would arrive poly
737
+ # and block String methods. Wrapping in an object lets the hook
738
+ # recover a concrete String via the accessor.
739
+ # * The accessor is named `chunk_text`, not `text`: a poly value's
740
+ # method call resolves by name across ALL classes, and `text`
741
+ # collides with Tep::WebSocket::Driver#text (returns int). A
742
+ # name with exactly one definition resolves cleanly to a String.
743
+ # See [[spinel-widening-dispatch]].
744
+ class StreamChunk
745
+ attr_accessor :chunk_text
746
+
747
+ def initialize(chunk_text)
748
+ @chunk_text = chunk_text
749
+ end
750
+ end
751
+
752
+ # Per-stream telemetry, carried across every on_stream_chunk call
753
+ # and into on_stream_end. The framework maintains byte_count /
754
+ # chunk_count (input bytes dispatched, chunk/event count) and
755
+ # errored (set when the upstream stalls past the io_wait timeout
756
+ # or closes mid-frame). Accumulate custom counters (tokens, etc.)
757
+ # in the `meta_bag` bag -- a typed object rather than the doc's
758
+ # stats[:sym] hash because spinel hashes are single-value-typed
759
+ # (same reason Tep::Llm::StreamState is a class).
760
+ #
761
+ # Field names are deliberately collision-free: spinel unifies
762
+ # field/accessor types by NAME file-wide. `bytes` collides with
763
+ # String#bytes (int-array) and `data` collides with WebSocket
764
+ # Event#data (String) -- either would mis-type these fields. Hence
765
+ # byte_count / chunk_count / meta_bag. See [[spinel-widening-dispatch]].
766
+ class StreamStats
767
+ attr_accessor :byte_count, :chunk_count, :errored, :meta_bag
768
+
769
+ def initialize
770
+ @byte_count = 0
771
+ @chunk_count = 0
772
+ @errored = false
773
+ @meta_bag = Tep.str_hash
774
+ end
775
+ end
776
+
777
+ # Thin Streamer shim. Holds the held-open upstream fd + state and
778
+ # delegates the actual pump to @proxy.run_stream. The work lives
779
+ # on Tep::Proxy (not here) so the per-chunk / end hooks dispatch
780
+ # through `self` -- a polymorphic-self call inside a base-Proxy
781
+ # method reaches subclass overrides, whereas a call through this
782
+ # object's @proxy slot (statically base-typed) would only ever hit
783
+ # the base hooks. See run_stream's comment + [[spinel-widening-dispatch]].
784
+ class ProxyStreamer < Tep::Streamer
785
+ attr_accessor :proxy, :fd, :leftover, :is_chunked, :is_sse, :req
786
+
787
+ def initialize
788
+ @proxy = Tep::Proxy.new("")
789
+ @fd = -1
790
+ @leftover = ""
791
+ @is_chunked = false
792
+ @is_sse = false
793
+ @req = Tep::Request.new
794
+ end
795
+
796
+ def pump(out)
797
+ @proxy.run_stream(out, @fd, @leftover, @is_chunked, @is_sse, @req)
798
+ end
799
+ end
800
+ end
801
+ end