nixamp 0.36.4 → 0.37.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/ai-keys.d.ts +9 -0
- package/dist/ai-keys.js +46 -0
- package/dist/ai-radio.d.ts +148 -0
- package/dist/ai-radio.js +477 -0
- package/dist/ai-routes.d.ts +31 -0
- package/dist/ai-routes.js +191 -0
- package/dist/ai-text.d.ts +23 -0
- package/dist/ai-text.js +102 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/listener-shares.d.ts +136 -0
- package/dist/listener-shares.js +486 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +62 -1
- package/dist/station-cli.js +166 -0
- package/dist/station-guide.d.ts +4 -1
- package/dist/station-guide.js +118 -21
- package/dist/station-mcp.d.ts +291 -20
- package/dist/station-mcp.js +66 -0
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +8 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +19 -0
- package/package.json +1 -1
- package/src/ai-keys.ts +49 -0
- package/src/ai-radio.ts +536 -0
- package/src/ai-routes.ts +220 -0
- package/src/ai-text.ts +117 -0
- package/src/library.ts +3 -0
- package/src/listener-shares.ts +528 -0
- package/src/server.ts +69 -1
- package/src/station-cli.ts +153 -0
- package/src/station-guide.ts +122 -21
- package/src/station-mcp.ts +62 -0
- package/src/station-routes.ts +11 -2
- package/src/station-usage.ts +19 -0
- package/web/dist/assets/{ar-B9jc6q-w.js → ar-B2m5eyoV.js} +1 -1
- package/web/dist/assets/{cs-lU25OfSM.js → cs-Cc5pOkWw.js} +1 -1
- package/web/dist/assets/{da-CAgdgQoL.js → da-10XVgmQy.js} +1 -1
- package/web/dist/assets/{de-CiusGizF.js → de-DIBX68fo.js} +1 -1
- package/web/dist/assets/{es-DVxl-KYi.js → es-B5wXCbD_.js} +1 -1
- package/web/dist/assets/{fi-C1-78Aib.js → fi-CfqtJU25.js} +1 -1
- package/web/dist/assets/{fr-BcAXoFCf.js → fr-DVpDbDWM.js} +1 -1
- package/web/dist/assets/{hi-DXdG5DSQ.js → hi-iQgB7g2O.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-C3dxJvEy.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
- package/web/dist/assets/{hu-qrAMY3zu.js → hu-cL8uAJW0.js} +1 -1
- package/web/dist/assets/{id-Cgtr-WML.js → id-DAEQSrx8.js} +1 -1
- package/web/dist/assets/{it-kuAXXts8.js → it-DARHJ2-F.js} +1 -1
- package/web/dist/assets/{main-C5IsPzDk.js → main-Bdbkg8vi.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-CYjdm0vd.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
- package/web/dist/assets/{mpegts-BWzEpcbk.js → mpegts-vNdAhc1w.js} +1 -1
- package/web/dist/assets/{nl-CEXpOYtY.js → nl-BNskTvYD.js} +1 -1
- package/web/dist/assets/{ru-v7b_XixQ.js → ru-PA8keJv_.js} +1 -1
- package/web/dist/assets/{sv-CJK5wtih.js → sv-sZu9UVEg.js} +1 -1
- package/web/dist/assets/{uk-qDSjg5zB.js → uk-FmHT0lvK.js} +1 -1
- package/web/dist/assets/{vi-kckrzXyJ.js → vi-BV81HbEM.js} +1 -1
- package/web/dist/assets/{zh-Cbq-3ikH.js → zh-CwzApCu7.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/src/ai-routes.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI radio and listener shares over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Public, no key, because sharing and reporting are what the guide offers
|
|
5
|
+
* anybody: `/api/v1/share` (what may be shared, and a link to share),
|
|
6
|
+
* `/api/v1/share/upload` (the file as the body), `/api/v1/share/:id?t=`
|
|
7
|
+
* (how it went; DELETE takes it back) and `/api/v1/share/report` (take what
|
|
8
|
+
* is playing off the air). Everything else is the owner's, under
|
|
9
|
+
* /api/v1/stations/ai and /api/v1/stations/shares.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import { AI_KEY_NAMES, aiKeysSet, saveAiKey, type AiKeyName } from "./ai-keys.ts";
|
|
13
|
+
import type { AiRadio } from "./ai-radio.ts";
|
|
14
|
+
import { SHARE_BASES, SHARE_GENRES, ownerShareView, sharerView, type ListenerShares } from "./listener-shares.ts";
|
|
15
|
+
import { StationError } from "./stations.ts";
|
|
16
|
+
|
|
17
|
+
const PUBLIC = /^\/api\/v1\/share(?:\/(upload|report|[a-f0-9]{12}))?$/;
|
|
18
|
+
|
|
19
|
+
/** Whether this path is one anybody may use without the server's key. */
|
|
20
|
+
export function isPublicSharePath(path: string): boolean {
|
|
21
|
+
return PUBLIC.test(path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AiRoutesContext {
|
|
25
|
+
ai?: AiRadio;
|
|
26
|
+
shares?: ListenerShares;
|
|
27
|
+
canControl: boolean;
|
|
28
|
+
/** Who is asking, for per-sharer limits (an address; hashed before it is kept). */
|
|
29
|
+
caller: string;
|
|
30
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
31
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
/** Null to go ahead, or how long to wait: a public limit on top of the per-sharer one. */
|
|
34
|
+
throttle?: () => { retryAfter: number } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function jsonBody(ctx: AiRoutesContext, request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
38
|
+
const raw = await ctx.readBody(request);
|
|
39
|
+
if (raw.trim() === "") return {};
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new StationError("bad_json", "the body is not JSON");
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
|
|
47
|
+
return parsed as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Answers the paths above; false when the path is not one of them. */
|
|
51
|
+
export async function handleAiRoutes(
|
|
52
|
+
request: IncomingMessage,
|
|
53
|
+
response: ServerResponse,
|
|
54
|
+
path: string,
|
|
55
|
+
url: URL,
|
|
56
|
+
ctx: AiRoutesContext,
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
const method = request.method ?? "GET";
|
|
59
|
+
const publicPath = PUBLIC.exec(path);
|
|
60
|
+
const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/");
|
|
61
|
+
if (!publicPath && !ownerPath) return false;
|
|
62
|
+
try {
|
|
63
|
+
if (publicPath) {
|
|
64
|
+
const shares = ctx.shares;
|
|
65
|
+
if (!shares) {
|
|
66
|
+
ctx.json(response, 404, { error: "this server does not take shared tracks", code: "not_found" });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const what = publicPath[1];
|
|
70
|
+
if (what === undefined && method === "GET") {
|
|
71
|
+
const setting = shares.setting();
|
|
72
|
+
ctx.json(response, 200, { open: setting.open, bases: SHARE_BASES, genres: SHARE_GENRES, maxMb: Math.round(setting.maxBytes / 1048576), perHour: setting.perHour });
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (method !== "GET" && method !== "DELETE" && ctx.throttle) {
|
|
76
|
+
const wait = ctx.throttle();
|
|
77
|
+
if (wait) {
|
|
78
|
+
response.setHeader("retry-after", String(wait.retryAfter));
|
|
79
|
+
ctx.json(response, 429, { error: "too many at once; wait a moment", code: "rate_limited", retryAfter: wait.retryAfter });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (what === undefined && method === "POST") {
|
|
84
|
+
const share = shares.fromUrl(await jsonBody(ctx, request), ctx.caller);
|
|
85
|
+
ctx.json(response, 202, { share: sharerView(share), token: share.token, status: `/api/v1/share/${share.id}?t=${share.token}` });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (what === "upload" && (method === "PUT" || method === "POST")) {
|
|
89
|
+
const input = Object.fromEntries(url.searchParams.entries());
|
|
90
|
+
const share = await shares.fromUpload(request, input, ctx.caller);
|
|
91
|
+
ctx.json(response, 202, { share: sharerView(share), token: share.token, status: `/api/v1/share/${share.id}?t=${share.token}` });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (what === "report" && method === "POST") {
|
|
95
|
+
const input = await jsonBody(ctx, request);
|
|
96
|
+
ctx.json(response, 200, shares.report(input, ctx.caller));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (what && /^[a-f0-9]{12}$/.test(what) && (method === "GET" || method === "DELETE")) {
|
|
100
|
+
const share = shares.forSharer(what, url.searchParams.get("t") ?? "");
|
|
101
|
+
if (!share) {
|
|
102
|
+
ctx.json(response, 404, { error: "no such share, or not with that link", code: "not_found" });
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
ctx.json(response, 200, { share: sharerView(method === "DELETE" ? shares.withdraw(share) : share) });
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!ctx.canControl) {
|
|
113
|
+
ctx.json(response, 403, { error: "only this server's owner manages AI radio and shares", code: "forbidden" });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")) {
|
|
118
|
+
const shares = ctx.shares;
|
|
119
|
+
if (!shares) {
|
|
120
|
+
ctx.json(response, 404, { error: "shares are not available here", code: "not_found" });
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (path === "/api/v1/stations/shares") {
|
|
124
|
+
if (method === "GET") {
|
|
125
|
+
const list = shares.list(url.searchParams.get("state") ?? undefined);
|
|
126
|
+
ctx.json(response, 200, { setting: shares.setting(), total: list.length, shares: list.slice(0, 200).map(ownerShareView) });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (method === "POST") {
|
|
130
|
+
ctx.json(response, 200, { setting: shares.configure(await jsonBody(ctx, request)) });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const match = /^\/api\/v1\/stations\/shares\/([a-f0-9]{12})(?:\/(approve|reject))?$/.exec(path);
|
|
135
|
+
if (match) {
|
|
136
|
+
const [, id, decision] = match;
|
|
137
|
+
if (!decision && method === "GET") {
|
|
138
|
+
const share = shares.get(id!);
|
|
139
|
+
if (!share) throw new StationError("not_found", "no such share", 404);
|
|
140
|
+
ctx.json(response, 200, { share: ownerShareView(share) });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (!decision && method === "DELETE") {
|
|
144
|
+
shares.decide(id!, "delete");
|
|
145
|
+
ctx.json(response, 200, { deleted: id });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (decision && method === "POST") {
|
|
149
|
+
const input = await jsonBody(ctx, request);
|
|
150
|
+
const share = shares.decide(id!, decision as "approve" | "reject", typeof input["reason"] === "string" ? input["reason"].slice(0, 300) : "");
|
|
151
|
+
ctx.json(response, 200, { share: share ? ownerShareView(share) : null });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
ctx.json(response, 404, { error: "no such share route", code: "not_found" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ai = ctx.ai;
|
|
160
|
+
if (!ai) {
|
|
161
|
+
ctx.json(response, 404, { error: "AI radio is not available here", code: "not_found" });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (path === "/api/v1/stations/ai") {
|
|
165
|
+
if (method === "GET") {
|
|
166
|
+
ctx.json(response, 200, { ...ai.status(), keys: aiKeysSet() });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (method === "POST") {
|
|
170
|
+
ctx.json(response, 200, { settings: ai.configure(await jsonBody(ctx, request)) });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (path === "/api/v1/stations/ai/keys" && method === "POST") {
|
|
175
|
+
const input = await jsonBody(ctx, request);
|
|
176
|
+
const name = String(input["name"] ?? "");
|
|
177
|
+
if (!AI_KEY_NAMES.includes(name as AiKeyName)) throw new StationError("unknown_key", `keys: ${AI_KEY_NAMES.join(", ")}`);
|
|
178
|
+
saveAiKey(name as AiKeyName, typeof input["value"] === "string" ? input["value"] : "");
|
|
179
|
+
ctx.json(response, 200, { keys: aiKeysSet() });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (path === "/api/v1/stations/ai/stations" && method === "POST") {
|
|
183
|
+
ctx.json(response, 201, { station: ai.create(await jsonBody(ctx, request)) });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const match = /^\/api\/v1\/stations\/ai\/stations\/([a-f0-9]{10})(?:\/(generate))?$/.exec(path);
|
|
187
|
+
if (match) {
|
|
188
|
+
const [, key, action] = match;
|
|
189
|
+
if (!action && method === "GET") {
|
|
190
|
+
const station = ai.get(key!);
|
|
191
|
+
if (!station) throw new StationError("not_found", "no such AI station", 404);
|
|
192
|
+
ctx.json(response, 200, { station });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (!action && method === "PATCH") {
|
|
196
|
+
ctx.json(response, 200, { station: ai.update(key!, await jsonBody(ctx, request)) });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (!action && method === "DELETE") {
|
|
200
|
+
ai.remove(key!, url.searchParams.get("files") === "1");
|
|
201
|
+
ctx.json(response, 200, { deleted: key });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (action === "generate" && method === "POST") {
|
|
205
|
+
// Composing takes a minute or two; answer when it is on the station.
|
|
206
|
+
ctx.json(response, 200, { track: await ai.generate(key!) });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
ctx.json(response, 404, { error: "no such AI radio route", code: "not_found" });
|
|
211
|
+
return true;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof StationError) {
|
|
214
|
+
ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
215
|
+
} else {
|
|
216
|
+
ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/ai-text.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One question to a language model, one JSON answer.
|
|
3
|
+
*
|
|
4
|
+
* AI radio asks for small things: a title and a prompt for the next track, a
|
|
5
|
+
* genre and a verdict for a shared file. Whichever model has a key answers --
|
|
6
|
+
* Gemini, then OpenAI, then Anthropic -- and when none has, the caller is told
|
|
7
|
+
* so and does without (a rule of its own, or a person). The answer is parsed
|
|
8
|
+
* and handed back as an object; a model that rambles round its JSON is read
|
|
9
|
+
* for the first object in what it said.
|
|
10
|
+
*/
|
|
11
|
+
import { aiKey } from "./ai-keys.ts";
|
|
12
|
+
|
|
13
|
+
export interface AskOptions {
|
|
14
|
+
/** What the model is for, said once. */
|
|
15
|
+
system: string;
|
|
16
|
+
/** The question, with what it should answer from. */
|
|
17
|
+
prompt: string;
|
|
18
|
+
/** Milliseconds before giving up (default 30 s). */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
fetcher?: typeof fetch;
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Answer {
|
|
25
|
+
/** Which model answered, for the record ("gemini-flash-latest", ...). */
|
|
26
|
+
model: string;
|
|
27
|
+
value: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class NoModel extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The first JSON object in a model's answer. */
|
|
37
|
+
export function firstObject(text: string): Record<string, unknown> {
|
|
38
|
+
const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(cleaned) as unknown;
|
|
41
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
42
|
+
} catch {
|
|
43
|
+
// Look inside it instead.
|
|
44
|
+
}
|
|
45
|
+
const start = cleaned.indexOf("{");
|
|
46
|
+
const end = cleaned.lastIndexOf("}");
|
|
47
|
+
if (start !== -1 && end > start) {
|
|
48
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1)) as unknown;
|
|
49
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
throw new Error("the model did not answer with a JSON object");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether any model can be asked. */
|
|
55
|
+
export function hasModel(env: Record<string, string | undefined> = process.env): boolean {
|
|
56
|
+
return Boolean(aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function askJson(options: AskOptions): Promise<Answer> {
|
|
60
|
+
const env = options.env ?? process.env;
|
|
61
|
+
const send = options.fetcher ?? fetch;
|
|
62
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
63
|
+
const gemini = aiKey("GEMINI_API_KEY", env);
|
|
64
|
+
const openai = aiKey("OPENAI_API_KEY", env);
|
|
65
|
+
const anthropic = aiKey("ANTHROPIC_API_KEY", env);
|
|
66
|
+
if (gemini) {
|
|
67
|
+
const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
|
|
68
|
+
const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
signal,
|
|
71
|
+
headers: { "content-type": "application/json", "x-goog-api-key": gemini },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
systemInstruction: { parts: [{ text: options.system }] },
|
|
74
|
+
contents: [{ role: "user", parts: [{ text: options.prompt }] }],
|
|
75
|
+
generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) throw new Error(`gemini answered HTTP ${response.status}`);
|
|
79
|
+
const body = (await response.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> };
|
|
80
|
+
const text = body.candidates?.[0]?.content?.parts?.map((one) => one.text ?? "").join("") ?? "";
|
|
81
|
+
return { model, value: firstObject(text) };
|
|
82
|
+
}
|
|
83
|
+
if (openai) {
|
|
84
|
+
const model = env["NIXAMP_OPENAI_MODEL"] || "gpt-4.1-mini";
|
|
85
|
+
const response = await send("https://api.openai.com/v1/chat/completions", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
signal,
|
|
88
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
model,
|
|
91
|
+
response_format: { type: "json_object" },
|
|
92
|
+
messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) throw new Error(`openai answered HTTP ${response.status}`);
|
|
96
|
+
const body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
|
97
|
+
return { model, value: firstObject(body.choices?.[0]?.message?.content ?? "") };
|
|
98
|
+
}
|
|
99
|
+
if (anthropic) {
|
|
100
|
+
const model = env["NIXAMP_ANTHROPIC_MODEL"] || "claude-haiku-5-5";
|
|
101
|
+
const response = await send("https://api.anthropic.com/v1/messages", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
signal,
|
|
104
|
+
headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
model,
|
|
107
|
+
max_tokens: 1024,
|
|
108
|
+
system: `${options.system}\nAnswer with one JSON object and nothing else.`,
|
|
109
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) throw new Error(`anthropic answered HTTP ${response.status}`);
|
|
113
|
+
const body = (await response.json()) as { content?: Array<{ type: string; text?: string }> };
|
|
114
|
+
return { model, value: firstObject(body.content?.filter((one) => one.type === "text").map((one) => one.text ?? "").join("") ?? "") };
|
|
115
|
+
}
|
|
116
|
+
throw new NoModel();
|
|
117
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface Config {
|
|
|
31
31
|
guideHosts?: string[];
|
|
32
32
|
/** Station limits kept across restarts (station-metering.ts). */
|
|
33
33
|
limits?: Record<string, unknown>;
|
|
34
|
+
/** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
|
|
35
|
+
trustedProxies?: string[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export function configPath(): string {
|
|
@@ -47,6 +49,7 @@ export function readConfig(): Config {
|
|
|
47
49
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
48
50
|
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] as Record<string, unknown> } : {}),
|
|
49
51
|
...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] as Record<string, unknown> } : {}),
|
|
52
|
+
...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: (record["trustedProxies"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
50
53
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
|
|
51
54
|
};
|
|
52
55
|
} catch {
|