Expose version/build metadata and improve provider error messaging

This commit is contained in:
2025-10-01 22:21:44 +02:00
parent a3e6b105d0
commit 7d4d56671f
5 changed files with 278 additions and 27 deletions

View File

@@ -10,6 +10,11 @@ import (
"github.com/stig/goaichat/internal/config" "github.com/stig/goaichat/internal/config"
) )
var (
version = "dev"
buildStamp = ""
)
func main() { func main() {
var configPath string var configPath string
flag.StringVar(&configPath, "config", "", "Path to configuration file") flag.StringVar(&configPath, "config", "", "Path to configuration file")
@@ -26,7 +31,12 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
application := app.New(logger, cfg) application := app.New(
logger,
cfg,
app.WithVersion(version),
app.WithBuild(buildStamp),
)
if err := application.Run(ctx); err != nil { if err := application.Run(ctx); err != nil {
logger.Error("application terminated with error", "error", err) logger.Error("application terminated with error", "error", err)
os.Exit(1) os.Exit(1)

View File

@@ -27,16 +27,19 @@ type App struct {
input io.Reader input io.Reader
output io.Writer output io.Writer
status string status string
version string
build string
streamBuffer strings.Builder streamBuffer strings.Builder
} }
// New constructs a new App instance. // New constructs a new App instance.
func New(logger *slog.Logger, cfg *config.Config, opts ...Option) *App { func New(logger *slog.Logger, cfg *config.Config, opts ...Option) *App {
app := &App{ app := &App{
logger: logger, logger: logger,
config: cfg, config: cfg,
input: os.Stdin, input: os.Stdin,
output: os.Stdout, output: os.Stdout,
version: "dev",
} }
for _, opt := range opts { for _, opt := range opts {
@@ -64,6 +67,20 @@ func WithIO(in io.Reader, out io.Writer) Option {
} }
} }
// WithVersion sets the application version string presented in the UI header.
func WithVersion(version string) Option {
return func(a *App) {
a.version = strings.TrimSpace(version)
}
}
// WithBuild sets the application build identifier presented in the UI header.
func WithBuild(build string) Option {
return func(a *App) {
a.build = strings.TrimSpace(build)
}
}
// Run starts the application lifecycle. // Run starts the application lifecycle.
func (a *App) Run(ctx context.Context) error { func (a *App) Run(ctx context.Context) error {
if a == nil { if a == nil {
@@ -201,7 +218,15 @@ func (a *App) runCLILoop(ctx context.Context) error {
} }
a.clearStreamingContent() a.clearStreamingContent()
a.setStatus("") notice := ""
if a.chat != nil {
notice = a.chat.ConsumeStreamingNotice()
}
if strings.TrimSpace(notice) != "" {
a.setStatus("%s", notice)
} else {
a.setStatus("")
}
if err := a.maybeSuggestSessionName(ctx); err != nil { if err := a.maybeSuggestSessionName(ctx); err != nil {
a.logger.WarnContext(ctx, "session name suggestion failed", "error", err) a.logger.WarnContext(ctx, "session name suggestion failed", "error", err)
@@ -386,7 +411,15 @@ func (a *App) renderUI() error {
sessionName = a.chat.SessionName() sessionName = a.chat.SessionName()
} }
title := fmt.Sprintf("goaichat - %s", sessionName) name := "goaichat"
if v := strings.TrimSpace(a.version); v != "" {
name = fmt.Sprintf("%s v%s", name, v)
}
if b := strings.TrimSpace(a.build); b != "" {
name = fmt.Sprintf("%s (build %s)", name, b)
}
title := fmt.Sprintf("%s - %s", name, sessionName)
underline := strings.Repeat("=", len(title)) underline := strings.Repeat("=", len(title))
if _, err := fmt.Fprintf(a.output, "%s\n%s\n\n", title, underline); err != nil { if _, err := fmt.Fprintf(a.output, "%s\n%s\n\n", title, underline); err != nil {
return err return err

View File

@@ -37,6 +37,7 @@ type Service struct {
model string model string
temperature float64 temperature float64
stream bool stream bool
streamNotice string
history []openai.ChatMessage history []openai.ChatMessage
sessionID int64 sessionID int64
summarySet bool summarySet bool
@@ -92,6 +93,8 @@ func (s *Service) Send(ctx context.Context, input string, streamHandler openai.C
messages := append([]openai.ChatMessage(nil), s.history...) messages := append([]openai.ChatMessage(nil), s.history...)
temperature := s.temperature temperature := s.temperature
s.streamNotice = ""
req := openai.ChatCompletionRequest{ req := openai.ChatCompletionRequest{
Model: s.model, Model: s.model,
Messages: messages, Messages: messages,
@@ -105,11 +108,17 @@ func (s *Service) Send(ctx context.Context, input string, streamHandler openai.C
var err error var err error
if s.stream { if s.stream {
resp, err = s.client.StreamChatCompletion(ctx, req, streamHandler) resp, err = s.client.StreamChatCompletion(ctx, req, streamHandler)
if err != nil {
resp, err = s.handleStreamingFailure(ctx, req, err)
if err != nil {
return "", s.translateProviderError(err)
}
}
} else { } else {
resp, err = s.client.CreateChatCompletion(ctx, req) resp, err = s.client.CreateChatCompletion(ctx, req)
} if err != nil {
if err != nil { return "", s.translateProviderError(err)
return "", err }
} }
if len(resp.Choices) == 0 { if len(resp.Choices) == 0 {
return "", errors.New("no choices returned from completion") return "", errors.New("no choices returned from completion")
@@ -135,6 +144,16 @@ func (s *Service) History() []openai.ChatMessage {
return historyCopy return historyCopy
} }
// ConsumeStreamingNotice returns any pending streaming notice and clears it.
func (s *Service) ConsumeStreamingNotice() string {
if s == nil {
return ""
}
notice := s.streamNotice
s.streamNotice = ""
return notice
}
// StreamingEnabled reports whether streaming completions are configured for this service. // StreamingEnabled reports whether streaming completions are configured for this service.
func (s *Service) StreamingEnabled() bool { func (s *Service) StreamingEnabled() bool {
if s == nil { if s == nil {
@@ -143,6 +162,74 @@ func (s *Service) StreamingEnabled() bool {
return s.stream return s.stream
} }
func (s *Service) translateProviderError(err error) error {
var reqErr *openai.RequestError
if !errors.As(err, &reqErr) {
return err
}
if guidance, ok := providerStatusGuidance(reqErr.StatusCode()); ok {
return errors.New(guidance)
}
return err
}
func (s *Service) handleStreamingFailure(ctx context.Context, req openai.ChatCompletionRequest, streamErr error) (*openai.ChatCompletionResponse, error) {
if s == nil {
return nil, streamErr
}
var reqErr *openai.RequestError
if !errors.As(streamErr, &reqErr) {
return nil, streamErr
}
status := reqErr.StatusCode()
if status < 400 || status >= 500 {
return nil, streamErr
}
guidance, hasGuidance := providerStatusGuidance(status)
message := guidance
if !hasGuidance {
message = strings.TrimSpace(reqErr.Message())
if message == "" {
message = strings.TrimSpace(streamErr.Error())
}
if message == "" {
message = "Streaming is unavailable"
}
}
message = fmt.Sprintf("%s\nStreaming has been disabled; responses will be fully buffered.", message)
s.logger.WarnContext(ctx, "streaming disabled", "status", status, "error", strings.TrimSpace(reqErr.Message()))
s.stream = false
s.streamNotice = message
req.Stream = false
resp, err := s.client.CreateChatCompletion(ctx, req)
if err != nil {
return nil, s.translateProviderError(err)
}
return resp, nil
}
func providerStatusGuidance(status int) (string, bool) {
switch status {
case 401:
return "Incorrect API key provided.\nVerify API key, clear browser cache, or generate a new key.", true
case 429:
return "Rate limit reached.\nPace requests and implement exponential backoff.", true
case 500:
return "Server error.\nRetry after a brief wait; contact support if persistent.", true
case 503:
return "Engine overloaded.\nRetry request after a brief wait; contact support if persistent.", true
default:
return "", false
}
}
// Reset clears the in-memory conversation history. // Reset clears the in-memory conversation history.
func (s *Service) Reset() { func (s *Service) Reset() {
if s == nil { if s == nil {

View File

@@ -22,6 +22,55 @@ type Client struct {
httpClient *http.Client httpClient *http.Client
} }
type contentPart struct {
Type string `json:"type"`
Text string `json:"text"`
}
func extractRoleAndContent(raw json.RawMessage) (string, string) {
if len(raw) == 0 || string(raw) == "null" {
return "", ""
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return "", ""
}
var role string
if value, ok := envelope["role"]; ok {
_ = json.Unmarshal(value, &role)
}
value, ok := envelope["content"]
if !ok {
return role, ""
}
var text string
if err := json.Unmarshal(value, &text); err == nil {
return role, text
}
var parts []contentPart
if err := json.Unmarshal(value, &parts); err == nil {
var builder strings.Builder
for _, part := range parts {
if part.Text != "" {
builder.WriteString(part.Text)
}
}
return role, builder.String()
}
var single contentPart
if err := json.Unmarshal(value, &single); err == nil {
return role, single.Text
}
return role, ""
}
// ClientOption customizes client construction. // ClientOption customizes client construction.
type ClientOption func(*Client) type ClientOption func(*Client)
@@ -131,10 +180,10 @@ func (c *Client) StreamChatCompletion(ctx context.Context, req ChatCompletionReq
ID string `json:"id"` ID string `json:"id"`
Object string `json:"object"` Object string `json:"object"`
Choices []struct { Choices []struct {
Index int `json:"index"` Index int `json:"index"`
Message ChatMessage `json:"message"` Message json.RawMessage `json:"message"`
Delta ChatMessage `json:"delta"` Delta json.RawMessage `json:"delta"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
} `json:"choices"` } `json:"choices"`
Usage Usage `json:"usage"` Usage Usage `json:"usage"`
} }
@@ -145,6 +194,7 @@ func (c *Client) StreamChatCompletion(ctx context.Context, req ChatCompletionReq
finish := "" finish := ""
var usage Usage var usage Usage
usageReceived := false usageReceived := false
var lastMessageText string
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@@ -190,19 +240,22 @@ func (c *Client) StreamChatCompletion(ctx context.Context, req ChatCompletionReq
finishReason := "" finishReason := ""
if len(chunk.Choices) > 0 { if len(chunk.Choices) > 0 {
choice := chunk.Choices[0] choice := chunk.Choices[0]
if choice.Message.Role != "" { choiceRole, choiceContent := extractRoleAndContent(choice.Message)
role = choice.Message.Role if choiceRole != "" {
role = choiceRole
} }
if choice.Delta.Role != "" { deltaRole, deltaContent := extractRoleAndContent(choice.Delta)
role = choice.Delta.Role if deltaRole != "" {
role = deltaRole
} }
if choice.Delta.Content != "" { if deltaContent != "" {
chunkText = choice.Delta.Content chunkText = deltaContent
} else if choice.Message.Content != "" && builder.Len() == 0 {
chunkText = choice.Message.Content
} }
if choice.Message.Content != "" && builder.Len() == 0 && chunkText == "" { if chunkText == "" && builder.Len() == 0 && choiceContent != "" {
chunkText = choice.Message.Content chunkText = choiceContent
}
if choiceContent != "" {
lastMessageText = choiceContent
} }
if choice.FinishReason != "" { if choice.FinishReason != "" {
finishReason = choice.FinishReason finishReason = choice.FinishReason
@@ -237,7 +290,23 @@ func (c *Client) StreamChatCompletion(ctx context.Context, req ChatCompletionReq
content := strings.TrimSpace(builder.String()) content := strings.TrimSpace(builder.String())
if content == "" { if content == "" {
return nil, errors.New("stream response contained no content") if trimmed := strings.TrimSpace(lastMessageText); trimmed != "" {
content = trimmed
}
}
if content == "" {
aggregated.Choices = []ChatCompletionChoice{{
Index: 0,
Message: ChatMessage{
Role: role,
Content: "",
},
FinishReason: finish,
}}
if usageReceived {
aggregated.Usage = usage
}
return &aggregated, nil
} }
aggregated.Choices = []ChatCompletionChoice{{ aggregated.Choices = []ChatCompletionChoice{{
@@ -280,8 +349,13 @@ func decodeSuccess(r io.Reader) (*ChatCompletionResponse, error) {
func decodeError(r io.Reader, status int) error { func decodeError(r io.Reader, status int) error {
var apiErr ErrorResponse var apiErr ErrorResponse
if err := json.NewDecoder(r).Decode(&apiErr); err != nil { if err := json.NewDecoder(r).Decode(&apiErr); err != nil {
return fmt.Errorf("api error (status %d): failed to decode body: %w", status, err) return &RequestError{
Status: status,
Response: ErrorResponse{
Error: APIError{Message: fmt.Sprintf("failed to decode error body: %v", err)},
},
}
} }
return fmt.Errorf("api error (status %d): %s", status, apiErr.Error.Message) return &RequestError{Status: status, Response: apiErr}
} }

View File

@@ -1,5 +1,10 @@
package openai package openai
import (
"fmt"
"strings"
)
// ChatMessage represents a single message within a chat completion request or response. // ChatMessage represents a single message within a chat completion request or response.
type ChatMessage struct { type ChatMessage struct {
Role string `json:"role"` Role string `json:"role"`
@@ -50,6 +55,48 @@ type ChatCompletionStreamEvent struct {
// ChatCompletionStreamHandler consumes streaming completion events. // ChatCompletionStreamHandler consumes streaming completion events.
type ChatCompletionStreamHandler func(ChatCompletionStreamEvent) error type ChatCompletionStreamHandler func(ChatCompletionStreamEvent) error
// RequestError captures an error response returned by the API together with the HTTP status code.
type RequestError struct {
Status int
Response ErrorResponse
}
// Error implements the error interface.
func (e *RequestError) Error() string {
if e == nil {
return ""
}
msg := strings.TrimSpace(e.Response.Error.Message)
if msg == "" {
return fmt.Sprintf("api error (status %d)", e.Status)
}
return fmt.Sprintf("api error (status %d): %s", e.Status, msg)
}
// StatusCode returns the originating HTTP status code.
func (e *RequestError) StatusCode() int {
if e == nil {
return 0
}
return e.Status
}
// Message returns the raw message provided by the API, if any.
func (e *RequestError) Message() string {
if e == nil {
return ""
}
return e.Response.Error.Message
}
// Type returns the OpenAI error type string, when present.
func (e *RequestError) Type() string {
if e == nil {
return ""
}
return e.Response.Error.Type
}
// APIError captures structured error responses returned by the API. // APIError captures structured error responses returned by the API.
type APIError struct { type APIError struct {
Message string `json:"message"` Message string `json:"message"`