Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba7667949 | ||
|
|
893e00d14e | ||
|
|
68391d14c8 | ||
|
|
cceeafd200 | ||
|
|
2afd6a9338 | ||
|
|
f333440256 | ||
|
|
095c03f112 | ||
|
|
3ddc18fa0d | ||
|
|
adfd84810e | ||
|
|
80d91eba70 | ||
|
|
1f4356cd17 | ||
|
|
6d0bea10b5 |
@@ -1,560 +0,0 @@
|
||||
// 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
@@ -1,41 +0,0 @@
|
||||
# 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. |
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1 +0,0 @@
|
||||
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||
@@ -1 +0,0 @@
|
||||
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||
@@ -1 +0,0 @@
|
||||
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||
@@ -1 +0,0 @@
|
||||
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||
@@ -1 +0,0 @@
|
||||
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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,9 +58,6 @@ 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 }
|
||||
|
||||
@@ -131,9 +128,5 @@ 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 }
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
// 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}
|
||||
}
|
||||
@@ -1,615 +0,0 @@
|
||||
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
@@ -1,36 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"error":"model \"llama9\" not found, try pulling it first"}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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,7 +15,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -53,7 +52,6 @@ type Config struct {
|
||||
BaseURL string
|
||||
// APIKey is sent as "Authorization: Bearer <APIKey>". Empty sends no
|
||||
// Authorization header, for endpoints such as Ollama that need none.
|
||||
// It must not have a control character other than tab.
|
||||
APIKey string
|
||||
// Model is the model Generate requests. ListModels and Verify do not
|
||||
// need it.
|
||||
@@ -103,9 +101,6 @@ func newFromConfig(cfg Config) (*Provider, error) {
|
||||
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||
return nil, errors.New("openai: base URL must not carry credentials, a query or a fragment")
|
||||
}
|
||||
if !validHeaderValue(cfg.APIKey) {
|
||||
return nil, errors.New("openai: API key has a control character")
|
||||
}
|
||||
// No error from New quotes caller-supplied text or a name derived from
|
||||
// it: any such text may equal or contain the API key, and redaction
|
||||
// cannot catch a fragment or a case-changed copy of it.
|
||||
@@ -137,7 +132,7 @@ func newFromConfig(cfg Config) (*Provider, error) {
|
||||
model: cfg.Model,
|
||||
headers: headers,
|
||||
mode: cfg.StructuredOutput,
|
||||
client: noRedirectClient(),
|
||||
client: &http.Client{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -467,15 +462,10 @@ func retryAfter(value string, now time.Time) time.Duration {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs, err := strconv.Atoi(value); err == nil {
|
||||
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) {
|
||||
@@ -484,22 +474,6 @@ 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())
|
||||
@@ -509,14 +483,16 @@ 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 {
|
||||
text string
|
||||
kind error
|
||||
detail 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) Error() string {
|
||||
if e.kind == llm.ErrTimeout {
|
||||
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
|
||||
}
|
||||
return "transport: " + e.detail
|
||||
}
|
||||
|
||||
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||
|
||||
@@ -535,11 +511,7 @@ func (p *Provider) transportError(err error) 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}
|
||||
return &transportFailure{detail: llm.Redact(err.Error(), p.apiKey), kind: kind}
|
||||
}
|
||||
|
||||
// redactedError stands in for one layer of an error chain that holds the
|
||||
@@ -548,8 +520,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 a match whose text, or whose %#v form,
|
||||
// holds the key, so it never hands back an error that can print it.
|
||||
// they always match; As refuses only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
@@ -558,9 +530,6 @@ 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) }
|
||||
@@ -570,8 +539,7 @@ func (e *redactedError) As(target any) bool {
|
||||
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) {
|
||||
if matched, ok := found.Interface().(error); ok && holdsKey(matched, e.key) {
|
||||
found.SetZero()
|
||||
return false
|
||||
}
|
||||
@@ -593,13 +561,12 @@ func (e *redactedJoin) Unwrap() []error {
|
||||
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.
|
||||
// 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.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
if err == nil || key == "" || !holdsKey(err, key) {
|
||||
return err
|
||||
}
|
||||
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||
|
||||
@@ -628,20 +628,6 @@ func TestNewRejectsBadHeaderWithoutEchoingValue(t *testing.T) {
|
||||
assertNoSecret(t, err)
|
||||
}
|
||||
|
||||
func TestNewRejectsKeyWithControlCharacter(t *testing.T) {
|
||||
for _, key := range []string{testKey + "\n", testKey + "\r\nX-Injected: 1", "\x00" + testKey, testKey + "\x7f"} {
|
||||
_, err := New(Config{BaseURL: "https://example.com/v1", APIKey: key, StructuredOutput: JSONSchema})
|
||||
if err == nil {
|
||||
t.Errorf("New(%q) = nil error", key)
|
||||
continue
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
if strings.Contains(err.Error()+fmt.Sprintf("%#v", err), key) {
|
||||
t.Errorf("error leaks the key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewConfigErrorsNeverLeakKey covers every New error that names
|
||||
// caller-supplied text, each with the API key embedded in that text.
|
||||
func TestNewConfigErrorsNeverLeakKey(t *testing.T) {
|
||||
@@ -793,9 +779,6 @@ 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 {
|
||||
@@ -899,8 +882,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 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)
|
||||
if redact(clean, testKey) != clean {
|
||||
t.Fatal("redact wrapped an error that holds no key")
|
||||
}
|
||||
// A key inside the redaction marker would survive replacement.
|
||||
if got := redact(errors.New("x E y"), "E").Error(); strings.Contains(got, "E") {
|
||||
@@ -955,7 +938,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{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}),
|
||||
fmt.Errorf("openai: %w and %w", inner, &transportFailure{detail: "slow", kind: llm.ErrTimeout}),
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := redact(c, testKey)
|
||||
@@ -981,7 +964,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{text: "llm: request timed out: transport: slow", kind: llm.ErrTimeout}), testKey)
|
||||
err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{detail: "slow", kind: llm.ErrTimeout}), testKey)
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("errors.Is lost ErrTimeout in %v", err)
|
||||
}
|
||||
@@ -1136,134 +1119,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// Package presets holds what MealQuest knows about each language-model
|
||||
// vendor — base URL, adapter, credentials, headers, structured output mode
|
||||
// and model quirks — and FromSettings, which turns the household's
|
||||
// llm.Settings into a ready llm.Provider.
|
||||
//
|
||||
// FromSettings lives here rather than in package llm because it builds the
|
||||
// adapters, and every adapter imports llm.
|
||||
//
|
||||
// Nothing in this package logs, and no error it returns or unwraps to
|
||||
// contains the API key, even where the key equals a setting's or request's
|
||||
// value or text the error spells anyway.
|
||||
package presets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/anthropic"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/ollama"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/openai"
|
||||
)
|
||||
|
||||
// Adapter names the llm adapter package a preset builds.
|
||||
type Adapter string
|
||||
|
||||
// The adapters.
|
||||
const (
|
||||
AdapterAnthropic Adapter = "anthropic"
|
||||
AdapterOpenAI Adapter = "openai"
|
||||
AdapterOllama Adapter = "ollama"
|
||||
)
|
||||
|
||||
// KeyPolicy is whether a preset sends an API key.
|
||||
type KeyPolicy int
|
||||
|
||||
// The key policies.
|
||||
const (
|
||||
// KeyRequired presets fail without a key.
|
||||
KeyRequired KeyPolicy = iota
|
||||
// KeyOptional presets send a key when one is given.
|
||||
KeyOptional
|
||||
// KeyNone presets send no key and refuse one.
|
||||
KeyNone
|
||||
)
|
||||
|
||||
// The reasoning models that reject the temperature field are OpenAI's o1
|
||||
// and o3 families: the bare family name or the name followed by a "-"
|
||||
// variant (o1, o1-mini, o3-2025-04-16), never a longer word such as o123.
|
||||
// OpenRouter names them with an "openai/" prefix and may add a ":" variant
|
||||
// (openai/o1:online). A preset whose OmitTemperature holds a pattern never
|
||||
// sends a temperature to a matching model.
|
||||
var (
|
||||
openAIReasoningModels = regexp.MustCompile(`^o[13](-.*)?$`)
|
||||
openRouterReasoningModels = regexp.MustCompile(`^openai/o[13]([-:].*)?$`)
|
||||
)
|
||||
|
||||
// Preset is one vendor's fixed configuration.
|
||||
type Preset struct {
|
||||
// Name is the Settings.Provider value that selects the preset.
|
||||
Name string
|
||||
// Adapter is the adapter the preset builds.
|
||||
Adapter Adapter
|
||||
// BaseURL is the API root the adapter appends its endpoint paths to.
|
||||
// Empty means the household must supply it in Settings.BaseURL.
|
||||
BaseURL string
|
||||
// Key is whether the preset sends an API key.
|
||||
Key KeyPolicy
|
||||
// RequiresPublicURL means the preset needs Settings.PublicURL for its
|
||||
// headers.
|
||||
RequiresPublicURL bool
|
||||
// Headers returns the extra request headers an openai-adapter preset
|
||||
// sends; nil means none.
|
||||
Headers func(llm.Settings) map[string]string
|
||||
// StructuredOutput is the default mode of an openai-adapter preset.
|
||||
StructuredOutput openai.StructuredOutput
|
||||
// FallbackStructuredOutput is the mode the household can select when
|
||||
// the default fails for its model; empty means none. FromSettings
|
||||
// builds one mode and never retries a request in another.
|
||||
FallbackStructuredOutput openai.StructuredOutput
|
||||
// StructuredOutputs are the modes Settings.StructuredOutput may select.
|
||||
StructuredOutputs []openai.StructuredOutput
|
||||
// MaxTokens is the max_tokens the anthropic adapter sends when a
|
||||
// Request sets none.
|
||||
MaxTokens int
|
||||
// OmitTemperature are model-id patterns the preset never sends a
|
||||
// temperature to.
|
||||
OmitTemperature []*regexp.Regexp
|
||||
}
|
||||
|
||||
// openRouterHeaders identify MealQuest to OpenRouter: HTTP-Referer is
|
||||
// MEALQUEST_PUBLIC_URL and X-Title is the app name.
|
||||
func openRouterHeaders(s llm.Settings) map[string]string {
|
||||
return map[string]string{
|
||||
"HTTP-Referer": s.PublicURL,
|
||||
"X-Title": "MealQuest",
|
||||
}
|
||||
}
|
||||
|
||||
// omitsTemperature reports whether model matches one of the preset's
|
||||
// OmitTemperature patterns.
|
||||
func (p Preset) omitsTemperature(model string) bool {
|
||||
return slices.ContainsFunc(p.OmitTemperature, func(re *regexp.Regexp) bool {
|
||||
return re.MatchString(model)
|
||||
})
|
||||
}
|
||||
|
||||
// Lookup returns the preset Settings.Provider name selects.
|
||||
func Lookup(name string) (Preset, bool) {
|
||||
p, ok := all[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// all is every preset, by Settings.Provider name.
|
||||
var all = map[string]Preset{
|
||||
"anthropic": {
|
||||
Name: "anthropic",
|
||||
Adapter: AdapterAnthropic,
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
Key: KeyRequired,
|
||||
MaxTokens: 4096,
|
||||
},
|
||||
"openai": {
|
||||
Name: "openai",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Key: KeyRequired,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema},
|
||||
OmitTemperature: []*regexp.Regexp{openAIReasoningModels},
|
||||
},
|
||||
"openrouter": {
|
||||
Name: "openrouter",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Key: KeyRequired,
|
||||
RequiresPublicURL: true,
|
||||
Headers: openRouterHeaders,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
FallbackStructuredOutput: openai.JSONObject,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||
OmitTemperature: []*regexp.Regexp{openRouterReasoningModels},
|
||||
},
|
||||
"xai": {
|
||||
Name: "xai",
|
||||
Adapter: AdapterOpenAI,
|
||||
BaseURL: "https://api.x.ai/v1",
|
||||
Key: KeyRequired,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
FallbackStructuredOutput: openai.JSONObject,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||
},
|
||||
"ollama": {
|
||||
Name: "ollama",
|
||||
Adapter: AdapterOllama,
|
||||
Key: KeyNone,
|
||||
},
|
||||
"openai_compatible": {
|
||||
Name: "openai_compatible",
|
||||
Adapter: AdapterOpenAI,
|
||||
Key: KeyOptional,
|
||||
StructuredOutput: openai.JSONSchema,
|
||||
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject, openai.PromptOnly},
|
||||
// The server may be OpenAI itself or a gateway that names models
|
||||
// the OpenRouter way.
|
||||
OmitTemperature: []*regexp.Regexp{openAIReasoningModels, openRouterReasoningModels},
|
||||
},
|
||||
}
|
||||
|
||||
// FromSettings returns the Provider s describes, using key as its API key.
|
||||
// It fails with a configuration error from package llm — ErrUnknownProvider,
|
||||
// ErrMissingKey, ErrUnexpectedKey, ErrInvalidKey, ErrMissingBaseURL, ErrMissingModel,
|
||||
// ErrMissingPublicURL or ErrInvalidSetting — before contacting any vendor.
|
||||
//
|
||||
// The Provider sends s.Temperature with every request whose Temperature is
|
||||
// nil; a request that sets one keeps it. Neither is sent to a model the
|
||||
// preset's OmitTemperature matches.
|
||||
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||
cp, err := fromSettings(s, key)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
p, ok := Lookup(s.Provider)
|
||||
if !ok {
|
||||
return nil, llm.ErrUnknownProvider
|
||||
}
|
||||
if err := check(p, s, key); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
base := p.BaseURL
|
||||
if base == "" {
|
||||
base = s.BaseURL
|
||||
}
|
||||
|
||||
var inner llm.Provider
|
||||
var err error
|
||||
switch p.Adapter {
|
||||
case AdapterAnthropic:
|
||||
inner, err = anthropic.New(anthropic.Config{BaseURL: base, APIKey: key, Model: s.Model, MaxTokens: p.MaxTokens})
|
||||
case AdapterOllama:
|
||||
inner, err = ollama.New(ollama.Config{BaseURL: base, Model: s.Model})
|
||||
case AdapterOpenAI:
|
||||
mode := p.StructuredOutput
|
||||
if s.StructuredOutput != "" {
|
||||
mode = openai.StructuredOutput(s.StructuredOutput)
|
||||
}
|
||||
cfg := openai.Config{BaseURL: base, APIKey: key, Model: s.Model, StructuredOutput: mode}
|
||||
if p.Headers != nil {
|
||||
cfg.Headers = p.Headers(s)
|
||||
}
|
||||
inner, err = openai.New(cfg)
|
||||
default:
|
||||
panic("presets: preset " + p.Name + " has unknown adapter " + string(p.Adapter))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err)
|
||||
}
|
||||
|
||||
cp := &configured{Provider: inner, key: key}
|
||||
if !p.omitsTemperature(s.Model) {
|
||||
t := s.Temperature
|
||||
cp.temperature = &t
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// check validates s and key against p. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string) error {
|
||||
switch {
|
||||
case p.Key == KeyRequired && key == "":
|
||||
return llm.ErrMissingKey
|
||||
case p.Key == KeyNone && key != "":
|
||||
return llm.ErrUnexpectedKey
|
||||
case !validHeaderValue(key):
|
||||
return llm.ErrInvalidKey
|
||||
}
|
||||
switch {
|
||||
case p.BaseURL == "" && s.BaseURL == "":
|
||||
return llm.ErrMissingBaseURL
|
||||
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
|
||||
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if s.Model == "" {
|
||||
return llm.ErrMissingModel
|
||||
}
|
||||
if p.RequiresPublicURL && s.PublicURL == "" {
|
||||
return llm.ErrMissingPublicURL
|
||||
}
|
||||
if s.StructuredOutput != "" && !slices.Contains(p.StructuredOutputs, openai.StructuredOutput(s.StructuredOutput)) {
|
||||
return fmt.Errorf("%w: structured output mode is not supported by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if math.IsNaN(s.Temperature) || math.IsInf(s.Temperature, 0) || s.Temperature < 0 {
|
||||
return fmt.Errorf("%w: temperature must be a non-negative number", llm.ErrInvalidSetting)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value has no control character other
|
||||
// than tab, so it can be sent as an HTTP header value.
|
||||
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
|
||||
}
|
||||
|
||||
// configured applies the household temperature and the preset's
|
||||
// temperature quirk to every Generate.
|
||||
type configured struct {
|
||||
llm.Provider
|
||||
// key is the API key, redacted from validation errors; the adapter
|
||||
// redacts its own.
|
||||
key string
|
||||
// temperature is sent when a request sets none; nil means the model
|
||||
// rejects temperature, so none is ever sent.
|
||||
temperature *float64
|
||||
}
|
||||
|
||||
// Generate validates req as sent by the caller before changing its
|
||||
// temperature, so clearing it for a model that rejects temperature never
|
||||
// hides an invalid one.
|
||||
func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key)
|
||||
}
|
||||
if c.temperature == nil {
|
||||
req.Temperature = nil
|
||||
} else if req.Temperature == nil {
|
||||
t := *c.temperature
|
||||
req.Temperature = &t
|
||||
}
|
||||
return c.Provider.Generate(ctx, req)
|
||||
}
|
||||
|
||||
// redactedError is an error with the API key removed from its text. It
|
||||
// does not unwrap, because every layer below may hold the key; Is still
|
||||
// matches the sentinels the original error wraps.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||
func (e *redactedError) GoString() string { return e.text }
|
||||
|
||||
// Is reports whether the original error matches target.
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
// redact returns err with key removed from its text; a nil err or an empty
|
||||
// key returns err unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" {
|
||||
return err
|
||||
}
|
||||
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
package presets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const testKey = "sk-test-0123456789"
|
||||
|
||||
// captured is the HTTP request an adapter sent.
|
||||
type captured struct {
|
||||
url string
|
||||
header http.Header
|
||||
body map[string]json.RawMessage
|
||||
}
|
||||
|
||||
var errStop = errors.New("recorded")
|
||||
|
||||
// recordingTransport records each request and fails it, so no request
|
||||
// leaves the process.
|
||||
type recordingTransport struct{ reqs []captured }
|
||||
|
||||
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
c := captured{url: r.URL.String(), header: r.Header.Clone()}
|
||||
if r.Body != nil {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rt.reqs = append(rt.reqs, c)
|
||||
return nil, errStop
|
||||
}
|
||||
|
||||
// generate builds the provider s describes and returns the request its
|
||||
// Generate sends for req. The adapters use http.DefaultTransport, which the
|
||||
// test replaces, so the real preset base URLs are never contacted.
|
||||
func generate(t *testing.T, s llm.Settings, key string, req llm.Request) captured {
|
||||
t.Helper()
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
p, err := FromSettings(s, key)
|
||||
if err != nil {
|
||||
t.Fatalf("FromSettings: %v", err)
|
||||
}
|
||||
if _, err := p.Generate(context.Background(), req); err == nil {
|
||||
t.Fatal("Generate succeeded through the recording transport")
|
||||
}
|
||||
if len(rt.reqs) != 1 {
|
||||
t.Fatalf("sent %d requests, want 1", len(rt.reqs))
|
||||
}
|
||||
return rt.reqs[0]
|
||||
}
|
||||
|
||||
func plainRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "dinner?"}}}
|
||||
}
|
||||
|
||||
func schemaRequest() llm.Request {
|
||||
r := plainRequest()
|
||||
r.Schema = &llm.JSONSchema{Name: "side_quests", Schema: json.RawMessage(`{"type":"object"}`)}
|
||||
return r
|
||||
}
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func responseFormatType(t *testing.T, c captured) string {
|
||||
t.Helper()
|
||||
raw, ok := c.body["response_format"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var rf struct{ Type string }
|
||||
if err := json.Unmarshal(raw, &rf); err != nil {
|
||||
t.Fatalf("response_format: %v", err)
|
||||
}
|
||||
return rf.Type
|
||||
}
|
||||
|
||||
func temperature(t *testing.T, c captured) (float64, bool) {
|
||||
t.Helper()
|
||||
raw, ok := c.body["temperature"]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatalf("temperature: %v", err)
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func TestAnthropicPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "anthropic", Model: "claude-sonnet-5", Temperature: 0.7}, testKey, plainRequest())
|
||||
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("X-Api-Key"); got != testKey {
|
||||
t.Fatalf("x-api-key = %q", got)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none", got)
|
||||
}
|
||||
if got, ok := temperature(t, c); !ok || got != 0.7 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.7", got, ok)
|
||||
}
|
||||
if string(c.body["max_tokens"]) != "4096" {
|
||||
t.Fatalf("max_tokens = %s", c.body["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-5"}, testKey, plainRequest())
|
||||
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.4}, testKey, schemaRequest())
|
||||
if c.url != "https://api.openai.com/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("response_format type = %q, want json_schema", got)
|
||||
}
|
||||
if c.header.Get("HTTP-Referer") != "" || c.header.Get("X-Title") != "" {
|
||||
t.Fatal("openai sends OpenRouter headers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterPreset(t *testing.T) {
|
||||
s := llm.Settings{Provider: "openrouter", Model: "openai/gpt-4o", PublicURL: "https://meals.example.com/"}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if c.url != "https://openrouter.ai/api/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
if got := c.header.Get("HTTP-Referer"); got != "https://meals.example.com/" {
|
||||
t.Fatalf("HTTP-Referer = %q", got)
|
||||
}
|
||||
if got := c.header.Get("X-Title"); got != "MealQuest" {
|
||||
t.Fatalf("X-Title = %q", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterFallbackModeIsJSONObject(t *testing.T) {
|
||||
p, _ := Lookup("openrouter")
|
||||
s := llm.Settings{
|
||||
Provider: "openrouter",
|
||||
Model: "meta-llama/llama-3.3-70b-instruct",
|
||||
PublicURL: "https://meals.example.com",
|
||||
StructuredOutput: string(p.FallbackStructuredOutput),
|
||||
}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if got := responseFormatType(t, c); got != "json_object" {
|
||||
t.Fatalf("response_format type = %q, want json_object", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIPreset(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "xai", Model: "grok-4", Temperature: 0.6}, testKey, schemaRequest())
|
||||
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
for _, h := range []string{"X-Api-Key", "HTTP-Referer", "X-Title"} {
|
||||
if got := c.header.Get(h); got != "" {
|
||||
t.Fatalf("%s = %q, want none", h, got)
|
||||
}
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
if got, ok := temperature(t, c); !ok || got != 0.6 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.6", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIFallbackModeIsJSONObject(t *testing.T) {
|
||||
p, _ := Lookup("xai")
|
||||
s := llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: string(p.FallbackStructuredOutput)}
|
||||
c := generate(t, s, testKey, schemaRequest())
|
||||
if got := responseFormatType(t, c); got != "json_object" {
|
||||
t.Fatalf("response_format type = %q, want json_object", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||
c := generate(t, llm.Settings{Provider: "xai", BaseURL: "https://api.x.ai/v1", Model: "grok-4"}, testKey, plainRequest())
|
||||
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaPreset(t *testing.T) {
|
||||
s := llm.Settings{Provider: "ollama", BaseURL: "http://ollama.lan:11434", Model: "llama3.2", Temperature: 0.5}
|
||||
c := generate(t, s, "", plainRequest())
|
||||
if c.url != "http://ollama.lan:11434/api/chat" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization = %q, want none", got)
|
||||
}
|
||||
var opts struct{ Temperature *float64 }
|
||||
if err := json.Unmarshal(c.body["options"], &opts); err != nil || opts.Temperature == nil || *opts.Temperature != 0.5 {
|
||||
t.Fatalf("options = %s", c.body["options"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatiblePreset(t *testing.T) {
|
||||
base := llm.Settings{Provider: "openai_compatible", BaseURL: "http://llm.lan:8000/v1", Model: "qwen2.5"}
|
||||
|
||||
c := generate(t, base, "", schemaRequest())
|
||||
if c.url != "http://llm.lan:8000/v1/chat/completions" {
|
||||
t.Fatalf("URL = %q", c.url)
|
||||
}
|
||||
if got := c.header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization without key = %q, want none", got)
|
||||
}
|
||||
if got := responseFormatType(t, c); got != "json_schema" {
|
||||
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||
}
|
||||
|
||||
c = generate(t, base, testKey, plainRequest())
|
||||
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||
t.Fatalf("Authorization with key = %q", got)
|
||||
}
|
||||
|
||||
for mode, want := range map[string]string{"json_schema": "json_schema", "json_object": "json_object", "prompt_only": ""} {
|
||||
s := base
|
||||
s.StructuredOutput = mode
|
||||
if got := responseFormatType(t, generate(t, s, "", schemaRequest())); got != want {
|
||||
t.Errorf("mode %s: response_format type = %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureOmittedForRejectingModels(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"openai": {"o1", "o1-mini", "o1-2024-12-17", "o3", "o3-mini"},
|
||||
"openai_compatible": {"o1", "o1-mini", "o3-mini", "openai/o1"},
|
||||
"openrouter": {"openai/o1", "openai/o1-mini", "openai/o3", "openai/o3-mini", "openai/o1:online"},
|
||||
}
|
||||
for provider, models := range cases {
|
||||
for _, model := range models {
|
||||
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||
switch provider {
|
||||
case "openai_compatible":
|
||||
s.BaseURL = "http://llm.lan/v1"
|
||||
case "openrouter":
|
||||
s.PublicURL = "https://mealquest.example"
|
||||
}
|
||||
for _, req := range []llm.Request{plainRequest(), func() llm.Request { r := plainRequest(); r.Temperature = ptr(0.2); return r }()} {
|
||||
if got, ok := temperature(t, generate(t, s, testKey, req)); ok {
|
||||
t.Errorf("%s %s: temperature %v sent, want omitted", provider, model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureSentForOtherModels(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"openai": {"gpt-4o", "gpt-4o-mini", "chatgpt-o1-like", "o123", "o3custom", "o1mini", "openai/o1"},
|
||||
"openrouter": {"openai/gpt-4o", "openai/o123", "openai/o3custom", "acme/o1", "o1-mini"},
|
||||
}
|
||||
for provider, models := range cases {
|
||||
for _, model := range models {
|
||||
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||
if provider == "openrouter" {
|
||||
s.PublicURL = "https://mealquest.example"
|
||||
}
|
||||
if got, ok := temperature(t, generate(t, s, testKey, plainRequest())); !ok || got != 0.8 {
|
||||
t.Errorf("%s %s: temperature = %v, %v; want 0.8", provider, model, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A request's own temperature wins over the household's.
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(0.1)
|
||||
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.8}, testKey, req)
|
||||
if got, ok := temperature(t, c); !ok || got != 0.1 {
|
||||
t.Fatalf("temperature = %v, %v; want 0.1", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A request is validated before the temperature quirk clears its
|
||||
// temperature, so a negative one is an error and nothing is sent.
|
||||
func TestNegativeTemperatureRejectedForRejectingModels(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
for _, s := range []llm.Settings{
|
||||
{Provider: "openai", Model: "o1"},
|
||||
{Provider: "openrouter", Model: "openai/o1", PublicURL: "https://mealquest.example"},
|
||||
} {
|
||||
p, err := FromSettings(s, testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: FromSettings: %v", s.Model, err)
|
||||
}
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(-0.5)
|
||||
if _, err := p.Generate(context.Background(), req); err == nil || errors.Is(err, errStop) || !strings.Contains(err.Error(), "temperature") {
|
||||
t.Errorf("%s: Generate = %v, want a temperature validation error", s.Model, err)
|
||||
}
|
||||
}
|
||||
if len(rt.reqs) != 0 {
|
||||
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperatureQuirkPresets(t *testing.T) {
|
||||
want := map[string]bool{"openai": true, "openrouter": true, "openai_compatible": true}
|
||||
for name, p := range all {
|
||||
if got := len(p.OmitTemperature) > 0; got != want[name] {
|
||||
t.Errorf("%s: lists temperature quirk = %v, want %v", name, got, want[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
adapter Adapter
|
||||
baseURL string
|
||||
key KeyPolicy
|
||||
}{
|
||||
{"anthropic", AdapterAnthropic, "https://api.anthropic.com", KeyRequired},
|
||||
{"openai", AdapterOpenAI, "https://api.openai.com/v1", KeyRequired},
|
||||
{"openrouter", AdapterOpenAI, "https://openrouter.ai/api/v1", KeyRequired},
|
||||
{"xai", AdapterOpenAI, "https://api.x.ai/v1", KeyRequired},
|
||||
{"ollama", AdapterOllama, "", KeyNone},
|
||||
{"openai_compatible", AdapterOpenAI, "", KeyOptional},
|
||||
}
|
||||
if len(all) != len(cases) {
|
||||
t.Fatalf("%d presets, want %d", len(all), len(cases))
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, ok := Lookup(c.name)
|
||||
if !ok {
|
||||
t.Fatalf("no preset %q", c.name)
|
||||
}
|
||||
if p.Name != c.name || p.Adapter != c.adapter || p.BaseURL != c.baseURL || p.Key != c.key {
|
||||
t.Errorf("%s = {%s %s %q %d}", c.name, p.Name, p.Adapter, p.BaseURL, p.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigErrors(t *testing.T) {
|
||||
sentinels := []error{
|
||||
llm.ErrUnknownProvider, llm.ErrMissingKey, llm.ErrUnexpectedKey, llm.ErrInvalidKey, llm.ErrMissingBaseURL,
|
||||
llm.ErrMissingModel, llm.ErrMissingPublicURL, llm.ErrInvalidSetting,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"unknown provider", llm.Settings{Provider: "gemini", Model: "m"}, testKey, llm.ErrUnknownProvider},
|
||||
{"anthropic no key", llm.Settings{Provider: "anthropic", Model: "m"}, "", llm.ErrMissingKey},
|
||||
{"openai no key", llm.Settings{Provider: "openai", Model: "m"}, "", llm.ErrMissingKey},
|
||||
{"openrouter no key", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, "", llm.ErrMissingKey},
|
||||
{"xai no key", llm.Settings{Provider: "xai", Model: "grok-4"}, "", llm.ErrMissingKey},
|
||||
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, testKey, llm.ErrUnexpectedKey},
|
||||
{"anthropic key with newline", llm.Settings{Provider: "anthropic", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openai key with newline", llm.Settings{Provider: "openai", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openrouter key with CR LF", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey + "\r\nX-Injected: 1", llm.ErrInvalidKey},
|
||||
{"xai key with newline", llm.Settings{Provider: "xai", Model: "grok-4"}, testKey + "\n", llm.ErrInvalidKey},
|
||||
{"openai_compatible key with NUL", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, "\x00" + testKey, llm.ErrInvalidKey},
|
||||
{"ollama no base URL", llm.Settings{Provider: "ollama", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||
{"openai_compatible no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||
{"anthropic no model", llm.Settings{Provider: "anthropic"}, testKey, llm.ErrMissingModel},
|
||||
{"openai no model", llm.Settings{Provider: "openai"}, testKey, llm.ErrMissingModel},
|
||||
{"openrouter no model", llm.Settings{Provider: "openrouter", PublicURL: "https://x.example"}, testKey, llm.ErrMissingModel},
|
||||
{"ollama no model", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434"}, "", llm.ErrMissingModel},
|
||||
{"openai_compatible no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "", llm.ErrMissingModel},
|
||||
{"xai no model", llm.Settings{Provider: "xai"}, testKey, llm.ErrMissingModel},
|
||||
{"openrouter no public URL", llm.Settings{Provider: "openrouter", Model: "m"}, testKey, llm.ErrMissingPublicURL},
|
||||
{"fixed base URL overridden", llm.Settings{Provider: "openai", BaseURL: "https://evil.example/v1", Model: "m"}, testKey, llm.ErrInvalidSetting},
|
||||
{"xai base URL overridden", llm.Settings{Provider: "xai", BaseURL: "https://api.openai.com/v1", Model: "grok-4"}, testKey, llm.ErrInvalidSetting},
|
||||
{"xai prompt_only", llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: "prompt_only"}, testKey, llm.ErrInvalidSetting},
|
||||
{"openai json_object", llm.Settings{Provider: "openai", Model: "m", StructuredOutput: "json_object"}, testKey, llm.ErrInvalidSetting},
|
||||
{"anthropic mode", llm.Settings{Provider: "anthropic", Model: "m", StructuredOutput: "json_schema"}, testKey, llm.ErrInvalidSetting},
|
||||
{"unknown mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "", llm.ErrInvalidSetting},
|
||||
{"negative temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, testKey, llm.ErrInvalidSetting},
|
||||
{"bad base URL", llm.Settings{Provider: "ollama", BaseURL: "ftp://o", Model: "m"}, "", llm.ErrInvalidSetting},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, err := FromSettings(c.s, c.key)
|
||||
if p != nil || !errors.Is(err, c.want) {
|
||||
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||
}
|
||||
for _, other := range sentinels {
|
||||
if other != c.want && errors.Is(err, other) {
|
||||
t.Fatalf("error %v also matches %v", err, other)
|
||||
}
|
||||
}
|
||||
if c.key != "" && strings.Contains(err.Error(), c.key) {
|
||||
t.Fatalf("error leaks the key: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyProviderForEveryPreset(t *testing.T) {
|
||||
for _, s := range []struct {
|
||||
s llm.Settings
|
||||
key string
|
||||
}{
|
||||
{llm.Settings{Provider: "anthropic", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "openai", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey},
|
||||
{llm.Settings{Provider: "xai", Model: "m"}, testKey},
|
||||
{llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, ""},
|
||||
{llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, ""},
|
||||
} {
|
||||
if p, err := FromSettings(s.s, s.key); err != nil || p == nil {
|
||||
t.Errorf("%s: FromSettings = %v, %v", s.s.Provider, p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that matches a value the request carries, or text the error spells
|
||||
// anyway, never appears in a validation error, and nothing is sent.
|
||||
func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) {
|
||||
rt := &recordingTransport{}
|
||||
orig := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
for _, key := range []string{"-0.5", "temperature", "llm", "negative"} {
|
||||
p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: FromSettings: %v", key, err)
|
||||
}
|
||||
req := plainRequest()
|
||||
req.Temperature = ptr(-0.5)
|
||||
_, err = p.Generate(context.Background(), req)
|
||||
if err == nil || errors.Is(err, errStop) {
|
||||
t.Fatalf("%s: Generate = %v, want a validation error", key, err)
|
||||
}
|
||||
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) {
|
||||
t.Fatalf("%s: error leaks the key: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rt.reqs) != 0 {
|
||||
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// A configuration error keeps its sentinel but never holds the key, even
|
||||
// when the key is also a setting's value or text the error spells anyway.
|
||||
func TestConfigErrorsNeverLeakKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider},
|
||||
{"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL},
|
||||
{"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel},
|
||||
{"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL},
|
||||
{"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting},
|
||||
{"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting},
|
||||
{"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting},
|
||||
{"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting},
|
||||
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey},
|
||||
{"key with newline", llm.Settings{Provider: "openai", Model: "m"}, "sk-test\n", llm.ErrInvalidKey},
|
||||
{"xai key as preset name", llm.Settings{Provider: "xai"}, "xai", llm.ErrMissingModel},
|
||||
{"xai key as missing-model text", llm.Settings{Provider: "xai"}, "model is required", llm.ErrMissingModel},
|
||||
{"xai key as fixed-URL text", llm.Settings{Provider: "xai", BaseURL: "https://x.example/v1", Model: "m"}, "fixed", llm.ErrInvalidSetting},
|
||||
{"xai key as structured-output text", llm.Settings{Provider: "xai", Model: "m", StructuredOutput: "prompt_only"}, "structured output", llm.ErrInvalidSetting},
|
||||
{"key with newline as text", llm.Settings{Provider: "openai", Model: "m"}, "header\n", llm.ErrInvalidKey},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, err := FromSettings(c.s, c.key)
|
||||
if p != nil || !errors.Is(err, c.want) {
|
||||
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||
}
|
||||
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) {
|
||||
t.Fatalf("error leaks the key: %s", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package llm
|
||||
|
||||
import "errors"
|
||||
|
||||
// Settings is the household's AI configuration, the input to
|
||||
// presets.FromSettings. The caller fills it from the db.AiSetting row and
|
||||
// the service config; this package does not import internal/db.
|
||||
//
|
||||
// llm.Settings{
|
||||
// Provider: row.Provider,
|
||||
// BaseURL: row.BaseUrl,
|
||||
// Model: row.Model,
|
||||
// Temperature: row.Temperature,
|
||||
// PublicURL: cfg.PublicURL.String(), // MEALQUEST_PUBLIC_URL
|
||||
// }
|
||||
//
|
||||
// Settings never holds the API key: the caller decrypts it and passes it
|
||||
// to presets.FromSettings separately.
|
||||
type Settings struct {
|
||||
// Provider names the preset: "anthropic", "openai", "openrouter",
|
||||
// "xai", "ollama" or "openai_compatible".
|
||||
Provider string
|
||||
// BaseURL is the API root. It is required for ollama and
|
||||
// openai_compatible; the other presets fix their own, and a non-empty
|
||||
// value must equal it.
|
||||
BaseURL string
|
||||
// Model is the model id Generate requests. It is required.
|
||||
Model string
|
||||
// Temperature is the household's sampling temperature. It is sent with
|
||||
// every request that sets none, except to models that reject it.
|
||||
Temperature float64
|
||||
// PublicURL is MEALQUEST_PUBLIC_URL. OpenRouter requires it and
|
||||
// receives it as the HTTP-Referer header; other presets ignore it.
|
||||
PublicURL string
|
||||
// StructuredOutput selects how an OpenAI-compatible preset asks for
|
||||
// JSON: "json_schema", "json_object" or "prompt_only". Empty selects
|
||||
// the preset's default, json_schema. Each preset allows only the modes
|
||||
// it lists; anthropic and ollama allow none, so it must be empty.
|
||||
StructuredOutput string
|
||||
}
|
||||
|
||||
// Configuration errors from presets.FromSettings. Each is distinct and
|
||||
// matches with errors.Is.
|
||||
var (
|
||||
// ErrUnknownProvider means Settings.Provider names no preset.
|
||||
ErrUnknownProvider = errors.New("llm: unknown provider")
|
||||
// ErrMissingKey means the preset requires an API key and none was
|
||||
// given.
|
||||
ErrMissingKey = errors.New("llm: API key is required")
|
||||
// ErrUnexpectedKey means an API key was given to a preset that sends
|
||||
// none.
|
||||
ErrUnexpectedKey = errors.New("llm: provider takes no API key")
|
||||
// ErrInvalidKey means the API key is not a valid HTTP header value:
|
||||
// it has a control character such as CR or LF.
|
||||
ErrInvalidKey = errors.New("llm: API key is not a valid header value")
|
||||
// ErrMissingBaseURL means the preset requires a base URL and none was
|
||||
// given.
|
||||
ErrMissingBaseURL = errors.New("llm: base URL is required")
|
||||
// ErrMissingModel means Settings.Model is empty.
|
||||
ErrMissingModel = errors.New("llm: model is required")
|
||||
// ErrMissingPublicURL means the preset requires Settings.PublicURL and
|
||||
// it is empty.
|
||||
ErrMissingPublicURL = errors.New("llm: public URL is required")
|
||||
// ErrInvalidSetting means a setting has a value the preset does not
|
||||
// accept, such as a base URL other than the preset's, an unsupported
|
||||
// structured output mode or a negative temperature.
|
||||
ErrInvalidSetting = errors.New("llm: invalid setting")
|
||||
)
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -309,180 +309,3 @@ func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
|
||||
t.Error("jobs_new left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAIProviderPresetsKeepsRowsAndReferences upgrades a database
|
||||
// holding ai_settings and ai_runs rows, with suggestion_batches and
|
||||
// week_plans referencing the run, and checks 0004 carries every row over,
|
||||
// keeps the references enforced and accepts the new providers.
|
||||
func TestMigrateAIProviderPresetsKeepsRowsAndReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
VALUES (1, 'anthropic', 'https://api.anthropic.com', 'claude', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`,
|
||||
`INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
VALUES ('run-1', 'suggest', '1', 'ollama', 'llama', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string
|
||||
var temperature float64
|
||||
var keyCT []byte
|
||||
if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`).
|
||||
Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_settings: %v", err)
|
||||
}
|
||||
settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt}
|
||||
if want := []any{"anthropic", "https://api.anthropic.com", "claude", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) {
|
||||
t.Errorf("ai_settings = %v, want %v", settings, want)
|
||||
}
|
||||
|
||||
var skill, skillVersion, status, errText, request, response string
|
||||
var latency, tokensIn, tokensOut int
|
||||
if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`).
|
||||
Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_runs: %v", err)
|
||||
}
|
||||
run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt}
|
||||
if want := []any{"suggest", "1", "ollama", "llama", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) {
|
||||
t.Errorf("ai_runs = %v, want %v", run, want)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query("PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows.Next() {
|
||||
t.Error("foreign_key_check reported violations after 0004")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
|
||||
if _, err := s.Writer.Exec(`UPDATE ai_settings SET provider = 'openrouter' WHERE id = 1`); err != nil {
|
||||
t.Errorf("ai_settings.provider openrouter rejected: %v", err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE ai_runs SET provider = 'openai' WHERE id = 'run-1'`); err != nil {
|
||||
t.Errorf("ai_runs.provider openai rejected: %v", err)
|
||||
}
|
||||
for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} {
|
||||
if tableExists(t, s, table) {
|
||||
t.Errorf("%s left behind", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAIProviderXAIKeepsRowsAndReferences upgrades a database at 0004
|
||||
// holding ai_settings and ai_runs rows with 0004's providers, with
|
||||
// suggestion_batches and week_plans referencing the run, and checks 0005
|
||||
// carries every row over, keeps the references enforced, accepts xai and
|
||||
// still rejects an unknown provider.
|
||||
func TestMigrateAIProviderXAIKeepsRowsAndReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql", "0004_ai_provider_presets.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
VALUES (1, 'openrouter', 'https://openrouter.ai/api/v1', 'openai/gpt-4o', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`,
|
||||
`INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
VALUES ('run-1', 'suggest', '1', 'openai', 'gpt-4o', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string
|
||||
var temperature float64
|
||||
var keyCT []byte
|
||||
if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`).
|
||||
Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_settings: %v", err)
|
||||
}
|
||||
settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt}
|
||||
if want := []any{"openrouter", "https://openrouter.ai/api/v1", "openai/gpt-4o", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) {
|
||||
t.Errorf("ai_settings = %v, want %v", settings, want)
|
||||
}
|
||||
|
||||
var skill, skillVersion, status, errText, request, response string
|
||||
var latency, tokensIn, tokensOut int
|
||||
if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`).
|
||||
Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read ai_runs: %v", err)
|
||||
}
|
||||
run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt}
|
||||
if want := []any{"suggest", "1", "openai", "gpt-4o", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) {
|
||||
t.Errorf("ai_runs = %v, want %v", run, want)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query("PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows.Next() {
|
||||
t.Error("foreign_key_check reported violations after 0005")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"ai_settings", "ai_runs"} {
|
||||
if _, err := s.Writer.Exec(`UPDATE ` + table + ` SET provider = 'xai'`); err != nil {
|
||||
t.Errorf("%s.provider xai rejected: %v", table, err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE ` + table + ` SET provider = 'grok'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("%s.provider grok: error %v, want CHECK constraint failure", table, err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} {
|
||||
if tableExists(t, s, table) {
|
||||
t.Errorf("%s left behind", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"household_settings", "plan_draft_dow", 5.5},
|
||||
{"household_settings", "suggestions_dow", 0.5},
|
||||
{"household_settings", "chat_autosend_dow", 3.5},
|
||||
{"ai_runs", "provider", "gemini"},
|
||||
{"ai_runs", "provider", "openai"},
|
||||
{"ai_runs", "status", "done"},
|
||||
{"dinners", "status", "deck"},
|
||||
{"dinners", "origin", "ai"},
|
||||
@@ -322,7 +322,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"rotation_reviews", "decision", "promote"},
|
||||
{"shopping_lists", "status", "frozen"},
|
||||
{"shopping_items", "needed", 2},
|
||||
{"ai_settings", "provider", "gemini"},
|
||||
{"ai_settings", "provider", "openai"},
|
||||
{"recipe_search_settings", "provider", "google"},
|
||||
{"chat_settings", "last_test_status", "pending"},
|
||||
{"notification_prefs", "event", "weekly_digest"},
|
||||
@@ -360,23 +360,6 @@ func TestSchemaRotationReviewEventAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaAIProvidersAllowed checks every provider preset can be stored in
|
||||
// both ai_settings and ai_runs.
|
||||
func TestSchemaAIProvidersAllowed(t *testing.T) {
|
||||
s := seededStore(t)
|
||||
for _, provider := range []string{"openai_compatible", "anthropic", "ollama", "openai", "openrouter", "xai"} {
|
||||
for _, table := range []string{"ai_settings", "ai_runs"} {
|
||||
inRolledBackTx(t, s, func(tx *sql.Tx) {
|
||||
row := testRow(table, 1)
|
||||
row["provider"] = provider
|
||||
if err := insertSchemaRow(tx, table, row); err != nil {
|
||||
t.Errorf("%s.provider %s rejected: %v", table, provider, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaDinnerStatuses(t *testing.T) {
|
||||
s := seededStore(t)
|
||||
for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
-- 0004_ai_provider_presets: ai_settings.provider and ai_runs.provider become
|
||||
-- openai_compatible, anthropic, ollama, openai, openrouter, the presets in
|
||||
-- internal/ai/llm/presets. Every 0001 value is kept, so existing rows copy
|
||||
-- unchanged. SQLite cannot change a CHECK in place, so both tables are
|
||||
-- rebuilt: copy the rows aside, drop the table, create it again with the
|
||||
-- wider CHECK, copy the rows back and drop the copy. Neither table has
|
||||
-- indexes or triggers.
|
||||
--
|
||||
-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs,
|
||||
-- so the rebuild cannot use 0003's create-new-then-rename: dropping ai_runs
|
||||
-- counts every referencing row as a foreign key violation, and only inserting
|
||||
-- the matching ai_runs rows under the name ai_runs clears them. Deferring
|
||||
-- foreign keys holds the check until commit, after the rows are back. The
|
||||
-- pragma lasts only for this migration's transaction.
|
||||
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings;
|
||||
|
||||
DROP TABLE ai_settings;
|
||||
|
||||
CREATE TABLE ai_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')),
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
system_prompt TEXT,
|
||||
api_key_ct BLOB,
|
||||
api_key_last4 TEXT,
|
||||
key_verified_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||
FROM ai_settings_copy;
|
||||
|
||||
DROP TABLE ai_settings_copy;
|
||||
|
||||
CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs;
|
||||
|
||||
DROP TABLE ai_runs;
|
||||
|
||||
CREATE TABLE ai_runs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
skill TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')),
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
||||
latency_ms INTEGER,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
error TEXT,
|
||||
request_redacted TEXT NOT NULL,
|
||||
response_redacted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at
|
||||
FROM ai_runs_copy;
|
||||
|
||||
DROP TABLE ai_runs_copy;
|
||||
@@ -1,64 +0,0 @@
|
||||
-- 0005_ai_provider_xai: ai_settings.provider and ai_runs.provider also
|
||||
-- accept xai, the xAI Grok preset in internal/ai/llm/presets. Every 0004
|
||||
-- value is kept, so existing rows copy unchanged. The rebuild is 0004's:
|
||||
-- copy the rows aside, drop the table, create it again with the wider CHECK,
|
||||
-- copy the rows back and drop the copy. Neither table has indexes or
|
||||
-- triggers.
|
||||
--
|
||||
-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs,
|
||||
-- so foreign keys are deferred until commit, after the ai_runs rows are
|
||||
-- back. The pragma lasts only for this migration's transaction.
|
||||
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings;
|
||||
|
||||
DROP TABLE ai_settings;
|
||||
|
||||
CREATE TABLE ai_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter', 'xai')),
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
system_prompt TEXT,
|
||||
api_key_ct BLOB,
|
||||
api_key_last4 TEXT,
|
||||
key_verified_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at)
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||
FROM ai_settings_copy;
|
||||
|
||||
DROP TABLE ai_settings_copy;
|
||||
|
||||
CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs;
|
||||
|
||||
DROP TABLE ai_runs;
|
||||
|
||||
CREATE TABLE ai_runs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
skill TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter', 'xai')),
|
||||
model TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
||||
latency_ms INTEGER,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
error TEXT,
|
||||
request_redacted TEXT NOT NULL,
|
||||
response_redacted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at)
|
||||
SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at
|
||||
FROM ai_runs_copy;
|
||||
|
||||
DROP TABLE ai_runs_copy;
|
||||
Reference in New Issue
Block a user