mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
/v1/audio/* resolves its caller through IAM and meters the account that caller
names, so speech is billed exactly like a completion — because to the wallet it
IS one. The completion path already knew this: `{{CHAT_OPENID_TOKEN}}` in the
config means "forward the signed-in user own IAM bearer", resolveTenantBearer
produces it, X-Org-Id rides beside it, and a guest gets the capped guest key.
STT and TTS did not. Both read their key with extractEnvVariable, so whatever
single credential the config named paid for every user in the system. Under
prepay that is not a rounding error: one wallet funds everyone, or nobody speaks.
This adds no second rule. resolveSpeechCredential defers to the same sentinel and
the same resolver, and both services now call it — one function, so a transcript
and the reply read back cannot come to disagree about who pays.
Signed out, or a bearer aged out, yields NO credential rather than a fallback.
Spending someone else money to avoid an error message is the worse failure, and
@hanzo/voice 0.1.6 reports the refusal and hands back to the browser recogniser
instead of absorbing it.
req had to be threaded to reach the providers. In TTS it goes AFTER stream:
strategy.call already passes stream in that position, so adding req before it
would have handed the provider `true` as a request object.
Mutation-proven: making the sentinel never match — the pre-fix behaviour, valid
JS — fails 4 of the 5 new tests; the one that survives is the literal-key case,
which is unchanged by design. The two neighbouring suites fail identically on
pristine main (a jest moduleNameMapper gap), and these are the only passing tests
in the directory.
Co-authored-by: Hanzo Dev <[email protected]>
65 lines
2.5 KiB
JavaScript
65 lines
2.5 KiB
JavaScript
// `@hanzochat/api` is a barrel that pulls the whole agent/langchain graph in, which
|
|
// a unit test has no business loading. The two symbols used here are pure, so the
|
|
// mock states the contract this file depends on rather than importing the world.
|
|
const SENTINEL = '{{CHAT_OPENID_TOKEN}}';
|
|
|
|
jest.mock('@hanzochat/api', () => ({
|
|
OPENID_BEARER_SENTINEL: '{{CHAT_OPENID_TOKEN}}',
|
|
// Mirrors the real resolver's shape: a forwardable bearer, or nothing.
|
|
resolveTenantBearer: (req) =>
|
|
req?.user?.provider === 'openid' ? (req?.session?.openidTokens?.accessToken ?? null) : null,
|
|
}));
|
|
|
|
jest.mock('@hanzochat/data-provider', () => ({
|
|
extractEnvVariable: (v) => v,
|
|
}));
|
|
|
|
const { resolveSpeechCredential } = require('./speechCredential');
|
|
|
|
const signedIn = (accessToken, extra = {}) => ({
|
|
user: { provider: 'openid', openidId: 'sub-123', ...extra.user },
|
|
session: { openidTokens: { accessToken } },
|
|
headers: extra.headers || {},
|
|
});
|
|
|
|
describe('resolveSpeechCredential', () => {
|
|
it('passes a literal key through, so a self-hosted OpenAI/Azure key still works', () => {
|
|
const { apiKey, tenantHeaders } = resolveSpeechCredential('sk-literal', signedIn('ignored'));
|
|
expect(apiKey).toBe('sk-literal');
|
|
expect(tenantHeaders).toEqual({});
|
|
});
|
|
|
|
it('forwards the caller OWN bearer for the sentinel, so the turn bills them', () => {
|
|
const { apiKey } = resolveSpeechCredential(SENTINEL, signedIn('user-bearer-abc'));
|
|
expect(apiKey).toBe('user-bearer-abc');
|
|
});
|
|
|
|
it('refuses instead of falling back when no bearer exists', () => {
|
|
// The one that matters: a signed-out caller must never inherit a shared key.
|
|
const { apiKey } = resolveSpeechCredential(SENTINEL, { user: undefined, headers: {} });
|
|
expect(apiKey).toBe('');
|
|
});
|
|
|
|
it('never emits the sentinel itself as a credential', () => {
|
|
// A leaked sentinel would travel as a Bearer and 401 every turn — the
|
|
// literal-${VAR} bug wearing a different hat.
|
|
const { apiKey } = resolveSpeechCredential(SENTINEL, { user: undefined, headers: {} });
|
|
expect(apiKey).not.toBe(SENTINEL);
|
|
});
|
|
|
|
it('sends X-Org-Id only alongside a bearer, never alone', () => {
|
|
const withBearer = resolveSpeechCredential(
|
|
SENTINEL,
|
|
signedIn('user-bearer-abc', { user: { activeOrg: 'acme' } }),
|
|
);
|
|
expect(withBearer.tenantHeaders).toEqual({ 'X-Org-Id': 'acme' });
|
|
|
|
const withoutBearer = resolveSpeechCredential(SENTINEL, {
|
|
user: { activeOrg: 'acme' },
|
|
headers: {},
|
|
});
|
|
expect(withoutBearer.apiKey).toBe('');
|
|
expect(withoutBearer.tenantHeaders).toEqual({});
|
|
});
|
|
});
|