Feature 08 task 2: OpenAI-compatible adapter (#35)
CI / ci (push) Successful in 1m28s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #35.
This commit is contained in:
2026-10-04 07:41:45 -04:00
committed by approver_agent
parent 72042516e2
commit acd29c8221
20 changed files with 2109 additions and 49 deletions
+59 -30
View File
@@ -26,13 +26,32 @@ var (
)
// RateLimitedError is a throttled request. It matches ErrRateLimited.
// Adapters build it with NewRateLimitedError so its text never holds their
// credential.
type RateLimitedError struct {
// RetryAfter is how long the vendor asked callers to wait; 0 means it
// did not say.
RetryAfter time.Duration
// text is the redacted Error text set by NewRateLimitedError; "" means
// the error was built without a credential.
text string
}
// NewRateLimitedError returns the error for a throttled request. secret is
// the credential sent with the request, or "" when the request sent none.
// Every occurrence of a non-empty secret is removed from the error text
// when it is built, even where the fixed text happens to spell it, and the
// secret itself is not kept.
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
e := &RateLimitedError{RetryAfter: retryAfter}
e.text = Redact(e.Error(), secret)
return e
}
func (e *RateLimitedError) Error() string {
if e.text != "" {
return e.text
}
if e.RetryAfter == 0 {
return ErrRateLimited.Error()
}
@@ -46,58 +65,68 @@ func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimit
// ProviderError keeps.
const MaxBodyExcerpt = 512
// redacted replaces each secret in a ProviderError body.
const redacted = "[REDACTED]"
// redactionMarker replaces each secret in redacted text, unless the marker
// would itself hold the secret.
const redactionMarker = "[REDACTED]"
// Redact returns s with every occurrence of a non-empty secret removed. Each
// occurrence becomes "[REDACTED]" when the result then holds no secret;
// otherwise, because the marker contains the secret or joins with its
// neighbours to form it, occurrences are deleted until none remains. The
// result never contains secret.
func Redact(s, secret string) string {
if secret == "" {
return s
}
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
return out
}
for strings.Contains(s, secret) {
s = strings.ReplaceAll(s, secret, "")
}
return s
}
// ProviderError is an unexpected HTTP status from the vendor. It matches
// ErrProvider. Build it with NewProviderError, which redacts the body.
type ProviderError struct {
status int
body string
text string
}
// NewProviderError returns the error for an unexpected HTTP status.
// secrets must hold the API key (and any other credential sent with the
// request). Every occurrence of each non-empty secret in body is replaced
// before the body is cut to MaxBodyExcerpt bytes, so no part of a secret
// survives. If secrets holds no non-empty value the body cannot be
// redacted, so no excerpt is kept at all.
func NewProviderError(status int, body []byte, secrets []string) *ProviderError {
s := string(body)
redactedAny := false
for _, secret := range secrets {
if secret != "" {
s = strings.ReplaceAll(s, secret, redacted)
redactedAny = true
}
}
if !redactedAny {
return &ProviderError{status: status}
}
s = strings.TrimSpace(s)
// NewProviderError returns the error for an unexpected HTTP status. It
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
// credential sent with the request, or "" when the request sent none; every
// occurrence of a non-empty secret in body is removed by Redact before the
// body is cut, so no part of it survives, and again after the cut, so the
// truncation mark cannot complete it. It is removed from the complete error
// text too, even where the fixed text happens to spell it, and the secret
// itself is not kept.
func NewProviderError(status int, body []byte, secret string) *ProviderError {
s := strings.TrimSpace(Redact(string(body), secret))
if len(s) > MaxBodyExcerpt {
cut := MaxBodyExcerpt
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
s = 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.
func (e *ProviderError) Status() int { return e.status }
// Body returns the redacted, truncated response-body excerpt; it is empty
// when the vendor sent no body 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) Error() string {
if e.body == "" {
return fmt.Sprintf("%s: HTTP %d", ErrProvider, e.status)
}
return fmt.Sprintf("%s: HTTP %d: %s", ErrProvider, e.status, e.body)
}
func (e *ProviderError) Error() string { return e.text }
// Is reports whether target is ErrProvider.
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
+93 -16
View File
@@ -46,7 +46,7 @@ func TestRateLimitedError(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) {
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"}` {
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)
}
}
@@ -71,7 +71,7 @@ func TestProviderError(t *testing.T) {
func TestProviderErrorRedactsSecrets(t *testing.T) {
const key = "sk-live-0123456789abcdef"
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
pe := NewProviderError(400, body, []string{key, ""})
pe := NewProviderError(400, body, key)
for _, s := range []string{pe.Error(), pe.Body()} {
if strings.Contains(s, key) {
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
// would leave its prefix in the excerpt.
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") {
t.Fatalf("secret prefix leaked: %q", pe.Body())
}
@@ -96,26 +96,103 @@ func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
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) + "…"
if pe.Body() != want {
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
}
}
func TestProviderErrorOmitsBodyWithoutSecret(t *testing.T) {
const key = "sk-live-0123456789abcdef"
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef"}`)
for _, secrets := range [][]string{nil, {}, {""}} {
pe := NewProviderError(401, body, secrets)
if pe.Body() != "" {
t.Fatalf("secrets %q: Body() = %q, want empty", secrets, pe.Body())
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
body := []byte(` {"error":"model \"llama9\" not found"} `)
pe := NewProviderError(404, body, "")
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
t.Fatalf("Body() = %q", pe.Body())
}
if got := pe.Error(); got != `llm: provider error: HTTP 404: {"error":"model \"llama9\" not found"}` {
t.Fatalf("Error() = %q", got)
}
long := NewProviderError(500, []byte(strings.Repeat("x", 4*MaxBodyExcerpt)), "")
if want := strings.Repeat("x", MaxBodyExcerpt) + "…"; long.Body() != want {
t.Fatalf("Body() has %d bytes, want %d", len(long.Body()), len(want))
}
}
func TestRedactNeverLeavesSecret(t *testing.T) {
cases := []struct{ s, secret, want string }{
{"key sk-1 and Bearer sk-1", "sk-1", "key [REDACTED] and Bearer [REDACTED]"},
{"no secret here", "sk-1", "no secret here"},
{"x", "", "x"},
// The marker holds the secret, so occurrences are deleted.
{"rejected REDACTED", "REDACTED", "rejected "},
{"A then A", "A", " then "},
// The marker joins with a neighbour to form the secret.
{"]]aa", "]a", ""},
// The marker holds the secret and deleting once leaves a new one.
{"DDAA", "DA", ""},
{"aabb", "ab", "a[REDACTED]b"},
}
for _, c := range cases {
if got := Redact(c.s, c.secret); got != c.want {
t.Errorf("Redact(%q, %q) = %q, want %q", c.s, c.secret, got, c.want)
}
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)
}
}
}
+4 -3
View File
@@ -67,14 +67,15 @@ type Request struct {
}
// 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 {
if len(r.Messages) == 0 {
return errors.New("llm: request has no messages")
}
for i, m := range r.Messages {
if m.Role != RoleUser && m.Role != RoleAssistant {
return fmt.Errorf("llm: message %d has unknown role %q", i, m.Role)
return fmt.Errorf("llm: message %d has an unknown role", i)
}
}
if r.Temperature != nil && *r.Temperature < 0 {
@@ -92,7 +93,7 @@ func (r Request) Validate() error {
}
var obj map[string]json.RawMessage
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
+599
View File
@@ -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
View File
@@ -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}
}
+11
View File
@@ -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}}
+7
View File
@@ -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}}
+7
View File
@@ -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"}}}}
+22
View File
@@ -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"]
}
]
}