@boldare/askdare 0.0.0-stage → 0.1.0-rc.1
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/dist/cli.js +1461 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +193 -0
- package/dist/next.d.ts +86 -0
- package/dist/next.js +2864 -0
- package/dist/store.d.ts +413 -0
- package/dist/version.js +641 -0
- package/docs/attachments.md +41 -0
- package/docs/cli.md +227 -0
- package/docs/configuration.md +357 -0
- package/docs/costs.md +181 -0
- package/docs/custom-store.md +259 -0
- package/docs/deployment.md +76 -0
- package/docs/embedding.md +258 -0
- package/docs/getting-started.md +116 -0
- package/docs/going-public.md +81 -0
- package/docs/identity.md +109 -0
- package/docs/knowledge-base.md +65 -0
- package/docs/migrating-from-openai-assistant.md +43 -0
- package/docs/privacy.md +175 -0
- package/docs/protocol.md +174 -0
- package/docs/styling.md +166 -0
- package/docs/support.md +48 -0
- package/docs/texts.md +176 -0
- package/docs/themes.md +172 -0
- package/docs/tools.md +52 -0
- package/docs/troubleshooting.md +345 -0
- package/docs/voice-input.md +31 -0
- package/llms.txt +32 -0
- package/package.json +98 -3
- package/skills/askdare/SKILL.md +36 -0
package/docs/costs.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Costs
|
|
2
|
+
|
|
3
|
+
Every message in the chat spends tokens in your OpenAI project, and anyone on the internet can send messages. This page shows the most a deployment can spend under the default limits, and the settings that change that number.
|
|
4
|
+
|
|
5
|
+
Prices change often, so this page counts tokens, seconds and files. To turn them into money, multiply by your model's current prices on [OpenAI's pricing page](https://developers.openai.com/api/docs/pricing).
|
|
6
|
+
|
|
7
|
+
## What you pay for
|
|
8
|
+
|
|
9
|
+
| What | OpenAI bills | What bounds it in askdare |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Messages | Input and output tokens. Reasoning tokens are billed as output tokens | Daily token budgets per bot, per IP and optionally for all bots |
|
|
12
|
+
| Knowledge base | Retrieved chunks as input tokens, a fee per file search call, and vector store storage per GB per day above a free allowance | Only the tokens are in the budgets |
|
|
13
|
+
| Attachments | Images and PDF pages as input tokens, when they are sent | The token budgets, plus upload limits |
|
|
14
|
+
| Voice input | Transcription, per minute of audio (some models bill by tokens instead) | Daily speech budgets in seconds |
|
|
15
|
+
| `askdare doctor` | A 16-token response, plus one second of transcription for bots with voice, per bot and run | 3 runs a minute for the whole app |
|
|
16
|
+
|
|
17
|
+
## The worst case under the default limits
|
|
18
|
+
|
|
19
|
+
These defaults bound spending. All of them can be changed in `limits` ([Configuration](configuration.md#limits)).
|
|
20
|
+
|
|
21
|
+
| Limit | Default | Counted per |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `dailyTokens` | 2,000,000 input plus output tokens | bot, per UTC day |
|
|
24
|
+
| `dailyTokensPerIp` | 200,000 input plus output tokens | client IP and bot, per UTC day |
|
|
25
|
+
| `globalDailyTokens` (in `createChatHandler`) | none | all bots, per UTC day |
|
|
26
|
+
| `maxOutputTokens` | 1,024 | model call |
|
|
27
|
+
| `maxSteps` | 5 model calls | message |
|
|
28
|
+
| `maxInputChars` | 4,000 characters | message |
|
|
29
|
+
| `maxTurns` | 50 messages | conversation |
|
|
30
|
+
| `messagesPerVisitorPerMinute` | 10 | visitor |
|
|
31
|
+
| `messagesPerIpPerMinute` | 30 | client IP |
|
|
32
|
+
| `voiceSecondsPerDay` | 3,600 seconds | bot, per UTC day |
|
|
33
|
+
| `voiceSecondsPerIpPerDay` | 600 seconds | client IP and bot, per UTC day |
|
|
34
|
+
| `uploadsPerDay` | 500 files | bot, per UTC day |
|
|
35
|
+
| `attachments.maxBytes` | 4,000,000 bytes | file |
|
|
36
|
+
|
|
37
|
+
### Tokens
|
|
38
|
+
|
|
39
|
+
The bot's daily budget is the ceiling, however many visitors there are:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
one bot, one day 2,000,000 tokens
|
|
43
|
+
one IP, one day 200,000 tokens (10 IPs use up the bot's budget)
|
|
44
|
+
one bot, 30 days 30 × 2,000,000 = 60,000,000 tokens
|
|
45
|
+
three bots, one day 3 × 2,000,000 = 6,000,000 tokens (unless you set globalDailyTokens)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The budget counts input, cached input and output tokens alike, but OpenAI prices them differently. For a ceiling in money, use the highest per-token price of your model:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
ceiling per bot per day = 2,000,000 / 1,000,000 × highest price per 1M tokens
|
|
52
|
+
= 2 × highest price per 1M tokens
|
|
53
|
+
ceiling per bot per 30 days = 60 × highest price per 1M tokens
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Real spending is lower whenever some of the tokens are cheaper input or cached input. The ceiling is what an attacker with ten or more IP addresses can make you pay every day.
|
|
57
|
+
|
|
58
|
+
### Speech
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
one bot, one day 3,600 seconds = 60 minutes
|
|
62
|
+
one IP, one day 600 seconds = 10 minutes
|
|
63
|
+
one bot, 30 days 30 × 60 minutes = 1,800 minutes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Multiply by the transcription model's price per minute.
|
|
67
|
+
|
|
68
|
+
### Files
|
|
69
|
+
|
|
70
|
+
Uploads cost no tokens until they are sent, and sent files count against the token budgets. They do take up storage in your OpenAI project:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
one bot, one day 500 files × 4 MB = 2 GB
|
|
74
|
+
files expire after 30 days, so at most 30 × 500 = 15,000 files (60 GB) per bot at once
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Check OpenAI's pricing page for how file storage is billed.
|
|
78
|
+
|
|
79
|
+
## When spending can go over the budget
|
|
80
|
+
|
|
81
|
+
The budgets are checked before each message. A few things let the real number end up higher:
|
|
82
|
+
|
|
83
|
+
- **Several instances with the default store.** Counters live in each instance's memory, so every instance allows the full budget, and a restart or deployment resets them. With more than one instance, use `@boldare/askdare-upstash` ([Going public](going-public.md#4-rate-limits-across-instances)).
|
|
84
|
+
- **A message can use somewhat more than it reserved.** Before each model call, askdare reserves an estimate of that call against every budget:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
one call = input tokens of the previous message in this conversation
|
|
88
|
+
+ characters of the instructions, message and page context / 2
|
|
89
|
+
+ maxOutputTokens
|
|
90
|
+
+ tokens of the attached files
|
|
91
|
+
+ maxResults × 800 (with a knowledge base)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The first call is reserved before the message starts. A bot with tools reserves one more call before each round of tool calls; when a budget can't hold it, the model answers without tools instead. When the message ends, the reservations are replaced by the real usage. Tool results and the model's own earlier output in the same message aren't in the estimate, so the counter can end slightly above the budget; the next message is then refused.
|
|
95
|
+
- **A message that fails, or is cut off by `turnTimeoutMs`, stays charged at its estimate,** whatever it really cost.
|
|
96
|
+
- **Dictations in progress** can go over the day's speech budget once, before further dictation is blocked.
|
|
97
|
+
- **The budgets count tokens only.** File search call fees, vector store storage, file storage, `doctor` runs and `kb sync` are not in them.
|
|
98
|
+
|
|
99
|
+
## The OpenAI hard limit
|
|
100
|
+
|
|
101
|
+
The budgets are the main cost control. The backstop is a **monthly budget with "Enforce a hard limit"** on the OpenAI project, with email alerts ([Going public](going-public.md#1-openai-project)). Set it a little above what your askdare budgets allow in a month, so that the budgets stop spending first and the hard limit catches anything they miss.
|
|
102
|
+
|
|
103
|
+
When the project reaches its limit, OpenAI refuses requests. Visitors see "The assistant is temporarily unavailable. Please try again." (`upstream_error`), and `npx @boldare/askdare doctor` reports "The OpenAI project is out of credit or reached its spend limit."
|
|
104
|
+
|
|
105
|
+
## Budgets reset at midnight UTC
|
|
106
|
+
|
|
107
|
+
Daily budgets count per UTC day and reset at 00:00 UTC, not at midnight where you are. A visitor who hits one sees "The assistant is unavailable for the rest of the day." (`budget_exceeded`), with a `Retry-After` header that counts down to 00:00 UTC.
|
|
108
|
+
|
|
109
|
+
Rate limits work differently: each window (a minute or an hour) starts with the first request it counts.
|
|
110
|
+
|
|
111
|
+
## Setting the budgets
|
|
112
|
+
|
|
113
|
+
1. Decide what a day may cost, per bot.
|
|
114
|
+
2. Divide it by your model's highest per-token price. That is `dailyTokens`.
|
|
115
|
+
3. Decide how much of it one visitor's IP may take. The default is a tenth (`dailyTokensPerIp`).
|
|
116
|
+
4. With several bots, cap them together with `globalDailyTokens` in `createChatHandler`.
|
|
117
|
+
5. Run a few typical conversations and compare the usage in the OpenAI dashboard with your estimate. Adjust after the first days of real traffic.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
defineBot({
|
|
121
|
+
// …
|
|
122
|
+
limits: { dailyTokens: 1_000_000, dailyTokensPerIp: 50_000 },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const { GET, POST, DELETE } = createChatHandler({
|
|
126
|
+
bots,
|
|
127
|
+
limits: { globalDailyTokens: 3_000_000 },
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Choosing a model
|
|
132
|
+
|
|
133
|
+
The model decides most of the bill, which is why askdare never picks one for you.
|
|
134
|
+
|
|
135
|
+
- **Smaller, cheaper models** are often enough for answers from your documents and short questions. Start with the cheapest model that answers your test questions well.
|
|
136
|
+
- **Reasoning models** think before they answer. Their reasoning tokens are billed as output tokens and count toward `maxOutputTokens`. askdare doesn't set a reasoning effort, so the model's default applies.
|
|
137
|
+
- Put only the model you chose on the project's model allowlist, so a changed config can't switch to a more expensive one by accident.
|
|
138
|
+
|
|
139
|
+
## `maxOutputTokens` and reasoning
|
|
140
|
+
|
|
141
|
+
`limits.maxOutputTokens` (default 1,024) caps each model call's output, reasoning included.
|
|
142
|
+
|
|
143
|
+
- Raising it raises the most a single call can cost, and the reservation of every message.
|
|
144
|
+
- Lowering it too far cuts answers off. With a reasoning model, the reasoning can use up the whole limit before any visible text: OpenAI still bills the input and reasoning tokens, and the visitor gets an empty or cut-off answer.
|
|
145
|
+
|
|
146
|
+
## Long conversations
|
|
147
|
+
|
|
148
|
+
A conversation is stored by OpenAI, and every message sends the whole conversation to the model as input. Each message therefore costs more than the one before.
|
|
149
|
+
|
|
150
|
+
- `maxTurns` (default 50) ends a conversation: the visitor is asked to start a new one.
|
|
151
|
+
- askdare sends `truncation: 'auto'`. When a conversation outgrows the model's context window, OpenAI drops the oldest items instead of failing.
|
|
152
|
+
- askdare doesn't compact or summarize conversations yet. OpenAI's server-side compaction is planned for 0.2.
|
|
153
|
+
- Attached files and your tools' results stay in the conversation, so every later message reads them again. Keep tool results small ([Tools](tools.md)).
|
|
154
|
+
|
|
155
|
+
## Prompt caching
|
|
156
|
+
|
|
157
|
+
OpenAI caches the beginning of prompts automatically on supported models, and cached input tokens are billed at a lower rate. A cached part can only be reused when everything before it is identical.
|
|
158
|
+
|
|
159
|
+
- askdare sends `prompt_cache_key: "askdare:<bot id>"`, the same for every visitor of a bot, so their requests can share a cache.
|
|
160
|
+
- Start the instructions with text that is the same for every visitor. Put what varies, such as the locale or a signed-in user's name, at the end.
|
|
161
|
+
- Keep the tool list stable. askdare already keeps it the same on every model call of a message.
|
|
162
|
+
|
|
163
|
+
askdare's budgets count cached tokens in full, so they overestimate the cost when caching works. OpenAI's [prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching) lists the minimum prompt length that can be cached.
|
|
164
|
+
|
|
165
|
+
## Knowledge base
|
|
166
|
+
|
|
167
|
+
- `knowledge.maxResults` (default 5) is how many chunks the model reads per search. askdare reserves 800 tokens per chunk, 4,000 per message by default.
|
|
168
|
+
- Each file search call has its own fee, and the vector store is billed for storage per GB per day above a free allowance. Neither is in askdare's budgets. askdare doesn't limit how many searches the model makes per message; the token budget limits them indirectly, because every search adds input tokens.
|
|
169
|
+
- See [Knowledge base](knowledge-base.md).
|
|
170
|
+
|
|
171
|
+
## Attachments
|
|
172
|
+
|
|
173
|
+
- askdare reserves about 1,500 tokens per image and 2,000 per PDF page before the message. A 50-page PDF reserves 100,000 tokens, half of the default `dailyTokensPerIp`.
|
|
174
|
+
- Every model call reads the files again. A bot with tools reserves the files once per call it actually makes, so a large PDF can mean the model answers without calling tools when the visitor's budget can't hold another call.
|
|
175
|
+
- Keep attachments off for bots that don't need them, and lower `maxPdfPages` if long PDFs aren't expected. See [Attachments](attachments.md).
|
|
176
|
+
|
|
177
|
+
## Voice input
|
|
178
|
+
|
|
179
|
+
- Transcription is billed per minute of audio for models that report a duration. askdare reserves `voice.maxSeconds` (default 60) per dictation and then charges the duration OpenAI reports.
|
|
180
|
+
- Models billed by tokens report no duration. askdare then charges the longest audio the recording could hold, so prefer a model billed by duration.
|
|
181
|
+
- A dictation OpenAI refuses with an error isn't charged against the speech budget. See [Voice input](voice-input.md).
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Custom rate-limit store
|
|
2
|
+
|
|
3
|
+
Rate limits and budgets are counters, and the counters live in a store. The default store keeps them in the memory of each server process. That works for one instance. With more instances (Vercel, several Heroku dynos, autoscaling), each one counts on its own, so visitors get a multiple of every limit.
|
|
4
|
+
|
|
5
|
+
`@boldare/askdare-upstash` is the shared store that askdare ships. Write your own when you want the counters in a database you already run.
|
|
6
|
+
|
|
7
|
+
`RateLimitStore` and `CounterHit` are public API ([ADR-0006](https://github.com/boldare/askdare/blob/main/docs/adr/0006-support-matrix-and-versioning.md)). Import them as types from `@boldare/askdare`.
|
|
8
|
+
|
|
9
|
+
## The interface
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
interface RateLimitStore {
|
|
13
|
+
hit(key: string, windowMs: number, amount: number): Promise<CounterHit>;
|
|
14
|
+
readonly shared: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CounterHit {
|
|
18
|
+
/** Counter value after the increment. */
|
|
19
|
+
readonly count: number;
|
|
20
|
+
/** Milliseconds until the window resets. */
|
|
21
|
+
readonly resetMs: number;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## `hit(key, windowMs, amount)`
|
|
26
|
+
|
|
27
|
+
Each key is one counter with a fixed window. `hit` adds `amount` to the counter and returns the result.
|
|
28
|
+
|
|
29
|
+
| Situation | What `hit` does |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| No open window for `key` (a new key, or its window has ended) | Starts a new counter at `amount`, with a window that ends `windowMs` milliseconds from now. |
|
|
32
|
+
| An open window | Adds `amount`. The window keeps its end, whatever `windowMs` this call passes. |
|
|
33
|
+
| `amount` is `0` | Reads. Returns the count without changing it, `0` when no window is open. |
|
|
34
|
+
| `amount` is negative | Refunds: the count goes down by that much. Don't clamp it at zero. |
|
|
35
|
+
|
|
36
|
+
The result:
|
|
37
|
+
|
|
38
|
+
| Field | Meaning |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `count` | The counter after this call's `amount` is added. askdare compares it with the limit. |
|
|
41
|
+
| `resetMs` | Milliseconds until the window ends: more than 0, and at most `windowMs` for a window that this call opened. askdare uses it for the `Retry-After` of a `rate_limited` answer, and waits at least one second. |
|
|
42
|
+
|
|
43
|
+
askdare uses refunds to give back a reservation. A turn reserves an estimate against each token budget. When the turn ends, askdare adds the difference between the real usage and the estimate, which is often negative. When a reservation would cross a budget, askdare refunds it from every budget it has already charged.
|
|
44
|
+
|
|
45
|
+
### Rules
|
|
46
|
+
|
|
47
|
+
- **Atomic per key.** Concurrent calls for the same key must all count, and each must see its own result. askdare reserves budgets with `hit` and checks the returned count, so a lost update lets concurrent turns overshoot a budget. Use one atomic operation, such as Redis `INCRBY` or an SQL upsert with `RETURNING`.
|
|
48
|
+
- **Keys are independent.** askdare never needs a change across several keys at once.
|
|
49
|
+
- **Whole numbers.** Amounts are always integers. Token budgets reach millions, so store counts as 64-bit integers.
|
|
50
|
+
- **Opaque keys.** Don't parse keys: their format is internal and may change. Some are longer than 100 characters. Some contain client IP addresses, so treat the store as personal data and keep keys out of your logs.
|
|
51
|
+
- **Windows** range from one minute to 30 days. You can delete a counter once its window has ended.
|
|
52
|
+
- **Fail loudly.** If `hit` throws or rejects, the request fails with `internal_error` (status 500), and `onError` receives it. Never catch an error and return a zero count: that turns every limit off. The default store does the same when it runs out of room: it drops expired and short-lived counters first, and refuses requests rather than forget what a budget has spent.
|
|
53
|
+
- **One round trip.** Each message calls `hit` several times, mostly one after another. Keep each call to one request to your database.
|
|
54
|
+
|
|
55
|
+
## The `shared` flag
|
|
56
|
+
|
|
57
|
+
`shared` says whether every server instance sees the same counters. It doesn't change how limits are enforced. It only changes two warnings:
|
|
58
|
+
|
|
59
|
+
- When `shared` is `false` and `NODE_ENV` is `production`, `createChatHandler` logs a warning as the route loads: `[askdare] Rate limits are kept in memory, per instance. With more than one instance, use a shared store such as @boldare/askdare-upstash.`
|
|
60
|
+
- `npx @boldare/askdare doctor` warns that rate limits and budgets are kept per instance, unless the app runs in development.
|
|
61
|
+
|
|
62
|
+
Set `shared: true` only when every instance reaches the same counters, such as a database they all connect to. A store that keeps counters inside the process is `shared: false`, even if you wrote it.
|
|
63
|
+
|
|
64
|
+
## Example: PostgreSQL
|
|
65
|
+
|
|
66
|
+
A sketch with the `pg` client. One table holds every counter:
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
CREATE TABLE askdare_counters (
|
|
70
|
+
key text PRIMARY KEY,
|
|
71
|
+
total bigint NOT NULL,
|
|
72
|
+
reset_at timestamptz NOT NULL
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
One upsert per call opens, adds to or restarts a window. PostgreSQL locks the row for it, so concurrent calls for a key never lose an update:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// lib/postgres-store.ts
|
|
80
|
+
import type { CounterHit, RateLimitStore } from '@boldare/askdare';
|
|
81
|
+
import { Pool } from 'pg';
|
|
82
|
+
|
|
83
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
84
|
+
|
|
85
|
+
const HIT = `
|
|
86
|
+
INSERT INTO askdare_counters AS c (key, total, reset_at)
|
|
87
|
+
VALUES ($1, $2, now() + make_interval(secs => $3::float8 / 1000))
|
|
88
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
89
|
+
total = CASE WHEN c.reset_at > now() THEN c.total + EXCLUDED.total ELSE EXCLUDED.total END,
|
|
90
|
+
reset_at = CASE WHEN c.reset_at > now() THEN c.reset_at ELSE EXCLUDED.reset_at END
|
|
91
|
+
RETURNING total, ceil(extract(epoch FROM reset_at - now()) * 1000) AS reset_ms`;
|
|
92
|
+
|
|
93
|
+
export function postgresStore(): RateLimitStore {
|
|
94
|
+
return {
|
|
95
|
+
shared: true,
|
|
96
|
+
async hit(key, windowMs, amount): Promise<CounterHit> {
|
|
97
|
+
const { rows } = await pool.query<{ total: string; reset_ms: string }>(HIT, [
|
|
98
|
+
key,
|
|
99
|
+
amount,
|
|
100
|
+
windowMs,
|
|
101
|
+
]);
|
|
102
|
+
const row = rows[0];
|
|
103
|
+
if (!row) throw new Error('postgresStore: the counter upsert returned no row.');
|
|
104
|
+
return { count: Number(row.total), resetMs: Number(row.reset_ms) };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Expired rows do no harm: the next `hit` restarts them. To keep the table small, delete them from a scheduled job:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
DELETE FROM askdare_counters WHERE reset_at < now();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Plug it in
|
|
117
|
+
|
|
118
|
+
Pass the store to `createChatHandler` as `limits.store`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// app/api/chat/[...path]/route.ts
|
|
122
|
+
import { createChatHandler } from '@boldare/askdare/next';
|
|
123
|
+
import { bots } from '@/askdare.config';
|
|
124
|
+
import { postgresStore } from '@/lib/postgres-store';
|
|
125
|
+
|
|
126
|
+
export const maxDuration = 300;
|
|
127
|
+
|
|
128
|
+
export const { GET, POST, DELETE } = createChatHandler({
|
|
129
|
+
bots,
|
|
130
|
+
limits: { store: postgresStore() },
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
One store serves every bot of the handler. If several apps share one database, give each its own key prefix, as `upstashStore({ prefix })` does. [Configuration](configuration.md#handler-limits) lists the other `limits` options.
|
|
135
|
+
|
|
136
|
+
## Test it
|
|
137
|
+
|
|
138
|
+
The built-in stores share a contract test, but the package doesn't export it. Copy the cases into your own tests:
|
|
139
|
+
|
|
140
|
+
| Case | Expected |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| Adds within a window | `hit(k, 60_000, 1)` returns a count of 1, then `hit(k, 60_000, 5)` returns 6. |
|
|
143
|
+
| Reads with `0` | After `hit(k, 60_000, 2)`, two calls of `hit(k, 60_000, 0)` both return 2. |
|
|
144
|
+
| Keeps keys apart | After `hit(c, 60_000, 3)`, `hit(d, 60_000, 1)` returns 1. |
|
|
145
|
+
| Reports the time left | A new window's `resetMs` is more than 0 and at most `windowMs`. |
|
|
146
|
+
| Refunds | After `hit(k, 60_000, 3)`, `hit(k, 60_000, -1)` returns 2. |
|
|
147
|
+
| Restarts after the window | Once the window has passed, the next `hit` starts again from its `amount`. |
|
|
148
|
+
| Never loses an update | 50 parallel calls of `hit(k, 60_000, 1)` return each count from 1 to 50 exactly once. |
|
|
149
|
+
|
|
150
|
+
The first four are the contract that `@boldare/askdare-upstash` and the in-memory store pass. The rest are tested on the in-memory store and the limits that use it. With Vitest:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// lib/postgres-store.test.ts
|
|
154
|
+
import { randomUUID } from 'node:crypto';
|
|
155
|
+
import type { RateLimitStore } from '@boldare/askdare';
|
|
156
|
+
import { describe, expect, it } from 'vitest';
|
|
157
|
+
import { postgresStore } from './postgres-store';
|
|
158
|
+
|
|
159
|
+
function rateLimitStoreContract(name: string, create: () => RateLimitStore): void {
|
|
160
|
+
describe(`${name} (RateLimitStore contract)`, () => {
|
|
161
|
+
// A fresh prefix per run, so counters from earlier runs don't interfere.
|
|
162
|
+
const run = randomUUID();
|
|
163
|
+
const key = (id: string): string => `contract:${run}:${id}`;
|
|
164
|
+
|
|
165
|
+
it('adds amounts within a window', async () => {
|
|
166
|
+
const store = create();
|
|
167
|
+
expect((await store.hit(key('a'), 60_000, 1)).count).toBe(1);
|
|
168
|
+
expect((await store.hit(key('a'), 60_000, 5)).count).toBe(6);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('reads without changing when the amount is 0', async () => {
|
|
172
|
+
const store = create();
|
|
173
|
+
await store.hit(key('b'), 60_000, 2);
|
|
174
|
+
expect((await store.hit(key('b'), 60_000, 0)).count).toBe(2);
|
|
175
|
+
expect((await store.hit(key('b'), 60_000, 0)).count).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('keeps keys independent', async () => {
|
|
179
|
+
const store = create();
|
|
180
|
+
await store.hit(key('c'), 60_000, 3);
|
|
181
|
+
expect((await store.hit(key('d'), 60_000, 1)).count).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reports the time left in the window', async () => {
|
|
185
|
+
const store = create();
|
|
186
|
+
const { resetMs } = await store.hit(key('e'), 60_000, 1);
|
|
187
|
+
expect(resetMs).toBeGreaterThan(0);
|
|
188
|
+
expect(resetMs).toBeLessThanOrEqual(60_000);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('refunds negative amounts', async () => {
|
|
192
|
+
const store = create();
|
|
193
|
+
await store.hit(key('f'), 60_000, 3);
|
|
194
|
+
expect((await store.hit(key('f'), 60_000, -1)).count).toBe(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('starts a new window once the old one has passed', async () => {
|
|
198
|
+
const store = create();
|
|
199
|
+
await store.hit(key('g'), 1_000, 3);
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
201
|
+
expect((await store.hit(key('g'), 1_000, 1)).count).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('never loses a concurrent update', async () => {
|
|
205
|
+
const store = create();
|
|
206
|
+
const hits = await Promise.all(
|
|
207
|
+
Array.from({ length: 50 }, () => store.hit(key('h'), 60_000, 1)),
|
|
208
|
+
);
|
|
209
|
+
expect(hits.map((hit) => hit.count).sort((a, b) => a - b)).toEqual(
|
|
210
|
+
Array.from({ length: 50 }, (_, index) => index + 1),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
rateLimitStoreContract('postgresStore', () => postgresStore());
|
|
217
|
+
|
|
218
|
+
describe('postgresStore', () => {
|
|
219
|
+
it('is shared across instances', () => {
|
|
220
|
+
expect(postgresStore().shared).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Run these tests against a real database. A mock can't show that concurrent updates are atomic.
|
|
226
|
+
|
|
227
|
+
## The reference implementation
|
|
228
|
+
|
|
229
|
+
`@boldare/askdare-upstash` stores the counters in Upstash Redis over HTTPS, so it works on Vercel and Heroku alike. Read it before you write your own.
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
npm install @boldare/askdare-upstash @upstash/redis
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { createChatHandler } from '@boldare/askdare/next';
|
|
237
|
+
import { upstashStore } from '@boldare/askdare-upstash';
|
|
238
|
+
import { bots } from '@/askdare.config';
|
|
239
|
+
|
|
240
|
+
export const { GET, POST, DELETE } = createChatHandler({
|
|
241
|
+
bots,
|
|
242
|
+
limits: { store: upstashStore() },
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Option | Default | Meaning |
|
|
247
|
+
| --- | --- | --- |
|
|
248
|
+
| `redis` | created from `url` and `token` | An existing `@upstash/redis` client. |
|
|
249
|
+
| `url` | `UPSTASH_REDIS_REST_URL`, else `KV_REST_API_URL` | The database's REST URL. |
|
|
250
|
+
| `token` | `UPSTASH_REDIS_REST_TOKEN`, else `KV_REST_API_TOKEN` | The database's REST token. |
|
|
251
|
+
| `prefix` | `'askdare:'` | Key prefix, to share one database between apps. |
|
|
252
|
+
|
|
253
|
+
How it meets the rules:
|
|
254
|
+
|
|
255
|
+
- Each `hit` runs one Lua script in one round trip. Redis runs a script atomically.
|
|
256
|
+
- The script adds `amount` with `INCRBY`, then reads the key's time to live with `PTTL`. Only a key without one gets `PEXPIRE` with `windowMs`. So an open window keeps its end, and a key that expired starts again.
|
|
257
|
+
- It returns the count and the time to live as `resetMs`.
|
|
258
|
+
- It reads the URL and token on first use, not at import, so `next build` doesn't need them. Without them, the first `hit` throws, and the request fails.
|
|
259
|
+
- `shared` is `true`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
askdare runs wherever Next.js runs on Node.js. It needs no database: conversations live in OpenAI, and your app only stores signed tokens in the visitor's browser. These are the steps for Vercel and Heroku. Before the chat is public, also work through the [going-public checklist](going-public.md).
|
|
4
|
+
|
|
5
|
+
## What every deployment needs
|
|
6
|
+
|
|
7
|
+
| Variable | Notes |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `OPENAI_API_KEY` | A key from the OpenAI project for **this** environment. Use separate projects for development and production. |
|
|
10
|
+
| `CHAT_SECRET` | A new secret per environment: `openssl rand -base64 32`. Don't reuse the one from `.env.local`. |
|
|
11
|
+
| `ASKDARE_MODEL` | If your config reads the model from it, as the `init` template does. |
|
|
12
|
+
| `IDENTITY_SECRET` | Only for [signed-in visitors](identity.md). Shared with your site's backend, different from `CHAT_SECRET`. |
|
|
13
|
+
| `KB_VECTOR_STORE_ID` | Only with a knowledge base. Vector stores belong to one OpenAI project, so run `kb sync` with the production key to get the production store. |
|
|
14
|
+
|
|
15
|
+
Set them in your host's settings, never in files you commit.
|
|
16
|
+
|
|
17
|
+
## Vercel
|
|
18
|
+
|
|
19
|
+
1. Import the repository in Vercel. The defaults for Next.js are right.
|
|
20
|
+
2. Add the variables above under **Settings → Environment Variables**, for Production and Preview. `next build` fails without `ASKDARE_MODEL`, so every environment that builds the app needs it. `vercel env pull .env.local` copies the development values to your machine.
|
|
21
|
+
3. **Share the rate limits.** Vercel runs many instances at once, and the default limits are kept per instance. Create a Redis database at [Upstash](https://upstash.com/) (also available in the Vercel Marketplace) and use the add-on:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @boldare/askdare-upstash
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// app/api/chat/[...path]/route.ts
|
|
29
|
+
import { upstashStore } from '@boldare/askdare-upstash';
|
|
30
|
+
|
|
31
|
+
export const { GET, POST, DELETE } = createChatHandler({
|
|
32
|
+
bots,
|
|
33
|
+
limits: { store: upstashStore() }, // UPSTASH_REDIS_REST_URL and _TOKEN, or KV_REST_API_URL and _TOKEN
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
4. Keep `export const maxDuration = 300` in the route file. That is the longest a streamed answer may take; each bot's `limits.turnTimeoutMs` (120 s by default) must stay below it.
|
|
38
|
+
5. Deploy, then check the live app:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @boldare/askdare doctor --url https://your-app.vercel.app --env-file .env.production.local
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`doctor` signs its request with `CHAT_SECRET`, so the file must hold the production secret. Keep it out of git; `init` already ignores `.env*.local`.
|
|
45
|
+
|
|
46
|
+
Requests to Vercel functions can be at most 4.5 MB, so keep `attachments.maxBytes` at its default of 4 MB or lower.
|
|
47
|
+
|
|
48
|
+
## Heroku
|
|
49
|
+
|
|
50
|
+
1. Create the app and set the Node.js version in `package.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "engines": { "node": "24.x" } }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
2. Add a `Procfile`. The longer keep-alive outlasts Heroku's router, so connections aren't cut mid-answer:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
web: next start --port $PORT --keepAliveTimeout 95000
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
3. Set the variables above under **Settings → Config Vars** in the dashboard. Typing the key into `heroku config:set` would leave it in your shell history.
|
|
63
|
+
4. **One dyno** works with the default limits. With more dynos, share the limits with `@boldare/askdare-upstash`, as for Vercel.
|
|
64
|
+
5. Deploy, then check it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx @boldare/askdare doctor --url https://your-app.herokuapp.com --env-file .env.production.local
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Heroku closes a request that sends nothing for 30 seconds at first, and 55 seconds after that. askdare sends the first byte at once and a heartbeat every 15 seconds, so slow answers survive.
|
|
71
|
+
|
|
72
|
+
## Behind another proxy or CDN
|
|
73
|
+
|
|
74
|
+
- Don't buffer `text/event-stream` responses; askdare sends `X-Accel-Buffering: no` for nginx.
|
|
75
|
+
- Each proxy adds itself to `X-Forwarded-For`. With more than the host's own proxy in front of the app, set `limits.trustProxy` in `createChatHandler` to the number of proxies, or per-IP limits count the proxy instead of the visitor.
|
|
76
|
+
- Leave `/api/chat` out of `proxy.ts`, of `headers()` in `next.config`, and of any CDN rule that sets `X-Frame-Options`, a Content Security Policy or cookies. `doctor` reports each of these.
|