Skip to content
Merged
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Playback uses `afplay` (macOS) or `ffplay`/`aplay`. Microphone enrollment uses
## Testing

`bun test` is offline: store, vocab, export, schema, keyless CLI paths. Provider
keys come from `src/values.ts` (env, then latch `celados-agent`); tests pass env
vars and never call latch. Live DashScope calls are not mocked — run `vox hear` / `vox say` by hand
keys are `secret()` `apiKey` fields from `src/auth.ts` (`@celados/argc/ref`;
default chain env, then latch `celados-agent` via `@celados/argc-latch`); tests
pass env vars or `{ ref: 'env:…' }` and never call latch. Live DashScope calls are not mocked — run `vox hear` / `vox say` by hand
against a real key.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ The account cap is 10 lists shared across all models.
```bash
vox say --text '你好世界' --voice Cherry --speed 1.2
vox say --text 'Save this' --output out.wav
vox say "{ text: { ref: 'file:script.txt' } }" # read the text from a file
vox voice.list
vox voice.record --file sample.wav --name myvoice
vox cache.status
Expand Down
18 changes: 10 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vox",
"version": "2.0.1",
"version": "3.0.0",
"description": "Agent-facing speech to text and text to speech over Alibaba Model Studio.",
"repository": "https://github.com/celados/vox",
"bin": {
Expand All @@ -18,10 +18,10 @@
"build": "bun build src/main.ts --outfile=dist/vox --target=bun --minify && chmod +x dist/vox"
},
"dependencies": {
"@celados/argc": "^9.0.0",
"@celados/latch-source": "^0.1.0",
"@valibot/to-json-schema": "^1.7.1",
"valibot": "^1.4.2",
"@celados/argc": "^10.3.1",
"@celados/argc-latch": "^1.0.0",
"@valibot/to-json-schema": "^1.8.0",
"valibot": "^1.5.0",
"yaml": "^2.9.0"
},
"devDependencies": {
Expand Down
79 changes: 79 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createLatch } from "@celados/argc-latch";
import { addGlobalDefs, env, secret } from "@celados/argc/ref";
import * as v from "valibot";

import { voxError } from "./cli-error.ts";

/**
* Provider keys are `secret()` input fields (argc 10, values by reference).
* Callers pass a literal or `{ ref: 'env:…' | 'bw://…' }`; omitted, the default
* chain runs: env var first (CI, one-off overrides), then the celados-agent
* vault item. Only the command being executed ever reads a source.
*/
const resolvers = [env(), createLatch()];
const key = v.pipe(v.string(), v.trim(), v.minLength(1));

export const providers = {
dashscope: {
label: "DashScope API key",
description: "Alibaba Model Studio (DashScope) API key — ASR fun/qwen, TTS, voices, vocab",
default: ["env:DASHSCOPE_API_KEY", "bw://7c38804e-df94-444c-8897-efcde8a77339/凭据"],
},
mimo: {
label: "MiMo API key",
description: "Xiaomi MiMo API key — ASR model mimo",
default: ["env:MIMO_API_KEY", "bw://52f369e9-e50d-4e49-89e7-a8901c944f41/api key"],
},
} as const;
export type Provider = keyof typeof providers;

const keyField = (provider: Provider) =>
secret(key, resolvers, {
description: providers[provider].description,
default: providers[provider].default,
});

const chains = (keys: readonly Provider[]) =>
keys.map((name) => `${name} ${providers[name].default.join(" → ")}`).join("; ");

/**
* Both key fields are named once (`addGlobalDefs`): `@schema` declares
* `type DashScopeKey` / `type ProviderKey` with grammar and default chain one
* time, and each command shows `apiKey?: DashScopeKey | ProviderKey`.
*/
const DashScopeKey = keyField("dashscope");
const ProviderKey = secret(key, resolvers, {
description: `Key for the provider the command calls (DashScope; MiMo for hear model mimo). Omitted → read only when a provider is actually called — stored runs and cached audio need none: ${chains(["dashscope", "mimo"])}`,
});
addGlobalDefs({ DashScopeKey, ProviderKey });

/** Commands that always call DashScope: the key resolves during validation. */
export const dashscopeAuth = { apiKey: DashScopeKey };

/**
* Commands that may not need a key (stored run, cached audio, keyless listing)
* take an optional override and resolve the default chain only when a provider
* is actually called — a stored replay stays offline and never waits on the vault.
*/
export const lazyAuth = { apiKey: v.optionalAsync(ProviderKey) };

export type KeyProbe = { available: true; value: string } | { available: false; tried: string };

/** Try one provider's default chain. Messages name references, never values. */
export async function probeKey(provider: Provider): Promise<KeyProbe> {
const result = await v.safeParseAsync(keyField(provider), undefined);
if (result.success) return { available: true, value: result.output };
return { available: false, tried: result.issues.map((issue) => issue.message).join("; ") };
}

/** The caller's key, else the provider's default chain; a miss is `key_unavailable`. */
export async function requireKey(provider: Provider, given: string | undefined): Promise<string> {
if (given !== undefined) return given;
const probe = await probeKey(provider);
if (probe.available) return probe.value;
throw voxError(
"key_unavailable",
`${providers[provider].label}: ${probe.tried}`,
"vox status shows every provider; or pass apiKey: { ref: 'env:NAME' }",
);
}
1 change: 1 addition & 0 deletions src/cli-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type VoxErrorCode =
| "session_not_found"
| "session_ambiguous"
| "api_error"
| "key_unavailable"
| "invalid_usage"
| "io_error";

Expand Down
51 changes: 26 additions & 25 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { writeFile } from "node:fs/promises";

import { probeKey, providers } from "./auth.ts";
import { voxError } from "./cli-error.ts";
import { loadConfig, type AppConfig } from "./config.ts";
import { loadConfig } from "./config.ts";
import { DashScopeClient, resolveModel, VOCABULARY_QUOTA } from "./dashscope.ts";
import { render } from "./export.ts";
import { hear } from "./hear.ts";
Expand All @@ -15,7 +16,6 @@ import {
} from "./speakers.ts";
import { listRuns, loadRun, newStore, removeAllRuns, removeRun, resolveSid } from "./run-store.ts";
import type { AppHandlers } from "./schema.ts";
import { isValueUnavailable, secret } from "./values.ts";
import { cacheClear, cacheStatus, listVoices, recordVoice, removeVoice, say } from "./tts.ts";
import {
describeIndex,
Expand Down Expand Up @@ -51,26 +51,26 @@ export const handlers: AppHandlers = {
},
export: async (options) => await exportRun(options.input),
vocab: {
list: async () => await vocabList(),
list: async (options) => await vocabList(options.input.apiKey),
sync: async (options) => await vocabSync(options.input),
prune: async (options) => await vocabPrune(options.input.dryRun ?? false),
prune: async (options) => await vocabPrune(options.input.apiKey, options.input.dryRun ?? false),
},
say: async (options) => {
const app = await loadConfig();
return await say(app, options.input);
},
voice: {
list: async () => {
list: async (options) => {
const app = await loadConfig();
return await listVoices(app);
return await listVoices(app, options.input.apiKey);
},
record: async (options) => {
const app = await loadConfig();
return await recordVoice(app, options.input);
},
remove: async (options) => {
const app = await loadConfig();
return await removeVoice(app, options.input.voiceId);
return await removeVoice(app, options.input.apiKey, options.input.voiceId);
},
},
cache: {
Expand All @@ -86,16 +86,13 @@ export const handlers: AppHandlers = {
};

async function providerStatus(): Promise<Record<string, unknown>> {
const providers = { dashscope: "DASHSCOPE_API_KEY", mimo: "MIMO_API_KEY" } as const;
const entries = await Promise.all(
Object.entries(providers).map(async ([provider, value]) => {
try {
await secret[value];
return [provider, { available: true }] as const;
} catch (error) {
if (!isValueUnavailable(error)) throw error;
return [provider, { available: false, tried: error.envelope.tried }] as const;
}
(Object.keys(providers) as Array<keyof typeof providers>).map(async (provider) => {
const probe = await probeKey(provider);
return [
provider,
probe.available ? { available: true } : { available: false, tried: probe.tried },
] as const;
}),
);
return Object.fromEntries(entries);
Expand Down Expand Up @@ -162,7 +159,7 @@ async function exportRun(input: {
return rendered;
}

async function vocabList(): Promise<unknown> {
async function vocabList(apiKey: string | undefined): Promise<unknown> {
const app = await loadConfig();
const names = await vocabNames(app.dir);
const index = await loadIndex(app.dir);
Expand All @@ -180,27 +177,32 @@ async function vocabList(): Promise<unknown> {
}
vocabularies.push(entry);
}
return { quota: await quotaUsage(app), vocabularies };
return { quota: await quotaUsage(apiKey), vocabularies };
}

async function quotaUsage(app: AppConfig): Promise<string> {
async function quotaUsage(given: string | undefined): Promise<string> {
let apiKey = given;
if (apiKey === undefined) {
const probe = await probeKey("dashscope");
if (!probe.available) return "unknown (DashScope API key unavailable — vox status shows why)";
apiKey = probe.value;
}
try {
const remote = await new DashScopeClient(await secret.DASHSCOPE_API_KEY).listVocabularies();
const remote = await new DashScopeClient(apiKey).listVocabularies();
return `${remote.length}/${VOCABULARY_QUOTA}`;
} catch (error) {
if (isValueUnavailable(error)) return "unknown (DASHSCOPE_API_KEY unavailable)";
return `unknown (${error instanceof Error ? error.message : String(error)})`;
}
}

async function vocabSync(input: {
apiKey: string;
name?: string;
all?: boolean;
model?: string;
force?: boolean;
}): Promise<unknown> {
const app = await loadConfig();
const apiKey = await secret.DASHSCOPE_API_KEY;
const vendor = input.model ?? "fun";
const model = resolveModel(vendor);
if (!model) throw voxError("invalid_usage", `unknown model vendor "${vendor}"`);
Expand All @@ -215,7 +217,7 @@ async function vocabSync(input: {
);
}

const client = new DashScopeClient(apiKey);
const client = new DashScopeClient(input.apiKey);
const targets = input.all
? await listVocabularies(app.dir)
: [await loadVocabulary(app.dir, input.name!)];
Expand All @@ -233,9 +235,8 @@ async function vocabSync(input: {
return { items };
}

async function vocabPrune(dryRun: boolean): Promise<unknown> {
async function vocabPrune(apiKey: string, dryRun: boolean): Promise<unknown> {
const app = await loadConfig();
const apiKey = await secret.DASHSCOPE_API_KEY;
const orphans = await pruneVocabularies(new DashScopeClient(apiKey), app.dir, dryRun);
return { orphans, dryRun };
}
5 changes: 3 additions & 2 deletions src/hear.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extname, resolve } from "node:path";

import { requireKey } from "./auth.ts";
import { durationSeconds, isSupportedFormat, supportedFormats } from "./audio.ts";
import { VoxError, voxError } from "./cli-error.ts";
import { type AppConfig } from "./config.ts";
Expand All @@ -12,7 +13,6 @@ import {
} from "./dashscope.ts";
import { MimoClient, MODEL_MIMO_ASR, mimoAudioData, mimoLanguage } from "./mimo.ts";
import { expandHome, tilde } from "./paths.ts";
import { secret } from "./values.ts";
import {
digestOf,
loadRun,
Expand All @@ -32,6 +32,7 @@ export const FOLD_TOKENS = 2000;
export const PREVIEW_RUNES = 60;

export type HearInput = {
apiKey?: string;
file: string;
model?: string;
vocab?: string;
Expand Down Expand Up @@ -114,7 +115,7 @@ export async function transcribeRun(app: AppConfig, input: HearInput): Promise<R
}

// Stored runs replay offline; only a real recognition needs the provider key.
const apiKey = await (vendor === "mimo" ? secret.MIMO_API_KEY : secret.DASHSCOPE_API_KEY);
const apiKey = await requireKey(vendor === "mimo" ? "mimo" : "dashscope", input.apiKey);
const client = vendor === "mimo" ? undefined : new DashScopeClient(apiKey);
let vocabularyId = "";
if (loadedVocab && args.vocab) {
Expand Down
Loading
Loading