@neuralnomads/codenomad-dev 0.18.0-dev-20260712-c8d4a509 → 0.18.0-dev-20260714-f900af1b
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/server/routes/settings.js +48 -2
- package/dist/server/routes/settings.test.js +58 -0
- package/dist/server/routes/workspaces.js +3 -2
- package/dist/settings/public-config.js +14 -0
- package/dist/settings/public-config.test.js +56 -0
- package/dist/speech/service.js +121 -10
- package/dist/speech/service.test.js +247 -0
- package/dist/workspaces/__tests__/workspace-identity.test.js +203 -0
- package/dist/workspaces/manager.js +164 -42
- package/dist/workspaces/workspace-identity.js +27 -0
- package/package.json +1 -1
- package/public/assets/{FilesTab-DWE08QeL.js → FilesTab-CjdVemhr.js} +2 -2
- package/public/assets/{GitChangesTab-B0YpYXig.js → GitChangesTab-DdojyK0_.js} +2 -2
- package/public/assets/{SplitFilePanel-r8xyMSwo.js → SplitFilePanel-CyoPkcLd.js} +1 -1
- package/public/assets/{StatusTab-CiF7bc-C.js → StatusTab-BFMrZokn.js} +1 -1
- package/public/assets/{align-justify-D7GxsbXt.js → align-justify-DMVIVNIA.js} +1 -1
- package/public/assets/{bundle-full-CaqU_-Bu.js → bundle-full-D5dzvToA.js} +1 -1
- package/public/assets/{diff-viewer-BemESP1g.js → diff-viewer-BjsGQ25b.js} +1 -1
- package/public/assets/{index-CdOQKtdG.js → index-3KsjbWGP.js} +1 -1
- package/public/assets/{index-Hbpg8uhJ.js → index-B0WK8-cF.js} +1 -1
- package/public/assets/{index-BzdwA-jE.js → index-BDv_iVtj.js} +1 -1
- package/public/assets/index-BMvFMsuy.css +1 -0
- package/public/assets/{index-B5fSJ4Sy.js → index-BjxZvyb9.js} +2 -2
- package/public/assets/{index-CqPbeD4l.js → index-BleKf9Nj.js} +1 -1
- package/public/assets/{index-BIJEJ4fH.js → index-CiIQ43NW.js} +1 -1
- package/public/assets/{index-CrwkCZNr.js → index-DBquqL6Q.js} +1 -1
- package/public/assets/{index-DqvdokW8.js → index-UbL8tz_V.js} +1 -1
- package/public/assets/{index-CNR6iBgw.js → index-lf4nUOU1.js} +1 -1
- package/public/assets/{index-CuOhJGR_.js → index-yf9zolv4.js} +1 -1
- package/public/assets/{index-DosiZsbF.js → index-ypaL7i_s.js} +1 -1
- package/public/assets/{loading-VAuQsLpy.js → loading-CZ_tt3h0.js} +1 -1
- package/public/assets/main-Cix6E-1V.js +68 -0
- package/public/assets/{markdown-DVTO-nx2.js → markdown-CFrxfiCa.js} +3 -3
- package/public/assets/{monaco-viewer-B3Jl6a5p.js → monaco-viewer-CZefBfct.js} +11 -11
- package/public/assets/{tool-call-BtNDaNja.js → tool-call-BkVG9Pe4.js} +3 -3
- package/public/assets/{unified-picker-CHfDVhMa.js → unified-picker-BRNI3UvE.js} +1 -1
- package/public/assets/{wrap-text-SJDACYWw.js → wrap-text-DtDLects.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/index-CD8jskQn.css +0 -1
- package/public/assets/main-BcrMO00Y.js +0 -68
|
@@ -8,12 +8,52 @@ function validateBinaryPath(binaryPath) {
|
|
|
8
8
|
const result = probeBinaryVersion(binaryPath);
|
|
9
9
|
return { valid: result.valid, version: result.version, error: result.error };
|
|
10
10
|
}
|
|
11
|
+
export function enforceSpeechCredentialPairing(body, currentSpeech) {
|
|
12
|
+
if (!body || typeof body !== "object")
|
|
13
|
+
return body;
|
|
14
|
+
const patch = { ...body };
|
|
15
|
+
const speech = patch.speech;
|
|
16
|
+
if (!speech || typeof speech !== "object")
|
|
17
|
+
return patch;
|
|
18
|
+
const speechPatch = { ...speech };
|
|
19
|
+
const cur = (currentSpeech && typeof currentSpeech === "object") ? currentSpeech : {};
|
|
20
|
+
const curSpeech = (cur.speech && typeof cur.speech === "object") ? cur.speech : cur;
|
|
21
|
+
if ("baseUrl" in speechPatch && !("apiKey" in speechPatch)) {
|
|
22
|
+
if ((speechPatch.baseUrl ?? "") !== (curSpeech.baseUrl ?? "")) {
|
|
23
|
+
speechPatch.apiKey = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const dir of ["stt", "tts"]) {
|
|
27
|
+
if (dir in speechPatch) {
|
|
28
|
+
const dirPatch = { ...speechPatch[dir] };
|
|
29
|
+
if ("baseUrl" in dirPatch && !("apiKey" in dirPatch)) {
|
|
30
|
+
const curDir = (curSpeech[dir] && typeof curSpeech[dir] === "object") ? curSpeech[dir] : {};
|
|
31
|
+
if ((dirPatch.baseUrl ?? "") !== (curDir.baseUrl ?? "")) {
|
|
32
|
+
dirPatch.apiKey = null;
|
|
33
|
+
}
|
|
34
|
+
speechPatch[dir] = dirPatch;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
patch.speech = speechPatch;
|
|
39
|
+
return patch;
|
|
40
|
+
}
|
|
11
41
|
export function registerSettingsRoutes(app, deps) {
|
|
12
42
|
// Full-document access
|
|
13
43
|
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")));
|
|
14
44
|
app.patch("/api/storage/config", async (request, reply) => {
|
|
15
45
|
try {
|
|
16
|
-
|
|
46
|
+
let body = request.body ?? {};
|
|
47
|
+
if (body && typeof body === "object" && "server" in body) {
|
|
48
|
+
const bodyObj = { ...body };
|
|
49
|
+
const serverPatch = bodyObj.server;
|
|
50
|
+
if (serverPatch && typeof serverPatch === "object") {
|
|
51
|
+
const currentServer = deps.settings.getOwner("config", "server");
|
|
52
|
+
bodyObj.server = enforceSpeechCredentialPairing(serverPatch, currentServer);
|
|
53
|
+
}
|
|
54
|
+
body = bodyObj;
|
|
55
|
+
}
|
|
56
|
+
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", body));
|
|
17
57
|
}
|
|
18
58
|
catch (error) {
|
|
19
59
|
reply.code(400);
|
|
@@ -25,7 +65,13 @@ export function registerSettingsRoutes(app, deps) {
|
|
|
25
65
|
});
|
|
26
66
|
app.patch("/api/storage/config/:owner", async (request, reply) => {
|
|
27
67
|
try {
|
|
28
|
-
|
|
68
|
+
const currentOwner = request.params.owner === "server"
|
|
69
|
+
? deps.settings.getOwner("config", "server")
|
|
70
|
+
: undefined;
|
|
71
|
+
const processed = request.params.owner === "server"
|
|
72
|
+
? enforceSpeechCredentialPairing(request.body ?? {}, currentOwner)
|
|
73
|
+
: request.body ?? {};
|
|
74
|
+
return sanitizeConfigOwner(request.params.owner, deps.settings.mergePatchOwner("config", request.params.owner, processed));
|
|
29
75
|
}
|
|
30
76
|
catch (error) {
|
|
31
77
|
reply.code(400);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { enforceSpeechCredentialPairing } from "./settings";
|
|
4
|
+
describe("enforceSpeechCredentialPairing", () => {
|
|
5
|
+
it("clears shared key when baseUrl differs from stored", () => {
|
|
6
|
+
const result = enforceSpeechCredentialPairing({ speech: { baseUrl: "https://new.com/v1" } }, { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } });
|
|
7
|
+
assert.equal(result.speech.apiKey, null);
|
|
8
|
+
});
|
|
9
|
+
it("does NOT clear shared key when baseUrl matches stored (old client full patch)", () => {
|
|
10
|
+
const result = enforceSpeechCredentialPairing({ speech: { baseUrl: "https://api.openai.com/v1", ttsVoice: "alloy", playbackMode: "streaming" } }, { speech: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-stored" } });
|
|
11
|
+
assert.equal("apiKey" in result.speech, false, "must not clear key when URL unchanged");
|
|
12
|
+
});
|
|
13
|
+
it("clears directional key when directional baseUrl differs from stored", () => {
|
|
14
|
+
const result = enforceSpeechCredentialPairing({ speech: { stt: { baseUrl: "https://new.com/v1" } } }, { speech: { stt: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } } });
|
|
15
|
+
assert.equal(result.speech.stt.apiKey, null);
|
|
16
|
+
});
|
|
17
|
+
it("does NOT clear directional key when baseUrl matches stored", () => {
|
|
18
|
+
const result = enforceSpeechCredentialPairing({ speech: { stt: { baseUrl: "https://same.com/v1", model: "whisper-1" } } }, { speech: { stt: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } } });
|
|
19
|
+
assert.equal("apiKey" in result.speech.stt, false, "must not clear directional key when URL unchanged");
|
|
20
|
+
});
|
|
21
|
+
it("preserves apiKey when both baseUrl and apiKey are in patch", () => {
|
|
22
|
+
const result = enforceSpeechCredentialPairing({ speech: { baseUrl: "https://new.com/v1", apiKey: "sk-new" } }, { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-old" } });
|
|
23
|
+
assert.equal(result.speech.apiKey, "sk-new");
|
|
24
|
+
});
|
|
25
|
+
it("handles both stt and tts independently against stored values", () => {
|
|
26
|
+
const result = enforceSpeechCredentialPairing({ speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-new.com/v1" } } }, { speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-old.com/v1", apiKey: "sk-tts" } } });
|
|
27
|
+
assert.equal(result.speech.stt.apiKey, "sk-stt");
|
|
28
|
+
assert.equal(result.speech.tts.apiKey, null);
|
|
29
|
+
});
|
|
30
|
+
it("passes through non-speech patches unchanged", () => {
|
|
31
|
+
const body = { logLevel: "INFO" };
|
|
32
|
+
const result = enforceSpeechCredentialPairing(body, { speech: {} });
|
|
33
|
+
assert.deepEqual(result, body);
|
|
34
|
+
});
|
|
35
|
+
it("passes through null/undefined body", () => {
|
|
36
|
+
assert.equal(enforceSpeechCredentialPairing(null), null);
|
|
37
|
+
assert.equal(enforceSpeechCredentialPairing(undefined), undefined);
|
|
38
|
+
});
|
|
39
|
+
it("does not mutate the original body", () => {
|
|
40
|
+
const original = { speech: { baseUrl: "https://new.com/v1" } };
|
|
41
|
+
const originalCopy = JSON.parse(JSON.stringify(original));
|
|
42
|
+
enforceSpeechCredentialPairing(original, { speech: { baseUrl: "https://old.com/v1" } });
|
|
43
|
+
assert.deepEqual(original, originalCopy);
|
|
44
|
+
});
|
|
45
|
+
it("doc-level payload: clears key when server.speech.baseUrl changes", () => {
|
|
46
|
+
const docBody = { server: { speech: { baseUrl: "https://new.com/v1" } } };
|
|
47
|
+
const currentServer = { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } };
|
|
48
|
+
const result = enforceSpeechCredentialPairing(docBody.server, currentServer);
|
|
49
|
+
assert.equal(result.speech.apiKey, null, "doc-level server.speech must have key cleared on URL change");
|
|
50
|
+
assert.equal(result.speech.baseUrl, "https://new.com/v1");
|
|
51
|
+
});
|
|
52
|
+
it("doc-level payload: preserves key when server.speech.baseUrl unchanged", () => {
|
|
53
|
+
const docBody = { server: { speech: { baseUrl: "https://same.com/v1", ttsVoice: "alloy" } } };
|
|
54
|
+
const currentServer = { speech: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } };
|
|
55
|
+
const result = enforceSpeechCredentialPairing(docBody.server, currentServer);
|
|
56
|
+
assert.equal("apiKey" in result.speech, false, "key must not be cleared when URL unchanged in doc-level patch");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -7,6 +7,7 @@ import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory";
|
|
|
7
7
|
const WorkspaceCreateSchema = z.object({
|
|
8
8
|
path: z.string(),
|
|
9
9
|
name: z.string().optional(),
|
|
10
|
+
forceNew: z.boolean().optional(),
|
|
10
11
|
});
|
|
11
12
|
const WorkspaceCloneSchema = z.object({
|
|
12
13
|
repositoryUrl: z.string().trim().min(1, "Repository URL is required"),
|
|
@@ -51,9 +52,9 @@ export function registerWorkspaceRoutes(app, deps) {
|
|
|
51
52
|
app.post("/api/workspaces", async (request, reply) => {
|
|
52
53
|
try {
|
|
53
54
|
const body = WorkspaceCreateSchema.parse(request.body ?? {});
|
|
54
|
-
const
|
|
55
|
+
const result = await deps.workspaceManager.create(body.path, body.name, { forceNew: body.forceNew });
|
|
55
56
|
reply.code(201);
|
|
56
|
-
return workspace;
|
|
57
|
+
return result.created ? result.workspace : { ...result.workspace, reused: true };
|
|
57
58
|
}
|
|
58
59
|
catch (error) {
|
|
59
60
|
request.log.error({ err: error }, "Failed to create workspace");
|
|
@@ -15,6 +15,20 @@ function sanitizeServerOwner(value) {
|
|
|
15
15
|
else if (!("hasApiKey" in speech)) {
|
|
16
16
|
speech.hasApiKey = false;
|
|
17
17
|
}
|
|
18
|
+
for (const dir of ["stt", "tts"]) {
|
|
19
|
+
if (isPlainObject(speech[dir])) {
|
|
20
|
+
const sub = { ...speech[dir] };
|
|
21
|
+
const dirKey = typeof sub.apiKey === "string" ? sub.apiKey.trim() : "";
|
|
22
|
+
if (dirKey) {
|
|
23
|
+
delete sub.apiKey;
|
|
24
|
+
sub.hasApiKey = true;
|
|
25
|
+
}
|
|
26
|
+
else if (!("hasApiKey" in sub)) {
|
|
27
|
+
sub.hasApiKey = false;
|
|
28
|
+
}
|
|
29
|
+
speech[dir] = sub;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
18
32
|
next.speech = speech;
|
|
19
33
|
return next;
|
|
20
34
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { sanitizeConfigOwner } from "./public-config";
|
|
4
|
+
describe("sanitizeConfigOwner server speech", () => {
|
|
5
|
+
it("strips shared apiKey and sets hasApiKey", () => {
|
|
6
|
+
const result = sanitizeConfigOwner("server", {
|
|
7
|
+
speech: { apiKey: "sk-secret", sttModel: "whisper-1" },
|
|
8
|
+
});
|
|
9
|
+
assert.equal(result.speech.apiKey, undefined);
|
|
10
|
+
assert.equal(result.speech.hasApiKey, true);
|
|
11
|
+
});
|
|
12
|
+
it("strips stt.apiKey and sets stt.hasApiKey when separateProviders is true", () => {
|
|
13
|
+
const result = sanitizeConfigOwner("server", {
|
|
14
|
+
speech: {
|
|
15
|
+
separateProviders: true,
|
|
16
|
+
apiKey: "sk-shared",
|
|
17
|
+
stt: { apiKey: "sk-stt-secret", baseUrl: "https://groq.com" },
|
|
18
|
+
tts: { apiKey: "sk-tts-secret", baseUrl: "https://openai.com" },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
assert.equal(result.speech.apiKey, undefined);
|
|
22
|
+
assert.equal(result.speech.hasApiKey, true);
|
|
23
|
+
assert.equal(result.speech.stt.apiKey, undefined);
|
|
24
|
+
assert.equal(result.speech.stt.hasApiKey, true);
|
|
25
|
+
assert.equal(result.speech.tts.apiKey, undefined);
|
|
26
|
+
assert.equal(result.speech.tts.hasApiKey, true);
|
|
27
|
+
});
|
|
28
|
+
it("sets hasApiKey=false when per-direction apiKey is absent", () => {
|
|
29
|
+
const result = sanitizeConfigOwner("server", {
|
|
30
|
+
speech: {
|
|
31
|
+
separateProviders: true,
|
|
32
|
+
stt: { baseUrl: "https://groq.com" },
|
|
33
|
+
tts: { baseUrl: "https://openai.com" },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
assert.equal(result.speech.stt.hasApiKey, false);
|
|
37
|
+
assert.equal(result.speech.tts.hasApiKey, false);
|
|
38
|
+
});
|
|
39
|
+
it("preserves stt/tts sub-objects that have no apiKey", () => {
|
|
40
|
+
const result = sanitizeConfigOwner("server", {
|
|
41
|
+
speech: {
|
|
42
|
+
separateProviders: true,
|
|
43
|
+
stt: { model: "whisper-large-v3" },
|
|
44
|
+
tts: { model: "tts-1" },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
assert.equal(result.speech.stt.model, "whisper-large-v3");
|
|
48
|
+
assert.equal(result.speech.stt.hasApiKey, false);
|
|
49
|
+
assert.equal(result.speech.tts.model, "tts-1");
|
|
50
|
+
assert.equal(result.speech.tts.hasApiKey, false);
|
|
51
|
+
});
|
|
52
|
+
it("passes through non-server owners unchanged", () => {
|
|
53
|
+
const result = sanitizeConfigOwner("ui", { foo: "bar" });
|
|
54
|
+
assert.deepEqual(result, { foo: "bar" });
|
|
55
|
+
});
|
|
56
|
+
});
|
package/dist/speech/service.js
CHANGED
|
@@ -10,6 +10,21 @@ const ServerSpeechSettingsSchema = z.object({
|
|
|
10
10
|
ttsModel: z.string().optional(),
|
|
11
11
|
ttsVoice: z.string().optional(),
|
|
12
12
|
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
|
13
|
+
separateProviders: z.boolean().optional(),
|
|
14
|
+
stt: z
|
|
15
|
+
.object({
|
|
16
|
+
apiKey: z.string().nullish(),
|
|
17
|
+
baseUrl: z.string().nullish(),
|
|
18
|
+
model: z.string().nullish(),
|
|
19
|
+
})
|
|
20
|
+
.optional(),
|
|
21
|
+
tts: z
|
|
22
|
+
.object({
|
|
23
|
+
apiKey: z.string().nullish(),
|
|
24
|
+
baseUrl: z.string().nullish(),
|
|
25
|
+
model: z.string().nullish(),
|
|
26
|
+
})
|
|
27
|
+
.optional(),
|
|
13
28
|
})
|
|
14
29
|
.optional(),
|
|
15
30
|
});
|
|
@@ -24,27 +39,65 @@ export class SpeechService {
|
|
|
24
39
|
this.logger = logger;
|
|
25
40
|
}
|
|
26
41
|
getCapabilities() {
|
|
27
|
-
|
|
42
|
+
const parsed = this.parseConfig();
|
|
43
|
+
const speech = parsed.speech ?? {};
|
|
44
|
+
const separate = speech.separateProviders === true;
|
|
45
|
+
const sttSettings = this.resolveSttSettingsFrom(speech);
|
|
46
|
+
const ttsSettings = this.resolveTtsSettingsFrom(speech);
|
|
47
|
+
const sttConfigured = Boolean(sttSettings.apiKey);
|
|
48
|
+
const ttsConfigured = Boolean(ttsSettings.apiKey);
|
|
49
|
+
return {
|
|
50
|
+
available: true,
|
|
51
|
+
configured: separate ? (sttConfigured && ttsConfigured) : (sttConfigured || ttsConfigured),
|
|
52
|
+
provider: sttSettings.provider,
|
|
53
|
+
supportsStt: true,
|
|
54
|
+
supportsTts: true,
|
|
55
|
+
supportsStreamingTts: true,
|
|
56
|
+
baseUrl: separate ? undefined : (speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined),
|
|
57
|
+
sttModel: sttSettings.sttModel,
|
|
58
|
+
ttsModel: ttsSettings.ttsModel,
|
|
59
|
+
ttsVoice: ttsSettings.ttsVoice,
|
|
60
|
+
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
|
61
|
+
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
|
62
|
+
separateProviders: separate,
|
|
63
|
+
sttConfigured,
|
|
64
|
+
ttsConfigured,
|
|
65
|
+
...(separate ? { sttBaseUrl: sttSettings.baseUrl, ttsBaseUrl: ttsSettings.baseUrl } : {}),
|
|
66
|
+
};
|
|
28
67
|
}
|
|
29
68
|
async transcribe(input) {
|
|
30
|
-
return this.
|
|
69
|
+
return this.createSttProvider().transcribe(input);
|
|
31
70
|
}
|
|
32
71
|
async synthesize(input) {
|
|
33
|
-
return this.
|
|
72
|
+
return this.createTtsProvider().synthesize(input);
|
|
34
73
|
}
|
|
35
74
|
async synthesizeStream(input) {
|
|
36
|
-
return this.
|
|
75
|
+
return this.createTtsProvider().synthesizeStream(input);
|
|
76
|
+
}
|
|
77
|
+
parseConfig() {
|
|
78
|
+
return ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {});
|
|
37
79
|
}
|
|
38
|
-
|
|
39
|
-
const settings = this.
|
|
80
|
+
createSttProvider() {
|
|
81
|
+
const settings = this.resolveSttSettings();
|
|
40
82
|
return new OpenAICompatibleSpeechProvider({
|
|
41
83
|
settings,
|
|
42
|
-
logger: this.logger.child({ provider: settings.provider }),
|
|
84
|
+
logger: this.logger.child({ provider: settings.provider, direction: "stt" }),
|
|
43
85
|
});
|
|
44
86
|
}
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
87
|
+
createTtsProvider() {
|
|
88
|
+
const settings = this.resolveTtsSettings();
|
|
89
|
+
return new OpenAICompatibleSpeechProvider({
|
|
90
|
+
settings,
|
|
91
|
+
logger: this.logger.child({ provider: settings.provider, direction: "tts" }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
resolveSttSettings() {
|
|
95
|
+
return this.resolveSttSettingsFrom(this.parseConfig().speech ?? {});
|
|
96
|
+
}
|
|
97
|
+
resolveTtsSettings() {
|
|
98
|
+
return this.resolveTtsSettingsFrom(this.parseConfig().speech ?? {});
|
|
99
|
+
}
|
|
100
|
+
resolveShared(speech) {
|
|
48
101
|
return {
|
|
49
102
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
|
50
103
|
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
|
@@ -55,4 +108,62 @@ export class SpeechService {
|
|
|
55
108
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
|
56
109
|
};
|
|
57
110
|
}
|
|
111
|
+
resolveSttSettingsFrom(speech) {
|
|
112
|
+
if (speech.separateProviders !== true) {
|
|
113
|
+
return this.resolveShared(speech);
|
|
114
|
+
}
|
|
115
|
+
const stt = speech.stt ?? {};
|
|
116
|
+
const sharedBaseUrl = speech.baseUrl?.trim() || undefined;
|
|
117
|
+
const rawDirBaseUrl = stt.baseUrl?.trim() || undefined;
|
|
118
|
+
const dirApiKey = stt.apiKey?.trim() || undefined;
|
|
119
|
+
const dirBaseUrl = dirApiKey
|
|
120
|
+
? rawDirBaseUrl
|
|
121
|
+
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined);
|
|
122
|
+
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl);
|
|
123
|
+
const hasNoDirectional = !dirApiKey && !dirBaseUrl;
|
|
124
|
+
return {
|
|
125
|
+
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
|
126
|
+
apiKey: hasCompleteDirectional
|
|
127
|
+
? dirApiKey
|
|
128
|
+
: hasNoDirectional
|
|
129
|
+
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
|
|
130
|
+
: undefined,
|
|
131
|
+
baseUrl: hasCompleteDirectional || hasNoDirectional
|
|
132
|
+
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
|
|
133
|
+
: dirBaseUrl || undefined,
|
|
134
|
+
sttModel: stt.model?.trim() || speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
|
135
|
+
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
|
136
|
+
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
|
137
|
+
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
resolveTtsSettingsFrom(speech) {
|
|
141
|
+
if (speech.separateProviders !== true) {
|
|
142
|
+
return this.resolveShared(speech);
|
|
143
|
+
}
|
|
144
|
+
const tts = speech.tts ?? {};
|
|
145
|
+
const sharedBaseUrl = speech.baseUrl?.trim() || undefined;
|
|
146
|
+
const rawDirBaseUrl = tts.baseUrl?.trim() || undefined;
|
|
147
|
+
const dirApiKey = tts.apiKey?.trim() || undefined;
|
|
148
|
+
const dirBaseUrl = dirApiKey
|
|
149
|
+
? rawDirBaseUrl
|
|
150
|
+
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined);
|
|
151
|
+
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl);
|
|
152
|
+
const hasNoDirectional = !dirApiKey && !dirBaseUrl;
|
|
153
|
+
return {
|
|
154
|
+
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
|
155
|
+
apiKey: hasCompleteDirectional
|
|
156
|
+
? dirApiKey
|
|
157
|
+
: hasNoDirectional
|
|
158
|
+
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
|
|
159
|
+
: undefined,
|
|
160
|
+
baseUrl: hasCompleteDirectional || hasNoDirectional
|
|
161
|
+
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
|
|
162
|
+
: dirBaseUrl || undefined,
|
|
163
|
+
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
|
164
|
+
ttsModel: tts.model?.trim() || speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
|
165
|
+
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
|
166
|
+
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
58
169
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { SpeechService } from "./service";
|
|
4
|
+
function createMockSettings(serverConfig) {
|
|
5
|
+
return {
|
|
6
|
+
getOwner: () => serverConfig,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
const mockLogger = {
|
|
10
|
+
child: () => mockLogger,
|
|
11
|
+
info: () => { },
|
|
12
|
+
warn: () => { },
|
|
13
|
+
error: () => { },
|
|
14
|
+
debug: () => { },
|
|
15
|
+
};
|
|
16
|
+
describe("SpeechService direction resolution", () => {
|
|
17
|
+
describe("separateProviders = false (default)", () => {
|
|
18
|
+
it("uses shared apiKey for both STT and TTS", () => {
|
|
19
|
+
const settings = createMockSettings({
|
|
20
|
+
speech: {
|
|
21
|
+
apiKey: "sk-shared",
|
|
22
|
+
baseUrl: "https://api.openai.com/v1",
|
|
23
|
+
sttModel: "whisper-1",
|
|
24
|
+
ttsModel: "tts-1",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const service = new SpeechService(settings, mockLogger);
|
|
28
|
+
const caps = service.getCapabilities();
|
|
29
|
+
assert.equal(caps.separateProviders, false);
|
|
30
|
+
assert.equal(caps.sttConfigured, true);
|
|
31
|
+
assert.equal(caps.ttsConfigured, true);
|
|
32
|
+
assert.equal(caps.configured, true);
|
|
33
|
+
});
|
|
34
|
+
it("reports unconfigured when no apiKey is set", () => {
|
|
35
|
+
const settings = createMockSettings({
|
|
36
|
+
speech: { sttModel: "whisper-1", ttsModel: "tts-1" },
|
|
37
|
+
});
|
|
38
|
+
const service = new SpeechService(settings, mockLogger);
|
|
39
|
+
const caps = service.getCapabilities();
|
|
40
|
+
assert.equal(caps.sttConfigured, false);
|
|
41
|
+
assert.equal(caps.ttsConfigured, false);
|
|
42
|
+
assert.equal(caps.configured, false);
|
|
43
|
+
});
|
|
44
|
+
it("transcribe throws not-configured when apiKey absent", async () => {
|
|
45
|
+
const service = new SpeechService(createMockSettings({ speech: {} }), mockLogger);
|
|
46
|
+
await assert.rejects(() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }), /not configured/i);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("separateProviders = true", () => {
|
|
50
|
+
it("reports per-direction configured status independently", () => {
|
|
51
|
+
const settings = createMockSettings({
|
|
52
|
+
speech: {
|
|
53
|
+
apiKey: "sk-shared",
|
|
54
|
+
separateProviders: true,
|
|
55
|
+
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1", model: "whisper-large-v3" },
|
|
56
|
+
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const service = new SpeechService(settings, mockLogger);
|
|
60
|
+
const caps = service.getCapabilities();
|
|
61
|
+
assert.equal(caps.separateProviders, true);
|
|
62
|
+
assert.equal(caps.sttConfigured, true);
|
|
63
|
+
assert.equal(caps.ttsConfigured, true);
|
|
64
|
+
assert.equal(caps.configured, true);
|
|
65
|
+
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1");
|
|
66
|
+
assert.equal(caps.ttsBaseUrl, "https://api.openai.com/v1");
|
|
67
|
+
});
|
|
68
|
+
it("reports partial config when only STT has complete directional pair", () => {
|
|
69
|
+
const settings = createMockSettings({
|
|
70
|
+
speech: {
|
|
71
|
+
separateProviders: true,
|
|
72
|
+
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const service = new SpeechService(settings, mockLogger);
|
|
76
|
+
const caps = service.getCapabilities();
|
|
77
|
+
assert.equal(caps.sttConfigured, true);
|
|
78
|
+
assert.equal(caps.ttsConfigured, false);
|
|
79
|
+
assert.equal(caps.configured, false);
|
|
80
|
+
});
|
|
81
|
+
it("does NOT combine directional key with shared URL (incomplete pair)", () => {
|
|
82
|
+
const settings = createMockSettings({
|
|
83
|
+
speech: {
|
|
84
|
+
apiKey: "sk-shared",
|
|
85
|
+
baseUrl: "https://api.openai.com/v1",
|
|
86
|
+
separateProviders: true,
|
|
87
|
+
stt: { apiKey: "sk-groq" },
|
|
88
|
+
tts: { apiKey: "sk-deepseek" },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const service = new SpeechService(settings, mockLogger);
|
|
92
|
+
const caps = service.getCapabilities();
|
|
93
|
+
assert.equal(caps.sttConfigured, false, "directional key without directional URL should not be configured");
|
|
94
|
+
assert.equal(caps.ttsConfigured, false);
|
|
95
|
+
});
|
|
96
|
+
it("does NOT combine shared key with foreign directional URL (incomplete pair)", () => {
|
|
97
|
+
const settings = createMockSettings({
|
|
98
|
+
speech: {
|
|
99
|
+
apiKey: "sk-shared",
|
|
100
|
+
baseUrl: "https://api.openai.com/v1",
|
|
101
|
+
separateProviders: true,
|
|
102
|
+
stt: { baseUrl: "https://api.groq.com/openai/v1" },
|
|
103
|
+
tts: { baseUrl: "https://api.deepseek.com/v1" },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const service = new SpeechService(settings, mockLogger);
|
|
107
|
+
const caps = service.getCapabilities();
|
|
108
|
+
assert.equal(caps.sttConfigured, false);
|
|
109
|
+
assert.equal(caps.ttsConfigured, false);
|
|
110
|
+
});
|
|
111
|
+
it("treats directional baseUrl matching shared as inherited (no directional key)", () => {
|
|
112
|
+
const sharedUrl = "https://api.openai.com/v1";
|
|
113
|
+
const settings = createMockSettings({
|
|
114
|
+
speech: {
|
|
115
|
+
apiKey: "sk-shared",
|
|
116
|
+
baseUrl: sharedUrl,
|
|
117
|
+
separateProviders: true,
|
|
118
|
+
stt: { baseUrl: sharedUrl },
|
|
119
|
+
tts: { baseUrl: sharedUrl },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const service = new SpeechService(settings, mockLogger);
|
|
123
|
+
const caps = service.getCapabilities();
|
|
124
|
+
assert.equal(caps.sttConfigured, true, "directional baseUrl matching shared with no key should inherit shared key");
|
|
125
|
+
assert.equal(caps.ttsConfigured, true);
|
|
126
|
+
});
|
|
127
|
+
it("inherits complete shared pair when neither directional value is set", () => {
|
|
128
|
+
const settings = createMockSettings({
|
|
129
|
+
speech: {
|
|
130
|
+
apiKey: "sk-shared",
|
|
131
|
+
baseUrl: "https://api.openai.com/v1",
|
|
132
|
+
separateProviders: true,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const service = new SpeechService(settings, mockLogger);
|
|
136
|
+
const caps = service.getCapabilities();
|
|
137
|
+
assert.equal(caps.sttConfigured, true);
|
|
138
|
+
assert.equal(caps.ttsConfigured, true);
|
|
139
|
+
});
|
|
140
|
+
it("uses complete directional pair when both key and URL are set", () => {
|
|
141
|
+
const settings = createMockSettings({
|
|
142
|
+
speech: {
|
|
143
|
+
apiKey: "sk-shared",
|
|
144
|
+
baseUrl: "https://api.openai.com/v1",
|
|
145
|
+
separateProviders: true,
|
|
146
|
+
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
|
|
147
|
+
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1" },
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const service = new SpeechService(settings, mockLogger);
|
|
151
|
+
const caps = service.getCapabilities();
|
|
152
|
+
assert.equal(caps.sttConfigured, true);
|
|
153
|
+
assert.equal(caps.ttsConfigured, true);
|
|
154
|
+
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1");
|
|
155
|
+
});
|
|
156
|
+
it("directional key with matching shared URL forms a complete pair", () => {
|
|
157
|
+
const sharedUrl = "https://api.openai.com/v1";
|
|
158
|
+
const settings = createMockSettings({
|
|
159
|
+
speech: {
|
|
160
|
+
apiKey: "sk-shared",
|
|
161
|
+
baseUrl: sharedUrl,
|
|
162
|
+
separateProviders: true,
|
|
163
|
+
stt: { apiKey: "sk-alt-openai", baseUrl: sharedUrl },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const service = new SpeechService(settings, mockLogger);
|
|
167
|
+
const caps = service.getCapabilities();
|
|
168
|
+
assert.equal(caps.sttConfigured, true, "directional key with matching shared URL should be a complete pair");
|
|
169
|
+
});
|
|
170
|
+
it("falls back to shared sttModel/ttsModel when per-direction model is absent", () => {
|
|
171
|
+
const settings = createMockSettings({
|
|
172
|
+
speech: {
|
|
173
|
+
apiKey: "sk-shared",
|
|
174
|
+
baseUrl: "https://api.openai.com/v1",
|
|
175
|
+
sttModel: "shared-stt-model",
|
|
176
|
+
ttsModel: "shared-tts-model",
|
|
177
|
+
separateProviders: true,
|
|
178
|
+
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1" },
|
|
179
|
+
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1" },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const service = new SpeechService(settings, mockLogger);
|
|
183
|
+
const caps = service.getCapabilities();
|
|
184
|
+
assert.equal(caps.sttModel, "shared-stt-model");
|
|
185
|
+
assert.equal(caps.ttsModel, "shared-tts-model");
|
|
186
|
+
});
|
|
187
|
+
it("uses per-direction model when set", () => {
|
|
188
|
+
const settings = createMockSettings({
|
|
189
|
+
speech: {
|
|
190
|
+
apiKey: "sk-shared",
|
|
191
|
+
baseUrl: "https://api.openai.com/v1",
|
|
192
|
+
sttModel: "shared-stt-model",
|
|
193
|
+
ttsModel: "shared-tts-model",
|
|
194
|
+
separateProviders: true,
|
|
195
|
+
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1", model: "whisper-large-v3" },
|
|
196
|
+
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const service = new SpeechService(settings, mockLogger);
|
|
200
|
+
const caps = service.getCapabilities();
|
|
201
|
+
assert.equal(caps.sttModel, "whisper-large-v3");
|
|
202
|
+
assert.equal(caps.ttsModel, "gpt-4o-mini-tts");
|
|
203
|
+
});
|
|
204
|
+
it("omits sttBaseUrl/ttsBaseUrl when separateProviders is false", () => {
|
|
205
|
+
const settings = createMockSettings({
|
|
206
|
+
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
|
|
207
|
+
});
|
|
208
|
+
const service = new SpeechService(settings, mockLogger);
|
|
209
|
+
const caps = service.getCapabilities();
|
|
210
|
+
assert.equal(caps.sttBaseUrl, undefined);
|
|
211
|
+
assert.equal(caps.ttsBaseUrl, undefined);
|
|
212
|
+
});
|
|
213
|
+
it("transcribe throws not-configured for incomplete pair (key without URL)", async () => {
|
|
214
|
+
const settings = createMockSettings({
|
|
215
|
+
speech: {
|
|
216
|
+
apiKey: "sk-shared",
|
|
217
|
+
baseUrl: "https://api.openai.com/v1",
|
|
218
|
+
separateProviders: true,
|
|
219
|
+
stt: { apiKey: "sk-groq" },
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
const service = new SpeechService(settings, mockLogger);
|
|
223
|
+
await assert.rejects(() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }), /not configured/i);
|
|
224
|
+
});
|
|
225
|
+
it("configured uses && in separate mode (both required for legacy compat)", () => {
|
|
226
|
+
const settings = createMockSettings({
|
|
227
|
+
speech: {
|
|
228
|
+
separateProviders: true,
|
|
229
|
+
stt: { apiKey: "sk-stt", baseUrl: "https://api.stt.com/v1" },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
const service = new SpeechService(settings, mockLogger);
|
|
233
|
+
const caps = service.getCapabilities();
|
|
234
|
+
assert.equal(caps.sttConfigured, true);
|
|
235
|
+
assert.equal(caps.ttsConfigured, false);
|
|
236
|
+
assert.equal(caps.configured, false, "configured should be false when only one direction is ready");
|
|
237
|
+
});
|
|
238
|
+
it("configured uses || in shared mode", () => {
|
|
239
|
+
const settings = createMockSettings({
|
|
240
|
+
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
|
|
241
|
+
});
|
|
242
|
+
const service = new SpeechService(settings, mockLogger);
|
|
243
|
+
const caps = service.getCapabilities();
|
|
244
|
+
assert.equal(caps.configured, true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|