1
0
mirror of https://github.com/go-micro/go-micro.git synced 2025-08-04 21:42:57 +02:00

GenAI interface (#2790)

* genai interface

* x

* x

* text to speech

* Re-add events package (#2761)

* Re-add events package

* run redis as a dep

* remove redis events

* fix: data race on event subscriber

* fix: data race in tests

* fix: store errors

* fix: lint issues

* feat: default stream

* Update file.go

---------

Co-authored-by: Brian Ketelsen <bketelsen@gmail.com>

* .

* copilot couldn't make it compile so I did

* copilot couldn't make it compile so I did

* x

---------

Co-authored-by: Brian Ketelsen <bketelsen@gmail.com>
This commit is contained in:
Asim Aslam
2025-06-20 10:24:31 +01:00
committed by GitHub
parent 7e1bba2baf
commit ee9f3afe37
17 changed files with 543 additions and 21 deletions

16
genai/default.go Normal file
View File

@@ -0,0 +1,16 @@
package genai
import (
"sync"
)
var (
DefaultGenAI GenAI = &noopGenAI{}
defaultOnce sync.Once
)
func SetDefault(g GenAI) {
defaultOnce.Do(func() {
DefaultGenAI = g
})
}

161
genai/gemini/gemini.go Normal file
View File

@@ -0,0 +1,161 @@
package gemini
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"go-micro.dev/v5/genai"
)
// gemini implements the GenAI interface using Google Gemini 2.5 API.
type gemini struct {
options genai.Options
}
func New(opts ...genai.Option) genai.GenAI {
var options genai.Options
for _, o := range opts {
o(&options)
}
if options.APIKey == "" {
options.APIKey = os.Getenv("GEMINI_API_KEY")
}
return &gemini{options: options}
}
func (g *gemini) Generate(prompt string, opts ...genai.Option) (*genai.Result, error) {
options := g.options
for _, o := range opts {
o(&options)
}
ctx := context.Background()
res := &genai.Result{Prompt: prompt, Type: options.Type}
endpoint := options.Endpoint
if endpoint == "" {
endpoint = "https://generativelanguage.googleapis.com/v1beta/models/"
}
var url string
var body map[string]interface{}
// Determine model to use
var model string
switch options.Type {
case "image":
if options.Model != "" {
model = options.Model
} else {
model = "gemini-2.5-pro-vision"
}
url = endpoint + model + ":generateContent?key=" + options.APIKey
body = map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
}
case "audio":
if options.Model != "" {
model = options.Model
} else {
model = "gemini-2.5-pro"
}
url = endpoint + model + ":generateContent?key=" + options.APIKey
body = map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
"response_mime_type": "audio/wav",
}
case "text":
fallthrough
default:
if options.Model != "" {
model = options.Model
} else {
model = "gemini-2.5-pro"
}
url = endpoint + model + ":generateContent?key=" + options.APIKey
body = map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
}
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if options.Type == "audio" {
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
Data []byte `json:"data"`
} `json:"inline_data"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("no audio returned")
}
res.Data = result.Candidates[0].Content.Parts[0].InlineData.Data
return res, nil
}
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("no candidates returned")
}
res.Text = result.Candidates[0].Content.Parts[0].Text
return res, nil
}
func (g *gemini) Stream(prompt string, opts ...genai.Option) (*genai.Stream, error) {
results := make(chan *genai.Result)
go func() {
defer close(results)
res, err := g.Generate(prompt, opts...)
if err != nil {
// Send error via Stream.Err, not channel
return
}
results <- res
}()
return &genai.Stream{Results: results}, nil
}
func init() {
genai.Register("gemini", New())
}

53
genai/genai.go Normal file
View File

@@ -0,0 +1,53 @@
// Package genai provides a generic interface for generative AI providers.
package genai
// Result is the unified response from GenAI providers.
type Result struct {
Prompt string
Type string
Data []byte // for audio/image binary data
Text string // for text or image URL
}
// Stream represents a streaming response from a GenAI provider.
type Stream struct {
Results <-chan *Result
Err error
// You can add fields for cancellation, errors, etc. if needed
}
// GenAI is the generic interface for generative AI providers.
type GenAI interface {
Generate(prompt string, opts ...Option) (*Result, error)
Stream(prompt string, opts ...Option) (*Stream, error)
}
// Option is a functional option for configuring providers.
type Option func(*Options)
// Options holds configuration for providers.
type Options struct {
APIKey string
Endpoint string
Type string // "text", "image", "audio", etc.
Model string // model name, e.g. "gemini-2.5-pro"
// Add more fields as needed
}
// Option functions for generation type
func Text(o *Options) { o.Type = "text" }
func Image(o *Options) { o.Type = "image" }
func Audio(o *Options) { o.Type = "audio" }
// Provider registry
var providers = make(map[string]GenAI)
// Register a GenAI provider by name.
func Register(name string, provider GenAI) {
providers[name] = provider
}
// Get a GenAI provider by name.
func Get(name string) GenAI {
return providers[name]
}

16
genai/noop.go Normal file
View File

@@ -0,0 +1,16 @@
package genai
type noopGenAI struct{}
func (n *noopGenAI) Generate(prompt string, opts ...Option) (*Result, error) {
return &Result{Prompt: prompt, Type: "noop", Text: "noop response"}, nil
}
func (n *noopGenAI) Stream(prompt string, opts ...Option) (*Stream, error) {
results := make(chan *Result, 1)
results <- &Result{Prompt: prompt, Type: "noop", Text: "noop response"}
close(results)
return &Stream{Results: results}, nil
}
var Default = &noopGenAI{}

151
genai/openai/openai.go Normal file
View File

@@ -0,0 +1,151 @@
package openai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"go-micro.dev/v5/genai"
)
type openAI struct {
options genai.Options
}
func New(opts ...genai.Option) genai.GenAI {
var options genai.Options
for _, o := range opts {
o(&options)
}
if options.APIKey == "" {
options.APIKey = os.Getenv("OPENAI_API_KEY")
}
return &openAI{options: options}
}
func (o *openAI) Generate(prompt string, opts ...genai.Option) (*genai.Result, error) {
options := o.options
for _, opt := range opts {
opt(&options)
}
res := &genai.Result{Prompt: prompt, Type: options.Type}
var url string
var body map[string]interface{}
switch options.Type {
case "image":
model := options.Model
if model == "" {
model = "dall-e-3"
}
url = "https://api.openai.com/v1/images/generations"
body = map[string]interface{}{
"prompt": prompt,
"n": 1,
"size": "1024x1024",
"model": model,
}
case "audio":
model := options.Model
if model == "" {
model = "tts-1"
}
url = "https://api.openai.com/v1/audio/speech"
body = map[string]interface{}{
"model": model,
"input": prompt,
"voice": "alloy", // or another supported voice
}
case "text":
fallthrough
default:
model := options.Model
if model == "" {
model = "gpt-3.5-turbo"
}
url = "https://api.openai.com/v1/chat/completions"
body = map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
}
}
b, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+options.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch options.Type {
case "image":
var result struct {
Data []struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if len(result.Data) == 0 {
return nil, fmt.Errorf("no image returned")
}
res.Text = result.Data[0].URL
return res, nil
case "audio":
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
res.Data = data
return res, nil
case "text":
fallthrough
default:
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if len(result.Choices) == 0 {
return nil, fmt.Errorf("no choices returned")
}
res.Text = result.Choices[0].Message.Content
return res, nil
}
}
func (o *openAI) Stream(prompt string, opts ...genai.Option) (*genai.Stream, error) {
results := make(chan *genai.Result)
go func() {
defer close(results)
res, err := o.Generate(prompt, opts...)
if err != nil {
// Send error via Stream.Err, not channel
return
}
results <- res
}()
return &genai.Stream{Results: results}, nil
}
func init() {
genai.Register("openai", New())
}

View File

@@ -0,0 +1,37 @@
package openai
import (
"go-micro.dev/v5/genai"
"os"
"testing"
)
func TestOpenAI_GenerateText(t *testing.T) {
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
t.Skip("OPENAI_API_KEY not set")
}
client := New(genai.WithAPIKey(apiKey))
res, err := client.Generate("Say hello world", genai.Text)
if err != nil {
t.Fatalf("Generate error: %v", err)
}
if res == nil || res.Text == "" {
t.Error("Expected non-empty text response")
}
}
func TestOpenAI_GenerateImage(t *testing.T) {
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
t.Skip("OPENAI_API_KEY not set")
}
client := New(genai.WithAPIKey(apiKey))
res, err := client.Generate("A cat wearing sunglasses", genai.Image)
if err != nil {
t.Fatalf("Generate error: %v", err)
}
if res == nil || res.Text == "" {
t.Error("Expected non-empty image URL")
}
}

20
genai/options.go Normal file
View File

@@ -0,0 +1,20 @@
package genai
// Option sets options for a GenAI provider.
func WithAPIKey(key string) Option {
return func(o *Options) {
o.APIKey = key
}
}
func WithEndpoint(endpoint string) Option {
return func(o *Options) {
o.Endpoint = endpoint
}
}
func WithModel(model string) Option {
return func(o *Options) {
o.Model = model
}
}