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
8 changes: 8 additions & 0 deletions client/provider_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
81 changes: 81 additions & 0 deletions engine/media.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
90 changes: 90 additions & 0 deletions engine/media_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
55 changes: 54 additions & 1 deletion internal/grpc/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
21 changes: 21 additions & 0 deletions internal/grpc/grpc_engine_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading