mirror of
https://github.com/hanzoai/voice.git
synced 2026-10-03 10:14:33 +00:00
Audio in, audio out, on an IAM bearer, billed to whoever spoke. speech holds the models; this holds the conversation. The wire is the OpenAI realtime protocol so existing clients work unchanged. hanzoai/go-openai-realtime already carries the event types but is a client, so it can only unmarshal the server's half; Read supplies the other direction. A browser cannot put a header on a WebSocket, so the bearer is spent on a POST and the URL carries a single-use thirty-second ticket naming a principal already verified here. It has no claims and cannot be verified elsewhere, so a copy lifted from a log buys nothing. Replies are synthesised a sentence at a time: 452 ms to first sound against 2267 ms for a whole reply, and every sentence after the first is free because synthesis outruns speech. Measured end to end at 1.15-1.55 s from the speaker stopping, of which 500 ms is the silence detector that Turn exists to replace. Turn, Mind and Meter are the seams. Hush ends a turn on silence alone, which cuts people off mid-thought and waits through gaps it should have taken; it is there so the surface works while a detector that reads intent is built. Co-authored-by: Hanzo Dev <[email protected]>
68 lines
2.4 KiB
Go
68 lines
2.4 KiB
Go
package voice
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/authz/edge"
|
|
)
|
|
|
|
// Who is the caller, as IAM attests them. Nothing here is read from a header
|
|
// the client controls: every field comes out of a signature.
|
|
type Who struct {
|
|
User string // subject — a person, never a key
|
|
Org string // the tenant that pays for the audio
|
|
Name string
|
|
Token string // the caller's own bearer, carried so downstream calls bill them
|
|
}
|
|
|
|
// Gate answers who a bearer belongs to.
|
|
//
|
|
// Verification is IAM's own SDK rather than a JWT library used directly. That
|
|
// is not deference: the org a token authorises is NOT its `owner` claim —
|
|
// `owner` names the application the token was minted through, so reading it as
|
|
// the tenant bills one org for another org's audio. Claims.Home() reads the
|
|
// signed membership set instead, and it is the only correct answer.
|
|
type Gate struct{ v *edge.Verifier }
|
|
|
|
// Keys are cached for a minute: long enough that a conversation never waits on
|
|
// IAM, short enough that a rotation lands without a restart.
|
|
const keyLife = time.Minute
|
|
|
|
func NewGate(jwks string, issuers, audiences []string) *Gate {
|
|
return &Gate{v: edge.NewVerifier(jwks, issuers, audiences, keyLife)}
|
|
}
|
|
|
|
// Read verifies a bearer and says who holds it.
|
|
//
|
|
// `selected` is the caller's X-Org-Id, honoured only when the signed membership
|
|
// set contains it — EffectiveOrg does that check, so a client cannot bill a
|
|
// tenant it does not belong to by asking nicely.
|
|
func (g *Gate) Read(bearer, selected string) (Who, error) {
|
|
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(bearer), "Bearer "))
|
|
if raw == "" {
|
|
return Who{}, fmt.Errorf("no bearer")
|
|
}
|
|
claims, err := g.v.VerifyRaw(raw)
|
|
if err != nil {
|
|
return Who{}, fmt.Errorf("bearer rejected: %w", err)
|
|
}
|
|
// A machine credential carries no subject. Refusing it here is structural
|
|
// rather than a list of credential kinds to keep up to date: audio seconds
|
|
// have to land on a person for caller-pays billing to mean anything.
|
|
if strings.TrimSpace(claims.Subject) == "" {
|
|
return Who{}, fmt.Errorf("credential names no subject")
|
|
}
|
|
org := claims.Home()
|
|
if selected = strings.TrimSpace(selected); selected != "" {
|
|
if eff, _ := claims.EffectiveOrg(selected); eff != "" {
|
|
org = eff
|
|
}
|
|
}
|
|
if org == "" {
|
|
return Who{}, fmt.Errorf("credential names no org")
|
|
}
|
|
return Who{User: claims.Subject, Org: org, Name: claims.Name, Token: raw}, nil
|
|
}
|