项目文件夹

文件
Asim Aslam cae6fbbe76 Framework hardening: security, reliability, and developer experience improvements (#2826)
* fix: remove deprecated rand.Seed calls

Go 1.20+ automatically seeds the global random number generator.
These calls are no-ops and generate warnings with newer Go versions.

Removed from:
- selector/strategy.go
- registry/cache/cache.go
- broker/memory.go
- broker/http.go
- cmd/cmd.go
- transport/memory.go

Co-authored-by: Shelley <shelley@exe.dev>

* fix: handle previously ignored errors

- MySQL store: properly handle prepared statement errors in initDB()
- Consul registry: handle client creation errors in Client() method

These silent failures could cause hard-to-debug issues in production.

Co-authored-by: Shelley <shelley@exe.dev>

* feat(genai): improve provider interface with context and streaming

Breaking changes:
- Generate() and Stream() now require context.Context as first parameter
- Stream.Close() added for proper resource cleanup

Improvements:
- Proper context support for cancellation and timeouts
- Real SSE streaming for OpenAI and Gemini text generation
- Better error handling with wrapped errors and API error responses
- Thread-safe provider registry with sync.RWMutex
- New options: WithMaxTokens, WithTemperature, WithTimeout
- Stream has proper Close() method for cleanup
- Results can include Error field for per-chunk errors

Provider updates:
- OpenAI: true streaming with SSE parsing, proper HTTP client with timeout
- Gemini: true streaming with streamGenerateContent endpoint
- Default model updated to gpt-4o-mini (OpenAI) and gemini-2.0-flash (Gemini)

Co-authored-by: Shelley <shelley@exe.dev>

* feat(tls): make TLS secure by default, configurable via environment

BREAKING: TLS now verifies certificates by default. Set MICRO_TLS_INSECURE=true
to restore previous behavior (NOT recommended for production).

Changes:
- Add util/tls.Config(), SecureConfig(), InsecureConfig(), ConfigFromEnv() helpers
- Update all components to use ConfigFromEnv() instead of hardcoded InsecureSkipVerify
- Set MinVersion to TLS 1.2 for all TLS configs

Affected components:
- broker/http
- broker/rabbitmq
- registry/etcd
- registry/consul
- transport/grpc

This improves security posture while allowing opt-out for development environments.

Co-authored-by: Shelley <shelley@exe.dev>

* feat(tls): add TLS helpers with opt-in secure mode

NOT a breaking change - keeps InsecureSkipVerify=true as default for
local development compatibility.

New util/tls helpers:
- Config() - returns config based on MICRO_TLS_SECURE env var
- SecureConfig() - certificate verification enabled
- InsecureConfig() - certificate verification disabled (dev only)

For production security, use one of:
- Set MICRO_TLS_SECURE=true with proper CA-signed certs
- Use a service mesh (Istio, Linkerd) for automatic mTLS
- Configure TLSConfig directly with your certificates

Also: Changed CLI alias from 'g' to 'gen' for clarity
- micro generate handler -> micro gen handler

Co-authored-by: Shelley <shelley@exe.dev>

* refactor(cli): rename generate directory to gen for consistency

Directory name now matches the command alias:
  cmd/micro/cli/gen/ -> micro gen handler

Co-authored-by: Shelley <shelley@exe.dev>

---------

Co-authored-by: Shelley <shelley@exe.dev>
2026-01-27 10:39:25 +00:00

327 行
7.0 KiB
Go

package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"go-micro.dev/v5/genai"
)
const (
defaultTextModel = "gpt-4o-mini"
defaultImageModel = "dall-e-3"
defaultAudioModel = "tts-1"
defaultTimeout = 120 // seconds
)
type openAI struct {
options genai.Options
client *http.Client
}
// New creates a new OpenAI provider.
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")
}
if options.Timeout == 0 {
options.Timeout = defaultTimeout
}
return &openAI{
options: options,
client: &http.Client{
Timeout: time.Duration(options.Timeout) * time.Second,
},
}
}
func (o *openAI) Generate(ctx context.Context, 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 = defaultImageModel
}
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 = defaultAudioModel
}
url = "https://api.openai.com/v1/audio/speech"
body = map[string]interface{}{
"model": model,
"input": prompt,
"voice": "alloy",
}
case "text":
fallthrough
default:
model := options.Model
if model == "" {
model = defaultTextModel
}
url = "https://api.openai.com/v1/chat/completions"
body = map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
}
if options.MaxTokens > 0 {
body["max_tokens"] = options.MaxTokens
}
if options.Temperature > 0 {
body["temperature"] = options.Temperature
}
}
// Use custom endpoint if provided
if options.Endpoint != "" {
url = options.Endpoint
}
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+options.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Check for API errors
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
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, fmt.Errorf("failed to decode response: %w", 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, fmt.Errorf("failed to read audio data: %w", err)
}
res.Data = data
return res, nil
default: // text
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, fmt.Errorf("failed to decode response: %w", err)
}
if len(result.Choices) == 0 {
return nil, fmt.Errorf("no choices returned")
}
res.Text = result.Choices[0].Message.Content
return res, nil
}
}
// Stream performs a streaming request for text generation.
func (o *openAI) Stream(ctx context.Context, prompt string, opts ...genai.Option) (*genai.Stream, error) {
options := o.options
for _, opt := range opts {
opt(&options)
}
// Only text supports streaming
if options.Type != "" && options.Type != "text" {
// For non-text types, fall back to non-streaming
results := make(chan *genai.Result, 1)
go func() {
defer close(results)
res, err := o.Generate(ctx, prompt, opts...)
if err != nil {
results <- &genai.Result{Error: err}
return
}
results <- res
}()
return genai.NewStream(results, nil), nil
}
model := options.Model
if model == "" {
model = defaultTextModel
}
body := map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
"stream": true,
}
if options.MaxTokens > 0 {
body["max_tokens"] = options.MaxTokens
}
if options.Temperature > 0 {
body["temperature"] = options.Temperature
}
url := "https://api.openai.com/v1/chat/completions"
if options.Endpoint != "" {
url = options.Endpoint
}
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create cancellable context for the stream
streamCtx, cancel := context.WithCancel(ctx)
req, err := http.NewRequestWithContext(streamCtx, "POST", url, bytes.NewReader(b))
if err != nil {
cancel()
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+options.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := o.client.Do(req)
if err != nil {
cancel()
return nil, fmt.Errorf("request failed: %w", err)
}
// Check for API errors
if resp.StatusCode >= 400 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
cancel()
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
results := make(chan *genai.Result, 16)
go func() {
defer close(results)
defer resp.Body.Close()
defer cancel()
reader := bufio.NewReader(resp.Body)
for {
select {
case <-streamCtx.Done():
return
default:
}
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
results <- &genai.Result{Error: err}
}
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // Skip malformed chunks
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
select {
case results <- &genai.Result{
Prompt: prompt,
Type: "text",
Text: chunk.Choices[0].Delta.Content,
}:
case <-streamCtx.Done():
return
}
}
}
}()
return genai.NewStream(results, cancel), nil
}
func (o *openAI) String() string {
return "openai"
}
func init() {
genai.Register("openai", New())
}