354 lines · 11.9 KB · Go
RawDownload
package executor import (	"bufio"	"bytes"	"context"	"fmt"	"io"	"net/http"	"time" 	"github.com/google/uuid"	copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"	"github.com/router-for-me/CLIProxyAPI/v6/internal/config"	cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"	cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"	sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"	log "github.com/sirupsen/logrus"	"github.com/tidwall/sjson") const (	githubCopilotBaseURL       = "https://api.githubcopilot.com"	githubCopilotChatPath      = "/chat/completions"	githubCopilotAuthType      = "github-copilot"	githubCopilotTokenCacheTTL = 25 * time.Minute) // GitHubCopilotExecutor handles requests to the GitHub Copilot API.type GitHubCopilotExecutor struct {	cfg *config.Config} // NewGitHubCopilotExecutor constructs a new executor instance.func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {	return &GitHubCopilotExecutor{cfg: cfg}} // Identifier implements ProviderExecutor.func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType } // PrepareRequest implements ProviderExecutor.func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {	return nil} // Execute handles non-streaming requests to GitHub Copilot.func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {	apiToken, errToken := e.ensureAPIToken(ctx, auth)	if errToken != nil {		return resp, errToken	} 	reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)	defer reporter.trackFailure(ctx, &err) 	from := opts.SourceFormat	to := sdktranslator.FromString("openai")	body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)	body = e.normalizeModel(req.Model, body)	body = applyPayloadConfig(e.cfg, req.Model, body)	body, _ = sjson.SetBytes(body, "stream", false) 	url := githubCopilotBaseURL + githubCopilotChatPath	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))	if err != nil {		return resp, err	}	e.applyHeaders(httpReq, apiToken) 	var authID, authLabel, authType, authValue string	if auth != nil {		authID = auth.ID		authLabel = auth.Label		authType, authValue = auth.AccountInfo()	}	recordAPIRequest(ctx, e.cfg, upstreamRequestLog{		URL:       url,		Method:    http.MethodPost,		Headers:   httpReq.Header.Clone(),		Body:      body,		Provider:  e.Identifier(),		AuthID:    authID,		AuthLabel: authLabel,		AuthType:  authType,		AuthValue: authValue,	}) 	httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)	httpResp, err := httpClient.Do(httpReq)	if err != nil {		recordAPIResponseError(ctx, e.cfg, err)		return resp, err	}	defer func() {		if errClose := httpResp.Body.Close(); errClose != nil {			log.Errorf("github-copilot executor: close response body error: %v", errClose)		}	}() 	recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) 	if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {		data, _ := io.ReadAll(httpResp.Body)		appendAPIResponseChunk(ctx, e.cfg, data)		log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))		err = statusErr{code: httpResp.StatusCode, msg: string(data)}		return resp, err	} 	data, err := io.ReadAll(httpResp.Body)	if err != nil {		recordAPIResponseError(ctx, e.cfg, err)		return resp, err	}	appendAPIResponseChunk(ctx, e.cfg, data) 	detail := parseOpenAIUsage(data)	if detail.TotalTokens > 0 {		reporter.publish(ctx, detail)	} 	var param any	converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)	resp = cliproxyexecutor.Response{Payload: []byte(converted)}	reporter.ensurePublished(ctx)	return resp, nil} // ExecuteStream handles streaming requests to GitHub Copilot.func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {	apiToken, errToken := e.ensureAPIToken(ctx, auth)	if errToken != nil {		return nil, errToken	} 	reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)	defer reporter.trackFailure(ctx, &err) 	from := opts.SourceFormat	to := sdktranslator.FromString("openai")	body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)	body = e.normalizeModel(req.Model, body)	body = applyPayloadConfig(e.cfg, req.Model, body)	body, _ = sjson.SetBytes(body, "stream", true)	// Enable stream options for usage stats in stream	body, _ = sjson.SetBytes(body, "stream_options.include_usage", true) 	url := githubCopilotBaseURL + githubCopilotChatPath	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))	if err != nil {		return nil, err	}	e.applyHeaders(httpReq, apiToken) 	var authID, authLabel, authType, authValue string	if auth != nil {		authID = auth.ID		authLabel = auth.Label		authType, authValue = auth.AccountInfo()	}	recordAPIRequest(ctx, e.cfg, upstreamRequestLog{		URL:       url,		Method:    http.MethodPost,		Headers:   httpReq.Header.Clone(),		Body:      body,		Provider:  e.Identifier(),		AuthID:    authID,		AuthLabel: authLabel,		AuthType:  authType,		AuthValue: authValue,	}) 	httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)	httpResp, err := httpClient.Do(httpReq)	if err != nil {		recordAPIResponseError(ctx, e.cfg, err)		return nil, err	} 	recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) 	if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {		data, readErr := io.ReadAll(httpResp.Body)		if errClose := httpResp.Body.Close(); errClose != nil {			log.Errorf("github-copilot executor: close response body error: %v", errClose)		}		if readErr != nil {			recordAPIResponseError(ctx, e.cfg, readErr)			return nil, readErr		}		appendAPIResponseChunk(ctx, e.cfg, data)		log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))		err = statusErr{code: httpResp.StatusCode, msg: string(data)}		return nil, err	} 	out := make(chan cliproxyexecutor.StreamChunk)	stream = out 	go func() {		defer close(out)		defer func() {			if errClose := httpResp.Body.Close(); errClose != nil {				log.Errorf("github-copilot executor: close response body error: %v", errClose)			}		}() 		scanner := bufio.NewScanner(httpResp.Body)		scanner.Buffer(nil, 20_971_520)		var param any 		for scanner.Scan() {			line := scanner.Bytes()			appendAPIResponseChunk(ctx, e.cfg, line) 			// Parse SSE data			if bytes.HasPrefix(line, dataTag) {				data := bytes.TrimSpace(line[5:])				if bytes.Equal(data, []byte("[DONE]")) {					continue				}				if detail, ok := parseOpenAIStreamUsage(line); ok {					reporter.publish(ctx, detail)				}			} 			chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)			for i := range chunks {				out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}			}		} 		if errScan := scanner.Err(); errScan != nil {			recordAPIResponseError(ctx, e.cfg, errScan)			reporter.publishFailure(ctx)			out <- cliproxyexecutor.StreamChunk{Err: errScan}		} else {			reporter.ensurePublished(ctx)		}	}() 	return stream, nil} // CountTokens is not supported for GitHub Copilot.func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {	return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"}} // Refresh validates the GitHub token is still working.// GitHub OAuth tokens don't expire traditionally, so we just validate.func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {	if auth == nil {		return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}	} 	// Get the GitHub access token	accessToken := metaStringValue(auth.Metadata, "access_token")	if accessToken == "" {		return auth, nil	} 	// Validate the token can still get a Copilot API token	copilotAuth := copilotauth.NewCopilotAuth(e.cfg)	_, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)	if err != nil {		return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)}	} 	return auth, nil} // ensureAPIToken gets or refreshes the Copilot API token.func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {	if auth == nil {		return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}	} 	// Check for cached API token	if cachedToken := metaStringValue(auth.Metadata, "copilot_api_token"); cachedToken != "" {		if expiresAt := tokenExpiry(auth.Metadata); expiresAt.After(time.Now().Add(5 * time.Minute)) {			return cachedToken, nil		}	} 	// Get the GitHub access token	accessToken := metaStringValue(auth.Metadata, "access_token")	if accessToken == "" {		return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}	} 	// Get a new Copilot API token	copilotAuth := copilotauth.NewCopilotAuth(e.cfg)	apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)	if err != nil {		return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)}	} 	// Cache the token in metadata (will be persisted on next save)	if auth.Metadata == nil {		auth.Metadata = make(map[string]any)	}	auth.Metadata["copilot_api_token"] = apiToken.Token	if apiToken.ExpiresAt > 0 {		auth.Metadata["expired"] = time.Unix(apiToken.ExpiresAt, 0).Format(time.RFC3339)	} else {		auth.Metadata["expired"] = time.Now().Add(githubCopilotTokenCacheTTL).Format(time.RFC3339)	} 	return apiToken.Token, nil} // applyHeaders sets the required headers for GitHub Copilot API requests.func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {	r.Header.Set("Content-Type", "application/json")	r.Header.Set("Authorization", "Bearer "+apiToken)	r.Header.Set("Accept", "application/json")	r.Header.Set("User-Agent", "GithubCopilot/1.0")	r.Header.Set("Editor-Version", "vscode/1.100.0")	r.Header.Set("Editor-Plugin-Version", "copilot/1.300.0")	r.Header.Set("Openai-Intent", "conversation-panel")	r.Header.Set("Copilot-Integration-Id", "vscode-chat")	r.Header.Set("X-Request-Id", uuid.NewString())} // normalizeModel ensures the model name is correct for the API.func (e *GitHubCopilotExecutor) normalizeModel(requestedModel string, body []byte) []byte {	// Map friendly names to API model names	modelMap := map[string]string{		"gpt-4.1":            "gpt-4.1",		"gpt-5":              "gpt-5",		"gpt-5-mini":         "gpt-5-mini",		"gpt-5-codex":        "gpt-5-codex",		"gpt-5.1":            "gpt-5.1",		"gpt-5.1-codex":      "gpt-5.1-codex",		"gpt-5.1-codex-mini": "gpt-5.1-codex-mini",		"claude-haiku-4.5":   "claude-haiku-4.5",		"claude-opus-4.1":    "claude-opus-4.1",		"claude-opus-4.5":    "claude-opus-4.5",		"claude-sonnet-4":    "claude-sonnet-4",		"claude-sonnet-4.5":  "claude-sonnet-4.5",		"gemini-2.5-pro":     "gemini-2.5-pro",		"gemini-3-pro":       "gemini-3-pro",		"grok-code-fast-1":   "grok-code-fast-1",		"raptor-mini":        "raptor-mini",	} 	if mapped, ok := modelMap[requestedModel]; ok {		body, _ = sjson.SetBytes(body, "model", mapped)	} 	return body}