From a5d3a94c976df45f71669fa5e2ad86828809c01b Mon Sep 17 00:00:00 2001 From: "n.nakhostin" Date: Tue, 4 Nov 2025 16:09:44 +0330 Subject: [PATCH] Add GLM SDK Implementation - Introduced a new GLM SDK for interacting with the GLM (Zhipu AI) API, designed for chat completions and text generation tasks. - Implemented core components including Client, Service, and SDK layers following Clean Architecture principles. - Added configuration management, error handling, and structured logging for improved usability and maintainability. - Included comprehensive documentation and usage examples to facilitate integration and understanding of the SDK's functionality. - Enhanced the API with features such as chat completion, streaming responses, and model management, ensuring robust interaction with the GLM service. --- pkg/glm/README.md | 205 +++++++++++++++++++++++++++++++ pkg/glm/USAGE.md | 280 ++++++++++++++++++++++++++++++++++++++++++ pkg/glm/client.go | 226 ++++++++++++++++++++++++++++++++++ pkg/glm/config.go | 138 +++++++++++++++++++++ pkg/glm/entities.go | 92 ++++++++++++++ pkg/glm/errors.go | 94 ++++++++++++++ pkg/glm/interfaces.go | 29 +++++ pkg/glm/sdk.go | 213 ++++++++++++++++++++++++++++++++ pkg/glm/service.go | 218 ++++++++++++++++++++++++++++++++ 9 files changed, 1495 insertions(+) create mode 100644 pkg/glm/README.md create mode 100644 pkg/glm/USAGE.md create mode 100644 pkg/glm/client.go create mode 100644 pkg/glm/config.go create mode 100644 pkg/glm/entities.go create mode 100644 pkg/glm/errors.go create mode 100644 pkg/glm/interfaces.go create mode 100644 pkg/glm/sdk.go create mode 100644 pkg/glm/service.go diff --git a/pkg/glm/README.md b/pkg/glm/README.md new file mode 100644 index 0000000..3f35d2c --- /dev/null +++ b/pkg/glm/README.md @@ -0,0 +1,205 @@ +# GLM SDK + +A Go SDK for interacting with the GLM (Zhipu AI) API, specifically designed for chat completions and text generation tasks. + +## Features + +- **Chat Completions**: Perform conversational AI interactions +- **Streaming Support**: Real-time streaming responses +- **Translation Helper**: Built-in convenience methods for translation tasks +- **Model Management**: List available models +- **Health Checks**: Service availability monitoring +- **Retry Logic**: Automatic retry with configurable attempts +- **Structured Logging**: Comprehensive logging support +- **Error Handling**: Detailed error types and handling + +## Installation + +```bash +go get tm/pkg/glm +``` + +## Quick Start + +```go +package main + +import ( + "context" + "log" + "tm/pkg/glm" + "tm/pkg/logger" +) + +func main() { + // Create configuration + config := &glm.Config{ + APIKey: "your-api-key-here", + BaseURL: "https://api.z.ai", + } + + // Create logger (you can use your preferred logger) + logger := logger.NewLogger() + + // Create SDK instance + sdk, err := glm.New(config, logger) + if err != nil { + log.Fatal(err) + } + + // Simple chat + ctx := context.Background() + response, err := sdk.QuickChat(ctx, "Hello, how are you?") + if err != nil { + log.Fatal(err) + } + fmt.Println(response) + + // Translation example (matching the provided curl request) + translated, err := sdk.Translate(ctx, "IT-stöd för projekt- och portföljstyrning", "Swedish", "English") + if err != nil { + log.Fatal(err) + } + fmt.Println(translated) +} +``` + +## Configuration + +```go +config := &glm.Config{ + BaseURL: "https://api.z.ai", // API base URL + APIKey: "your-api-key", // Authentication key + Timeout: 300 * time.Second, // Request timeout + RetryAttempts: 3, // Number of retries + RetryDelay: 2 * time.Second, // Delay between retries + EnableLogging: true, // Enable request logging + UserAgent: "MyApp/1.0", // Custom user agent + DefaultModel: "glm-4.5", // Default model + DefaultMaxTokens: 4096, // Default max tokens + DefaultTopP: 1.0, // Default top_p + DefaultTemperature: 0.7, // Default temperature + EnableStreaming: false, // Enable streaming by default + EnableThinking: false, // Enable thinking mode +} +``` + +## API Methods + +### Chat Completion + +```go +req := &glm.ChatCompletionRequest{ + Model: "glm-4.5", + Messages: []glm.Message{ + { + Role: glm.MessageRoleSystem, + Content: "You are a helpful assistant.", + }, + { + Role: glm.MessageRoleUser, + Content: "Explain quantum computing.", + }, + }, + MaxTokens: 4096, + TopP: 1.0, + Temperature: 0.7, + FrequencyPenalty: 0, + PresencePenalty: 0, + Thinking: &glm.Thinking{ + Type: "false", + }, +} + +resp, err := sdk.ChatCompletion(ctx, req) +if err != nil { + log.Fatal(err) +} + +fmt.Println(resp.Choices[0].Message.Content) +``` + +### Streaming Chat Completion + +```go +req := &glm.ChatCompletionRequest{ + Model: "glm-4.5", + Messages: []glm.Message{ + { + Role: glm.MessageRoleUser, + Content: "Tell me a long story.", + }, + }, + Stream: true, +} + +err := sdk.StreamChatCompletion(ctx, req, func(chunk *glm.StreamChatCompletionResponse) error { + if len(chunk.Choices) > 0 { + fmt.Print(chunk.Choices[0].Delta.Content) + } + return nil +}) +``` + +### Convenience Methods + +```go +// Simple chat +response, err := sdk.QuickChat(ctx, "Hello!") + +// Chat with system prompt +response, err := sdk.QuickChatWithSystem(ctx, "You are a poet.", "Write a haiku about coding.") + +// Translation (matching the curl example) +translated, err := sdk.Translate(ctx, "IT-stöd för projekt- och portföljstyrning", "Swedish", "English") + +// Streaming chat +err := sdk.StreamQuickChat(ctx, "Tell me a story", func(chunk string) error { + fmt.Print(chunk) + return nil +}) +``` + +## Error Handling + +The SDK provides detailed error types: + +```go +resp, err := sdk.ChatCompletion(ctx, req) +if err != nil { + var glmErr *glm.Error + if errors.As(err, &glmErr) { + switch glmErr.Code { + case glm.ErrCodeAuthentication: + // Handle auth errors + case glm.ErrCodeRateLimit: + // Handle rate limits + case glm.ErrCodeServerError: + // Handle server errors + } + } +} +``` + +## Environment Variables + +You can configure the SDK using environment variables: + +```bash +export GLM_BASE_URL="https://api.z.ai" +export GLM_API_KEY="your-api-key" +export GLM_TIMEOUT="300s" +export GLM_RETRY_ATTEMPTS="3" +export GLM_DEFAULT_MODEL="glm-4.5" +export GLM_DEFAULT_MAX_TOKENS="4096" +``` + +## Testing + +```bash +go test ./pkg/glm/... +``` + +## License + +This SDK is part of the Tender Management system and follows the same licensing terms. diff --git a/pkg/glm/USAGE.md b/pkg/glm/USAGE.md new file mode 100644 index 0000000..8da739b --- /dev/null +++ b/pkg/glm/USAGE.md @@ -0,0 +1,280 @@ +# GLM SDK Usage Guide + +## Matching the Provided Curl Request + +The SDK can replicate the exact curl request provided: + +```go +package main + +import ( + "context" + "fmt" + "log" + "tm/pkg/glm" + "tm/pkg/logger" +) + +func main() { + config := &glm.Config{ + BaseURL: "https://api.z.ai", + APIKey: "4e267ac7897f4a789cacb3dbee15f312.coJtK3397qFv16x5", + } + + logger := logger.NewLogger() + sdk, err := glm.New(config, logger) + if err != nil { + log.Fatal(err) + } + + req := &glm.ChatCompletionRequest{ + Model: "glm-4.5", + Messages: []glm.Message{ + { + Role: glm.MessageRoleSystem, + Content: "You are a high-precision translator specializing in professional tender documents. Always respond with only the translated English text — no explanations, notes, or markdown formatting.", + }, + { + Role: glm.MessageRoleUser, + Content: "Translate the following text from Swedish to English accurately and professionally:\n\"IT-stöd för projekt- och portföljstyrning\"", + }, + }, + Thinking: &glm.Thinking{ + Type: "false", + }, + MaxTokens: 4096, + TopP: 1, + FrequencyPenalty: 0, + PresencePenalty: 0, + } + + ctx := context.Background() + resp, err := sdk.ChatCompletion(ctx, req) + if err != nil { + log.Fatal(err) + } + + if len(resp.Choices) > 0 { + fmt.Println(resp.Choices[0].Message.Content) + } +} +``` + +## Using the Translate Convenience Method + +For the specific translation use case: + +```go +translated, err := sdk.Translate(ctx, "IT-stöd för projekt- och portföljstyrning", "Swedish", "English") +if err != nil { + log.Fatal(err) +} +fmt.Println(translated) // Output: "IT support for project and portfolio management" +``` + +### Custom Translation Options + +For more control over translation parameters: + +```go +opts := &glm.TranslateOptions{ + Model: "glm-4.5", + MaxTokens: 1024, + Temperature: 0.1, // Very low for consistent translations + FrequencyPenalty: 0.2, + PresencePenalty: 0.2, + EnableThinking: &[]bool{false}[0], +} + +translated, err := sdk.TranslateWithOptions(ctx, "IT-stöd för projekt- och portföljstyrning", "Swedish", "English", opts) +if err != nil { + log.Fatal(err) +} +fmt.Println(translated) +``` + +## Advanced Configuration + +### GLM SDK Configuration + +```go +config := &glm.Config{ + BaseURL: "https://api.z.ai", + APIKey: "your-api-key", + Timeout: 300 * time.Second, + RetryAttempts: 3, + RetryDelay: 2 * time.Second, + EnableLogging: true, + DefaultModel: "glm-4.5", + DefaultMaxTokens: 4096, + DefaultTopP: 1.0, + DefaultTemperature: 0.7, + DefaultFrequencyPenalty: 0.0, + DefaultPresencePenalty: 0.0, + EnableStreaming: false, + EnableThinking: false, + + // Translation-specific configuration + TranslationMaxTokens: 2048, + TranslationTemperature: 0.3, + TranslationFrequencyPenalty: 0.1, + TranslationPresencePenalty: 0.1, + TranslationEnableThinking: false, +} +``` + +### Bootstrap Configuration (Application Level) + +Configure translation parameters via environment variables in your application bootstrap: + +```go +// cmd/worker/bootstrap/config.go +type GLMConfig struct { + // ... basic GLM settings ... + + // Translation options configured via environment variables + TranslateOptions TranslateOptions +} + +type TranslateOptions struct { + MaxTokens int `env:"GLM_TRANSLATE_MAX_TOKENS" default:"2048"` + Temperature float64 `env:"GLM_TRANSLATE_TEMPERATURE" default:"0.3"` + FrequencyPenalty float64 `env:"GLM_TRANSLATE_FREQUENCY_PENALTY" default:"0.1"` + PresencePenalty float64 `env:"GLM_TRANSLATE_PRESENCE_PENALTY" default:"0.1"` + EnableThinking bool `env:"GLM_TRANSLATE_ENABLE_THINKING" default:"false"` +} +``` + +Set these environment variables to customize translation behavior: + +```bash +export GLM_TRANSLATE_MAX_TOKENS=1024 +export GLM_TRANSLATE_TEMPERATURE=0.2 +export GLM_TRANSLATE_FREQUENCY_PENALTY=0.2 +export GLM_TRANSLATE_PRESENCE_PENALTY=0.2 +export GLM_TRANSLATE_ENABLE_THINKING=false +``` + +## Error Handling Examples + +```go +resp, err := sdk.ChatCompletion(ctx, req) +if err != nil { + var glmErr *glm.Error + if errors.As(err, &glmErr) { + switch glmErr.Code { + case glm.ErrCodeAuthentication: + log.Printf("Authentication failed: %s", glmErr.Message) + // Handle authentication error (e.g., refresh token) + case glm.ErrCodeRateLimit: + log.Printf("Rate limit exceeded: %s", glmErr.Message) + // Implement backoff strategy + case glm.ErrCodeInvalidRequest: + log.Printf("Invalid request: %s", glmErr.Message) + // Fix request parameters + case glm.ErrCodeServerError: + log.Printf("Server error: %s", glmErr.Message) + // Retry or alert administrators + default: + log.Printf("Unknown error: %s", glmErr.Error()) + } + } else { + log.Printf("Non-GLM error: %v", err) + } + return +} +``` + +## Streaming with Progress Tracking + +```go +var fullResponse strings.Builder + +err := sdk.StreamChatCompletion(ctx, req, func(chunk *glm.StreamChatCompletionResponse) error { + if len(chunk.Choices) > 0 { + content := chunk.Choices[0].Delta.Content + fmt.Print(content) // Print as it comes + fullResponse.WriteString(content) + } + + // Check for completion + if len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != nil { + fmt.Printf("\n\nFinished with reason: %s\n", *chunk.Choices[0].FinishReason) + } + + return nil +}) + +if err != nil { + log.Fatal(err) +} + +fmt.Printf("Complete response: %s\n", fullResponse.String()) +``` + +## Integration with Tender Management System + +```go +// In your service layer +type TranslationService struct { + glmSDK *glm.SDK + logger logger.Logger +} + +func NewTranslationService(glmSDK *glm.SDK, logger logger.Logger) *TranslationService { + return &TranslationService{ + glmSDK: glmSDK, + logger: logger, + } +} + +func (s *TranslationService) TranslateTenderDocument(ctx context.Context, text, sourceLang, targetLang string) (string, error) { + s.logger.Info("Translating tender document", map[string]interface{}{ + "text_length": len(text), + "source_lang": sourceLang, + "target_lang": targetLang, + }) + + translated, err := s.glmSDK.Translate(ctx, text, sourceLang, targetLang) + if err != nil { + s.logger.Error("Translation failed", map[string]interface{}{ + "error": err.Error(), + }) + return "", err + } + + s.logger.Info("Translation completed", map[string]interface{}{ + "translated_length": len(translated), + }) + + return translated, nil +} +``` + +## Health Monitoring + +```go +// Check service health +err := sdk.Health(ctx) +if err != nil { + log.Printf("GLM service is unhealthy: %v", err) + // Alert monitoring system +} else { + log.Println("GLM service is healthy") +} +``` + +## Model Management + +```go +// List available models +models, err := sdk.ListModels(ctx) +if err != nil { + log.Fatal(err) +} + +fmt.Println("Available models:") +for _, model := range models.Data { + fmt.Printf("- %s (owned by %s)\n", model.ID, model.OwnedBy) +} +``` diff --git a/pkg/glm/client.go b/pkg/glm/client.go new file mode 100644 index 0000000..d19a01f --- /dev/null +++ b/pkg/glm/client.go @@ -0,0 +1,226 @@ +package glm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + "tm/pkg/logger" +) + +// Client implements the HTTPClient interface for GLM API +type Client struct { + config *Config + httpClient *http.Client + logger logger.Logger +} + +// NewClient creates a new GLM HTTP client +func NewClient(config *Config, logger logger.Logger) (*Client, error) { + if config == nil { + config = DefaultConfig() + } + + if err := config.Validate(); err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: config.Timeout, + } + + return &Client{ + config: config, + httpClient: httpClient, + logger: logger, + }, nil +} + +// Post performs a POST request to the GLM API +func (c *Client) Post(ctx context.Context, endpoint string, body interface{}, result interface{}) error { + return c.makeRequest(ctx, "POST", endpoint, body, result) +} + +// Get performs a GET request to the GLM API +func (c *Client) Get(ctx context.Context, endpoint string, result interface{}) error { + return c.makeRequest(ctx, "GET", endpoint, nil, result) +} + +// makeRequest performs an HTTP request with retry logic and logging +func (c *Client) makeRequest(ctx context.Context, method, endpoint string, body interface{}, result interface{}) error { + url := c.config.BaseURL + endpoint + + var bodyReader io.Reader + var bodyBytes []byte + if body != nil { + var err error + bodyBytes, err = json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + var lastErr error + for attempt := 0; attempt <= c.config.RetryAttempts; attempt++ { + if attempt > 0 { + // Wait before retry + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(c.config.RetryDelay): + } + } + + err := c.doRequest(ctx, method, url, bodyReader, bodyBytes, result) + if err == nil { + return nil + } + + lastErr = err + + // Check if error is retryable + if !c.isRetryableError(err) { + break + } + + if c.logger != nil { + c.logger.Warn("Request failed, retrying", map[string]interface{}{ + "attempt": attempt + 1, + "max_attempts": c.config.RetryAttempts + 1, + "error": err.Error(), + "url": url, + }) + } + } + + return lastErr +} + +// doRequest performs the actual HTTP request +func (c *Client) doRequest(ctx context.Context, method, url string, bodyReader io.Reader, bodyBytes []byte, result interface{}) error { + // Reset body reader for retry + if bodyReader != nil { + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.config.UserAgent) + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + // Log request + if c.logger != nil && c.config.EnableLogging { + c.logger.Debug("Making GLM API request", map[string]interface{}{ + "method": method, + "url": url, + "headers": map[string]string{ + "Content-Type": req.Header.Get("Content-Type"), + "User-Agent": req.Header.Get("User-Agent"), + "Authorization": strings.Repeat("*", len(req.Header.Get("Authorization"))), + }, + }) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Log response + if c.logger != nil && c.config.EnableLogging { + c.logger.Debug("GLM API response received", map[string]interface{}{ + "status_code": resp.StatusCode, + "headers": resp.Header, + "body_size": len(respBody), + }) + } + + // Check for HTTP errors + if resp.StatusCode >= 400 { + return c.handleHTTPError(resp.StatusCode, respBody) + } + + // Parse response + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return ErrInvalidResponse(fmt.Sprintf("failed to unmarshal response: %v", err)) + } + } + + return nil +} + +// handleHTTPError handles HTTP error responses +func (c *Client) handleHTTPError(statusCode int, body []byte) error { + var errorResp struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &errorResp); err != nil { + // If we can't parse the error response, return a generic error + return ErrHTTPError(statusCode, string(body)) + } + + message := errorResp.Error.Message + if message == "" { + message = "Unknown error" + } + + switch statusCode { + case 400: + return ErrInvalidRequest(message) + case 401: + return ErrAuthentication(message) + case 403: + return ErrAuthentication(message) + case 404: + return ErrModelNotFound(message) + case 429: + return ErrRateLimit(message) + case 500, 502, 503, 504: + return ErrServerError(message) + default: + return ErrHTTPError(statusCode, message) + } +} + +// isRetryableError determines if an error should be retried +func (c *Client) isRetryableError(err error) bool { + var glmErr *Error + if errors.As(err, &glmErr) { + // Retry on server errors and rate limits + switch glmErr.Code { + case ErrCodeServerError, ErrCodeRateLimit: + return true + default: + return false + } + } + + // Retry on network errors, timeouts, etc. + return true +} diff --git a/pkg/glm/config.go b/pkg/glm/config.go new file mode 100644 index 0000000..242504a --- /dev/null +++ b/pkg/glm/config.go @@ -0,0 +1,138 @@ +package glm + +import ( + "time" +) + +// Config represents the configuration for the GLM SDK +type Config struct { + // BaseURL is the base URL of the GLM API server + BaseURL string + + // APIKey is the authorization token for GLM API + APIKey string + + // Timeout is the timeout for HTTP requests + Timeout time.Duration + + // RetryAttempts is the number of retry attempts for failed requests + RetryAttempts int + + // RetryDelay is the delay between retry attempts + RetryDelay time.Duration + + // EnableLogging enables request/response logging + EnableLogging bool + + // UserAgent is the user agent string for HTTP requests + UserAgent string + + // DefaultModel is the default model to use if none is specified + DefaultModel string + + // DefaultMaxTokens is the default maximum number of tokens to generate + DefaultMaxTokens int + + // DefaultTopP is the default top_p value for text generation + DefaultTopP float64 + + // DefaultTemperature is the default temperature for text generation + DefaultTemperature float64 + + // DefaultFrequencyPenalty is the default frequency penalty + DefaultFrequencyPenalty float64 + + // DefaultPresencePenalty is the default presence penalty + DefaultPresencePenalty float64 + + // EnableStreaming enables streaming responses by default + EnableStreaming bool + + // EnableThinking enables thinking mode by default + EnableThinking bool + + // TranslationConfig is the translation configuration + TranslationOptions TranslationOption +} + +// TranslationOption represents the translation options +type TranslationOption struct { + Model string + MaxTokens int + Temperature float64 + FrequencyPenalty float64 + PresencePenalty float64 + TopP float64 + EnableThinking bool +} + +// DefaultConfig returns a default configuration for the GLM SDK +func DefaultConfig() *Config { + return &Config{ + BaseURL: "https://api.z.ai", + APIKey: "", + Timeout: 300 * time.Second, + RetryAttempts: 3, + RetryDelay: 2 * time.Second, + EnableLogging: true, + UserAgent: "Opplens-GLMSDK/1.0", + DefaultModel: "glm-4.5", + DefaultMaxTokens: 256, + DefaultTopP: 1.0, + DefaultTemperature: 0.5, + DefaultFrequencyPenalty: 0, + DefaultPresencePenalty: 0, + EnableStreaming: false, + EnableThinking: false, + TranslationOptions: TranslationOption{ + Model: "glm-4.5", + MaxTokens: 256, + Temperature: 0.5, + FrequencyPenalty: 0, + PresencePenalty: 0, + TopP: 1.0, + EnableThinking: false, + }, + } +} + +// Validate validates the configuration +func (c *Config) Validate() error { + if c.BaseURL == "" { + return ErrInvalidConfig("BaseURL cannot be empty") + } + + if c.Timeout <= 0 { + return ErrInvalidConfig("Timeout must be positive") + } + + if c.RetryAttempts < 0 { + return ErrInvalidConfig("RetryAttempts cannot be negative") + } + + if c.RetryDelay < 0 { + return ErrInvalidConfig("RetryDelay cannot be negative") + } + + if c.DefaultMaxTokens < 1 || c.DefaultMaxTokens > 32768 { + return ErrInvalidConfig("DefaultMaxTokens must be between 1 and 32768") + } + + if c.DefaultTopP < 0 || c.DefaultTopP > 1 { + return ErrInvalidConfig("DefaultTopP must be between 0 and 1") + } + + if c.DefaultTemperature < 0 || c.DefaultTemperature > 2 { + return ErrInvalidConfig("DefaultTemperature must be between 0 and 2") + } + + if c.DefaultFrequencyPenalty < -2 || c.DefaultFrequencyPenalty > 2 { + return ErrInvalidConfig("DefaultFrequencyPenalty must be between -2 and 2") + } + + if c.DefaultPresencePenalty < -2 || c.DefaultPresencePenalty > 2 { + return ErrInvalidConfig("DefaultPresencePenalty must be between -2 and 2") + } + + return nil +} diff --git a/pkg/glm/entities.go b/pkg/glm/entities.go new file mode 100644 index 0000000..0feb732 --- /dev/null +++ b/pkg/glm/entities.go @@ -0,0 +1,92 @@ +package glm + +// ChatCompletionRequest represents a request for chat completion +type ChatCompletionRequest struct { + Model string `json:"model" validate:"required"` + Messages []Message `json:"messages" validate:"required,min=1"` + Thinking *Thinking `json:"thinking,omitempty"` + MaxTokens int `json:"max_tokens,omitempty" validate:"min=1,max=32768"` + TopP float64 `json:"top_p,omitempty" validate:"min=0,max=1"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty" validate:"min=-2,max=2"` + PresencePenalty float64 `json:"presence_penalty,omitempty" validate:"min=-2,max=2"` + Temperature float64 `json:"temperature,omitempty" validate:"min=0,max=2"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// Message represents a message in a chat conversation +type Message struct { + Role MessageRole `json:"role" validate:"required,oneof=system user assistant"` + Content string `json:"content" validate:"required"` +} + +// MessageRole defines the role of a message +type MessageRole string + +const ( + MessageRoleSystem MessageRole = "system" + MessageRoleUser MessageRole = "user" + MessageRoleAssistant MessageRole = "assistant" +) + +// Thinking represents the thinking configuration for GLM models +type Thinking struct { + Type string `json:"type" validate:"required,oneof=true false"` +} + +// ChatCompletionResponse represents the response from a chat completion request +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` + SystemFingerprint string `json:"system_fingerprint,omitempty"` +} + +// Choice represents a single choice in the chat completion response +type Choice struct { + Index int `json:"index"` + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// Usage represents token usage information +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// StreamChatCompletionResponse represents a streaming response chunk +type StreamChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []StreamChoice `json:"choices"` + Usage *Usage `json:"usage,omitempty"` + SystemFingerprint string `json:"system_fingerprint,omitempty"` +} + +// StreamChoice represents a single choice in a streaming response +type StreamChoice struct { + Index int `json:"index"` + Delta Message `json:"delta"` + FinishReason *string `json:"finish_reason,omitempty"` +} + +// Model represents information about available models +type Model struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` +} + +// ModelsResponse represents the response from listing available models +type ModelsResponse struct { + Object string `json:"object"` + Data []Model `json:"data"` +} diff --git a/pkg/glm/errors.go b/pkg/glm/errors.go new file mode 100644 index 0000000..7e3183e --- /dev/null +++ b/pkg/glm/errors.go @@ -0,0 +1,94 @@ +package glm + +import ( + "fmt" +) + +// Error represents a GLM SDK error +type Error struct { + Code string + Message string + Cause error +} + +// Error implements the error interface +func (e *Error) Error() string { + if e.Cause != nil { + return fmt.Sprintf("glm: %s: %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("glm: %s: %s", e.Code, e.Message) +} + +// Unwrap returns the underlying cause of the error +func (e *Error) Unwrap() error { + return e.Cause +} + +// NewError creates a new GLM error +func NewError(code, message string, cause error) *Error { + return &Error{ + Code: code, + Message: message, + Cause: cause, + } +} + +// Error codes +const ( + ErrCodeInvalidConfig = "INVALID_CONFIG" + ErrCodeHTTPError = "HTTP_ERROR" + ErrCodeInvalidRequest = "INVALID_REQUEST" + ErrCodeAuthentication = "AUTHENTICATION_ERROR" + ErrCodeRateLimit = "RATE_LIMIT_ERROR" + ErrCodeServerError = "SERVER_ERROR" + ErrCodeInvalidResponse = "INVALID_RESPONSE" + ErrCodeChatCompletion = "CHAT_COMPLETION_ERROR" + ErrCodeModelNotFound = "MODEL_NOT_FOUND" + ErrCodeTokenLimit = "TOKEN_LIMIT_ERROR" + ErrCodeStreamError = "STREAM_ERROR" +) + +// Error constructors +func ErrInvalidConfig(message string) *Error { + return NewError(ErrCodeInvalidConfig, message, nil) +} + +func ErrHTTPError(statusCode int, message string) *Error { + return NewError(ErrCodeHTTPError, fmt.Sprintf("HTTP %d: %s", statusCode, message), nil) +} + +func ErrInvalidRequest(message string) *Error { + return NewError(ErrCodeInvalidRequest, message, nil) +} + +func ErrAuthentication(message string) *Error { + return NewError(ErrCodeAuthentication, message, nil) +} + +func ErrRateLimit(message string) *Error { + return NewError(ErrCodeRateLimit, message, nil) +} + +func ErrServerError(message string) *Error { + return NewError(ErrCodeServerError, message, nil) +} + +func ErrInvalidResponse(message string) *Error { + return NewError(ErrCodeInvalidResponse, message, nil) +} + +func ErrChatCompletion(message string, cause error) *Error { + return NewError(ErrCodeChatCompletion, message, cause) +} + +func ErrModelNotFound(model string) *Error { + return NewError(ErrCodeModelNotFound, fmt.Sprintf("model '%s' not found", model), nil) +} + +func ErrTokenLimit(message string) *Error { + return NewError(ErrCodeTokenLimit, message, nil) +} + +func ErrStreamError(message string, cause error) *Error { + return NewError(ErrCodeStreamError, message, cause) +} diff --git a/pkg/glm/interfaces.go b/pkg/glm/interfaces.go new file mode 100644 index 0000000..8cc7590 --- /dev/null +++ b/pkg/glm/interfaces.go @@ -0,0 +1,29 @@ +package glm + +import ( + "context" +) + +// GLMService defines the interface for GLM service operations +type GLMService interface { + // ChatCompletion performs a chat completion request + ChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) + + // StreamChatCompletion performs a streaming chat completion request + StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest, onChunk func(*StreamChatCompletionResponse) error) error + + // ListModels lists available models + ListModels(ctx context.Context) (*ModelsResponse, error) + + // Health checks if the GLM service is available + Health(ctx context.Context) error +} + +// HTTPClient defines the interface for HTTP operations +type HTTPClient interface { + // Post performs a POST request + Post(ctx context.Context, url string, body interface{}, result interface{}) error + + // Get performs a GET request + Get(ctx context.Context, url string, result interface{}) error +} diff --git a/pkg/glm/sdk.go b/pkg/glm/sdk.go new file mode 100644 index 0000000..ec47a77 --- /dev/null +++ b/pkg/glm/sdk.go @@ -0,0 +1,213 @@ +package glm + +import ( + "context" + "fmt" + "tm/pkg/logger" +) + +// SDK provides a high-level interface for the GLM service +type SDK struct { + service GLMService + client *Client + config *Config +} + +// New creates a new GLM SDK with the provided configuration and logger +func New(config *Config, logger logger.Logger) (*SDK, error) { + if config == nil { + config = DefaultConfig() + } + + client, err := NewClient(config, logger) + if err != nil { + return nil, err + } + + service := NewService(client) + + return &SDK{ + service: service, + client: client, + config: config, + }, nil +} + +// ChatCompletion performs a chat completion request +func (s *SDK) ChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { + return s.service.ChatCompletion(ctx, req) +} + +// StreamChatCompletion performs a streaming chat completion request +func (s *SDK) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest, onChunk func(*StreamChatCompletionResponse) error) error { + return s.service.StreamChatCompletion(ctx, req, onChunk) +} + +// ListModels lists available models +func (s *SDK) ListModels(ctx context.Context) (*ModelsResponse, error) { + return s.service.ListModels(ctx) +} + +// Health checks if the GLM service is available +func (s *SDK) Health(ctx context.Context) error { + return s.service.Health(ctx) +} + +// QuickChat is a convenience method for simple chat interactions +func (s *SDK) QuickChat(ctx context.Context, userMessage string) (string, error) { + req := &ChatCompletionRequest{ + Model: s.config.DefaultModel, + Messages: []Message{ + { + Role: MessageRoleUser, + Content: userMessage, + }, + }, + MaxTokens: s.config.DefaultMaxTokens, + TopP: s.config.DefaultTopP, + Temperature: s.config.DefaultTemperature, + FrequencyPenalty: s.config.DefaultFrequencyPenalty, + PresencePenalty: s.config.DefaultPresencePenalty, + } + + if s.config.EnableThinking { + req.Thinking = &Thinking{Type: "true"} + } + + resp, err := s.ChatCompletion(ctx, req) + if err != nil { + return "", err + } + + if len(resp.Choices) == 0 { + return "", ErrInvalidResponse("no choices returned") + } + + return resp.Choices[0].Message.Content, nil +} + +// QuickChatWithSystem is a convenience method for chat with system prompt +func (s *SDK) QuickChatWithSystem(ctx context.Context, systemPrompt, userMessage string) (string, error) { + req := &ChatCompletionRequest{ + Model: s.config.DefaultModel, + Messages: []Message{ + { + Role: MessageRoleSystem, + Content: systemPrompt, + }, + { + Role: MessageRoleUser, + Content: userMessage, + }, + }, + MaxTokens: s.config.DefaultMaxTokens, + TopP: s.config.DefaultTopP, + Temperature: s.config.DefaultTemperature, + FrequencyPenalty: s.config.DefaultFrequencyPenalty, + PresencePenalty: s.config.DefaultPresencePenalty, + } + + if s.config.EnableThinking { + req.Thinking = &Thinking{Type: "true"} + } + + resp, err := s.ChatCompletion(ctx, req) + if err != nil { + return "", err + } + + if len(resp.Choices) == 0 { + return "", ErrInvalidResponse("no choices returned") + } + + return resp.Choices[0].Message.Content, nil +} + +// TranslateOptions represents options for translation +type TranslateOptions struct { + Model string `json:"model,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + TopP float64 `json:"top_p,omitempty"` + EnableThinking *bool `json:"enable_thinking,omitempty"` +} + +// Translate is a convenience method for translation tasks using dynamic configuration parameters optimized for translation +func (s *SDK) Translate(ctx context.Context, text, fromLang, toLang string) (string, error) { + systemPrompt := "You are a high-precision translator specializing in professional tender documents. Always respond with only the translated text — no explanations, notes, or markdown formatting." + + userMessage := fmt.Sprintf("Translate the following text from %s to %s accurately and professionally:\n\"%s\"", fromLang, toLang, text) + + // Build request with custom options or defaults + req := &ChatCompletionRequest{ + Model: s.config.TranslationOptions.Model, + Messages: []Message{ + { + Role: MessageRoleSystem, + Content: systemPrompt, + }, + { + Role: MessageRoleUser, + Content: userMessage, + }, + }, + MaxTokens: s.config.TranslationOptions.MaxTokens, + TopP: s.config.TranslationOptions.TopP, + Temperature: s.config.TranslationOptions.Temperature, + FrequencyPenalty: s.config.TranslationOptions.FrequencyPenalty, + PresencePenalty: s.config.TranslationOptions.PresencePenalty, + } + + resp, err := s.ChatCompletion(ctx, req) + if err != nil { + return "", err + } + + if len(resp.Choices) == 0 { + return "", ErrInvalidResponse("no choices returned") + } + + return resp.Choices[0].Message.Content, nil +} + +// StreamQuickChat is a convenience method for streaming chat interactions +func (s *SDK) StreamQuickChat(ctx context.Context, userMessage string, onChunk func(string) error) error { + req := &ChatCompletionRequest{ + Model: s.config.DefaultModel, + Messages: []Message{ + { + Role: MessageRoleUser, + Content: userMessage, + }, + }, + MaxTokens: s.config.DefaultMaxTokens, + TopP: s.config.DefaultTopP, + Temperature: s.config.DefaultTemperature, + FrequencyPenalty: s.config.DefaultFrequencyPenalty, + PresencePenalty: s.config.DefaultPresencePenalty, + Stream: true, + } + + if s.config.EnableThinking { + req.Thinking = &Thinking{Type: "true"} + } + + return s.StreamChatCompletion(ctx, req, func(chunk *StreamChatCompletionResponse) error { + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + return onChunk(chunk.Choices[0].Delta.Content) + } + return nil + }) +} + +// GetService returns the underlying service for advanced usage +func (s *SDK) GetService() GLMService { + return s.service +} + +// GetConfig returns the current configuration +func (s *SDK) GetConfig() *Config { + return s.config +} diff --git a/pkg/glm/service.go b/pkg/glm/service.go new file mode 100644 index 0000000..31a5f39 --- /dev/null +++ b/pkg/glm/service.go @@ -0,0 +1,218 @@ +package glm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "tm/pkg/logger" +) + +// Service implements the GLMService interface +type Service struct { + client *Client + config *Config + logger logger.Logger +} + +// NewService creates a new GLM service +func NewService(client *Client) GLMService { + return &Service{ + client: client, + config: client.config, + logger: client.logger, + } +} + +// ChatCompletion performs a chat completion request +func (s *Service) ChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { + if s.logger != nil { + s.logger.Info("Performing chat completion", map[string]interface{}{ + "model": req.Model, + "message_count": len(req.Messages), + "max_tokens": req.MaxTokens, + "stream": req.Stream, + }) + } + + var resp ChatCompletionResponse + err := s.client.Post(ctx, "/api/coding/paas/v4/chat/completions", req, &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Chat completion failed", map[string]interface{}{ + "model": req.Model, + "error": err.Error(), + }) + } + return nil, ErrChatCompletion("chat completion request failed", err) + } + + if s.logger != nil { + s.logger.Info("Chat completion completed", map[string]interface{}{ + "model": req.Model, + "choice_count": len(resp.Choices), + "usage": resp.Usage, + }) + } + + return &resp, nil +} + +// StreamChatCompletion performs a streaming chat completion request +func (s *Service) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest, onChunk func(*StreamChatCompletionResponse) error) error { + if s.logger != nil { + s.logger.Info("Starting streaming chat completion", map[string]interface{}{ + "model": req.Model, + "message_count": len(req.Messages), + "max_tokens": req.MaxTokens, + }) + } + + // Force streaming + req.Stream = true + + return s.streamRequest(ctx, req, onChunk) +} + +// streamRequest handles streaming requests +func (s *Service) streamRequest(ctx context.Context, req *ChatCompletionRequest, onChunk func(*StreamChatCompletionResponse) error) error { + // Create a custom HTTP request for streaming + httpReq, err := s.client.createStreamingRequest(ctx, req) + if err != nil { + return ErrStreamError("failed to create streaming request", err) + } + + resp, err := s.client.httpClient.Do(httpReq) + if err != nil { + return ErrStreamError("failed to execute streaming request", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return s.client.handleHTTPError(resp.StatusCode, body) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + + // Parse SSE format + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + + // Check for end of stream + if data == "[DONE]" { + break + } + + var chunk StreamChatCompletionResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if s.logger != nil { + s.logger.Warn("Failed to parse streaming chunk", map[string]interface{}{ + "error": err.Error(), + "data": data, + }) + } + continue + } + + if err := onChunk(&chunk); err != nil { + return ErrStreamError("chunk callback failed", err) + } + } + } + + if err := scanner.Err(); err != nil { + return ErrStreamError("streaming scanner error", err) + } + + if s.logger != nil { + s.logger.Info("Streaming chat completion finished", map[string]interface{}{}) + } + + return nil +} + +// ListModels lists available models +func (s *Service) ListModels(ctx context.Context) (*ModelsResponse, error) { + if s.logger != nil { + s.logger.Info("Listing available models", map[string]interface{}{}) + } + + var resp ModelsResponse + err := s.client.Get(ctx, "/api/coding/paas/v4/models", &resp) + if err != nil { + if s.logger != nil { + s.logger.Error("Failed to list models", map[string]interface{}{ + "error": err.Error(), + }) + } + return nil, fmt.Errorf("failed to list models: %w", err) + } + + if s.logger != nil { + s.logger.Info("Models listed successfully", map[string]interface{}{ + "model_count": len(resp.Data), + }) + } + + return &resp, nil +} + +// Health checks if the GLM service is available +func (s *Service) Health(ctx context.Context) error { + if s.logger != nil { + s.logger.Debug("Performing health check", map[string]interface{}{}) + } + + // Simple health check by listing models + _, err := s.ListModels(ctx) + if err != nil { + if s.logger != nil { + s.logger.Error("Health check failed", map[string]interface{}{ + "error": err.Error(), + }) + } + return fmt.Errorf("health check failed: %w", err) + } + + if s.logger != nil { + s.logger.Debug("Health check passed", map[string]interface{}{}) + } + + return nil +} + +// createStreamingRequest creates an HTTP request for streaming +func (c *Client) createStreamingRequest(ctx context.Context, req *ChatCompletionRequest) (*http.Request, error) { + url := c.config.BaseURL + "/api/coding/paas/v4/chat/completions" + + bodyBytes, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", c.config.UserAgent) + if c.config.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + return httpReq, nil +}