Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11cdbbefb | ||
|
|
cdf59c4eee | ||
|
|
4f26ea5ad6 | ||
|
|
3b36f07cb8 | ||
|
|
acd29c8221 | ||
|
|
72042516e2 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 |
@@ -0,0 +1,560 @@
|
|||||||
|
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
||||||
|
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
||||||
|
// net/http.
|
||||||
|
//
|
||||||
|
// The adapter never logs, and no error it returns contains the API key:
|
||||||
|
// errors do not quote caller- or vendor-supplied text, and every exported
|
||||||
|
// method passes its error through redact as a backstop.
|
||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIVersion is the Messages API version sent as the anthropic-version
|
||||||
|
// header.
|
||||||
|
const APIVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// Config configures a Provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
||||||
|
// "https://api.anthropic.com".
|
||||||
|
BaseURL string
|
||||||
|
// APIKey is sent as the x-api-key header. It must not be empty.
|
||||||
|
APIKey string
|
||||||
|
// Model is the model Generate requests. ListModels and Verify do not
|
||||||
|
// need it.
|
||||||
|
Model string
|
||||||
|
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
||||||
|
// the Messages API requires one. It must be positive.
|
||||||
|
MaxTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an Anthropic Messages API llm.Provider.
|
||||||
|
type Provider struct {
|
||||||
|
base string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
maxTokens int
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ llm.Provider = (*Provider)(nil)
|
||||||
|
|
||||||
|
// New validates cfg and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
p, err := newFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, redact(err, cfg.APIKey)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFromConfig(cfg Config) (*Provider, error) {
|
||||||
|
// No error from New quotes caller-supplied text: any of it may equal or
|
||||||
|
// contain the API key. Base URL errors never echo the URL.
|
||||||
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("anthropic: base URL does not parse")
|
||||||
|
}
|
||||||
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
||||||
|
}
|
||||||
|
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||||
|
// endpoint path appended to it would land in the query or fragment.
|
||||||
|
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||||
|
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return nil, errors.New("anthropic: no API key configured")
|
||||||
|
}
|
||||||
|
if !validHeaderValue(cfg.APIKey) {
|
||||||
|
return nil, errors.New("anthropic: API key has a control character")
|
||||||
|
}
|
||||||
|
if cfg.MaxTokens <= 0 {
|
||||||
|
return nil, errors.New("anthropic: max tokens must be positive")
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
model: cfg.Model,
|
||||||
|
maxTokens: cfg.MaxTokens,
|
||||||
|
client: noRedirectClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||||
|
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||||
|
func validHeaderValue(value string) bool {
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolName is the pattern the Messages API requires of a tool name.
|
||||||
|
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||||
|
const maxResponseBody = 16 << 20
|
||||||
|
|
||||||
|
// maxErrorBody caps how much of an error response body is read. It is far
|
||||||
|
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||||
|
// reach the excerpt.
|
||||||
|
const maxErrorBody = 64 << 10
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolChoice struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Messages []message `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
Tools []tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagesResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Input json.RawMessage `json:"input"`
|
||||||
|
} `json:"content"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
||||||
|
// as one tool whose input_schema is the schema, with tool_choice forcing
|
||||||
|
// it; Response.JSON is then that tool's input.
|
||||||
|
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
resp, err := p.generate(ctx, req)
|
||||||
|
return resp, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||||
|
}
|
||||||
|
if p.model == "" {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
||||||
|
}
|
||||||
|
body := messagesRequest{
|
||||||
|
Model: p.model,
|
||||||
|
MaxTokens: p.maxTokens,
|
||||||
|
System: req.System,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
}
|
||||||
|
if req.MaxTokens > 0 {
|
||||||
|
body.MaxTokens = req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Schema != nil {
|
||||||
|
if !toolName.MatchString(req.Schema.Name) {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
||||||
|
}
|
||||||
|
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
||||||
|
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mr messagesResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Type != "message" {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
||||||
|
}
|
||||||
|
resp := llm.Response{
|
||||||
|
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
||||||
|
Model: mr.Model,
|
||||||
|
FinishReason: mr.StopReason,
|
||||||
|
}
|
||||||
|
var text strings.Builder
|
||||||
|
for _, block := range mr.Content {
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
text.WriteString(block.Text)
|
||||||
|
case "tool_use":
|
||||||
|
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
||||||
|
resp.JSON = block.Input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Text = text.String()
|
||||||
|
if req.Schema == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if !isJSONObject(resp.JSON) {
|
||||||
|
resp.JSON = nil
|
||||||
|
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONObject reports whether raw is exactly one JSON object.
|
||||||
|
func isJSONObject(raw json.RawMessage) bool {
|
||||||
|
s := bytes.TrimSpace(raw)
|
||||||
|
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"data"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
LastID string `json:"last_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPageLimit is the largest page GET /v1/models serves.
|
||||||
|
const modelsPageLimit = 1000
|
||||||
|
|
||||||
|
// ListModels returns every model {base}/v1/models lists, following pages.
|
||||||
|
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models, err := p.listModels(ctx)
|
||||||
|
return models, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models := []llm.Model{}
|
||||||
|
after := ""
|
||||||
|
for {
|
||||||
|
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
||||||
|
}
|
||||||
|
for _, m := range page.Data {
|
||||||
|
if m.ID == "" {
|
||||||
|
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
||||||
|
}
|
||||||
|
name := m.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = m.ID
|
||||||
|
}
|
||||||
|
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||||
|
}
|
||||||
|
if !page.HasMore {
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
if page.LastID == "" || page.LastID == after {
|
||||||
|
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
||||||
|
}
|
||||||
|
after = page.LastID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPage fetches one page of GET {base}/v1/models.
|
||||||
|
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
||||||
|
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
||||||
|
if after != "" {
|
||||||
|
q.Set("after_id", after)
|
||||||
|
}
|
||||||
|
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return modelsResponse{}, err
|
||||||
|
}
|
||||||
|
var mr modelsResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Data == nil {
|
||||||
|
return modelsResponse{}, errors.New("response has no data list")
|
||||||
|
}
|
||||||
|
return mr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the credentials with the cheapest authenticated call: one
|
||||||
|
// page of GET {base}/v1/models holding at most one model.
|
||||||
|
func (p *Provider) Verify(ctx context.Context) error {
|
||||||
|
_, err := p.modelsPage(ctx, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("anthropic: verify: %w", err)
|
||||||
|
}
|
||||||
|
return redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends one request and returns the body of a 2xx response. Any other
|
||||||
|
// status becomes the matching llm typed error.
|
||||||
|
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
// The parse error names the URL, whose path may hold the API key.
|
||||||
|
return nil, errors.New("build request: request URL is invalid")
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||||
|
httpReq.Header.Set("anthropic-version", APIVersion)
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
if payload != nil {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
return nil, p.statusError(httpResp, body)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
if len(body) > maxResponseBody {
|
||||||
|
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusOverloaded is the status the Messages API returns when it is
|
||||||
|
// temporarily overloaded.
|
||||||
|
const statusOverloaded = 529
|
||||||
|
|
||||||
|
// statusError maps a non-2xx response to an llm typed error.
|
||||||
|
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||||
|
case http.StatusTooManyRequests, statusOverloaded:
|
||||||
|
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||||
|
case http.StatusRequestTimeout:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||||
|
default:
|
||||||
|
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||||
|
// form. A missing, malformed or past value means the vendor gave no usable
|
||||||
|
// wait, reported as 0.
|
||||||
|
func retryAfter(value string, now time.Time) time.Duration {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||||
|
if secs < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||||
|
// capped to the most whole seconds it holds instead of overflowing.
|
||||||
|
if secs > maxRetryAfterSeconds {
|
||||||
|
secs = maxRetryAfterSeconds
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||||
|
return at.Sub(now).Round(time.Second)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||||
|
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||||
|
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
|
||||||
|
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||||
|
// A followed redirect would resend the credential headers, which net/http
|
||||||
|
// strips only for its own list of sensitive headers and only across hosts;
|
||||||
|
// instead the 3xx response is returned and becomes a provider error.
|
||||||
|
func noRedirectClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
var ne net.Error
|
||||||
|
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||||
|
// with the request URL removed and the API key redacted, and unwraps only to
|
||||||
|
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||||
|
type transportFailure struct {
|
||||||
|
text string
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *transportFailure) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *transportFailure) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
// transportError reports a failure to complete the HTTP exchange. A
|
||||||
|
// *url.Error names the request URL, and a base URL path may hold the API
|
||||||
|
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||||
|
func (p *Provider) transportError(err error) error {
|
||||||
|
var kind error
|
||||||
|
switch {
|
||||||
|
case isTimeout(err):
|
||||||
|
kind = llm.ErrTimeout
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
kind = context.Canceled
|
||||||
|
}
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
err = ue.Err
|
||||||
|
}
|
||||||
|
text := "transport: " + err.Error()
|
||||||
|
if kind == llm.ErrTimeout {
|
||||||
|
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||||
|
}
|
||||||
|
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedError stands in for one layer of an error chain that holds the
|
||||||
|
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||||
|
// returns the layer's causes passed through redact, so no error reached by
|
||||||
|
// unwrapping has text holding the key. Is consults the original layer and
|
||||||
|
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||||
|
// remove the key from their own complete text, fixed words included, so
|
||||||
|
// they always match; As refuses a match whose text, or whose %#v form,
|
||||||
|
// holds the key, so it never hands back an error that can print it.
|
||||||
|
type redactedError struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v from printing the fields, which hold the key.
|
||||||
|
func (e *redactedError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||||
|
|
||||||
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||||
|
|
||||||
|
func (e *redactedError) As(target any) bool {
|
||||||
|
if !errors.As(e.err, target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := reflect.ValueOf(target).Elem()
|
||||||
|
matched, isErr := found.Interface().(error)
|
||||||
|
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||||
|
found.SetZero()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedJoin is redactedError for a layer with several causes, such as
|
||||||
|
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||||
|
type redactedJoin struct {
|
||||||
|
redactedError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedJoin) Unwrap() []error {
|
||||||
|
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||||
|
safe := make([]error, len(causes))
|
||||||
|
for i, c := range causes {
|
||||||
|
safe[i] = redact(c, e.key)
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact is the backstop every exported method applies to its error: with
|
||||||
|
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||||
|
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||||
|
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||||
|
// prints its struct field names, and a key may spell one.
|
||||||
|
func redact(err error, key string) error {
|
||||||
|
if err == nil || key == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||||
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
return &redactedJoin{re}
|
||||||
|
}
|
||||||
|
return &re
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsKey reports whether the text of err or of any error in its chain
|
||||||
|
// contains key.
|
||||||
|
func holdsKey(err error, key string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch u := err.(type) {
|
||||||
|
case interface{ Unwrap() error }:
|
||||||
|
return holdsKey(u.Unwrap(), key)
|
||||||
|
case interface{ Unwrap() []error }:
|
||||||
|
for _, c := range u.Unwrap() {
|
||||||
|
if holdsKey(c, key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Anthropic adapter fixtures
|
||||||
|
|
||||||
|
These are the Messages API response bodies the contract tests in
|
||||||
|
`anthropic_test.go` replay. They are **not live captures**. Product AI is
|
||||||
|
tested with fakes, and live vendor calls are opt-in and never use
|
||||||
|
credentials, so no request was sent to Anthropic to make them. Each fixture
|
||||||
|
is a sanitized adaptation of the response shapes in Anthropic's public API
|
||||||
|
documentation:
|
||||||
|
|
||||||
|
- Messages: <https://docs.anthropic.com/en/api/messages>
|
||||||
|
- Tool use, including `tool_choice` `{type: tool}`:
|
||||||
|
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
|
||||||
|
- List models: <https://docs.anthropic.com/en/api/models-list>
|
||||||
|
- Errors: <https://docs.anthropic.com/en/api/errors>
|
||||||
|
|
||||||
|
Sanitizing:
|
||||||
|
|
||||||
|
- IDs, request IDs, timestamps and token counts are illustrative values in
|
||||||
|
the documented format, not values from a real account.
|
||||||
|
- Message content and tool input are replaced with MealQuest dinner text.
|
||||||
|
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||||
|
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||||
|
a vendor body.
|
||||||
|
|
||||||
|
Below, *not in the public example* marks fields or values that were added
|
||||||
|
for a test case and do not come from the documented example.
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
|
||||||
|
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
|
||||||
|
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
|
||||||
|
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
|
||||||
|
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
|
||||||
|
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
|
||||||
|
| `error_permission.json` | Error, `permission_error` (403). | — |
|
||||||
|
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
|
||||||
|
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
|
||||||
|
| `error_not_found.json` | Error, `not_found_error` (404). | — |
|
||||||
|
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
|
||||||
|
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
|
||||||
|
],
|
||||||
|
"stop_reason": "max_tokens",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 472, "output_tokens": 400}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Try a crock pot chili: "},
|
||||||
|
{"type": "text", "text": "it is a Quick dinner."}
|
||||||
|
],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 2095,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"output_tokens": 503
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01Aq9w938a90dw8q",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_01A09q90qw90lq917835lq9",
|
||||||
|
"name": "side_quests",
|
||||||
|
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 472,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"output_tokens": 61
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-sonnet-5",
|
||||||
|
"display_name": "Claude Sonnet 5",
|
||||||
|
"created_at": "2026-02-24T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": true,
|
||||||
|
"first_id": "claude-sonnet-5",
|
||||||
|
"last_id": "claude-sonnet-5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-haiku-4-5-20251001",
|
||||||
|
"display_name": "Claude Haiku 4.5",
|
||||||
|
"created_at": "2025-10-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-opus-5-5",
|
||||||
|
"created_at": "2026-05-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": false,
|
||||||
|
"first_id": "claude-haiku-4-5-20251001",
|
||||||
|
"last_id": "claude-opus-5-5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAuth means the vendor rejected the credentials.
|
||||||
|
ErrAuth = errors.New("llm: authentication failed")
|
||||||
|
// ErrRateLimited means the vendor throttled the request. The concrete
|
||||||
|
// error is a *RateLimitedError carrying the requested wait.
|
||||||
|
ErrRateLimited = errors.New("llm: rate limited")
|
||||||
|
// ErrTimeout means the request did not finish within its timeout.
|
||||||
|
ErrTimeout = errors.New("llm: request timed out")
|
||||||
|
// ErrBadOutput means the model replied but the reply is unusable, e.g.
|
||||||
|
// structured output that does not parse.
|
||||||
|
ErrBadOutput = errors.New("llm: unusable model output")
|
||||||
|
// ErrProvider means the vendor returned an unexpected HTTP status. The
|
||||||
|
// concrete error is a *ProviderError carrying the status and a redacted
|
||||||
|
// body excerpt.
|
||||||
|
ErrProvider = errors.New("llm: provider error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
||||||
|
// Adapters build it with NewRateLimitedError so its text never holds their
|
||||||
|
// credential.
|
||||||
|
type RateLimitedError struct {
|
||||||
|
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
||||||
|
// did not say.
|
||||||
|
RetryAfter time.Duration
|
||||||
|
// text is the redacted Error text set by NewRateLimitedError; "" means
|
||||||
|
// the error was built without a credential.
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimitedError returns the error for a throttled request. secret is
|
||||||
|
// the credential sent with the request, or "" when the request sent none.
|
||||||
|
// Every occurrence of a non-empty secret is removed from the error text
|
||||||
|
// when it is built, even where the fixed text happens to spell it, and the
|
||||||
|
// secret itself is not kept.
|
||||||
|
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
|
||||||
|
e := &RateLimitedError{RetryAfter: retryAfter}
|
||||||
|
e.text = Redact(e.Error(), secret)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RateLimitedError) Error() string {
|
||||||
|
if e.text != "" {
|
||||||
|
return e.text
|
||||||
|
}
|
||||||
|
if e.RetryAfter == 0 {
|
||||||
|
return ErrRateLimited.Error()
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// MaxBodyExcerpt is the longest response-body excerpt, in bytes, a
|
||||||
|
// ProviderError keeps.
|
||||||
|
const MaxBodyExcerpt = 512
|
||||||
|
|
||||||
|
// redactionMarker replaces each secret in redacted text, unless the marker
|
||||||
|
// would itself hold the secret.
|
||||||
|
const redactionMarker = "[REDACTED]"
|
||||||
|
|
||||||
|
// Redact returns s with every occurrence of a non-empty secret removed. Each
|
||||||
|
// occurrence becomes "[REDACTED]" when the result then holds no secret;
|
||||||
|
// otherwise, because the marker contains the secret or joins with its
|
||||||
|
// neighbours to form it, occurrences are deleted until none remains. The
|
||||||
|
// result never contains secret.
|
||||||
|
func Redact(s, secret string) string {
|
||||||
|
if secret == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for strings.Contains(s, secret) {
|
||||||
|
s = strings.ReplaceAll(s, secret, "")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
||||||
|
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
||||||
|
type ProviderError struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProviderError returns the error for an unexpected HTTP status. It
|
||||||
|
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
||||||
|
// credential sent with the request, or "" when the request sent none; every
|
||||||
|
// occurrence of a non-empty secret in body is removed by Redact before the
|
||||||
|
// body is cut, so no part of it survives, and again after the cut, so the
|
||||||
|
// truncation mark cannot complete it. It is removed from the complete error
|
||||||
|
// text too, even where the fixed text happens to spell it, and the secret
|
||||||
|
// itself is not kept.
|
||||||
|
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
||||||
|
s := strings.TrimSpace(Redact(string(body), secret))
|
||||||
|
if len(s) > MaxBodyExcerpt {
|
||||||
|
cut := MaxBodyExcerpt
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
s = Redact(s[:cut]+"…", secret)
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%s: HTTP %d", ErrProvider, status)
|
||||||
|
if s != "" {
|
||||||
|
text += ": " + s
|
||||||
|
}
|
||||||
|
return &ProviderError{status: status, body: s, text: Redact(text, secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the HTTP status code.
|
||||||
|
func (e *ProviderError) Status() int { return e.status }
|
||||||
|
|
||||||
|
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||||
|
// when the vendor sent no body.
|
||||||
|
func (e *ProviderError) Body() string { return e.body }
|
||||||
|
|
||||||
|
func (e *ProviderError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields,
|
||||||
|
// whose names could spell the credential.
|
||||||
|
func (e *ProviderError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
// Is reports whether target is ErrProvider.
|
||||||
|
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSentinelErrorsMatchWhenWrapped(t *testing.T) {
|
||||||
|
for _, sentinel := range []error{ErrAuth, ErrTimeout, ErrBadOutput} {
|
||||||
|
wrapped := fmt.Errorf("openai: generate: %w", sentinel)
|
||||||
|
if !errors.Is(wrapped, sentinel) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = false", wrapped, sentinel)
|
||||||
|
}
|
||||||
|
for _, other := range []error{ErrAuth, ErrTimeout, ErrBadOutput, ErrRateLimited, ErrProvider} {
|
||||||
|
if other != sentinel && errors.Is(wrapped, other) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = true", wrapped, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitedError(t *testing.T) {
|
||||||
|
err := fmt.Errorf("anthropic: %w", &RateLimitedError{RetryAfter: 30 * time.Second})
|
||||||
|
if !errors.Is(err, ErrRateLimited) {
|
||||||
|
t.Fatal("errors.Is(err, ErrRateLimited) = false")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrProvider) || errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatal("rate-limited error matches an unrelated sentinel")
|
||||||
|
}
|
||||||
|
var rl *RateLimitedError
|
||||||
|
if !errors.As(err, &rl) {
|
||||||
|
t.Fatal("errors.As(err, *RateLimitedError) = false")
|
||||||
|
}
|
||||||
|
if rl.RetryAfter != 30*time.Second {
|
||||||
|
t.Fatalf("RetryAfter = %v, want 30s", rl.RetryAfter)
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != "anthropic: llm: rate limited: retry after 30s" {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
if got := (&RateLimitedError{}).Error(); got != "llm: rate limited" {
|
||||||
|
t.Fatalf("Error() without RetryAfter = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderError(t *testing.T) {
|
||||||
|
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), "sk-test"))
|
||||||
|
if !errors.Is(err, ErrProvider) {
|
||||||
|
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatal("provider error matches an unrelated sentinel")
|
||||||
|
}
|
||||||
|
var pe *ProviderError
|
||||||
|
if !errors.As(err, &pe) {
|
||||||
|
t.Fatal("errors.As(err, *ProviderError) = false")
|
||||||
|
}
|
||||||
|
if pe.Status() != 503 || pe.Body() != `{"error":"overloaded"}` {
|
||||||
|
t.Fatalf("Status, Body = %d, %q", pe.Status(), pe.Body())
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
if got := NewProviderError(500, nil, "sk-test").Error(); got != "llm: provider error: HTTP 500" {
|
||||||
|
t.Fatalf("Error() without body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||||
|
const key = "sk-live-0123456789abcdef"
|
||||||
|
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||||
|
pe := NewProviderError(400, body, key)
|
||||||
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("secret leaked: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := `{"error":"invalid key [REDACTED]","echo":"Bearer [REDACTED]"}`
|
||||||
|
if pe.Body() != want {
|
||||||
|
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
||||||
|
const key = "sk-secret-straddling-the-cut"
|
||||||
|
// The key starts just before the excerpt limit, so truncating first
|
||||||
|
// would leave its prefix in the excerpt.
|
||||||
|
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||||
|
pe := NewProviderError(500, []byte(body), key)
|
||||||
|
if strings.Contains(pe.Body(), "sk-se") {
|
||||||
|
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||||
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||||
|
pe := NewProviderError(500, []byte(body), "sk-test")
|
||||||
|
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||||
|
if pe.Body() != want {
|
||||||
|
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
|
||||||
|
body := []byte(` {"error":"model \"llama9\" not found"} `)
|
||||||
|
pe := NewProviderError(404, body, "")
|
||||||
|
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
|
||||||
|
t.Fatalf("Body() = %q", pe.Body())
|
||||||
|
}
|
||||||
|
if got := pe.Error(); got != `llm: provider error: HTTP 404: {"error":"model \"llama9\" not found"}` {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
long := NewProviderError(500, []byte(strings.Repeat("x", 4*MaxBodyExcerpt)), "")
|
||||||
|
if want := strings.Repeat("x", MaxBodyExcerpt) + "…"; long.Body() != want {
|
||||||
|
t.Fatalf("Body() has %d bytes, want %d", len(long.Body()), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactNeverLeavesSecret(t *testing.T) {
|
||||||
|
cases := []struct{ s, secret, want string }{
|
||||||
|
{"key sk-1 and Bearer sk-1", "sk-1", "key [REDACTED] and Bearer [REDACTED]"},
|
||||||
|
{"no secret here", "sk-1", "no secret here"},
|
||||||
|
{"x", "", "x"},
|
||||||
|
// The marker holds the secret, so occurrences are deleted.
|
||||||
|
{"rejected REDACTED", "REDACTED", "rejected "},
|
||||||
|
{"A then A", "A", " then "},
|
||||||
|
// The marker joins with a neighbour to form the secret.
|
||||||
|
{"]]aa", "]a", ""},
|
||||||
|
// The marker holds the secret and deleting once leaves a new one.
|
||||||
|
{"DDAA", "DA", ""},
|
||||||
|
{"aabb", "ab", "a[REDACTED]b"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := Redact(c.s, c.secret); got != c.want {
|
||||||
|
t.Errorf("Redact(%q, %q) = %q, want %q", c.s, c.secret, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorWithMarkerSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
const key = "REDACTED"
|
||||||
|
pe := NewProviderError(500, []byte(`{"error":"bad key REDACTED","echo":"Bearer REDACTED"}`), key)
|
||||||
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("secret leaked: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := `{"error":"bad key ","echo":"Bearer "}`; pe.Body() != want {
|
||||||
|
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTruncationMarkCannotCompleteSecret(t *testing.T) {
|
||||||
|
const key = "z…"
|
||||||
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "z" + strings.Repeat("b", 10)
|
||||||
|
if pe := NewProviderError(500, []byte(body), key); strings.Contains(pe.Body(), key) {
|
||||||
|
t.Fatalf("secret leaked: %q", pe.Body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
const secret = "HTTP"
|
||||||
|
pe := NewProviderError(500, []byte(`{"error":"HTTP down"}`), secret)
|
||||||
|
if strings.Contains(pe.Error(), secret) || strings.Contains(pe.Body(), secret) {
|
||||||
|
t.Fatalf("secret leaked: Error %q, Body %q", pe.Error(), pe.Body())
|
||||||
|
}
|
||||||
|
if got := NewProviderError(500, nil, secret).Error(); got != "llm: provider error: [REDACTED] 500" {
|
||||||
|
t.Fatalf("Error() without body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitedErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
for _, secret := range []string{"rate", "retry after"} {
|
||||||
|
for _, wait := range []time.Duration{0, 30 * time.Second} {
|
||||||
|
rl := NewRateLimitedError(wait, secret)
|
||||||
|
if strings.Contains(rl.Error(), secret) {
|
||||||
|
t.Fatalf("secret %q leaked: %q", secret, rl.Error())
|
||||||
|
}
|
||||||
|
if rl.RetryAfter != wait || !errors.Is(rl, ErrRateLimited) {
|
||||||
|
t.Fatalf("NewRateLimitedError(%v) = %+v", wait, rl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypedErrorsDoNotKeepSecret(t *testing.T) {
|
||||||
|
const secret = "sk-live-0123456789abcdef"
|
||||||
|
for _, err := range []error{
|
||||||
|
NewProviderError(500, []byte("down "+secret), secret),
|
||||||
|
NewRateLimitedError(30*time.Second, secret),
|
||||||
|
} {
|
||||||
|
if got := fmt.Sprintf("%#v %+v", err, err); strings.Contains(got, secret) {
|
||||||
|
t.Fatalf("formatting %T leaks the secret: %q", err, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fake is a scripted Provider for tests. Each method returns its queued
|
||||||
|
// results in order, one per call, and records every Request Generate
|
||||||
|
// receives. A call with nothing left in its queue fails, so an unexpected
|
||||||
|
// call is never silently answered. It is safe for concurrent use.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
generate []GenerateResult
|
||||||
|
models []ListModelsResult
|
||||||
|
verify []error
|
||||||
|
requests []Request
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Provider = (*Fake)(nil)
|
||||||
|
|
||||||
|
// GenerateResult is one scripted Generate outcome.
|
||||||
|
type GenerateResult struct {
|
||||||
|
Response Response
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModelsResult is one scripted ListModels outcome.
|
||||||
|
type ListModelsResult struct {
|
||||||
|
Models []Model
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueGenerate appends results to the Generate script.
|
||||||
|
func (f *Fake) QueueGenerate(results ...GenerateResult) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.generate = append(f.generate, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueListModels appends results to the ListModels script.
|
||||||
|
func (f *Fake) QueueListModels(results ...ListModelsResult) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.models = append(f.models, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueVerify appends results to the Verify script; a nil entry is success.
|
||||||
|
func (f *Fake) QueueVerify(results ...error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.verify = append(f.verify, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate records req and returns the next scripted result.
|
||||||
|
func (f *Fake) Generate(_ context.Context, req Request) (Response, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requests = append(f.requests, cloneRequest(req))
|
||||||
|
if len(f.generate) == 0 {
|
||||||
|
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", len(f.requests))
|
||||||
|
}
|
||||||
|
r := f.generate[0]
|
||||||
|
f.generate = f.generate[1:]
|
||||||
|
return r.Response, r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns the next scripted result.
|
||||||
|
func (f *Fake) ListModels(context.Context) ([]Model, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.models) == 0 {
|
||||||
|
return nil, fmt.Errorf("llm: fake: unscripted ListModels call")
|
||||||
|
}
|
||||||
|
r := f.models[0]
|
||||||
|
f.models = f.models[1:]
|
||||||
|
return slices.Clone(r.Models), r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify returns the next scripted result.
|
||||||
|
func (f *Fake) Verify(context.Context) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.verify) == 0 {
|
||||||
|
return fmt.Errorf("llm: fake: unscripted Verify call")
|
||||||
|
}
|
||||||
|
err := f.verify[0]
|
||||||
|
f.verify = f.verify[1:]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests returns a copy of every Request Generate received, in order.
|
||||||
|
func (f *Fake) Requests() []Request {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]Request, len(f.requests))
|
||||||
|
for i, r := range f.requests {
|
||||||
|
out[i] = cloneRequest(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining reports how many scripted results of each kind are unused, so a
|
||||||
|
// test can assert its script was consumed.
|
||||||
|
func (f *Fake) Remaining() (generate, listModels, verify int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.generate), len(f.models), len(f.verify)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRequest deep-copies r so a recorded Request is unaffected by later
|
||||||
|
// changes the caller makes to its slices and pointers.
|
||||||
|
func cloneRequest(r Request) Request {
|
||||||
|
r.Messages = slices.Clone(r.Messages)
|
||||||
|
if r.Temperature != nil {
|
||||||
|
t := *r.Temperature
|
||||||
|
r.Temperature = &t
|
||||||
|
}
|
||||||
|
if r.Schema != nil {
|
||||||
|
s := JSONSchema{Name: r.Schema.Name, Schema: json.RawMessage(slices.Clone([]byte(r.Schema.Schema)))}
|
||||||
|
r.Schema = &s
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeGenerateReturnsScriptInOrder(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var f Fake
|
||||||
|
f.QueueGenerate(
|
||||||
|
GenerateResult{Response: Response{Text: "first", Model: "m1"}},
|
||||||
|
GenerateResult{Err: &RateLimitedError{RetryAfter: time.Second}},
|
||||||
|
)
|
||||||
|
f.QueueGenerate(GenerateResult{Response: Response{JSON: json.RawMessage(`{"ok":true}`), FinishReason: "stop"}})
|
||||||
|
|
||||||
|
req := Request{Messages: []Message{{Role: RoleUser, Content: "one"}}}
|
||||||
|
resp, err := f.Generate(ctx, req)
|
||||||
|
if err != nil || resp.Text != "first" || resp.Model != "m1" {
|
||||||
|
t.Fatalf("call 1 = %+v, %v", resp, err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(ctx, req); !errors.Is(err, ErrRateLimited) {
|
||||||
|
t.Fatalf("call 2 err = %v, want ErrRateLimited", err)
|
||||||
|
}
|
||||||
|
resp, err = f.Generate(ctx, req)
|
||||||
|
if err != nil || string(resp.JSON) != `{"ok":true}` || resp.FinishReason != "stop" {
|
||||||
|
t.Fatalf("call 3 = %+v, %v", resp, err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(ctx, req); err == nil {
|
||||||
|
t.Fatal("unscripted call 4 succeeded")
|
||||||
|
}
|
||||||
|
if g, l, v := f.Remaining(); g != 0 || l != 0 || v != 0 {
|
||||||
|
t.Fatalf("Remaining() = %d, %d, %d", g, l, v)
|
||||||
|
}
|
||||||
|
if n := len(f.Requests()); n != 4 {
|
||||||
|
t.Fatalf("recorded %d requests, want 4 (unscripted calls are recorded too)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecordsRequests(t *testing.T) {
|
||||||
|
var f Fake
|
||||||
|
f.QueueGenerate(GenerateResult{}, GenerateResult{})
|
||||||
|
temp := 0.2
|
||||||
|
first := Request{
|
||||||
|
System: "be brief",
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "plan dinners"}},
|
||||||
|
Temperature: &temp,
|
||||||
|
MaxTokens: 100,
|
||||||
|
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(context.Background(), first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(context.Background(), Request{System: "second"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later changes by the caller must not alter what was recorded.
|
||||||
|
first.Messages[0].Content = "changed"
|
||||||
|
temp = 1
|
||||||
|
first.Schema.Schema[2] = 'X'
|
||||||
|
|
||||||
|
got := f.Requests()
|
||||||
|
if len(got) != 2 || got[1].System != "second" {
|
||||||
|
t.Fatalf("Requests() = %+v", got)
|
||||||
|
}
|
||||||
|
r := got[0]
|
||||||
|
if r.System != "be brief" || r.Messages[0].Content != "plan dinners" || *r.Temperature != 0.2 ||
|
||||||
|
r.MaxTokens != 100 || r.Schema.Name != "plan" || string(r.Schema.Schema) != `{"type":"object"}` ||
|
||||||
|
r.Timeout != 5*time.Second {
|
||||||
|
t.Fatalf("recorded request = %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a returned copy must not alter the record either.
|
||||||
|
got[0].Messages[0].Content = "mutated"
|
||||||
|
if f.Requests()[0].Messages[0].Content != "plan dinners" {
|
||||||
|
t.Fatal("Requests() returned shared state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeListModelsAndVerify(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var f Fake
|
||||||
|
f.QueueListModels(
|
||||||
|
ListModelsResult{Models: []Model{{ID: "a", DisplayName: "A"}, {ID: "b", DisplayName: "b"}}},
|
||||||
|
ListModelsResult{Err: ErrAuth},
|
||||||
|
)
|
||||||
|
f.QueueVerify(nil, fmt.Errorf("wrapped: %w", ErrAuth))
|
||||||
|
|
||||||
|
models, err := f.ListModels(ctx)
|
||||||
|
if err != nil || len(models) != 2 || models[0].ID != "a" {
|
||||||
|
t.Fatalf("ListModels 1 = %+v, %v", models, err)
|
||||||
|
}
|
||||||
|
if _, err := f.ListModels(ctx); !errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatalf("ListModels 2 err = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if _, err := f.ListModels(ctx); err == nil {
|
||||||
|
t.Fatal("unscripted ListModels succeeded")
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); err != nil {
|
||||||
|
t.Fatalf("Verify 1 = %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); !errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatalf("Verify 2 = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); err == nil {
|
||||||
|
t.Fatal("unscripted Verify succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeConcurrentUse(t *testing.T) {
|
||||||
|
const n = 50
|
||||||
|
var f Fake
|
||||||
|
for i := range n {
|
||||||
|
f.QueueGenerate(GenerateResult{Response: Response{Text: fmt.Sprint(i)}})
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
if _, err := f.Generate(context.Background(), Request{}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if len(f.Requests()) != n {
|
||||||
|
t.Fatalf("recorded %d requests, want %d", len(f.Requests()), n)
|
||||||
|
}
|
||||||
|
if g, _, _ := f.Remaining(); g != 0 {
|
||||||
|
t.Fatalf("%d Generate results unused", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Package llm is the single interface MealQuest uses to talk to a language
|
||||||
|
// model. Every vendor adapter implements Provider over plain net/http (no
|
||||||
|
// vendor SDKs) and reports failures with this package's typed errors, so
|
||||||
|
// callers decide what to do from errors.Is and errors.As alone.
|
||||||
|
//
|
||||||
|
// Errors from this package and its adapters never contain an API key.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider is a language-model vendor.
|
||||||
|
type Provider interface {
|
||||||
|
// Generate sends one request and returns the model's reply.
|
||||||
|
Generate(ctx context.Context, req Request) (Response, error)
|
||||||
|
// ListModels returns the models the configured credentials can use.
|
||||||
|
ListModels(ctx context.Context) ([]Model, error)
|
||||||
|
// Verify checks that the configured credentials are accepted.
|
||||||
|
Verify(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role is the author of a Message.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// The roles a Message may have. The system prompt is Request.System, not a
|
||||||
|
// message.
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleAssistant Role = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one turn of a conversation.
|
||||||
|
type Message struct {
|
||||||
|
Role Role
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONSchema is a named structured-output contract: when a Request carries
|
||||||
|
// one, the model must reply with JSON matching Schema.
|
||||||
|
type JSONSchema struct {
|
||||||
|
// Name identifies the contract to the vendor, e.g. "side_quests".
|
||||||
|
Name string
|
||||||
|
// Schema is a JSON Schema object.
|
||||||
|
Schema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is one generation request.
|
||||||
|
type Request struct {
|
||||||
|
// System is the system prompt; empty means none.
|
||||||
|
System string
|
||||||
|
// Messages is the conversation, oldest first. It must not be empty.
|
||||||
|
Messages []Message
|
||||||
|
// Temperature is the sampling temperature; nil means the vendor default.
|
||||||
|
Temperature *float64
|
||||||
|
// MaxTokens caps the reply length; 0 means the vendor default.
|
||||||
|
MaxTokens int
|
||||||
|
// Schema, when set, requests structured output; the reply is then in
|
||||||
|
// Response.JSON.
|
||||||
|
Schema *JSONSchema
|
||||||
|
// Timeout bounds the request; 0 means only ctx bounds it.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether r is well formed. Adapters call it before
|
||||||
|
// contacting the vendor. Its errors never quote the request's text, such as
|
||||||
|
// a role or schema name: any of it may equal the API key.
|
||||||
|
func (r Request) Validate() error {
|
||||||
|
if len(r.Messages) == 0 {
|
||||||
|
return errors.New("llm: request has no messages")
|
||||||
|
}
|
||||||
|
for i, m := range r.Messages {
|
||||||
|
if m.Role != RoleUser && m.Role != RoleAssistant {
|
||||||
|
return fmt.Errorf("llm: message %d has an unknown role", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Temperature != nil && *r.Temperature < 0 {
|
||||||
|
return fmt.Errorf("llm: temperature %v is negative", *r.Temperature)
|
||||||
|
}
|
||||||
|
if r.MaxTokens < 0 {
|
||||||
|
return fmt.Errorf("llm: max tokens %d is negative", r.MaxTokens)
|
||||||
|
}
|
||||||
|
if r.Timeout < 0 {
|
||||||
|
return fmt.Errorf("llm: timeout %v is negative", r.Timeout)
|
||||||
|
}
|
||||||
|
if r.Schema != nil {
|
||||||
|
if r.Schema.Name == "" {
|
||||||
|
return errors.New("llm: schema has no name")
|
||||||
|
}
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
||||||
|
return errors.New("llm: schema is not a JSON object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is the token count of one Generate call.
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int
|
||||||
|
OutputTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns InputTokens + OutputTokens.
|
||||||
|
func (u Usage) Total() int { return u.InputTokens + u.OutputTokens }
|
||||||
|
|
||||||
|
// Response is the model's reply to a Request.
|
||||||
|
type Response struct {
|
||||||
|
// Text is the reply text.
|
||||||
|
Text string
|
||||||
|
// JSON is the structured reply when the Request carried a Schema, and
|
||||||
|
// nil otherwise.
|
||||||
|
JSON json.RawMessage
|
||||||
|
// Usage is the tokens the call consumed.
|
||||||
|
Usage Usage
|
||||||
|
// Model is the model that answered, as the vendor reports it.
|
||||||
|
Model string
|
||||||
|
// FinishReason is why generation stopped, as the vendor reports it.
|
||||||
|
FinishReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is a model a Provider offers.
|
||||||
|
type Model struct {
|
||||||
|
// ID is the vendor's model identifier, used in requests.
|
||||||
|
ID string
|
||||||
|
// DisplayName is a human-readable name; it equals ID when the vendor
|
||||||
|
// gives none.
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestValidate(t *testing.T) {
|
||||||
|
neg := -0.1
|
||||||
|
valid := func() Request {
|
||||||
|
return Request{
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "hi"}, {Role: RoleAssistant, Content: "hello"}},
|
||||||
|
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := valid().Validate(); err != nil {
|
||||||
|
t.Fatalf("valid request: %v", err)
|
||||||
|
}
|
||||||
|
cases := map[string]func(*Request){
|
||||||
|
"no messages": func(r *Request) { r.Messages = nil },
|
||||||
|
"unknown role": func(r *Request) { r.Messages[0].Role = "system" },
|
||||||
|
"negative temp": func(r *Request) { r.Temperature = &neg },
|
||||||
|
"negative max": func(r *Request) { r.MaxTokens = -1 },
|
||||||
|
"negative time": func(r *Request) { r.Timeout = -1 },
|
||||||
|
"unnamed schema": func(r *Request) { r.Schema.Name = "" },
|
||||||
|
"schema array": func(r *Request) { r.Schema.Schema = json.RawMessage(`[]`) },
|
||||||
|
"schema null": func(r *Request) { r.Schema.Schema = json.RawMessage(`null`) },
|
||||||
|
"schema invalid": func(r *Request) { r.Schema.Schema = json.RawMessage(`{`) },
|
||||||
|
"schema empty": func(r *Request) { r.Schema.Schema = nil },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
r := valid()
|
||||||
|
mutate(&r)
|
||||||
|
if err := r.Validate(); err == nil {
|
||||||
|
t.Errorf("%s: Validate() = nil", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageTotal(t *testing.T) {
|
||||||
|
if got := (Usage{InputTokens: 12, OutputTokens: 30}).Total(); got != 42 {
|
||||||
|
t.Fatalf("Total() = %d, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
// Package openai is the llm.Provider adapter for OpenAI-compatible chat
|
||||||
|
// APIs: OpenAI itself, OpenRouter and Ollama's /v1 endpoint. It calls
|
||||||
|
// POST {base}/chat/completions and GET {base}/models over plain net/http.
|
||||||
|
//
|
||||||
|
// The adapter never logs, and no error it returns contains the API key or
|
||||||
|
// the Authorization header value: errors do not quote caller- or
|
||||||
|
// vendor-supplied text, and every exported method passes its error through
|
||||||
|
// redact as a backstop.
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StructuredOutput is how the adapter asks the vendor for JSON when a
|
||||||
|
// Request carries a Schema. Which mode a vendor and model support comes
|
||||||
|
// from its preset.
|
||||||
|
type StructuredOutput string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JSONSchema sends response_format {type: json_schema, strict: true}
|
||||||
|
// with the Request's schema.
|
||||||
|
JSONSchema StructuredOutput = "json_schema"
|
||||||
|
// JSONObject sends response_format {type: json_object} and puts the
|
||||||
|
// schema in the system prompt.
|
||||||
|
JSONObject StructuredOutput = "json_object"
|
||||||
|
// PromptOnly sends no response_format, puts the schema in the system
|
||||||
|
// prompt and takes the first JSON object found in the reply.
|
||||||
|
PromptOnly StructuredOutput = "prompt_only"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the API root the endpoint paths are appended to, e.g.
|
||||||
|
// "https://api.openai.com/v1", "https://openrouter.ai/api/v1" or
|
||||||
|
// "http://ollama:11434/v1".
|
||||||
|
BaseURL string
|
||||||
|
// APIKey is sent as "Authorization: Bearer <APIKey>". Empty sends no
|
||||||
|
// Authorization header, for endpoints such as Ollama that need none.
|
||||||
|
APIKey string
|
||||||
|
// Model is the model Generate requests. ListModels and Verify do not
|
||||||
|
// need it.
|
||||||
|
Model string
|
||||||
|
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
|
||||||
|
// X-Title. An Accept header replaces the default "application/json".
|
||||||
|
// Headers the adapter or net/http set themselves (see managedHeaders)
|
||||||
|
// are rejected.
|
||||||
|
Headers map[string]string
|
||||||
|
// StructuredOutput is how JSON is requested when a Request has a Schema.
|
||||||
|
StructuredOutput StructuredOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an OpenAI-compatible llm.Provider.
|
||||||
|
type Provider struct {
|
||||||
|
base string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
headers http.Header
|
||||||
|
mode StructuredOutput
|
||||||
|
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) {
|
||||||
|
// Base URL errors never echo the URL: a rejected one may carry a
|
||||||
|
// credential.
|
||||||
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("openai: base URL does not parse")
|
||||||
|
}
|
||||||
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, errors.New("openai: 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("openai: base URL must not carry credentials, a query or a fragment")
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
switch cfg.StructuredOutput {
|
||||||
|
case JSONSchema, JSONObject, PromptOnly:
|
||||||
|
default:
|
||||||
|
return nil, errors.New("openai: unknown structured output mode")
|
||||||
|
}
|
||||||
|
headers := make(http.Header, len(cfg.Headers)+1)
|
||||||
|
for name, value := range cfg.Headers {
|
||||||
|
if !validHeaderName(name) {
|
||||||
|
return nil, errors.New("openai: an extra header name is not a valid HTTP token")
|
||||||
|
}
|
||||||
|
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
||||||
|
if managedHeaders[canonical] {
|
||||||
|
return nil, errors.New("openai: an extra header is set by the adapter")
|
||||||
|
}
|
||||||
|
if !validHeaderValue(value) {
|
||||||
|
return nil, errors.New("openai: an extra header value has a control character")
|
||||||
|
}
|
||||||
|
headers.Set(canonical, value)
|
||||||
|
}
|
||||||
|
if headers.Get("Accept") == "" {
|
||||||
|
headers.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
model: cfg.Model,
|
||||||
|
headers: headers,
|
||||||
|
mode: cfg.StructuredOutput,
|
||||||
|
client: noRedirectClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedHeaders are the request headers the adapter or net/http set; a
|
||||||
|
// configured value for one would be overwritten or ignored.
|
||||||
|
var managedHeaders = map[string]bool{
|
||||||
|
"Authorization": true,
|
||||||
|
"Content-Type": true,
|
||||||
|
"Content-Length": true,
|
||||||
|
"Transfer-Encoding": true,
|
||||||
|
"Host": true,
|
||||||
|
"Connection": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderName reports whether name is an RFC 9110 field-name: a
|
||||||
|
// non-empty token of letters, digits and !#$%&'*+-.^_`|~.
|
||||||
|
func validHeaderName(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
c := name[i]
|
||||||
|
switch {
|
||||||
|
case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9':
|
||||||
|
case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonSchemaFormat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema *jsonSchemaFormat `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
ResponseFormat *responseFormat `json:"response_format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Refusal *string `json:"refusal"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
Error json.RawMessage `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaInstruction is appended to the system prompt in the json_object and
|
||||||
|
// prompt_only modes, where the schema is not sent in response_format.
|
||||||
|
const schemaInstruction = "Reply with only a JSON object, and no other text, that matches this JSON Schema:\n"
|
||||||
|
|
||||||
|
// Generate sends req to {base}/chat/completions.
|
||||||
|
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("openai: generate: %w", err)
|
||||||
|
}
|
||||||
|
if p.model == "" {
|
||||||
|
return llm.Response{}, errors.New("openai: generate: no model configured")
|
||||||
|
}
|
||||||
|
body := chatRequest{
|
||||||
|
Model: p.model,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
}
|
||||||
|
system := req.System
|
||||||
|
if req.Schema != nil {
|
||||||
|
switch p.mode {
|
||||||
|
case JSONSchema:
|
||||||
|
body.ResponseFormat = &responseFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
JSONSchema: &jsonSchemaFormat{Name: req.Schema.Name, Schema: req.Schema.Schema, Strict: true},
|
||||||
|
}
|
||||||
|
case JSONObject:
|
||||||
|
body.ResponseFormat = &responseFormat{Type: "json_object"}
|
||||||
|
system = appendInstruction(system, req.Schema.Schema)
|
||||||
|
case PromptOnly:
|
||||||
|
system = appendInstruction(system, req.Schema.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if system != "" {
|
||||||
|
body.Messages = append(body.Messages, chatMessage{Role: "system", Content: system})
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
body.Messages = append(body.Messages, chatMessage{Role: string(m.Role), Content: m.Content})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
raw, status, err := p.do(ctx, http.MethodPost, "/chat/completions", payload)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cr chatResponse
|
||||||
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if len(cr.Error) > 0 && string(cr.Error) != "null" {
|
||||||
|
// OpenRouter can report an upstream failure in a 2xx body.
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: %w", llm.NewProviderError(status, raw, p.apiKey))
|
||||||
|
}
|
||||||
|
if len(cr.Choices) == 0 {
|
||||||
|
return llm.Response{}, errors.New("openai: generate: response has no choices")
|
||||||
|
}
|
||||||
|
choice := cr.Choices[0]
|
||||||
|
resp := llm.Response{
|
||||||
|
Usage: llm.Usage{InputTokens: cr.Usage.PromptTokens, OutputTokens: cr.Usage.CompletionTokens},
|
||||||
|
Model: cr.Model,
|
||||||
|
FinishReason: choice.FinishReason,
|
||||||
|
}
|
||||||
|
if choice.Message.Content != nil {
|
||||||
|
resp.Text = *choice.Message.Content
|
||||||
|
}
|
||||||
|
if req.Schema == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if choice.Message.Refusal != nil && *choice.Message.Refusal != "" {
|
||||||
|
return resp, fmt.Errorf("openai: generate: model refused the structured request: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
if p.mode == PromptOnly {
|
||||||
|
resp.JSON = firstJSONObject(resp.Text)
|
||||||
|
} else if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||||
|
resp.JSON = json.RawMessage(text)
|
||||||
|
}
|
||||||
|
if resp.JSON == nil {
|
||||||
|
return resp, fmt.Errorf("openai: generate: reply holds no JSON object: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendInstruction(system string, schema json.RawMessage) string {
|
||||||
|
instruction := schemaInstruction + string(schema)
|
||||||
|
if system == "" {
|
||||||
|
return instruction
|
||||||
|
}
|
||||||
|
return system + "\n\n" + instruction
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONObject reports whether s is exactly one JSON object.
|
||||||
|
func isJSONObject(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstJSONObject returns the first complete JSON object in s, trying each
|
||||||
|
// "{" in order, or nil when there is none. Text around it, such as prose or
|
||||||
|
// a Markdown code fence, is ignored.
|
||||||
|
func firstJSONObject(s string) json.RawMessage {
|
||||||
|
for i := strings.IndexByte(s, '{'); i >= 0; {
|
||||||
|
var obj json.RawMessage
|
||||||
|
if err := json.NewDecoder(strings.NewReader(s[i:])).Decode(&obj); err == nil {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
next := strings.IndexByte(s[i+1:], '{')
|
||||||
|
if next < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1 + next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns the models {base}/models lists.
|
||||||
|
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) {
|
||||||
|
raw, _, err := p.do(ctx, http.MethodGet, "/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openai: list models: %w", err)
|
||||||
|
}
|
||||||
|
var mr modelsResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return nil, fmt.Errorf("openai: list models: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Data == nil {
|
||||||
|
return nil, errors.New("openai: list models: response has no data list")
|
||||||
|
}
|
||||||
|
models := make([]llm.Model, 0, len(mr.Data))
|
||||||
|
for i, m := range mr.Data {
|
||||||
|
if m.ID == "" {
|
||||||
|
return nil, fmt.Errorf("openai: list models: model %d has no id", i)
|
||||||
|
}
|
||||||
|
name := m.Name
|
||||||
|
if name == "" {
|
||||||
|
name = m.ID
|
||||||
|
}
|
||||||
|
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the credentials with an authenticated ListModels call.
|
||||||
|
func (p *Provider) Verify(ctx context.Context) error {
|
||||||
|
_, err := p.listModels(ctx)
|
||||||
|
return redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends one request and returns the body and status 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, int, 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, 0, errors.New("build request: request URL is invalid")
|
||||||
|
}
|
||||||
|
for name, values := range p.headers {
|
||||||
|
httpReq.Header[name] = values
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if p.apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 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, httpResp.StatusCode, p.transportError(err)
|
||||||
|
}
|
||||||
|
return nil, httpResp.StatusCode, p.statusError(httpResp, body)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, httpResp.StatusCode, p.transportError(err)
|
||||||
|
}
|
||||||
|
if len(body) > maxResponseBody {
|
||||||
|
return nil, httpResp.StatusCode, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||||
|
}
|
||||||
|
return body, httpResp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
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
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# OpenAI-compatible adapter fixtures
|
||||||
|
|
||||||
|
These are the vendor response bodies the contract tests in `openai_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 a vendor to make them. Each fixture is adapted from the response
|
||||||
|
shape in that vendor's public API documentation or open source, then
|
||||||
|
sanitized:
|
||||||
|
|
||||||
|
- IDs, timestamps, fingerprints and token counts are illustrative values in
|
||||||
|
the documented format, not values from a real account.
|
||||||
|
- Message content is 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.
|
||||||
|
|
||||||
|
The tests check the adapter against these bodies with independent
|
||||||
|
assertions on both the request sent (path, headers, `response_format`,
|
||||||
|
messages) and the response decoded. The request side does not depend on the
|
||||||
|
fixtures.
|
||||||
|
|
||||||
|
Below, *not in the public example* marks fields or values that were added
|
||||||
|
for a test case and do not come from the vendor's documented example.
|
||||||
|
|
||||||
|
## OpenAI
|
||||||
|
|
||||||
|
Sources: API reference, "The chat completion object" and "List models"
|
||||||
|
(<https://platform.openai.com/docs/api-reference/chat/object>,
|
||||||
|
<https://platform.openai.com/docs/api-reference/models/list>); the
|
||||||
|
Structured Outputs guide, for `refusal`
|
||||||
|
(<https://platform.openai.com/docs/guides/structured-outputs>); and the error
|
||||||
|
codes guide (<https://platform.openai.com/docs/guides/error-codes>).
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `openai_chat_json_schema.json` | Chat completion object with `usage` details, `service_tier`, `system_fingerprint`. | The JSON reply content. |
|
||||||
|
| `openai_chat_refusal.json` | Structured Outputs refusal: `content: null`, `refusal` set. | — |
|
||||||
|
| `openai_models.json` | `{object: list, data: [model]}`. | — |
|
||||||
|
| `openai_error_invalid_key.json` | Error object, `code: invalid_api_key`, message echoing the key. | The full key in the message; OpenAI masks most of it. Kept whole to test redaction. |
|
||||||
|
| `openai_error_server.json` | Error object, `type: server_error`. | The `Authorization: Bearer {{KEY}}` text; added to test redaction. |
|
||||||
|
|
||||||
|
## OpenRouter
|
||||||
|
|
||||||
|
Sources: API reference, chat completion response and errors
|
||||||
|
(<https://openrouter.ai/docs/api-reference/overview>,
|
||||||
|
<https://openrouter.ai/docs/api-reference/errors>), and "List available
|
||||||
|
models" (<https://openrouter.ai/docs/api-reference/list-available-models>).
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `openrouter_chat_json_object.json` | Chat completion with `provider`, `native_finish_reason` and `message.reasoning`. | The JSON reply content. |
|
||||||
|
| `openrouter_chat_upstream_error.json` | An `error` object (`code`, `message`, `metadata.provider_name`, `metadata.raw`) in an HTTP 200 body, which OpenRouter documents for upstream failures. | `user_id`. |
|
||||||
|
| `openrouter_error_rate_limited.json` | Error object, `code: 429`, rate-limit `metadata`. | The `Key {{KEY}}` text; added to test redaction. |
|
||||||
|
| `openrouter_models.json` | `{data: [model]}` with `name`, `context_length`, `architecture`, `pricing`, `supported_parameters`. | The second model is a minimal entry, to test optional fields. |
|
||||||
|
|
||||||
|
## Ollama
|
||||||
|
|
||||||
|
Ollama's OpenAI compatibility page
|
||||||
|
(<https://github.com/ollama/ollama/blob/main/docs/openai.md>) documents
|
||||||
|
requests but shows no response bodies. The shapes come from its `openai`
|
||||||
|
middleware source (<https://github.com/ollama/ollama/blob/main/openai/openai.go>):
|
||||||
|
`chatcmpl-<n>` IDs, `system_fingerprint: fp_ollama`, `owned_by: library` on
|
||||||
|
models, and errors as `{error: {message, type: api_error, param, code}}`.
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ollama_chat_text.json` | Plain chat completion. | — |
|
||||||
|
| `ollama_chat_prompt_only.json` | Chat completion. | The reply text: prose and stray braces around a fenced JSON object, to test prompt-only extraction. |
|
||||||
|
| `ollama_chat_truncated.json` | Chat completion with `finish_reason: length`. | The cut-off JSON reply. |
|
||||||
|
| `ollama_models.json` | `{object: list, data: [model]}`. | — |
|
||||||
|
| `ollama_error_not_found.json` | Middleware error object for a missing model. | — |
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-417",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570322,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Sure! Here is a dinner {idea} for you:\n```json\n{\"dinners\": [{\"name\": \"Crock pot chili\", \"why\": \"A Quick dinner like your {Regular} stew.\"}]}\n```\nEnjoy {\"not\": \"this one\"}!"
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 512, "completion_tokens": 58, "total_tokens": 570}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-419",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570340,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "message": {"role": "assistant", "content": "Try a lemon orzo bake tonight."}, "finish_reason": "stop"}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 40, "completion_tokens": 9, "total_tokens": 49}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-418",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570330,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "{\"dinners\": [{\"name\": \"Crock pot ch"},
|
||||||
|
"finish_reason": "length"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 512, "completion_tokens": 16, "total_tokens": 528}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{"id": "llama3.2:latest", "object": "model", "created": 1741000000, "owned_by": "library"},
|
||||||
|
{"id": "qwen2.5:7b", "object": "model", "created": 1740000000, "owned_by": "library"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeG",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570283,
|
||||||
|
"model": "gpt-4o-2024-08-06",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\"dinners\":[{\"name\":\"Sheet-pan gnocchi\",\"why\":\"Like your Regular tray bake, but quicker.\"}]}",
|
||||||
|
"refusal": null,
|
||||||
|
"annotations": []
|
||||||
|
},
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 1117,
|
||||||
|
"completion_tokens": 46,
|
||||||
|
"total_tokens": 1163,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
|
||||||
|
"completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
|
||||||
|
},
|
||||||
|
"service_tier": "default",
|
||||||
|
"system_fingerprint": "fp_fc9f1d7035"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeH",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570290,
|
||||||
|
"model": "gpt-4o-2024-08-06",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": null, "refusal": "I'm sorry, I can't help with that request.", "annotations": []},
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 210, "completion_tokens": 11, "total_tokens": 221},
|
||||||
|
"system_fingerprint": "fp_fc9f1d7035"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Incorrect API key provided: {{KEY}}. You can find your API key at https://platform.openai.com/account/api-keys.",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"param": null,
|
||||||
|
"code": "invalid_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{"id": "gpt-4o-2024-08-06", "object": "model", "created": 1722814719, "owned_by": "system"},
|
||||||
|
{"id": "gpt-4o-mini", "object": "model", "created": 1721172741, "owned_by": "system"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "gen-1741570300-xQmT9vWb4kR2nP7sL3aE",
|
||||||
|
"provider": "OpenAI",
|
||||||
|
"model": "openai/gpt-4o-mini",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570300,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"native_finish_reason": "stop",
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\n \"dinners\": [{\"name\": \"Chicken tinga tacos\", \"why\": \"Close to your taco-night Regular.\"}]\n}",
|
||||||
|
"refusal": null,
|
||||||
|
"reasoning": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 842, "completion_tokens": 31, "total_tokens": 873}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
|
||||||
|
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
|
||||||
|
"user_id": "user_2abcDEF"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"code": 429, "message": "Rate limit exceeded: free-models-per-min. Key {{KEY}}", "metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0"}}}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "openai/gpt-4o-mini",
|
||||||
|
"canonical_slug": "openai/gpt-4o-mini",
|
||||||
|
"name": "OpenAI: GPT-4o-mini",
|
||||||
|
"created": 1721260800,
|
||||||
|
"description": "GPT-4o mini is OpenAI's newest model after GPT-4 Omni.",
|
||||||
|
"context_length": 128000,
|
||||||
|
"architecture": {"modality": "text+image->text", "input_modalities": ["text", "image"], "output_modalities": ["text"], "tokenizer": "GPT"},
|
||||||
|
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
|
||||||
|
"supported_parameters": ["max_tokens", "temperature", "response_format", "structured_outputs"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anthropic/claude-sonnet-5",
|
||||||
|
"name": "Anthropic: Claude Sonnet 5",
|
||||||
|
"created": 1767225600,
|
||||||
|
"context_length": 200000,
|
||||||
|
"supported_parameters": ["max_tokens", "temperature"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+64
-13
@@ -68,6 +68,14 @@ var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
|||||||
// ErrNotFound is returned by Get for an unknown job id.
|
// ErrNotFound is returned by Get for an unknown job id.
|
||||||
var ErrNotFound = errors.New("jobs: job not found")
|
var ErrNotFound = errors.New("jobs: job not found")
|
||||||
|
|
||||||
|
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||||
|
// registered handler.
|
||||||
|
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||||
|
|
||||||
|
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||||
|
// not decode into the kind's payload type.
|
||||||
|
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||||
|
|
||||||
// Failure is a handler error reporting a failure code the household may
|
// Failure is a handler error reporting a failure code the household may
|
||||||
// see. Code must be one the job's kind declared at registration; the runner
|
// see. Code must be one the job's kind declared at registration; the runner
|
||||||
// stores it as the job's error. Any other handler error, including a Failure
|
// stores it as the job's error. Any other handler error, including a Failure
|
||||||
@@ -106,6 +114,13 @@ type Job struct {
|
|||||||
// never quotes an error.
|
// never quotes an error.
|
||||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||||
|
|
||||||
|
// handler is a registered kind: run runs a job, and check reports whether
|
||||||
|
// a payload decodes into the kind's payload type.
|
||||||
|
type handler struct {
|
||||||
|
run runFunc
|
||||||
|
check func(payload []byte) bool
|
||||||
|
}
|
||||||
|
|
||||||
// Runner owns the job handlers and the worker pool.
|
// Runner owns the job handlers and the worker pool.
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
st *store.Store
|
st *store.Store
|
||||||
@@ -118,7 +133,7 @@ type Runner struct {
|
|||||||
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
handlers map[string]runFunc
|
handlers map[string]handler
|
||||||
started bool
|
started bool
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -141,7 +156,7 @@ func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, erro
|
|||||||
workers: workers,
|
workers: workers,
|
||||||
wake: make(chan struct{}, workers),
|
wake: make(chan struct{}, workers),
|
||||||
claim: db.New(st.Writer).ClaimNextJob,
|
claim: db.New(st.Writer).ClaimNextJob,
|
||||||
handlers: make(map[string]runFunc),
|
handlers: make(map[string]handler),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +194,9 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC
|
|||||||
if _, ok := r.handlers[kind]; ok {
|
if _, ok := r.handlers[kind]; ok {
|
||||||
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||||
}
|
}
|
||||||
r.handlers[kind] = func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||||
var p P
|
p, err := decodePayload[P]([]byte(payload))
|
||||||
dec := json.NewDecoder(bytes.NewReader([]byte(payload)))
|
if err != nil {
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&p); err != nil {
|
|
||||||
return nil, CodeInternal, "payload does not decode"
|
return nil, CodeInternal, "payload does not decode"
|
||||||
}
|
}
|
||||||
result, err := h(ctx, p, func(partial R) error {
|
result, err := h(ctx, p, func(partial R) error {
|
||||||
@@ -209,9 +222,23 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC
|
|||||||
}
|
}
|
||||||
return data, "", ""
|
return data, "", ""
|
||||||
}
|
}
|
||||||
|
check := func(payload []byte) bool {
|
||||||
|
_, err := decodePayload[P](payload)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
r.handlers[kind] = handler{run: run, check: check}
|
||||||
return &Kind[P]{r: r, name: kind}, nil
|
return &Kind[P]{r: r, name: kind}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodePayload decodes payload into P, refusing unknown fields.
|
||||||
|
func decodePayload[P any](payload []byte) (P, error) {
|
||||||
|
var p P
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
err := dec.Decode(&p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||||
// value. Its error never quotes PublicJSON's error.
|
// value. Its error never quotes PublicJSON's error.
|
||||||
func publicJSON(v Public) (json.RawMessage, error) {
|
func publicJSON(v Public) (json.RawMessage, error) {
|
||||||
@@ -229,19 +256,43 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||||
}
|
}
|
||||||
|
return k.r.enqueue(ctx, k.name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||||
|
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||||
|
// ErrUnknownKind for a kind with no registered handler and
|
||||||
|
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||||
|
// payload type; neither stores a job.
|
||||||
|
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
h, ok := r.handlers[kind]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return "", ErrUnknownKind
|
||||||
|
}
|
||||||
|
if !h.check(payload) {
|
||||||
|
return "", ErrInvalidPayload
|
||||||
|
}
|
||||||
|
return r.enqueue(ctx, kind, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||||
|
// worker.
|
||||||
|
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||||
id := store.NewID()
|
id := store.NewID()
|
||||||
now := store.Now().String()
|
now := store.Now().String()
|
||||||
if err := db.New(k.r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||||
ID: id,
|
ID: id,
|
||||||
Kind: k.name,
|
Kind: kind,
|
||||||
Payload: string(data),
|
Payload: string(payload),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return "", fmt.Errorf("jobs: enqueue %s: %w", k.name, err)
|
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case k.r.wake <- struct{}{}:
|
case r.wake <- struct{}{}:
|
||||||
default: // every worker already has a token and will look again
|
default: // every worker already has a token and will look again
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
@@ -365,7 +416,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, code, reason := h(ctx, job.Payload, publish)
|
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||||
if code != "" {
|
if code != "" {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||||
|
|||||||
@@ -568,3 +568,39 @@ func TestClaimErrorIsRetried(t *testing.T) {
|
|||||||
t.Fatalf("claim error text logged: %s", out)
|
t.Fatalf("claim error text logged: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||||
|
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||||
|
}
|
||||||
|
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||||
|
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||||
|
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||||
|
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -10,6 +14,80 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxJobBody bounds a POST /jobs request body.
|
||||||
|
const maxJobBody = 64 << 10
|
||||||
|
|
||||||
|
// jobRequest is the POST /jobs body. Payload is required; a job kind that
|
||||||
|
// takes no input is sent the empty object {}.
|
||||||
|
type jobRequest struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobCreated is the POST /jobs response.
|
||||||
|
type jobCreated struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||||
|
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||||
|
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, jobs.ErrUnknownKind):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||||
|
return
|
||||||
|
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJobRequest reads a single JSON object with a non-empty kind and a
|
||||||
|
// payload, refusing unknown fields, an omitted or null payload and trailing
|
||||||
|
// data.
|
||||||
|
func decodeJobRequest(body io.Reader) (jobRequest, error) {
|
||||||
|
data, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||||
|
}
|
||||||
|
return jobRequest{}, errors.New("cannot read request body")
|
||||||
|
}
|
||||||
|
var req jobRequest
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err != io.EOF {
|
||||||
|
return jobRequest{}, errors.New("request body must be a single JSON object")
|
||||||
|
}
|
||||||
|
if req.Kind == "" {
|
||||||
|
return jobRequest{}, errors.New("kind: required")
|
||||||
|
}
|
||||||
|
if req.Payload == nil {
|
||||||
|
return jobRequest{}, errors.New("payload: required")
|
||||||
|
}
|
||||||
|
if bytes.Equal(req.Payload, []byte("null")) {
|
||||||
|
return jobRequest{}, errors.New("payload: must not be null")
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||||
// result and error.
|
// result and error.
|
||||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -151,3 +152,95 @@ func TestGetJobRequiresSession(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want 401", rec.Code)
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPostJobThenGetProgressAndResult drives the whole flow over the
|
||||||
|
// router: POST enqueues a progressive "draft" job, GET reads its partial
|
||||||
|
// result while the handler is held mid-run, then its final result.
|
||||||
|
func TestPostJobThenGetProgressAndResult(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
release := make(chan struct{})
|
||||||
|
runner, _ := startedRunner(t, release, nil)
|
||||||
|
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||||
|
|
||||||
|
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
|
||||||
|
if rec.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
id, _ := body["id"].(string)
|
||||||
|
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
|
||||||
|
t.Fatalf("body = %v, want only a job id", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||||
|
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||||
|
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||||
|
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
|
||||||
|
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostJobRefusesBadRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
runner, _ := startedRunner(t, nil, nil)
|
||||||
|
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, code, message string
|
||||||
|
}{
|
||||||
|
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
|
||||||
|
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
|
||||||
|
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
|
||||||
|
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
|
||||||
|
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
|
||||||
|
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
|
||||||
|
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||||
|
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||||
|
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||||
|
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
|
||||||
|
t.Errorf("body = %+v, want %+v", body, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
router := newTestRouter(t, "dev")
|
||||||
|
const body = `{"kind":"noop","payload":{}}`
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
|
||||||
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||||
|
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, header := range []string{"", "wrong-token"} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
|
||||||
|
req.AddCookie(validSessionCookie)
|
||||||
|
if header != "" {
|
||||||
|
req.Header.Set(CSRFHeader, header)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||||
|
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type RouteMounter interface {
|
|||||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||||
// /media; settings serves /api/v1/settings/household; runner serves
|
// /media; settings serves /api/v1/settings/household; runner serves
|
||||||
// /api/v1/jobs/{id}; assets is the web build
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// served for every other GET. /health, /auth and the web build are public.
|
||||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||||
@@ -68,6 +68,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Get("/me", me(logger))
|
apiV1.Get("/me", me(logger))
|
||||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||||
|
apiV1.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
|
|||||||
Reference in New Issue
Block a user