mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572f68d460 | ||
|
|
c13e1a36ab | ||
|
|
99721c762f |
No files matched your search
+22
-25
@@ -494,31 +494,28 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
err error
|
||||
}
|
||||
resultChan := make(chan callbackResult, 1)
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}),
|
||||
}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
// The shared callback server bounds header reads, force-closes a client
|
||||
// still connected when shutdown runs out of time, and waits for Serve to
|
||||
// return. The bare http.Server this used to build did none of that, so a
|
||||
// client stalled mid-header outlived Login (#1028).
|
||||
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
open := options.OpenBrowser
|
||||
if open == nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -668,3 +670,86 @@ func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
|
||||
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE LOGIN (#1028).
|
||||
//
|
||||
// Login built a bare http.Server and ended it with Shutdown under a one-second
|
||||
// deadline, and Shutdown only closes idle connections. A client that has sent
|
||||
// part of a request header is not idle, so it survived the deadline and kept
|
||||
// its goroutine, and the Serve goroutine, alive after Login returned.
|
||||
//
|
||||
// No sleep is needed to get the stalled connection accepted before Login ends:
|
||||
// it is dialled before the real callback's connection, Serve accepts in order,
|
||||
// and Login cannot finish until the real callback has been served.
|
||||
func TestLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-final", "token_type": "Bearer", "expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
var stalled net.Conn
|
||||
opener := func(authURL string) error {
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect, err := url.Parse(parsed.Query().Get("redirect_uri"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stalled, err = net.Dial("tcp", redirect.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", redirect.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
cbURL := redirect.String() + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
|
||||
go func() {
|
||||
if resp, err := http.Get(cbURL); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "demo",
|
||||
ServerURL: "https://issuer.invalid",
|
||||
Config: OAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://issuer.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
},
|
||||
HTTPClient: http.DefaultClient,
|
||||
OpenBrowser: opener,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if stalled == nil {
|
||||
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
|
||||
}
|
||||
defer stalled.Close()
|
||||
requireCallbackClientClosed(t, stalled)
|
||||
}
|
||||
|
||||
// requireCallbackClientClosed fails unless the server end of conn is already
|
||||
// closed: a read has to fail with something other than a timeout.
|
||||
func requireCallbackClientClosed(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
t.Fatal("the stalled callback client was still connected after Login returned")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("the stalled callback client received data instead of being closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// callbackReadHeaderTimeout bounds how long an accepted connection may take to
|
||||
// send its request header. A single-use loopback callback server has no reason
|
||||
// to wait longer, and without a bound a client that stalls mid-header holds the
|
||||
// connection, and the server goroutine, open indefinitely. A var so the
|
||||
// package tests can shorten it.
|
||||
var callbackReadHeaderTimeout = 5 * time.Second
|
||||
|
||||
// callbackShutdownBudget is how long Close waits for in-flight requests before
|
||||
// it force-closes whatever is still open.
|
||||
const callbackShutdownBudget = time.Second
|
||||
|
||||
// CallbackServer is the lifecycle every loopback OAuth callback server needs,
|
||||
// in one place.
|
||||
//
|
||||
// It exists because the same server was written out three times (the shared
|
||||
// LoopbackListener, MCP's Login and OpenRouter's login) and a fix to one of
|
||||
// them did not reach the others. http.Server.Shutdown only closes idle
|
||||
// connections, so a client that has sent part of a request header is active,
|
||||
// survives the shutdown deadline, and keeps its goroutine and the Serve
|
||||
// goroutine alive past the end of the flow (#1028). #1051 closed that for
|
||||
// LoopbackListener; the other two kept the original construction.
|
||||
type CallbackServer struct {
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// StartCallbackServer serves handler on listener until Close.
|
||||
func StartCallbackServer(listener net.Listener, handler http.Handler) *CallbackServer {
|
||||
return startCallbackServer(listener, handler, nil)
|
||||
}
|
||||
|
||||
// startCallbackServer is StartCallbackServer with a ConnState hook, which the
|
||||
// package's own tests use to see a connection reach a given state.
|
||||
func startCallbackServer(listener net.Listener, handler http.Handler, connState func(net.Conn, http.ConnState)) *CallbackServer {
|
||||
s := &CallbackServer{
|
||||
server: &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: callbackReadHeaderTimeout,
|
||||
ConnState: connState,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
_ = s.server.Serve(listener)
|
||||
close(s.done)
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
// Close shuts the server down within callbackShutdownBudget. A connection still
|
||||
// open when the budget runs out (a stalled request header, say) is force-closed
|
||||
// rather than leaked, and Close returns only after the Serve goroutine has
|
||||
// finished, so nothing this server started outlives the call. Idempotent.
|
||||
func (s *CallbackServer) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownBudget)
|
||||
defer cancel()
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
<-s.done
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startStalledCallbackClient starts a CallbackServer and connects a client that
|
||||
// sends part of a request header and then stops, which is the shape #1028 is
|
||||
// about. It returns once the server has ACCEPTED the connection, using the
|
||||
// ConnState hook rather than a sleep. StateNew, not StateActive: Go reports
|
||||
// StateActive only after a request read returns, and a stalled header read
|
||||
// returns only when the header timeout fires, so waiting for StateActive would
|
||||
// make the setup depend on the very bound the test is about.
|
||||
func startStalledCallbackClient(t *testing.T) (*CallbackServer, net.Conn) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted := make(chan struct{}, 1)
|
||||
server := startCallbackServer(listener, http.NotFoundHandler(), func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(conn, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", listener.Addr()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("SETUP INVALID: the server never accepted the stalled client")
|
||||
}
|
||||
return server, conn
|
||||
}
|
||||
|
||||
// connClosedWithin reports whether the server end of conn closes before the
|
||||
// deadline: a read must fail with something other than a timeout.
|
||||
func connClosedWithin(conn net.Conn, deadline time.Duration) bool {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(deadline))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return false
|
||||
}
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// THE HEADER READ IS BOUNDED EVEN BEFORE CLOSE. The force-close in Close ends a
|
||||
// stalled client when the flow ends; the header timeout ends it while the flow
|
||||
// is still waiting for its callback, which can be minutes. Close is not called
|
||||
// until after the assertion, so this can only pass on ReadHeaderTimeout.
|
||||
func TestCallbackServerBoundsAStalledHeaderWithoutClose(t *testing.T) {
|
||||
previous := callbackReadHeaderTimeout
|
||||
callbackReadHeaderTimeout = 200 * time.Millisecond
|
||||
defer func() { callbackReadHeaderTimeout = previous }()
|
||||
|
||||
server, conn := startStalledCallbackClient(t)
|
||||
defer server.Close()
|
||||
defer conn.Close()
|
||||
|
||||
if !connClosedWithin(conn, 3*time.Second) {
|
||||
t.Fatal("a client stalled mid-header was still connected with Close never called; the header read is unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// CLOSE RETURNS ONLY AFTER SERVE HAS. Its doc comment promises that nothing the
|
||||
// server started outlives the call. With no connections open, Shutdown closes
|
||||
// the listener and returns at once, so this is the case where Close could beat
|
||||
// the Serve goroutine back; the goroutine closing done is the last thing it
|
||||
// does, so done has to be closed by the time Close returns.
|
||||
func TestCallbackServerCloseWaitsForServe(t *testing.T) {
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := StartCallbackServer(listener, http.NotFoundHandler())
|
||||
server.Close()
|
||||
select {
|
||||
case <-server.done:
|
||||
default:
|
||||
t.Fatalf("attempt %d: Close returned while the Serve goroutine was still running", attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
@@ -19,8 +18,7 @@ type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
server *CallbackServer
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
@@ -58,20 +56,8 @@ func newLoopbackListener(state string, port int, connState func(net.Conn, http.C
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
l.server = &http.Server{
|
||||
Handler: http.HandlerFunc(l.handle),
|
||||
// This is a single-use, loopback-only callback server: a client that
|
||||
// never finishes sending its request header must not be able to hold
|
||||
// the accepted connection (and Close, below) open indefinitely.
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnState: connState,
|
||||
}
|
||||
go func() {
|
||||
_ = l.server.Serve(ln)
|
||||
close(l.done)
|
||||
}()
|
||||
l.server = startCallbackServer(ln, http.HandlerFunc(l.handle), connState)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
@@ -122,14 +108,10 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
// Close shuts the listener down (bounded), idempotent. If a client leaves a
|
||||
// connection open past the shutdown deadline (e.g. a stalled request header),
|
||||
// Close forcibly closes it rather than leaking it past the end of the flow,
|
||||
// and waits for the Serve goroutine to finish before returning.
|
||||
// and waits for the Serve goroutine to finish before returning. See
|
||||
// CallbackServer.Close.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := l.server.Shutdown(shutdownCtx); err != nil {
|
||||
_ = l.server.Close()
|
||||
}
|
||||
<-l.done
|
||||
l.server.Close()
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
|
||||
@@ -79,7 +79,11 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
|
||||
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The shared callback server bounds header reads, force-closes a client
|
||||
// still connected when shutdown runs out of time, and waits for Serve to
|
||||
// return. The bare http.Server this used to build did none of that, so a
|
||||
// client stalled mid-header outlived the login (#1028).
|
||||
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -98,13 +102,8 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
|
||||
case errCh <- errors.New("provideroauth: callback missing authorization code"):
|
||||
default:
|
||||
}
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelShutdown()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
authURL := base + openRouterAuthPath + "?" + url.Values{
|
||||
"callback_url": {callbackURL},
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
|
||||
@@ -129,3 +132,62 @@ func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
|
||||
t.Fatal("a browser-open failure should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE THE LOGIN (#1028). Same defect
|
||||
// as MCP's Login: a bare http.Server ended by Shutdown alone, which leaves a
|
||||
// connection that has sent part of a request header open after the flow ends.
|
||||
// The stalled client is dialled before the real callback, so Serve has
|
||||
// accepted it by the time the login can complete; no sleep is involved.
|
||||
func TestOpenRouterLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var stalled net.Conn
|
||||
open := func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cb, err := url.Parse(u.Query().Get("callback_url"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stalled, err = net.Dial("tcp", cb.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", cb.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.Get(cb.String() + "?code=TESTCODE") //nolint:noctx // test loopback
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL, HTTPClient: srv.Client(), OpenBrowser: open,
|
||||
}); err != nil {
|
||||
t.Fatalf("OpenRouterLogin: %v", err)
|
||||
}
|
||||
if stalled == nil {
|
||||
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
|
||||
}
|
||||
defer stalled.Close()
|
||||
|
||||
_ = stalled.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var one [1]byte
|
||||
_, err := stalled.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
t.Fatal("the stalled callback client was still connected after OpenRouterLogin returned")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("the stalled callback client received data instead of being closed")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ type Options struct {
|
||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||
StreamIdleTimeout time.Duration
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from Anthropic's Messages API.
|
||||
@@ -108,6 +109,7 @@ type Provider struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an Anthropic provider.
|
||||
@@ -143,6 +145,7 @@ func New(options Options) (*Provider, error) {
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -163,12 +166,12 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
// the request, which unblocks the SSE reader goroutine.
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
@@ -185,6 +188,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("anthropic-version", provider.version)
|
||||
if provider.beta != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -53,6 +54,11 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
return newCodexProvider(profile, resolved, options)
|
||||
}
|
||||
|
||||
sessionHeader := sessionHeaderForProfile(profile, resolved)
|
||||
if sessionHeader != "" && strings.TrimSpace(options.UserAgent) == "" {
|
||||
options.UserAgent = "zero"
|
||||
}
|
||||
|
||||
switch resolved.providerKind {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
||||
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
|
||||
@@ -74,6 +80,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
UserAgent: options.UserAgent,
|
||||
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
|
||||
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return anthropic.New(anthropic.Options{
|
||||
@@ -88,6 +95,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindGoogle:
|
||||
return gemini.New(gemini.Options{
|
||||
@@ -345,6 +353,18 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
|
||||
return providercatalog.NormalizeID(profile.CatalogID) == "chatgpt"
|
||||
}
|
||||
|
||||
func sessionHeaderForProfile(profile config.ProviderProfile, resolved resolvedProfile) string {
|
||||
switch providercatalog.NormalizeID(profile.CatalogID) {
|
||||
case "opencode", "opencode-go", "opencode-go-anthropic-compatible":
|
||||
return "x-opencode-session"
|
||||
}
|
||||
endpoint, err := url.Parse(resolved.baseURL)
|
||||
if err == nil && strings.EqualFold(endpoint.Hostname(), "opencode.ai") && (endpoint.Path == "/zen" || strings.HasPrefix(endpoint.Path, "/zen/")) {
|
||||
return "x-opencode-session"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
|
||||
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
|
||||
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
|
||||
|
||||
@@ -3,13 +3,17 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -794,3 +798,566 @@ func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T
|
||||
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeader(t *testing.T) {
|
||||
const sessionHeader = "x-opencode-session"
|
||||
tests := []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
wantHeader bool
|
||||
wantAuth map[string]string
|
||||
}{
|
||||
{
|
||||
name: "catalog opencode-go default URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc-go",
|
||||
Model: "deepseek-v4-pro",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-oc-go"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode-go-anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-oc-ant"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-zen",
|
||||
CatalogID: "opencode",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-zen",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zen"},
|
||||
},
|
||||
{
|
||||
name: "renamed catalog profile with custom proxy URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "my-opencode",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://proxy.example/v1",
|
||||
APIKey: "sk-proxy",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-proxy"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less OpenAI-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zen/go/v1",
|
||||
APIKey: "sk-manual",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-manual"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less Anthropic-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go-anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
BaseURL: "https://opencode.ai/zen/go",
|
||||
APIKey: "sk-manual-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-manual-ant"},
|
||||
},
|
||||
{
|
||||
name: "official OpenAI",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
APIKey: "sk-openai",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-openai"},
|
||||
},
|
||||
{
|
||||
name: "generic OpenAI-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "generic",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://provider.example/v1",
|
||||
APIKey: "sk-generic",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-generic"},
|
||||
},
|
||||
{
|
||||
name: "official Anthropic",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropic,
|
||||
APIKey: "sk-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-ant"},
|
||||
},
|
||||
{
|
||||
name: "lookalike subdomain is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-host",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai.example/zen/go/v1",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "lookalike path is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-path",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://example.com/opencode.ai/zen/go",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "zenith path is not Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "zenith",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zenith",
|
||||
APIKey: "sk-zenith",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zenith"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: test.response}
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
got := transport.request.Header.Get(sessionHeader)
|
||||
if test.wantHeader && got != "conversation-a" {
|
||||
t.Fatalf("%s = %q, want %q", sessionHeader, got, "conversation-a")
|
||||
}
|
||||
if !test.wantHeader && got != "" {
|
||||
t.Fatalf("%s = %q, want no session header", sessionHeader, got)
|
||||
}
|
||||
for header, want := range test.wantAuth {
|
||||
if got := transport.request.Header.Get(header); got != want {
|
||||
t.Fatalf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero-test" {
|
||||
t.Fatalf("User-Agent = %q, want zero-test", got)
|
||||
}
|
||||
if test.profile.ProviderKind == config.ProviderKindOpenAICompatible {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if _, ok := body["prompt_cache_key"]; ok {
|
||||
t.Fatalf("openai-compatible body carried prompt_cache_key: %#v", body["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFollowsConversationKey(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"}
|
||||
provider, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"conversation-a", "conversation-a", "conversation-b"} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
got := transport.sessionHeaders()
|
||||
want := []string{"conversation-a", "conversation-a", "conversation-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("captured %d requests, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("request %d x-opencode-session = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
reconstructed, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := reconstructed.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
got = transport.sessionHeaders()
|
||||
if last := got[len(got)-1]; last != "conversation-a" {
|
||||
t.Fatalf("reconstructed provider sent %s = %q, want conversation-a", "x-opencode-session", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderCustomOverride(t *testing.T) {
|
||||
custom := map[string]string{"X-Opencode-Session": "explicit-session"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
CustomHeaders: custom,
|
||||
}
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(profile, Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("x-opencode-session"); got != "explicit-session" {
|
||||
t.Fatalf("x-opencode-session = %q, want explicit custom value", got)
|
||||
}
|
||||
if len(custom) != 1 || custom["X-Opencode-Session"] != "explicit-session" {
|
||||
t.Fatalf("profile CustomHeaders mutated: %#v", custom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFallback(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}}
|
||||
first, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
second, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
}
|
||||
for _, provider := range []zeroruntime.Provider{first, first, second} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
headers := transport.sessionHeaders()
|
||||
if len(headers) != 3 {
|
||||
t.Fatalf("captured %d requests, want 3", len(headers))
|
||||
}
|
||||
if headers[0] == "" || headers[1] != headers[0] {
|
||||
t.Fatalf("fallback headers = %q, %q — want one stable nonempty value per provider", headers[0], headers[1])
|
||||
}
|
||||
if headers[2] == headers[0] {
|
||||
t.Fatalf("distinct provider instances shared fallback %q", headers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderDefaultsUserAgent(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero" {
|
||||
t.Fatalf("User-Agent = %q, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderConcurrent(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errsMu sync.Mutex
|
||||
var errs []string
|
||||
for _, session := range []string{"session-a", "session-b"} {
|
||||
wg.Add(1)
|
||||
go func(session string) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 5; i++ {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("req-%s-%d", session, i)}},
|
||||
PromptCacheKey: session,
|
||||
})
|
||||
if err != nil {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, err.Error())
|
||||
errsMu.Unlock()
|
||||
return
|
||||
}
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, event.Error)
|
||||
errsMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}(session)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, errText := range errs {
|
||||
t.Errorf("concurrent stream error: %s", errText)
|
||||
}
|
||||
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 10 {
|
||||
t.Fatalf("captured %d requests, want 10", len(requests))
|
||||
}
|
||||
for _, request := range requests {
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(request.body), &payload); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(payload.Messages) == 0 {
|
||||
t.Fatal("request carried no user message")
|
||||
}
|
||||
content := payload.Messages[0].Content
|
||||
want := "session-a"
|
||||
if strings.Contains(content, "session-b") {
|
||||
want = "session-b"
|
||||
}
|
||||
if got := request.header.Get("x-opencode-session"); got != want {
|
||||
t.Fatalf("request for %q carried x-opencode-session %q, want %q", content, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderAuthRetry(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "openai-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
},
|
||||
{
|
||||
name: "anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: test.response, unauthorized: 1}
|
||||
var calls atomic.Int32
|
||||
resolver := providerio.TokenResolver(func(context.Context, bool) (string, string, bool, error) {
|
||||
n := calls.Add(1)
|
||||
return "Authorization", fmt.Sprintf("Bearer tok-%d", n), true, nil
|
||||
})
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
OAuthResolver: resolver,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("captured %d requests, want 2 (401 + retry)", len(requests))
|
||||
}
|
||||
for i, request := range requests {
|
||||
if got := request.header.Get("x-opencode-session"); got != "conversation-a" {
|
||||
t.Fatalf("attempt %d x-opencode-session = %q, want conversation-a", i, got)
|
||||
}
|
||||
}
|
||||
if first, second := requests[0].header.Get("Authorization"), requests[1].header.Get("Authorization"); first == second {
|
||||
t.Fatalf("Authorization not refreshed across retry: %q", first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func drainStream(t *testing.T, stream <-chan zeroruntime.StreamEvent) {
|
||||
t.Helper()
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
t.Fatalf("stream error: %s", event.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordedRequest struct {
|
||||
header http.Header
|
||||
body string
|
||||
}
|
||||
|
||||
type recordingTransport struct {
|
||||
mu sync.Mutex
|
||||
requests []recordedRequest
|
||||
status int
|
||||
body string
|
||||
unauthorized int
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if request.Body != nil {
|
||||
data, _ := io.ReadAll(request.Body)
|
||||
body = string(data)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
transport.requests = append(transport.requests, recordedRequest{header: request.Header.Clone(), body: body})
|
||||
attempt := len(transport.requests)
|
||||
transport.mu.Unlock()
|
||||
|
||||
status := transport.status
|
||||
if attempt <= transport.unauthorized {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: fmt.Sprintf("%d", status),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(transport.body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) recorded() []recordedRequest {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
return append([]recordedRequest(nil), transport.requests...)
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) sessionHeaders() []string {
|
||||
requests := transport.recorded()
|
||||
headers := make([]string, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
headers = append(headers, request.header.Get("x-opencode-session"))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Options struct {
|
||||
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
|
||||
// on top for any endpoint.
|
||||
DisablePromptCacheKey bool
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from an OpenAI-compatible chat completions API.
|
||||
@@ -85,6 +86,7 @@ type Provider struct {
|
||||
parseThinkTags bool
|
||||
setRequestExtra func(*http.Request)
|
||||
disablePromptCacheKey bool
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
@@ -140,6 +142,7 @@ func New(options Options) (*Provider, error) {
|
||||
parseThinkTags: options.ParseThinkTags,
|
||||
setRequestExtra: options.SetRequestExtra,
|
||||
disablePromptCacheKey: options.DisablePromptCacheKey,
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -156,13 +159,13 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
endpoint := provider.endpoint
|
||||
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
@@ -186,6 +189,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,6 +53,28 @@ func ApplyAuthHeaders(request *http.Request, options AuthHeaders) {
|
||||
request.Header.Set(header, value)
|
||||
}
|
||||
|
||||
type SessionHeader struct {
|
||||
name string
|
||||
fallback string
|
||||
}
|
||||
|
||||
func NewSessionHeader(name string) SessionHeader {
|
||||
if name == "" {
|
||||
return SessionHeader{}
|
||||
}
|
||||
return SessionHeader{name: name, fallback: rand.Text()}
|
||||
}
|
||||
|
||||
func (header SessionHeader) Apply(request *http.Request, sessionID string) {
|
||||
if header.name == "" {
|
||||
return
|
||||
}
|
||||
if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
|
||||
sessionID = header.fallback
|
||||
}
|
||||
request.Header.Set(header.name, sessionID)
|
||||
}
|
||||
|
||||
func CopyHeaders(headers map[string]string) map[string]string {
|
||||
if headers == nil {
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user