tep 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# agentic chat -- the four-battery demo
|
|
2
|
+
|
|
3
|
+
A small chat room exercising every battery in tep's agentic
|
|
4
|
+
story: identity, broadcast, presence, server-rendered HTML
|
|
5
|
+
pushed over WebSocket.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌───────────────────────────────────────────────────────────┐
|
|
9
|
+
│ agentic chat you are user:uXsC2g │
|
|
10
|
+
├──────────────────────────────────────┬────────────────────┤
|
|
11
|
+
│ user:42 hi │ HUMANS (2) │
|
|
12
|
+
│ user:99 hello │ • user:42 │
|
|
13
|
+
│ agent:summarizer-bot/user:42 │ • user:99 │
|
|
14
|
+
│ i'm here -- watching for │ │
|
|
15
|
+
│ things to summarize. │ AGENTS (1) │
|
|
16
|
+
│ │ • user:42 │
|
|
17
|
+
│ [ type message ] [send] │ via summari… │
|
|
18
|
+
│ │ busy: │
|
|
19
|
+
│ [+ summarizer] │ summarizing │
|
|
20
|
+
└──────────────────────────────────────┴────────────────────┘
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Run
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bin/tep build examples/agentic_chat/app.rb -o /tmp/agentic_chat
|
|
27
|
+
/tmp/agentic_chat -p 4567
|
|
28
|
+
# open http://127.0.0.1:4567/ in two browsers
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every chat message + agent spawn arrives in the other tab in
|
|
32
|
+
<100ms via a WebSocket push. No polling, no full-page reload --
|
|
33
|
+
the server re-renders the `#messages` + `#presence` regions on
|
|
34
|
+
every change and broadcasts both to all subscribed sockets.
|
|
35
|
+
Click **+ summarizer** to invite a synthetic agent into the
|
|
36
|
+
room -- it appears in the presence sidebar with `kind=agent_for`,
|
|
37
|
+
shares the inviter's `principal_id`, and posts an arrival
|
|
38
|
+
message.
|
|
39
|
+
|
|
40
|
+
## What's wired
|
|
41
|
+
|
|
42
|
+
| Battery | What it does here |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `Tep::Auth` (`Tep::AuthSessionCookie`) | Every visitor's first request auto-creates an `identity` cookie. Subsequent requests land with `req.identity` populated; `req.identity.subject` is rendered in the header + drives presence rows. |
|
|
45
|
+
| `Tep::AuthOAuth2`-style delegation | The `+ summarizer` route constructs a `Tep::AgentDelegation` + `Tep::Identity` with `kind=:agent_for, origin=:oauth_grant` -- same shape an external bot would receive over the real OAuth flow. |
|
|
46
|
+
| `Tep::Broadcast` | Every `POST /chat/send` and `POST /agent/add` calls `publish_room`, which builds the updated `#messages` + `#presence` HTML once and publishes a single TEXT frame to every WS subscriber via `Tep::Broadcast.publish`. |
|
|
47
|
+
| `Tep::Presence` | Humans tracked on every `GET /chat` (one row per principal_id). Agents tracked on `+ summarizer` with `status_state=:busy, status_note="summarizing the room"`. Sidebar renders both groups with the agentic kind + status. |
|
|
48
|
+
| `Tep::LiveView` | `CHAT.render` + `render_presence` are the live-view content targets. The WS push delivers the new HTML; client-side JS does `outerHTML = ...` on both regions in place. The same render functions run for the initial page load and for every push -- no special template-vs-push divergence. |
|
|
49
|
+
|
|
50
|
+
## Wire shape
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
client server
|
|
54
|
+
| GET /chat |
|
|
55
|
+
|<------------------------------| full page (HTML + JS)
|
|
56
|
+
| |
|
|
57
|
+
| GET /chat/ws (Upgrade) |
|
|
58
|
+
|------------------------------>| websocket "/chat/ws" do |ws|
|
|
59
|
+
|<------------------------------| on_open -> subscribe_ws
|
|
60
|
+
| 101 Switching |
|
|
61
|
+
| |
|
|
62
|
+
| POST /chat/send {body:"hi"} |
|
|
63
|
+
|------------------------------>| CHAT.add + publish_room
|
|
64
|
+
|<------------------------------| 204 No Content
|
|
65
|
+
| |
|
|
66
|
+
|<------------------------------| WS TEXT frame:
|
|
67
|
+
| "<<TEP>><div id='messages'> | "<<TEP>>{messages}
|
|
68
|
+
| ...<<TEP>><aside id=' | <<TEP>>{presence}"
|
|
69
|
+
| presence'>..." |
|
|
70
|
+
| |
|
|
71
|
+
| JS: e.data.split('<<TEP>>') |
|
|
72
|
+
| -> swap each outerHTML |
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`<<TEP>>` is a sentinel separator (ASCII, unlikely to appear in
|
|
76
|
+
user text). The server packs both regions into one frame so
|
|
77
|
+
subscribers see them update atomically.
|
|
78
|
+
|
|
79
|
+
## Code size
|
|
80
|
+
|
|
81
|
+
| File | LOC |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `app.rb` | ~270 (incl. CSS + JS inline) |
|
|
84
|
+
| `README.md` | this file |
|
|
85
|
+
|
|
86
|
+
No CSS framework, no JS bundler, no DB.
|
|
87
|
+
|
|
88
|
+
## What this demo does NOT show
|
|
89
|
+
|
|
90
|
+
- **Cross-worker presence/broadcast.** Single worker. Both
|
|
91
|
+
`Tep::Broadcast.enable_pg_backend` and
|
|
92
|
+
`Tep::Presence.enable_pg_mirror` would make this multi-
|
|
93
|
+
worker; left out for demo simplicity.
|
|
94
|
+
- **Real bot in a separate process.** The "+ summarizer"
|
|
95
|
+
button adds a synthetic presence row + posts one message
|
|
96
|
+
in the same process. A real bot would open its own WS with
|
|
97
|
+
the JWT minted by `Tep::AuthOAuth2.exchange_code` and chat
|
|
98
|
+
alongside the humans -- the framework surface is identical.
|
|
99
|
+
- **Per-subscriber rendering.** Every WS subscriber gets the
|
|
100
|
+
same HTML payload. Personalized views (e.g. mention
|
|
101
|
+
highlights for the current user) would need either per-fd
|
|
102
|
+
render filters or a client-side template that consumes a
|
|
103
|
+
structured payload instead of HTML.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Agentic chat -- the four-battery demo, now WS-driven.
|
|
2
|
+
#
|
|
3
|
+
# Exercises all four batteries in tep's agentic story:
|
|
4
|
+
# * Tep::Auth (session-cookie identity)
|
|
5
|
+
# * Tep::Broadcast (in-process pub-sub over WS)
|
|
6
|
+
# * Tep::Presence (who's here, agent-aware, with structured status)
|
|
7
|
+
# * Tep::LiveView (server-rendered HTML pushed over WS on change)
|
|
8
|
+
#
|
|
9
|
+
# Run:
|
|
10
|
+
# bin/tep build examples/agentic_chat/app.rb -o /tmp/agentic_chat
|
|
11
|
+
# /tmp/agentic_chat -p 4567
|
|
12
|
+
# Open http://127.0.0.1:4567/ in two browsers; each message + agent
|
|
13
|
+
# spawn lands in <100ms via WS push, no polling, no reloads.
|
|
14
|
+
require 'sinatra'
|
|
15
|
+
|
|
16
|
+
set :scheduler, :scheduled
|
|
17
|
+
|
|
18
|
+
Tep.session_secret = "demo-only-do-not-use-in-prod-XXXXXXXXXX"
|
|
19
|
+
Tep::Auth.install!
|
|
20
|
+
|
|
21
|
+
CHAT_TOPIC = "agentic_chat:room"
|
|
22
|
+
|
|
23
|
+
# Sentinel for the WS payload's two-region split. ASCII-only,
|
|
24
|
+
# unlikely to appear in any user-typed message.
|
|
25
|
+
TEP_SEP = "<<TEP>>"
|
|
26
|
+
|
|
27
|
+
# ---- shared chat state ----
|
|
28
|
+
|
|
29
|
+
class ChatRoom
|
|
30
|
+
def initialize
|
|
31
|
+
@msg_subjects = [""]
|
|
32
|
+
@msg_subjects.delete_at(0)
|
|
33
|
+
@msg_bodies = [""]
|
|
34
|
+
@msg_bodies.delete_at(0)
|
|
35
|
+
@msg_kinds = [""]
|
|
36
|
+
@msg_kinds.delete_at(0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add(subject, body, kind)
|
|
40
|
+
@msg_subjects.push(subject)
|
|
41
|
+
@msg_bodies.push(body)
|
|
42
|
+
@msg_kinds.push(kind)
|
|
43
|
+
while @msg_subjects.length > 50
|
|
44
|
+
@msg_subjects.delete_at(0)
|
|
45
|
+
@msg_bodies.delete_at(0)
|
|
46
|
+
@msg_kinds.delete_at(0)
|
|
47
|
+
end
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render
|
|
52
|
+
out = "<div id='messages'>"
|
|
53
|
+
if @msg_subjects.length == 0
|
|
54
|
+
out = out + "<div class='msg empty'>" +
|
|
55
|
+
"<em>no messages yet. say hi using the form below.</em></div>"
|
|
56
|
+
end
|
|
57
|
+
i = 0
|
|
58
|
+
while i < @msg_subjects.length
|
|
59
|
+
out = out + "<div class='msg " + @msg_kinds[i] + "'>" +
|
|
60
|
+
"<span class='who'>" + Tep.h(@msg_subjects[i]) + "</span>" +
|
|
61
|
+
"<span class='body'>" + Tep.h(@msg_bodies[i]) + "</span>" +
|
|
62
|
+
"</div>"
|
|
63
|
+
i += 1
|
|
64
|
+
end
|
|
65
|
+
out + "</div>"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
CHAT = ChatRoom.new
|
|
70
|
+
|
|
71
|
+
# ---- synthetic agent state ----
|
|
72
|
+
|
|
73
|
+
AGENT_FD_COUNTER = [-9000]
|
|
74
|
+
|
|
75
|
+
def next_agent_fd
|
|
76
|
+
AGENT_FD_COUNTER[0] = AGENT_FD_COUNTER[0] - 1
|
|
77
|
+
AGENT_FD_COUNTER[0]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def spawn_agent(principal_id)
|
|
81
|
+
fd = next_agent_fd
|
|
82
|
+
agent_req = Tep::Request.new
|
|
83
|
+
delegation = Tep::AgentDelegation.new(
|
|
84
|
+
"summarizer-bot", Time.now.to_i,
|
|
85
|
+
Time.now.to_i + 3600, :oauth_grant)
|
|
86
|
+
agent_req.identity = Tep::Identity.new(
|
|
87
|
+
principal_id, delegation, [:read, :post_summary])
|
|
88
|
+
Tep::Presence.track(agent_req, CHAT_TOPIC, fd)
|
|
89
|
+
Tep::Presence.set_status(
|
|
90
|
+
CHAT_TOPIC, fd, :busy, "summarizing the room",
|
|
91
|
+
Time.now.to_i + 60)
|
|
92
|
+
CHAT.add(
|
|
93
|
+
"agent:summarizer-bot/" + principal_id,
|
|
94
|
+
"i'm here -- watching for things to summarize.", "agent")
|
|
95
|
+
fd
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ---- presence sidebar ----
|
|
99
|
+
|
|
100
|
+
def render_presence
|
|
101
|
+
entries = Tep::Presence.list(CHAT_TOPIC)
|
|
102
|
+
humans = ""
|
|
103
|
+
agents = ""
|
|
104
|
+
hcount = 0
|
|
105
|
+
acount = 0
|
|
106
|
+
i = 0
|
|
107
|
+
while i < entries.length
|
|
108
|
+
e = entries[i]
|
|
109
|
+
row = "<div class='pres-row " + e.kind.to_s + " " +
|
|
110
|
+
e.status_state.to_s + "'>" +
|
|
111
|
+
"<span class='dot'></span>" +
|
|
112
|
+
"<span class='who'>" + Tep.h(e.principal_id) + "</span>"
|
|
113
|
+
if e.kind == :agent_for
|
|
114
|
+
row = row + "<span class='agent-of'>via " +
|
|
115
|
+
Tep.h(e.agent_id) + "</span>"
|
|
116
|
+
end
|
|
117
|
+
if e.status_state != :available
|
|
118
|
+
row = row + "<div class='note'>" + e.status_state.to_s +
|
|
119
|
+
": " + Tep.h(e.status_note) + "</div>"
|
|
120
|
+
end
|
|
121
|
+
row = row + "</div>"
|
|
122
|
+
if e.kind == :human
|
|
123
|
+
humans = humans + row
|
|
124
|
+
hcount += 1
|
|
125
|
+
else
|
|
126
|
+
agents = agents + row
|
|
127
|
+
acount += 1
|
|
128
|
+
end
|
|
129
|
+
i += 1
|
|
130
|
+
end
|
|
131
|
+
"<aside id='presence'>" +
|
|
132
|
+
"<h3>humans (" + hcount.to_s + ")</h3>" +
|
|
133
|
+
"<div class='group humans'>" + humans + "</div>" +
|
|
134
|
+
"<h3>agents (" + acount.to_s + ")</h3>" +
|
|
135
|
+
"<div class='group agents'>" + agents + "</div>" +
|
|
136
|
+
"</aside>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Broadcast both regions in one frame. Subscribers' JS splits on
|
|
140
|
+
# TEP_SEP and swaps each outerHTML in place -- no full reload.
|
|
141
|
+
def publish_room
|
|
142
|
+
payload = TEP_SEP + CHAT.render + TEP_SEP + render_presence
|
|
143
|
+
Tep::Broadcast.publish(CHAT_TOPIC, payload)
|
|
144
|
+
0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ---- routes ----
|
|
148
|
+
|
|
149
|
+
before do
|
|
150
|
+
if req.session.get("identity_sub").length == 0
|
|
151
|
+
pid = Crypto.sp_crypto_random_b64url(4)
|
|
152
|
+
ident = Tep::Identity.new(pid, nil, [:read, :write])
|
|
153
|
+
Tep::AuthSessionCookie.set(req, ident, 0)
|
|
154
|
+
req.identity = ident
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
get '/' do
|
|
159
|
+
res.set_status(302)
|
|
160
|
+
res.headers["Location"] = "/chat"
|
|
161
|
+
""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
get '/chat' do
|
|
165
|
+
res.headers["Content-Type"] = "text/html; charset=utf-8"
|
|
166
|
+
# Presence tracking happens at WS upgrade (see the websocket
|
|
167
|
+
# block below) -- gives a true "currently connected" view with
|
|
168
|
+
# auto-cleanup on close instead of synthesizing a per-principal
|
|
169
|
+
# fd at page-render time.
|
|
170
|
+
user_subject = req.identity.subject
|
|
171
|
+
"<!doctype html><html><head>" +
|
|
172
|
+
"<meta charset='utf-8'>" +
|
|
173
|
+
"<title>agentic chat (tep)</title>" +
|
|
174
|
+
"<link rel='stylesheet' href='/agentic_chat/style.css'>" +
|
|
175
|
+
"</head><body>" +
|
|
176
|
+
"<header>" +
|
|
177
|
+
"<span class='title'>agentic chat</span>" +
|
|
178
|
+
"<span class='user'>you are <code>" +
|
|
179
|
+
Tep.h(user_subject) + "</code></span>" +
|
|
180
|
+
"</header>" +
|
|
181
|
+
"<main>" +
|
|
182
|
+
"<section id='room'>" +
|
|
183
|
+
CHAT.render +
|
|
184
|
+
"<form id='compose' onsubmit='return tepSend(event)' method='POST' action='/chat/send'>" +
|
|
185
|
+
"<input name='body' placeholder='message...' autocomplete='off' autofocus>" +
|
|
186
|
+
"<button type='submit'>send</button>" +
|
|
187
|
+
"</form>" +
|
|
188
|
+
"<form id='agent-form' onsubmit='return tepSend(event)' method='POST' action='/agent/add'>" +
|
|
189
|
+
"<button type='submit'>+ summarizer</button>" +
|
|
190
|
+
"</form>" +
|
|
191
|
+
"</section>" +
|
|
192
|
+
render_presence +
|
|
193
|
+
"</main>" +
|
|
194
|
+
"<script>" + agentic_chat_js + "</script>" +
|
|
195
|
+
"</body></html>"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
post '/chat/send' do
|
|
199
|
+
body = req.params["body"]
|
|
200
|
+
if body.length > 0
|
|
201
|
+
CHAT.add(req.identity.subject, body, "human")
|
|
202
|
+
publish_room
|
|
203
|
+
end
|
|
204
|
+
res.set_status(204)
|
|
205
|
+
""
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
post '/agent/add' do
|
|
209
|
+
pid = req.identity.principal_id + ""
|
|
210
|
+
spawn_agent(pid)
|
|
211
|
+
publish_room
|
|
212
|
+
res.set_status(204)
|
|
213
|
+
""
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
get '/agentic_chat/style.css' do
|
|
217
|
+
res.headers["Content-Type"] = "text/css"
|
|
218
|
+
agentic_chat_css
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
websocket "/chat/ws" do |ws|
|
|
222
|
+
on_open do |evt|
|
|
223
|
+
# req is the upgrade-time request, so req.identity is the
|
|
224
|
+
# session-cookie identity of whoever opened the socket.
|
|
225
|
+
Tep::Broadcast.subscribe_ws(CHAT_TOPIC, ws.fd)
|
|
226
|
+
Tep::Presence.track(req, CHAT_TOPIC, ws.fd)
|
|
227
|
+
publish_room
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# No explicit on_close needed -- Tep::WebSocket::Connection auto-
|
|
231
|
+
# drops every Broadcast subscription AND Presence row keyed on
|
|
232
|
+
# the closed fd. Apps that want to do additional work on close
|
|
233
|
+
# (logging, custom cleanup) still register on_close blocks
|
|
234
|
+
# normally.
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ---- inline assets ----
|
|
238
|
+
|
|
239
|
+
def agentic_chat_js
|
|
240
|
+
"var __tepSep = '" + TEP_SEP + "';" +
|
|
241
|
+
"var __tepWs = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/chat/ws');" +
|
|
242
|
+
"__tepWs.onmessage = function(e){" +
|
|
243
|
+
"var parts = e.data.split(__tepSep);" +
|
|
244
|
+
"if (parts[1]) {" +
|
|
245
|
+
"var m = document.getElementById('messages');" +
|
|
246
|
+
"if (m) m.outerHTML = parts[1];" +
|
|
247
|
+
"}" +
|
|
248
|
+
"if (parts[2]) {" +
|
|
249
|
+
"var p = document.getElementById('presence');" +
|
|
250
|
+
"if (p) p.outerHTML = parts[2];" +
|
|
251
|
+
"}" +
|
|
252
|
+
"};" +
|
|
253
|
+
"function tepSend(ev){" +
|
|
254
|
+
"ev.preventDefault();" +
|
|
255
|
+
"var f = ev.target;" +
|
|
256
|
+
"fetch(f.action, {method:f.method, body:new FormData(f)});" +
|
|
257
|
+
"var inp = f.querySelector('input[name=body]');" +
|
|
258
|
+
"if (inp) inp.value='';" +
|
|
259
|
+
"return false;" +
|
|
260
|
+
"}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def agentic_chat_css
|
|
264
|
+
"*{box-sizing:border-box}" +
|
|
265
|
+
"body{margin:0;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;" +
|
|
266
|
+
"background:#f6f7f9;color:#1a1a1a}" +
|
|
267
|
+
"header{display:flex;justify-content:space-between;align-items:center;" +
|
|
268
|
+
"padding:.6rem 1rem;background:#1a1a1a;color:#f6f7f9}" +
|
|
269
|
+
"header .title{font-weight:600}" +
|
|
270
|
+
"header .user code{background:#2a2a2a;padding:.15em .4em;border-radius:3px;" +
|
|
271
|
+
"font-size:.85em;color:#cde}" +
|
|
272
|
+
"main{display:grid;grid-template-columns:1fr 260px;height:calc(100vh - 44px)}" +
|
|
273
|
+
"#room{display:flex;flex-direction:column;background:#fff}" +
|
|
274
|
+
"#messages{flex:1;overflow-y:auto;padding:1rem;display:flex;" +
|
|
275
|
+
"flex-direction:column;gap:.4rem}" +
|
|
276
|
+
".msg{display:flex;gap:.6rem;padding:.3rem .5rem;border-radius:4px}" +
|
|
277
|
+
".msg.empty{justify-content:center;color:#888}" +
|
|
278
|
+
".msg .who{color:#666;font-size:.85em;min-width:11rem;text-align:right;" +
|
|
279
|
+
"flex-shrink:0;font-family:ui-monospace,monospace}" +
|
|
280
|
+
".msg .body{flex:1}" +
|
|
281
|
+
".msg.agent{background:#fef8eb}" +
|
|
282
|
+
".msg.agent .who{color:#a07412}" +
|
|
283
|
+
"#compose{display:flex;gap:.5rem;padding:.7rem;border-top:1px solid #e8e9eb;" +
|
|
284
|
+
"background:#fafbfc}" +
|
|
285
|
+
"#compose input{flex:1;padding:.5rem .7rem;border:1px solid #d0d3d8;" +
|
|
286
|
+
"border-radius:4px;font:inherit;background:#fff}" +
|
|
287
|
+
"#compose input:focus{outline:none;border-color:#3b82f6}" +
|
|
288
|
+
"#compose button{padding:.5rem .9rem;border:1px solid #d0d3d8;background:#1a1a1a;" +
|
|
289
|
+
"color:#fff;border-color:#1a1a1a;border-radius:4px;font:inherit;cursor:pointer}" +
|
|
290
|
+
"#compose button:hover{background:#000}" +
|
|
291
|
+
"#agent-form{padding:.5rem .7rem;background:#fafbfc;border-top:1px solid #e8e9eb}" +
|
|
292
|
+
"#agent-form button{padding:.4rem .8rem;border:1px solid #d0d3d8;background:#fff;" +
|
|
293
|
+
"border-radius:4px;font:inherit;cursor:pointer}" +
|
|
294
|
+
"#agent-form button:hover{background:#f0f1f3}" +
|
|
295
|
+
"#presence{background:#fafbfc;border-left:1px solid #e8e9eb;padding:1rem;" +
|
|
296
|
+
"overflow-y:auto}" +
|
|
297
|
+
"#presence h3{margin:0 0 .5rem;font-size:.7em;text-transform:uppercase;" +
|
|
298
|
+
"letter-spacing:.08em;color:#888;font-weight:600}" +
|
|
299
|
+
"#presence .group{margin-bottom:1.5rem;display:flex;flex-direction:column;gap:.4rem}" +
|
|
300
|
+
".pres-row{display:flex;align-items:center;gap:.5rem;font-size:.9em;flex-wrap:wrap}" +
|
|
301
|
+
".pres-row .dot{width:.6rem;height:.6rem;border-radius:50%;background:#22c55e;" +
|
|
302
|
+
"flex-shrink:0}" +
|
|
303
|
+
".pres-row.busy .dot{background:#eab308}" +
|
|
304
|
+
".pres-row.blocked .dot{background:#ef4444}" +
|
|
305
|
+
".pres-row .who{font-family:ui-monospace,monospace;font-size:.85em;color:#1a1a1a}" +
|
|
306
|
+
".pres-row.agent_for .who{color:#a07412}" +
|
|
307
|
+
".pres-row .agent-of{font-size:.75em;color:#888;font-family:ui-monospace,monospace}" +
|
|
308
|
+
".pres-row .note{width:100%;padding-left:1.1rem;font-size:.8em;color:#888;" +
|
|
309
|
+
"font-style:italic}"
|
|
310
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# api_gateway — a capability-gated API gateway on `Tep::Proxy`
|
|
2
|
+
|
|
3
|
+
The non-streaming sibling of [`examples/llm_gateway`](../llm_gateway).
|
|
4
|
+
Fronts an upstream HTTP API on the buffered (6.1) proxy path and adds
|
|
5
|
+
the three jobs of a gateway in ~30 lines:
|
|
6
|
+
|
|
7
|
+
1. **Authorization** — `before` short-circuits with `403` unless
|
|
8
|
+
`req.identity.may?(:call_upstream)`. The upstream is never hit for
|
|
9
|
+
a denied request.
|
|
10
|
+
2. **Credential swap** — for an authorized request, strip the
|
|
11
|
+
client's key and attach the server-side one (`ureq.set_header`).
|
|
12
|
+
3. **Observability** — `after` logs the call and stamps
|
|
13
|
+
`X-Proxy-Status` / `X-Proxy-Upstream` on the response — **including
|
|
14
|
+
for rejected requests** (`after` runs on the short-circuit path
|
|
15
|
+
too, so the audit log sees denials).
|
|
16
|
+
|
|
17
|
+
Uses the **block-form proxy DSL** (`api.before do … end`), which
|
|
18
|
+
`bin/tep` lowers to a `Tep::Proxy` subclass.
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
UPSTREAM=https://api.example.com \
|
|
24
|
+
UPSTREAM_KEY=secret \
|
|
25
|
+
GATEWAY_KEY=let-me-in \
|
|
26
|
+
bin/tep build examples/api_gateway/app.rb -o /tmp/ag && /tmp/ag -p 4567
|
|
27
|
+
|
|
28
|
+
curl -i localhost:4567/v1/data # 403, missing capability
|
|
29
|
+
curl -i localhost:4567/v1/data -H 'x-api-key: let-me-in' # forwarded with upstream key
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Both responses carry `X-Proxy-Status` / `X-Proxy-Upstream`.
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- The `before do … end` filter granting `:call_upstream` on the
|
|
37
|
+
gateway key is a **stand-in** for the Auth battery — a real app
|
|
38
|
+
installs `Tep::Auth` (bearer JWT / session / OAuth2), which
|
|
39
|
+
populates `req.identity` the same way, so the `may?` gate is
|
|
40
|
+
unchanged.
|
|
41
|
+
- One `Tep::Proxy` instance serves many routes; mount whatever paths
|
|
42
|
+
you proxy.
|
|
43
|
+
- Non-streaming (buffered) — for SSE/streaming upstreams + per-request
|
|
44
|
+
telemetry, see `examples/llm_gateway`.
|
|
45
|
+
|
|
46
|
+
## See also
|
|
47
|
+
|
|
48
|
+
- [`docs/PROXY-BATTERY.md`](../../docs/PROXY-BATTERY.md) — the battery.
|
|
49
|
+
- [`examples/llm_gateway`](../llm_gateway) — the streaming half of 6.3.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# examples/api_gateway -- a capability-gated API gateway on Tep::Proxy.
|
|
2
|
+
#
|
|
3
|
+
# The non-streaming sibling of examples/llm_gateway. Fronts an upstream
|
|
4
|
+
# HTTP API and adds the three things a gateway exists for, on the
|
|
5
|
+
# buffered (6.1) proxy path:
|
|
6
|
+
#
|
|
7
|
+
# 1. Authorization -- gate on req.identity.may?(:call_upstream);
|
|
8
|
+
# reject (403) before the upstream is ever hit.
|
|
9
|
+
# 2. Credential swap -- strip the client's key, attach the server's.
|
|
10
|
+
# 3. Observability -- log + stamp X-Proxy-* headers on the way out,
|
|
11
|
+
# including for rejected requests (audit).
|
|
12
|
+
#
|
|
13
|
+
# Run:
|
|
14
|
+
# UPSTREAM=https://api.example.com UPSTREAM_KEY=secret \
|
|
15
|
+
# GATEWAY_KEY=let-me-in \
|
|
16
|
+
# bin/tep build examples/api_gateway/app.rb -o /tmp/ag && /tmp/ag -p 4567
|
|
17
|
+
#
|
|
18
|
+
# curl -i localhost:4567/v1/data # 403 (no key)
|
|
19
|
+
# curl -i localhost:4567/v1/data -H 'x-api-key: let-me-in' # forwarded
|
|
20
|
+
require 'sinatra'
|
|
21
|
+
|
|
22
|
+
UPSTREAM = ENV["UPSTREAM"] || "http://127.0.0.1:8080"
|
|
23
|
+
UPSTREAM_KEY = ENV["UPSTREAM_KEY"] || ""
|
|
24
|
+
GATEWAY_KEY = ENV["GATEWAY_KEY"] || "let-me-in"
|
|
25
|
+
LOGGER = Tep::Logger.new # stderr; .to_file(path) to redirect
|
|
26
|
+
|
|
27
|
+
# Stand-in for the Auth battery: grant :call_upstream to callers
|
|
28
|
+
# presenting the gateway key. A real app installs Tep::Auth (bearer
|
|
29
|
+
# JWT / session / OAuth2), which populates req.identity the same way.
|
|
30
|
+
before do
|
|
31
|
+
if req.req_headers["x-api-key"] == GATEWAY_KEY
|
|
32
|
+
req.identity = Tep::Identity.new("client:demo", nil, [:call_upstream])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
api = Tep::Proxy.new(UPSTREAM)
|
|
37
|
+
|
|
38
|
+
# Capability gate + credential swap. Returning true short-circuits --
|
|
39
|
+
# the upstream is never called; res (set here) goes straight back.
|
|
40
|
+
api.before do |req, res, ureq|
|
|
41
|
+
if !req.identity.may?(:call_upstream)
|
|
42
|
+
res.set_status(403)
|
|
43
|
+
res.set_body("{\"error\":\"missing capability: call_upstream\"}")
|
|
44
|
+
true
|
|
45
|
+
else
|
|
46
|
+
if UPSTREAM_KEY.length > 0
|
|
47
|
+
ureq.set_header("Authorization", "Bearer " + UPSTREAM_KEY)
|
|
48
|
+
end
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Observability. Runs on the forwarded path AND the short-circuit path
|
|
54
|
+
# (ures.status is 0 when before_forward rejected) -- so the audit log
|
|
55
|
+
# sees denied requests too.
|
|
56
|
+
api.after do |req, ures, res|
|
|
57
|
+
res.headers["X-Proxy-Status"] = ures.status.to_s
|
|
58
|
+
res.headers["X-Proxy-Upstream"] = UPSTREAM
|
|
59
|
+
LOGGER.info("[api_gateway] " + req.verb + " " + req.raw_path +
|
|
60
|
+
" -> " + ures.status.to_s)
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Mount whatever paths you proxy (one instance serves many).
|
|
65
|
+
Tep.get "/v1/data", api
|
|
66
|
+
Tep.post "/v1/submit", api
|