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/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
|