Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./ui/AppText";
import { cn } from "../lib/cn";
import { getProviderClient, openRouterIcon } from "@t3tools/client-runtime/provider-clients";
import {
elevenLabsIcon,
getProviderClient,
openRouterIcon,
} from "@t3tools/client-runtime/provider-clients";

type ProviderIconProps = {
readonly provider: string | null | undefined;
Expand Down Expand Up @@ -100,7 +104,11 @@ export function ProviderIcon(props: ProviderIconProps) {
}

const packageIcon =
props.provider === "openrouter" ? openRouterIcon : getProviderClient(props.provider)?.icon;
props.provider === "openrouter"
? openRouterIcon
: props.provider === "elevenlabs"
? elevenLabsIcon
: getProviderClient(props.provider)?.icon;
if (packageIcon) {
const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
isDarkMode ? fill.dark : fill.light;
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
openai: SECRET_REDACTED,
xai: SECRET_REDACTED,
openrouter: "",
elevenlabs: "",
});
assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED);
assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED);
Expand Down Expand Up @@ -1841,6 +1842,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
openai: usageReference,
xai: "literal-fixture",
openrouter: "",
elevenlabs: "",
});
assert.deepEqual(echoed.easDeviceHosts[0]?.accessToken, easReference);
assert.deepEqual(echoed.simlockDeviceHosts[0]?.agentToken, simlockReference);
Expand Down Expand Up @@ -1926,7 +1928,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"ghe.acme.test": work,
});
assert.equal(removed.usageLimitSources[hub]?.managementKey, "");
assert.deepEqual(removed.cloudSpeechCredentials, { openai: "", xai: work, openrouter: "" });
assert.deepEqual(removed.cloudSpeechCredentials, {
openai: "",
xai: work,
openrouter: "",
elevenlabs: "",
});
assert.equal(removed.easDeviceHosts[0]?.accessToken, "");
assert.deepEqual(Object.keys(removed.secretProviders), ["work"]);
}).pipe(Effect.provide(layerServerSettingsWithSecrets())),
Expand All @@ -1939,6 +1946,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"cloudSpeechCredentials.openai",
"cloudSpeechCredentials.xai",
"cloudSpeechCredentials.openrouter",
"cloudSpeechCredentials.elevenlabs",
"easDeviceHosts.[].accessToken",
"simlockDeviceHosts.[].agentToken",
"usageLimitSources.*.managementKey",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,12 @@ it.effect("reads a collected reference again once it is invalidated", () =>
resolver,
Effect.succeed({
sourceControlHosts: hosts,
cloudSpeechCredentials: { openai: source("voice#key"), xai: "", openrouter: "" },
cloudSpeechCredentials: {
openai: source("voice#key"),
xai: "",
openrouter: "",
elevenlabs: "",
},
usageLimitSources,
textGenerators: {},
easDeviceHosts,
Expand Down
68 changes: 51 additions & 17 deletions apps/server/src/speech/CloudSpeech.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,36 @@ import { expect, vi } from "vite-plus/test";
import * as ServerSettings from "../serverSettings.ts";
import * as CloudSpeech from "./CloudSpeech.ts";

it.effect.each(["openai", "xai"] as const)("sends only to %s using its own saved key", (provider) =>
const wire = {
openai: {
url: "https://api.openai.com/v1/audio/transcriptions",
auth: ["authorization", "Bearer "],
model: ["model", "gpt-transcribe"],
keyterm: "keywords[]",
language: "languages[]",
},
xai: {
url: "https://api.x.ai/v1/stt",
auth: ["authorization", "Bearer "],
model: ["model", "grok-voice-transcribe-2.0"],
keyterm: "keyterm",
language: "language",
},
elevenlabs: {
url: "https://api.elevenlabs.io/v1/speech-to-text",
auth: ["xi-api-key", ""],
model: ["model_id", "scribe_v3"],
keyterm: "keyterms",
language: "language_code",
},
} as const;

const fixedProviders = Object.keys(wire) as Array<keyof typeof wire>;

it.effect.each(fixedProviders)("sends only to %s using its own saved key", (provider) =>
Effect.gen(function* () {
const { url: endpoint, auth, model, keyterm, language } = wire[provider];
const other = provider === "openai" ? "xai" : "openai";
const reference = {
provider: SecretProviderId.make("work"),
reference: "op://Work/Voice/key",
Expand Down Expand Up @@ -43,31 +71,37 @@ it.effect.each(["openai", "xai"] as const)("sends only to %s using its own saved
.transcribe(provider, pcm, {
...DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS,
speechLanguage: "en",
speechCustomWords: [{ term: "T3 Code", aliases: [] }],
speechCustomWords: [
{ term: "T3 Code", aliases: [] },
{ term: "{brace}", aliases: [] },
],
})
.pipe(Effect.provideService(FetchHttpClient.Fetch, upstream));
yield* service.setKey(provider === "openai" ? "xai" : "openai", Redacted.make("other"));
yield* service.setKey(other, Redacted.make("other"));
expect((yield* Effect.flip(transcribe()))._tag).toBe("CloudSpeechError");
yield* service.setKey(provider, Redacted.make("chosen"));
expect(yield* service.configured).toEqual({ openai: true, xai: true, openrouter: false });
expect(yield* service.configured).toEqual({
openai: false,
xai: false,
openrouter: false,
elevenlabs: false,
[other]: true,
[provider]: true,
});
yield* Effect.flip(transcribe(new Uint8Array(3)));
expect(upstream).not.toHaveBeenCalled();
expect(yield* transcribe()).toBe("hello");
const [url, init] = upstream.mock.calls[0]!;
expect(String(url)).toBe(
{
openai: "https://api.openai.com/v1/audio/transcriptions",
xai: "https://api.x.ai/v1/stt",
}[provider],
);
expect(String(url)).toBe(endpoint);
expect(init?.redirect).toBe("error");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer chosen");
expect(new Headers(init?.headers).get(auth[0])).toBe(`${auth[1]}chosen`);
const form = init?.body as FormData;
expect(form.get("model")).toBe(
provider === "openai" ? "gpt-transcribe" : "grok-voice-transcribe-2.0",
expect(form.get(model[0])).toBe(model[1]);
// Scribe fails the whole request on bracket characters, so those terms stay local.
expect(form.getAll(keyterm)).toEqual(
provider === "elevenlabs" ? ["T3 Code"] : ["T3 Code", "{brace}"],
);
expect(form.getAll(provider === "openai" ? "keywords[]" : "keyterm")).toEqual(["T3 Code"]);
expect(form.get(provider === "openai" ? "languages[]" : "language")).toBe("en");
expect(form.get(language)).toBe("en");
expect([...form.keys()].at(-1)).toBe("file");
const wav = new DataView(yield* Effect.promise(() => (form.get("file") as Blob).arrayBuffer()));
expect(wav.getUint32(24, true)).toBe(16_000);
Expand All @@ -84,8 +118,8 @@ it.effect.each(["openai", "xai"] as const)("sends only to %s using its own saved
resolved = "resolved-fixture";
expect(yield* transcribe()).toBe("hello");
expect(read).toHaveBeenCalledWith([reference]);
expect(new Headers(upstream.mock.calls[2]?.[1]?.headers).get("authorization")).toBe(
"Bearer resolved-fixture",
expect(new Headers(upstream.mock.calls[2]?.[1]?.headers).get(auth[0])).toBe(
`${auth[1]}resolved-fixture`,
);
resolved = undefined;
expect(yield* Effect.flip(transcribe())).toMatchObject({
Expand Down
39 changes: 35 additions & 4 deletions apps/server/src/speech/CloudSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import {
FetchHttpClient,
Headers,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "effect/http";
import * as ServerSettings from "../serverSettings.ts";
import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts";
import { applySpeechAliases } from "./customWords.ts";
Expand All @@ -31,9 +37,12 @@ const endpoints = {
openai: { url: "https://api.openai.com/v1/audio/transcriptions", model: "gpt-transcribe" },
xai: { url: "https://api.x.ai/v1/stt", model: "grok-voice-transcribe-2.0" },
openrouter: { url: "https://openrouter.ai/api/v1/audio/transcriptions" },
elevenlabs: { url: "https://api.elevenlabs.io/v1/speech-to-text", model: "scribe_v3" },
};
const OPENROUTER_CATALOG_URL =
"https://openrouter.ai/api/v1/models?output_modalities=transcription&zdr=true";
// ElevenLabs authenticates with `xi-api-key`, which Effect's span headers don't redact by default.
const REDACTED_HEADERS = [...Headers.CurrentRedactedNames.defaultValue(), "xi-api-key"];
const isApiKey = Schema.is(CloudSpeechApiKey);
const isModelId = Schema.is(CloudSpeechModelId);
const sameCredential = Schema.toEquivalence(SettingsSecretValue);
Expand Down Expand Up @@ -92,6 +101,22 @@ function audioBody(
return HttpClientRequest.bodyFormData(form);
}

function elevenLabsBody(audio: ArrayBuffer, language: string, options: SpeechTranscriptionOptions) {
const form = new FormData();
form.set("model_id", endpoints.elevenlabs.model);
form.set("tag_audio_events", "false");
form.set("timestamps_granularity", "none");
if (language !== "auto") form.set("language_code", language);
form.set("no_verbatim", String(options.speechRemoveFillerWords && !options.speechCorrectionWord));
for (const { term } of options.speechCustomWords) {
// Scribe rejects the whole request for a keyterm over five words or with these characters.
if (/[<>{}[\]\\]/.test(term) || term.trim().split(/\s+/).length > 5) continue;
form.append("keyterms", term);
}
form.set("file", new Blob([audio], { type: "audio/wav" }), "dictation.wav");
return HttpClientRequest.bodyFormData(form);
}

function openRouterBody(model: string, audio: ArrayBuffer, language: string) {
return HttpClientRequest.bodyJsonUnsafe({
model,
Expand Down Expand Up @@ -130,10 +155,11 @@ export const layer = Layer.effect(
Effect.map((value) => value.cloudSpeechCredentials),
);
const configured = credentials.pipe(
Effect.map(({ openai, xai, openrouter }) => ({
Effect.map(({ openai, xai, openrouter, elevenlabs }) => ({
openai: Boolean(openai),
xai: Boolean(xai),
openrouter: Boolean(openrouter),
elevenlabs: Boolean(elevenlabs),
})),
);
const execute = (request: HttpClientRequest.HttpClientRequest) =>
Expand All @@ -142,6 +168,7 @@ export const layer = Layer.effect(
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
Effect.provideService(Headers.CurrentRedactedNames, REDACTED_HEADERS),
);
const fetchCatalog = Effect.fn("CloudSpeech.catalog")(function* () {
const unavailable = () =>
Expand Down Expand Up @@ -209,10 +236,14 @@ export const layer = Layer.effect(
if (!isApiKey(key)) return yield* unreadable();
const audio = wave(samples);
const request = HttpClientRequest.post(endpoints[provider].url).pipe(
HttpClientRequest.bearerToken(Redacted.make(key)),
provider === "elevenlabs"
? HttpClientRequest.setHeader("xi-api-key", key)
: HttpClientRequest.bearerToken(Redacted.make(key)),
provider === "openrouter"
? openRouterBody(model!, audio, language)
: audioBody(provider, audio, language, options),
: provider === "elevenlabs"
? elevenLabsBody(audio, language, options)
: audioBody(provider, audio, language, options),
);
const unavailable = () => new CloudSpeechError({ provider, reason: "unavailable" });
// A catalog fetch or vault lookup may have outlived the credential it started with.
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/components/settings/CloudVoiceConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ export function CloudVoiceConfiguration({
const destination = (): CloudDestination =>
routed && isModelId(model)
? { kind: "cloud", provider: "openrouter", environmentId: id, model }
: { kind: "cloud", provider: provider as "openai" | "xai", environmentId: id };
: {
kind: "cloud",
provider: provider as Exclude<CloudSpeechProvider, "openrouter">,
environmentId: id,
};
const enable = () => chooseAudioDestination(destination());
const canSubmit = routed
? modelChosen && keyReady && (!active || modelChanged || keyChanged)
Expand Down Expand Up @@ -343,11 +347,10 @@ export function CloudVoiceConfiguration({
<Button size="sm" variant="outline" onClick={refresh}>
Retry loading API key
</Button>
) : routed &&
AsyncResult.isSuccess(presence) &&
presence.value &&
saved === undefined ? (
<p>Update {environment.label} to use OpenRouter dictation.</p>
) : AsyncResult.isSuccess(presence) && presence.value && saved === undefined ? (
<p>
Update {environment.label} to use {name} dictation.
</p>
) : saved === undefined ? (
<p role="status">Checking for an API key…</p>
) : routed ? (
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/settings/VoiceSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import {
updateEnvironmentSpeechModelUnloadTimeout,
} from "@t3tools/client-runtime/voice-input";
import type { PreparedConnection } from "@t3tools/client-runtime/connection";
import { openRouterIcon } from "@t3tools/client-runtime/provider-clients";
import { elevenLabsIcon, openRouterIcon } from "@t3tools/client-runtime/provider-clients";
import {
AuthFilesystemReadScope,
AuthSettingsWriteScope,
CLOUD_SPEECH_LABELS,
CLOUD_SPEECH_PROVIDER_NAMES,
CloudSpeechProvider,
DEFAULT_SPEECH_POST_PROCESSING_PROMPT,
type EnvironmentId,
Expand Down Expand Up @@ -142,6 +143,8 @@ const OFF = "off";
function CloudSpeechLogo({ provider }: { provider: CloudSpeechProvider }) {
if (provider === "openrouter")
return <ProviderPackageIcon icon={openRouterIcon} aria-hidden className="size-4 shrink-0" />;
if (provider === "elevenlabs")
return <ProviderPackageIcon icon={elevenLabsIcon} aria-hidden className="size-4 shrink-0" />;
return (
<ProviderInstanceIcon
driverKind={ProviderDriverKind.make(provider === "openai" ? "codex" : "grok")}
Expand Down Expand Up @@ -1256,6 +1259,10 @@ export function VoiceSettingsPanel() {
const cloudEnvironment = environments.find(
(env) => env.environmentId === pendingCloud?.environmentId,
);
const ownsSilence =
destination.cloudProvider === "openai" || destination.cloudProvider === "elevenlabs"
? destination.cloudProvider
: null;
// OpenRouter users without settings access still manage their own model choice.
const manageLabel =
destination.cloudProvider === "openrouter" ? "Manage OpenRouter" : "Manage key";
Expand Down Expand Up @@ -1538,14 +1545,14 @@ export function VoiceSettingsPanel() {
{...searchableSetting("speech-silence-filtering")}
title="Silence filtering"
description={
destination.cloudProvider === "openai"
? "OpenAI handles silence itself."
ownsSilence
? `${CLOUD_SPEECH_PROVIDER_NAMES[ownsSilence]} handles silence itself.`
: "Skip silence and background noise before transcribing."
}
control={
<Switch
aria-label="Silence filtering"
disabled={destination.cloudProvider === "openai"}
disabled={ownsSilence !== null}
checked={settings.speechSilenceFiltering}
onCheckedChange={(checked) =>
void updateClientSettings({ speechSilenceFiltering: checked })
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Transcribe with",
to: "/settings/voice",
searchTerms: [
"send microphone audio dictation destination speech voice stt transcription environment machine consent privacy cloud OpenAI Grok OpenRouter",
"send microphone audio dictation destination speech voice stt transcription environment machine consent privacy cloud OpenAI Grok OpenRouter ElevenLabs Scribe",
],
},
{
Expand Down
4 changes: 2 additions & 2 deletions docs/user/voice-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Set it back to **Off** at any time; a recording in progress is discarded.
If the chosen environment goes offline, voice input waits for it. T3 Code never
substitutes another environment.

For cloud dictation, choose **OpenAI** or **Grok Voice Transcribe** via the environment
you want to use. If it needs a key, enter one and choose **Save and enable**.
For cloud dictation, choose **OpenAI**, **Grok Voice Transcribe**, or **ElevenLabs Scribe**
via the environment you want to use. If it needs a key, enter one and choose **Save and enable**.
You can also choose a [secret provider](secrets.md) instead of **Value** and enter a
reference. Unlock the vault on that environment before dictating. Anyone with
dictation access to the environment can use the key, but only this device opts in.
Expand Down
7 changes: 7 additions & 0 deletions packages/client-runtime/src/providerClients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export const openRouterIcon: ProviderIcon = {
],
};

// ElevenLabs' two-bar logomark.
export const elevenLabsIcon: ProviderIcon = {
viewBox: "0 0 24 24",
fill: { light: "#000000", dark: "#FFFFFF" },
paths: [{ d: "M7 3h3.5v18H7zM13.5 3H17v18h-3.5z" }],
};

export const providerClients = makeProviderClientRegistry([
{
driverKind: ProviderDriverKind.make("codex"),
Expand Down
Loading
Loading