sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
@@ -0,0 +1,44 @@
package realtime
import (
"ai-operator/internal/ai"
"ai-operator/internal/audio"
"ai-operator/internal/config"
"encoding/json"
)
func BuildSessionUpdate(cfg config.OpenAIConfig, instructions string) ([]byte, error) {
session := map[string]any{"type": "realtime", "model": cfg.RealtimeModel, "instructions": instructions, "audio": map[string]any{"input": map[string]any{"format": map[string]any{"type": "audio/pcm", "rate": cfg.RealtimeInputSampleRate}, "turn_detection": map[string]any{"type": cfg.RealtimeTurnDetection}}, "output": map[string]any{"format": map[string]any{"type": "audio/pcm", "rate": cfg.RealtimeOutputSampleRate}}}, "reasoning": map[string]any{"effort": cfg.RealtimeReasoningEffort}}
if cfg.RealtimeVoice != "" {
session["audio"].(map[string]any)["output"].(map[string]any)["voice"] = cfg.RealtimeVoice
}
return json.Marshal(map[string]any{"type": "session.update", "session": session})
}
func BuildAudioAppend(pcm []byte) ([]byte, error) {
return json.Marshal(map[string]any{"type": "input_audio_buffer.append", "audio": audio.Base64Encode(pcm)})
}
func BuildCommit() ([]byte, error) {
return json.Marshal(map[string]any{"type": "input_audio_buffer.commit"})
}
func BuildResponseCreate() ([]byte, error) {
return json.Marshal(map[string]any{"type": "response.create"})
}
func BuildResponseCreateWithInstructions(instructions string) ([]byte, error) {
if instructions == "" {
return BuildResponseCreate()
}
return json.Marshal(map[string]any{"type": "response.create", "response": map[string]any{"instructions": instructions}})
}
func BuildResponseCancel() ([]byte, error) {
return json.Marshal(map[string]any{"type": "response.cancel"})
}
func BuildInputAudioClear() ([]byte, error) {
return json.Marshal(map[string]any{"type": "input_audio_buffer.clear"})
}
func BuildToolResult(result ai.ToolResult) ([]byte, error) {
b, _ := json.Marshal(result.Result)
if result.Error != "" {
b = []byte(result.Error)
}
return json.Marshal(map[string]any{"type": "conversation.item.create", "item": map[string]any{"type": "function_call_output", "call_id": result.ToolCallID, "output": string(b)}})
}
+103
View File
@@ -0,0 +1,103 @@
package realtime
import (
"ai-operator/internal/ai"
"encoding/base64"
"encoding/json"
"time"
)
type GenericRealtimeEvent struct {
Type string
Raw map[string]any
}
func ParseServerEvent(data []byte) (ai.VoiceEvent, GenericRealtimeEvent, error) {
var base map[string]any
if err := json.Unmarshal(data, &base); err != nil {
return ai.VoiceEvent{}, GenericRealtimeEvent{}, err
}
typ, _ := base["type"].(string)
ev := ai.VoiceEvent{At: time.Now().UTC(), Metadata: map[string]any{"openai_type": typ}}
switch typ {
case "session.created":
ev.Type = ai.VoiceEventSessionStarted
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "session.updated":
ev.Type = ai.VoiceEventSessionUpdated
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "error":
ev.Type = ai.VoiceEventError
if e, ok := base["error"].(map[string]any); ok {
ev.Error = toStr(e["message"])
} else {
ev.Error = "openai error"
}
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "input_audio_buffer.speech_started":
ev.Type = ai.VoiceEventInterruption
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "response.output_audio.delta":
ev.Type = ai.VoiceEventAssistantAudioDelta
b, err := base64.StdEncoding.DecodeString(toStr(base["delta"]))
if err != nil {
return ai.VoiceEvent{}, GenericRealtimeEvent{}, err
}
ev.Audio = b
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "response.output_audio.done":
ev.Type = ai.VoiceEventAssistantAudioDone
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "response.output_audio_transcript.delta", "response.output_text.delta":
ev.Type = ai.VoiceEventAssistantTranscriptDelta
ev.Text = toStr(base["delta"])
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "response.output_audio_transcript.done", "response.output_text.done":
ev.Type = ai.VoiceEventAssistantTranscriptDone
ev.Text = toStr(base["transcript"]) + toStr(base["text"])
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "rate_limits.updated":
ev.Type = ai.VoiceEventRateLimitsUpdated
ev.Metadata = base
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
case "response.done":
if tc := extractToolCall(base); tc != nil {
ev.Type = ai.VoiceEventToolCall
ev.ToolCall = tc
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
}
ev.Type = ai.VoiceEventAssistantAudioDone
return ev, GenericRealtimeEvent{Type: typ, Raw: base}, nil
default:
return ai.VoiceEvent{}, GenericRealtimeEvent{Type: typ, Raw: base}, nil
}
}
func extractToolCall(m map[string]any) *ai.ToolCall {
resp, ok := m["response"].(map[string]any)
if !ok {
return nil
}
outs, ok := resp["output"].([]any)
if !ok {
return nil
}
for _, o := range outs {
item, ok := o.(map[string]any)
if !ok {
continue
}
if toStr(item["type"]) == "function_call" {
raw := toStr(item["arguments"])
args := map[string]any{}
_ = json.Unmarshal([]byte(raw), &args)
return &ai.ToolCall{ID: toStr(item["call_id"]), Name: toStr(item["name"]), Arguments: args, RawArguments: raw}
}
}
return nil
}
func toStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
+224
View File
@@ -0,0 +1,224 @@
package realtime
import (
"ai-operator/internal/ai"
"ai-operator/internal/audio"
"ai-operator/internal/config"
"ai-operator/internal/media"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"github.com/gorilla/websocket"
"log/slog"
"net/http"
"sync"
"time"
)
type Provider struct {
cfg config.Config
logger *slog.Logger
conn *websocket.Conn
events chan ai.VoiceEvent
mu sync.Mutex
stats ai.VoiceProviderStats
inputBytes, outputBytes int64
eventsClosed bool
}
func NewProvider(cfg config.Config, logger *slog.Logger) *Provider {
return &Provider{cfg: cfg, logger: logger, events: make(chan ai.VoiceEvent, 64)}
}
func (p *Provider) StartSession(ctx context.Context, sc ai.VoiceSessionConfig) error {
if p.cfg.OpenAI.APIKey == "" {
return errors.New("OPENAI_API_KEY is required for OpenAI Realtime")
}
p.mu.Lock()
p.events = make(chan ai.VoiceEvent, 64)
p.eventsClosed = false
p.stats = ai.VoiceProviderStats{}
p.inputBytes = 0
p.outputBytes = 0
p.mu.Unlock()
u, _, err := BuildURL(p.cfg.OpenAI)
if err != nil {
return err
}
h := http.Header{}
h.Set("Authorization", "Bearer "+p.cfg.OpenAI.APIKey)
h.Set("OpenAI-Safety-Identifier", safetyID(sc.CallID))
d := websocket.Dialer{HandshakeTimeout: p.cfg.OpenAI.RealtimeConnectTimeout}
conn, _, err := d.DialContext(ctx, u, h)
if err != nil {
return err
}
p.conn = conn
now := time.Now().UTC()
p.mu.Lock()
p.stats.StartedAt = &now
p.mu.Unlock()
if err := p.waitFor(ctx, "session.created"); err != nil {
return err
}
instr := sc.SystemPrompt
if instr == "" {
instr = "You are a test voice provider adapter for an AI operator. For now, keep responses very short. Do not claim to access customer data. Do not answer business questions yet."
}
msg, err := BuildSessionUpdate(p.cfg.OpenAI, instr)
if err != nil {
return err
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return err
}
_ = p.waitFor(ctx, "session.updated")
go p.readLoop(sc.CallID)
if p.cfg.OpenAI.RealtimeInitialGreeting {
greeting := "Say exactly this short natural greeting in a calm, natural contact-center voice, without asking to choose language or region: Здравствуйте, меня зовут Жанна. Я AI-оператор QazAimaqGas. Чем могу помочь?"
rc, err := BuildResponseCreateWithInstructions(greeting)
if err != nil {
return err
}
if err := conn.WriteMessage(websocket.TextMessage, rc); err != nil {
return err
}
}
return nil
}
func (p *Provider) waitFor(ctx context.Context, want string) error {
deadline := time.After(p.cfg.OpenAI.RealtimeConnectTimeout)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline:
return errors.New("timeout waiting for " + want)
default:
_, data, err := p.conn.ReadMessage()
if err != nil {
return err
}
var m map[string]any
_ = jsonUnmarshal(data, &m)
if toStr(m["type"]) == want {
return nil
}
}
}
}
func (p *Provider) readLoop(callID string) {
defer p.closeEvents()
for {
_, data, err := p.conn.ReadMessage()
if err != nil {
p.emit(ai.VoiceEvent{Type: ai.VoiceEventClosed, CallID: callID, At: time.Now().UTC()})
return
}
ev, _, err := ParseServerEvent(data)
if err != nil {
p.emit(ai.VoiceEvent{Type: ai.VoiceEventError, CallID: callID, Error: err.Error(), At: time.Now().UTC()})
continue
}
if ev.Type != "" {
ev.CallID = callID
p.emit(ev)
}
}
}
func (p *Provider) SendAudio(ctx context.Context, ch media.AudioChunk) error {
if p.conn == nil {
return errors.New("openai realtime websocket not connected")
}
if !audio.IsPCM16Aligned(ch.Data) {
return errors.New("pcm16 audio is not aligned")
}
p.mu.Lock()
p.inputBytes += int64(len(ch.Data))
if p.inputBytes > int64(p.cfg.OpenAI.RealtimeMaxInputAudioBytes) {
p.mu.Unlock()
return errors.New("max input audio bytes exceeded")
}
p.stats.InputAudioFrames++
p.stats.InputAudioBytes += int64(len(ch.Data))
p.mu.Unlock()
chunks := audio.ChunkBytes(ch.Data, p.cfg.OpenAI.RealtimeMaxAudioChunkBytes)
for _, c := range chunks {
msg, err := BuildAudioAppend(c)
if err != nil {
return err
}
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return err
}
p.mu.Lock()
p.stats.EventsSent++
p.mu.Unlock()
}
return nil
}
func (p *Provider) SendToolResult(ctx context.Context, r ai.ToolResult) error {
if p.conn == nil {
return errors.New("openai realtime websocket not connected")
}
msg, err := BuildToolResult(r)
if err != nil {
return err
}
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return err
}
rc, _ := BuildResponseCreate()
return p.conn.WriteMessage(websocket.TextMessage, rc)
}
func (p *Provider) Close(ctx context.Context) error {
p.mu.Lock()
now := time.Now().UTC()
p.stats.ClosedAt = &now
p.mu.Unlock()
if p.conn != nil {
_ = p.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return p.conn.Close()
}
p.closeEvents()
return nil
}
func (p *Provider) Events() <-chan ai.VoiceEvent { return p.events }
func (p *Provider) Stats() ai.VoiceProviderStats { p.mu.Lock(); defer p.mu.Unlock(); return p.stats }
func (p *Provider) emit(e ai.VoiceEvent) {
p.mu.Lock()
defer p.mu.Unlock()
p.stats.EventsReceived++
if e.Type == ai.VoiceEventAssistantAudioDelta {
p.outputBytes += int64(len(e.Audio))
p.stats.OutputAudioFrames++
p.stats.OutputAudioBytes += int64(len(e.Audio))
}
if e.Type == ai.VoiceEventToolCall {
p.stats.ToolCallsReceived++
}
if e.Type == ai.VoiceEventError {
p.stats.Errors++
}
if p.eventsClosed {
return
}
select {
case p.events <- e:
default:
}
}
func (p *Provider) closeEvents() {
p.mu.Lock()
defer p.mu.Unlock()
if !p.eventsClosed {
close(p.events)
p.eventsClosed = true
}
}
func safetyID(callID string) string {
sum := sha256.Sum256([]byte(callID))
return "aiop-" + hex.EncodeToString(sum[:])[:16]
}
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
@@ -0,0 +1,161 @@
package realtime
import (
"ai-operator/internal/ai"
"ai-operator/internal/config"
"ai-operator/internal/media"
"context"
"encoding/json"
"github.com/gorilla/websocket"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestURLBuildersParsers(t *testing.T) {
cfg := config.OpenAIConfig{RealtimeURL: "wss://api.openai.com/v1/realtime", RealtimeModel: "gpt realtime", RealtimeVoice: "marin", RealtimeInputSampleRate: 24000, RealtimeOutputSampleRate: 24000, RealtimeTurnDetection: "server_vad", RealtimeReasoningEffort: "low"}
u, _, err := BuildURL(cfg)
if err != nil || !strings.Contains(u, "model=gpt+realtime") {
t.Fatalf("url=%s err=%v", u, err)
}
b, err := BuildSessionUpdate(cfg, "test")
if err != nil || strings.Contains(string(b), "sk-") {
t.Fatal(string(b))
}
if !strings.Contains(string(b), `"voice":"marin"`) {
t.Fatalf("session.update missing configured voice: %s", b)
}
for _, fn := range []func() ([]byte, error){func() ([]byte, error) { return BuildAudioAppend([]byte{1, 2}) }, BuildCommit, BuildResponseCreate, BuildResponseCancel, BuildInputAudioClear, func() ([]byte, error) {
return BuildToolResult(ai.ToolResult{ToolCallID: "tc", Result: map[string]string{"ok": "true"}})
}} {
x, err := fn()
if err != nil || !json.Valid(x) {
t.Fatalf("bad event %s %v", x, err)
}
}
ev, _, err := ParseServerEvent([]byte(`{"type":"response.output_audio.delta","delta":"AQI="}`))
if err != nil || ev.Type != ai.VoiceEventAssistantAudioDelta || len(ev.Audio) != 2 {
t.Fatalf("ev=%+v err=%v", ev, err)
}
ev, _, _ = ParseServerEvent([]byte(`{"type":"input_audio_buffer.speech_started"}`))
if ev.Type != ai.VoiceEventInterruption {
t.Fatal(ev.Type)
}
ev, _, _ = ParseServerEvent([]byte(`{"type":"response.done","response":{"output":[{"type":"function_call","call_id":"c","name":"set_language","arguments":"{\"language\":\"ru\"}"}]}}`))
if ev.ToolCall == nil || ev.ToolCall.Name != "set_language" {
t.Fatalf("tool=%+v", ev.ToolCall)
}
if _, _, err := ParseServerEvent([]byte(`bad`)); err == nil {
t.Fatal("want err")
}
}
func TestProviderFakeWebSocket(t *testing.T) {
up := websocket.Upgrader{}
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
_ = c.WriteJSON(map[string]any{"type": "session.created"})
_, msg, _ := c.ReadMessage()
if !strings.Contains(string(msg), "session.update") {
t.Errorf("msg=%s", msg)
}
_ = c.WriteJSON(map[string]any{"type": "session.updated"})
_, _, _ = c.ReadMessage()
_ = c.WriteJSON(map[string]any{"type": "response.output_audio.delta", "delta": "AQI="})
time.Sleep(20 * time.Millisecond)
}))
defer srv.Close()
cfg := config.Config{OpenAI: config.OpenAIConfig{APIKey: "sk-test", RealtimeURL: "ws" + strings.TrimPrefix(srv.URL, "http"), RealtimeModel: "m", RealtimeConnectTimeout: time.Second, RealtimeMaxAudioChunkBytes: 100, RealtimeMaxInputAudioBytes: 1000, RealtimeInputSampleRate: 24000, RealtimeOutputSampleRate: 24000, RealtimeTurnDetection: "server_vad", RealtimeReasoningEffort: "low"}}
p := NewProvider(cfg, nil)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := p.StartSession(ctx, ai.VoiceSessionConfig{CallID: "c1"}); err != nil {
t.Fatal(err)
}
if auth != "Bearer sk-test" {
t.Fatal("auth missing")
}
if err := p.SendAudio(ctx, media.AudioChunk{Data: []byte{0, 0}}); err != nil {
t.Fatal(err)
}
select {
case ev := <-p.Events():
_ = ev
case <-time.After(time.Second):
t.Fatal("no event")
}
_ = p.Close(ctx)
}
func TestProviderCanStartAfterClose(t *testing.T) {
up := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
_ = c.WriteJSON(map[string]any{"type": "session.created"})
_, msg, _ := c.ReadMessage()
if !strings.Contains(string(msg), "session.update") {
t.Errorf("msg=%s", msg)
}
_ = c.WriteJSON(map[string]any{"type": "session.updated"})
_ = c.WriteJSON(map[string]any{"type": "response.output_audio.delta", "delta": "AQI="})
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
defer srv.Close()
cfg := config.Config{OpenAI: config.OpenAIConfig{APIKey: "sk-test", RealtimeURL: "ws" + strings.TrimPrefix(srv.URL, "http"), RealtimeModel: "m", RealtimeConnectTimeout: time.Second, RealtimeMaxAudioChunkBytes: 100, RealtimeMaxInputAudioBytes: 1000, RealtimeInputSampleRate: 24000, RealtimeOutputSampleRate: 24000, RealtimeTurnDetection: "server_vad", RealtimeReasoningEffort: "low"}}
p := NewProvider(cfg, nil)
for _, callID := range []string{"c1", "c2"} {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
if err := p.StartSession(ctx, ai.VoiceSessionConfig{CallID: callID}); err != nil {
cancel()
t.Fatal(err)
}
select {
case ev := <-p.Events():
if ev.CallID != callID {
cancel()
t.Fatalf("call_id=%s want %s", ev.CallID, callID)
}
case <-ctx.Done():
cancel()
t.Fatal("no event")
}
if err := p.Close(ctx); err != nil {
cancel()
t.Fatal(err)
}
for {
select {
case _, ok := <-p.Events():
if !ok {
cancel()
goto next
}
case <-ctx.Done():
cancel()
t.Fatal("events channel did not close")
}
}
next:
}
}
func TestCostGuard(t *testing.T) {
p := NewProvider(config.Config{OpenAI: config.OpenAIConfig{RealtimeMaxInputAudioBytes: 1}}, nil)
p.conn = &websocket.Conn{}
if err := p.SendAudio(context.Background(), media.AudioChunk{Data: []byte{0, 0}}); err == nil {
t.Fatal("want cost err")
}
}
+17
View File
@@ -0,0 +1,17 @@
package realtime
import (
"ai-operator/internal/config"
"net/url"
)
func BuildURL(cfg config.OpenAIConfig) (string, string, error) {
u, err := url.Parse(cfg.RealtimeURL)
if err != nil {
return "", "", err
}
q := u.Query()
q.Set("model", cfg.RealtimeModel)
u.RawQuery = q.Encode()
return u.String(), u.String(), nil
}