package engine import ( "errors" "fmt" "net" "regexp" "strconv" "strings" "github.com/openai/openai-go" ) // statusInText recovers the HTTP status from the OpenAI client's error text // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors // with %v instead of %w, and the typed error is then out of reach of errors.As; // the text still carries the number. var statusInText = regexp.MustCompile(`": (\d{3}) `) // Explain maps a failed generation to one line the user can act on. The REPL // prints `[error: …]` and moves on; a stack of wrapped messages there is noise // on stage, while "start llama-server with --jinja" is the whole diagnosis. // Unknown errors come back unchanged: better a raw message than a wrong hint. func (p *openaiCompat) Explain(err error, b Backend) string { if err == nil { return "" } msg := err.Error() status := 0 var apiErr *openai.Error if errors.As(err, &apiErr) && apiErr.Response != nil { status = apiErr.Response.StatusCode } else if m := statusInText.FindStringSubmatch(msg); m != nil { status, _ = strconv.Atoi(m[1]) } switch { case connectionRefused(err, msg): return fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint) // llama-server without --jinja refuses `tools` with a message naming the // flag. It is the one llama.cpp error a first-time user hits, so it gets // its own line rather than the generic "server error". case strings.Contains(msg, "jinja"): return fmt.Sprintf("%s: tool calls need llama-server started with --jinja", p.name) // The variable named here is the one Resolve actually read (yaml `apiKeyEnv`, // else the provider's default). When there is none — DMR ignores keys — a // 401 means the URL is not the server we think it is, so that is the hint. case status == 401 || status == 403: if b.APIKeyEnv == "" { return fmt.Sprintf("%s: authentication failed at %s — this server should not need a key; check baseUrl", p.name, b.BaseURL) } return fmt.Sprintf("%s: authentication failed at %s — is $%s set and valid?", p.name, b.BaseURL, b.APIKeyEnv) case status == 404: return fmt.Sprintf("%s: %q not found at %s — %s", p.name, b.Model, b.BaseURL, fmt.Sprintf(p.notFoundHint, b.Model)) case status == 429: return fmt.Sprintf("%s: rate limited — wait, or lower the pace", p.name) case status >= 500: return fmt.Sprintf("%s: server error %d — %s", p.name, status, firstLine(msg)) } return msg } // connectionRefused: a dial that failed, whatever the wrapping. The typed check // covers the errors.As path; the text check covers Genkit's %v rebuilds. func connectionRefused(err error, msg string) bool { var opErr *net.OpError if errors.As(err, &opErr) && opErr.Op == "dial" { return true } return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") } // firstLine keeps a server's message to its first line, cut at 120 columns — // the same one-line-per-item rule as the command recap. func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } if r := []rune(s); len(r) > 120 { return string(r[:119]) + "…" } return s }