Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba7667949 | ||
|
|
893e00d14e | ||
|
|
68391d14c8 | ||
|
|
cceeafd200 | ||
|
|
2afd6a9338 | ||
|
|
f333440256 | ||
|
|
095c03f112 | ||
|
|
3ddc18fa0d | ||
|
|
adfd84810e | ||
|
|
80d91eba70 | ||
|
|
1f4356cd17 | ||
|
|
6d0bea10b5 |
+59
-30
@@ -26,13 +26,32 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
||||||
|
// Adapters build it with NewRateLimitedError so its text never holds their
|
||||||
|
// credential.
|
||||||
type RateLimitedError struct {
|
type RateLimitedError struct {
|
||||||
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
||||||
// did not say.
|
// did not say.
|
||||||
RetryAfter time.Duration
|
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 {
|
func (e *RateLimitedError) Error() string {
|
||||||
|
if e.text != "" {
|
||||||
|
return e.text
|
||||||
|
}
|
||||||
if e.RetryAfter == 0 {
|
if e.RetryAfter == 0 {
|
||||||
return ErrRateLimited.Error()
|
return ErrRateLimited.Error()
|
||||||
}
|
}
|
||||||
@@ -46,58 +65,68 @@ func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimit
|
|||||||
// ProviderError keeps.
|
// ProviderError keeps.
|
||||||
const MaxBodyExcerpt = 512
|
const MaxBodyExcerpt = 512
|
||||||
|
|
||||||
// redacted replaces each secret in a ProviderError body.
|
// redactionMarker replaces each secret in redacted text, unless the marker
|
||||||
const redacted = "[REDACTED]"
|
// 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
|
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
||||||
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
||||||
type ProviderError struct {
|
type ProviderError struct {
|
||||||
status int
|
status int
|
||||||
body string
|
body string
|
||||||
|
text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderError returns the error for an unexpected HTTP status.
|
// NewProviderError returns the error for an unexpected HTTP status. It
|
||||||
// secrets must hold the API key (and any other credential sent with the
|
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
||||||
// request). Every occurrence of each non-empty secret in body is replaced
|
// credential sent with the request, or "" when the request sent none; every
|
||||||
// before the body is cut to MaxBodyExcerpt bytes, so no part of a secret
|
// occurrence of a non-empty secret in body is removed by Redact before the
|
||||||
// survives. If secrets holds no non-empty value the body cannot be
|
// body is cut, so no part of it survives, and again after the cut, so the
|
||||||
// redacted, so no excerpt is kept at all.
|
// truncation mark cannot complete it. It is removed from the complete error
|
||||||
func NewProviderError(status int, body []byte, secrets []string) *ProviderError {
|
// text too, even where the fixed text happens to spell it, and the secret
|
||||||
s := string(body)
|
// itself is not kept.
|
||||||
redactedAny := false
|
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
||||||
for _, secret := range secrets {
|
s := strings.TrimSpace(Redact(string(body), secret))
|
||||||
if secret != "" {
|
|
||||||
s = strings.ReplaceAll(s, secret, redacted)
|
|
||||||
redactedAny = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !redactedAny {
|
|
||||||
return &ProviderError{status: status}
|
|
||||||
}
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if len(s) > MaxBodyExcerpt {
|
if len(s) > MaxBodyExcerpt {
|
||||||
cut := MaxBodyExcerpt
|
cut := MaxBodyExcerpt
|
||||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
cut--
|
cut--
|
||||||
}
|
}
|
||||||
s = s[:cut] + "…"
|
s = Redact(s[:cut]+"…", secret)
|
||||||
}
|
}
|
||||||
return &ProviderError{status: status, body: s}
|
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.
|
// Status returns the HTTP status code.
|
||||||
func (e *ProviderError) Status() int { return e.status }
|
func (e *ProviderError) Status() int { return e.status }
|
||||||
|
|
||||||
// Body returns the redacted, truncated response-body excerpt; it is empty
|
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||||
// when the vendor sent no body or no secret was given to redact it with.
|
// when the vendor sent no body.
|
||||||
func (e *ProviderError) Body() string { return e.body }
|
func (e *ProviderError) Body() string { return e.body }
|
||||||
|
|
||||||
func (e *ProviderError) Error() string {
|
func (e *ProviderError) Error() string { return e.text }
|
||||||
if e.body == "" {
|
|
||||||
return fmt.Sprintf("%s: HTTP %d", ErrProvider, e.status)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s: HTTP %d: %s", ErrProvider, e.status, e.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is reports whether target is ErrProvider.
|
// Is reports whether target is ErrProvider.
|
||||||
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestRateLimitedError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderError(t *testing.T) {
|
func TestProviderError(t *testing.T) {
|
||||||
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), []string{"sk-test"}))
|
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), "sk-test"))
|
||||||
if !errors.Is(err, ErrProvider) {
|
if !errors.Is(err, ErrProvider) {
|
||||||
t.Fatal("errors.Is(err, ErrProvider) = false")
|
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestProviderError(t *testing.T) {
|
|||||||
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||||
t.Fatalf("Error() = %q", got)
|
t.Fatalf("Error() = %q", got)
|
||||||
}
|
}
|
||||||
if got := NewProviderError(500, nil, []string{"sk-test"}).Error(); got != "llm: provider error: HTTP 500" {
|
if got := NewProviderError(500, nil, "sk-test").Error(); got != "llm: provider error: HTTP 500" {
|
||||||
t.Fatalf("Error() without body = %q", got)
|
t.Fatalf("Error() without body = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func TestProviderError(t *testing.T) {
|
|||||||
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||||
const key = "sk-live-0123456789abcdef"
|
const key = "sk-live-0123456789abcdef"
|
||||||
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||||
pe := NewProviderError(400, body, []string{key, ""})
|
pe := NewProviderError(400, body, key)
|
||||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
if strings.Contains(s, key) {
|
if strings.Contains(s, key) {
|
||||||
t.Fatalf("secret leaked: %q", s)
|
t.Fatalf("secret leaked: %q", s)
|
||||||
@@ -88,7 +88,7 @@ func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
|||||||
// The key starts just before the excerpt limit, so truncating first
|
// The key starts just before the excerpt limit, so truncating first
|
||||||
// would leave its prefix in the excerpt.
|
// would leave its prefix in the excerpt.
|
||||||
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||||
pe := NewProviderError(500, []byte(body), []string{key})
|
pe := NewProviderError(500, []byte(body), key)
|
||||||
if strings.Contains(pe.Body(), "sk-se") {
|
if strings.Contains(pe.Body(), "sk-se") {
|
||||||
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||||
}
|
}
|
||||||
@@ -96,26 +96,103 @@ func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
|||||||
|
|
||||||
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||||
pe := NewProviderError(500, []byte(body), []string{"sk-test"})
|
pe := NewProviderError(500, []byte(body), "sk-test")
|
||||||
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||||
if pe.Body() != want {
|
if pe.Body() != want {
|
||||||
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderErrorOmitsBodyWithoutSecret(t *testing.T) {
|
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
|
||||||
const key = "sk-live-0123456789abcdef"
|
body := []byte(` {"error":"model \"llama9\" not found"} `)
|
||||||
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef"}`)
|
pe := NewProviderError(404, body, "")
|
||||||
for _, secrets := range [][]string{nil, {}, {""}} {
|
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
|
||||||
pe := NewProviderError(401, body, secrets)
|
t.Fatalf("Body() = %q", pe.Body())
|
||||||
if pe.Body() != "" {
|
}
|
||||||
t.Fatalf("secrets %q: Body() = %q, want empty", secrets, 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)
|
||||||
}
|
}
|
||||||
if got := pe.Error(); got != "llm: provider error: HTTP 401" {
|
}
|
||||||
t.Fatalf("secrets %q: Error() = %q", secrets, got)
|
}
|
||||||
|
|
||||||
|
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 strings.Contains(pe.Error(), key) {
|
}
|
||||||
t.Fatalf("secrets %q: secret leaked: %q", secrets, pe.Error())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,14 +67,15 @@ type Request struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate reports whether r is well formed. Adapters call it before
|
// Validate reports whether r is well formed. Adapters call it before
|
||||||
// contacting the vendor.
|
// 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 {
|
func (r Request) Validate() error {
|
||||||
if len(r.Messages) == 0 {
|
if len(r.Messages) == 0 {
|
||||||
return errors.New("llm: request has no messages")
|
return errors.New("llm: request has no messages")
|
||||||
}
|
}
|
||||||
for i, m := range r.Messages {
|
for i, m := range r.Messages {
|
||||||
if m.Role != RoleUser && m.Role != RoleAssistant {
|
if m.Role != RoleUser && m.Role != RoleAssistant {
|
||||||
return fmt.Errorf("llm: message %d has unknown role %q", i, m.Role)
|
return fmt.Errorf("llm: message %d has an unknown role", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Temperature != nil && *r.Temperature < 0 {
|
if r.Temperature != nil && *r.Temperature < 0 {
|
||||||
@@ -92,7 +93,7 @@ func (r Request) Validate() error {
|
|||||||
}
|
}
|
||||||
var obj map[string]json.RawMessage
|
var obj map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
||||||
return fmt.Errorf("llm: schema %q is not a JSON object", r.Schema.Name)
|
return errors.New("llm: schema is not a JSON object")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
// 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"
|
||||||
|
"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: &http.Client{},
|
||||||
|
}, 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.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 }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user