From e8e3bc86167ff2ab7daccc4e24d9a99dc8109fcf Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 10 Jan 2026 16:25:25 +0800 Subject: [PATCH] feat(executor): add HttpRequest support across executors for better http request handling --- examples/custom-provider/main.go | 26 +++- examples/http-request/main.go | 140 ++++++++++++++++++ .../runtime/executor/aistudio_executor.go | 59 ++++++++ .../runtime/executor/antigravity_executor.go | 33 ++++- internal/runtime/executor/claude_executor.go | 42 +++++- internal/runtime/executor/codex_executor.go | 33 ++++- .../runtime/executor/gemini_cli_executor.go | 38 ++++- internal/runtime/executor/gemini_executor.go | 34 ++++- .../executor/gemini_vertex_executor.go | 42 +++++- internal/runtime/executor/iflow_executor.go | 29 +++- .../executor/openai_compat_executor.go | 32 +++- internal/runtime/executor/qwen_executor.go | 28 +++- sdk/cliproxy/auth/conductor.go | 101 ++++++++++++- 13 files changed, 617 insertions(+), 20 deletions(-) create mode 100644 examples/http-request/main.go diff --git a/examples/custom-provider/main.go b/examples/custom-provider/main.go index 930afdcf..9dab183e 100644 --- a/examples/custom-provider/main.go +++ b/examples/custom-provider/main.go @@ -14,6 +14,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/url" @@ -122,7 +123,9 @@ func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Re httpReq.Header.Set("Content-Type", "application/json") // Inject credentials via PrepareRequest hook. - _ = (MyExecutor{}).PrepareRequest(httpReq, a) + if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil { + return clipexec.Response{}, errPrep + } resp, errDo := client.Do(httpReq) if errDo != nil { @@ -130,13 +133,28 @@ func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Re } defer func() { if errClose := resp.Body.Close(); errClose != nil { - // Best-effort close; log if needed in real projects. + fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose) } }() body, _ := io.ReadAll(resp.Body) return clipexec.Response{Payload: body}, nil } +func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("myprov executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil { + return nil, errPrep + } + client := buildHTTPClient(a) + return client.Do(httpReq) +} + func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) { return clipexec.Response{}, errors.New("count tokens not implemented") } @@ -199,8 +217,8 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(err) + if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) { + panic(errRun) } _ = os.Stderr // keep os import used (demo only) _ = time.Second diff --git a/examples/http-request/main.go b/examples/http-request/main.go new file mode 100644 index 00000000..4daee547 --- /dev/null +++ b/examples/http-request/main.go @@ -0,0 +1,140 @@ +// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest +// to execute arbitrary HTTP requests with provider credentials injected. +// +// This example registers a minimal custom executor that injects an Authorization +// header from auth.Attributes["api_key"], then performs two requests against +// httpbin.org to show the injected headers. +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + log "github.com/sirupsen/logrus" +) + +const providerKey = "echo" + +// EchoExecutor is a minimal provider implementation for demonstration purposes. +type EchoExecutor struct{} + +func (EchoExecutor) Identifier() string { return providerKey } + +func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error { + if req == nil || auth == nil { + return nil + } + if auth.Attributes != nil { + if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + } + return nil +} + +func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("echo executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil { + return nil, errPrep + } + return http.DefaultClient.Do(httpReq) +} + +func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) { + return clipexec.Response{}, errors.New("echo executor: Execute not implemented") +} + +func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (<-chan clipexec.StreamChunk, error) { + return nil, errors.New("echo executor: ExecuteStream not implemented") +} + +func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) { + return nil, errors.New("echo executor: Refresh not implemented") +} + +func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) { + return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented") +} + +func main() { + log.SetLevel(log.InfoLevel) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + core := coreauth.NewManager(nil, nil, nil) + core.RegisterExecutor(EchoExecutor{}) + + auth := &coreauth.Auth{ + ID: "demo-echo", + Provider: providerKey, + Attributes: map[string]string{ + "api_key": "demo-api-key", + }, + } + + // Example 1: Build a prepared request and execute it using your own http.Client. + reqPrepared, errReqPrepared := core.NewHttpRequest( + ctx, + auth, + http.MethodGet, + "https://httpbin.org/anything", + nil, + http.Header{"X-Example": []string{"prepared"}}, + ) + if errReqPrepared != nil { + panic(errReqPrepared) + } + respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared) + if errDoPrepared != nil { + panic(errDoPrepared) + } + defer func() { + if errClose := respPrepared.Body.Close(); errClose != nil { + log.Errorf("close response body error: %v", errClose) + } + }() + bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body) + if errReadPrepared != nil { + panic(errReadPrepared) + } + fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared) + + // Example 2: Execute a raw request via core.HttpRequest (auto inject + do). + rawBody := []byte(`{"hello":"world"}`) + rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody)) + if errRawReq != nil { + panic(errRawReq) + } + rawReq.Header.Set("Content-Type", "application/json") + rawReq.Header.Set("X-Example", "executed") + + respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq) + if errDoExec != nil { + panic(errDoExec) + } + defer func() { + if errClose := respExec.Body.Close(); errClose != nil { + log.Errorf("close response body error: %v", errClose) + } + }() + bodyExec, errReadExec := io.ReadAll(respExec.Body) + if errReadExec != nil { + panic(errReadExec) + } + fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec) +} diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index ba8d8058..c3e3edb0 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" @@ -50,6 +51,64 @@ func (e *AIStudioExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) return nil } +// HttpRequest forwards an arbitrary HTTP request through the websocket relay. +func (e *AIStudioExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("aistudio executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + if e.relay == nil { + return nil, fmt.Errorf("aistudio executor: ws relay is nil") + } + if auth == nil || auth.ID == "" { + return nil, fmt.Errorf("aistudio executor: missing auth") + } + httpReq := req.WithContext(ctx) + if httpReq.URL == nil || strings.TrimSpace(httpReq.URL.String()) == "" { + return nil, fmt.Errorf("aistudio executor: request URL is empty") + } + + var body []byte + if httpReq.Body != nil { + b, errRead := io.ReadAll(httpReq.Body) + if errRead != nil { + return nil, errRead + } + body = b + httpReq.Body = io.NopCloser(bytes.NewReader(b)) + } + + wsReq := &wsrelay.HTTPRequest{ + Method: httpReq.Method, + URL: httpReq.URL.String(), + Headers: httpReq.Header.Clone(), + Body: body, + } + wsResp, errRelay := e.relay.NonStream(ctx, auth.ID, wsReq) + if errRelay != nil { + return nil, errRelay + } + if wsResp == nil { + return nil, fmt.Errorf("aistudio executor: ws response is nil") + } + + statusText := http.StatusText(wsResp.Status) + if statusText == "" { + statusText = "Unknown" + } + resp := &http.Response{ + StatusCode: wsResp.Status, + Status: fmt.Sprintf("%d %s", wsResp.Status, statusText), + Header: wsResp.Headers.Clone(), + Body: io.NopCloser(bytes.NewReader(wsResp.Body)), + ContentLength: int64(len(wsResp.Body)), + Request: httpReq, + } + return resp, nil +} + // Execute performs a non-streaming request to the AI Studio API. func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 47b2ac48..8d1ef23d 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -73,8 +73,37 @@ func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor { // Identifier returns the executor identifier. func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType } -// PrepareRequest prepares the HTTP request for execution (no-op for Antigravity). -func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Antigravity credentials into the outgoing HTTP request. +func (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + token, _, errToken := e.ensureAccessToken(req.Context(), auth) + if errToken != nil { + return errToken + } + if strings.TrimSpace(token) == "" { + return statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + +// HttpRequest injects Antigravity credentials into the request and executes it. +func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("antigravity executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index d426326f..4242a244 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -41,7 +41,47 @@ func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecu func (e *ClaudeExecutor) Identifier() string { return "claude" } -func (e *ClaudeExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Claude credentials into the outgoing HTTP request. +func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + apiKey, _ := claudeCreds(auth) + if strings.TrimSpace(apiKey) == "" { + return nil + } + useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" + isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com") + if isAnthropicBase && useAPIKey { + req.Header.Del("Authorization") + req.Header.Set("x-api-key", apiKey) + } else { + req.Header.Del("x-api-key") + req.Header.Set("Authorization", "Bearer "+apiKey) + } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) + return nil +} + +// HttpRequest injects Claude credentials into the request and executes it. +func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("claude executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { apiKey, baseURL := claudeCreds(auth) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 8e7c8df9..f791fd6b 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -38,7 +38,38 @@ func NewCodexExecutor(cfg *config.Config) *CodexExecutor { return &CodexExecutor func (e *CodexExecutor) Identifier() string { return "codex" } -func (e *CodexExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Codex credentials into the outgoing HTTP request. +func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + apiKey, _ := codexCreds(auth) + if strings.TrimSpace(apiKey) != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) + return nil +} + +// HttpRequest injects Codex credentials into the request and executes it. +func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("codex executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { apiKey, baseURL := codexCreds(auth) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index e4bb7340..20b93a92 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -63,8 +63,42 @@ func NewGeminiCLIExecutor(cfg *config.Config) *GeminiCLIExecutor { // Identifier returns the executor identifier. func (e *GeminiCLIExecutor) Identifier() string { return "gemini-cli" } -// PrepareRequest prepares the HTTP request for execution (no-op for Gemini CLI). -func (e *GeminiCLIExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Gemini CLI credentials into the outgoing HTTP request. +func (e *GeminiCLIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + tokenSource, _, errSource := prepareGeminiCLITokenSource(req.Context(), e.cfg, auth) + if errSource != nil { + return errSource + } + tok, errTok := tokenSource.Token() + if errTok != nil { + return errTok + } + if strings.TrimSpace(tok.AccessToken) == "" { + return statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + applyGeminiCLIHeaders(req) + return nil +} + +// HttpRequest injects Gemini CLI credentials into the request and executes it. +func (e *GeminiCLIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("gemini-cli executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} // Execute performs a non-streaming request to the Gemini CLI API. func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 192f42e2..a913a5c0 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -55,8 +55,38 @@ func NewGeminiExecutor(cfg *config.Config) *GeminiExecutor { // Identifier returns the executor identifier. func (e *GeminiExecutor) Identifier() string { return "gemini" } -// PrepareRequest prepares the HTTP request for execution (no-op for Gemini). -func (e *GeminiExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Gemini credentials into the outgoing HTTP request. +func (e *GeminiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + apiKey, bearer := geminiCreds(auth) + if apiKey != "" { + req.Header.Set("x-goog-api-key", apiKey) + req.Header.Del("Authorization") + } else if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Del("x-goog-api-key") + } + applyGeminiHeaders(req, auth) + return nil +} + +// HttpRequest injects Gemini credentials into the request and executes it. +func (e *GeminiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("gemini executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} // Execute performs a non-streaming request to the Gemini API. // It translates the request to Gemini format, sends it to the API, and translates diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index bcf4473c..eebf6b1b 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -50,11 +50,49 @@ func NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor { // Identifier returns the executor identifier. func (e *GeminiVertexExecutor) Identifier() string { return "vertex" } -// PrepareRequest prepares the HTTP request for execution (no-op for Vertex). -func (e *GeminiVertexExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { +// PrepareRequest injects Vertex credentials into the outgoing HTTP request. +func (e *GeminiVertexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + apiKey, _ := vertexAPICreds(auth) + if strings.TrimSpace(apiKey) != "" { + req.Header.Set("x-goog-api-key", apiKey) + req.Header.Del("Authorization") + return nil + } + _, _, saJSON, errCreds := vertexCreds(auth) + if errCreds != nil { + return errCreds + } + token, errToken := vertexAccessToken(req.Context(), e.cfg, auth, saJSON) + if errToken != nil { + return errToken + } + if strings.TrimSpace(token) == "" { + return statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Del("x-goog-api-key") return nil } +// HttpRequest injects Vertex credentials into the request and executes it. +func (e *GeminiVertexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("vertex executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} + // Execute performs a non-streaming request to the Vertex AI API. func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { // Try API key authentication first diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index e1b0394e..c8b7706c 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -37,8 +37,33 @@ func NewIFlowExecutor(cfg *config.Config) *IFlowExecutor { return &IFlowExecutor // Identifier returns the provider key. func (e *IFlowExecutor) Identifier() string { return "iflow" } -// PrepareRequest implements ProviderExecutor but requires no preprocessing. -func (e *IFlowExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects iFlow credentials into the outgoing HTTP request. +func (e *IFlowExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + apiKey, _ := iflowCreds(auth) + if strings.TrimSpace(apiKey) != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + return nil +} + +// HttpRequest injects iFlow credentials into the request and executes it. +func (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("iflow executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} // Execute performs a non-streaming chat completion request. func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 78b787dd..04dbf23f 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -35,11 +35,39 @@ func NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatE // Identifier implements cliproxyauth.ProviderExecutor. func (e *OpenAICompatExecutor) Identifier() string { return e.provider } -// PrepareRequest is a no-op for now (credentials are added via headers at execution time). -func (e *OpenAICompatExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { +// PrepareRequest injects OpenAI-compatible credentials into the outgoing HTTP request. +func (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + _, apiKey := e.resolveCredentials(auth) + if strings.TrimSpace(apiKey) != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) return nil } +// HttpRequest injects OpenAI-compatible credentials into the request and executes it. +func (e *OpenAICompatExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("openai compat executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} + func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) defer reporter.trackFailure(ctx, &err) diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index be6c1024..ee014fc7 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -36,7 +36,33 @@ func NewQwenExecutor(cfg *config.Config) *QwenExecutor { return &QwenExecutor{cf func (e *QwenExecutor) Identifier() string { return "qwen" } -func (e *QwenExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// PrepareRequest injects Qwen credentials into the outgoing HTTP request. +func (e *QwenExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + token, _ := qwenCreds(auth) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return nil +} + +// HttpRequest injects Qwen credentials into the request and executes it. +func (e *QwenExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("qwen executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { token, baseURL := qwenCreds(auth) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 689d3a21..431e2259 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1,9 +1,11 @@ package auth import ( + "bytes" "context" "encoding/json" "errors" + "io" "net/http" "path/filepath" "strconv" @@ -32,6 +34,9 @@ type ProviderExecutor interface { Refresh(ctx context.Context, auth *Auth) (*Auth, error) // CountTokens returns the token count for the given request. CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) + // HttpRequest injects provider credentials into the supplied HTTP request and executes it. + // Callers must close the response body when non-nil. + HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) } // RefreshEvaluator allows runtime state to override refresh decisions. @@ -1572,6 +1577,23 @@ type RequestPreparer interface { PrepareRequest(req *http.Request, auth *Auth) error } +func executorKeyFromAuth(auth *Auth) string { + if auth == nil { + return "" + } + if auth.Attributes != nil { + providerKey := strings.TrimSpace(auth.Attributes["provider_key"]) + compatName := strings.TrimSpace(auth.Attributes["compat_name"]) + if compatName != "" { + if providerKey == "" { + providerKey = compatName + } + return strings.ToLower(providerKey) + } + } + return strings.ToLower(strings.TrimSpace(auth.Provider)) +} + // logEntryWithRequestID returns a logrus entry with request_id field if available in context. func logEntryWithRequestID(ctx context.Context) *log.Entry { if ctx == nil { @@ -1647,7 +1669,7 @@ func (m *Manager) InjectCredentials(req *http.Request, authID string) error { a := m.auths[authID] var exec ProviderExecutor if a != nil { - exec = m.executors[a.Provider] + exec = m.executors[executorKeyFromAuth(a)] } m.mu.RUnlock() if a == nil || exec == nil { @@ -1658,3 +1680,80 @@ func (m *Manager) InjectCredentials(req *http.Request, authID string) error { } return nil } + +// PrepareHttpRequest injects provider credentials into the supplied HTTP request. +func (m *Manager) PrepareHttpRequest(ctx context.Context, auth *Auth, req *http.Request) error { + if m == nil { + return &Error{Code: "provider_not_found", Message: "manager is nil"} + } + if auth == nil { + return &Error{Code: "auth_not_found", Message: "auth is nil"} + } + if req == nil { + return &Error{Code: "invalid_request", Message: "http request is nil"} + } + if ctx != nil { + *req = *req.WithContext(ctx) + } + providerKey := executorKeyFromAuth(auth) + if providerKey == "" { + return &Error{Code: "provider_not_found", Message: "auth provider is empty"} + } + exec := m.executorFor(providerKey) + if exec == nil { + return &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey} + } + preparer, ok := exec.(RequestPreparer) + if !ok || preparer == nil { + return &Error{Code: "not_supported", Message: "executor does not support http request preparation"} + } + return preparer.PrepareRequest(req, auth) +} + +// NewHttpRequest constructs a new HTTP request and injects provider credentials into it. +func (m *Manager) NewHttpRequest(ctx context.Context, auth *Auth, method, targetURL string, body []byte, headers http.Header) (*http.Request, error) { + if ctx == nil { + ctx = context.Background() + } + method = strings.TrimSpace(method) + if method == "" { + method = http.MethodGet + } + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, targetURL, reader) + if err != nil { + return nil, err + } + if headers != nil { + httpReq.Header = headers.Clone() + } + if errPrepare := m.PrepareHttpRequest(ctx, auth, httpReq); errPrepare != nil { + return nil, errPrepare + } + return httpReq, nil +} + +// HttpRequest injects provider credentials into the supplied HTTP request and executes it. +func (m *Manager) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) { + if m == nil { + return nil, &Error{Code: "provider_not_found", Message: "manager is nil"} + } + if auth == nil { + return nil, &Error{Code: "auth_not_found", Message: "auth is nil"} + } + if req == nil { + return nil, &Error{Code: "invalid_request", Message: "http request is nil"} + } + providerKey := executorKeyFromAuth(auth) + if providerKey == "" { + return nil, &Error{Code: "provider_not_found", Message: "auth provider is empty"} + } + exec := m.executorFor(providerKey) + if exec == nil { + return nil, &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey} + } + return exec.HttpRequest(ctx, auth, req) +}