Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4c0bd0e1 | ||
|
|
d5fe7548c5 | ||
|
|
15886f2c55 |
@@ -0,0 +1,560 @@
|
||||
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
||||
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
||||
// net/http.
|
||||
//
|
||||
// The adapter never logs, and no error it returns contains the API key:
|
||||
// errors do not quote caller- or vendor-supplied text, and every exported
|
||||
// method passes its error through redact as a backstop.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// APIVersion is the Messages API version sent as the anthropic-version
|
||||
// header.
|
||||
const APIVersion = "2023-06-01"
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
||||
// "https://api.anthropic.com".
|
||||
BaseURL string
|
||||
// APIKey is sent as the x-api-key header. It must not be empty.
|
||||
APIKey string
|
||||
// Model is the model Generate requests. ListModels and Verify do not
|
||||
// need it.
|
||||
Model string
|
||||
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
||||
// the Messages API requires one. It must be positive.
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// Provider is an Anthropic Messages API llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
apiKey string
|
||||
model string
|
||||
maxTokens int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
p, err := newFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, redact(err, cfg.APIKey)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newFromConfig(cfg Config) (*Provider, error) {
|
||||
// No error from New quotes caller-supplied text: any of it may equal or
|
||||
// contain the API key. Base URL errors never echo the URL.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("anthropic: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("anthropic: no API key configured")
|
||||
}
|
||||
if !validHeaderValue(cfg.APIKey) {
|
||||
return nil, errors.New("anthropic: API key has a control character")
|
||||
}
|
||||
if cfg.MaxTokens <= 0 {
|
||||
return nil, errors.New("anthropic: max tokens must be positive")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
maxTokens: cfg.MaxTokens,
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toolName is the pattern the Messages API requires of a tool name.
|
||||
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||
const maxResponseBody = 16 << 20
|
||||
|
||||
// maxErrorBody caps how much of an error response body is read. It is far
|
||||
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||
// reach the excerpt.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type tool struct {
|
||||
Name string `json:"name"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
|
||||
type toolChoice struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type messagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []message `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Tools []tool `json:"tools,omitempty"`
|
||||
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
||||
}
|
||||
|
||||
type messagesResponse struct {
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
||||
// as one tool whose input_schema is the schema, with tool_choice forcing
|
||||
// it; Response.JSON is then that tool's input.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
resp, err := p.generate(ctx, req)
|
||||
return resp, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
||||
}
|
||||
body := messagesRequest{
|
||||
Model: p.model,
|
||||
MaxTokens: p.maxTokens,
|
||||
System: req.System,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
body.MaxTokens = req.MaxTokens
|
||||
}
|
||||
if req.Schema != nil {
|
||||
if !toolName.MatchString(req.Schema.Name) {
|
||||
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
||||
}
|
||||
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
||||
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||
}
|
||||
|
||||
var mr messagesResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
||||
}
|
||||
if mr.Type != "message" {
|
||||
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
||||
}
|
||||
resp := llm.Response{
|
||||
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
||||
Model: mr.Model,
|
||||
FinishReason: mr.StopReason,
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, block := range mr.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
text.WriteString(block.Text)
|
||||
case "tool_use":
|
||||
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
||||
resp.JSON = block.Input
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Text = text.String()
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isJSONObject(resp.JSON) {
|
||||
resp.JSON = nil
|
||||
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isJSONObject reports whether raw is exactly one JSON object.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
s := bytes.TrimSpace(raw)
|
||||
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
HasMore bool `json:"has_more"`
|
||||
LastID string `json:"last_id"`
|
||||
}
|
||||
|
||||
// modelsPageLimit is the largest page GET /v1/models serves.
|
||||
const modelsPageLimit = 1000
|
||||
|
||||
// ListModels returns every model {base}/v1/models lists, following pages.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.listModels(ctx)
|
||||
return models, redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models := []llm.Model{}
|
||||
after := ""
|
||||
for {
|
||||
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
||||
}
|
||||
for _, m := range page.Data {
|
||||
if m.ID == "" {
|
||||
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
||||
}
|
||||
name := m.DisplayName
|
||||
if name == "" {
|
||||
name = m.ID
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||
}
|
||||
if !page.HasMore {
|
||||
return models, nil
|
||||
}
|
||||
if page.LastID == "" || page.LastID == after {
|
||||
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
||||
}
|
||||
after = page.LastID
|
||||
}
|
||||
}
|
||||
|
||||
// modelsPage fetches one page of GET {base}/v1/models.
|
||||
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
||||
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
||||
if after != "" {
|
||||
q.Set("after_id", after)
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return modelsResponse{}, err
|
||||
}
|
||||
var mr modelsResponse
|
||||
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if mr.Data == nil {
|
||||
return modelsResponse{}, errors.New("response has no data list")
|
||||
}
|
||||
return mr, nil
|
||||
}
|
||||
|
||||
// Verify checks the credentials with the cheapest authenticated call: one
|
||||
// page of GET {base}/v1/models holding at most one model.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
_, err := p.modelsPage(ctx, 1, "")
|
||||
if err != nil {
|
||||
err = fmt.Errorf("anthropic: verify: %w", err)
|
||||
}
|
||||
return redact(err, p.apiKey)
|
||||
}
|
||||
|
||||
// do sends one request and returns the body of a 2xx response. Any other
|
||||
// status becomes the matching llm typed error.
|
||||
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
reqBody = bytes.NewReader(payload)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||
if err != nil {
|
||||
// The parse error names the URL, whose path may hold the API key.
|
||||
return nil, errors.New("build request: request URL is invalid")
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", APIVersion)
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
return nil, p.statusError(httpResp, body)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// statusOverloaded is the status the Messages API returns when it is
|
||||
// temporarily overloaded.
|
||||
const statusOverloaded = 529
|
||||
|
||||
// statusError maps a non-2xx response to an llm typed error.
|
||||
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||
case http.StatusTooManyRequests, statusOverloaded:
|
||||
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||
default:
|
||||
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||
// form. A missing, malformed or past value means the vendor gave no usable
|
||||
// wait, reported as 0.
|
||||
func retryAfter(value string, now time.Time) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||
// capped to the most whole seconds it holds instead of overflowing.
|
||||
if secs > maxRetryAfterSeconds {
|
||||
secs = maxRetryAfterSeconds
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||
return at.Sub(now).Round(time.Second)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||
// A followed redirect would resend the credential headers, which net/http
|
||||
// strips only for its own list of sensitive headers and only across hosts;
|
||||
// instead the 3xx response is returned and becomes a provider error.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *transportFailure) GoString() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// *url.Error names the request URL, and a base URL path may hold the API
|
||||
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||
func (p *Provider) transportError(err error) error {
|
||||
var kind error
|
||||
switch {
|
||||
case isTimeout(err):
|
||||
kind = llm.ErrTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
kind = context.Canceled
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
text := "transport: " + err.Error()
|
||||
if kind == llm.ErrTimeout {
|
||||
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||
}
|
||||
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses a match whose text, or whose %#v form,
|
||||
// holds the key, so it never hands back an error that can print it.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
func (e *redactedError) As(target any) bool {
|
||||
if !errors.As(e.err, target) {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
matched, isErr := found.Interface().(error)
|
||||
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedJoin is redactedError for a layer with several causes, such as
|
||||
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||
type redactedJoin struct {
|
||||
redactedError
|
||||
}
|
||||
|
||||
func (e *redactedJoin) Unwrap() []error {
|
||||
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||
safe := make([]error, len(causes))
|
||||
for i, c := range causes {
|
||||
safe[i] = redact(c, e.key)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: with
|
||||
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||
// prints its struct field names, and a key may spell one.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return &redactedJoin{re}
|
||||
}
|
||||
return &re
|
||||
}
|
||||
|
||||
// holdsKey reports whether the text of err or of any error in its chain
|
||||
// contains key.
|
||||
func holdsKey(err error, key string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), key) {
|
||||
return true
|
||||
}
|
||||
switch u := err.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
return holdsKey(u.Unwrap(), key)
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
if holdsKey(c, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
# Anthropic adapter fixtures
|
||||
|
||||
These are the Messages API response bodies the contract tests in
|
||||
`anthropic_test.go` replay. They are **not live captures**. Product AI is
|
||||
tested with fakes, and live vendor calls are opt-in and never use
|
||||
credentials, so no request was sent to Anthropic to make them. Each fixture
|
||||
is a sanitized adaptation of the response shapes in Anthropic's public API
|
||||
documentation:
|
||||
|
||||
- Messages: <https://docs.anthropic.com/en/api/messages>
|
||||
- Tool use, including `tool_choice` `{type: tool}`:
|
||||
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
|
||||
- List models: <https://docs.anthropic.com/en/api/models-list>
|
||||
- Errors: <https://docs.anthropic.com/en/api/errors>
|
||||
|
||||
Sanitizing:
|
||||
|
||||
- IDs, request IDs, timestamps and token counts are illustrative values in
|
||||
the documented format, not values from a real account.
|
||||
- Message content and tool input are replaced with MealQuest dinner text.
|
||||
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||
a vendor body.
|
||||
|
||||
Below, *not in the public example* marks fields or values that were added
|
||||
for a test case and do not come from the documented example.
|
||||
|
||||
| Fixture | Shape | Not in the public example |
|
||||
| --- | --- | --- |
|
||||
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
|
||||
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
|
||||
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
|
||||
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
|
||||
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
|
||||
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_permission.json` | Error, `permission_error` (403). | — |
|
||||
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
|
||||
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
|
||||
| `error_not_found.json` | Error, `not_found_error` (404). | — |
|
||||
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
|
||||
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
|
||||
],
|
||||
"stop_reason": "max_tokens",
|
||||
"stop_sequence": null,
|
||||
"usage": {"input_tokens": 472, "output_tokens": 400}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "Try a crock pot chili: "},
|
||||
{"type": "text", "text": "it is a Quick dinner."}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 2095,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 503
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "msg_01Aq9w938a90dw8q",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01A09q90qw90lq917835lq9",
|
||||
"name": "side_quests",
|
||||
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 472,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 61
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-sonnet-5",
|
||||
"display_name": "Claude Sonnet 5",
|
||||
"created_at": "2026-02-24T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"first_id": "claude-sonnet-5",
|
||||
"last_id": "claude-sonnet-5"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"display_name": "Claude Haiku 4.5",
|
||||
"created_at": "2025-10-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"type": "model",
|
||||
"id": "claude-opus-5-5",
|
||||
"created_at": "2026-05-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-haiku-4-5-20251001",
|
||||
"last_id": "claude-opus-5-5"
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func (e *RateLimitedError) Error() string {
|
||||
return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter)
|
||||
}
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *RateLimitedError) GoString() string { return e.Error() }
|
||||
|
||||
// Is reports whether target is ErrRateLimited.
|
||||
func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited }
|
||||
|
||||
@@ -128,5 +131,9 @@ func (e *ProviderError) Body() string { return e.body }
|
||||
|
||||
func (e *ProviderError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields,
|
||||
// whose names could spell the credential.
|
||||
func (e *ProviderError) GoString() string { return e.text }
|
||||
|
||||
// Is reports whether target is ErrProvider.
|
||||
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package ollama is the llm.Provider adapter for Ollama's native API. It
|
||||
// calls POST {base}/api/chat and GET {base}/api/tags over plain net/http.
|
||||
//
|
||||
// Ollama takes no credentials: the adapter sends no Authorization header
|
||||
// and never logs. The base URL may be a LAN address, and errors for an
|
||||
// unreachable server name it, so New refuses a base URL that could carry a
|
||||
// credential.
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Config configures a Provider.
|
||||
type Config struct {
|
||||
// BaseURL is the Ollama server root the /api endpoint paths are
|
||||
// appended to, e.g. "http://192.168.1.20:11434" or
|
||||
// "http://ollama:11434". It is required.
|
||||
BaseURL string
|
||||
// Model is the model Generate requests, e.g. "llama3.2". ListModels
|
||||
// and Verify do not need it.
|
||||
Model string
|
||||
}
|
||||
|
||||
// Provider is an Ollama native API llm.Provider.
|
||||
type Provider struct {
|
||||
base string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ llm.Provider = (*Provider)(nil)
|
||||
|
||||
// New validates cfg and returns a Provider.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
// Base URL errors never echo the URL: a rejected one may hold a
|
||||
// credential.
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, errors.New("ollama: base URL does not parse")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("ollama: base URL must be an absolute http or https URL")
|
||||
}
|
||||
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||
// endpoint path appended to it would land in the query or fragment.
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("ollama: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
return &Provider{
|
||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
model: cfg.Model,
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||
const maxResponseBody = 16 << 20
|
||||
|
||||
// maxErrorBody caps how much of an error response body is read; only an
|
||||
// llm.MaxBodyExcerpt of it is kept.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type options struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
NumPredict int `json:"num_predict,omitempty"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Format json.RawMessage `json:"format,omitempty"`
|
||||
Options *options `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
}
|
||||
|
||||
// Generate sends req to {base}/api/chat without streaming. A Request with a
|
||||
// Schema sends it as "format", so Ollama constrains the reply to it;
|
||||
// Response.JSON is then the reply text, which must be a JSON object.
|
||||
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||
}
|
||||
if p.model == "" {
|
||||
return llm.Response{}, errors.New("ollama: generate: no model configured")
|
||||
}
|
||||
body := chatRequest{Model: p.model, Stream: false}
|
||||
if req.System != "" {
|
||||
body.Messages = append(body.Messages, message{Role: "system", Content: req.System})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||
}
|
||||
if req.Schema != nil {
|
||||
body.Format = req.Schema.Schema
|
||||
}
|
||||
if req.Temperature != nil || req.MaxTokens > 0 {
|
||||
body.Options = &options{Temperature: req.Temperature, NumPredict: req.MaxTokens}
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: encode request: %w", err)
|
||||
}
|
||||
|
||||
if req.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
raw, err := p.do(ctx, http.MethodPost, "/api/chat", payload)
|
||||
if err != nil {
|
||||
var pe *llm.ProviderError
|
||||
if errors.As(err, &pe) && pe.Status() == http.StatusNotFound && p.modelNotFound(pe.Body()) {
|
||||
// The body is not quoted: the model name is all the caller
|
||||
// needs. Any other 404 keeps its body excerpt below.
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: model %q not found: %w", p.model, llm.NewProviderError(pe.Status(), nil, ""))
|
||||
}
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||
}
|
||||
|
||||
var cr chatResponse
|
||||
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||
return llm.Response{}, fmt.Errorf("ollama: generate: decode response: %w", err)
|
||||
}
|
||||
if !cr.Done || cr.Message == nil {
|
||||
return llm.Response{}, errors.New("ollama: generate: response is not a finished message")
|
||||
}
|
||||
resp := llm.Response{
|
||||
Text: cr.Message.Content,
|
||||
Usage: llm.Usage{InputTokens: cr.PromptEvalCount, OutputTokens: cr.EvalCount},
|
||||
Model: cr.Model,
|
||||
FinishReason: cr.DoneReason,
|
||||
}
|
||||
if req.Schema == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||
resp.JSON = json.RawMessage(text)
|
||||
return resp, nil
|
||||
}
|
||||
return resp, fmt.Errorf("ollama: generate: reply is not a JSON object: %w", llm.ErrBadOutput)
|
||||
}
|
||||
|
||||
// modelNotFound reports whether body is Ollama's error for the configured
|
||||
// model not being pulled: {"error": "model \"name\" not found, ..."}, or
|
||||
// with the name in single quotes as newer servers send it. A 404 from
|
||||
// anything else, such as a proxy without /api/chat, is not.
|
||||
func (p *Provider) modelNotFound(body string) bool {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &e); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(e.Error, fmt.Sprintf("model %q not found", p.model)) ||
|
||||
strings.HasPrefix(e.Error, fmt.Sprintf("model '%s' not found", p.model))
|
||||
}
|
||||
|
||||
// isJSONObject reports whether s is exactly one JSON object.
|
||||
func isJSONObject(s string) bool {
|
||||
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
// ListModels returns every model {base}/api/tags lists.
|
||||
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
models, err := p.tags(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama: list models: %w", err)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// Verify checks that the server answers GET {base}/api/tags with a model
|
||||
// list.
|
||||
func (p *Provider) Verify(ctx context.Context) error {
|
||||
if _, err := p.tags(ctx); err != nil {
|
||||
return fmt.Errorf("ollama: verify: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) tags(ctx context.Context) ([]llm.Model, error) {
|
||||
raw, err := p.do(ctx, http.MethodGet, "/api/tags", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tr tagsResponse
|
||||
if err := json.Unmarshal(raw, &tr); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if tr.Models == nil {
|
||||
return nil, errors.New("response has no models list")
|
||||
}
|
||||
models := make([]llm.Model, 0, len(tr.Models))
|
||||
for i, m := range tr.Models {
|
||||
if m.Name == "" {
|
||||
return nil, fmt.Errorf("model %d has no name", i)
|
||||
}
|
||||
models = append(models, llm.Model{ID: m.Name, DisplayName: m.Name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// do sends one request and returns the body of a 2xx response. Any other
|
||||
// status, a 3xx included, is an *llm.ProviderError.
|
||||
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
reqBody = bytes.NewReader(payload)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpResp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
return nil, llm.NewProviderError(httpResp.StatusCode, body, "")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, p.transportError(err)
|
||||
}
|
||||
if len(body) > maxResponseBody {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect:
|
||||
// the 3xx response is returned and becomes a provider error, so a request
|
||||
// is only ever sent to the configured server.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
}
|
||||
|
||||
// transportFailure is a failed HTTP exchange. Its text names the base URL
|
||||
// and the cause; it unwraps only to the cause's category, llm.ErrTimeout or
|
||||
// context.Canceled.
|
||||
type transportFailure struct {
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
// transportError reports a failure to complete the HTTP exchange. A
|
||||
// cancelled context is the caller's choice and stays context.Canceled.
|
||||
// Every other failure, a refused connection or a timeout alike, means the
|
||||
// server could not be reached in time: it matches llm.ErrTimeout and says
|
||||
// "Ollama not reachable at {base}".
|
||||
func (p *Provider) transportError(err error) error {
|
||||
timedOut := isTimeout(err)
|
||||
// The *url.Error repeats the request URL; the base is named once below.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
if !timedOut && errors.Is(err, context.Canceled) {
|
||||
return &transportFailure{text: "request canceled: " + err.Error(), kind: context.Canceled}
|
||||
}
|
||||
text := fmt.Sprintf("Ollama not reachable at %s: %s", p.base, err)
|
||||
if timedOut {
|
||||
text = fmt.Sprintf("Ollama not reachable at %s: %s: %s", p.base, llm.ErrTimeout, err)
|
||||
}
|
||||
return &transportFailure{text: text, kind: llm.ErrTimeout}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// recorded is one request the test server received.
|
||||
type recorded struct {
|
||||
method string
|
||||
path string
|
||||
query url.Values
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// reply is a response the test server replays.
|
||||
type reply struct {
|
||||
status int
|
||||
fixture string // testdata file
|
||||
body string // used when fixture is empty
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// ollamaServer replays its replies in order, repeating the last, and
|
||||
// records every request.
|
||||
type ollamaServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
got []recorded
|
||||
}
|
||||
|
||||
func newOllamaServer(t *testing.T, replies ...reply) *ollamaServer {
|
||||
t.Helper()
|
||||
payloads := make([][]byte, len(replies))
|
||||
for i, r := range replies {
|
||||
payloads[i] = []byte(r.body)
|
||||
if r.fixture != "" {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payloads[i] = raw
|
||||
}
|
||||
}
|
||||
s := &ollamaServer{}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
s.mu.Lock()
|
||||
n := len(s.got)
|
||||
s.got = append(s.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
|
||||
s.mu.Unlock()
|
||||
i := min(n, len(replies)-1)
|
||||
r := replies[i]
|
||||
if r.delay > 0 {
|
||||
select {
|
||||
case <-time.After(r.delay):
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(r.status)
|
||||
w.Write(payloads[i])
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ollamaServer) requests() []recorded {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]recorded(nil), s.got...)
|
||||
}
|
||||
|
||||
func (s *ollamaServer) only(t *testing.T) recorded {
|
||||
t.Helper()
|
||||
got := s.requests()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("server got %d requests, want 1", len(got))
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, base string) *Provider {
|
||||
t.Helper()
|
||||
p, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
|
||||
|
||||
func schemaRequest() llm.Request {
|
||||
temp := 0.0
|
||||
return llm.Request{
|
||||
System: "You plan dinners for Our kitchen.",
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "Suggest a Side quest."},
|
||||
{Role: llm.RoleAssistant, Content: "Something quick?"},
|
||||
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
|
||||
},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 400,
|
||||
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
|
||||
}
|
||||
}
|
||||
|
||||
func textRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
|
||||
}
|
||||
|
||||
// sentBody decodes a request body into a generic map.
|
||||
func sentBody(t *testing.T, r recorded) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r.body, &m); err != nil {
|
||||
t.Fatalf("request body %q: %v", r.body, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// canonical re-encodes JSON so two encodings of one value compare equal.
|
||||
func canonical(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("JSON %q: %v", raw, err)
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func assertNoAuth(t *testing.T, r recorded) {
|
||||
t.Helper()
|
||||
if got := r.header.Values("Authorization"); len(got) != 0 {
|
||||
t.Errorf("Authorization = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateText(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
p := newProvider(t, s.URL+"/")
|
||||
|
||||
resp, err := p.Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
|
||||
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 26, OutputTokens: 298}) || resp.Model != "llama3.2" || resp.FinishReason != "stop" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := s.only(t)
|
||||
if got.method != http.MethodPost || got.path != "/api/chat" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
body := sentBody(t, got)
|
||||
if stream, ok := body["stream"]; !ok || stream != false {
|
||||
t.Errorf("stream = %v (present %v), want false", stream, ok)
|
||||
}
|
||||
if body["model"] != "llama3.2" {
|
||||
t.Errorf("model = %v", body["model"])
|
||||
}
|
||||
for _, field := range []string{"format", "options"} {
|
||||
if v, ok := body[field]; ok {
|
||||
t.Errorf("%s = %v, want it omitted", field, v)
|
||||
}
|
||||
}
|
||||
if got, want := canonical(t, mustMarshal(t, body["messages"])), `[{"content":"What should we cook tonight?","role":"user"}]`; got != want {
|
||||
t.Errorf("messages = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestGenerateSchemaOutput(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json"})
|
||||
p := newProvider(t, s.URL)
|
||||
|
||||
resp, err := p.Generate(context.Background(), schemaRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`
|
||||
if got := canonical(t, resp.JSON); got != want {
|
||||
t.Errorf("Response.JSON = %s, want %s", got, want)
|
||||
}
|
||||
if canonical(t, []byte(resp.Text)) != want {
|
||||
t.Errorf("Text = %q, want the reply content", resp.Text)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 147, OutputTokens: 41}) || resp.FinishReason != "stop" {
|
||||
t.Errorf("Usage, FinishReason = %+v, %q", resp.Usage, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := s.only(t)
|
||||
assertNoAuth(t, got)
|
||||
body := sentBody(t, got)
|
||||
if body["stream"] != false {
|
||||
t.Errorf("stream = %v, want false", body["stream"])
|
||||
}
|
||||
if got, want := canonical(t, mustMarshal(t, body["format"])), canonical(t, dinnersSchema); got != want {
|
||||
t.Errorf("format = %s, want the schema %s", got, want)
|
||||
}
|
||||
opts, _ := body["options"].(map[string]any)
|
||||
if len(opts) != 2 || opts["temperature"] != 0.0 || opts["num_predict"] != 400.0 {
|
||||
t.Errorf("options = %v, want temperature 0 and num_predict 400", body["options"])
|
||||
}
|
||||
wantMessages := `[{"content":"You plan dinners for Our kitchen.","role":"system"},` +
|
||||
`{"content":"Suggest a Side quest.","role":"user"},` +
|
||||
`{"content":"Something quick?","role":"assistant"},` +
|
||||
`{"content":"Yes, a Quick dinner.","role":"user"}]`
|
||||
if got := canonical(t, mustMarshal(t, body["messages"])); got != wantMessages {
|
||||
t.Errorf("messages = %s, want %s", got, wantMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTemperatureOnlyWhenSet(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
req := textRequest()
|
||||
temp := 0.7
|
||||
req.Temperature = &temp
|
||||
if _, err := newProvider(t, s.URL).Generate(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, _ := sentBody(t, s.only(t))["options"].(map[string]any)
|
||||
if len(opts) != 1 || opts["temperature"] != 0.7 {
|
||||
t.Errorf("options = %v, want only temperature 0.7", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBadOutput(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"prose": `{"model":"m","message":{"role":"assistant","content":"Sure! Try chili."},"done":true,"done_reason":"stop"}`,
|
||||
"array": `{"model":"m","message":{"role":"assistant","content":"[1,2]"},"done":true,"done_reason":"stop"}`,
|
||||
"cut-off JSON": `{"model":"m","message":{"role":"assistant","content":"{\"dinners\": ["},"done":true,"done_reason":"length"}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||
resp, err := newProvider(t, s.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want ErrBadOutput", err)
|
||||
}
|
||||
if resp.JSON != nil || resp.Model != "m" || resp.Text == "" {
|
||||
t.Fatalf("Response = %+v, want no JSON and the reply kept", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsMalformedResponses(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"not JSON": `not json`,
|
||||
"not finished": `{"model":"m","message":{"role":"assistant","content":"hi"},"done":false}`,
|
||||
"no message": `{"model":"m","done":true}`,
|
||||
} {
|
||||
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||
_, err := newProvider(t, s.URL).Generate(context.Background(), textRequest())
|
||||
if err == nil || errors.Is(err, llm.ErrBadOutput) || errors.Is(err, llm.ErrProvider) {
|
||||
t.Errorf("%s: err = %v, want an untyped malformed-response error", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||
if _, err := newProvider(t, s.URL).Generate(context.Background(), llm.Request{}); err == nil {
|
||||
t.Error("Generate accepted a request with no messages")
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: s.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
|
||||
t.Error("Generate ran with no model configured")
|
||||
}
|
||||
if n := len(s.requests()); n != 0 {
|
||||
t.Errorf("server got %d requests, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateModelMissing(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, fixture: "error_model_not_found.json"})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||
}
|
||||
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err.Error() != want {
|
||||
t.Fatalf("Error() = %q, want %q", err, want)
|
||||
}
|
||||
if pe.Body() != "" {
|
||||
t.Fatalf("Body() = %q, want the response body left out", pe.Body())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNotFoundUnrelatedToModel(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"proxy page": "404 page not found",
|
||||
"other error": `{"error":"no route for /api/chat"}`,
|
||||
"other model": `{"error":"model \"llama3\" not found, try pulling it first"}`,
|
||||
"empty": "",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, body: body})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") && !strings.Contains(body, "not found") {
|
||||
t.Fatalf("Error() = %q claims something was not found", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "llama9") {
|
||||
t.Fatalf("Error() = %q claims the model was not found", err)
|
||||
}
|
||||
if pe.Body() != strings.TrimSpace(body) {
|
||||
t.Fatalf("Body() = %q, want the response body %q", pe.Body(), body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateModelMissingSingleQuoted(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 404, body: `{"error":"model 'llama9' not found"}`})
|
||||
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), textRequest())
|
||||
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err == nil || err.Error() != want {
|
||||
t.Fatalf("err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModels(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
models, err := newProvider(t, s.URL).ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []llm.Model{{ID: "llama3.2:latest", DisplayName: "llama3.2:latest"}, {ID: "qwen3:8b", DisplayName: "qwen3:8b"}}
|
||||
if len(models) != len(want) || models[0] != want[0] || models[1] != want[1] {
|
||||
t.Fatalf("ListModels() = %+v, want %+v", models, want)
|
||||
}
|
||||
got := s.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/api/tags" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
|
||||
empty := newOllamaServer(t, reply{status: 200, body: `{"models":[]}`})
|
||||
if models, err := newProvider(t, empty.URL).ListModels(context.Background()); err != nil || models == nil || len(models) != 0 {
|
||||
t.Fatalf("empty list: %v, %v", models, err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"no models list": `{}`,
|
||||
"unnamed model": `{"models":[{"model":"x"}]}`,
|
||||
"not JSON": `<html>`,
|
||||
} {
|
||||
bad := newOllamaServer(t, reply{status: 200, body: body})
|
||||
if _, err := newProvider(t, bad.URL).ListModels(context.Background()); err == nil {
|
||||
t.Errorf("%s: ListModels accepted it", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
ok := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
|
||||
t.Fatalf("Verify() = %v", err)
|
||||
}
|
||||
got := ok.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/api/tags" {
|
||||
t.Errorf("Verify called %s %s", got.method, got.path)
|
||||
}
|
||||
assertNoAuth(t, got)
|
||||
|
||||
bad := newOllamaServer(t, reply{status: 500, body: `{"error":"something went wrong"}`})
|
||||
err := newProvider(t, bad.URL).Verify(context.Background())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != 500 || !strings.Contains(err.Error(), "something went wrong") {
|
||||
t.Fatalf("Verify() = %v, want a ProviderError for HTTP 500", err)
|
||||
}
|
||||
malformed := newOllamaServer(t, reply{status: 200, body: `{}`})
|
||||
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
|
||||
t.Error("Verify accepted a body with no models list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrors(t *testing.T) {
|
||||
cases := map[string]reply{
|
||||
"400": {status: 400, body: `{"error":"invalid format"}`},
|
||||
"500": {status: 500, body: `{"error":"llama runner process has terminated"}`},
|
||||
"503": {status: 503, body: `{"error":"server busy, please try again"}`},
|
||||
"huge": {status: 500, body: strings.Repeat("x", 1<<20)},
|
||||
}
|
||||
for name, r := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newOllamaServer(t, r)
|
||||
p := newProvider(t, s.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
for _, err := range []error{genErr, listErr} {
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != r.status {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP %d", err, r.status)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err = %v, want only a provider error", err)
|
||||
}
|
||||
if len(err.Error()) > llm.MaxBodyExcerpt+200 {
|
||||
t.Fatalf("error text has %d bytes, want a bounded excerpt", len(err.Error()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertUnreachable checks the error a request to an unreachable base
|
||||
// returns: it matches llm.ErrTimeout and names the base.
|
||||
func assertUnreachable(t *testing.T, err error, base string) {
|
||||
t.Helper()
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if errors.Is(err, llm.ErrProvider) || errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||
}
|
||||
if want := "Ollama not reachable at " + base; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Error() = %q, want it to contain %q", err, want)
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
t.Fatalf("error chain exposes the url.Error: %v", ue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableHost(t *testing.T) {
|
||||
closed := httptest.NewServer(http.NotFoundHandler())
|
||||
base := closed.URL
|
||||
closed.Close()
|
||||
|
||||
p := newProvider(t, base+"/")
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
assertUnreachable(t, err, base)
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Errorf("Error() = %q, want the cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}))
|
||||
t.Cleanup(dropped.Close)
|
||||
_, err := newProvider(t, dropped.URL).Generate(context.Background(), textRequest())
|
||||
assertUnreachable(t, err, dropped.URL)
|
||||
}
|
||||
|
||||
func TestTimeouts(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json", delay: 5 * time.Second})
|
||||
p := newProvider(t, s.URL)
|
||||
|
||||
req := schemaRequest()
|
||||
req.Timeout = 50 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
assertUnreachable(t, err, s.URL)
|
||||
if !strings.Contains(err.Error(), llm.ErrTimeout.Error()) {
|
||||
t.Errorf("Error() = %q, want it to say the request timed out", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = p.ListModels(ctx)
|
||||
assertUnreachable(t, err, s.URL)
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
assertUnreachable(t, p.Verify(ctx), s.URL)
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = p.Generate(ctx, schemaRequest())
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not reachable") {
|
||||
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectIsNotFollowed(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var secondCalls int
|
||||
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
mu.Lock()
|
||||
secondCalls++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(second.Close)
|
||||
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(first.Close)
|
||||
p := newProvider(t, first.URL)
|
||||
|
||||
_, genErr := p.Generate(context.Background(), textRequest())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, verifyErr} {
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 302", err)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if secondCalls != 0 {
|
||||
t.Fatalf("redirect target got %d requests, want 0", secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadConfig(t *testing.T) {
|
||||
const secret = "hunter2-secret"
|
||||
cases := map[string]string{
|
||||
"empty base": "",
|
||||
"relative base": "ollama:11434",
|
||||
"host only": "192.168.1.20",
|
||||
"ftp base": "ftp://192.168.1.20:11434",
|
||||
"no host": "http://",
|
||||
"credentials in URL": "http://user:" + secret + "@192.168.1.20:11434",
|
||||
"user in URL": "http://" + secret + "@ollama:11434",
|
||||
"secret in query": "http://ollama:11434/?token=" + secret,
|
||||
"secret in fragment": "http://ollama:11434/#" + secret,
|
||||
"unparseable": "http://ollama:11434/%zz?token=" + secret,
|
||||
"empty query": "http://ollama:11434?",
|
||||
"empty fragment": "http://ollama:11434#",
|
||||
"empty both": "http://ollama:11434/?#",
|
||||
}
|
||||
for name, base := range cases {
|
||||
_, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||
if err == nil {
|
||||
t.Errorf("%s: New() = nil error", name)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("%s: error echoes the URL: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, base := range []string{
|
||||
"http://192.168.1.20:11434",
|
||||
"http://ollama:11434",
|
||||
"http://localhost:11434/",
|
||||
"https://ollama.lan/proxy/",
|
||||
} {
|
||||
if _, err := New(Config{BaseURL: base, Model: "llama3.2"}); err != nil {
|
||||
t.Errorf("New(%q) = %v", base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWithPath(t *testing.T) {
|
||||
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||
if err := newProvider(t, s.URL+"/proxy/").Verify(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.only(t).path; got != "/proxy/api/tags" {
|
||||
t.Fatalf("path = %s, want /proxy/api/tags", got)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Ollama adapter fixtures
|
||||
|
||||
These are the native Ollama API response bodies the contract tests in
|
||||
`ollama_test.go` replay. They are **not live captures**. Product AI is tested
|
||||
with fakes and live vendor calls are opt-in, so no request was sent to an
|
||||
Ollama server to make them. Each fixture is a sanitized adaptation of the
|
||||
response shapes in Ollama's public API documentation
|
||||
(<https://github.com/ollama/ollama/blob/main/docs/api.md>):
|
||||
|
||||
- Chat, non-streaming: `POST /api/chat` with `"stream": false`.
|
||||
- Structured outputs: `POST /api/chat` with a JSON schema in `"format"`.
|
||||
- List local models: `GET /api/tags`.
|
||||
- Errors: a JSON `{"error": "..."}` body with a non-2xx status; a model
|
||||
that is not pulled is HTTP 404.
|
||||
|
||||
Sanitizing:
|
||||
|
||||
- Timestamps, durations, sizes, digests and token counts are illustrative
|
||||
values in the documented format, not values from a real server.
|
||||
- Message content is replaced with MealQuest dinner text.
|
||||
- Ollama takes no credentials, so none appear.
|
||||
|
||||
Below, *not in the public example* marks values that were added for a test
|
||||
case and do not come from the documented example.
|
||||
|
||||
| Fixture | Case | Shape | Not in the public example |
|
||||
| --- | --- | --- | --- |
|
||||
| `chat_text.json` | Success | Finished chat message, `done_reason: stop`, `prompt_eval_count` and `eval_count`. | — |
|
||||
| `chat_schema.json` | Schema output | Finished chat message whose content is a JSON object string. | The dinner object. |
|
||||
| `error_model_not_found.json` | Model missing | `{"error": "model \"…\" not found, try pulling it first"}`, served with HTTP 404. | — |
|
||||
| `tags.json` | Model list | `{"models": [...]}` with `name`, `model`, `digest` and `details`. | — |
|
||||
|
||||
The unreachable-host case has no fixture: there is no response to record.
|
||||
The tests close an `httptest` server and dial its address, so the
|
||||
connection is refused, and they hold a reply past a short deadline for the
|
||||
timeout.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"model": "llama3.2",
|
||||
"created_at": "2026-10-04T12:20:11.009132Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"dinners\": [{\"name\": \"Sheet-pan gnocchi\", \"why\": \"Like your Regular tray bake, but quicker.\"}]}"
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": true,
|
||||
"total_duration": 1416823291,
|
||||
"load_duration": 12309083,
|
||||
"prompt_eval_count": 147,
|
||||
"prompt_eval_duration": 271802000,
|
||||
"eval_count": 41,
|
||||
"eval_duration": 1130948000
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"model": "llama3.2",
|
||||
"created_at": "2026-10-04T12:13:43.416799Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Try a crock pot chili: it is a Quick dinner."
|
||||
},
|
||||
"done": true,
|
||||
"done_reason": "stop",
|
||||
"total_duration": 5191566416,
|
||||
"load_duration": 2154458,
|
||||
"prompt_eval_count": 26,
|
||||
"prompt_eval_duration": 383809000,
|
||||
"eval_count": 298,
|
||||
"eval_duration": 4799921000
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"model \"llama9\" not found, try pulling it first"}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "llama3.2:latest",
|
||||
"model": "llama3.2:latest",
|
||||
"modified_at": "2026-09-30T08:06:48.639712648-07:00",
|
||||
"size": 2019393189,
|
||||
"digest": "a80c4f17acd55265feec403c7aef86be0c25983ab279d83f3bcd3abbcb5b8b72",
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": "llama",
|
||||
"families": ["llama"],
|
||||
"parameter_size": "3.2B",
|
||||
"quantization_level": "Q4_K_M"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "qwen3:8b",
|
||||
"model": "qwen3:8b",
|
||||
"modified_at": "2026-09-28T19:41:02.112408113-07:00",
|
||||
"size": 5225388164,
|
||||
"digest": "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41",
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": "qwen3",
|
||||
"families": ["qwen3"],
|
||||
"parameter_size": "8.2B",
|
||||
"quantization_level": "Q4_K_M"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -132,7 +133,7 @@ func newFromConfig(cfg Config) (*Provider, error) {
|
||||
model: cfg.Model,
|
||||
headers: headers,
|
||||
mode: cfg.StructuredOutput,
|
||||
client: &http.Client{},
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -462,10 +463,15 @@ func retryAfter(value string, now time.Time) time.Duration {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(value); err == nil {
|
||||
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||
// capped to the most whole seconds it holds instead of overflowing.
|
||||
if secs > maxRetryAfterSeconds {
|
||||
secs = maxRetryAfterSeconds
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||
@@ -474,6 +480,22 @@ func retryAfter(value string, now time.Time) time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||
// A followed redirect would resend the credential headers, which net/http
|
||||
// strips only for its own list of sensitive headers and only across hosts;
|
||||
// instead the 3xx response is returned and becomes a provider error.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
@@ -483,16 +505,14 @@ func isTimeout(err error) bool {
|
||||
// with the request URL removed and the API key redacted, and unwraps only to
|
||||
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||
type transportFailure struct {
|
||||
detail string
|
||||
kind error
|
||||
text string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string {
|
||||
if e.kind == llm.ErrTimeout {
|
||||
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
|
||||
}
|
||||
return "transport: " + e.detail
|
||||
}
|
||||
func (e *transportFailure) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *transportFailure) GoString() string { return e.text }
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
@@ -511,7 +531,11 @@ func (p *Provider) transportError(err error) error {
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind}
|
||||
text := "transport: " + err.Error()
|
||||
if kind == llm.ErrTimeout {
|
||||
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||
}
|
||||
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
@@ -520,8 +544,8 @@ func (p *Provider) transportError(err error) error {
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
// they always match; As refuses a match whose text, or whose %#v form,
|
||||
// holds the key, so it never hands back an error that can print it.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
@@ -530,6 +554,9 @@ type redactedError struct {
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v from printing the fields, which hold the key.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
@@ -539,7 +566,8 @@ func (e *redactedError) As(target any) bool {
|
||||
return false
|
||||
}
|
||||
found := reflect.ValueOf(target).Elem()
|
||||
if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) {
|
||||
matched, isErr := found.Interface().(error)
|
||||
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
@@ -561,12 +589,13 @@ func (e *redactedJoin) Unwrap() []error {
|
||||
return safe
|
||||
}
|
||||
|
||||
// redact is the backstop every exported method applies to its error: when
|
||||
// the text of err or of any error in its chain holds a non-empty key, it
|
||||
// returns a stand-in whose text, and the text of every error it unwraps
|
||||
// to, has the key redacted. Any other err is returned unchanged.
|
||||
// redact is the backstop every exported method applies to its error: with
|
||||
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||
// prints its struct field names, and a key may spell one.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" || !holdsKey(err, key) {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
|
||||
@@ -779,6 +779,9 @@ func TestRetryAfter(t *testing.T) {
|
||||
"soon": 0,
|
||||
"Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute,
|
||||
"Sat, 03 Oct 2026 11:59:00 GMT": 0,
|
||||
"9223372036854775807": time.Duration(maxRetryAfterSeconds) * time.Second,
|
||||
"99999999999999999999": time.Duration(maxRetryAfterSeconds) * time.Second,
|
||||
"-99999999999999999999": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := retryAfter(in, now); got != want {
|
||||
@@ -882,8 +885,8 @@ func TestRedactKeepsChainAndHidesKey(t *testing.T) {
|
||||
t.Fatal("redact changed an error for the empty key")
|
||||
}
|
||||
clean := errors.New("openai: generate: no model configured")
|
||||
if redact(clean, testKey) != clean {
|
||||
t.Fatal("redact wrapped an error that holds no key")
|
||||
if got := redact(clean, testKey); got.Error() != clean.Error() || !errors.Is(got, clean) {
|
||||
t.Fatalf("redact(%v) = %v, want the same text and chain", clean, got)
|
||||
}
|
||||
// A key inside the redaction marker would survive replacement.
|
||||
if got := redact(errors.New("x E y"), "E").Error(); strings.Contains(got, "E") {
|
||||
@@ -938,7 +941,7 @@ func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) {
|
||||
fmt.Errorf("openai: generate: %w", inner),
|
||||
fmt.Errorf("openai: generate: %w", hidden),
|
||||
fmt.Errorf("openai: generate: %w", joined),
|
||||
fmt.Errorf("openai: %w and %w", inner, &transportFailure{detail: "slow", kind: llm.ErrTimeout}),
|
||||
fmt.Errorf("openai: %w and %w", inner, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}),
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := redact(c, testKey)
|
||||
@@ -964,7 +967,7 @@ func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) {
|
||||
if !errors.Is(err, llm.ErrAuth) {
|
||||
t.Fatalf("errors.Is lost ErrAuth in %v", err)
|
||||
}
|
||||
err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{detail: "slow", kind: llm.ErrTimeout}), testKey)
|
||||
err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}), testKey)
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("errors.Is lost ErrTimeout in %v", err)
|
||||
}
|
||||
@@ -1119,3 +1122,134 @@ func TestGenerateWithAdapterNameKeyNeverLeaksKey(t *testing.T) {
|
||||
assertChainNoExactKey(t, err, key)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRedactedErrorsFormatWithoutKey formats every error type that holds the
|
||||
// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString.
|
||||
func TestRedactedErrorsFormatWithoutKey(t *testing.T) {
|
||||
const key = "sk-distinctive-openai-format-7c1e"
|
||||
single := redact(fmt.Errorf("openai %s: %w", key, llm.ErrBadOutput), key)
|
||||
joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key)
|
||||
if _, ok := single.(*redactedError); !ok {
|
||||
t.Fatalf("single = %T, want *redactedError", single)
|
||||
}
|
||||
if _, ok := joined.(*redactedJoin); !ok {
|
||||
t.Fatalf("joined = %T, want *redactedJoin", joined)
|
||||
}
|
||||
p := &Provider{apiKey: key}
|
||||
transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded))
|
||||
for _, err := range []error{single, joined, transport} {
|
||||
for _, verb := range []string{"%v", "%+v", "%#v"} {
|
||||
if got := fmt.Sprintf(verb, err); strings.Contains(got, key) {
|
||||
t.Fatalf("%s of %T leaks the key: %q", verb, err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitedHugeRetryAfterIsCapped(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Retry-After", "9223372036854775807")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = p.Verify(context.Background())
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != time.Duration(maxRetryAfterSeconds)*time.Second {
|
||||
t.Fatalf("err = %v, want a RateLimitedError with RetryAfter capped to %v", err, time.Duration(maxRetryAfterSeconds)*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectIsNotFollowed(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var secondCalls int
|
||||
var secondHeader http.Header
|
||||
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
mu.Lock()
|
||||
secondCalls++
|
||||
secondHeader = req.Header.Clone()
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(second.Close)
|
||||
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(first.Close)
|
||||
p, err := New(Config{BaseURL: first.URL + "/v1", APIKey: testKey, Model: "test-model", Headers: map[string]string{"X-Title": testKey}, StructuredOutput: PromptOnly})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = p.Verify(context.Background())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound {
|
||||
t.Fatalf("err = %v, want a ProviderError for HTTP 302", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if secondCalls != 0 {
|
||||
for name, values := range secondHeader {
|
||||
for _, v := range values {
|
||||
if strings.Contains(v, testKey) {
|
||||
t.Errorf("redirect target saw the key in %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("redirect target got %d requests, want 0", secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// assertGoStringNoKey checks that %#v of err, of every layer reached by
|
||||
// unwrapping and of the typed errors reached by errors.As holds no key.
|
||||
func assertGoStringNoKey(t *testing.T, err error, key string) {
|
||||
t.Helper()
|
||||
var walk func(error)
|
||||
walk = func(e error) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if got := fmt.Sprintf("%#v", e); strings.Contains(got, key) {
|
||||
t.Fatalf("%%#v of %T leaks %q: %q", e, key, got)
|
||||
}
|
||||
switch u := e.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
walk(u.Unwrap())
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err)
|
||||
var pe *llm.ProviderError
|
||||
if errors.As(err, &pe) {
|
||||
walk(pe)
|
||||
}
|
||||
var rl *llm.RateLimitedError
|
||||
if errors.As(err, &rl) {
|
||||
walk(rl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldNameKeyNeverLeaksThroughGoString uses struct field names of the
|
||||
// returned error types as the API key; %#v must not print any of them.
|
||||
func TestFieldNameKeyNeverLeaksThroughGoString(t *testing.T) {
|
||||
for _, key := range []string{"body", "status", "text", "RetryAfter", "msg", "err", "key", "kind"} {
|
||||
_, err := keyServerWithKey(t, key, 500, "down").Generate(context.Background(), schemaRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != 500 {
|
||||
t.Fatalf("key %q: err = %v, want a 500 ProviderError", key, err)
|
||||
}
|
||||
assertGoStringNoKey(t, err, key)
|
||||
err = keyServerWithKey(t, key, 429, "slow").Verify(context.Background())
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.As(err, &rl) {
|
||||
t.Fatalf("key %q: err = %v, want a RateLimitedError", key, err)
|
||||
}
|
||||
assertGoStringNoKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user