psiguard 1.0.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/README.md +151 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +360 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# PsiGuard JavaScript / TypeScript SDK
|
|
2
|
+
|
|
3
|
+
The official Node client for the [PsiGuard](https://psiguard.net) guarded-chat API.
|
|
4
|
+
|
|
5
|
+
PsiGuard wraps a language model with a live structural monitor. You send it a
|
|
6
|
+
prompt; it runs the model, watches the generation as it happens, and hands you
|
|
7
|
+
back a clean answer plus one coarse signal telling you whether it had to step
|
|
8
|
+
in. Everything about *how* it decides stays on PsiGuard's servers — this client
|
|
9
|
+
only ever sees the answer and that one signal.
|
|
10
|
+
|
|
11
|
+
Written in TypeScript, ships with types, works from plain JavaScript too.
|
|
12
|
+
Requires Node 18+ and has **no runtime dependencies** (it uses the built-in
|
|
13
|
+
`fetch`).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install psiguard
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { PsiGuard } from "psiguard";
|
|
25
|
+
|
|
26
|
+
const client = new PsiGuard({ apiKey: "psg_live_your_key_here" });
|
|
27
|
+
|
|
28
|
+
const result = await client.guard("What is your return policy?");
|
|
29
|
+
console.log(result.answer); // the reply to show your user
|
|
30
|
+
console.log(result.protection); // "safe" | "caution" | "intervened"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Plain JavaScript (CommonJS) works the same way:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const { PsiGuard } = require("psiguard");
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set `PSIGUARD_API_KEY` in your environment and you can skip passing the key:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const client = new PsiGuard(); // reads PSIGUARD_API_KEY
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Get a key from your dashboard at **psiguard.net/dashboard/keys**.
|
|
46
|
+
|
|
47
|
+
> **Keep your key server-side.** Like a password, anyone holding it can spend
|
|
48
|
+
> against your account. Use this SDK from your back end, not from browser code.
|
|
49
|
+
|
|
50
|
+
## Give your assistant a personality
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const result = await client.guard("Do you ship to Canada?", {
|
|
54
|
+
systemPrompt: "You are a friendly support agent for Acme Tools.",
|
|
55
|
+
conversationId: "cust-8472",
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Multi-turn conversations
|
|
60
|
+
|
|
61
|
+
The `conversation()` helper reuses one id and accumulates history for you, so
|
|
62
|
+
each turn carries the ones before it:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const chat = client.conversation({ systemPrompt: "You are Acme's assistant." });
|
|
66
|
+
|
|
67
|
+
await chat.send("Hi, my order hasn't arrived.");
|
|
68
|
+
const reply = await chat.send("It's order 1234."); // remembers the turn before
|
|
69
|
+
|
|
70
|
+
console.log(reply.answer);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Reading the protection signal
|
|
74
|
+
|
|
75
|
+
Every turn comes back with one of three values. A simple integration can ignore
|
|
76
|
+
it and just show `answer`.
|
|
77
|
+
|
|
78
|
+
| value | meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `safe` | Clean run. PsiGuard didn't need to act. |
|
|
81
|
+
| `caution` | PsiGuard steered the response back on course. `answer` is the corrected one. |
|
|
82
|
+
| `intervened` | PsiGuard withheld the response. `answer` is a neutral refusal, safe to show as-is. |
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const result = await client.guard(prompt);
|
|
86
|
+
if (result.intervened) {
|
|
87
|
+
console.log("PsiGuard stepped in on this turn");
|
|
88
|
+
}
|
|
89
|
+
showToUser(result.answer);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Handling errors
|
|
93
|
+
|
|
94
|
+
Everything the client throws extends `PsiGuardError`, so you can catch broadly
|
|
95
|
+
or narrowly:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
PsiGuard, AuthenticationError,
|
|
100
|
+
RateLimitError, UsageLimitError, PsiGuardError,
|
|
101
|
+
} from "psiguard";
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = await client.guard(prompt);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e instanceof AuthenticationError) {
|
|
107
|
+
// key missing, invalid, or revoked
|
|
108
|
+
} else if (e instanceof RateLimitError) {
|
|
109
|
+
// too many requests — slow down and retry shortly
|
|
110
|
+
} else if (e instanceof UsageLimitError) {
|
|
111
|
+
// monthly cap or provider budget reached — a stop sign, not a retry
|
|
112
|
+
// e.scope, e.plan, e.provider, e.used, e.limit, e.month
|
|
113
|
+
} else if (e instanceof PsiGuardError) {
|
|
114
|
+
// anything else
|
|
115
|
+
} else {
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The client automatically retries the failures that are safe to retry (network
|
|
122
|
+
errors and 5xx), and never retries the ones that aren't (4xx and usage caps).
|
|
123
|
+
Tune it with `new PsiGuard({ maxRetries: 2, timeoutMs: 60000 })`.
|
|
124
|
+
|
|
125
|
+
## Verify a key
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const who = await client.whoami(); // runs no model, isn't metered
|
|
129
|
+
console.log(who.account);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Configuration reference
|
|
133
|
+
|
|
134
|
+
| Option | Default | Notes |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `apiKey` | `$PSIGUARD_API_KEY` | Your `psg_live_…` key. |
|
|
137
|
+
| `baseUrl` | `$PSIGUARD_BASE_URL` or `https://psiguard.net` | Your PsiGuard host. |
|
|
138
|
+
| `timeoutMs` | `60000` | Per-request timeout in milliseconds. |
|
|
139
|
+
| `maxRetries` | `2` | Extra attempts for retryable failures only. |
|
|
140
|
+
|
|
141
|
+
## A note on what's inside
|
|
142
|
+
|
|
143
|
+
This client is a courier, not the engine. It contains none of PsiGuard's
|
|
144
|
+
internal workings — no metrics, no thresholds, no scoring. It speaks only the
|
|
145
|
+
public contract: a prompt goes out, an answer and a protection signal come
|
|
146
|
+
back. PsiGuard's monitoring framework is proprietary and confidential,
|
|
147
|
+
protected as a trade secret of PsiCo, LLC.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
© PsiCo, LLC. Licensed for use through the PsiGuard API.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PsiGuard JavaScript / TypeScript SDK
|
|
3
|
+
* ====================================
|
|
4
|
+
*
|
|
5
|
+
* The official Node client for the PsiGuard guarded-chat API.
|
|
6
|
+
*
|
|
7
|
+
* PsiGuard wraps a language model with a live structural monitor. You send it a
|
|
8
|
+
* prompt; it runs the model, watches the generation as it happens, and hands
|
|
9
|
+
* you back a clean answer plus one coarse signal telling you whether it had to
|
|
10
|
+
* step in. Everything about *how* it decides stays on PsiGuard's servers — this
|
|
11
|
+
* client only ever sees the answer and that one signal.
|
|
12
|
+
*
|
|
13
|
+
* Quick start
|
|
14
|
+
* -----------
|
|
15
|
+
* import { PsiGuard } from "psiguard";
|
|
16
|
+
*
|
|
17
|
+
* const client = new PsiGuard({ apiKey: "psg_live_your_key_here" });
|
|
18
|
+
* const result = await client.guard("What is your return policy?");
|
|
19
|
+
* console.log(result.answer); // the reply to show your user
|
|
20
|
+
* console.log(result.protection); // "safe" | "caution" | "intervened"
|
|
21
|
+
*
|
|
22
|
+
* Set PSIGUARD_API_KEY in your environment and you can skip passing the key:
|
|
23
|
+
*
|
|
24
|
+
* const client = new PsiGuard(); // reads PSIGUARD_API_KEY
|
|
25
|
+
*
|
|
26
|
+
* This client is a courier, not the engine. It contains none of PsiGuard's
|
|
27
|
+
* inner workings — no metrics, no thresholds, no scoring. It speaks only the
|
|
28
|
+
* public contract: a prompt goes out, an answer and a protection signal come
|
|
29
|
+
* back.
|
|
30
|
+
*
|
|
31
|
+
* Keep your API key server-side. Like a password, anyone holding it can spend
|
|
32
|
+
* against your account — so this SDK is built for Node/back-end use, not for
|
|
33
|
+
* shipping a key into a browser.
|
|
34
|
+
*
|
|
35
|
+
* Requires Node 18+ (uses the built-in fetch). No runtime dependencies.
|
|
36
|
+
*/
|
|
37
|
+
export declare const VERSION = "1.0.0";
|
|
38
|
+
/** The value of a turn's `protection` field. */
|
|
39
|
+
export type ProtectionValue = "safe" | "caution" | "intervened";
|
|
40
|
+
/** Named constants for the three protection values. */
|
|
41
|
+
export declare const Protection: {
|
|
42
|
+
/** Clean run; PsiGuard didn't need to act. */
|
|
43
|
+
readonly SAFE: "safe";
|
|
44
|
+
/** Steered back on course mid-generation. */
|
|
45
|
+
readonly CAUTION: "caution";
|
|
46
|
+
/** Response withheld; answer is a neutral refusal. */
|
|
47
|
+
readonly INTERVENED: "intervened";
|
|
48
|
+
};
|
|
49
|
+
/** One prior turn, as the model should see it. */
|
|
50
|
+
export interface HistoryMessage {
|
|
51
|
+
role: string;
|
|
52
|
+
content: string;
|
|
53
|
+
}
|
|
54
|
+
/** Options for a single guarded turn. */
|
|
55
|
+
export interface GuardOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Your assistant's personality, scope, or instructions. Omit to run the
|
|
58
|
+
* model with no steering (still guarded).
|
|
59
|
+
*/
|
|
60
|
+
systemPrompt?: string;
|
|
61
|
+
/**
|
|
62
|
+
* A caller-chosen id that groups turns into one thread, so monitoring
|
|
63
|
+
* carries context across a conversation. Reuse the same id for every turn
|
|
64
|
+
* in a chat. Omit and the server uses "default".
|
|
65
|
+
*/
|
|
66
|
+
conversationId?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Prior turns you want the model to see. You own and store the transcript;
|
|
69
|
+
* PsiGuard doesn't retain it.
|
|
70
|
+
*/
|
|
71
|
+
history?: HistoryMessage[];
|
|
72
|
+
}
|
|
73
|
+
/** Options for constructing a client. */
|
|
74
|
+
export interface ClientOptions {
|
|
75
|
+
/** Your PsiGuard API key (looks like "psg_live_…"). Falls back to PSIGUARD_API_KEY. */
|
|
76
|
+
apiKey?: string;
|
|
77
|
+
/** Your PsiGuard host. Falls back to PSIGUARD_BASE_URL, then https://psiguard.net. */
|
|
78
|
+
baseUrl?: string;
|
|
79
|
+
/** Per-request timeout in milliseconds. Default 60000. */
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
/** Extra attempts for retryable failures (network + 5xx). Default 2. */
|
|
82
|
+
maxRetries?: number;
|
|
83
|
+
}
|
|
84
|
+
/** Options for starting a conversation. */
|
|
85
|
+
export interface ConversationOptions {
|
|
86
|
+
/** The thread id to use. A random one is generated if omitted. */
|
|
87
|
+
conversationId?: string;
|
|
88
|
+
/** An assistant personality applied to every turn in this conversation. */
|
|
89
|
+
systemPrompt?: string;
|
|
90
|
+
}
|
|
91
|
+
/** The outcome of one guarded turn. */
|
|
92
|
+
export declare class GuardResult {
|
|
93
|
+
/**
|
|
94
|
+
* The reply to show your user. On an intervention this is a neutral refusal,
|
|
95
|
+
* indistinguishable from an ordinary model decline — safe to display as-is.
|
|
96
|
+
*/
|
|
97
|
+
readonly answer: string;
|
|
98
|
+
/** One of "safe", "caution", or "intervened". */
|
|
99
|
+
readonly protection: ProtectionValue;
|
|
100
|
+
/** An echo of the id this turn belongs to. */
|
|
101
|
+
readonly conversationId: string;
|
|
102
|
+
/** The exact JSON object the API returned. */
|
|
103
|
+
readonly raw: Record<string, unknown>;
|
|
104
|
+
constructor(data: Record<string, unknown>, fallbackConversationId: string);
|
|
105
|
+
/** True when PsiGuard did not need to act on this turn. */
|
|
106
|
+
get safe(): boolean;
|
|
107
|
+
/** True when PsiGuard steered the response back on course. */
|
|
108
|
+
get caution(): boolean;
|
|
109
|
+
/** True when PsiGuard withheld the response (answer is a refusal). */
|
|
110
|
+
get intervened(): boolean;
|
|
111
|
+
}
|
|
112
|
+
/** Who an API key belongs to, as reported by a verify check. */
|
|
113
|
+
export interface Account {
|
|
114
|
+
account: string;
|
|
115
|
+
authVia: string;
|
|
116
|
+
raw: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
/** Base class for everything this SDK throws. */
|
|
119
|
+
export declare class PsiGuardError extends Error {
|
|
120
|
+
constructor(message: string);
|
|
121
|
+
}
|
|
122
|
+
/** The client was set up wrong (e.g. no API key). No request was made. */
|
|
123
|
+
export declare class ConfigurationError extends PsiGuardError {
|
|
124
|
+
}
|
|
125
|
+
/** The request never got a reply — network error or timeout. Safe to retry. */
|
|
126
|
+
export declare class APIConnectionError extends PsiGuardError {
|
|
127
|
+
}
|
|
128
|
+
/** The API replied with a non-success HTTP status. */
|
|
129
|
+
export declare class APIStatusError extends PsiGuardError {
|
|
130
|
+
/** The HTTP status (e.g. 400, 401, 429, 502). */
|
|
131
|
+
readonly statusCode: number;
|
|
132
|
+
/** The short machine-readable code from the body's `error` field, if present. */
|
|
133
|
+
readonly errorCode?: string;
|
|
134
|
+
/** The full parsed response body. */
|
|
135
|
+
readonly body: Record<string, unknown>;
|
|
136
|
+
constructor(message: string, statusCode: number, errorCode: string | undefined, body: Record<string, unknown>);
|
|
137
|
+
}
|
|
138
|
+
/** 400 — the request was malformed or the account has no model configured. */
|
|
139
|
+
export declare class BadRequestError extends APIStatusError {
|
|
140
|
+
}
|
|
141
|
+
/** 401 — the API key was missing, invalid, or revoked. */
|
|
142
|
+
export declare class AuthenticationError extends APIStatusError {
|
|
143
|
+
}
|
|
144
|
+
/** 429 — too many requests in a short window. Slow down and retry shortly. */
|
|
145
|
+
export declare class RateLimitError extends APIStatusError {
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 429 — a usage limit was reached (a stop sign, not a retry).
|
|
149
|
+
*
|
|
150
|
+
* Distinct from RateLimitError: you've hit either the account's monthly cap or
|
|
151
|
+
* a provider budget. Retrying won't help — raise the cap or bring your own
|
|
152
|
+
* provider key.
|
|
153
|
+
*/
|
|
154
|
+
export declare class UsageLimitError extends APIStatusError {
|
|
155
|
+
/** "account" for a monthly cap, "provider" for a provider budget. */
|
|
156
|
+
readonly scope: "account" | "provider";
|
|
157
|
+
/** The account's plan (account cap only). */
|
|
158
|
+
readonly plan?: string;
|
|
159
|
+
/** The provider whose budget was reached (provider budget only). */
|
|
160
|
+
readonly provider?: string;
|
|
161
|
+
/** How much has been used this month. */
|
|
162
|
+
readonly used?: number;
|
|
163
|
+
/** The cap or budget that was reached. */
|
|
164
|
+
readonly limit?: number;
|
|
165
|
+
/** The month the counter applies to. */
|
|
166
|
+
readonly month?: string;
|
|
167
|
+
constructor(message: string, statusCode: number, errorCode: string | undefined, body: Record<string, unknown>);
|
|
168
|
+
}
|
|
169
|
+
/** 5xx — the server or the underlying model failed. Usually safe to retry. */
|
|
170
|
+
export declare class ServerError extends APIStatusError {
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* A connection to the PsiGuard API.
|
|
174
|
+
*
|
|
175
|
+
* const client = new PsiGuard({ apiKey: "psg_live_…" });
|
|
176
|
+
* const result = await client.guard("hello");
|
|
177
|
+
*
|
|
178
|
+
* The client is safe to keep and reuse for the life of your process.
|
|
179
|
+
*/
|
|
180
|
+
export declare class PsiGuard {
|
|
181
|
+
readonly apiKey: string;
|
|
182
|
+
readonly baseUrl: string;
|
|
183
|
+
readonly timeoutMs: number;
|
|
184
|
+
readonly maxRetries: number;
|
|
185
|
+
constructor(options?: ClientOptions);
|
|
186
|
+
/**
|
|
187
|
+
* Run one guarded turn and return the answer.
|
|
188
|
+
*
|
|
189
|
+
* This is the call most integrations only ever need.
|
|
190
|
+
*/
|
|
191
|
+
guard(prompt: string, options?: GuardOptions): Promise<GuardResult>;
|
|
192
|
+
/**
|
|
193
|
+
* Confirm the API key is valid and see which account it belongs to.
|
|
194
|
+
* Runs no model and isn't metered. Throws AuthenticationError if the key is bad.
|
|
195
|
+
*/
|
|
196
|
+
whoami(): Promise<Account>;
|
|
197
|
+
/**
|
|
198
|
+
* Start a multi-turn conversation that manages its own thread.
|
|
199
|
+
*
|
|
200
|
+
* The returned Conversation reuses one conversationId and accumulates history
|
|
201
|
+
* for you, so each send() carries the turns before it.
|
|
202
|
+
*/
|
|
203
|
+
conversation(options?: ConversationOptions): Conversation;
|
|
204
|
+
private get headers();
|
|
205
|
+
/**
|
|
206
|
+
* Make one API call, retrying the failures that are safe to retry.
|
|
207
|
+
* Retried: network errors, timeouts, and 5xx. Never: 4xx and usage caps.
|
|
208
|
+
*/
|
|
209
|
+
private request;
|
|
210
|
+
}
|
|
211
|
+
/** Alias so `new Client(...)` works too, matching the Python SDK's name. */
|
|
212
|
+
export { PsiGuard as Client };
|
|
213
|
+
/**
|
|
214
|
+
* An ongoing guarded conversation on a single thread.
|
|
215
|
+
*
|
|
216
|
+
* Holds a conversationId and a running transcript so each turn carries the ones
|
|
217
|
+
* before it. Make one with `client.conversation()` rather than directly.
|
|
218
|
+
*/
|
|
219
|
+
export declare class Conversation {
|
|
220
|
+
readonly client: PsiGuard;
|
|
221
|
+
readonly conversationId: string;
|
|
222
|
+
readonly systemPrompt?: string;
|
|
223
|
+
/** The running transcript: two entries per turn (user prompt, assistant answer). */
|
|
224
|
+
history: HistoryMessage[];
|
|
225
|
+
constructor(client: PsiGuard, options: {
|
|
226
|
+
conversationId: string;
|
|
227
|
+
systemPrompt?: string;
|
|
228
|
+
});
|
|
229
|
+
/** Send the next turn and record both sides in the transcript. */
|
|
230
|
+
send(prompt: string): Promise<GuardResult>;
|
|
231
|
+
/** Forget the transcript but keep the same conversationId. */
|
|
232
|
+
reset(): void;
|
|
233
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PsiGuard JavaScript / TypeScript SDK
|
|
4
|
+
* ====================================
|
|
5
|
+
*
|
|
6
|
+
* The official Node client for the PsiGuard guarded-chat API.
|
|
7
|
+
*
|
|
8
|
+
* PsiGuard wraps a language model with a live structural monitor. You send it a
|
|
9
|
+
* prompt; it runs the model, watches the generation as it happens, and hands
|
|
10
|
+
* you back a clean answer plus one coarse signal telling you whether it had to
|
|
11
|
+
* step in. Everything about *how* it decides stays on PsiGuard's servers — this
|
|
12
|
+
* client only ever sees the answer and that one signal.
|
|
13
|
+
*
|
|
14
|
+
* Quick start
|
|
15
|
+
* -----------
|
|
16
|
+
* import { PsiGuard } from "psiguard";
|
|
17
|
+
*
|
|
18
|
+
* const client = new PsiGuard({ apiKey: "psg_live_your_key_here" });
|
|
19
|
+
* const result = await client.guard("What is your return policy?");
|
|
20
|
+
* console.log(result.answer); // the reply to show your user
|
|
21
|
+
* console.log(result.protection); // "safe" | "caution" | "intervened"
|
|
22
|
+
*
|
|
23
|
+
* Set PSIGUARD_API_KEY in your environment and you can skip passing the key:
|
|
24
|
+
*
|
|
25
|
+
* const client = new PsiGuard(); // reads PSIGUARD_API_KEY
|
|
26
|
+
*
|
|
27
|
+
* This client is a courier, not the engine. It contains none of PsiGuard's
|
|
28
|
+
* inner workings — no metrics, no thresholds, no scoring. It speaks only the
|
|
29
|
+
* public contract: a prompt goes out, an answer and a protection signal come
|
|
30
|
+
* back.
|
|
31
|
+
*
|
|
32
|
+
* Keep your API key server-side. Like a password, anyone holding it can spend
|
|
33
|
+
* against your account — so this SDK is built for Node/back-end use, not for
|
|
34
|
+
* shipping a key into a browser.
|
|
35
|
+
*
|
|
36
|
+
* Requires Node 18+ (uses the built-in fetch). No runtime dependencies.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.Conversation = exports.Client = exports.PsiGuard = exports.ServerError = exports.UsageLimitError = exports.RateLimitError = exports.AuthenticationError = exports.BadRequestError = exports.APIStatusError = exports.APIConnectionError = exports.ConfigurationError = exports.PsiGuardError = exports.GuardResult = exports.Protection = exports.VERSION = void 0;
|
|
40
|
+
exports.VERSION = "1.0.0";
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Defaults
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const DEFAULT_BASE_URL = "https://psiguard.net";
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 60000; // a guarded turn runs a full model call
|
|
46
|
+
const DEFAULT_MAX_RETRIES = 2; // retryable failures only
|
|
47
|
+
const RETRY_BACKOFF_MS = 750; // grows each retry: 750ms, 1.5s, 3s, …
|
|
48
|
+
const USER_AGENT = `psiguard-js/${exports.VERSION}`;
|
|
49
|
+
/** Named constants for the three protection values. */
|
|
50
|
+
exports.Protection = {
|
|
51
|
+
/** Clean run; PsiGuard didn't need to act. */
|
|
52
|
+
SAFE: "safe",
|
|
53
|
+
/** Steered back on course mid-generation. */
|
|
54
|
+
CAUTION: "caution",
|
|
55
|
+
/** Response withheld; answer is a neutral refusal. */
|
|
56
|
+
INTERVENED: "intervened",
|
|
57
|
+
};
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Results
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/** The outcome of one guarded turn. */
|
|
62
|
+
class GuardResult {
|
|
63
|
+
constructor(data, fallbackConversationId) {
|
|
64
|
+
this.answer = typeof data.answer === "string" ? data.answer : "";
|
|
65
|
+
this.protection = data.protection ?? "safe";
|
|
66
|
+
this.conversationId =
|
|
67
|
+
typeof data.conversation_id === "string"
|
|
68
|
+
? data.conversation_id
|
|
69
|
+
: fallbackConversationId;
|
|
70
|
+
this.raw = data;
|
|
71
|
+
}
|
|
72
|
+
/** True when PsiGuard did not need to act on this turn. */
|
|
73
|
+
get safe() {
|
|
74
|
+
return this.protection === "safe";
|
|
75
|
+
}
|
|
76
|
+
/** True when PsiGuard steered the response back on course. */
|
|
77
|
+
get caution() {
|
|
78
|
+
return this.protection === "caution";
|
|
79
|
+
}
|
|
80
|
+
/** True when PsiGuard withheld the response (answer is a refusal). */
|
|
81
|
+
get intervened() {
|
|
82
|
+
return this.protection === "intervened";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
exports.GuardResult = GuardResult;
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Errors
|
|
88
|
+
//
|
|
89
|
+
// One base class so callers can `catch (e) { if (e instanceof PsiGuardError) }`
|
|
90
|
+
// and be done, plus specific subclasses for the cases worth handling apart.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/** Base class for everything this SDK throws. */
|
|
93
|
+
class PsiGuardError extends Error {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = new.target.name;
|
|
97
|
+
// Keep `instanceof` working across compile targets / bundlers.
|
|
98
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
exports.PsiGuardError = PsiGuardError;
|
|
102
|
+
/** The client was set up wrong (e.g. no API key). No request was made. */
|
|
103
|
+
class ConfigurationError extends PsiGuardError {
|
|
104
|
+
}
|
|
105
|
+
exports.ConfigurationError = ConfigurationError;
|
|
106
|
+
/** The request never got a reply — network error or timeout. Safe to retry. */
|
|
107
|
+
class APIConnectionError extends PsiGuardError {
|
|
108
|
+
}
|
|
109
|
+
exports.APIConnectionError = APIConnectionError;
|
|
110
|
+
/** The API replied with a non-success HTTP status. */
|
|
111
|
+
class APIStatusError extends PsiGuardError {
|
|
112
|
+
constructor(message, statusCode, errorCode, body) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.statusCode = statusCode;
|
|
115
|
+
this.errorCode = errorCode;
|
|
116
|
+
this.body = body;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
exports.APIStatusError = APIStatusError;
|
|
120
|
+
/** 400 — the request was malformed or the account has no model configured. */
|
|
121
|
+
class BadRequestError extends APIStatusError {
|
|
122
|
+
}
|
|
123
|
+
exports.BadRequestError = BadRequestError;
|
|
124
|
+
/** 401 — the API key was missing, invalid, or revoked. */
|
|
125
|
+
class AuthenticationError extends APIStatusError {
|
|
126
|
+
}
|
|
127
|
+
exports.AuthenticationError = AuthenticationError;
|
|
128
|
+
/** 429 — too many requests in a short window. Slow down and retry shortly. */
|
|
129
|
+
class RateLimitError extends APIStatusError {
|
|
130
|
+
}
|
|
131
|
+
exports.RateLimitError = RateLimitError;
|
|
132
|
+
/**
|
|
133
|
+
* 429 — a usage limit was reached (a stop sign, not a retry).
|
|
134
|
+
*
|
|
135
|
+
* Distinct from RateLimitError: you've hit either the account's monthly cap or
|
|
136
|
+
* a provider budget. Retrying won't help — raise the cap or bring your own
|
|
137
|
+
* provider key.
|
|
138
|
+
*/
|
|
139
|
+
class UsageLimitError extends APIStatusError {
|
|
140
|
+
constructor(message, statusCode, errorCode, body) {
|
|
141
|
+
super(message, statusCode, errorCode, body);
|
|
142
|
+
this.scope = errorCode === "provider_budget_reached" ? "provider" : "account";
|
|
143
|
+
this.plan = body.plan;
|
|
144
|
+
this.provider = body.provider;
|
|
145
|
+
this.used = body.used;
|
|
146
|
+
// The body names the ceiling "cap" for accounts and "budget" for providers.
|
|
147
|
+
this.limit = (body.cap ?? body.budget);
|
|
148
|
+
this.month = body.month;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
exports.UsageLimitError = UsageLimitError;
|
|
152
|
+
/** 5xx — the server or the underlying model failed. Usually safe to retry. */
|
|
153
|
+
class ServerError extends APIStatusError {
|
|
154
|
+
}
|
|
155
|
+
exports.ServerError = ServerError;
|
|
156
|
+
/** Usage-cap error codes that mean "stop", not "slow down". */
|
|
157
|
+
const USAGE_CODES = new Set(["monthly_cap_reached", "provider_budget_reached"]);
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// The client
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
/**
|
|
162
|
+
* A connection to the PsiGuard API.
|
|
163
|
+
*
|
|
164
|
+
* const client = new PsiGuard({ apiKey: "psg_live_…" });
|
|
165
|
+
* const result = await client.guard("hello");
|
|
166
|
+
*
|
|
167
|
+
* The client is safe to keep and reuse for the life of your process.
|
|
168
|
+
*/
|
|
169
|
+
class PsiGuard {
|
|
170
|
+
constructor(options = {}) {
|
|
171
|
+
const key = options.apiKey ?? process.env.PSIGUARD_API_KEY;
|
|
172
|
+
if (!key) {
|
|
173
|
+
throw new ConfigurationError("No API key. Pass { apiKey } or set the PSIGUARD_API_KEY environment " +
|
|
174
|
+
"variable. Create a key at https://psiguard.net/dashboard/keys");
|
|
175
|
+
}
|
|
176
|
+
this.apiKey = key.trim();
|
|
177
|
+
const host = options.baseUrl ?? process.env.PSIGUARD_BASE_URL ?? DEFAULT_BASE_URL;
|
|
178
|
+
this.baseUrl = host.replace(/\/+$/, "");
|
|
179
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
180
|
+
this.maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Run one guarded turn and return the answer.
|
|
184
|
+
*
|
|
185
|
+
* This is the call most integrations only ever need.
|
|
186
|
+
*/
|
|
187
|
+
async guard(prompt, options = {}) {
|
|
188
|
+
if (typeof prompt !== "string" || prompt.trim() === "") {
|
|
189
|
+
throw new ConfigurationError("guard() needs a non-empty 'prompt' string.");
|
|
190
|
+
}
|
|
191
|
+
const body = { prompt };
|
|
192
|
+
if (options.systemPrompt)
|
|
193
|
+
body.system_prompt = options.systemPrompt;
|
|
194
|
+
if (options.conversationId)
|
|
195
|
+
body.conversation_id = options.conversationId;
|
|
196
|
+
if (options.history)
|
|
197
|
+
body.history = cleanHistory(options.history);
|
|
198
|
+
const data = await this.request("POST", "/v1/guard", body);
|
|
199
|
+
return new GuardResult(data, options.conversationId ?? "default");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Confirm the API key is valid and see which account it belongs to.
|
|
203
|
+
* Runs no model and isn't metered. Throws AuthenticationError if the key is bad.
|
|
204
|
+
*/
|
|
205
|
+
async whoami() {
|
|
206
|
+
const data = await this.request("GET", "/v1/whoami");
|
|
207
|
+
return {
|
|
208
|
+
account: typeof data.account === "string" ? data.account : "",
|
|
209
|
+
authVia: typeof data.auth_via === "string" ? data.auth_via : "",
|
|
210
|
+
raw: data,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Start a multi-turn conversation that manages its own thread.
|
|
215
|
+
*
|
|
216
|
+
* The returned Conversation reuses one conversationId and accumulates history
|
|
217
|
+
* for you, so each send() carries the turns before it.
|
|
218
|
+
*/
|
|
219
|
+
conversation(options = {}) {
|
|
220
|
+
return new Conversation(this, {
|
|
221
|
+
conversationId: options.conversationId ?? newConversationId(),
|
|
222
|
+
systemPrompt: options.systemPrompt,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// -- internals ----------------------------------------------------------
|
|
226
|
+
get headers() {
|
|
227
|
+
return {
|
|
228
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
Accept: "application/json",
|
|
231
|
+
"User-Agent": USER_AGENT,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Make one API call, retrying the failures that are safe to retry.
|
|
236
|
+
* Retried: network errors, timeouts, and 5xx. Never: 4xx and usage caps.
|
|
237
|
+
*/
|
|
238
|
+
async request(method, path, jsonBody) {
|
|
239
|
+
const url = this.baseUrl + path;
|
|
240
|
+
let attempt = 0;
|
|
241
|
+
// eslint-disable-next-line no-constant-condition
|
|
242
|
+
while (true) {
|
|
243
|
+
attempt += 1;
|
|
244
|
+
let resp;
|
|
245
|
+
try {
|
|
246
|
+
resp = await fetch(url, {
|
|
247
|
+
method,
|
|
248
|
+
headers: this.headers,
|
|
249
|
+
body: jsonBody !== undefined ? JSON.stringify(jsonBody) : undefined,
|
|
250
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
// No response at all (network error or timeout). Retry if attempts remain.
|
|
255
|
+
if (attempt <= this.maxRetries) {
|
|
256
|
+
await sleepBackoff(attempt);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
throw new APIConnectionError(`Could not reach PsiGuard at ${url}: ${e.message}`);
|
|
260
|
+
}
|
|
261
|
+
// A 5xx is the model or server faltering — retry it.
|
|
262
|
+
if (resp.status >= 500 && attempt <= this.maxRetries) {
|
|
263
|
+
await sleepBackoff(attempt);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
return await handleResponse(resp);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
exports.PsiGuard = PsiGuard;
|
|
271
|
+
exports.Client = PsiGuard;
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Multi-turn helper
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
/**
|
|
276
|
+
* An ongoing guarded conversation on a single thread.
|
|
277
|
+
*
|
|
278
|
+
* Holds a conversationId and a running transcript so each turn carries the ones
|
|
279
|
+
* before it. Make one with `client.conversation()` rather than directly.
|
|
280
|
+
*/
|
|
281
|
+
class Conversation {
|
|
282
|
+
constructor(client, options) {
|
|
283
|
+
/** The running transcript: two entries per turn (user prompt, assistant answer). */
|
|
284
|
+
this.history = [];
|
|
285
|
+
this.client = client;
|
|
286
|
+
this.conversationId = options.conversationId;
|
|
287
|
+
this.systemPrompt = options.systemPrompt;
|
|
288
|
+
}
|
|
289
|
+
/** Send the next turn and record both sides in the transcript. */
|
|
290
|
+
async send(prompt) {
|
|
291
|
+
const result = await this.client.guard(prompt, {
|
|
292
|
+
systemPrompt: this.systemPrompt,
|
|
293
|
+
conversationId: this.conversationId,
|
|
294
|
+
history: this.history,
|
|
295
|
+
});
|
|
296
|
+
this.history.push({ role: "user", content: prompt });
|
|
297
|
+
this.history.push({ role: "assistant", content: result.answer });
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
/** Forget the transcript but keep the same conversationId. */
|
|
301
|
+
reset() {
|
|
302
|
+
this.history = [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
exports.Conversation = Conversation;
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Small helpers
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
function cleanHistory(history) {
|
|
310
|
+
return history.map((m, i) => {
|
|
311
|
+
if (m &&
|
|
312
|
+
typeof m.role === "string" &&
|
|
313
|
+
m.role &&
|
|
314
|
+
typeof m.content === "string" &&
|
|
315
|
+
m.content) {
|
|
316
|
+
return { role: m.role, content: m.content };
|
|
317
|
+
}
|
|
318
|
+
throw new ConfigurationError(`history[${i}] must be an object with non-empty string 'role' and 'content'.`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async function handleResponse(resp) {
|
|
322
|
+
let body = {};
|
|
323
|
+
try {
|
|
324
|
+
const parsed = await resp.json();
|
|
325
|
+
body = parsed && typeof parsed === "object" ? parsed : { raw: parsed };
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
body = {};
|
|
329
|
+
}
|
|
330
|
+
const status = resp.status;
|
|
331
|
+
if (status >= 200 && status < 300)
|
|
332
|
+
return body;
|
|
333
|
+
const code = typeof body.error === "string" ? body.error : undefined;
|
|
334
|
+
const message = (typeof body.message === "string" && body.message) ||
|
|
335
|
+
code ||
|
|
336
|
+
`PsiGuard request failed with status ${status}`;
|
|
337
|
+
switch (true) {
|
|
338
|
+
case status === 400:
|
|
339
|
+
throw new BadRequestError(message, status, code, body);
|
|
340
|
+
case status === 401:
|
|
341
|
+
throw new AuthenticationError(message, status, code, body);
|
|
342
|
+
case status === 429:
|
|
343
|
+
if (code && USAGE_CODES.has(code)) {
|
|
344
|
+
throw new UsageLimitError(message, status, code, body);
|
|
345
|
+
}
|
|
346
|
+
throw new RateLimitError(message, status, code, body);
|
|
347
|
+
case status >= 500:
|
|
348
|
+
throw new ServerError(message, status, code, body);
|
|
349
|
+
default:
|
|
350
|
+
throw new APIStatusError(message, status, code, body);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function newConversationId() {
|
|
354
|
+
// A short, unique-enough id for a fresh thread.
|
|
355
|
+
return "conv-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-6);
|
|
356
|
+
}
|
|
357
|
+
function sleepBackoff(attempt) {
|
|
358
|
+
const ms = RETRY_BACKOFF_MS * 2 ** (attempt - 1);
|
|
359
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
360
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "psiguard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official JavaScript/TypeScript client for the PsiGuard guarded-chat API.",
|
|
5
|
+
"author": "PsiCo, LLC",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"homepage": "https://psiguard.net",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"psiguard",
|
|
10
|
+
"llm",
|
|
11
|
+
"ai-safety",
|
|
12
|
+
"guardrails",
|
|
13
|
+
"monitoring"
|
|
14
|
+
],
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"prepublishOnly": "tsc"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.4.0",
|
|
30
|
+
"@types/node": "^18.19.0"
|
|
31
|
+
}
|
|
32
|
+
}
|