@wopr-network/platform-core 1.50.2 → 1.52.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/dist/email/client.d.ts +13 -8
- package/dist/email/client.js +80 -32
- package/dist/email/ses-transport.d.ts +26 -0
- package/dist/email/ses-transport.js +63 -0
- package/dist/gateway/proxy.d.ts +1 -0
- package/dist/gateway/proxy.js +9 -2
- package/dist/gateway/types.d.ts +4 -0
- package/package.json +2 -1
- package/src/email/client.ts +89 -33
- package/src/email/ses-transport.ts +80 -0
- package/src/gateway/proxy.ts +10 -2
- package/src/gateway/types.ts +4 -0
package/dist/email/client.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
export interface EmailClientConfig {
|
|
8
11
|
apiKey: string;
|
|
@@ -23,22 +26,24 @@ export interface EmailSendResult {
|
|
|
23
26
|
id: string;
|
|
24
27
|
success: boolean;
|
|
25
28
|
}
|
|
29
|
+
/** Transport abstraction — any backend that can send an email. */
|
|
30
|
+
export interface EmailTransport {
|
|
31
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
32
|
+
}
|
|
26
33
|
/**
|
|
27
|
-
* Transactional email client
|
|
34
|
+
* Transactional email client with pluggable transport.
|
|
28
35
|
*
|
|
29
36
|
* Usage:
|
|
30
37
|
* ```ts
|
|
31
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
38
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
32
39
|
* const template = verifyEmailTemplate(url, email);
|
|
33
40
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
34
41
|
* ```
|
|
35
42
|
*/
|
|
36
43
|
export declare class EmailClient {
|
|
37
|
-
private
|
|
38
|
-
private from;
|
|
39
|
-
private replyTo;
|
|
44
|
+
private transport;
|
|
40
45
|
private onSend;
|
|
41
|
-
constructor(
|
|
46
|
+
constructor(configOrTransport: EmailClientConfig | EmailTransport);
|
|
42
47
|
/** Register a callback invoked after each successful send (for audit logging). */
|
|
43
48
|
onEmailSent(callback: (opts: SendTemplateEmailOpts, result: EmailSendResult) => void): void;
|
|
44
49
|
/** Send a transactional email. */
|
package/dist/email/client.js
CHANGED
|
@@ -1,36 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
import { Resend } from "resend";
|
|
8
11
|
import { logger } from "../config/logger.js";
|
|
12
|
+
import { SesTransport } from "./ses-transport.js";
|
|
9
13
|
/**
|
|
10
|
-
* Transactional email client
|
|
14
|
+
* Transactional email client with pluggable transport.
|
|
11
15
|
*
|
|
12
16
|
* Usage:
|
|
13
17
|
* ```ts
|
|
14
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
18
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
15
19
|
* const template = verifyEmailTemplate(url, email);
|
|
16
20
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
17
21
|
* ```
|
|
18
22
|
*/
|
|
19
23
|
export class EmailClient {
|
|
24
|
+
transport;
|
|
25
|
+
onSend = null;
|
|
26
|
+
constructor(configOrTransport) {
|
|
27
|
+
if ("send" in configOrTransport) {
|
|
28
|
+
this.transport = configOrTransport;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
this.transport = new ResendTransport(configOrTransport);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Register a callback invoked after each successful send (for audit logging). */
|
|
35
|
+
onEmailSent(callback) {
|
|
36
|
+
this.onSend = callback;
|
|
37
|
+
}
|
|
38
|
+
/** Send a transactional email. */
|
|
39
|
+
async send(opts) {
|
|
40
|
+
const result = await this.transport.send(opts);
|
|
41
|
+
if (this.onSend) {
|
|
42
|
+
try {
|
|
43
|
+
this.onSend(opts, result);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Audit callback failure should not break email sending
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Resend-backed transport (original implementation). */
|
|
53
|
+
class ResendTransport {
|
|
20
54
|
resend;
|
|
21
55
|
from;
|
|
22
56
|
replyTo;
|
|
23
|
-
onSend = null;
|
|
24
57
|
constructor(config) {
|
|
25
58
|
this.resend = new Resend(config.apiKey);
|
|
26
59
|
this.from = config.from;
|
|
27
60
|
this.replyTo = config.replyTo;
|
|
28
61
|
}
|
|
29
|
-
/** Register a callback invoked after each successful send (for audit logging). */
|
|
30
|
-
onEmailSent(callback) {
|
|
31
|
-
this.onSend = callback;
|
|
32
|
-
}
|
|
33
|
-
/** Send a transactional email. */
|
|
34
62
|
async send(opts) {
|
|
35
63
|
const { data, error } = await this.resend.emails.send({
|
|
36
64
|
from: this.from,
|
|
@@ -41,7 +69,7 @@ export class EmailClient {
|
|
|
41
69
|
text: opts.text,
|
|
42
70
|
});
|
|
43
71
|
if (error) {
|
|
44
|
-
logger.error("Failed to send email", {
|
|
72
|
+
logger.error("Failed to send email via Resend", {
|
|
45
73
|
to: opts.to,
|
|
46
74
|
template: opts.templateName,
|
|
47
75
|
error: error.message,
|
|
@@ -52,43 +80,63 @@ export class EmailClient {
|
|
|
52
80
|
id: data?.id || "",
|
|
53
81
|
success: true,
|
|
54
82
|
};
|
|
55
|
-
logger.info("Email sent", {
|
|
83
|
+
logger.info("Email sent via Resend", {
|
|
56
84
|
emailId: result.id,
|
|
57
85
|
to: opts.to,
|
|
58
86
|
template: opts.templateName,
|
|
59
87
|
userId: opts.userId,
|
|
60
88
|
});
|
|
61
|
-
if (this.onSend) {
|
|
62
|
-
try {
|
|
63
|
-
this.onSend(opts, result);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Audit callback failure should not break email sending
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
89
|
return result;
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
92
|
/**
|
|
73
93
|
* Create a lazily-initialized singleton EmailClient from environment variables.
|
|
74
94
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
95
|
+
* Backend selection (first match wins):
|
|
96
|
+
* 1. AWS SES — AWS_SES_REGION is set
|
|
97
|
+
* 2. Resend — RESEND_API_KEY is set
|
|
98
|
+
*
|
|
99
|
+
* Common env vars:
|
|
100
|
+
* - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
|
|
101
|
+
* - EMAIL_REPLY_TO (default: "support@wopr.bot") — reply-to address
|
|
102
|
+
*
|
|
103
|
+
* SES env vars:
|
|
104
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
105
|
+
* - AWS_ACCESS_KEY_ID
|
|
106
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
107
|
+
*
|
|
108
|
+
* Resend env vars:
|
|
109
|
+
* - RESEND_API_KEY
|
|
110
|
+
*
|
|
111
|
+
* Legacy env vars (still supported):
|
|
112
|
+
* - RESEND_FROM → falls back if EMAIL_FROM is not set
|
|
113
|
+
* - RESEND_REPLY_TO → falls back if EMAIL_REPLY_TO is not set
|
|
79
114
|
*/
|
|
80
115
|
let _client = null;
|
|
81
116
|
export function getEmailClient() {
|
|
82
117
|
if (!_client) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
const from = process.env.EMAIL_FROM || process.env.RESEND_FROM || "noreply@wopr.bot";
|
|
119
|
+
const replyTo = process.env.EMAIL_REPLY_TO || process.env.RESEND_REPLY_TO || "support@wopr.bot";
|
|
120
|
+
const sesRegion = process.env.AWS_SES_REGION;
|
|
121
|
+
if (sesRegion) {
|
|
122
|
+
const transport = new SesTransport({
|
|
123
|
+
region: sesRegion,
|
|
124
|
+
from,
|
|
125
|
+
replyTo,
|
|
126
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
127
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
128
|
+
});
|
|
129
|
+
_client = new EmailClient(transport);
|
|
130
|
+
logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
134
|
+
if (!apiKey) {
|
|
135
|
+
throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
|
|
136
|
+
}
|
|
137
|
+
_client = new EmailClient({ apiKey, from, replyTo });
|
|
138
|
+
logger.info("Email client initialized with Resend", { from });
|
|
86
139
|
}
|
|
87
|
-
_client = new EmailClient({
|
|
88
|
-
apiKey,
|
|
89
|
-
from: process.env.RESEND_FROM || "noreply@wopr.bot",
|
|
90
|
-
replyTo: process.env.RESEND_REPLY_TO || "support@wopr.bot",
|
|
91
|
-
});
|
|
92
140
|
}
|
|
93
141
|
return _client;
|
|
94
142
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
|
|
13
|
+
export interface SesTransportConfig {
|
|
14
|
+
region: string;
|
|
15
|
+
from: string;
|
|
16
|
+
replyTo?: string;
|
|
17
|
+
accessKeyId?: string;
|
|
18
|
+
secretAccessKey?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class SesTransport implements EmailTransport {
|
|
21
|
+
private client;
|
|
22
|
+
private from;
|
|
23
|
+
private replyTo;
|
|
24
|
+
constructor(config: SesTransportConfig);
|
|
25
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
|
|
13
|
+
import { logger } from "../config/logger.js";
|
|
14
|
+
export class SesTransport {
|
|
15
|
+
client;
|
|
16
|
+
from;
|
|
17
|
+
replyTo;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
const credentials = config.accessKeyId && config.secretAccessKey
|
|
20
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
21
|
+
: undefined;
|
|
22
|
+
this.client = new SESClient({
|
|
23
|
+
region: config.region,
|
|
24
|
+
...(credentials ? { credentials } : {}),
|
|
25
|
+
});
|
|
26
|
+
this.from = config.from;
|
|
27
|
+
this.replyTo = config.replyTo;
|
|
28
|
+
}
|
|
29
|
+
async send(opts) {
|
|
30
|
+
const command = new SendEmailCommand({
|
|
31
|
+
Source: this.from,
|
|
32
|
+
ReplyToAddresses: this.replyTo ? [this.replyTo] : undefined,
|
|
33
|
+
Destination: { ToAddresses: [opts.to] },
|
|
34
|
+
Message: {
|
|
35
|
+
Subject: { Data: opts.subject, Charset: "UTF-8" },
|
|
36
|
+
Body: {
|
|
37
|
+
Html: { Data: opts.html, Charset: "UTF-8" },
|
|
38
|
+
...(opts.text ? { Text: { Data: opts.text, Charset: "UTF-8" } } : {}),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.client.send(command);
|
|
44
|
+
const messageId = response.MessageId || "";
|
|
45
|
+
logger.info("Email sent via SES", {
|
|
46
|
+
messageId,
|
|
47
|
+
to: opts.to,
|
|
48
|
+
template: opts.templateName,
|
|
49
|
+
userId: opts.userId,
|
|
50
|
+
});
|
|
51
|
+
return { id: messageId, success: true };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
logger.error("Failed to send email via SES", {
|
|
55
|
+
to: opts.to,
|
|
56
|
+
template: opts.templateName,
|
|
57
|
+
userId: opts.userId,
|
|
58
|
+
error: error instanceof Error ? error.message : String(error),
|
|
59
|
+
});
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/gateway/proxy.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface ProxyDeps {
|
|
|
24
24
|
topUpUrl: string;
|
|
25
25
|
graceBufferCents?: number;
|
|
26
26
|
providers: ProviderConfig;
|
|
27
|
+
defaultModel?: string;
|
|
27
28
|
defaultMargin: number;
|
|
28
29
|
fetchFn: FetchFn;
|
|
29
30
|
arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
|
package/dist/gateway/proxy.js
CHANGED
|
@@ -52,6 +52,7 @@ export function buildProxyDeps(config) {
|
|
|
52
52
|
topUpUrl: config.topUpUrl ?? "/dashboard/credits",
|
|
53
53
|
graceBufferCents: config.graceBufferCents,
|
|
54
54
|
providers: config.providers,
|
|
55
|
+
defaultModel: config.defaultModel,
|
|
55
56
|
defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
|
|
56
57
|
fetchFn: config.fetchFn ?? fetch,
|
|
57
58
|
arbitrageRouter: config.arbitrageRouter,
|
|
@@ -108,18 +109,24 @@ export function chatCompletions(deps) {
|
|
|
108
109
|
return c.json({ error: creditErr }, 402);
|
|
109
110
|
}
|
|
110
111
|
// Parse body once — needed for both arbitrage routing and direct proxy.
|
|
111
|
-
const
|
|
112
|
+
const rawBody = await c.req.text();
|
|
112
113
|
let isStreaming = false;
|
|
113
114
|
let requestModel;
|
|
114
115
|
let parsedBody;
|
|
115
116
|
try {
|
|
116
|
-
parsedBody = JSON.parse(
|
|
117
|
+
parsedBody = JSON.parse(rawBody);
|
|
117
118
|
isStreaming = parsedBody?.stream === true;
|
|
119
|
+
// Enforce single-model gateway: override whatever model the client sent.
|
|
120
|
+
if (deps.defaultModel && parsedBody) {
|
|
121
|
+
parsedBody.model = deps.defaultModel;
|
|
122
|
+
}
|
|
118
123
|
requestModel = parsedBody?.model;
|
|
119
124
|
}
|
|
120
125
|
catch {
|
|
121
126
|
// Not valid JSON, assume non-streaming
|
|
122
127
|
}
|
|
128
|
+
// Re-serialize if model was overridden, otherwise forward raw body.
|
|
129
|
+
const body = deps.defaultModel && parsedBody ? JSON.stringify(parsedBody) : rawBody;
|
|
123
130
|
deps.metrics?.recordGatewayRequest("chat-completions");
|
|
124
131
|
// WOP-746: Arbitrage routing for non-streaming chat completions.
|
|
125
132
|
// Mirrors the TTS arbitrage pattern. When arbitrageRouter is present and
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -102,6 +102,10 @@ export interface ProviderConfig {
|
|
|
102
102
|
}
|
|
103
103
|
/** Full gateway configuration. */
|
|
104
104
|
export interface GatewayConfig {
|
|
105
|
+
/** Force all LLM requests to use this model, ignoring the client's model field.
|
|
106
|
+
* When set, the gateway rewrites body.model before forwarding to the upstream provider.
|
|
107
|
+
* This enforces "we serve one model" pricing — clients don't get to choose. */
|
|
108
|
+
defaultModel?: string;
|
|
105
109
|
/** MeterEmitter instance for usage tracking */
|
|
106
110
|
meter: MeterEmitter;
|
|
107
111
|
/** BudgetChecker instance for pre-call budget validation */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
},
|
|
129
129
|
"packageManager": "pnpm@10.31.0",
|
|
130
130
|
"dependencies": {
|
|
131
|
+
"@aws-sdk/client-ses": "^3.1014.0",
|
|
131
132
|
"@hono/node-server": "^1.19.11",
|
|
132
133
|
"@noble/hashes": "^2.0.1",
|
|
133
134
|
"@scure/base": "^2.0.0",
|
package/src/email/client.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email Client — Template-based transactional email sender.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports two backends:
|
|
5
|
+
* - **Resend**: Set RESEND_API_KEY env var
|
|
6
|
+
* - **AWS SES**: Set AWS_SES_REGION env var (+ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
7
|
+
*
|
|
8
|
+
* SES takes priority when both are configured.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { Resend } from "resend";
|
|
9
12
|
import { logger } from "../config/logger.js";
|
|
13
|
+
import { SesTransport } from "./ses-transport.js";
|
|
10
14
|
|
|
11
15
|
export interface EmailClientConfig {
|
|
12
16
|
apiKey: string;
|
|
@@ -30,26 +34,31 @@ export interface EmailSendResult {
|
|
|
30
34
|
success: boolean;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/** Transport abstraction — any backend that can send an email. */
|
|
38
|
+
export interface EmailTransport {
|
|
39
|
+
send(opts: SendTemplateEmailOpts): Promise<EmailSendResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
/**
|
|
34
|
-
* Transactional email client
|
|
43
|
+
* Transactional email client with pluggable transport.
|
|
35
44
|
*
|
|
36
45
|
* Usage:
|
|
37
46
|
* ```ts
|
|
38
|
-
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@
|
|
47
|
+
* const client = new EmailClient({ apiKey: "re_xxx", from: "noreply@example.com" });
|
|
39
48
|
* const template = verifyEmailTemplate(url, email);
|
|
40
49
|
* await client.send({ to: email, ...template, userId: "user-123", templateName: "verify-email" });
|
|
41
50
|
* ```
|
|
42
51
|
*/
|
|
43
52
|
export class EmailClient {
|
|
44
|
-
private
|
|
45
|
-
private from: string;
|
|
46
|
-
private replyTo: string | undefined;
|
|
53
|
+
private transport: EmailTransport;
|
|
47
54
|
private onSend: ((opts: SendTemplateEmailOpts, result: EmailSendResult) => void) | null = null;
|
|
48
55
|
|
|
49
|
-
constructor(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
constructor(configOrTransport: EmailClientConfig | EmailTransport) {
|
|
57
|
+
if ("send" in configOrTransport) {
|
|
58
|
+
this.transport = configOrTransport;
|
|
59
|
+
} else {
|
|
60
|
+
this.transport = new ResendTransport(configOrTransport);
|
|
61
|
+
}
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Register a callback invoked after each successful send (for audit logging). */
|
|
@@ -58,6 +67,33 @@ export class EmailClient {
|
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/** Send a transactional email. */
|
|
70
|
+
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
71
|
+
const result = await this.transport.send(opts);
|
|
72
|
+
|
|
73
|
+
if (this.onSend) {
|
|
74
|
+
try {
|
|
75
|
+
this.onSend(opts, result);
|
|
76
|
+
} catch {
|
|
77
|
+
// Audit callback failure should not break email sending
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resend-backed transport (original implementation). */
|
|
86
|
+
class ResendTransport implements EmailTransport {
|
|
87
|
+
private resend: Resend;
|
|
88
|
+
private from: string;
|
|
89
|
+
private replyTo: string | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(config: EmailClientConfig) {
|
|
92
|
+
this.resend = new Resend(config.apiKey);
|
|
93
|
+
this.from = config.from;
|
|
94
|
+
this.replyTo = config.replyTo;
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
62
98
|
const { data, error } = await this.resend.emails.send({
|
|
63
99
|
from: this.from,
|
|
@@ -69,7 +105,7 @@ export class EmailClient {
|
|
|
69
105
|
});
|
|
70
106
|
|
|
71
107
|
if (error) {
|
|
72
|
-
logger.error("Failed to send email", {
|
|
108
|
+
logger.error("Failed to send email via Resend", {
|
|
73
109
|
to: opts.to,
|
|
74
110
|
template: opts.templateName,
|
|
75
111
|
error: error.message,
|
|
@@ -82,21 +118,13 @@ export class EmailClient {
|
|
|
82
118
|
success: true,
|
|
83
119
|
};
|
|
84
120
|
|
|
85
|
-
logger.info("Email sent", {
|
|
121
|
+
logger.info("Email sent via Resend", {
|
|
86
122
|
emailId: result.id,
|
|
87
123
|
to: opts.to,
|
|
88
124
|
template: opts.templateName,
|
|
89
125
|
userId: opts.userId,
|
|
90
126
|
});
|
|
91
127
|
|
|
92
|
-
if (this.onSend) {
|
|
93
|
-
try {
|
|
94
|
-
this.onSend(opts, result);
|
|
95
|
-
} catch {
|
|
96
|
-
// Audit callback failure should not break email sending
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
128
|
return result;
|
|
101
129
|
}
|
|
102
130
|
}
|
|
@@ -104,24 +132,52 @@ export class EmailClient {
|
|
|
104
132
|
/**
|
|
105
133
|
* Create a lazily-initialized singleton EmailClient from environment variables.
|
|
106
134
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
135
|
+
* Backend selection (first match wins):
|
|
136
|
+
* 1. AWS SES — AWS_SES_REGION is set
|
|
137
|
+
* 2. Resend — RESEND_API_KEY is set
|
|
138
|
+
*
|
|
139
|
+
* Common env vars:
|
|
140
|
+
* - EMAIL_FROM (default: "noreply@wopr.bot") — sender address
|
|
141
|
+
* - EMAIL_REPLY_TO (default: "support@wopr.bot") — reply-to address
|
|
142
|
+
*
|
|
143
|
+
* SES env vars:
|
|
144
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
145
|
+
* - AWS_ACCESS_KEY_ID
|
|
146
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
147
|
+
*
|
|
148
|
+
* Resend env vars:
|
|
149
|
+
* - RESEND_API_KEY
|
|
150
|
+
*
|
|
151
|
+
* Legacy env vars (still supported):
|
|
152
|
+
* - RESEND_FROM → falls back if EMAIL_FROM is not set
|
|
153
|
+
* - RESEND_REPLY_TO → falls back if EMAIL_REPLY_TO is not set
|
|
111
154
|
*/
|
|
112
155
|
let _client: EmailClient | null = null;
|
|
113
156
|
|
|
114
157
|
export function getEmailClient(): EmailClient {
|
|
115
158
|
if (!_client) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
159
|
+
const from = process.env.EMAIL_FROM || process.env.RESEND_FROM || "noreply@wopr.bot";
|
|
160
|
+
const replyTo = process.env.EMAIL_REPLY_TO || process.env.RESEND_REPLY_TO || "support@wopr.bot";
|
|
161
|
+
|
|
162
|
+
const sesRegion = process.env.AWS_SES_REGION;
|
|
163
|
+
if (sesRegion) {
|
|
164
|
+
const transport = new SesTransport({
|
|
165
|
+
region: sesRegion,
|
|
166
|
+
from,
|
|
167
|
+
replyTo,
|
|
168
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
169
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
170
|
+
});
|
|
171
|
+
_client = new EmailClient(transport);
|
|
172
|
+
logger.info("Email client initialized with AWS SES", { region: sesRegion, from });
|
|
173
|
+
} else {
|
|
174
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
175
|
+
if (!apiKey) {
|
|
176
|
+
throw new Error("Either AWS_SES_REGION or RESEND_API_KEY environment variable is required");
|
|
177
|
+
}
|
|
178
|
+
_client = new EmailClient({ apiKey, from, replyTo });
|
|
179
|
+
logger.info("Email client initialized with Resend", { from });
|
|
119
180
|
}
|
|
120
|
-
_client = new EmailClient({
|
|
121
|
-
apiKey,
|
|
122
|
-
from: process.env.RESEND_FROM || "noreply@wopr.bot",
|
|
123
|
-
replyTo: process.env.RESEND_REPLY_TO || "support@wopr.bot",
|
|
124
|
-
});
|
|
125
181
|
}
|
|
126
182
|
return _client;
|
|
127
183
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SES Email Transport.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in alternative to Resend for transactional email.
|
|
5
|
+
* Activated when AWS_SES_REGION env var is set.
|
|
6
|
+
*
|
|
7
|
+
* Required env vars:
|
|
8
|
+
* - AWS_SES_REGION (e.g. "us-east-1")
|
|
9
|
+
* - AWS_ACCESS_KEY_ID
|
|
10
|
+
* - AWS_SECRET_ACCESS_KEY
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
|
|
14
|
+
import { logger } from "../config/logger.js";
|
|
15
|
+
import type { EmailSendResult, EmailTransport, SendTemplateEmailOpts } from "./client.js";
|
|
16
|
+
|
|
17
|
+
export interface SesTransportConfig {
|
|
18
|
+
region: string;
|
|
19
|
+
from: string;
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
accessKeyId?: string;
|
|
22
|
+
secretAccessKey?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SesTransport implements EmailTransport {
|
|
26
|
+
private client: SESClient;
|
|
27
|
+
private from: string;
|
|
28
|
+
private replyTo: string | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(config: SesTransportConfig) {
|
|
31
|
+
const credentials =
|
|
32
|
+
config.accessKeyId && config.secretAccessKey
|
|
33
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
this.client = new SESClient({
|
|
37
|
+
region: config.region,
|
|
38
|
+
...(credentials ? { credentials } : {}),
|
|
39
|
+
});
|
|
40
|
+
this.from = config.from;
|
|
41
|
+
this.replyTo = config.replyTo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
|
|
45
|
+
const command = new SendEmailCommand({
|
|
46
|
+
Source: this.from,
|
|
47
|
+
ReplyToAddresses: this.replyTo ? [this.replyTo] : undefined,
|
|
48
|
+
Destination: { ToAddresses: [opts.to] },
|
|
49
|
+
Message: {
|
|
50
|
+
Subject: { Data: opts.subject, Charset: "UTF-8" },
|
|
51
|
+
Body: {
|
|
52
|
+
Html: { Data: opts.html, Charset: "UTF-8" },
|
|
53
|
+
...(opts.text ? { Text: { Data: opts.text, Charset: "UTF-8" } } : {}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const response = await this.client.send(command);
|
|
60
|
+
const messageId = response.MessageId || "";
|
|
61
|
+
|
|
62
|
+
logger.info("Email sent via SES", {
|
|
63
|
+
messageId,
|
|
64
|
+
to: opts.to,
|
|
65
|
+
template: opts.templateName,
|
|
66
|
+
userId: opts.userId,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { id: messageId, success: true };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.error("Failed to send email via SES", {
|
|
72
|
+
to: opts.to,
|
|
73
|
+
template: opts.templateName,
|
|
74
|
+
userId: opts.userId,
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/gateway/proxy.ts
CHANGED
|
@@ -67,6 +67,7 @@ export interface ProxyDeps {
|
|
|
67
67
|
topUpUrl: string;
|
|
68
68
|
graceBufferCents?: number;
|
|
69
69
|
providers: ProviderConfig;
|
|
70
|
+
defaultModel?: string;
|
|
70
71
|
defaultMargin: number;
|
|
71
72
|
fetchFn: FetchFn;
|
|
72
73
|
arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
|
|
@@ -91,6 +92,7 @@ export function buildProxyDeps(config: GatewayConfig): ProxyDeps {
|
|
|
91
92
|
topUpUrl: config.topUpUrl ?? "/dashboard/credits",
|
|
92
93
|
graceBufferCents: config.graceBufferCents,
|
|
93
94
|
providers: config.providers,
|
|
95
|
+
defaultModel: config.defaultModel,
|
|
94
96
|
defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
|
|
95
97
|
fetchFn: config.fetchFn ?? fetch,
|
|
96
98
|
arbitrageRouter: config.arbitrageRouter,
|
|
@@ -166,7 +168,7 @@ export function chatCompletions(deps: ProxyDeps) {
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
// Parse body once — needed for both arbitrage routing and direct proxy.
|
|
169
|
-
const
|
|
171
|
+
const rawBody = await c.req.text();
|
|
170
172
|
let isStreaming = false;
|
|
171
173
|
let requestModel: string | undefined;
|
|
172
174
|
let parsedBody:
|
|
@@ -179,12 +181,18 @@ export function chatCompletions(deps: ProxyDeps) {
|
|
|
179
181
|
}
|
|
180
182
|
| undefined;
|
|
181
183
|
try {
|
|
182
|
-
parsedBody = JSON.parse(
|
|
184
|
+
parsedBody = JSON.parse(rawBody) as typeof parsedBody;
|
|
183
185
|
isStreaming = parsedBody?.stream === true;
|
|
186
|
+
// Enforce single-model gateway: override whatever model the client sent.
|
|
187
|
+
if (deps.defaultModel && parsedBody) {
|
|
188
|
+
parsedBody.model = deps.defaultModel;
|
|
189
|
+
}
|
|
184
190
|
requestModel = parsedBody?.model;
|
|
185
191
|
} catch {
|
|
186
192
|
// Not valid JSON, assume non-streaming
|
|
187
193
|
}
|
|
194
|
+
// Re-serialize if model was overridden, otherwise forward raw body.
|
|
195
|
+
const body = deps.defaultModel && parsedBody ? JSON.stringify(parsedBody) : rawBody;
|
|
188
196
|
|
|
189
197
|
deps.metrics?.recordGatewayRequest("chat-completions");
|
|
190
198
|
|
package/src/gateway/types.ts
CHANGED
|
@@ -98,6 +98,10 @@ export interface ProviderConfig {
|
|
|
98
98
|
|
|
99
99
|
/** Full gateway configuration. */
|
|
100
100
|
export interface GatewayConfig {
|
|
101
|
+
/** Force all LLM requests to use this model, ignoring the client's model field.
|
|
102
|
+
* When set, the gateway rewrites body.model before forwarding to the upstream provider.
|
|
103
|
+
* This enforces "we serve one model" pricing — clients don't get to choose. */
|
|
104
|
+
defaultModel?: string;
|
|
101
105
|
/** MeterEmitter instance for usage tracking */
|
|
102
106
|
meter: MeterEmitter;
|
|
103
107
|
/** BudgetChecker instance for pre-call budget validation */
|