From 947792abd097be9b58a64ad7c5d3ef861ff80567 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 10 Sep 2026 11:10:47 +0000 Subject: [PATCH] feat: longcat provider client, engine chat gRPC service, media facade - provider_registry: route longcat to NewLongCatClient (preserves Anthropic wire) - grpc: add EngineChatService adapting conversation.Engine to ChatService - engine/media: host-facing GenerateImage/Transcribe facade with tests --- client/provider_registry.go | 8 +++ engine/media.go | 81 ++++++++++++++++++++++++++++ engine/media_test.go | 90 +++++++++++++++++++++++++++++++ internal/grpc/grpc.go | 55 ++++++++++++++++++- internal/grpc/grpc_engine_test.go | 21 ++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 engine/media.go create mode 100644 engine/media_test.go create mode 100644 internal/grpc/grpc_engine_test.go diff --git a/client/provider_registry.go b/client/provider_registry.go index a4c283a..9ca0e7a 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -153,6 +153,14 @@ func (c *GraycodeRouterClient) getOrCreateProvider(providerName string) (Provide p = adapters.NewPoolsideClient(apiKey, baseURL) break } + if providerName == "longcat" { + // LongCat speaks both the OpenAI and Anthropic wire protocols; the + // dedicated client preserves both paths, matching the setup path. + // Without this, the generic OpenAI client below would silently drop + // the Anthropic route for Anthropic-configured longcat deployments. + p = adapters.NewLongCatClient(apiKey, baseURL, config.DefaultLongCatAnthropicBaseURL, info.Compat) + break + } p = adapters.NewOpenAIClient(apiKey, baseURL, info.Compat) } diff --git a/engine/media.go b/engine/media.go new file mode 100644 index 0000000..390a3b6 --- /dev/null +++ b/engine/media.go @@ -0,0 +1,81 @@ +package engine + +import ( + "context" + + "github.com/GrayCodeAI/graycode-router/client" +) + +// MediaOptions carries the credentials and endpoint for a media backend call. +// Credentials are supplied per call by the host — no new secret paths are +// introduced, and the engine never stores media credentials. +type MediaOptions struct { + APIKey string + BaseURL string +} + +// GenerateImageRequest is the host-facing request for image generation. +type GenerateImageRequest struct { + MediaOptions + Prompt string + Model string + Size string // e.g. "1024x1024" + N int +} + +// GenerateImageResult is one generated image plus its provider URL when present. +type GenerateImageResult struct { + Image []byte + ProviderURL string +} + +// GenerateImage generates images through the OpenAI-compatible endpoint +// configured in req. It is a stateless facade over client.ImageClient, +// returning decoded image bytes (plus any provider URL). The engine keeps no +// media state; the host owns conversation and persistence. +func (e *Engine) GenerateImage(ctx context.Context, req GenerateImageRequest) ([]GenerateImageResult, error) { + if ctx == nil { + ctx = context.Background() + } + c := client.NewImageClient(req.APIKey, req.BaseURL) + imgs, urls, err := c.Generate(ctx, req.Prompt, req.Model, req.Size, req.N) + if err != nil { + return nil, err + } + out := make([]GenerateImageResult, 0, len(imgs)) + for i := range imgs { + r := GenerateImageResult{Image: imgs[i]} + if i < len(urls) { + r.ProviderURL = urls[i] + } + out = append(out, r) + } + return out, nil +} + +// TranscribeRequest is the host-facing request for audio transcription. +type TranscribeRequest struct { + MediaOptions + Audio []byte + FileName string + Model string + Language string // optional ISO-639-1 + Prompt string // optional context/hint +} + +// Transcribe transcribes audio through the OpenAI-compatible endpoint +// configured in req, returning the transcript text. It is a stateless facade +// over client.AudioClient. +func (e *Engine) Transcribe(ctx context.Context, req TranscribeRequest) (string, error) { + if ctx == nil { + ctx = context.Background() + } + c := client.NewAudioClient(req.APIKey, req.BaseURL) + return c.Transcribe(ctx, client.TranscriptionRequest{ + Model: req.Model, + File: req.Audio, + FileName: req.FileName, + Language: req.Language, + Prompt: req.Prompt, + }) +} diff --git a/engine/media_test.go b/engine/media_test.go new file mode 100644 index 0000000..b13ec8c --- /dev/null +++ b/engine/media_test.go @@ -0,0 +1,90 @@ +package engine + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GrayCodeAI/graycode-router/credentials" +) + +func newMediaTestEngine(t *testing.T) *Engine { + t.Helper() + eng, err := New(Options{SecretStore: &credentials.MapStore{}}) + if err != nil { + t.Fatalf("New: %v", err) + } + return eng +} + +func TestEngineGenerateImage(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("fakepng")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/images/generations" { + t.Fatalf("path = %s", r.URL.Path) + } + fmt.Fprintf(w, `{"created":1,"data":[{"b64_json":%q},{"b64_json":%q}]}`, pngB64, pngB64) + })) + defer srv.Close() + + eng := newMediaTestEngine(t) + results, err := eng.GenerateImage(context.Background(), GenerateImageRequest{ + MediaOptions: MediaOptions{APIKey: "k", BaseURL: srv.URL}, + Prompt: "a cat", Model: "dall-e-3", Size: "1024x1024", N: 2, + }) + if err != nil { + t.Fatalf("GenerateImage: %v", err) + } + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + if string(results[0].Image) != "fakepng" { + t.Fatalf("image[0] = %q", results[0].Image) + } +} + +func TestEngineGenerateImageError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":{"message":"bad key"}}`) + })) + defer srv.Close() + + eng := newMediaTestEngine(t) + _, err := eng.GenerateImage(context.Background(), GenerateImageRequest{ + MediaOptions: MediaOptions{APIKey: "k", BaseURL: srv.URL}, + Prompt: "p", N: 1, + }) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("err = %v, want 401", err) + } +} + +func TestEngineTranscribe(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/audio/transcriptions" { + t.Fatalf("path = %s", r.URL.Path) + } + if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { + t.Fatal("expected multipart") + } + fmt.Fprint(w, `{"text":"hello world"}`) + })) + defer srv.Close() + + eng := newMediaTestEngine(t) + text, err := eng.Transcribe(context.Background(), TranscribeRequest{ + MediaOptions: MediaOptions{APIKey: "k", BaseURL: srv.URL}, + Audio: []byte("audio-bytes"), FileName: "voice.m4a", Model: "whisper-1", + }) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "hello world" { + t.Fatalf("text = %q", text) + } +} diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go index 92ada3b..531ebd1 100644 --- a/internal/grpc/grpc.go +++ b/internal/grpc/grpc.go @@ -8,7 +8,13 @@ // note and codegen steps. package grpc -import "context" +import ( + "context" + "errors" + "fmt" + + "github.com/GrayCodeAI/graycode-router/conversation" +) // ChatRequest is the unary Chat request payload. It mirrors the HTTP // /prompt request fields so a gRPC implementation can reuse the conversation @@ -59,3 +65,50 @@ func (noopChatService) Chat(_ context.Context, _ *ChatRequest) (*ChatResponse, e func NewChatService() ChatService { return noopChatService{} } + +// EngineChatService adapts conversation.Engine to the ChatService contract: a +// unary Chat RPC becomes a single Prompt over the conversation engine, with +// the streamed assistant content aggregated into the response. +type EngineChatService struct { + engine *conversation.Engine +} + +// NewEngineChatService returns a ChatService backed by a conversation.Engine. +// It is the real backend referenced by the gRPC server (build tag "grpc"). +func NewEngineChatService(engine *conversation.Engine) ChatService { + return &EngineChatService{engine: engine} +} + +// Chat runs a single prompt through the conversation engine and aggregates the +// streamed assistant content into a ChatResponse. +func (s *EngineChatService) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { + if s.engine == nil { + return nil, ErrUnimplemented + } + if req == nil { + return nil, fmt.Errorf("graycode-router/grpc: chat request is required") + } + ch, err := s.engine.Prompt(ctx, req.Message, conversation.PromptOpts{ + Model: req.Model, + SystemPrompt: req.SystemPrompt, + MaxTokens: req.MaxTokens, + }) + if err != nil { + return nil, err + } + var content string + var nodeID string + for ev := range ch { + switch ev.Type { + case conversation.EventDelta: + content += ev.Content + case conversation.EventError: + if ev.Error != "" { + return nil, errors.New(ev.Error) + } + case conversation.EventDone: + nodeID = ev.NodeID + } + } + return &ChatResponse{Content: content, NodeID: nodeID, FinishReason: "stop"}, nil +} diff --git a/internal/grpc/grpc_engine_test.go b/internal/grpc/grpc_engine_test.go new file mode 100644 index 0000000..cb484b9 --- /dev/null +++ b/internal/grpc/grpc_engine_test.go @@ -0,0 +1,21 @@ +package grpc + +import ( + "context" + "testing" +) + +// TestEngineChatServiceContract verifies the constructor surface and the noop +// fallback. A full engine-backed round-trip is covered by server_grpc_test.go +// (build tag "grpc") and requires a store-backed conversation.Engine. +func TestEngineChatServiceContract(t *testing.T) { + if NewChatService() == nil { + t.Fatal("NewChatService returned nil") + } + if svc := NewEngineChatService(nil); svc == nil { + t.Fatal("NewEngineChatService returned nil") + } + if _, err := NewEngineChatService(nil).Chat(context.Background(), &ChatRequest{Message: "hi"}); err != ErrUnimplemented { + t.Fatalf("expected ErrUnimplemented for nil engine, got %v", err) + } +}