472 lines
11 KiB
Go
472 lines
11 KiB
Go
// Package uncloseai provides a Go client library for interacting with vLLM, Ollama, and OpenAI-compatible APIs.
|
|
//
|
|
// Features:
|
|
// - Automatic model discovery from environment variables
|
|
// - Streaming and non-streaming chat completions
|
|
// - Text-to-speech generation
|
|
// - Support for multiple endpoints
|
|
// - Type-safe API with comprehensive error handling
|
|
//
|
|
// Example:
|
|
//
|
|
// client, err := uncloseai.New(nil)
|
|
// if err != nil {
|
|
// log.Fatal(err)
|
|
// }
|
|
//
|
|
// response, err := client.Chat(ctx, []uncloseai.Message{
|
|
// {Role: "user", Content: "Hello!"},
|
|
// }, nil)
|
|
package uncloseai
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Error types
|
|
type Error string
|
|
|
|
const (
|
|
ErrConnection Error = "connection error"
|
|
ErrModelNotFound Error = "model not found"
|
|
ErrStreaming Error = "streaming error"
|
|
ErrNoModels Error = "no models available"
|
|
ErrNoTTSEndpoints Error = "no TTS endpoints available"
|
|
ErrInvalidResponse Error = "invalid response"
|
|
)
|
|
|
|
func (e Error) Error() string {
|
|
return string(e)
|
|
}
|
|
|
|
// ModelInfo contains metadata about a discovered model
|
|
type ModelInfo struct {
|
|
ID string `json:"id"`
|
|
Endpoint string `json:"endpoint"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
}
|
|
|
|
// Message represents a chat message
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// ChatResponse is the response from a non-streaming chat completion
|
|
type ChatResponse struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
Choices []ChatChoice `json:"choices"`
|
|
}
|
|
|
|
// ChatChoice represents a single choice in the response
|
|
type ChatChoice struct {
|
|
Index int `json:"index"`
|
|
Message Message `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
}
|
|
|
|
// StreamChunk represents a chunk from streaming chat
|
|
type StreamChunk struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
Choices []StreamChunkChoice `json:"choices"`
|
|
}
|
|
|
|
// StreamChunkChoice represents a single choice in a streaming chunk
|
|
type StreamChunkChoice struct {
|
|
Index int `json:"index"`
|
|
Delta StreamDelta `json:"delta"`
|
|
}
|
|
|
|
// StreamDelta represents the delta content in a streaming chunk
|
|
type StreamDelta struct {
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
}
|
|
|
|
// Config holds configuration options for the UncloseAI client
|
|
type Config struct {
|
|
// Endpoints for model discovery (nil = auto-discover from environment)
|
|
Endpoints []string
|
|
// TTS endpoints (nil = auto-discover from environment)
|
|
TTSEndpoints []string
|
|
// API key for authentication (empty = no authentication)
|
|
APIKey string
|
|
// HTTP client timeout (0 = 30 seconds default)
|
|
Timeout time.Duration
|
|
// Enable debug logging
|
|
Debug bool
|
|
}
|
|
|
|
// ChatOptions holds options for chat completions
|
|
type ChatOptions struct {
|
|
// Model ID (empty = auto-select first available)
|
|
Model string
|
|
// Maximum tokens to generate (0 = no limit)
|
|
MaxTokens int
|
|
// Sampling temperature (0.0 - 2.0)
|
|
Temperature float64
|
|
// Nucleus sampling (0.0 - 1.0)
|
|
TopP float64
|
|
}
|
|
|
|
// Client is the main UncloseAI client
|
|
type Client struct {
|
|
models []ModelInfo
|
|
ttsEndpoints []string
|
|
apiKey string
|
|
timeout time.Duration
|
|
httpClient *http.Client
|
|
debug bool
|
|
}
|
|
|
|
// New creates a new UncloseAI client with optional configuration.
|
|
// If config is nil, defaults are used and endpoints are auto-discovered from environment variables.
|
|
func New(config *Config) (*Client, error) {
|
|
if config == nil {
|
|
config = &Config{}
|
|
}
|
|
|
|
timeout := config.Timeout
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
client := &Client{
|
|
models: make([]ModelInfo, 0),
|
|
ttsEndpoints: make([]string, 0),
|
|
apiKey: config.APIKey,
|
|
timeout: timeout,
|
|
httpClient: &http.Client{Timeout: timeout},
|
|
debug: config.Debug,
|
|
}
|
|
|
|
// Discover endpoints from environment if not provided
|
|
endpoints := config.Endpoints
|
|
if endpoints == nil {
|
|
endpoints = discoverEnvEndpoints("MODEL_ENDPOINT")
|
|
}
|
|
|
|
ttsEndpoints := config.TTSEndpoints
|
|
if ttsEndpoints == nil {
|
|
ttsEndpoints = discoverEnvEndpoints("TTS_ENDPOINT")
|
|
}
|
|
|
|
if client.debug {
|
|
fmt.Printf("[DEBUG] Initialized with %d model endpoint(s) and %d TTS endpoint(s)\n",
|
|
len(endpoints), len(ttsEndpoints))
|
|
}
|
|
|
|
// Discover models from each endpoint
|
|
for _, endpoint := range endpoints {
|
|
if err := client.discoverModelsFromEndpoint(endpoint); err != nil {
|
|
if client.debug {
|
|
fmt.Printf("[DEBUG] Failed to discover models from %s: %v\n", endpoint, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
client.ttsEndpoints = ttsEndpoints
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// discoverEnvEndpoints finds endpoints from environment variables like PREFIX_1, PREFIX_2, ...
|
|
func discoverEnvEndpoints(prefix string) []string {
|
|
endpoints := make([]string, 0)
|
|
for i := 1; i <= 9999; i++ {
|
|
endpoint := os.Getenv(fmt.Sprintf("%s_%d", prefix, i))
|
|
if endpoint == "" {
|
|
break
|
|
}
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// discoverModelsFromEndpoint discovers available models from an endpoint
|
|
func (c *Client) discoverModelsFromEndpoint(endpoint string) error {
|
|
if c.debug {
|
|
fmt.Printf("[DEBUG] Discovering models from: %s\n", endpoint)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", endpoint+"/models", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
MaxModelLen int `json:"max_model_len"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, model := range result.Data {
|
|
maxTokens := model.MaxModelLen
|
|
if maxTokens == 0 {
|
|
maxTokens = 8192
|
|
}
|
|
c.models = append(c.models, ModelInfo{
|
|
ID: model.ID,
|
|
Endpoint: endpoint,
|
|
MaxTokens: maxTokens,
|
|
})
|
|
|
|
if c.debug {
|
|
fmt.Printf("[DEBUG] Discovered: %s\n", model.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListModels returns all discovered models
|
|
func (c *Client) ListModels() []ModelInfo {
|
|
return c.models
|
|
}
|
|
|
|
// Chat performs a non-streaming chat completion
|
|
func (c *Client) Chat(ctx context.Context, messages []Message, options *ChatOptions) (*ChatResponse, error) {
|
|
if options == nil {
|
|
options = &ChatOptions{Temperature: 0.7, TopP: 1.0}
|
|
}
|
|
|
|
modelInfo, err := c.getModelInfo(options.Model)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"model": modelInfo.ID,
|
|
"messages": messages,
|
|
"temperature": options.Temperature,
|
|
"top_p": options.TopP,
|
|
"stream": false,
|
|
}
|
|
|
|
if options.MaxTokens > 0 {
|
|
payload["max_tokens"] = options.MaxTokens
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", modelInfo.Endpoint+"/chat/completions", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnection, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("%w: status %d: %s", ErrInvalidResponse, resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var chatResp ChatResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidResponse, err)
|
|
}
|
|
|
|
return &chatResp, nil
|
|
}
|
|
|
|
// ChatStream performs a streaming chat completion, returning channels for chunks and errors
|
|
func (c *Client) ChatStream(ctx context.Context, messages []Message, options *ChatOptions) (<-chan StreamChunk, <-chan error) {
|
|
chunkChan := make(chan StreamChunk)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
defer close(chunkChan)
|
|
defer close(errChan)
|
|
|
|
if options == nil {
|
|
options = &ChatOptions{Temperature: 0.7, TopP: 1.0}
|
|
}
|
|
|
|
modelInfo, err := c.getModelInfo(options.Model)
|
|
if err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"model": modelInfo.ID,
|
|
"messages": messages,
|
|
"temperature": options.Temperature,
|
|
"top_p": options.TopP,
|
|
"stream": true,
|
|
}
|
|
|
|
if options.MaxTokens > 0 {
|
|
payload["max_tokens"] = options.MaxTokens
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", modelInfo.Endpoint+"/chat/completions", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
errChan <- fmt.Errorf("%w: %v", ErrConnection, err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
errChan <- fmt.Errorf("%w: status %d: %s", ErrStreaming, resp.StatusCode, string(bodyBytes))
|
|
return
|
|
}
|
|
|
|
// Parse SSE stream
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
// SSE format: "data: {...}"
|
|
if strings.HasPrefix(line, "data: ") {
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
|
|
// Check for stream termination
|
|
if strings.TrimSpace(data) == "[DONE]" {
|
|
break
|
|
}
|
|
|
|
var chunk StreamChunk
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
if c.debug {
|
|
fmt.Printf("[DEBUG] Failed to parse chunk: %s\n", data)
|
|
}
|
|
continue // Skip malformed chunks
|
|
}
|
|
|
|
select {
|
|
case chunkChan <- chunk:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
errChan <- fmt.Errorf("%w: %v", ErrStreaming, err)
|
|
}
|
|
}()
|
|
|
|
return chunkChan, errChan
|
|
}
|
|
|
|
// TTS generates speech from text
|
|
func (c *Client) TTS(ctx context.Context, text, voice, model string) ([]byte, error) {
|
|
if len(c.ttsEndpoints) == 0 {
|
|
return nil, ErrNoTTSEndpoints
|
|
}
|
|
|
|
endpoint := c.ttsEndpoints[0]
|
|
|
|
payload := map[string]interface{}{
|
|
"model": model,
|
|
"voice": voice,
|
|
"input": text,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint+"/audio/speech", bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnection, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%w: status %d", ErrInvalidResponse, resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// getModelInfo retrieves model info by ID or returns first available
|
|
func (c *Client) getModelInfo(modelID string) (*ModelInfo, error) {
|
|
if len(c.models) == 0 {
|
|
return nil, ErrNoModels
|
|
}
|
|
|
|
if modelID == "" {
|
|
return &c.models[0], nil
|
|
}
|
|
|
|
for i := range c.models {
|
|
if c.models[i].ID == modelID {
|
|
return &c.models[i], nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: '%s'", ErrModelNotFound, modelID)
|
|
}
|