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:
16
genai/default.go
Normal file
16
genai/default.go
Normal 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
161
genai/gemini/gemini.go
Normal 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
53
genai/genai.go
Normal 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
16
genai/noop.go
Normal 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
151
genai/openai/openai.go
Normal 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())
|
||||
}
|
37
genai/openai/openai_test.go
Normal file
37
genai/openai/openai_test.go
Normal 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
20
genai/options.go
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user