Anthropic Messages adapter #40
@@ -0,0 +1,534 @@
|
||||
// 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"
|
||||
"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: &http.Client{},
|
||||
}, 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.Atoi(value); err == nil {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
detail string
|
||||
kind error
|
||||
}
|
||||
|
||||
func (e *transportFailure) Error() string {
|
||||
if e.kind == llm.ErrTimeout {
|
||||
return fmt.Sprintf("%s: transport: %s", llm.ErrTimeout, e.detail)
|
||||
}
|
||||
return "transport: " + e.detail
|
||||
}
|
||||
|
||||
func (e *transportFailure) 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
|
||||
}
|
||||
return &transportFailure{detail: llm.Redact(err.Error(), 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 only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
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()
|
||||
if matched, ok := found.Interface().(error); ok && holdsKey(matched, 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: when
|
||||
// the text of err or of any error in its chain holds a non-empty key, it
|
||||
// returns a stand-in whose text, and the text of every error it unwraps
|
||||
// to, has the key redacted. Any other err is returned unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if err == nil || key == "" || !holdsKey(err, 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
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const testKey = "sk-ant-test-9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
|
||||
// recorded is one request the test server received.
|
||||
type recorded struct {
|
||||
method string
|
||||
path string
|
||||
query url.Values
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// reply is a response the test server replays.
|
||||
type reply struct {
|
||||
status int
|
||||
header map[string]string
|
||||
fixture string // testdata file; {{KEY}} is replaced with the key
|
||||
body string // used when fixture is empty
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// vendorServer replays its replies in order, repeating the last, and
|
||||
// records every request.
|
||||
type vendorServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
got []recorded
|
||||
}
|
||||
|
||||
func newVendorServer(t *testing.T, replies ...reply) *vendorServer {
|
||||
return newVendorServerWithKey(t, testKey, replies...)
|
||||
}
|
||||
|
||||
func newVendorServerWithKey(t *testing.T, key string, replies ...reply) *vendorServer {
|
||||
t.Helper()
|
||||
payloads := make([][]byte, len(replies))
|
||||
for i, r := range replies {
|
||||
payloads[i] = []byte(r.body)
|
||||
if r.fixture != "" {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payloads[i] = bytes.ReplaceAll(raw, []byte("{{KEY}}"), []byte(key))
|
||||
}
|
||||
}
|
||||
vs := &vendorServer{}
|
||||
vs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
vs.mu.Lock()
|
||||
n := len(vs.got)
|
||||
vs.got = append(vs.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
|
||||
vs.mu.Unlock()
|
||||
i := min(n, len(replies)-1)
|
||||
r := replies[i]
|
||||
if r.delay > 0 {
|
||||
select {
|
||||
case <-time.After(r.delay):
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
for k, v := range r.header {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(r.status)
|
||||
w.Write(payloads[i])
|
||||
}))
|
||||
t.Cleanup(vs.Close)
|
||||
return vs
|
||||
}
|
||||
|
||||
func (vs *vendorServer) requests() []recorded {
|
||||
vs.mu.Lock()
|
||||
defer vs.mu.Unlock()
|
||||
return append([]recorded(nil), vs.got...)
|
||||
}
|
||||
|
||||
func (vs *vendorServer) only(t *testing.T) recorded {
|
||||
t.Helper()
|
||||
got := vs.requests()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("server got %d requests, want 1", len(got))
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, base string) *Provider {
|
||||
return newProviderWithKey(t, base, testKey)
|
||||
}
|
||||
|
||||
func newProviderWithKey(t *testing.T, base, key string) *Provider {
|
||||
t.Helper()
|
||||
p, err := New(Config{BaseURL: base, APIKey: key, Model: "test-model", MaxTokens: 1024})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
|
||||
|
||||
func schemaRequest() llm.Request {
|
||||
temp := 0.0
|
||||
return llm.Request{
|
||||
System: "You plan dinners for Our kitchen.",
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "Suggest a Side quest."},
|
||||
{Role: llm.RoleAssistant, Content: "Something quick?"},
|
||||
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
|
||||
},
|
||||
Temperature: &temp,
|
||||
MaxTokens: 400,
|
||||
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
|
||||
}
|
||||
}
|
||||
|
||||
func textRequest() llm.Request {
|
||||
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
|
||||
}
|
||||
|
||||
// sentBody decodes a request body into a generic map.
|
||||
func sentBody(t *testing.T, r recorded) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r.body, &m); err != nil {
|
||||
t.Fatalf("request body %q: %v", r.body, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// canonical re-encodes JSON so two encodings of one value compare equal.
|
||||
func canonical(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("JSON %q: %v", raw, err)
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func assertHeaders(t *testing.T, r recorded, key string) {
|
||||
t.Helper()
|
||||
if got := r.header.Values("X-Api-Key"); len(got) != 1 || got[0] != key {
|
||||
t.Errorf("x-api-key = %q", got)
|
||||
}
|
||||
if got := r.header.Values("Anthropic-Version"); len(got) != 1 || got[0] != "2023-06-01" {
|
||||
t.Errorf("anthropic-version = %q", got)
|
||||
}
|
||||
if got := r.header.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToolForcedJSON(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json"})
|
||||
p := newProvider(t, vs.URL+"/")
|
||||
|
||||
resp, err := p.Generate(context.Background(), schemaRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := canonical(t, resp.JSON), `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`; got != want {
|
||||
t.Errorf("Response.JSON = %s, want %s", got, want)
|
||||
}
|
||||
if resp.Text != "" {
|
||||
t.Errorf("Text = %q, want none", resp.Text)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 472, OutputTokens: 61}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "tool_use" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := vs.only(t)
|
||||
if got.method != http.MethodPost || got.path != "/v1/messages" || len(got.query) != 0 {
|
||||
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
if h := got.header.Get("Content-Type"); h != "application/json" {
|
||||
t.Errorf("Content-Type = %q", h)
|
||||
}
|
||||
body := sentBody(t, got)
|
||||
if body["model"] != "test-model" || body["temperature"] != 0.0 || body["max_tokens"] != 400.0 {
|
||||
t.Errorf("model, temperature, max_tokens = %v, %v, %v", body["model"], body["temperature"], body["max_tokens"])
|
||||
}
|
||||
if body["system"] != "You plan dinners for Our kitchen." {
|
||||
t.Errorf("system = %v", body["system"])
|
||||
}
|
||||
msgs := body["messages"].([]any)
|
||||
wantMsgs := []string{"user:Suggest a Side quest.", "assistant:Something quick?", "user:Yes, a Quick dinner."}
|
||||
if len(msgs) != len(wantMsgs) {
|
||||
t.Fatalf("messages = %v", msgs)
|
||||
}
|
||||
for i, m := range msgs {
|
||||
mm := m.(map[string]any)
|
||||
if got := fmt.Sprintf("%v:%v", mm["role"], mm["content"]); got != wantMsgs[i] {
|
||||
t.Errorf("message %d = %s, want %s", i, got, wantMsgs[i])
|
||||
}
|
||||
}
|
||||
tools := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools = %v", tools)
|
||||
}
|
||||
tool := tools[0].(map[string]any)
|
||||
schema, _ := json.Marshal(tool["input_schema"])
|
||||
if tool["name"] != "side_quests" || string(schema) != canonical(t, dinnersSchema) {
|
||||
t.Errorf("tool = %v", tool)
|
||||
}
|
||||
choice := body["tool_choice"].(map[string]any)
|
||||
if len(choice) != 2 || choice["type"] != "tool" || choice["name"] != "side_quests" {
|
||||
t.Errorf("tool_choice = %v", choice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateText(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProvider(t, vs.URL)
|
||||
|
||||
resp, err := p.Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
|
||||
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
|
||||
}
|
||||
if resp.Usage != (llm.Usage{InputTokens: 2095, OutputTokens: 503}) || resp.Model != "claude-sonnet-5" || resp.FinishReason != "end_turn" {
|
||||
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||
}
|
||||
|
||||
got := vs.only(t)
|
||||
if got.path != "/v1/messages" {
|
||||
t.Errorf("path = %s", got.path)
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
body := sentBody(t, got)
|
||||
if body["max_tokens"] != 1024.0 {
|
||||
t.Errorf("max_tokens = %v, want the configured 1024", body["max_tokens"])
|
||||
}
|
||||
for _, field := range []string{"system", "temperature", "tools", "tool_choice"} {
|
||||
if v, ok := body[field]; ok {
|
||||
t.Errorf("%s = %v, want it omitted", field, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextKeepsStopReason(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.FinishReason != "max_tokens" || resp.Usage.OutputTokens != 400 {
|
||||
t.Errorf("FinishReason, Usage = %q, %+v", resp.FinishReason, resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBadOutput(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"text instead of tool": `{"type":"message","model":"m","content":[{"type":"text","text":"Sure!"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`,
|
||||
"other tool": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"other","input":{"a":1}}],"stop_reason":"tool_use","usage":{}}`,
|
||||
"input not an object": `{"type":"message","model":"m","content":[{"type":"tool_use","id":"t","name":"side_quests","input":[1]}],"stop_reason":"tool_use","usage":{}}`,
|
||||
"no content": `{"type":"message","model":"m","content":[],"stop_reason":"max_tokens","usage":{}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, body: body})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want ErrBadOutput", err)
|
||||
}
|
||||
if resp.JSON != nil || resp.Model != "m" {
|
||||
t.Fatalf("Response = %+v, want no JSON and the model kept", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_max_tokens.json"})
|
||||
resp, err := newProvider(t, vs.URL).Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrBadOutput) || resp.FinishReason != "max_tokens" {
|
||||
t.Fatalf("truncated: resp, err = %+v, %v", resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsNonMessage(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "error_overloaded.json"})
|
||||
_, err := newProvider(t, vs.URL).Generate(context.Background(), textRequest())
|
||||
if err == nil || errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want an untyped malformed-response error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProvider(t, vs.URL)
|
||||
if _, err := p.Generate(context.Background(), llm.Request{}); err == nil {
|
||||
t.Error("Generate accepted a request with no messages")
|
||||
}
|
||||
for _, name := range []string{"side quests", "", strings.Repeat("a", 65), "plan.v2"} {
|
||||
req := schemaRequest()
|
||||
req.Schema.Name = name
|
||||
if _, err := p.Generate(context.Background(), req); err == nil {
|
||||
t.Errorf("Generate accepted tool name %q", name)
|
||||
}
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: vs.URL, APIKey: testKey, MaxTokens: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
|
||||
t.Error("Generate succeeded with no model configured")
|
||||
}
|
||||
if n := len(vs.requests()); n != 0 {
|
||||
t.Errorf("invalid requests reached the vendor %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsFollowsPages(t *testing.T) {
|
||||
vs := newVendorServer(t,
|
||||
reply{status: 200, fixture: "models_page1.json"},
|
||||
reply{status: 200, fixture: "models_page2.json"},
|
||||
)
|
||||
models, err := newProvider(t, vs.URL).ListModels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []llm.Model{
|
||||
{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
|
||||
{ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
|
||||
{ID: "claude-opus-5-5", DisplayName: "claude-opus-5-5"},
|
||||
}
|
||||
if len(models) != len(want) {
|
||||
t.Fatalf("models = %+v", models)
|
||||
}
|
||||
for i := range want {
|
||||
if models[i] != want[i] {
|
||||
t.Errorf("model %d = %+v, want %+v", i, models[i], want[i])
|
||||
}
|
||||
}
|
||||
got := vs.requests()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("server got %d requests, want 2", len(got))
|
||||
}
|
||||
for i, wantAfter := range []string{"", "claude-sonnet-5"} {
|
||||
r := got[i]
|
||||
if r.method != http.MethodGet || r.path != "/v1/models" || r.query.Get("limit") != "1000" || r.query.Get("after_id") != wantAfter {
|
||||
t.Errorf("request %d = %s %s?%s", i, r.method, r.path, r.query.Encode())
|
||||
}
|
||||
assertHeaders(t, r, testKey)
|
||||
if len(r.body) != 0 || r.header.Get("Content-Type") != "" {
|
||||
t.Errorf("request %d sent a body", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsRejectsBadPages(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no data": `{"has_more":false}`,
|
||||
"no id": `{"data":[{"type":"model","display_name":"x"}],"has_more":false}`,
|
||||
"no cursor": `{"data":[{"id":"a"}],"has_more":true}`,
|
||||
"stuck cursor": `{"data":[{"id":"a"}],"has_more":true,"last_id":"a"}`,
|
||||
"invalid": `{"data":`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
vs := newVendorServer(t, reply{status: 200, body: body})
|
||||
if models, err := newProvider(t, vs.URL).ListModels(context.Background()); err == nil {
|
||||
t.Errorf("%s: ListModels() = %+v, nil error", name, models)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModelsEmpty(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, body: `{"data":[],"has_more":false}`})
|
||||
models, err := newProvider(t, vs.URL).ListModels(context.Background())
|
||||
if err != nil || models == nil || len(models) != 0 {
|
||||
t.Fatalf("ListModels() = %#v, %v", models, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
ok := newVendorServer(t, reply{status: 200, fixture: "models_page1.json"})
|
||||
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
|
||||
t.Fatalf("Verify() = %v", err)
|
||||
}
|
||||
got := ok.only(t)
|
||||
if got.method != http.MethodGet || got.path != "/v1/models" || got.query.Get("limit") != "1" || got.query.Has("after_id") {
|
||||
t.Errorf("Verify called %s %s?%s", got.method, got.path, got.query.Encode())
|
||||
}
|
||||
assertHeaders(t, got, testKey)
|
||||
|
||||
for status, want := range map[int]error{401: llm.ErrAuth, 403: llm.ErrAuth, 529: llm.ErrRateLimited, 500: llm.ErrProvider} {
|
||||
bad := newVendorServer(t, reply{status: status, fixture: "error_api.json"})
|
||||
err := newProvider(t, bad.URL).Verify(context.Background())
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("HTTP %d: Verify() = %v, want %v", status, err, want)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
}
|
||||
malformed := newVendorServer(t, reply{status: 200, body: `{}`})
|
||||
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
|
||||
t.Error("Verify accepted a body with no data list")
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecret fails if err's text holds the key or a prefix of it.
|
||||
func assertNoSecret(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{testKey, testKey[:16]} {
|
||||
if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(secret)) {
|
||||
t.Fatalf("error leaks %q: %v", secret, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMapping(t *testing.T) {
|
||||
rateLimited := func(want time.Duration) func(*testing.T, error) {
|
||||
return func(t *testing.T, err error) {
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.As(err, &rl) || rl.RetryAfter != want {
|
||||
t.Fatalf("RetryAfter from %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
providerError := func(status int, excerpt string) func(*testing.T, error) {
|
||||
return func(t *testing.T, err error) {
|
||||
var pe *llm.ProviderError
|
||||
if !errors.As(err, &pe) || pe.Status() != status || !strings.Contains(pe.Body(), excerpt) {
|
||||
t.Fatalf("ProviderError from %v, want HTTP %d with %q", err, status, excerpt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), pe.Body()) {
|
||||
t.Fatalf("Error() = %q, want it to carry the excerpt", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
reply reply
|
||||
want error
|
||||
check func(*testing.T, error)
|
||||
}{
|
||||
{name: "401", reply: reply{status: 401, fixture: "error_authentication.json"}, want: llm.ErrAuth},
|
||||
{name: "403", reply: reply{status: 403, fixture: "error_permission.json"}, want: llm.ErrAuth},
|
||||
{name: "429 seconds", reply: reply{status: 429, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(17 * time.Second)},
|
||||
{name: "429 no Retry-After", reply: reply{status: 429, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
|
||||
{name: "429 HTTP-date", reply: reply{status: 429, header: map[string]string{"Retry-After": time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)}, fixture: "error_rate_limit.json"}, want: llm.ErrRateLimited,
|
||||
check: func(t *testing.T, err error) {
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.As(err, &rl) || rl.RetryAfter < 85*time.Second || rl.RetryAfter > 91*time.Second {
|
||||
t.Fatalf("RetryAfter from %v", err)
|
||||
}
|
||||
}},
|
||||
{name: "529 seconds", reply: reply{status: 529, header: map[string]string{"Retry-After": "4"}, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(4 * time.Second)},
|
||||
{name: "529 no Retry-After", reply: reply{status: 529, fixture: "error_overloaded.json"}, want: llm.ErrRateLimited, check: rateLimited(0)},
|
||||
{name: "408", reply: reply{status: 408}, want: llm.ErrTimeout},
|
||||
{name: "400", reply: reply{status: 400, fixture: "error_invalid_request.json"}, want: llm.ErrProvider, check: providerError(400, "max_tokens: Field required")},
|
||||
{name: "404", reply: reply{status: 404, fixture: "error_not_found.json"}, want: llm.ErrProvider, check: providerError(404, "not_found_error")},
|
||||
{name: "500 echoing the key", reply: reply{status: 500, fixture: "error_api.json"}, want: llm.ErrProvider, check: providerError(500, "x-api-key: [REDACTED]")},
|
||||
{name: "503", reply: reply{status: 503, body: "upstream connect error"}, want: llm.ErrProvider, check: providerError(503, "upstream connect error")},
|
||||
}
|
||||
all := []error{llm.ErrAuth, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrBadOutput, llm.ErrProvider}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
vs := newVendorServer(t, c.reply)
|
||||
p := newProvider(t, vs.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
for _, err := range []error{genErr, listErr} {
|
||||
for _, sentinel := range all {
|
||||
if errors.Is(err, sentinel) != (sentinel == c.want) {
|
||||
t.Fatalf("errors.Is(%v, %v) = %v", err, sentinel, sentinel != c.want)
|
||||
}
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
if c.check != nil {
|
||||
c.check(t, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeouts(t *testing.T) {
|
||||
vs := newVendorServer(t, reply{status: 200, fixture: "messages_tool_use.json", delay: 5 * time.Second})
|
||||
p := newProvider(t, vs.URL)
|
||||
|
||||
req := schemaRequest()
|
||||
req.Timeout = 50 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("Request.Timeout: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := p.ListModels(ctx); !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("context deadline: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := p.Verify(ctx); !errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("Verify context deadline: err = %v, want ErrTimeout", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := p.Generate(ctx, schemaRequest()); !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) {
|
||||
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportErrorWithKeyInBaseURLPath fails the exchange against a base
|
||||
// URL whose path holds the key, which New accepts; net/http names the full
|
||||
// request URL in its transport errors.
|
||||
func TestTransportErrorWithKeyInBaseURLPath(t *testing.T) {
|
||||
refused := newVendorServer(t, reply{status: 200})
|
||||
refusedBase := refused.URL
|
||||
refused.Close()
|
||||
|
||||
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}))
|
||||
t.Cleanup(dropped.Close)
|
||||
|
||||
for name, base := range map[string]string{
|
||||
"connection refused": refusedBase + "/" + testKey,
|
||||
"connection dropped": dropped.URL + "/" + testKey,
|
||||
} {
|
||||
p := newProvider(t, base)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: nil error", name)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
t.Fatalf("%s: error chain exposes the request URL: %v", name, ue)
|
||||
}
|
||||
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrProvider) {
|
||||
t.Fatalf("%s: err = %v, want an untyped transport failure", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The request URL escapes a key with spaces, so redacting the key
|
||||
// itself would miss that copy.
|
||||
const spacedKey = "sk ant key"
|
||||
p := newProviderWithKey(t, refusedBase+"/"+spacedKey, spacedKey)
|
||||
_, genErr := p.Generate(context.Background(), textRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatal("escaped key: nil error")
|
||||
}
|
||||
assertChainNoExactKey(t, err, spacedKey)
|
||||
assertChainNoExactKey(t, err, url.PathEscape(spacedKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadConfig(t *testing.T) {
|
||||
cases := map[string]Config{
|
||||
"empty base": {},
|
||||
"relative base": {BaseURL: "api.anthropic.com"},
|
||||
"ftp base": {BaseURL: "ftp://example.com"},
|
||||
"credentials in URL": {BaseURL: "https://user:" + testKey + "@example.com"},
|
||||
"key in query": {BaseURL: "https://example.com/?key=" + testKey},
|
||||
"key in fragment": {BaseURL: "https://example.com/#" + testKey},
|
||||
"key, relative base": {BaseURL: "example.com?key=" + testKey},
|
||||
"key, ftp base": {BaseURL: "ftp://example.com/?key=" + testKey},
|
||||
"key, unparseable": {BaseURL: "https://example.com/%zz?key=" + testKey},
|
||||
"empty query": {BaseURL: "https://example.com?"},
|
||||
"empty fragment": {BaseURL: "https://example.com#"},
|
||||
"empty both": {BaseURL: "https://example.com/?#"},
|
||||
"empty key": {BaseURL: "https://example.com", APIKey: "-"},
|
||||
"key with newline": {BaseURL: "https://example.com", APIKey: testKey + "\n"},
|
||||
"zero max tokens": {BaseURL: "https://example.com", MaxTokens: -1},
|
||||
"negative max": {BaseURL: "https://example.com", MaxTokens: -5},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
switch cfg.APIKey {
|
||||
case "":
|
||||
cfg.APIKey = testKey
|
||||
case "-":
|
||||
cfg.APIKey = ""
|
||||
}
|
||||
switch cfg.MaxTokens {
|
||||
case 0:
|
||||
cfg.MaxTokens = 1024
|
||||
case -1:
|
||||
cfg.MaxTokens = 0
|
||||
}
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Errorf("%s: New() = nil error", name)
|
||||
continue
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
}
|
||||
if _, err := New(Config{BaseURL: "http://anthropic:8080/proxy/", APIKey: testKey, MaxTokens: 1}); err != nil {
|
||||
t.Errorf("New() rejected a valid config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
cases := map[string]time.Duration{
|
||||
"": 0,
|
||||
"30": 30 * time.Second,
|
||||
" 5 ": 5 * time.Second,
|
||||
"-3": 0,
|
||||
"soon": 0,
|
||||
"Sat, 03 Oct 2026 12:02:00 GMT": 2 * time.Minute,
|
||||
"Sat, 03 Oct 2026 11:59:00 GMT": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := retryAfter(in, now); got != want {
|
||||
t.Errorf("retryAfter(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertChainNoExactKey fails if key appears, case-sensitively, in the text
|
||||
// of err, of any error reached from it by unwrapping, or of a typed error
|
||||
// errors.As finds in it, including a ProviderError's Body.
|
||||
func assertChainNoExactKey(t *testing.T, err error, key string) {
|
||||
t.Helper()
|
||||
var walk func(error)
|
||||
walk = func(e error) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if strings.Contains(e.Error(), key) {
|
||||
t.Fatalf("layer leaks %q: %q", key, e.Error())
|
||||
}
|
||||
switch u := e.(type) {
|
||||
case interface{ Unwrap() error }:
|
||||
walk(u.Unwrap())
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, c := range u.Unwrap() {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err)
|
||||
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
|
||||
if strings.Contains(layer.Error(), key) {
|
||||
t.Fatalf("errors.Unwrap layer leaks %q: %q", key, layer.Error())
|
||||
}
|
||||
}
|
||||
var pe *llm.ProviderError
|
||||
if errors.As(err, &pe) && (strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key)) {
|
||||
t.Fatalf("ProviderError leaks %q: Error %q, Body %q", key, pe.Error(), pe.Body())
|
||||
}
|
||||
var rl *llm.RateLimitedError
|
||||
if errors.As(err, &rl) && strings.Contains(rl.Error(), key) {
|
||||
t.Fatalf("RateLimitedError leaks %q: %q", key, rl.Error())
|
||||
}
|
||||
var syntax *json.SyntaxError
|
||||
if errors.As(err, &syntax) && strings.Contains(syntax.Error(), key) {
|
||||
t.Fatalf("errors.As reached a *json.SyntaxError leaking %q: %q", key, syntax.Error())
|
||||
}
|
||||
if got := fmt.Sprintf("%v %+v %#v", err, err, err); strings.Contains(got, key) {
|
||||
t.Fatalf("formatting leaks %q: %q", key, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderNameKeyNeverLeaks uses the header name "x-api-key" as the API
|
||||
// key, a word an error naming the header would print, and drives every
|
||||
// construction and request error path with it.
|
||||
func TestHeaderNameKeyNeverLeaks(t *testing.T) {
|
||||
const key = "x-api-key"
|
||||
construction := map[string]Config{
|
||||
"unparseable base": {BaseURL: "https://example.com/%zz/" + key, APIKey: key, MaxTokens: 1},
|
||||
"relative base": {BaseURL: key, APIKey: key, MaxTokens: 1},
|
||||
"query in base": {BaseURL: "https://example.com/?" + key, APIKey: key, MaxTokens: 1},
|
||||
"userinfo in base": {BaseURL: "https://" + key + "@example.com", APIKey: key, MaxTokens: 1},
|
||||
"no max tokens": {BaseURL: "https://example.com", APIKey: key},
|
||||
"key with newline": {BaseURL: "https://example.com", APIKey: key + "\r\n", MaxTokens: 1},
|
||||
}
|
||||
for name, cfg := range construction {
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: New() = nil error", name)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
body := `{"type":"error","error":{"type":"x","message":"bad x-api-key header: x-api-key"}}`
|
||||
statuses := []int{400, 401, 403, 404, 408, 429, 500, 529}
|
||||
for _, status := range statuses {
|
||||
vs := newVendorServerWithKey(t, key, reply{status: status, body: body, header: map[string]string{"Retry-After": "3"}})
|
||||
p := newProviderWithKey(t, vs.URL, key)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: nil error", status)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
ok := newVendorServerWithKey(t, key, reply{status: 200, fixture: "messages_text.json"})
|
||||
p := newProviderWithKey(t, ok.URL, key)
|
||||
requests := map[string]llm.Request{
|
||||
"unknown role": {Messages: []llm.Message{{Role: llm.Role(key), Content: "hi"}}},
|
||||
"bad tool name": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key + "!", Schema: dinnersSchema}},
|
||||
"schema array": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: json.RawMessage(`[]`)}},
|
||||
"bad output": {Messages: textRequest().Messages, Schema: &llm.JSONSchema{Name: key, Schema: dinnersSchema}},
|
||||
}
|
||||
for name, req := range requests {
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: nil error", name)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
noModel, err := New(Config{BaseURL: ok.URL, APIKey: key, MaxTokens: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = noModel.Generate(context.Background(), textRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Generate succeeded with no model")
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
|
||||
refused := newVendorServer(t, reply{status: 200})
|
||||
refusedBase := refused.URL
|
||||
refused.Close()
|
||||
_, err = newProviderWithKey(t, refusedBase+"/"+key, key).Generate(context.Background(), textRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Generate against a closed server succeeded")
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestAdapterNameKeyOn401 uses the adapter's own error prefix "anthropic" as
|
||||
// the API key.
|
||||
func TestAdapterNameKeyOn401(t *testing.T) {
|
||||
const key = "anthropic"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 401, fixture: "error_authentication.json"})
|
||||
p := newProviderWithKey(t, vs.URL, key)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if !errors.Is(err, llm.ErrAuth) {
|
||||
t.Fatalf("err = %v, want ErrAuth", err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixedWordKeyOn500 uses the fixed word "HTTP" of ProviderError's text
|
||||
// as the API key: errors.As must still reach the ProviderError, whose Body
|
||||
// and Error must drop it.
|
||||
func TestFixedWordKeyOn500(t *testing.T) {
|
||||
const key = "HTTP"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 500, body: `{"type":"error","error":{"type":"api_error","message":"HTTP backend down"}}`})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
|
||||
t.Fatalf("err = %v, want a 500 ProviderError", err)
|
||||
}
|
||||
if strings.Contains(pe.Error(), key) || strings.Contains(pe.Body(), key) {
|
||||
t.Fatalf("ProviderError leaks the key: Error %q, Body %q", pe.Error(), pe.Body())
|
||||
}
|
||||
if !strings.Contains(pe.Body(), "backend down") {
|
||||
t.Fatalf("Body() = %q, want the redacted excerpt", pe.Body())
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestRateLimitedFixedWordKey uses the fixed word "rate" of
|
||||
// RateLimitedError's text as the API key, on both rate-limit statuses.
|
||||
func TestRateLimitedFixedWordKey(t *testing.T) {
|
||||
const key = "rate"
|
||||
for _, status := range []int{429, 529} {
|
||||
vs := newVendorServerWithKey(t, key, reply{status: status, header: map[string]string{"Retry-After": "17"}, fixture: "error_rate_limit.json"})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), schemaRequest())
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
|
||||
t.Fatalf("HTTP %d: err = %v, want a RateLimitedError with RetryAfter 17s", status, err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseBodyWithKeyNeverLeaks has the vendor echo the key in error
|
||||
// and success bodies, including ones that fail to decode, and checks
|
||||
// Error(), every errors.Unwrap layer and every errors.As target.
|
||||
func TestResponseBodyWithKeyNeverLeaks(t *testing.T) {
|
||||
bodies := []reply{
|
||||
{status: 500, fixture: "error_api.json"},
|
||||
{status: 400, body: `{"type":"error","error":{"type":"invalid_request_error","message":"key ` + testKey + ` rejected"}}`},
|
||||
{status: 401, fixture: "error_authentication.json"},
|
||||
{status: 429, fixture: "error_rate_limit.json"},
|
||||
{status: 200, body: `{"type": ` + testKey + `}`},
|
||||
{status: 200, body: testKey},
|
||||
{status: 200, body: `{"type":"message","content":[{"type":"text","text":"` + testKey + `"}],"stop_reason":"` + testKey + `"}`},
|
||||
{status: 200, body: `{"data": [` + testKey + `]}`},
|
||||
}
|
||||
for i, r := range bodies {
|
||||
vs := newVendorServer(t, r)
|
||||
p := newProvider(t, vs.URL)
|
||||
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||
_, listErr := p.ListModels(context.Background())
|
||||
verifyErr := p.Verify(context.Background())
|
||||
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||
if err == nil {
|
||||
t.Fatalf("body %d: nil error", i)
|
||||
}
|
||||
assertNoSecret(t, err)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
}
|
||||
}
|
||||
// A short key that decode errors quote from a malformed body.
|
||||
const key = "Z"
|
||||
vs := newVendorServerWithKey(t, key, reply{status: 200, body: `{"type": Z}`})
|
||||
_, err := newProviderWithKey(t, vs.URL, key).Generate(context.Background(), textRequest())
|
||||
var syntax *json.SyntaxError
|
||||
if err == nil || errors.As(err, &syntax) {
|
||||
t.Fatalf("err = %v, want no reachable *json.SyntaxError quoting the key", err)
|
||||
}
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestRedactKeepsChainMatches checks that the redaction backstop keeps
|
||||
// errors.Is and errors.As working through a chain that holds the key.
|
||||
func TestRedactKeepsChainMatches(t *testing.T) {
|
||||
pe := llm.NewProviderError(500, []byte(`{"error":"down"}`), testKey)
|
||||
rl := llm.NewRateLimitedError(time.Second, testKey)
|
||||
inner := fmt.Errorf("decode %s: %w", testKey, llm.ErrBadOutput)
|
||||
joined := errors.Join(inner, pe, rl, context.Canceled, &transportFailure{detail: "slow", kind: llm.ErrTimeout}, fmt.Errorf("HTTP 401: %w", llm.ErrAuth))
|
||||
err := redact(fmt.Errorf("anthropic %s: %w", testKey, joined), testKey)
|
||||
assertChainNoExactKey(t, err, testKey)
|
||||
for _, target := range []error{llm.ErrBadOutput, llm.ErrProvider, llm.ErrRateLimited, llm.ErrTimeout, llm.ErrAuth, context.Canceled} {
|
||||
if !errors.Is(err, target) {
|
||||
t.Fatalf("errors.Is(%v, %v) = false", err, target)
|
||||
}
|
||||
}
|
||||
var gotPE *llm.ProviderError
|
||||
var gotRL *llm.RateLimitedError
|
||||
if !errors.As(err, &gotPE) || gotPE != pe || !errors.As(err, &gotRL) || gotRL != rl {
|
||||
t.Fatalf("errors.As lost a typed error in %v", err)
|
||||
}
|
||||
clean := errors.New("anthropic: generate: no model configured")
|
||||
if redact(clean, testKey) != clean {
|
||||
t.Fatal("redact wrapped an error that holds no key")
|
||||
}
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Reference in New Issue
Block a user