Feature 08 task 2: OpenAI-compatible adapter #35
@@ -54,7 +54,9 @@ type Config struct {
|
|||||||
// need it.
|
// need it.
|
||||||
Model string
|
Model string
|
||||||
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
|
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
|
||||||
// X-Title. They must not set Authorization or Content-Type.
|
// 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
|
Headers map[string]string
|
||||||
// StructuredOutput is how JSON is requested when a Request has a Schema.
|
// StructuredOutput is how JSON is requested when a Request has a Schema.
|
||||||
StructuredOutput StructuredOutput
|
StructuredOutput StructuredOutput
|
||||||
@@ -91,17 +93,24 @@ func New(cfg Config) (*Provider, error) {
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("openai: unknown structured output mode %q", cfg.StructuredOutput)
|
return nil, fmt.Errorf("openai: unknown structured output mode %q", cfg.StructuredOutput)
|
||||||
}
|
}
|
||||||
headers := make(http.Header, len(cfg.Headers))
|
headers := make(http.Header, len(cfg.Headers)+1)
|
||||||
for name, value := range cfg.Headers {
|
for name, value := range cfg.Headers {
|
||||||
|
// Values are never echoed: one may carry a credential.
|
||||||
|
if !validHeaderName(name) {
|
||||||
|
return nil, fmt.Errorf("openai: extra header name %q is not a valid HTTP token", name)
|
||||||
|
}
|
||||||
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
||||||
if canonical == "Authorization" || canonical == "Content-Type" {
|
if managedHeaders[canonical] {
|
||||||
return nil, fmt.Errorf("openai: extra header %q is set by the adapter", canonical)
|
return nil, fmt.Errorf("openai: extra header %q is set by the adapter", canonical)
|
||||||
}
|
}
|
||||||
if name == "" || strings.ContainsAny(name, " \t\r\n:") || strings.ContainsAny(value, "\r\n") {
|
if !validHeaderValue(value) {
|
||||||
return nil, fmt.Errorf("openai: extra header %q is malformed", name)
|
return nil, fmt.Errorf("openai: extra header %q has a control character in its value", canonical)
|
||||||
}
|
}
|
||||||
headers.Set(canonical, value)
|
headers.Set(canonical, value)
|
||||||
}
|
}
|
||||||
|
if headers.Get("Accept") == "" {
|
||||||
|
headers.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
return &Provider{
|
return &Provider{
|
||||||
base: strings.TrimRight(cfg.BaseURL, "/"),
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
apiKey: cfg.APIKey,
|
apiKey: cfg.APIKey,
|
||||||
@@ -112,6 +121,46 @@ func New(cfg Config) (*Provider, error) {
|
|||||||
}, nil
|
}, 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.
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||||
const maxResponseBody = 16 << 20
|
const maxResponseBody = 16 << 20
|
||||||
|
|
||||||
@@ -339,7 +388,6 @@ func (p *Provider) do(ctx context.Context, method, path string, payload []byte)
|
|||||||
if payload != nil {
|
if payload != nil {
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,6 +619,40 @@ func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadHeaderWithoutEchoingValue(t *testing.T) {
|
||||||
|
_, err := New(Config{BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": testKey + "\u0019"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New() = nil error")
|
||||||
|
}
|
||||||
|
assertNoSecret(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptHeader(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
headers map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"default": {nil, "application/json"},
|
||||||
|
"configured": {map[string]string{"accept": "application/json, text/event-stream"}, "application/json, text/event-stream"},
|
||||||
|
} {
|
||||||
|
vs := newVendorServer(t, reply{status: 200, fixture: "openai_models.json"})
|
||||||
|
p := newProvider(t, vs.URL+"/v1", JSONSchema, tc.headers)
|
||||||
|
if _, err := p.ListModels(context.Background()); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if got := vs.only(t).header.Values("Accept"); len(got) != 1 || got[0] != tc.want {
|
||||||
|
t.Errorf("%s: Accept = %q, want %q", name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAcceptsValidHeaders(t *testing.T) {
|
||||||
|
headers := map[string]string{"X-Trace_ID.v2!#$%&'*+^`|~": "tab\tand obs-text \xe2\x9c\x93", "X-Empty": ""}
|
||||||
|
if _, err := New(Config{BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: headers}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRejectsBadConfig(t *testing.T) {
|
func TestNewRejectsBadConfig(t *testing.T) {
|
||||||
cases := map[string]Config{
|
cases := map[string]Config{
|
||||||
"empty base": {StructuredOutput: JSONSchema},
|
"empty base": {StructuredOutput: JSONSchema},
|
||||||
@@ -636,6 +670,15 @@ func TestNewRejectsBadConfig(t *testing.T) {
|
|||||||
"content-type hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"Content-Type": "text/plain"}},
|
"content-type hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"Content-Type": "text/plain"}},
|
||||||
"header newline": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\r\nb"}},
|
"header newline": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\r\nb"}},
|
||||||
"header bad name": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X Title": "a"}},
|
"header bad name": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X Title": "a"}},
|
||||||
|
"header empty name": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"": "a"}},
|
||||||
|
"header bracket": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X[Trace]": "a"}},
|
||||||
|
"header colon": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title:": "a"}},
|
||||||
|
"header non-ASCII": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Tïtle": "a"}},
|
||||||
|
"header U+0019": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\u0019b"}},
|
||||||
|
"header NUL": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\x00b"}},
|
||||||
|
"header DEL": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"X-Title": "a\x7fb"}},
|
||||||
|
"host hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"host": "other.example"}},
|
||||||
|
"content-length hdr": {BaseURL: "https://example.com/v1", StructuredOutput: JSONSchema, Headers: map[string]string{"Content-Length": "1"}},
|
||||||
}
|
}
|
||||||
for name, cfg := range cases {
|
for name, cfg := range cases {
|
||||||
cfg.APIKey = testKey
|
cfg.APIKey = testKey
|
||||||
|
|||||||
Reference in New Issue
Block a user