@gnldev/chat-adapter 0.1.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.
- package/LICENSE +201 -0
- package/README.md +181 -0
- package/dist/approve.d.ts +32 -0
- package/dist/approve.js +46 -0
- package/dist/approve.js.map +1 -0
- package/dist/chat-route.d.ts +107 -0
- package/dist/chat-route.js +280 -0
- package/dist/chat-route.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +22 -0
- package/dist/messages.js +116 -0
- package/dist/messages.js.map +1 -0
- package/dist/sentinel-mask.d.ts +34 -0
- package/dist/sentinel-mask.js +39 -0
- package/dist/sentinel-mask.js.map +1 -0
- package/dist/ui-stream.d.ts +43 -0
- package/dist/ui-stream.js +85 -0
- package/dist/ui-stream.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Karaca Yılmaz (https://gnl.dev)
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# @gnldev/chat-adapter
|
|
2
|
+
|
|
3
|
+
Compatibility with the Vercel AI SDK's UI layer: run a durable agent on the server, render it with
|
|
4
|
+
`useChat` on the client.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
> Install: `pnpm add @gnldev/chat-adapter` — or use it from a [repo clone](https://github.com/Karaca7/gnldev): `pnpm install && pnpm -r build`.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i @gnldev/chat-adapter
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Chat route
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { createChatRoute } from '@gnldev/chat-adapter';
|
|
18
|
+
|
|
19
|
+
app.route('/api', createChatRoute({ gnl }));
|
|
20
|
+
// POST /api/agents/:name/chat — useChat({ api: '/api/agents/pay/chat' }) works unchanged.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The route streams back in the AI SDK's UI-message format, so an existing `useChat` frontend works
|
|
24
|
+
unchanged — while the run behind it is journaled, replayable, and its side effects are
|
|
25
|
+
[at-most-once](../durable/README.md#what-never-charged-twice-actually-means).
|
|
26
|
+
|
|
27
|
+
You normally do NOT need `resolveRunId`: the default derivation `${body.id}:${lastMessage.id}` gives
|
|
28
|
+
one durable run PER TURN (a network retry of the same turn replays; a new turn runs fresh).
|
|
29
|
+
Anti-pattern to avoid: `resolveRunId: (_c, body) => body.id` — useChat's `body.id` is stable for the
|
|
30
|
+
WHOLE conversation, so every later turn would replay turn 1 from the journal forever. What that
|
|
31
|
+
derived string *is* — a name the engine hashes into an id, or the id itself — depends on whether the
|
|
32
|
+
route can name the user; see [the idempotency contract](#the-idempotency-contract).
|
|
33
|
+
|
|
34
|
+
## Who is this request for? (`identity` / `resolveResourceId`)
|
|
35
|
+
|
|
36
|
+
This route ships with **no auth of its own** — deliberately, and the same posture as `@gnldev/agui`.
|
|
37
|
+
What that leaves you responsible for is one thing: naming the end user each run acts for.
|
|
38
|
+
|
|
39
|
+
**Why it is not optional.** GNL has no end-user identity of its own. An end user is a *subject* a
|
|
40
|
+
trusted application names, not a principal GNL authenticates. The engine treats a few reserved
|
|
41
|
+
context keys as "the server established this" — and an early version of this route forwarded
|
|
42
|
+
`body.context` verbatim, so the reserved key arrived from whoever sent the request. Measured against
|
|
43
|
+
a running app: a plain POST carrying `{"context":{"__gnl_resourceId":"VICTIM"}}` produced a run owned
|
|
44
|
+
by that name, and the ownership stamp followed it.
|
|
45
|
+
|
|
46
|
+
The route now **always seals** the context, so that specific forgery is closed whether or not you
|
|
47
|
+
pass a resolver. What a resolver decides is the other half: whether the run has an owner at all.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createChatRoute } from '@gnldev/chat-adapter';
|
|
51
|
+
|
|
52
|
+
const chat = createChatRoute({ gnl }, {
|
|
53
|
+
// ONE hook for both fields. The same signature @gnldev/agui's route takes.
|
|
54
|
+
identity: (req) => {
|
|
55
|
+
const session = db.sessions.get(req.headers.get('cookie')); // YOUR session store
|
|
56
|
+
return session ? { resourceId: session.userId, threadId: session.conversationId } : undefined;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`identity` receives the **web `Request`**, not the Hono context, so a host bridging this route from
|
|
62
|
+
Express or Fastify can use it. It is called once per request and may return `undefined`.
|
|
63
|
+
|
|
64
|
+
**Read it from something the server trusts** — a session cookie, a verified JWT,
|
|
65
|
+
`principalOf(req)?.id` — and **never from the request body**. A body-supplied subject is the caller
|
|
66
|
+
naming whoever they like, which is the hole the context seal exists to close.
|
|
67
|
+
|
|
68
|
+
**If you give none.** Nothing is asserted and nothing is forged: runs are born **ownerless**. That is
|
|
69
|
+
safe against impersonation and weak in the other direction — an ownership gate with no owner to
|
|
70
|
+
compare against refuses nobody, so the protection reads as present and is not. Memory also has
|
|
71
|
+
nothing to scope on, so per-user recall and `listThreads` have no subject to key by. In
|
|
72
|
+
`NODE_ENV=production` the route says so once, at construction, with a `console.warn` — it never
|
|
73
|
+
throws, because a deployment whose boundary genuinely lives in front of this route is not broken.
|
|
74
|
+
|
|
75
|
+
**Precedence**, field by field:
|
|
76
|
+
|
|
77
|
+
| Field | Order |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `resourceId` | `resolveResourceId(c, body)` → `identity(req).resourceId` → *(none)* |
|
|
80
|
+
| `threadId` | `resolveThreadId(c, body)` → `identity(req).threadId` → `body.threadId` → `body.id` → the runId |
|
|
81
|
+
|
|
82
|
+
`resolveResourceId` / `resolveThreadId` still win: they are the existing contract, and a newer
|
|
83
|
+
convenience must not quietly take a working deployment's answer away. `identity` outranks the body,
|
|
84
|
+
because it is server-derived and the body is not.
|
|
85
|
+
|
|
86
|
+
**Honest bound.** A resolver reading an *unauthenticated* request asserts a subject nobody verified.
|
|
87
|
+
Put auth in front of this route — or compose `@gnldev/server`'s `createRestApi` auth middleware
|
|
88
|
+
around it — or the subject is only as trustworthy as the caller.
|
|
89
|
+
|
|
90
|
+
## The idempotency contract
|
|
91
|
+
|
|
92
|
+
**Two regimes, decided by whether the route can name a subject.** The per-turn key
|
|
93
|
+
(`${body.id}:${lastMessage.id}`, or an `Idempotency-Key` header when a gateway sends one) is this
|
|
94
|
+
route's name for *the work this turn is*. When `identity` / `resolveResourceId` gives that turn an
|
|
95
|
+
owner, the key is promoted to a **`workKey`**: the engine derives the run's id from it
|
|
96
|
+
(`run1_<digest>`) and the string you sent stops being a journal key. When there is nobody to name —
|
|
97
|
+
the anonymous quickstart, no auth, no session store — the same string stays the raw runId it has
|
|
98
|
+
always been, byte for byte. Deriving an id from a name needs an *address* to make it unique within,
|
|
99
|
+
and a route with no subject has none; refusing those requests would replace a working first five
|
|
100
|
+
minutes with an error message. Your retry contract is identical in both: the same message ids
|
|
101
|
+
produce the same key, and the same key lands on the same run.
|
|
102
|
+
|
|
103
|
+
One migration note, because the regime is decided by the resolver: **adopting this version — or
|
|
104
|
+
wiring `identity` into a deployment that ran without it — changes which id an in-flight turn's retry
|
|
105
|
+
lands on** (raw key on the old pods, `run1_` on the new). During that window a retried turn can run
|
|
106
|
+
once more. Close the window by draining in-flight requests over the deploy rather than rolling
|
|
107
|
+
through it.
|
|
108
|
+
|
|
109
|
+
- **`X-Gnl-Run-Id` on every response** (success and error): the opaque id of the run this call
|
|
110
|
+
landed on — a **correlation handle** for logs, traces and Studio. It is **not your retry key**: to
|
|
111
|
+
retry, send the same turn again (the same conversation id and the same last-message id). Explicit
|
|
112
|
+
`body.runId` and `resolveRunId` still win, and both stay raw — they name an *id*, and a host
|
|
113
|
+
holding one has already decided the addressing. The `Idempotency-Key` header is read AFTER them (a
|
|
114
|
+
gateway-stamped header must not silently override an application decision).
|
|
115
|
+
- **A per-run lock is ON by default** (`lock: { ttlMs: 300_000 }`): two concurrent requests with the
|
|
116
|
+
same runId (double-click, two tabs, a retry racing the original) no longer both execute — the
|
|
117
|
+
loser gets a typed `409 { code: 'run_busy', resumable: true }` + `Retry-After`, and retrying the
|
|
118
|
+
same runId lands on the journal replay. `lock: false` restores the old behavior. Scope note: this
|
|
119
|
+
serializes CONCURRENT duplicates; serial retries were already deduped by the runId derivation.
|
|
120
|
+
- **`X-Gnl-Idempotency-Status` on success responses**: `new` on a fresh run, `replay` when this
|
|
121
|
+
runId had prior journaled input (a retry/resume landing on journal state) — an observability
|
|
122
|
+
contract for client-side reconciliation, not a byte-identity guarantee.
|
|
123
|
+
- **Typed errors instead of a flat 400**: `run_thread_mismatch` / `run_input_mismatch` /
|
|
124
|
+
`run_actor_mismatch` / `run_swept` → 409 without `resumable` (fix the id, not the request);
|
|
125
|
+
`run_busy` → 409 + `Retry-After`; `retry_limit_exceeded` → 422; upstream provider failures →
|
|
126
|
+
429/502/504. Malformed `messages` stays a 400 — with the header contract intact.
|
|
127
|
+
|
|
128
|
+
## Approvals round-trip (`approve` / `approvalPayload`)
|
|
129
|
+
|
|
130
|
+
When a tool suspends (a guard's `require-approval`, the `confirm` field, a duplicate/semantic
|
|
131
|
+
question), the stream carries a `data-gnl-interrupt` chunk whose entries include the **suspended
|
|
132
|
+
run's `runId`** — the approval's ADDRESS. A naive client that just re-POSTs its messages derives a
|
|
133
|
+
FRESH runId from the new last-message id: the approval lands on a brand-new run and the suspended
|
|
134
|
+
one waits forever. Use the helpers:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { approvalPayload, approve } from '@gnldev/chat-adapter';
|
|
138
|
+
|
|
139
|
+
// useChat-style: merge the payload into YOUR next request body (same conversation id, same messages)
|
|
140
|
+
sendMessage(undefined, { body: approvalPayload(interrupt) }); // { runId, approvals: { [toolCallId]: true } }
|
|
141
|
+
|
|
142
|
+
// headless/manual: a convenience fetch that re-POSTs and returns the streaming Response
|
|
143
|
+
await approve('/api/agents/pay/chat', { interrupt, chatId, messages });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`approvalPayload` THROWS on an interrupt without `runId` rather than silently targeting a fresh run.
|
|
147
|
+
Product rules, stated plainly: approval is a BUTTON — the route reads decisions only from
|
|
148
|
+
`body.approvals`; a user typing "yes, do it" starts a fresh turn, it approves nothing. And
|
|
149
|
+
"regenerate" with the same runId gets the journal REPLAY (the safe default); genuinely re-running a
|
|
150
|
+
side effect goes through the approval ladder, never a silent re-execution.
|
|
151
|
+
|
|
152
|
+
## Rebuilding history from the journal
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { toUIMessages } from '@gnldev/chat-adapter';
|
|
156
|
+
|
|
157
|
+
const messages = toUIMessages(await journal.list(runId));
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The journal is the source of truth, so a reconnecting client can rebuild the conversation without
|
|
161
|
+
the server holding session state.
|
|
162
|
+
|
|
163
|
+
## Exports
|
|
164
|
+
|
|
165
|
+
| Export | What it is |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `createChatRoute` | A handler that runs an agent and streams UI messages |
|
|
168
|
+
| `toUIMessageStream` / `toUIMessageStreamResponse` | Converts a durable stream into the UI-message wire format, masking the internal sentinels |
|
|
169
|
+
| `toUIMessages` | Journal records → `UIMessage[]` for history reconstruction |
|
|
170
|
+
| `approvalPayload` / `approve` | The approval round-trip helpers — land the decision on the SUSPENDED run (see above) |
|
|
171
|
+
| `maskSentinelOutput` | The shared sentinel-masking primitive (used by both live streaming and history) |
|
|
172
|
+
|
|
173
|
+
## A note on history
|
|
174
|
+
|
|
175
|
+
`useChat` posts the entire client-side history on every turn. When the agent has memory and a
|
|
176
|
+
`threadId`, the server owns the history instead — the client's copy is a view, not the record. That
|
|
177
|
+
contract is enforced in the core, not here.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The minimum an approval needs from a `data-gnl-interrupt` entry (see GnlInterruptData). */
|
|
2
|
+
export interface ApprovableInterrupt {
|
|
3
|
+
toolCallId: string;
|
|
4
|
+
/** The suspended run's id — REQUIRED to route the approval (older servers that don't stamp it
|
|
5
|
+
* Can't be addressed reliably; see `approvalPayload`'s throw). */
|
|
6
|
+
runId?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request
|
|
10
|
+
* shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route
|
|
11
|
+
* guarantees these fields win over derivation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function approvalPayload(interrupt: ApprovableInterrupt, approved?: boolean): {
|
|
14
|
+
runId: string;
|
|
15
|
+
approvals: Record<string, boolean>;
|
|
16
|
+
};
|
|
17
|
+
export interface ApproveOptions {
|
|
18
|
+
interrupt: ApprovableInterrupt;
|
|
19
|
+
/** The SAME UIMessage history the original turn sent — the journal's frozen input wins on replay,
|
|
20
|
+
* But the route still converts these (and the thread guard needs the same conversation). */
|
|
21
|
+
messages: unknown[];
|
|
22
|
+
/** UseChat's stable conversation id (`body.id`) — REQUIRED when the original turn had one: threadId
|
|
23
|
+
* Defaults to it, and a mismatched thread is refused with 409 run_thread_mismatch. */
|
|
24
|
+
chatId?: string;
|
|
25
|
+
threadId?: string;
|
|
26
|
+
approved?: boolean;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
/** Override for non-global-fetch environments/tests. */
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
/** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */
|
|
32
|
+
export declare function approve(endpoint: string, opts: ApproveOptions): Promise<Response>;
|
package/dist/approve.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// FAZ-2 — the approval round-trip, fixed at the CLIENT edge. The failure this closes (heyet kararı
|
|
2
|
+
// 1.8): a suspended tool emits a `data-gnl-interrupt`; the user clicks "approve"; a naive client
|
|
3
|
+
// re-POSTs its messages and the route derives a FRESH runId from the new last-message id — the
|
|
4
|
+
// approval lands on a brand-new run, the suspended run stays suspended forever (retention keeps
|
|
5
|
+
// suspended runs deliberately → unbounded accumulation), and the tool never fires. The fix is
|
|
6
|
+
// addressing: an approval must travel with the SUSPENDED run's id (`interrupt.runId`, stamped by
|
|
7
|
+
// ui-stream.ts) and the SAME conversation identity (`body.id`/threadId — a different thread 409s on
|
|
8
|
+
// the frozen-input guard).
|
|
9
|
+
//
|
|
10
|
+
// Two surfaces, by integration style:
|
|
11
|
+
// `approvalPayload(interrupt)` — pure: the body fields to merge into YOUR request (useChat's
|
|
12
|
+
// `sendMessage(..., { body })`, a custom fetch, a proxy). No I/O, no deps.
|
|
13
|
+
// `approve(endpoint, {...})` — convenience fetch for headless/manual flows: re-POSTs the SAME
|
|
14
|
+
// Messages + the payload and returns the raw streaming Response (the caller renders/drains it).
|
|
15
|
+
//
|
|
16
|
+
// Approval is a BUTTON, not prose: the route reads decisions ONLY from `body.approvals`
|
|
17
|
+
// (chat-route.ts) — a user typing "yes, do it" into the chat input does NOT approve anything, it
|
|
18
|
+
// starts a fresh turn. Likewise "regenerate" on a completed turn re-POSTs the same runId and gets the
|
|
19
|
+
// journal REPLAY (safe default); genuinely re-running a side effect goes through this approval
|
|
20
|
+
// ladder, never through a silent re-execution.
|
|
21
|
+
/**
|
|
22
|
+
* The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request
|
|
23
|
+
* shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route
|
|
24
|
+
* guarantees these fields win over derivation.
|
|
25
|
+
*/
|
|
26
|
+
export function approvalPayload(interrupt, approved = true) {
|
|
27
|
+
if (!interrupt.runId) {
|
|
28
|
+
throw new Error('@gnldev/chat-adapter: approvalPayload needs interrupt.runId — this server did not stamp runId onto the interrupt (upgrade the route/ui-stream), and approving by a DERIVED id would target a fresh run instead of the suspended one.');
|
|
29
|
+
}
|
|
30
|
+
return { runId: interrupt.runId, approvals: { [interrupt.toolCallId]: approved } };
|
|
31
|
+
}
|
|
32
|
+
/** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */
|
|
33
|
+
export async function approve(endpoint, opts) {
|
|
34
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
35
|
+
return doFetch(endpoint, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
...(opts.chatId !== undefined ? { id: opts.chatId } : {}),
|
|
40
|
+
...(opts.threadId !== undefined ? { threadId: opts.threadId } : {}),
|
|
41
|
+
messages: opts.messages,
|
|
42
|
+
...approvalPayload(opts.interrupt, opts.approved ?? true),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=approve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approve.js","sourceRoot":"","sources":["../src/approve.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,8FAA8F;AAC9F,iGAAiG;AACjG,oGAAoG;AACpG,2BAA2B;AAC3B,EAAE;AACF,sCAAsC;AACtC,+FAA+F;AAC/F,8EAA8E;AAC9E,kGAAkG;AAClG,mGAAmG;AACnG,EAAE;AACF,wFAAwF;AACxF,iGAAiG;AACjG,sGAAsG;AACtG,+FAA+F;AAC/F,+CAA+C;AAU/C;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAC7B,SAA8B,EAC9B,QAAQ,GAAG,IAAI;IAEf,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CACb,sOAAsO,CACvO,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,EAAE,CAAC;AACrF,CAAC;AAiBD,yFAAyF;AACzF,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,QAAgB,EAAE,IAAoB;IAClE,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC;IACxC,OAAO,OAAO,CAAC,QAAQ,EAAE;QACvB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE;QACxE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACzD,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnE,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,GAAG,eAAe,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC;SAC1D,CAAC;KACH,CAAC,CAAC;AACL,CAAC","sourcesContent":["// FAZ-2 — the approval round-trip, fixed at the CLIENT edge. The failure this closes (heyet kararı\n// 1.8): a suspended tool emits a `data-gnl-interrupt`; the user clicks \"approve\"; a naive client\n// re-POSTs its messages and the route derives a FRESH runId from the new last-message id — the\n// approval lands on a brand-new run, the suspended run stays suspended forever (retention keeps\n// suspended runs deliberately → unbounded accumulation), and the tool never fires. The fix is\n// addressing: an approval must travel with the SUSPENDED run's id (`interrupt.runId`, stamped by\n// ui-stream.ts) and the SAME conversation identity (`body.id`/threadId — a different thread 409s on\n// the frozen-input guard).\n//\n// Two surfaces, by integration style:\n// `approvalPayload(interrupt)` — pure: the body fields to merge into YOUR request (useChat's\n// `sendMessage(..., { body })`, a custom fetch, a proxy). No I/O, no deps.\n// `approve(endpoint, {...})` — convenience fetch for headless/manual flows: re-POSTs the SAME\n// Messages + the payload and returns the raw streaming Response (the caller renders/drains it).\n//\n// Approval is a BUTTON, not prose: the route reads decisions ONLY from `body.approvals`\n// (chat-route.ts) — a user typing \"yes, do it\" into the chat input does NOT approve anything, it\n// starts a fresh turn. Likewise \"regenerate\" on a completed turn re-POSTs the same runId and gets the\n// journal REPLAY (safe default); genuinely re-running a side effect goes through this approval\n// ladder, never through a silent re-execution.\n\n/** The minimum an approval needs from a `data-gnl-interrupt` entry (see GnlInterruptData). */\nexport interface ApprovableInterrupt {\n toolCallId: string;\n /** The suspended run's id — REQUIRED to route the approval (older servers that don't stamp it\n * Can't be addressed reliably; see `approvalPayload`'s throw). */\n runId?: string;\n}\n\n/**\n * The body fields that turn a chat POST into an approval of `interrupt`. Merge into the SAME request\n * shape you normally send (same conversation `id`, same `messages`) — runId precedence in the route\n * guarantees these fields win over derivation.\n */\nexport function approvalPayload(\n interrupt: ApprovableInterrupt,\n approved = true,\n): { runId: string; approvals: Record<string, boolean> } {\n if (!interrupt.runId) {\n throw new Error(\n '@gnldev/chat-adapter: approvalPayload needs interrupt.runId — this server did not stamp runId onto the interrupt (upgrade the route/ui-stream), and approving by a DERIVED id would target a fresh run instead of the suspended one.',\n );\n }\n return { runId: interrupt.runId, approvals: { [interrupt.toolCallId]: approved } };\n}\n\nexport interface ApproveOptions {\n interrupt: ApprovableInterrupt;\n /** The SAME UIMessage history the original turn sent — the journal's frozen input wins on replay,\n * But the route still converts these (and the thread guard needs the same conversation). */\n messages: unknown[];\n /** UseChat's stable conversation id (`body.id`) — REQUIRED when the original turn had one: threadId\n * Defaults to it, and a mismatched thread is refused with 409 run_thread_mismatch. */\n chatId?: string;\n threadId?: string;\n approved?: boolean;\n headers?: Record<string, string>;\n /** Override for non-global-fetch environments/tests. */\n fetchImpl?: typeof fetch;\n}\n\n/** Re-POSTs the approval to the chat endpoint and returns the raw streaming Response. */\nexport async function approve(endpoint: string, opts: ApproveOptions): Promise<Response> {\n const doFetch = opts.fetchImpl ?? fetch;\n return doFetch(endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) },\n body: JSON.stringify({\n ...(opts.chatId !== undefined ? { id: opts.chatId } : {}),\n ...(opts.threadId !== undefined ? { threadId: opts.threadId } : {}),\n messages: opts.messages,\n ...approvalPayload(opts.interrupt, opts.approved ?? true),\n }),\n });\n}\n"]}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { createGnl } from '@gnldev/durable';
|
|
4
|
+
import type { CreateGnlConfig, GnlIdentity } from '@gnldev/durable';
|
|
5
|
+
export interface CreateChatRouteOptions {
|
|
6
|
+
/** Resolve the durable `runId` (exactly-once key for THIS request) from the request/body. */
|
|
7
|
+
resolveRunId?: (c: Context, body: any) => string | undefined;
|
|
8
|
+
/** Resolve the conversation `threadId` (memory continuity across requests) from the request/body. */
|
|
9
|
+
resolveThreadId?: (c: Context, body: any) => string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* WHO this request acts for — the end user's `resourceId`, read from something the SERVER trusts
|
|
12
|
+
* (a session cookie, a verified JWT, `principalOf(c.req.raw)?.id`) and NEVER from the body.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT EXISTS. GNL has no end-user identity: an end user is a SUBJECT that a trusted application
|
|
15
|
+
* names, not a principal GNL authenticates (`resolveResourceId` in @gnldev/server states this).
|
|
16
|
+
* The engine treats the reserved context keys as "the server established this", and this route used
|
|
17
|
+
* to forward `body.context` verbatim — so the reserved key arrived from whoever sent the request.
|
|
18
|
+
* MEASURED against a running app: a plain POST carrying
|
|
19
|
+
* `{"context":{"__gnl_resourceId":"KURBAN-KULLANICI"}}` produced a run owned by that name, and the
|
|
20
|
+
* ownership stamp followed it. The seal that exists precisely to prevent this (registry.ts's
|
|
21
|
+
* `sealRequestContext`, whose own comment names the attack) was never applied on this path.
|
|
22
|
+
*
|
|
23
|
+
* The route now ALWAYS seals. With no resolver the seal carries no identity, which STRIPS the
|
|
24
|
+
* reserved keys: a forged subject cannot get through, and none is asserted either.
|
|
25
|
+
*
|
|
26
|
+
* HONEST BOUND — it decides what every ownership guarantee downstream is worth: this route ships
|
|
27
|
+
* with NO auth of its own (see the createChatRoute JSDoc). A resolver reading an unauthenticated
|
|
28
|
+
* request asserts a subject nobody verified. Put auth in front of this route, or the subject is
|
|
29
|
+
* only as trustworthy as the caller.
|
|
30
|
+
*/
|
|
31
|
+
resolveResourceId?: (c: Context, body: any) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* WHO and WHICH CONVERSATION, in one hook — the same signature @gnldev/agui's route takes, so the
|
|
34
|
+
* function a host writes once works on both.
|
|
35
|
+
*
|
|
36
|
+
* It exists because the two hooks above are two hooks. A host wiring identity had to write
|
|
37
|
+
* `resolveResourceId` AND `resolveThreadId`, and on the sibling adapter the second one had a
|
|
38
|
+
* different shape and, for a while, no effect at all (see agui's route.ts note on the dead
|
|
39
|
+
* `threadId` line). Answering "who is this request for" twice is how one of the answers ends up
|
|
40
|
+
* missing.
|
|
41
|
+
*
|
|
42
|
+
* Takes the web `Request`, not the Hono `Context` — the precedent is @gnldev/server's
|
|
43
|
+
* `OrgOptions.resolve`, and the reason is the same: a host binding this route from Express or
|
|
44
|
+
* Fastify has a Request and no Context.
|
|
45
|
+
*
|
|
46
|
+
* PRECEDENCE: `resolveResourceId` / `resolveThreadId` still WIN, field by field. They are the
|
|
47
|
+
* existing contract and a new convenience must not silently take a working deployment's answer
|
|
48
|
+
* away. This fills whichever of the two the host did not supply.
|
|
49
|
+
*
|
|
50
|
+
* Called ONCE per request, for the reason already written below about `subject`: a resolver that
|
|
51
|
+
* reads the request may answer differently the second time.
|
|
52
|
+
*
|
|
53
|
+
* HONEST BOUND — unchanged from `resolveResourceId`: this route ships with no auth of its own. A
|
|
54
|
+
* resolver reading an unauthenticated request asserts a subject nobody verified.
|
|
55
|
+
*/
|
|
56
|
+
identity?: GnlIdentity;
|
|
57
|
+
/**
|
|
58
|
+
* FAZ-2 — per-run concurrency lock, ON by default (`{ ttlMs: 300_000 }`). Two CONCURRENT requests
|
|
59
|
+
* with the same runId (double-click, two tabs, a retry racing the original) used to BOTH execute;
|
|
60
|
+
* Now the loser gets the typed `409 run_busy` (+ Retry-After) and the winner's journal replay
|
|
61
|
+
* answers the retry. `lock: false` restores the old behavior. The streamed lock SELF-RENEWS on a
|
|
62
|
+
* ttl/2 heartbeat (engine parity with run()), so ttlMs is the crash-takeover window — the generous
|
|
63
|
+
* 5-minute default simply keeps takeover conservative.
|
|
64
|
+
*/
|
|
65
|
+
lock?: {
|
|
66
|
+
ttlMs?: number;
|
|
67
|
+
} | false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Produces a single-endpoint Hono router from a createGnl config (or an already-built `gnl` instance)
|
|
71
|
+
* that a `useChat({ api: '.../agents/:name/chat' })` client can talk to:
|
|
72
|
+
* POST /agents/:name/chat { id?, messages: UIMessage[], runId?, threadId?, approvals? } → UI message stream
|
|
73
|
+
* Deliberately kept small — SAME posture as @gnldev/agui's `createAguiRoute`: NO auth/org/budget gates (if
|
|
74
|
+
* needed, wrap this route, or compose @gnldev/server's createRestApi's auth middleware around it — see README).
|
|
75
|
+
*
|
|
76
|
+
* IDENTITY precedence: `body.runId` > `opts.resolveRunId(...)` > the TURN'S NAME
|
|
77
|
+
* (`Idempotency-Key` header, else DERIVED `${body.id}:${lastMessage.id}`) > a generated id. The
|
|
78
|
+
* derivation is the load-bearing default: `body.id` is useChat's STABLE per-conversation id — using
|
|
79
|
+
* it ALONE would make every later turn replay turn 1 from the journal (withDurableModel replays
|
|
80
|
+
* `runId:model:0` and the model never runs again). Combining it with the LAST message's id (useChat
|
|
81
|
+
* stamps a fresh id per message) gives one exactly-once run PER TURN, and makes a network retry of
|
|
82
|
+
* the SAME turn land on the same run (deduped replay — free idempotency) while a NEW turn runs fresh.
|
|
83
|
+
* `threadId` defaults to `body.id` (the conversation), NOT the per-turn key — conversation memory
|
|
84
|
+
* must span turns.
|
|
85
|
+
*
|
|
86
|
+
* TWO REGIMES, decided by whether the route can name a SUBJECT (package #5, §7). With one, the
|
|
87
|
+
* turn's name is a `workKey` and the engine derives an opaque `run1_` id from it; without one it
|
|
88
|
+
* stays the raw id it has always been, because deriving needs an address (§6) and this route's
|
|
89
|
+
* quickstart names nobody. The retry contract is identical either way: the same message ids produce
|
|
90
|
+
* the same key, and the same key lands on the same run.
|
|
91
|
+
*
|
|
92
|
+
* SCOPE of that idempotency (FAZ-2): serial retries dedupe via journal replay, and CONCURRENT
|
|
93
|
+
* duplicates are now serialized by the default per-run lock (`CreateChatRouteOptions.lock`) — the
|
|
94
|
+
* loser gets the typed 409 `run_busy` + Retry-After instead of a second execution. The effective
|
|
95
|
+
* RunId is echoed on every response as `X-Gnl-Run-Id` and stamped onto interrupt chunks — a
|
|
96
|
+
* CORRELATION handle, not the retry key: to retry, send the same turn again. One exception, agui's
|
|
97
|
+
* too: a pre-run identity refusal (unknown agent, unaddressable scope) carries no header, because
|
|
98
|
+
* the header names a run and none exists yet on that path.
|
|
99
|
+
*
|
|
100
|
+
* Body `workKey` is deliberately NOT read here (the REST, agui and workflow doors all take it):
|
|
101
|
+
* this route speaks the useChat wire format, where the turn IS the work — the per-turn key above,
|
|
102
|
+
* or a gateway's `Idempotency-Key`, is what gets promoted. A field the UI library never sends would
|
|
103
|
+
* be dead surface with a live failure mode (a stale key silently pinning every turn to one run).
|
|
104
|
+
*/
|
|
105
|
+
export declare function createChatRoute(config: CreateGnlConfig | {
|
|
106
|
+
gnl: ReturnType<typeof createGnl>;
|
|
107
|
+
}, opts?: CreateChatRouteOptions): Hono;
|