Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
42
backend/sdk/cliproxy/executor/context.go
Normal file
42
backend/sdk/cliproxy/executor/context.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package executor
|
||||
|
||||
import "context"
|
||||
|
||||
type downstreamWebsocketContextKey struct{}
|
||||
type requireUpstreamWebsocketContextKey struct{}
|
||||
|
||||
// WithDownstreamWebsocket marks the current request as coming from a downstream websocket connection.
|
||||
func WithDownstreamWebsocket(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, downstreamWebsocketContextKey{}, true)
|
||||
}
|
||||
|
||||
// DownstreamWebsocket reports whether the current request originates from a downstream websocket connection.
|
||||
func DownstreamWebsocket(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
raw := ctx.Value(downstreamWebsocketContextKey{})
|
||||
enabled, ok := raw.(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// WithRequiredUpstreamWebsocket marks a request whose incremental context is valid only on the current upstream websocket.
|
||||
func WithRequiredUpstreamWebsocket(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, requireUpstreamWebsocketContextKey{}, true)
|
||||
}
|
||||
|
||||
// RequiredUpstreamWebsocket reports whether falling back to an HTTP upstream would lose request context.
|
||||
func RequiredUpstreamWebsocket(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
raw := ctx.Value(requireUpstreamWebsocketContextKey{})
|
||||
enabled, ok := raw.(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
33
backend/sdk/cliproxy/executor/lifecycle.go
Normal file
33
backend/sdk/cliproxy/executor/lifecycle.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExecutionLifecycle owns resources associated with an execution attempt.
|
||||
type ExecutionLifecycle interface {
|
||||
Bind(func() error) error
|
||||
End(string)
|
||||
}
|
||||
|
||||
// BindExecutionResource binds a closer to the execution lifecycle.
|
||||
func BindExecutionResource(opts Options, closer io.Closer) error {
|
||||
if opts.ExecutionLifecycle == nil || closer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var closeOnce sync.Once
|
||||
var closeErr error
|
||||
closeResource := func() error {
|
||||
closeOnce.Do(func() {
|
||||
closeErr = closer.Close()
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
if errBind := opts.ExecutionLifecycle.Bind(closeResource); errBind != nil {
|
||||
return errors.Join(errBind, closeResource())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
69
backend/sdk/cliproxy/executor/lifecycle_test.go
Normal file
69
backend/sdk/cliproxy/executor/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type lifecycleRecorder struct {
|
||||
closeFn func() error
|
||||
}
|
||||
|
||||
func (r *lifecycleRecorder) Bind(closeFn func() error) error {
|
||||
r.closeFn = closeFn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*lifecycleRecorder) End(string) {}
|
||||
|
||||
type lifecycleCloser struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *lifecycleCloser) Close() error {
|
||||
c.calls.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBindExecutionResourceClosesResourceOnce(t *testing.T) {
|
||||
lifecycle := &lifecycleRecorder{}
|
||||
closer := &lifecycleCloser{}
|
||||
|
||||
if errBind := BindExecutionResource(Options{ExecutionLifecycle: lifecycle}, closer); errBind != nil {
|
||||
t.Fatalf("BindExecutionResource() error = %v", errBind)
|
||||
}
|
||||
if lifecycle.closeFn == nil {
|
||||
t.Fatal("BindExecutionResource() did not bind a closer")
|
||||
}
|
||||
if errClose := lifecycle.closeFn(); errClose != nil {
|
||||
t.Fatalf("first close error = %v", errClose)
|
||||
}
|
||||
if errClose := lifecycle.closeFn(); errClose != nil {
|
||||
t.Fatalf("second close error = %v", errClose)
|
||||
}
|
||||
if got := closer.calls.Load(); got != 1 {
|
||||
t.Fatalf("closer calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindExecutionResourceClosesWhenBindFails(t *testing.T) {
|
||||
want := errors.New("selection ended")
|
||||
lifecycle := &failingLifecycle{err: want}
|
||||
closer := &lifecycleCloser{}
|
||||
|
||||
errBind := BindExecutionResource(Options{ExecutionLifecycle: lifecycle}, closer)
|
||||
if !errors.Is(errBind, want) {
|
||||
t.Fatalf("BindExecutionResource() error = %v, want %v", errBind, want)
|
||||
}
|
||||
if got := closer.calls.Load(); got != 1 {
|
||||
t.Fatalf("closer calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingLifecycle struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *failingLifecycle) Bind(func() error) error { return l.err }
|
||||
func (*failingLifecycle) End(string) {}
|
||||
229
backend/sdk/cliproxy/executor/types.go
Normal file
229
backend/sdk/cliproxy/executor/types.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
)
|
||||
|
||||
// RequestedModelMetadataKey stores the client-requested model name in Options.Metadata.
|
||||
const RequestedModelMetadataKey = "requested_model"
|
||||
|
||||
// RequestPathMetadataKey stores the inbound HTTP request path (e.g. "/v1/images/generations") in Options.Metadata.
|
||||
// It is optional and may be absent for non-HTTP executions.
|
||||
const RequestPathMetadataKey = "request_path"
|
||||
|
||||
// DisallowFreeAuthMetadataKey instructs auth selection to skip known free-tier credentials.
|
||||
const DisallowFreeAuthMetadataKey = "disallow_free_auth"
|
||||
|
||||
// AuthSelectionModelMetadataKey overrides the model used only for auth selection.
|
||||
const AuthSelectionModelMetadataKey = "auth_selection_model"
|
||||
|
||||
// ReasoningEffortMetadataKey stores the client-requested reasoning effort for usage logs.
|
||||
const ReasoningEffortMetadataKey = "reasoning_effort"
|
||||
|
||||
// ServiceTierMetadataKey stores the client-requested service tier for usage logs.
|
||||
const ServiceTierMetadataKey = "service_tier"
|
||||
|
||||
// GenerateMetadataKey stores whether the client requested actual generation for usage logs.
|
||||
// Missing or true means generation is enabled; only an explicit false disables generation.
|
||||
const GenerateMetadataKey = "generate"
|
||||
|
||||
const (
|
||||
// PinnedAuthMetadataKey locks execution to a specific auth ID.
|
||||
PinnedAuthMetadataKey = "pinned_auth_id"
|
||||
// SelectedAuthMetadataKey stores the auth ID selected by the scheduler.
|
||||
SelectedAuthMetadataKey = "selected_auth_id"
|
||||
// SelectedAuthCallbackMetadataKey carries an optional callback invoked with the selected auth ID.
|
||||
SelectedAuthCallbackMetadataKey = "selected_auth_callback"
|
||||
// SelectedAuthIndexMetadataKey stores the stable index of the auth selected by the scheduler.
|
||||
SelectedAuthIndexMetadataKey = "selected_auth_index"
|
||||
// SelectedAuthIndexCallbackMetadataKey carries an optional callback invoked with the selected auth index.
|
||||
SelectedAuthIndexCallbackMetadataKey = "selected_auth_index_callback"
|
||||
// ExecutionSessionMetadataKey identifies a long-lived downstream execution session.
|
||||
ExecutionSessionMetadataKey = "execution_session_id"
|
||||
// DerivedSessionIDMetadataKey stores a stable session identity inferred from request context.
|
||||
DerivedSessionIDMetadataKey = "derived_session_id"
|
||||
// CallerScopeMetadataKey isolates inferred session identities between downstream callers.
|
||||
CallerScopeMetadataKey = "caller_scope"
|
||||
// SessionAffinityProviderMetadataKey carries the affinity selection namespace
|
||||
// (provider string, e.g. the literal "mixed" pool key) used by SessionAffinitySelector.Pick,
|
||||
// so OnResult keys the session cache identically to how selection read it.
|
||||
SessionAffinityProviderMetadataKey = "session_affinity_provider"
|
||||
// SessionAffinityModelMetadataKey carries the model used during session affinity selection.
|
||||
SessionAffinityModelMetadataKey = "session_affinity_model"
|
||||
)
|
||||
|
||||
// Request encapsulates the translated payload that will be sent to a provider executor.
|
||||
type Request struct {
|
||||
// Model is the upstream model identifier after translation.
|
||||
Model string
|
||||
// Payload is the provider specific JSON payload.
|
||||
Payload []byte
|
||||
// Format represents the provider payload schema.
|
||||
Format sdktranslator.Format
|
||||
// Metadata carries optional provider specific execution hints.
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// RequestAfterAuthInterceptor rewrites a request after credential selection and before executor translation.
|
||||
type RequestAfterAuthInterceptor func(context.Context, RequestAfterAuthInterceptRequest) RequestAfterAuthInterceptResponse
|
||||
|
||||
// RequestAfterAuthInterceptRequest describes a selected-auth request before executor translation.
|
||||
type RequestAfterAuthInterceptRequest struct {
|
||||
// SourceFormat is the original client protocol format.
|
||||
SourceFormat sdktranslator.Format
|
||||
// ToFormat is the selected upstream protocol format.
|
||||
ToFormat sdktranslator.Format
|
||||
// Model is the selected upstream model for this attempt.
|
||||
Model string
|
||||
// RequestedModel is the client-requested model before alias/model-pool rewriting.
|
||||
RequestedModel string
|
||||
// Stream reports whether the request expects streaming output.
|
||||
Stream bool
|
||||
// Headers contains the current upstream request headers.
|
||||
Headers http.Header
|
||||
// Body contains the current request payload.
|
||||
Body []byte
|
||||
// Metadata is a best-effort cloned context snapshot. Treat it as read-only and JSON-like.
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// RequestAfterAuthInterceptResponse returns selected-auth request modifications.
|
||||
type RequestAfterAuthInterceptResponse struct {
|
||||
// Headers replaces matching current request headers and preserves headers not mentioned here.
|
||||
Headers http.Header
|
||||
// Body replaces the current request body only when non-empty.
|
||||
Body []byte
|
||||
// ClearHeaders explicitly removes current request headers before Headers is applied.
|
||||
ClearHeaders []string
|
||||
// Terminate prevents the selected executor from receiving the request.
|
||||
Terminate bool
|
||||
// StatusCode is the downstream HTTP status used when Terminate is true.
|
||||
StatusCode int
|
||||
// ResponseHeaders contains downstream response headers used when Terminate is true.
|
||||
ResponseHeaders http.Header
|
||||
// ResponseBody contains the downstream response body used when Terminate is true.
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
// RequestTerminatedError carries a plugin-defined downstream response without executing upstream.
|
||||
type RequestTerminatedError struct {
|
||||
HTTPStatus int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (e *RequestTerminatedError) Error() string {
|
||||
return "request terminated by plugin"
|
||||
}
|
||||
|
||||
// StatusCode returns the plugin-defined downstream HTTP status.
|
||||
func (e *RequestTerminatedError) StatusCode() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.HTTPStatus
|
||||
}
|
||||
|
||||
// ResponseHeaders returns a copy of the plugin-defined downstream headers.
|
||||
func (e *RequestTerminatedError) ResponseHeaders() http.Header {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Header.Clone()
|
||||
}
|
||||
|
||||
// ResponseBody returns a copy of the plugin-defined downstream body.
|
||||
func (e *RequestTerminatedError) ResponseBody() []byte {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), e.Body...)
|
||||
}
|
||||
|
||||
// Options controls execution behavior for both streaming and non-streaming calls.
|
||||
type Options struct {
|
||||
// Stream toggles streaming mode.
|
||||
Stream bool
|
||||
// Alt carries optional alternate format hint (e.g. SSE JSON key).
|
||||
Alt string
|
||||
// Headers are forwarded to the provider request builder.
|
||||
Headers http.Header
|
||||
// Query contains optional query string parameters.
|
||||
Query url.Values
|
||||
// OriginalRequest preserves the inbound request bytes prior to translation.
|
||||
OriginalRequest []byte
|
||||
// SourceFormat identifies the inbound schema.
|
||||
SourceFormat sdktranslator.Format
|
||||
// ResponseFormat identifies the downstream response schema.
|
||||
// Empty means responses should use SourceFormat for backward compatibility.
|
||||
ResponseFormat sdktranslator.Format
|
||||
// Metadata carries extra execution hints shared across selection and executors.
|
||||
Metadata map[string]any
|
||||
// RequestAfterAuthInterceptor runs after credential selection and before executor translation.
|
||||
RequestAfterAuthInterceptor RequestAfterAuthInterceptor
|
||||
// ExecutionLifecycle owns Home-dispatched execution resources. Executors must not add it to request metadata.
|
||||
ExecutionLifecycle ExecutionLifecycle
|
||||
}
|
||||
|
||||
// EnsureMetadata initializes and returns Metadata, ensuring it is non-nil.
|
||||
func (o *Options) EnsureMetadata() map[string]any {
|
||||
if o.Metadata == nil {
|
||||
o.Metadata = make(map[string]any)
|
||||
}
|
||||
return o.Metadata
|
||||
}
|
||||
|
||||
// ResponseFormatOrSource returns the response target format for an execution.
|
||||
func ResponseFormatOrSource(opts Options) sdktranslator.Format {
|
||||
if opts.ResponseFormat != "" {
|
||||
return opts.ResponseFormat
|
||||
}
|
||||
return opts.SourceFormat
|
||||
}
|
||||
|
||||
// Response wraps either a full provider response or metadata for streaming flows.
|
||||
type Response struct {
|
||||
// Payload is the provider response in the executor format.
|
||||
Payload []byte
|
||||
// Metadata exposes optional structured data for translators.
|
||||
Metadata map[string]any
|
||||
// Headers carries upstream HTTP response headers for passthrough to clients.
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
// StreamChunk represents a single streaming payload unit emitted by provider executors.
|
||||
type StreamChunk struct {
|
||||
// Payload is the raw provider chunk payload.
|
||||
Payload []byte
|
||||
// Err reports any terminal error encountered while producing chunks.
|
||||
Err error
|
||||
}
|
||||
|
||||
// StreamResult wraps the streaming response, providing both the chunk channel
|
||||
// and the upstream HTTP response headers captured before streaming begins.
|
||||
type StreamResult struct {
|
||||
// Headers carries upstream HTTP response headers from the initial connection.
|
||||
Headers http.Header
|
||||
// Chunks is the channel of streaming payload units.
|
||||
Chunks <-chan StreamChunk
|
||||
}
|
||||
|
||||
// StatusError represents an error that carries an HTTP-like status code.
|
||||
// Provider executors should implement this when possible to enable
|
||||
// better auth state updates on failures (e.g., 401/402/429).
|
||||
type StatusError interface {
|
||||
error
|
||||
StatusCode() int
|
||||
}
|
||||
|
||||
// RequestScopedError identifies a failure tied to the current request rather
|
||||
// than the selected credential. Auth managers should not retry these errors
|
||||
// across credentials or change credential availability because of them.
|
||||
type RequestScopedError interface {
|
||||
error
|
||||
IsRequestScoped() bool
|
||||
}
|
||||
26
backend/sdk/cliproxy/executor/types_test.go
Normal file
26
backend/sdk/cliproxy/executor/types_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
)
|
||||
|
||||
func TestResponseFormatOrSourceUsesExplicitResponseFormat(t *testing.T) {
|
||||
opts := Options{
|
||||
SourceFormat: sdktranslator.FormatOpenAI,
|
||||
ResponseFormat: sdktranslator.FormatClaude,
|
||||
}
|
||||
|
||||
if got := ResponseFormatOrSource(opts); got != sdktranslator.FormatClaude {
|
||||
t.Fatalf("ResponseFormatOrSource() = %q, want %q", got, sdktranslator.FormatClaude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseFormatOrSourceFallsBackToSourceFormat(t *testing.T) {
|
||||
opts := Options{SourceFormat: sdktranslator.FormatGemini}
|
||||
|
||||
if got := ResponseFormatOrSource(opts); got != sdktranslator.FormatGemini {
|
||||
t.Fatalf("ResponseFormatOrSource() = %q, want %q", got, sdktranslator.FormatGemini)
|
||||
}
|
||||
}
|
||||
29
backend/sdk/cliproxy/executor/websocket.go
Normal file
29
backend/sdk/cliproxy/executor/websocket.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// UpstreamWebsocketReplayRequiredError indicates that an incremental request
|
||||
// cannot safely continue because its upstream websocket is no longer reusable.
|
||||
type UpstreamWebsocketReplayRequiredError struct{}
|
||||
|
||||
func (*UpstreamWebsocketReplayRequiredError) Error() string {
|
||||
return `{"error":{"message":"upstream transport requires full HTTP replay","type":"server_error","code":"upstream_http_replay_required","status":426}}`
|
||||
}
|
||||
|
||||
func (*UpstreamWebsocketReplayRequiredError) StatusCode() int { return http.StatusUpgradeRequired }
|
||||
|
||||
func (*UpstreamWebsocketReplayRequiredError) IsRequestScoped() bool { return true }
|
||||
|
||||
// NewUpstreamWebsocketReplayRequiredError creates a request-scoped replay signal.
|
||||
func NewUpstreamWebsocketReplayRequiredError() error {
|
||||
return &UpstreamWebsocketReplayRequiredError{}
|
||||
}
|
||||
|
||||
// IsUpstreamWebsocketReplayRequired reports whether err is the internal replay signal.
|
||||
func IsUpstreamWebsocketReplayRequired(err error) bool {
|
||||
var replayErr *UpstreamWebsocketReplayRequiredError
|
||||
return errors.As(err, &replayErr)
|
||||
}
|
||||
25
backend/sdk/cliproxy/executor/websocket_test.go
Normal file
25
backend/sdk/cliproxy/executor/websocket_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpstreamWebsocketReplayRequiredError(t *testing.T) {
|
||||
err := NewUpstreamWebsocketReplayRequiredError()
|
||||
if !IsUpstreamWebsocketReplayRequired(err) {
|
||||
t.Fatal("replay error was not recognized")
|
||||
}
|
||||
if !IsUpstreamWebsocketReplayRequired(fmt.Errorf("wrapped: %w", err)) {
|
||||
t.Fatal("wrapped replay error was not recognized")
|
||||
}
|
||||
statusErr, ok := err.(interface{ StatusCode() int })
|
||||
if !ok || statusErr.StatusCode() != http.StatusUpgradeRequired {
|
||||
t.Fatalf("replay error = %T %v, want status 426", err, err)
|
||||
}
|
||||
requestErr, ok := err.(RequestScopedError)
|
||||
if !ok || !requestErr.IsRequestScoped() {
|
||||
t.Fatalf("replay error = %T, want request scoped", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue